Skip to content

Part 1 · The C Language32 min read

The C Standard Library on a Microcontroller

On a desktop, printf just works because an operating system sits underneath it. On an STM32 there is no operating system, no terminal and no file descriptors, yet printf still links and, with a few lines of glue, prints over a UART. This lesson explains how: what the C library is and where it comes from, the choices you have on a microcontroller, the system call layer you must supply, and what every convenient libc function costs in flash, RAM and determinism. It ends with a complete printf-style formatter small enough to use in any project.

arm-none-eabi-gcc is a compiler. It turns C into machine code. The functions declared in stdio.h, string.h and stdlib.h are implemented in a separate C library (libc), an ordinary collection of compiled object files that the linker pulls in as needed. On Linux that library is usually glibc. The GNU Arm Embedded toolchain ships with newlib, built for every Cortex-M variant and floating-point ABI.

Three layers are involved when you build firmware:

LayerProvided byContains
freestanding headersthe compilerstddef.h, stdint.h, stdbool.h, limits.h, float.h, stdarg.h, stdalign.h, stdnoreturn.h, iso646.h
compiler runtime (libgcc)the compilerhelpers the compiler calls on its own: 64-bit division (__aeabi_uldivmod), software double arithmetic (__aeabi_dadd), integer division on cores without a divide instruction
C library (libc, libm)newlib, picolibc, or a vendorprintf, memcpy, strtol, malloc, sinf, …

The freestanding headers are pure declarations and macros with no code behind them, so you can always use them, even in a bootloader linked with -nostdlib. Everything in the third row is optional, and on a microcontroller each piece has a price.

LibraryWhere you meet itCharacteristics
newlibdefault in the GNU Arm toolchaincomplete, mature, large; reentrancy through a per-thread struct _reent
newlib-nanosame toolchain, selected with --specs=nano.specsnewlib rebuilt for size: smaller printf without float by default, simpler malloc, smaller buffers
picolibcZephyr, some toolchains, many newer projectsdescended from newlib and AVR libc; small, thread-local storage instead of _reent, tiny stdio
vendor librariesIAR DLIB, Keil/Arm C library and microlibtied to their compilers; configurable size/feature trade-offs

For GCC-based firmware, newlib-nano is the usual starting point and picolibc is a good choice for new projects. This lesson uses newlib-nano because it is what the standard toolchain gives you.

The link flags that select it:

Terminal window
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 \
-T link.ld --specs=nano.specs --specs=nosys.specs \
main.o syscalls.o startup.o -o firmware.elf

A specs file is a small script that adjusts the compiler driver’s link command. nano.specs swaps libc.a for libc_nano.a (and the matching libg and libstdc++ variants). nosys.specs adds libnosys.a, a set of stub system calls that do nothing and return an error, so that the link succeeds even if you have not written your own. A third, rdimon.specs, routes system calls through the debugger with semihosting, covered in Debugging and tracing on real hardware.

newlib is written as if it ran on Unix. Underneath printf, malloc and fopen it calls a small set of low-level functions that, on an operating system, would be system calls. On bare metal you implement them.

printf("t=%d\n", t) your code
│
vfprintf: format into stdout's buffer newlib
│
fflush / buffer full / newline
│
_write_r(reent, 1, buf, len) newlib reentrant wrapper
│
_write(1, buf, len) YOU provide (syscalls.c)
│
uart_putc(c) ... USART2 DR your driver

The functions newlib may need:

FunctionCalled byMinimal bare-metal implementation
_write(fd, buf, len)printf, puts, fwritesend bytes to a UART, return len
_read(fd, buf, len)scanf, getchar, fgetsread from a UART, or return 0
_sbrk(incr)mallocmove the heap end, refuse when out of space
_close, _lseekstdio internalsreturn -1
_fstat, _isattystdio deciding how to bufferreport a character device / return 1
_exit(status)exit, abortloop forever or reset
_kill, _getpidabort via raisereturn -1 / return 1

