Systems Programming · C and the ABI
Variadic Calls and the Default Argument Promotions
What a va_list actually points at, why float and char arguments are widened before a variadic call, and how the absence of type information turns a wrong format into undefined behaviour.
printf("%f", 1.0f) prints a value. printf("%f", 1) does not, and the failure is not a formatting mistake. It is a consequence of the fact that a variadic function is called without any type information for the arguments after the format, so the compiler applies a fixed set of promotions that the callee must guess correctly from the format string. When the guess is wrong, the callee reads a register or a stack slot that holds something else.
The mechanism behind variadic calls is worth understanding in detail because it is the clearest case where the ABI, not the source, decides what happens. It also explains why va_arg is one of the few places in C where the type must match exactly, with no conversion permitted.
The promotion rule and why it exists
In a prototyped call, the compiler knows the type of every parameter and passes each argument as declared. In a call to a function with ... in its parameter list, the compiler knows the types of the named parameters only. For the rest, it applies the default argument promotions: float is promoted to double, and every integer type of rank lower than int is promoted to int or unsigned int following the integer promotion rules.
The reason is historical and structural. Before prototypes existed, every function call was unprototyped, and the callee had no way to know the width of an argument except by convention. Widening everything narrow to int and every float to double gives the callee a small, fixed set of possible widths, which makes a register-based or stack-based convention tractable. The rule survived into the prototype era because ... reintroduces exactly the same information gap. The widths that survive promotion are the ones described in integer object representation and conversion.
The practical consequence is that a variadic function can never receive a float. It receives a double, and va_arg(ap, float) is undefined behaviour because the value in the slot is 8 bytes wide and the argument type requested is 4. Similarly, it can never receive a char or short; those arrive as int. va_arg(ap, char) reads an int-width slot as a char, and while the value may appear to be correct on a little-endian target, the read is still undefined and the remaining arguments are unaffected only because the slot width did not change.
There is a related rule for pointers: NULL defined as 0 is an int, and passing it where a variadic function expects a pointer gives an int-width argument. NULL defined as ((void *)0) is a pointer and is passed correctly. This is why the standard permits either definition and why passing a literal 0 to a variadic parameter expecting a pointer is a portability defect.
What va_list is, concretely
<stdarg.h> defines va_list and the macros va_start, va_arg, va_end, and optionally va_copy. The standard says only that va_list is an object type suitable for holding the information needed by the macros, and that the object passed to va_start must be the last named parameter. Everything else is implementation-defined, which is why the type is different on different ABIs.
On the x86-64 System V ABI, a va_list is an array of one structure with four fields: an offset into the register save area for the next general-purpose argument, a second offset for the next vector argument, a pointer to the next argument in the overflow area, and a pointer to the register save area itself. The structure has an array type so that passing a va_list to another function decays to a pointer, which is why va_copy exists: copying the pointer copies the position, not the state, and a function that consumes arguments in a helper would otherwise corrupt its caller’s traversal.
On AArch64 the layout differs: a va_list is a structure containing a stack pointer, a gr_top/gr_offset pair for the general register save area, and a vr_top/vr_offset pair for the vector save area. The field names are the same in spirit, and the offsets are not.
The register save area is the key mechanism. Because the first several arguments arrive in registers, and because a variadic function cannot know how many arguments there are, the function’s prologue spills all of the potentially-variadic argument registers to a contiguous area of the stack. On x86-64 this is the 48 bytes holding rdi, rsi, rdx, rcx, r8, and r9, and the 128 bytes of xmm0 through xmm7 when the function is known to use vector arguments. The prologue is generated because the function’s declaration contains ...; a function without ... does not spill its argument registers.
va_start initialises the traversal state from the named parameters, and va_arg fetches the next argument by advancing the offset or the stack pointer, depending on whether the register save area is exhausted. Integer-class arguments are read from the save area until the general-purpose slots run out; the rest come from the overflow area.
#include <stdarg.h>
/* Sums n integer arguments. */
long sum_ints(int n, ...)
{
va_list ap;
long total = 0;
va_start(ap, n);
for (int i = 0; i < n; i++)
total += va_arg(ap, int); /* must be int, not char or short */
va_end(ap);
return total;
}
Alignment, overflow area, and the eightbyte rule
Arguments that do not fit in registers go to the overflow area on the stack, and they are laid out with the same eightbyte granularity as the register case. Each stack argument occupies a number of eightbytes determined by its size and alignment; a double occupies one, a 16-byte structure two, and a 16-byte-aligned type may occupy an extra eightbyte for alignment.
va_arg(ap, T) advances the traversal by the size of T rounded up to eight bytes, subject to the alignment of T. Requesting a type with a different alignment than the one passed therefore desynchronises the traversal, and every subsequent read returns a value from the wrong offset. This is why a mismatch in a printf format does not merely produce one wrong value: the format string drives a sequence of reads with no type checking, and an early mismatch shifts the interpretation of everything after it.
The %f and %d case illustrates the width problem. %d reads an int from the next integer-class slot. %f reads a double from the next SSE-class slot. The two use different register sequences, so a printf("%f", 1) reads xmm0 while the caller placed the value in esi, and the printed result comes from whatever the caller last left in xmm0. In the printf("%d", 1.0) direction the value was promoted to double and placed in xmm0, while %d reads esi, which holds something unrelated.
A variadic function that mixes integer and floating-point arguments has a further subtlety: the number of vector registers used must be communicated to the callee. The System V ABI requires the caller to set al to the number of vector registers used for the call when calling a variadic function. A function that does not set al leaves the callee with an indeterminate count, and a va_arg that requests a double may read from the register save area even though no vector arguments were passed. Compilers emit the mov al, N before such calls; hand-written assembly that omits it is a common source of apparently random floating-point output.
Where format strings and va_arg diverge from safety
va_arg has no way to verify the requested type. The standard’s requirement is that the type be compatible with the actual argument’s promoted type, and violating it is undefined behaviour, with no diagnostic required. Two patterns make this dangerous in practice.
The first is a format string that does not match its arguments. Compilers with -Wformat check the common cases for printf-family functions when the format is a literal, using the format attribute. A format built at runtime, or passed through a wrapper without the attribute, escapes the check entirely. The attribute is a compiler extension but is supported by GCC and Clang and by the __attribute__((format(printf, m, n))) spelling on both; a wrapper function should carry it so that call sites are checked.
The second is a va_arg sequence that consumes a different number of arguments than the caller supplied. A function that reads n integers from a caller that passed n - 1 reads past the last argument into the caller’s stack frame. The values are whatever the caller left there, and the read itself is legal as far as the hardware is concerned, which is why the failure is a corrupted result rather than a fault.
The va_copy macro is the remedy for the aliasing problem. A function that needs to traverse the arguments twice must copy the va_list first, because traversal is destructive. On the array-typed va_list of x86-64, passing a va_list to a function passes a pointer to the state, so the callee advances the caller’s position. va_copy duplicates the state, including the register save area pointer and offset, and va_end must be called on both.
#include <stdarg.h>
#include <stdio.h>
void log_twice(const char *fmt, ...)
{
va_list ap, copy;
va_start(ap, fmt);
va_copy(copy, ap);
vprintf(fmt, ap); /* first pass consumes ap */
vprintf(fmt, copy); /* second pass uses the copy */
va_end(copy);
va_end(ap);
}
The price of a variadic call
A variadic call is not free relative to a fixed-arity call. The callee’s prologue must spill the argument registers to the save area whether or not they are used, which costs six stores and, for vector arguments, up to 128 bytes of stores. The caller must set al and may need to build a register-save frame for a call that in the fixed case would pass everything in registers. For a function called in a tight loop with a fixed small argument list, this overhead is measurable; for a logging call on a cold path it is irrelevant.
The type-safety problem is separate from the cost, and the standard answer is a non-variadic interface. A function taking a pointer and a length, or an array of typed structures, gives the compiler enough information to check every argument. Where the convenience of a variadic interface is worth keeping, the format attribute recovers most of the checking for literal formats, and a length-prefixed or null-terminated argument list with an explicit terminator at least bounds the traversal.
The final interaction worth noting is with prototypes. An old-style declaration int f(); provides no parameter information at all, so every call to it is treated as variadic for promotion purposes, and a float argument is widened even though the definition may expect a float. The definition and the call then disagree about the width, and the callee reads the wrong bytes. The declaration int f(void) is not the same thing and is the one to use for a function that takes no arguments. The full set of argument-passing rules that these promotions feed into is described in the x86-64 register assignment article.
References
- ISO/IEC 9899:2018 — Programming languages — C — ISO/IEC
- System V Application Binary Interface, AMD64 Architecture Processor Supplement — x86-64 psABI committee
- stdarg(3) — Linux man-pages — Linux man-pages project
- Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64) — Arm Limited