A calling convention is not an encoding. It is a contract between two compilers that neither of them can see, and the property that makes it measurable is unusual: you can quote it exactly, and then you can check a compiler against it. That is why this concept comes first, and why the specification here is the oracle and clang is the test subject. Every disagreement between them is printed as a retraction rather than explained away.
The method, and it is x86abi’s method for the same reason: every argument is read from its own volatile global, so the compiler cannot forward-fold it, cannot constant-propagate it, and cannot reorder two arguments that happen to have the same value. Without that, f(a, b) returning a - b compiles to nothing at -O1 and the measurement is about the compiler rather than about the ABI.
And the second half of the method is what makes the numbers MEASURED-ON-BYTES rather than MEASURED-from-text. A store into a global looks like this:
$ llvm-objdump-21 --triple=riscv64 -dr abi_O2.o | sed -n '/sw.*0x0(a1)/,+2p' | head -3
6: 00a5a023 sw a0, 0x0(a1)
0000000000000006: R_RISCV_PCREL_LO12_S .Lpcrel_hi0
0000000000000006: R_RISCV_RELAX *ABS*
And the relocation does not name G0. It names .Lpcrel_hi0 — a local label the compiler invented — and the real address is in a second entry that that label points at. Following the chain is what turns the store into “the third integer argument arrived in a2”, and it is the difference between a claim about the ABI and a claim about a text file. Concept 2 is not about a contract at all, and it is here that the difference shows: there, the compiler’s choice is the subject.
This is PIC medlow addressing, and it is not an accident of this corpus — it is what a position-independent store into a global is. One more thing about the corpus is worth knowing before the numbers, though, because it is a deliberate choice: every global is in its own section, because clang’s default is to merge adjacent globals of the same type into .L_MergedGlobals, which would add a third hop — store to local label, local label to auipc, auipc to the merged object, merged object to the pointer-table slot, slot to the G8 entry. A four-hop chain in the middle of a measurement is four chances to be wrong for a reason that has nothing to do with the calling convention, so the corpus removes the hops it does not need and the audit counts the ones it does.
Everything in this unit is QUOTED, because nothing in it needs to be measured: it is the document, and the document is the oracle. Sections 3 and 4 of the artifact test the compiler against it.
riscv-cc, "Register Convention / Integer Register Convention"
Name ABI Mnemonic Meaning Preserved across calls?
x0 zero Zero -- (Immutable)
x1 ra Return address No
x2 sp Stack pointer Yes
x3 gp Global pointer -- (Unallocatable)
x4 tp Thread pointer -- (Unallocatable)
x5 - x7 t0 - t2 Temporary registers No
x8 - x9 s0 - s1 Callee-saved registers Yes
x10 - x17 a0 - a7 Argument registers No
x18 - x27 s2 - s11 Callee-saved registers Yes
x28 - x31 t3 - t6 Temporary registers No
Read the fourth column twice. x0 is not merely preserved; it is IMMUTABLE, which is a stronger word and the only one in that column that is not Yes or No. x3 and x4 are UNALLOCATABLE, which is stronger still and means something no other register in the table means: they are not yours to use even when nothing else is available. Concept 3 measures all three.
And ra and sp are both privileged: ra is No because nobody preserves a return address, and that single fact is why the first stacked argument is at offset zero rather than eight. On x86-64 the call instruction pushes the return address, so the callee must skip it. On RISC-V the return address lives in a register, so there is nothing on the stack to skip — and every stacked offset in this course is 8 lower than the x86-64 equivalent. One sentence, and it replaces a diagram.
The floating-point table is a SECOND FILE with the same roles, and the collision is the sharpest thing in this course’s naming: riscv-cc gives both f10-f17 and x10-x17 the range name a0-a7, with a leading f to tell them apart.
f0 - f7 ft0 - ft7 Temporary registers No
f8 - f9 fs0 - fs1 Callee-saved registers Yes*
f10 - f17 fa0 - fa7 Argument registers No
f18 - f27 fs2 - fs11 Callee-saved registers Yes*
f28 - f31 ft8 - ft11 Temporary registers No
* only preserved across calls if no larger than the width of a
floating-point register in the targeted ABI
So fa5 and a5 are two different registers in two different files, and the only reason they are not confused is that the printed name has the f in it. That is a decoder problem as much as a reader problem, and concept 3 has the sharpest available example of getting it wrong.
The stack rules, and the no-red-zone one is quoted as an OBLIGATION rather than a region:
* "The stack grows downwards (towards lower addresses) and the
stack pointer shall be aligned to a 128-bit boundary upon procedure
entry."
* "The first argument passed on the stack is located at OFFSET ZERO of
the stack pointer on function entry; following arguments are stored
at correspondingly higher addresses."
* "Procedures must not rely upon the persistence of stack-allocated
data whose addresses lie below the stack pointer."
* "Registers s0-s11 shall be preserved across procedure calls."
* "The presence of a frame pointer is optional. If a frame pointer
exists, it must reside in x8 (s0); the register remains
callee-saved."
Read the third quote carefully, because the wording is doing work. x86-64 defines a 128-byte red zone and then forbids an interrupt from clobbering it. RISC-V states an obligation on the procedure and defines no region at all. There is nothing to count, which is a different thing from there being nothing there, and the only measurable consequence is what the compiler does instead: every spilled argument costs an explicit frame.
And the last quote is the one with four levels of conformance available to the platform, including “not to maintain a frame chain and use the frame pointer register as a general purpose callee-saved register”. s0 is callee-saved regardless of whether it is a frame pointer — which is the sentence that lets a compiler use s0 as an ordinary register, and which concept 3 measures at four optimisation levels.
For each of i1…i9, find the store whose relocation names G<K> and read the rs2 field of the instruction word. That register is where the K-th integer argument arrived.
$ python3 rvabi.py --run | sed -n '/INTEGER ARGUMENTS, -O2/,+6p'
function global riscv-cc says the bytes say verdict
i1 G0 a0 a0 agree
i1 -- a0 1 in registers agree
i2 G0 a0 a0 agree
i2 G1 a1 a1 agree
i2 -- a1 2 in registers agree
...
i9 G7 a7 a7 agree
i9 -- none: there is no a8 8 in registers agree
44 of 44 integer argument placements agree with riscv-cc's table, at -O2.
Forty-four of forty-four, and the denominator is arithmetic rather than a convenience: i1…i8 contribute 1+2+…+8 = 36 register-resident arguments and i9 contributes 8 more. The ninth of each function is not in the table because there is no register for it.
That is the specification’s half. Now the compiler’s, and it is a different number with a different owner: the spill count is the compiler’s, and it moves with the optimisation level.
$ python3 rvabi.py --run | sed -n '/in a reg/,+6p'
level in a reg on the stack specification
-O0 0 36 a0-a7 per riscv-cc
-O1 36 0 a0-a7 per riscv-cc
-O2 36 0 a0-a7 per riscv-cc
-Os 36 0 a0-a7 per riscv-cc
At -O0 clang spills every argument and at every other level it keeps all thirty-six in registers. No row is marked DISAGREE at any level — because a spilled argument is still passed according to the convention, it just took the “or on the stack by value if none is available” branch. This is the sharpest available lesson in separating a contract from an implementation: a number that moves with -O is the compiler’s, and a number that does not is the ABI’s.
Two hypotheses about what happens when the argument registers run out. Either there is ONE counter that runs out after eight values of any kind, so the ninth value of anything is on the stack; or there are TWO INDEPENDENT counters, one over a0-a7 and one over fa0-fa7, so the ninth value is on the stack only if both are exhausted.
Here is the ninth integer argument, at four levels:
$ python3 rvabi.py --run | sed -n '/loads from sp/,+6p'
level loads from sp <= 8 offset(instruction) via sp via s0
-O0 0 (none) 0(c.ld)
-O1 1 0(c.ldsp) (none)
-O2 1 0(c.ldsp) (none)
-Os 1 0(c.ldsp) (none)
Offset zero, at every level, and the -O0 row is a better finding than the row it replaced. At -O0 clang emits addi s0, sp, N in the prologue and reads the ninth argument from 0(s0) — through the frame pointer, not through sp. And s0is the CFA: the stack pointer as it was on entry, which is exactly what the frame-pointer convention defines it to be. So the -O0 offset is also zero, read through a different register. The first version of this section reported (none) at -O0 because the audit only looked at the base register sp, and because the -O0 load is a compressedc.ld whose base is a three-bit rs1' field rather than the five-bit one.
A row that is empty at one optimisation level and full at every other one is almost always a base-register assumption and not a measurement. That is the third time this collection has learned that in a row that looked empty rather than wrong.
And the stack alignment is an ARITHMETIC IDENTITY and not a measurement of anything: -O0’s prologue is addi sp, sp, -0x40 and its epilogue is addi sp, sp, 0x40, so the sum is 0 mod 128 at the return. What the alignment forces — a fault on a misaligned access — cannot be measured here and is not claimed, and the artifact says so in the limits section where a reader would otherwise expect the number.
This is the row. f9 takes nine doubles and nothing else:
$ python3 rvabi.py --run | sed -n '/f9, ninth double/,+2p'
case after fa7 ninth double arrived in stack loads
f9, ninth double fa7 (exhausted) a0 -- an INTEGER register 0 stack reads
The ninth double arrives in a0 — an integer register — and f9 reads no stack argument at any optimisation level. The reason is the second sentence of the hardware floating-point convention, which has to be read with the third:
* fa0-fa7 are exhausted after eight doubles.
* "A real floating-point argument is passed in a floating-point
argument register if it is no more than ABI_FLEN bits wide AND
AT LEAST ONE FLOATING-POINT ARGUMENT REGISTER IS AVAILABLE.
OTHERWISE, IT IS PASSED ACCORDING TO THE INTEGER CALLING
CONVENTION."
* the integer calling convention is a0-a7 first, and those are
still free.
So the ninth double goes to a0. It does NOT go on the stack, because nothing has exhausted the integer argument registers yet. A summary that says “the ninth argument is on the stack” is right for integers and wrong for doubles, and the only way to know which is which is to read the second convention and then measure it.
The two counters are then settled by m18 — nine integers then nine doubles — which is the case that separates the hypotheses, and which is in the corpus for exactly that reason:
$ python3 rvabi.py --run | sed -n '/ONE TOKEN PER COLUMN/,+3p'
m8 4 int + 4 double 1<-a0,...,4<-a3 1<-fa0,...,4<-fa3 (none)
m18 9 int + 9 double 1<-a0,...,8<-a7,9<-t0
1<-fa0,...,8<-fa7,9<-ft0 0, 8
m8 agrees with both hypotheses and the artifact prints that it settles nothing, because a distinguishing case that is not printed looks like a case that was not considered. m18 decides it: the ninth integer goes into t0 and the ninth double into ft0, and it reads exactly two stack offsets, 0 and 8. Two independent counters, confirmed — the ninth double is on the stack because the integers took a0-a7, and it is at 8 rather than 0 because the ninth integer took 0.
And both ninth values went through a temporary, which is worth naming because it is the only reason a reader can see the stack in that row at all. The store to G8 has rs2 = t0 and the store to D8 has rs2 = ft0, and neither is an argument register — so the audit cannot conclude “the ninth argument arrived in t0” and stop. It has to find the c.ldsp that filledt0, and that load is the evidence. A register-based audit with no load audit reports a temporary and a reader has no way to know whether it was filled from the stack, from a global, or from nowhere.
And the caller agrees, which is the part a callee-only audit cannot do. A callee audit shows only that the callee read something; the caller shows where it wrote, and neither would catch a mistake in the other:
$ python3 rvabi.py --run | sed -n '/the caller, -O2/,+12p'
call 1 stack offsets written: [0, 24, 32, 40, 48, 56, 64]
c.li a7, 8
c.sdsp s1, 0(sp) <- the ninth INTEGER at offset 0
auipc ra, 0x0
jalr ra, 0(ra)
call 2 stack offsets written: NONE
fsgnj.d fa7, fs7, fs7
c.mv a0, s0 <- the ninth DOUBLE goes into a0
auipc ra, 0x0
jalr ra, 0(ra)
call 3 stack offsets written: [0, 8]
fsgnj.d fa6, fs6, fs6
fsgnj.d fa7, fs7, fs7
auipc ra, 0x0
jalr ra, 0(ra)
Three calls, one function, and the offsets are the specification’s rather than the compiler’s, which is why the artifact prints them at one level deliberately: the register names inside the tail do move with the compiler, and printing four levels of them would be four copies of a compiler’s choices wearing the weight of a contract.
fa0-fa7 fallback from your mental model and write double d = 1.0; f9(1,2,3,4,5,6,7,8,d);, then read the caller’s disassembly with llvm-objdump-21 -d. Expect the ninth double in a0. Now add eight integer arguments in front of it so a0-a7 are exhausted, and expect the double to move to the stack at offset 8. Two experiments, one specification, and the rule “falls back to the integer calling convention” is the whole of the difference.)rvabi.py, make stores_to_globals return an empty dict. Re-run and count: the audit will report 0 of 44 with every row reading (not found), and nothing anywhere will say “the audit was disabled.” A silently dropped measurement is a measurement that cannot disagree, which is the exact shape of the bug this collection has paid for twice. Then read what the artifact does about it — the drops are COUNTED, not skipped.)Backwards, exe-frontend is what an ELF64 relocation is — this page reads the tables by hand and that is a choice, not a necessity, and the choice is what makes the measurement about bytes. rv-encoding is where c.ld’s three-bit base register is measured, and the -O0 row above depends on it: without that finding the audit reports (none) for a function that plainly reads the stack.
Across architectures, x86-calling is the same contract with two differences that are worth the comparison. x86-64 has a red zone — 128 bytes below rsp that a leaf function may use without adjusting rsp — and it has a hidden return pointer in rax for an aggregate larger than 16 bytes. RISC-V has neither: no red zone because the manual forbids relying on memory below sp, and the same hidden-pointer rule filled by a0.
And a64-aapcs is where the two independent argument counters as AArch64 states them are set out, because AArch64 reaches the same conclusion — 8 integer and 8 floating-point registers, then the stack — through a different document and with a different rule for what happens when the floating-point registers run out first. Both architectures have two counters. RISC-V is the one whose specification says so out loud in the floating-point section, which is why this row is findable at all.
Forwards, concept 3 counts the register file this page has only quoted, and finds that a callee here saves fewer registers than an x86-64 callee for the same C — because there is no flags register to save.