With --specs=nosys.specs, every function you do not define comes from libnosys. Recent toolchains print a link-time warning such as _write is not implemented and will always fail when a nosys stub is actually used, which is a useful hint that you forgot to retarget something.

Here is the glue that makes printf print over USART2 on the Discovery board. The UART is assumed to be initialized already (clock enabled, PA2 set to AF7, baud rate programmed); UART: serial communication does that part.

syscalls.c
#include <errno.h>
#include <stddef.h>
#include <stdint.h>
#define USART2_BASE 0x40004400u
#define USART2_SR (*(volatile uint32_t *)(USART2_BASE + 0x00u))
#define USART2_DR (*(volatile uint32_t *)(USART2_BASE + 0x04u))
#define USART_SR_TXE (1u << 7) /* transmit data register empty */
static void uart_putc(char c)
{
while ((USART2_SR & USART_SR_TXE) == 0u) {
/* wait until DR can accept a byte */
}
USART2_DR = (uint8_t)c;
}
int _write(int fd, const char *buf, int len)
{
if (fd != 1 && fd != 2) { /* only stdout and stderr */
errno = EBADF;
return -1;
}
for (int i = 0; i < len; i++) {
if (buf[i] == '\n') {
uart_putc('\r'); /* terminals expect CR LF */
}
uart_putc(buf[i]);
}
return len;
}
/* Heap for malloc: from the end of .bss up to a limit set in the linker script. */
extern uint8_t _end[]; /* first byte after .bss */
extern uint8_t _heap_limit[]; /* top of the region reserved for the heap */
static uint8_t *s_brk = _end;
void *_sbrk(ptrdiff_t incr)
{
uintptr_t cur = (uintptr_t)s_brk;
uintptr_t limit = (uintptr_t)_heap_limit;
uintptr_t base = (uintptr_t)_end;
if ((incr > 0 && (uintptr_t)incr > limit - cur) ||
(incr < 0 && (uintptr_t)(-incr) > cur - base)) {
errno = ENOMEM;
return (void *)-1; /* malloc then returns NULL */
}
uint8_t *prev = s_brk;
s_brk += incr;
return prev;
}

The important lines:

  • USART_SR_TXE is bit 7 of USART2_SR. The loop waits until the data register is free, then writes the byte. This is a blocking write: at 115200 baud each byte takes about 87 µs, so a 60-character line stalls the CPU for over 5 ms. That is acceptable for bring-up and fatal inside an interrupt or a control loop.
  • _write returns the number of bytes consumed. Returning less makes newlib retry the rest; returning -1 makes it record an error on the stream.
  • The \n to \r\n translation happens here, because serial terminals move to a new line only on carriage return plus line feed.
  • _sbrk grows the heap from _end towards _heap_limit, both symbols you define in the linker script (Linker scripts shows how). Many example _sbrk implementations found online compare against the current stack pointer instead, or do not check at all, so malloc happily hands out memory that overlaps the stack. Use a fixed limit.
  • (void *)-1 is the traditional “failure” return value for sbrk. newlib’s malloc converts it to a NULL return.

One surprise when you look at the map file: GCC rewrites printf("hello\n") into puts("hello") and printf("%c", c) into putchar(c), because they do the same thing more cheaply. Both still go through stdio and end in _write, so retargeting still works, but do not be confused when puts appears in a program that never calls it.

printf is a small interpreter. It parses the format string at runtime and must contain code for every conversion it might be asked for, since the linker cannot know which ones your format strings use. That is why it is big.

Typical figures for Cortex-M4 at -Os are below. They vary with toolchain version and options, so treat them as orders of magnitude and measure your own (exercise 1).

