5.1.29 · D5C Programming
Question bank — Variadic functions — va_list, va_start, va_arg, va_end
This is the sibling of the parent note [[Variadic Functions — va_list, va_start, va_arg, va_end|the main topic note]]. No heavy computation here — just the reasoning.
True or false — justify
The compiler type-checks the ... arguments the same way it checks named parameters.
False. Once you write
..., the compiler stops verifying types and counts; that trust is the whole cost of the feature and the source of most UB.va_list is guaranteed to be a plain pointer you can do arithmetic on.
False. It is an opaque type — on some ABIs it is a struct (or an array!) tracking register-save areas, so
ap + 1 or sizeof(ap) is meaningless and non-portable.You can copy a va_list with a normal = assignment.
False. Because it may be an array type,
dst = src can fail or alias the same cursor; the only portable duplication is ==va_copy(dst, src)==.A variadic function may legally have zero named parameters, e.g. void f(...).
False. C requires at least one named parameter, because
va_start needs its name as the anchor to find where ... begins.va_end is optional if the function is about to return anyway.
False. Some ABIs allocate cleanup state in
va_start; skipping va_end is UB even if it "works" on your machine today.Reading exactly one more va_arg than was passed just returns 0 or a harmless default.
False. Nothing marks the end of the list, so an extra read pulls whatever bytes follow — garbage, another variable, or a crash. Pure UB.
Passing a float and reading it with va_arg(ap, float) is correct because the types match.
False. A
float is promoted to ==double== before being passed; the float never exists in the call. You must read va_arg(ap, double).printf("%d", 3.5) is caught by the compiler because %d clearly wants an integer.
False. It usually compiles (maybe with a warning); at runtime
%d reads an int-sized chunk of a double's bytes → garbage. The format string is trusted blindly.Once you've walked a va_list to the end, you can reset it and walk again.
False. A
va_list may be single-use with no rewind. To scan twice you must va_copy a fresh cursor before consuming the first.The named parameter's own value is read back by the first va_arg.
False.
va_start(ap, last) positions the cursor just after last, so the first va_arg returns the first ... argument, never last itself.Spot the error
void log(...) { va_list ap; va_start(ap, ...); } — what's wrong?
Two errors: no named parameter is allowed with a bare
..., and va_start's second argument must be a name, not ....char c = va_arg(ap, char); for an argument you passed as 'A'.
The
char was promoted to int; reading char mis-sizes the slot. Use va_arg(ap, int) then narrow: char c = (char)va_arg(ap, int);.sum(3, 10, 20); calling int sum(int n, ...) — the count says 3 but only 2 follow.
The third
va_arg(ap, int) reads past the arguments → UB. The count must match the number actually supplied.total_len("a", "b", "c"); for a NULL-sentinel function.
The caller forgot the terminating
NULL, so the while loop never sees the sentinel and reads past the end → UB. Caller must pass NULL.va_start(ap, n); return va_arg(ap, int); /* no va_end */
Missing
va_end(ap) — cleanup is mandatory on every path, including early returns and error branches.va_copy(ap2, ap); used but only va_end(ap) is called, not va_end(ap2).
A copied
va_list is its own cursor and needs its own va_end(ap2); leaking it is UB just like leaking the original.va_arg(ap, int*) to read something passed as an int array arr.
An array decays to a pointer, so the argument is actually an
int* — that read is fine; the error would be reading it as int. (Trap: the mismatch is the assumption, not the code.)Why questions
Why can't the compiler just figure out the argument types on its own?
At the call site it sees the actual arguments, but inside the function body it only sees
... — there is no per-call type information stored anywhere for va_arg to consult.Why does the standard hide the mechanism behind macros instead of a plain pointer?
Because the real layout depends on registers, alignment, and calling conventions that differ per ABI; macros keep your code portable while the compiler fills in the platform details.
Why do small integer types get promoted to int when passed variadically?
Default Argument Promotions widen
char/short→int and float→double so every argument lands in a uniform, register-friendly size — there is literally no way to transmit an un-promoted char.Why does a variadic function need a count or a sentinel?
Nothing in the calling convention records how many arguments arrived, so the callee has no intrinsic way to know when to stop; you must encode the boundary yourself.
Why is printf a classic security risk?
It reads arguments strictly according to the format string; if an attacker controls that string (
printf(user)), they can make it read arguments that were never passed, leaking or corrupting memory.Why must va_start know the last named parameter specifically?
The cursor must begin exactly where the named parameters end;
last is the landmark from which "the byte after this" locates the first variadic argument.Why can va_arg never verify that the type you asked for is right?
It only advances a cursor by the size you name; it has no record of what was actually pushed, so it will happily reinterpret whatever bytes are there.
Edge cases
What does a variadic function do when zero variadic arguments are passed (only the named one)?
That is legal — you simply never call
va_arg. The count/sentinel logic must handle the "0 items" case (e.g. average returning 0.0 when n == 0).Can you pass a va_list to another function and let it call va_arg?
Yes, but pass it (or a
va_copy) carefully — after the callee consumes it, the cursor position in the caller may be indeterminate. This is why vprintf/vsnprintf exist, taking a va_list parameter.Is it safe to va_arg(ap, struct Foo) for a passed-by-value struct?
It is defined only if the actual argument had exactly that type; there is no promotion for structs, so the type must match the pushed struct exactly or it's UB.
What happens if you call va_start twice without a va_end in between?
Undefined behavior — each
va_start must be paired with its own va_end before re-initialising; overlapping starts corrupt the cleanup state.Can va_arg be used to read a long double?
Yes —
long double is not promoted (it's already the widest float), so you read it exactly as va_arg(ap, long double). Beware: it differs in size from double, so mismatching them mis-sizes the cursor.If a function takes (int a, int b, ...), where does va_start(ap, b) start?
Just after
b, the last named parameter — both a and b are skipped, and the first va_arg returns the first ... argument.Is passing NULL as (void*)0 vs bare 0 as a sentinel always safe?
Not always — a bare
0 may be passed as int, but the reader expects a pointer width. On platforms where they differ, this mismatches; use (void*)NULL or (char*)NULL to force pointer size.