📚 The Standard Library
Variadic functions: how printf really works
▶ Open the interactive lesson — free, no signupprintf takes two arguments one moment and ten the next — you've leaned on that magic since your very first program without knowing how it's possible. Today you build a working mini-printf of your own, and learn why printf(user_input) is a genuine security hole that attackers have exploited for decades.
Since day one you've called printf("x=%d y=%d", x, y) — a function that somehow accepts any number of arguments of any type. Today the magic trick is revealed: such functions are called variadic (they take a variable number of arguments), and the machinery behind them is the three-dot ... ellipsis and stdarg.h. By the end you'll have written your own.
The machinery: va_list and friends
A declaration like int my_sum(int count, ...) means: one named parameter, then anything. Inside, four macros from stdarg.h walk the extras:
| macro | role |
|---|---|
va_list ap; | declares a cursor over the unnamed arguments |
va_start(ap, last) | points the cursor just past the last named parameter |
va_arg(ap, type) | yields the next argument as type and advances — you must know the type; nothing checks |
va_copy(dst, src) | clones a cursor (C99) — needed to walk the args twice |
va_end(ap) | cleanup; required before returning |
Notice what's missing: any way to ask "how many arguments are there?" or "what type is next?". The callee is blind — that information must be smuggled in separately, which is exactly what printf's format string does. Step through the simplest possible example:
▶ This spot has an interactive trace widget — open the interactive lesson to play with it.
🧠 Checkpoint: How does a variadic function know how many unnamed arguments it received?
- va_start returns the count
- sizeof(ap) reveals it
- It can’t — the caller must communicate it (count parameter, format string, or sentinel)
- va_arg returns NULL after the last one
Show answer
It can’t — the caller must communicate it (count parameter, format string, or sentinel) — The mechanism is completely blind: no count, no types, no end marker. Every variadic API layers its own protocol on top — printf’s format string, my_sum’s count, execl’s NULL sentinel. Get the protocol wrong and it’s UB.
Build your own printf
Now the real thing, miniaturized. The format string is the type map: each % letter tells us which type to pull with va_arg:
#include <stdio.h>
#include <stdarg.h>
void mini_printf(const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
for (const char *p = fmt; *p; p++) {
if (*p != '%') { putchar(*p); continue; }
switch (*++p) { /* char after '%' */
case 'd': printf("%d", va_arg(ap, int)); break;
case 'f': printf("%g", va_arg(ap, double)); break;
case 's': fputs(va_arg(ap, const char *), stdout); break;
case 'c': putchar(va_arg(ap, int)); break;
case '%': putchar('%'); break;
}
}
va_end(ap);
}
int main(void) {
mini_printf("%s scored %d points (%f%%) — grade %c\n",
"ada", 97, 97.5, 'A');
return 0;
}$ gcc miniprintf.c -o miniprintf && ./miniprintf ada scored 97 points (97.5%) — grade A # the format string told us the type sequence: # char* , int , double , int(char)
To build a logging wrapper around real printf, don't re-parse formats — pass the whole va_list along to vprintf/vfprintf/vsnprintf: va_start(ap, fmt); vfprintf(stderr, fmt, ap); va_end(ap);. That's the v-family's whole purpose (and where va_copy shines if you must format twice).
Default argument promotions: why %f handles float
Ever wondered why printf has %d for int and %f for double — but no specifier for char, short, or float? Because they can never arrive. For arguments matched by ..., the compiler applies the default argument promotions: char and short are promoted to int, and float is promoted to double. So va_arg(ap, char) is simply wrong — the value on the stack is an int:
void f(int count, ...);
char c = 'A'; /* passed through ... as: int */
short s = 7; /* as: int */
float x = 2.5f; /* as: double */
f(3, c, s, x);
/* inside f, the ONLY correct pulls are:
va_arg(ap, int) for c
va_arg(ap, int) for s
va_arg(ap, double) for x
va_arg(ap, char) / (short) / (float) are UB —
those types can never come through the ellipsis. */🧠 Checkpoint: You pass a float f to printf. Which specifier is correct?
%f— the float was promoted to double on the way in%hffor half precision- Floats can’t be printed without a cast
%lfis required for the promotion
Show answer
%f — the float was promoted to double on the way in — Default argument promotions convert every float argument to double before it enters the .... So %f (which expects double) is exactly right — printf never sees a real float. (%lf is also accepted for printf since C99, but it’s the same thing.)
The price: zero type safety
If va_arg pulls the wrong type — printf("%d", 3.14), printf("%s", 42) — the behavior is undefined: garbage output if you're lucky, a segfault (or an exploitable hole — look up "format string vulnerability") if you're not. Modern compilers check literal printf formats for you (-Wformat, and you can extend it to your own functions with GCC's format attribute) — but only when the format is a literal. Never write printf(user_input); write printf("%s", user_input).
Since the callee can't count arguments, every variadic API picks a protocol:
- Format string — printf, scanf: the string encodes count and types.
- Explicit count —
my_sum(3, a, b, c): the first argument says how many follow. - Sentinel — POSIX
execl("ls", "ls", "-l", (char *)NULL): a special terminator value marks the end. (Note the cast — NULL alone might not be pointer-sized in a variadic call!)
🧠 Checkpoint: What does printf("%s", 42) do?
- Prints "42"
- Compile error, guaranteed
- Undefined behavior — printf dereferences 42 as if it were a char pointer
- Prints the address 42
Show answer
Undefined behavior — printf dereferences 42 as if it were a char pointer — va_arg pulls the 42 and treats it as a char* to walk and print — reading address 42 is UB, usually a segfault. Compilers catch this for literal formats (-Wformat is on by default in warnings), which is one great reason to keep formats literal.
C23 tidied this corner: va_start(ap) no longer needs the second argument (it's ignored if given), and a function may now be fully variadic — int f(...) with no named parameters at all, something previously illegal. The old two-argument form still works everywhere.
▶ This spot has an interactive editor widget — open the interactive lesson to play with it.
🎉 That's the standard library toured — every header worth knowing, from printf to setjmp. Next part we put it all to work: algorithms, Big-O, and classic data structures in C.
▶ Practice this lesson interactively (with live gcc)