VariantExtra flashStack during a callNotes
newlib printf20 to 30 KBaround 1 KB or morefull feature set, float included, plus struct _reent in RAM
newlib-nano printf5 to 8 KBa few hundred bytesno %f unless enabled
newlib-nano with -u _printf_floatabout 10 KB or more on topmorepulls in software double arithmetic
the formatter in this lessonunder 1 KBunder 150 bytes%d %i %u %x %X %c %s %%, width, flags

Floating-point support deserves its own warning. In newlib-nano the float formatting code is linked weakly, and %f silently prints nothing until you add -u _printf_float to the link flags. When you do, every float you pass to printf is promoted to double, because variadic arguments undergo default argument promotion. The Cortex-M4F’s FPU handles single precision only, so double formatting runs entirely in software helper routines from libgcc. A single printf("%f", temp) can therefore cost more flash than the rest of a small application. Types, sizes, and how numbers are stored explains the promotion, and -Wdouble-promotion catches it.

The alternatives, in rough order of how often they are used:

  1. snprintf into a buffer, then hand the buffer to a non-blocking UART or DMA driver. The formatting cost remains but the blocking does not.
  2. A small formatter like the one at the end of this lesson: a few hundred bytes, no heap, no _reent, reentrant, safe to call from any context that may block for the output.
  3. Fixed-point printing: print a temperature of 23.45 °C as %d.%02d from an integer in hundredths, avoiding float support entirely.
  4. Deferred or binary logging: store a format-string ID and the raw argument values in a ring buffer, and let a host tool do the formatting. This is the fastest and smallest option and is covered in Debugging and tracing on real hardware.

These are small, well optimized in newlib, and safe as long as you respect their contracts:

FunctionUseContract to remember
memcpy, memmove, memset, memcmpraw memorycovered in Memory: where everything lives; no overlap for memcpy
strlenlength of a NUL-terminated stringwalks until NUL: undefined on unterminated buffers
strnlenlength with an upper boundPOSIX rather than ISO C, but available in newlib and picolibc
strcmp, strncmpcompare stringsreturn the sign of the first difference, not -1/0/1
strchr, strrchrfind a characterreturn NULL if absent
strstrfind a substringO(n·m) worst case; fine for short command lines
strspn, strcspnspan of accepted/rejected charactershandy for tokenizing without strtok

The traps in strncpy, strtok and strcat are covered in Arrays, strings, and tables.

The Cortex-M4F has a single-precision FPU. To use it, call the float versions of the math functions, which end in f, and write float literals with an f suffix:

#include <math.h>
float vector_length(float x, float y)
{
return sqrtf(x * x + y * y); /* not sqrt(): that is double */
}

sqrtf maps directly to the hardware instruction vsqrt.f32. Because the C standard says sqrtf of a negative number must set errno, GCC emits the instruction plus a check that calls the library function for negative inputs. Compile with -fno-math-errno and the result is a single instruction. fabsf becomes vabs.f32. Transcendental functions such as sinf, expf and atan2f are software routines, usually a few hundred cycles each using the FPU for the arithmetic.

Call sin instead of sinf, or write 0.5 instead of 0.5f, and the computation silently moves to software double, often ten times slower. Link with -lm (the specs files do not add it, though many project Makefiles already do). And note that M_PI is not standard C; define your own constant with an f suffix.

strtol and strtoul are the correct way to parse numbers, unlike atoi, which cannot report errors. Getting the error checking right takes more care than most code shows:

#include <errno.h>
#include <limits.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
bool parse_i32(const char *s, int32_t *out)
{
char *end;
errno = 0;
long v = strtol(s, &end, 0); /* base 0: accepts 42, 0x2A, 052 */
if (end == s || *end != '\0') {
return false; /* no digits, or trailing junk */
}
if (errno == ERANGE) {
return false; /* out of range for long */
}
#if LONG_MAX > INT32_MAX
if (v < INT32_MIN || v > INT32_MAX) {
return false; /* fits long but not int32_t (64-bit hosts) */
}
#endif
*out = (int32_t)v;
return true;
}

