Every other rule in this ABI is implicit. The callee knows how many arguments there are because the caller and the callee were compiled from the same declaration, so the two of them share something — a prototype — and the registers are just the places they agreed to put them. Nobody has to be told anything at run time.
A variadic function breaks that completely. printf does not know how many arguments it was called with, or what they are, and neither does the code that walks the list. Something has to carry the information, and the ABI's answer is a small block of stack that the callee fills in before it can read anything, plus one integer in %rax that says how much of it is worth filling in.
That is the only self-describing convention in the whole of x86-64, and it is worth looking at closely for two reasons. It is the mechanism behind the single most-used function in the C library. And it is the one place where a caller can be wrong in a way that produces a plausible number that nobody passed rather than a crash — which makes it the best demonstration in this course of what a contract violation actually looks like when the contract is about meaning rather than about memory.
The register save area is a fixed 176-byte block, and the division is not arbitrary: 48 bytes for the six integer registers at 8 bytes each, and 128 bytes for the eight SSE registers at 16 bytes each.
$ ./abidump | sed -n '/1B\./,/^ the two sequences/p' | tail -6
the return value rax; rdx for a second integer; xmm0
for a double; xmm0:xmm1 for a long double
the stack alignment constant 16 bytes
%rsp at a callee's entry 8 (mod 16), because `call` pushed 8
the red zone 128 bytes below %rsp, LEAF functions
the varargs save area 176 bytes = 48 integer + 128 SSE
the initial gp_offset 8
the initial fp_offset 48
the vector count in %al 0 .. 8
And here is the shape of the whole mechanism, which is a two-field cursor and a pointer:
So a variadic callee does two things. It fills in the save area — six integer registers unconditionally, and the SSE registers only if %al is non-zero. And it sets the two cursors to 8 and 48. Then va_arg is arithmetic: read the field, add the size of the type, write the field back.
Here is a real one, compiled at -O0 so that every store is still in the listing, and read back:
$ gcc -O0 -S -o - abidump.c && sed -n '/^abi_take2:/,/^\.L190:/p' abidump.s abi_take2: .cfi_startproc endbr64 pushq %rbp movq %rsp, %rbp subq $256, %rsp movq %rdi, -248(%rbp) -> the NAMED parameter, elsewhere movq %rsi, -168(%rbp) -> the first vararg register movq %rdx, -160(%rbp) movq %rcx, -152(%rbp) movq %r8, -144(%rbp) movq %r9, -136(%rbp) -> the sixth, and the LAST testb %al, %al -> %al: is it ZERO? je .L190 -> if so, do not spill xmm at all movaps %xmm0, -128(%rbp) -> the FP half begins here movaps %xmm1, -112(%rbp) movaps %xmm2, -96(%rbp) movaps %xmm3, -80(%rbp) movaps %xmm4, -64(%rbp) movaps %xmm5, -48(%rbp) movaps %xmm6, -32(%rbp) movaps %xmm7, -16(%rbp) -> eight of them, 16 bytes each .L190: movq %fs:40, %rax -> the stack protector canary, movq %rax, -184(%rbp) which is a DIFFERENT convention movl $8, -208(%rbp) -> gp_offset = 8 movl $48, -204(%rbp) -> fp_offset = 48 leaq 16(%rbp), %rax -> overflow_arg_area movq %rax, -200(%rbp) leaq -176(%rbp), %rax -> reg_save_area, the 176 bytes movq %rax, -192(%rbp) movl -204(%rbp), %eax cmpl $175, %eax -> will the next 16 bytes fit? ja .L191 -> no: read the stack instead
Three things to read out of that, and one of them is a surprise.
testb %al, %al followed by a jump over all eight stores. So %al is being used as a boolean, not as a count. The specification says it is the number of vector registers used, and gcc asks only whether it is zero. Hold that thought; it is the whole of the last section.movaps stores are 16-byte aligned stores, at -128 through -16 from %rbp. That is the alignment rule from the previous concept appearing in a place nobody thinks about it: a variadic function's save area is a 16-byte-aligned object, and the ABI's stack alignment rule is what makes it possible. A variadic function compiled by a compiler that broke the alignment rule would fault in its own prologue, before reading a single argument.%alHere is the experiment. The caller puts two doubles in %xmm0 and %xmm1, and then puts a chosen value in %al — the number of vector registers it claims to have used. The consumer is an ordinary variadic C function using va_start and va_arg, so the whole thing is compiled by gcc and the prologue above is the code that runs.
$ ./abidump | sed -n '/The caller claims a number/,/arm 4/p'
honest, arm 1: 1.5 and 2.5
al=2 a=0x3ff8000000000000 (1.500000) b=0x4004000000000000 (2.500000)
honest, arm 2: 101.25 and 202.5 -- these are now what the save
area holds, and arm 3 is where it stops mattering
what the caller put in xmm0 and xmm1.
al=2 a=0x4059500000000000 (101.250000) b=0x4069500000000000 (202.500000)
LYING, arm 3: the caller puts 3.5 and 4.5 in xmm0 and xmm1 and
then says it used NO vector registers:
al=0 a=0x4059500000000000 (101.250000) b=0x000000000000000a (0.000000)
al=1, arm 4: the same lie, understated rather than zero:
al=1 a=0x400c000000000000 (3.500000) b=0x4012000000000000 (4.500000)
Read arm 3 again, because the first number is the finding. The caller passed 3.5 and 4.5. The consumer returned 0x4059500000000000, which is 101.25 — exactly what arm 2 passed, bit for bit, and not what this arm passed. The second came back 0x000000000000000a, which is a fragment of a pointer and not a number anybody passed to anything.
That is a much better failure than a crash, and it is the reason this concept is on a page about contracts. A crash tells you where to look. A plausible number tells you nothing, and it will be printed into a log, compared against a tolerance, and believed.
The same experiment through glibc's printf, which is a variadic consumer and obeys the same rule:
$ ./abidump | sed -n '/AND THE SAME FAILURE/,/wrong answer,/p' glibc printf, al=2: 1.500000 2.500000 glibc printf, al=2: 101.250000 202.500000 and now the same call with al=0, having been passed 3.5 and 4.5: glibc printf, al=0: 101.250000 0.000000 A number nobody passed, printed as though somebody had. That is not a slow path and not a portability warning: it is a wrong answer, produced by a caller that broke one instruction's contract.
So the mechanism is not an academic corner of the convention. It is the code path of every printf call with a floating-point argument in it, and the reason %al exists at all.
The specification says %al is the number of vector registers used. Arm 4 claims one and passes two, which is a lie in the strict reading — and it works, because gcc tests %al for zero and spills all eight when it is non-zero. So on this compiler the field is a boolean and the specification's stricter reading would be a lie that happens to be harmless.
That is worth stating as what it is rather than as a contradiction: a self-describing convention is still a convention. The description is there, and nothing checks it. Any callee is entitled to use %al as an upper bound — to trust it and read only that many registers — and one that does will break on arm 4 while working perfectly on arm 3. The value of the mechanism is that it makes the common case safe; the cost is that the common case and the correct case are not the same case.
The layout deserves to be written out once, because it is short and everything about varargs is a consequence of it:
Two consequences fall out of the layout and both are worth stating as rules rather than as trivia.
The movaps stores are aligned because the area is 16-byte aligned, and the area is 16-byte aligned because of the stack alignment rule. That is why the two concepts are adjacent: a variadic function is the one function whose prologue depends on the caller's alignment, because the save area is a 16-byte-aligned object that the callee does not build — it inherits.
Saving eight XMM registers costs 128 bytes of store and, when %al is zero, is skipped entirely. The skip is the optimisation that made the mechanism cheap, and it is also the entire attack surface: a caller that says zero pays nothing and the callee reads nothing. An implementation that trusted %al as an exact count would save four stores in the common case where one float is passed, at the cost of a second pass over the save area for the rest. gcc's author chose the branch.
And the arithmetic of va_arg itself is worth one sentence, because it explains the cmpl $175 in the listing: before reading, the callee compares the SSE cursor against 175 — the last byte of the 176-byte area — and if the next 16-byte argument would not fit, it reads from overflow_arg_area instead. That is the boundary between “passed in a register” and “passed on the stack”, and it is the same boundary the first concept measured at stack=0x10. The rest of va_arg is the same idea: load the cursor, add the size of the type, store the cursor back, read through the pointer.
VARARG_CALL macro out of abidump.c into your own file, change the al value, and watch which arms come back wrong. (Expect al=0 to lose the FP arguments and al=1 to keep them on gcc, and expect the values you get back to be the previous call's rather than zeroes. That second half is the part that makes it a security-shaped bug rather than a numerical one: the value is not garbage, it is someone else's data.)objdump -d any variadic function and look for testb %al,%al above a block of eight movaps stores. (Expect to find it in printf's implementation, in snprintf, in every libc routine that takes a floating-point variadic argument, and in every program your compiler has ever produced that includes <stdarg.h>. Then change al to 1 in a hand-written caller and see the ones that trust the count fail and the ones that test for zero survive.)gp_offset to start at 0 rather than 8, because the first integer register is still available, and fp_offset to start at 64 rather than 48. This is the clearest demonstration that the two cursors really are independent counters, and it is the same fact the first concept measured as mx in the disassembly.)subq $8, %rsp from its outgoing-argument area. (Expect a SIGSEGV inside the callee's movaps, before a single argument is read, and expect the fault address to be 8 bytes off a 16-byte boundary. That is the alignment fault wearing a different hat, and it is the reason the two concepts are in the same module.)Backwards, the calling convention is where the independence of the two register sequences was measured, and this concept is the only place in the ABI where that independence is load-bearing: the two cursors in the va_list are the same two counters, and a function whose first parameter is a double has an integer cursor starting at 0 and an SSE cursor starting at 64. The stack frame is where the movaps stores in that prologue get their alignment, and a reader who has read it will recognise -128(%rbp) as the first slot of a 16-byte-aligned object rather than as a coincidence of the frame size.
Forwards, the %al finding is the last retraction in this module and it is a general one: a convention that describes itself is not thereby checked. The unwinding concept is the other place in this ABI where the hardware is told something it could have worked out, and the parallel is exact — %al is information the caller has and the callee cannot derive, and the .eh_frame table is information the compiler has and the profiler cannot derive. Both are conventions rather than instructions, and both are paid for in bytes.
Outward, the neutral course that owns the why of dynamic dispatch across a language boundary. This concept is about how a C caller tells a C callee what it passed; it is not about what happens when the two sides were compiled by different compilers in different languages, and that is the dynamic-linking course's runtime-order concept and its interposition concept. The one thing worth carrying across is the shape of the failure: a boundary that disagrees about a signature does not crash, it returns a value from a different conversation.