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.
The compiler does not provide libc
Section titled “The compiler does not provide libc”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:
| Layer | Provided by | Contains |
|---|---|---|
| freestanding headers | the compiler | stddef.h, stdint.h, stdbool.h, limits.h, float.h, stdarg.h, stdalign.h, stdnoreturn.h, iso646.h |
compiler runtime (libgcc) | the compiler | helpers 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 vendor | printf, 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.
Which C library
Section titled “Which C library”| Library | Where you meet it | Characteristics |
|---|---|---|
| newlib | default in the GNU Arm toolchain | complete, mature, large; reentrancy through a per-thread struct _reent |
| newlib-nano | same toolchain, selected with --specs=nano.specs | newlib rebuilt for size: smaller printf without float by default, simpler malloc, smaller buffers |
| picolibc | Zephyr, some toolchains, many newer projects | descended from newlib and AVR libc; small, thread-local storage instead of _reent, tiny stdio |
| vendor libraries | IAR DLIB, Keil/Arm C library and microlib | tied 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:
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.elfA 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.
The system call layer
Section titled “The system call layer”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 driverThe functions newlib may need:
| Function | Called by | Minimal bare-metal implementation |
|---|---|---|
_write(fd, buf, len) | printf, puts, fwrite | send bytes to a UART, return len |
_read(fd, buf, len) | scanf, getchar, fgets | read from a UART, or return 0 |
_sbrk(incr) | malloc | move the heap end, refuse when out of space |
_close, _lseek | stdio internals | return -1 |
_fstat, _isatty | stdio deciding how to buffer | report a character device / return 1 |
_exit(status) | exit, abort | loop forever or reset |
_kill, _getpid | abort via raise | return -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.
Retargeting _write to USART2
Section titled “Retargeting _write to USART2”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.
#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_TXEis bit 7 ofUSART2_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._writereturns the number of bytes consumed. Returning less makes newlib retry the rest; returning -1 makes it record an error on the stream.- The
\nto\r\ntranslation happens here, because serial terminals move to a new line only on carriage return plus line feed. _sbrkgrows the heap from_endtowards_heap_limit, both symbols you define in the linker script (Linker scripts shows how). Many example_sbrkimplementations found online compare against the current stack pointer instead, or do not check at all, somallochappily hands out memory that overlaps the stack. Use a fixed limit.(void *)-1is the traditional “failure” return value forsbrk. newlib’smallocconverts it to aNULLreturn.
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.
What printf costs
Section titled “What printf costs”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).
| Variant | Extra flash | Stack during a call | Notes |
|---|---|---|---|
newlib printf | 20 to 30 KB | around 1 KB or more | full feature set, float included, plus struct _reent in RAM |
newlib-nano printf | 5 to 8 KB | a few hundred bytes | no %f unless enabled |
newlib-nano with -u _printf_float | about 10 KB or more on top | more | pulls in software double arithmetic |
| the formatter in this lesson | under 1 KB | under 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:
snprintfinto a buffer, then hand the buffer to a non-blocking UART or DMA driver. The formatting cost remains but the blocking does not.- 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. - Fixed-point printing: print a temperature of 23.45 °C as
%d.%02dfrom an integer in hundredths, avoiding float support entirely. - 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.
string.h: the functions you use daily
Section titled “string.h: the functions you use daily”These are small, well optimized in newlib, and safe as long as you respect their contracts:
| Function | Use | Contract to remember |
|---|---|---|
memcpy, memmove, memset, memcmp | raw memory | covered in Memory: where everything lives; no overlap for memcpy |
strlen | length of a NUL-terminated string | walks until NUL: undefined on unterminated buffers |
strnlen | length with an upper bound | POSIX rather than ISO C, but available in newlib and picolibc |
strcmp, strncmp | compare strings | return the sign of the first difference, not -1/0/1 |
strchr, strrchr | find a character | return NULL if absent |
strstr | find a substring | O(n·m) worst case; fine for short command lines |
strspn, strcspn | span of accepted/rejected characters | handy for tokenizing without strtok |
The traps in strncpy, strtok and strcat are covered in Arrays, strings, and tables.
math.h and the FPU
Section titled “math.h and the FPU”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.
stdlib.h
Section titled “stdlib.h”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.
assert and your own handler
Section titled “assert and your own handler”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 on bare metal
Section titled “errno on bare metal”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.
ctype.h
Section titled “ctype.h”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.
What is unavailable or dangerous
Section titled “What is unavailable or dangerous”| Feature | Situation on bare metal |
|---|---|
malloc, free | work through _sbrk, with every problem from Memory: where everything lives; some libc functions call malloc internally (stdio buffers, strdup) |
time, clock, gettimeofday | need _gettimeofday/_times syscalls you must implement from an RTC or SysTick; return garbage or -1 otherwise |
fopen, fread, remove | no filesystem; the stubs fail. Use a real filesystem library if you need one |
rand | deterministic, same sequence after every reset unless seeded; statistically adequate for jitter, never for anything security related |
exit, atexit | firmware has nowhere to exit to; pulls in cleanup code. main should never return |
strtok, asctime, rand | keep hidden static state: not reentrant, unsafe from ISRs and across tasks |
printf family from an ISR | not reentrant, may call malloc, blocks for output: never |
setjmp/longjmp, signal, locales, wide characters | available 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.
A minimal printf-style formatter
Section titled “A minimal printf-style formatter”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.
#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);
#endifThe 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.
#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_tbundles the output callback, its context pointer and a character count. Every character goes throughemit, so the count is always right, which is what letsmini_snprintfreport the untruncated length the waysnprintfdoes. - Parsing. After a
%, the loop reads flags, then decimal width digits, then an optionall, then the conversion character. The width is capped so a malicious or mistyped%99999999999dcannot overflowint. A lone%at the end of the string stops formatting instead of reading past the terminator. - Reading arguments.
va_argmust be given the promoted type of the argument.charandshortarguments arrive asint, so%creads anintand converts it.%dwithoutlreads anint; withlit reads along. Getting this wrong is undefined behavior, which is exactly what theformatattribute protects callers from. - Negative numbers.
%dconverts the value to its magnitude with0ul - (unsigned long)v. Converting a negativelongtounsigned longis well defined (it wraps modulo 2^N), and subtracting from zero yields the magnitude, even forLONG_MIN, whose magnitude does not fit in along. The naive-vwould overflow for exactly that value. - Digits.
emit_numberproduces digits least significant first, using% baseand/ base, into a small local array, then emits them in reverse. On Arm,unsigned longis 32 bits, so these compile to the Cortex-M4’s hardwareudivinstruction, with no call intolibgcc. Usingunsigned long longhere 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,%06dand%-6d. - The buffer sink.
buf_putcstores a character only if there is room left for the terminator, but always advancespos. Afterwards the terminator goes atposor at the last byte, whichever comes first.
A host test, using the real printf only to display the results:
#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;}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=18The 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.
Common mistakes
Section titled “Common mistakes”printfprints nothing._writenot retargeted (look for the nosys link warning) or stdout fully buffered. Fix: implement_writeand callsetvbuf(stdout, NULL, _IONBF, 0).%fprints 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.
printfcalled from an ISR. Non-reentrant, may allocate, blocks for milliseconds. Fix: post an event or write raw bytes to a ring buffer._sbrkwithout a limit.mallocreturns memory overlapping the stack. Fix: check against a linker-script heap limit.sininstead ofsinf,1.0instead of1.0f. Silent softwaredouble. Fix:ffunctions and literals,-Wdouble-promotion.strtolwithout checkingendanderrno. Junk input parses as 0. Fix: theparse_i32pattern.isdigit(c)with a plainchar. Undefined for negative values on hosts wherecharis signed. Fix: cast tounsigned char.assertdragging in stdio. Fix: provide__assert_funcor your ownASSERTmacro.
Exercises
Section titled “Exercises”- 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. Recordarm-none-eabi-sizeoutput for each. Then addprintf("%f\n", f)with-u _printf_floatto the nano build and record the increase. Usearm-none-eabi-nm --size-sort -Sto name the three largest symbols that float support added. - Retarget
_writeto 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 thesetvbufcall and describe what happens to the output timing. - Extend the formatter with
%p(print0xfollowed by the address in hex) and a precision for strings (%.5sprints at most five characters). Add test lines totest_mini_printf.cfor both, with expected output written down before you run them. - Write
bool parse_u16(const char *s, uint16_t *out)usingstrtoul. Test it with"65535","65536","-1","0x10","", and"12abc", and explain why"-1"needs special handling (strtoulaccepts a minus sign and negates the result). - Compile the formatter for the target with
-Osand inspect the disassembly ofemit_numberwitharm-none-eabi-objdump -d. Find theudivinstruction. Then changeunsigned longtounsigned long longthroughout, rebuild, and show the call to__aeabi_uldivmodand the change in code size.
Check yourself
Section titled “Check yourself”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?
newlib routes all stdio output through _write. The nosys stub returns an error, so the text goes nowhere until you provide your own _write.
Question 2:Why does printf("%f", temperature) on a Cortex-M4F pull in software floating-point routines even though the chip has an FPU?
Default argument promotion converts float to double when passed to a variadic function. The M4F FPU has no double-precision instructions, so the formatting runs in software.
Question 3:In the formatter, %d computes the magnitude as 0ul - (unsigned long)v instead of -v. Why?
The magnitude of LONG_MIN does not fit in a long, so negating it is signed overflow. Unsigned arithmetic wraps by definition, giving the correct magnitude.
Question 4:mini_snprintf(buf, 8, "%s", "hello world") returns what, and what does buf contain?
Like snprintf, it writes at most 7 characters plus the terminator and returns the length the full output would have had, so the caller can detect truncation.
Question 5:Which comparison function for qsort over int32_t values is correct for all inputs?
Subtraction overflows for large values of opposite sign, and returning only 0 or 1 never reports "less than". The two-comparison idiom returns -1, 0 or 1 without overflow.
Key takeaways
Section titled “Key takeaways”- The compiler provides only freestanding headers and
libgcc;printf,strtolandmalloccome 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_writeto a UART makesprintfwork. printfcosts kilobytes of flash and hundreds of bytes of stack, much more with float support, and must never run in an ISR. Prefersnprintfto a buffer, a small formatter, fixed-point output or deferred logging.- Use
floatmath functions withfsuffixes and-fno-math-errnoto stay on the M4F’s single-precision FPU; anythingdoubleruns in software. - Check
strtolwith bothendanderrno, compare without subtraction inqsortcallbacks, and cast tounsigned charbefore callingctypefunctions. - Replace the default
assertpath 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.