end tells you where parsing stopped, which distinguishes “0” from “no number at all”. errno must be cleared first because strtol only sets it on error. The #if keeps the range check for hosts where long is 64 bits and removes it on Arm, where long is 32 bits and the comparison would always be false. Arrays, strings, and tables shows a hand-written parse_u32 for cases where you want no libc dependency at all.

qsort and bsearch sort and search arrays through a comparison callback:

#include <stdint.h>
#include <stdlib.h>
static int cmp_i32(const void *a, const void *b)
{
int32_t x = *(const int32_t *)a;
int32_t y = *(const int32_t *)b;
return (x > y) - (x < y); /* never "return x - y": it can overflow */
}
void sort_samples(int32_t *samples, size_t n)
{
qsort(samples, n, sizeof samples[0], cmp_i32);
}

return x - y overflows for values of opposite sign near the limits, and signed overflow is undefined. The (x > y) - (x < y) idiom returns -1, 0 or 1 safely. Two firmware cautions: qsort is often implemented recursively, so its stack use depends on the data, and an indirect function call per comparison makes it slow for small arrays. For a median of five ADC readings, an insertion sort is smaller and faster.

abs has its own trap: abs(INT_MIN) overflows, because the positive value does not fit. rand, exit, atexit and system are covered in the “dangerous” section below.

The standard assert(expr) macro, when expr is false and NDEBUG is not defined, calls a library function to print a message and then abort(). In newlib that function is __assert_func(file, line, func, expr), which calls fiprintf on stderr. One innocent assert therefore drags all of stdio into your image and prints to a UART that may not be initialized.

Two fixes are common. The first is to keep assert but supply your own __assert_func, which newlib documents as the hook:

#include <stdint.h>
#define SCB_AIRCR (*(volatile uint32_t *)0xE000ED0Cu)
typedef struct {
const char *file;
int line;
} assert_record_t;
/* Should live in a .noinit section so it survives the reset; see the linker script lesson. */
volatile assert_record_t g_last_assert;
_Noreturn void __assert_func(const char *file, int line, const char *func, const char *expr)
{
(void)func;
(void)expr;
__asm volatile ("cpsid i" ::: "memory"); /* stop everything else */
g_last_assert.file = file;
g_last_assert.line = line;
SCB_AIRCR = (0x05FAu << 16) | (1u << 2); /* VECTKEY + SYSRESETREQ */
for (;;) {
}
}

Writing 0x05FA into the VECTKEY field of AIRCR together with the SYSRESETREQ bit (bit 2) requests a system reset. The for (;;) covers the few cycles before the reset takes effect and satisfies _Noreturn. After the reset, the application can report the recorded file and line.

The second fix is your own macro, which gives you full control over cost:

#ifndef NDEBUG
#define ASSERT(expr) ((expr) ? (void)0 : assert_failed(__LINE__))
#else
#define ASSERT(expr) ((void)0)
#endif
_Noreturn void assert_failed(int line);

Passing only __LINE__, not __FILE__ and the expression text, saves the flash those strings occupy, which adds up over hundreds of asserts. Whether production firmware should keep asserts enabled, and what they should do, is a policy question discussed in Watchdogs, faults, and recovering from them. Compile-time checks with _Static_assert are free and should be used everywhere they apply.

errno is not a simple global in newlib. It is a macro that expands to something like (*__errno()), a field inside the current struct _reent. In a single-threaded bare-metal program there is one _reent and errno behaves like a global. Under an RTOS, each task needs its own _reent, or one task’s strtol error can appear in another task’s errno; FreeRTOS supports this with configUSE_NEWLIB_REENTRANT.

Library functions set errno (strtol, the math functions unless -fno-math-errno, malloc via _sbrk) but never clear it. Read it only immediately after a call that documents setting it, and clear it beforehand. Do not use errno in your own APIs: return an error code that the caller cannot accidentally ignore.

isdigit, isalpha, isspace, toupper and friends classify characters, usually through a lookup table. Their argument must be EOF or a value representable as unsigned char. Passing a plain char that holds a negative value is undefined behavior, typically an out-of-bounds table read. On Arm plain char is unsigned, so the bug hides there and appears when the same code runs on x86. Write isdigit((unsigned char)c) always.

For parsing protocol input, a direct range check is clearer and locale-independent: c >= '0' && c <= '9' is guaranteed to work because the standard requires the digits to be contiguous. Letters carry no such guarantee in theory, but every character set you will meet on a microcontroller is ASCII.

FeatureSituation on bare metal
malloc, freework through _sbrk, with every problem from Memory: where everything lives; some libc functions call malloc internally (stdio buffers, strdup)
time, clock, gettimeofdayneed _gettimeofday/_times syscalls you must implement from an RTC or SysTick; return garbage or -1 otherwise
fopen, fread, removeno filesystem; the stubs fail. Use a real filesystem library if you need one
randdeterministic, same sequence after every reset unless seeded; statistically adequate for jitter, never for anything security related
exit, atexitfirmware has nowhere to exit to; pulls in cleanup code. main should never return
strtok, asctime, randkeep hidden static state: not reentrant, unsafe from ISRs and across tasks
printf family from an ISRnot reentrant, may call malloc, blocks for output: never
setjmp/longjmp, signal, locales, wide charactersavailable in part, rarely appropriate, often large

The general rule: anything that touches hidden global state, the heap, or an operating system concept is suspect. Pure functions over caller-provided memory (memcpy, strlen, strtol, qsort) are the safe core.

Here is a complete formatter you can drop into any project. It supports %d, %i, %u, %x, %X, %c, %s and %%, the 0 and - flags, a field width, and the l length modifier. It has no heap, no static state and no dependence on libc. Output goes through a callback, so the same core can write to a UART, a buffer or a log ring.

mini_printf.h
#ifndef MINI_PRINTF_H
#define MINI_PRINTF_H
#include <stdarg.h>
#include <stddef.h>
#if defined(__GNUC__)
#define MINI_PRINTF_FMT(fmt_idx, args_idx) \
__attribute__((format(printf, fmt_idx, args_idx)))
#else
#define MINI_PRINTF_FMT(fmt_idx, args_idx)
#endif
typedef void (*mini_putc_fn)(char c, void *ctx);
/* Formats fmt with ap, sending each character to out(c, ctx).
* Returns the number of characters produced. */
int mini_vformat(mini_putc_fn out, void *ctx, const char *fmt, va_list ap);
/* Like snprintf: always NUL-terminates when size > 0, returns the length
* the full output would have had. */
int mini_snprintf(char *buf, size_t size, const char *fmt, ...) MINI_PRINTF_FMT(3, 4);
#endif

The format(printf, 3, 4) attribute tells GCC and Clang that argument 3 is a printf-style format string and the variadic arguments start at 4. You get the same -Wformat checking as for the real printf: passing a uint32_t to %s is now a compile-time warning. The macro hides it from compilers that do not understand GCC attributes.

mini_printf.c
#include "mini_printf.h"
#include <stdbool.h>
typedef struct {
mini_putc_fn out;
void *ctx;
int count;
} sink_t;
static void emit(sink_t *s, char c)
{
s->out(c, s->ctx);
s->count++;
}
static void emit_repeat(sink_t *s, char c, int n)
{
while (n-- > 0) {
emit(s, c);
}
}
static void emit_number(sink_t *s, unsigned long value, unsigned base, bool upper,
bool negative, int width, bool zero_pad, bool left)
{
static const char lower_digits[] = "0123456789abcdef";
static const char upper_digits[] = "0123456789ABCDEF";
const char *set = upper ? upper_digits : lower_digits;
char digits[24]; /* 64-bit unsigned long needs 20 */
int n = 0;
do {
digits[n++] = set[value % base];
value /= base;
} while (value != 0u);
int len = n + (negative ? 1 : 0);
int pad = (width > len) ? width - len : 0;
if (!left && !zero_pad) {
emit_repeat(s, ' ', pad); /* " -42" */
}
if (negative) {
emit(s, '-');
}
if (!left && zero_pad) {
emit_repeat(s, '0', pad); /* "-00042": sign before zeros */
}
while (n > 0) {
emit(s, digits[--n]); /* digits were produced in reverse */
}
if (left) {
emit_repeat(s, ' ', pad); /* "-42 " */
}
}
static void emit_string(sink_t *s, const char *str, int width, bool left)
{
if (str == NULL) {
str = "(null)";
}
int len = 0;
while (str[len] != '\0') {
len++;
}
int pad = (width > len) ? width - len : 0;
if (!left) {
emit_repeat(s, ' ', pad);
}
for (int i = 0; i < len; i++) {
emit(s, str[i]);
}
if (left) {
emit_repeat(s, ' ', pad);
}
}
int mini_vformat(mini_putc_fn out, void *ctx, const char *fmt, va_list ap)
{
sink_t s = { out, ctx, 0 };
while (*fmt != '\0') {
char c = *fmt++;
if (c != '%') {
emit(&s, c);
continue;
}
bool left = false;
bool zero_pad = false;
bool is_long = false;
int width = 0;
for (;;) { /* flags */
if (*fmt == '-') {
left = true;
} else if (*fmt == '0') {
zero_pad = true;
} else {
break;
}
fmt++;
}
while (*fmt >= '0' && *fmt <= '9') { /* width */
if (width < 1000) {
width = width * 10 + (*fmt - '0');
}
fmt++;
}
if (*fmt == 'l') { /* length */
is_long = true;
fmt++;
}
char conv = *fmt;
if (conv == '\0') {
break; /* lone '%' at end of string */
}
fmt++;
zero_pad = zero_pad && !left; /* '-' overrides '0', as in printf */
switch (conv) {
case 'd':
case 'i': {
long v = is_long ? va_arg(ap, long) : (long)va_arg(ap, int);
bool neg = v < 0;
unsigned long mag = neg ? 0ul - (unsigned long)v : (unsigned long)v;
emit_number(&s, mag, 10u, false, neg, width, zero_pad, left);
break;
}
case 'u':
case 'x':
case 'X': {
unsigned long v = is_long ? va_arg(ap, unsigned long)
: (unsigned long)va_arg(ap, unsigned int);
unsigned base = (conv == 'u') ? 10u : 16u;
emit_number(&s, v, base, conv == 'X', false, width, zero_pad, left);
break;
}
case 'c': {
char ch[2] = { (char)va_arg(ap, int), '\0' };
emit_string(&s, ch, width, left);
break;
}
case 's':
emit_string(&s, va_arg(ap, const char *), width, left);
break;
case '%':
emit(&s, '%');
break;
default: /* unsupported: show it, do not guess */
emit(&s, '%');
emit(&s, conv);
break;
}
}
return s.count;
}
typedef struct {
char *buf;
size_t size;
size_t pos;
} buf_ctx_t;
static void buf_putc(char c, void *ctx)
{
buf_ctx_t *b = ctx;
if (b->pos + 1u < b->size) { /* always leave room for the NUL */
b->buf[b->pos] = c;
}
b->pos++;
}
int mini_snprintf(char *buf, size_t size, const char *fmt, ...)
{
buf_ctx_t b = { buf, size, 0u };
va_list ap;
va_start(ap, fmt);
int n = mini_vformat(buf_putc, &b, fmt, ap);
va_end(ap);
if (size > 0u) {
buf[(b.pos < size) ? b.pos : size - 1u] = '\0';
}
return n;
}

How it works, piece by piece:

  • The sink. sink_t bundles the output callback, its context pointer and a character count. Every character goes through emit, so the count is always right, which is what lets mini_snprintf report the untruncated length the way snprintf does.
  • Parsing. After a %, the loop reads flags, then decimal width digits, then an optional l, then the conversion character. The width is capped so a malicious or mistyped %99999999999d cannot overflow int. A lone % at the end of the string stops formatting instead of reading past the terminator.
  • Reading arguments. va_arg must be given the promoted type of the argument. char and short arguments arrive as int, so %c reads an int and converts it. %d without l reads an int; with l it reads a long. Getting this wrong is undefined behavior, which is exactly what the format attribute protects callers from.
  • Negative numbers. %d converts the value to its magnitude with 0ul - (unsigned long)v. Converting a negative long to unsigned long is well defined (it wraps modulo 2^N), and subtracting from zero yields the magnitude, even for LONG_MIN, whose magnitude does not fit in a long. The naive -v would overflow for exactly that value.
  • Digits. emit_number produces digits least significant first, using % base and / base, into a small local array, then emits them in reverse. On Arm, unsigned long is 32 bits, so these compile to the Cortex-M4’s hardware udiv instruction, with no call into libgcc. Using unsigned long long here would pull in the software 64-bit division routine and double the code size.
  • Padding. Spaces go before the sign, zeros go after it, and left-justified fields pad on the right, matching the standard’s behavior for %6d, %06d and %-6d.
  • The buffer sink. buf_putc stores a character only if there is room left for the terminator, but always advances pos. Afterwards the terminator goes at pos or at the last byte, whichever comes first.

A host test, using the real printf only to display the results:

test_mini_printf.c
#include <stdio.h>
#include "mini_printf.h"
int main(void)
{
char buf[64];
int n;
n = mini_snprintf(buf, sizeof buf, "d=%d u=%u x=%x X=%08X", -42, 42u, 0xBEEFu, 0xBEEFu);
printf("[%s] n=%d\n", buf, n);
n = mini_snprintf(buf, sizeof buf, "c=%c s=%-6s| w=%5d|", 'A', "ab", -7);
printf("[%s] n=%d\n", buf, n);
n = mini_snprintf(buf, sizeof buf, "min=%ld", -2147483647L - 1L);
printf("[%s] n=%d\n", buf, n);
n = mini_snprintf(buf, 8, "truncate me please");
printf("[%s] n=%d\n", buf, n);
return 0;
}
Terminal window
gcc -std=c11 -Wall -Wextra -Wpedantic -O2 mini_printf.c test_mini_printf.c -o test_mini_printf
./test_mini_printf
[d=-42 u=42 x=beef X=0000BEEF] n=28
[c=A s=ab | w= -7|] n=22
[min=-2147483648] n=15
[truncat] n=18

The last line shows the snprintf contract: the 8-byte buffer holds 7 characters and the terminator, and the return value 18 tells the caller how big the buffer should have been. if (n >= (int)sizeof buf) is the standard truncation check.

On the target, add a UART sink and a mini_printf wrapper:

#include <stdarg.h>
#include "mini_printf.h"
void uart_putc(char c); /* from your UART driver */
static void uart_sink(char c, void *ctx)
{
(void)ctx;
if (c == '\n') {
uart_putc('\r');
}
uart_putc(c);
}
int mini_printf(const char *fmt, ...) MINI_PRINTF_FMT(1, 2);
int mini_printf(const char *fmt, ...)
{
va_list ap;
va_start(ap, fmt);
int n = mini_vformat(uart_sink, NULL, fmt, ap);
va_end(ap);
return n;
}

With this in place you can link with -nostdlib-style minimalism or keep newlib for memcpy and strtol only, and your logging costs a few hundred bytes of flash instead of tens of kilobytes. Because the formatter holds no static state, two tasks can call mini_snprintf at the same time safely; the UART sink, of course, needs whatever locking your UART driver provides.

  • printf prints nothing. _write not retargeted (look for the nosys link warning) or stdout fully buffered. Fix: implement _write and call setvbuf(stdout, NULL, _IONBF, 0).
  • %f prints an empty field with newlib-nano. Fix: add -u _printf_float, or better, print fixed-point integers.
  • Flash usage jumps by 20 KB after adding one log line. A float format or full newlib got linked. Fix: check the map file; use nano, integers, or a small formatter.
  • printf called from an ISR. Non-reentrant, may allocate, blocks for milliseconds. Fix: post an event or write raw bytes to a ring buffer.
  • _sbrk without a limit. malloc returns memory overlapping the stack. Fix: check against a linker-script heap limit.
  • sin instead of sinf, 1.0 instead of 1.0f. Silent software double. Fix: f functions and literals, -Wdouble-promotion.
  • strtol without checking end and errno. Junk input parses as 0. Fix: the parse_i32 pattern.
  • isdigit(c) with a plain char. Undefined for negative values on hosts where char is signed. Fix: cast to unsigned char.
  • assert dragging in stdio. Fix: provide __assert_func or your own ASSERT macro.
  1. Build a minimal target program three ways: with printf("%d\n", x) and full newlib, with --specs=nano.specs, and with the formatter from this lesson. Record arm-none-eabi-size output for each. Then add printf("%f\n", f) with -u _printf_float to the nano build and record the increase. Use arm-none-eabi-nm --size-sort -S to name the three largest symbols that float support added.
  2. Retarget _write to USART2 on the board or in Renode, print a line every second from the main loop, and confirm the output in a terminal. Then remove the setvbuf call and describe what happens to the output timing.
  3. Extend the formatter with %p (print 0x followed by the address in hex) and a precision for strings (%.5s prints at most five characters). Add test lines to test_mini_printf.c for both, with expected output written down before you run them.
  4. Write bool parse_u16(const char *s, uint16_t *out) using strtoul. Test it with "65535", "65536", "-1", "0x10", "", and "12abc", and explain why "-1" needs special handling (strtoul accepts a minus sign and negates the result).
  5. Compile the formatter for the target with -Os and inspect the disassembly of emit_number with arm-none-eabi-objdump -d. Find the udiv instruction. Then change unsigned long to unsigned long long throughout, rebuild, and show the call to __aeabi_uldivmod and the change in code size.

Question 1:A bare-metal program calls printf("boot\n"), links fine with --specs=nosys.specs, but nothing appears on the UART. What is the most likely cause?

Question 2:Why does printf("%f", temperature) on a Cortex-M4F pull in software floating-point routines even though the chip has an FPU?

Question 3:In the formatter, %d computes the magnitude as 0ul - (unsigned long)v instead of -v. Why?

Question 4:mini_snprintf(buf, 8, "%s", "hello world") returns what, and what does buf contain?

Question 5:Which comparison function for qsort over int32_t values is correct for all inputs?

  • The compiler provides only freestanding headers and libgcc; printf, strtol and malloc come from a separate C library, usually newlib or newlib-nano with GCC, or picolibc.
  • newlib calls a small set of system call functions (_write, _read, _sbrk, …) that you implement on bare metal; retargeting _write to a UART makes printf work.
  • printf costs kilobytes of flash and hundreds of bytes of stack, much more with float support, and must never run in an ISR. Prefer snprintf to a buffer, a small formatter, fixed-point output or deferred logging.
  • Use float math functions with f suffixes and -fno-math-errno to stay on the M4F’s single-precision FPU; anything double runs in software.
  • Check strtol with both end and errno, compare without subtraction in qsort callbacks, and cast to unsigned char before calling ctype functions.
  • Replace the default assert path with your own handler so a failed check records a location and resets instead of dragging in stdio.
  • A complete printf-style formatter with width and flags fits in a few hundred bytes and contains no hidden state, which makes it reentrant.

Next: Testing and debugging C on the host, where the ring buffer from earlier gets real unit tests and you learn to drive gdb.