Three courses in this collection already taught the three calling conventions, in full, each with its document named: x86-calling, a64-aapcs, and rv-calling. This page does not repeat a single table from them. If you want to know which registers carry arguments on AArch64, you already know, and the right page for that is the one linked above.
What is left is the other half of the bargain, and it is the half nobody teaches: what a compiler has to know about the ABI, and when it has to know it. The convention is a document. The obligation is a scheduling constraint on your own passes, and getting it wrong does not produce a program that disagrees with a spec — it produces a program that is slower than it needed to be, with nothing in the output to say so.
So the subject here is an order, and the artifact states the order before it prints a register name:
$ python3 compback.py --run | sed -n '/THE OBLIGATION IS NOT THE CONVENTION/,/IT LOOKED LIKE IT NEEDED NONE/p'
THE OBLIGATION IS NOT THE CONVENTION. x86abi, a64abi and rvabi each
taught their convention in full. This section measures the BACKEND'S
SIDE of the bargain, and the order matters:
a backend that emits a call must ALREADY know the argument
registers, the return register, the callee-saved set and the stack
alignment -- and it must know them BEFORE it has allocated a
register to anything.
§KEEP§REGISTER ALLOCATION ASSIGNS NAMES TO VALUES AND THE ABI ASSIGNS
NAMES TO ARGUMENTS, AND THE ORDER IS NOT A MATTER OF TASTE. §KEEP§IF
THE ALLOCATOR RUNS FIRST AND THE ABI IS CONSULTED SECOND, THE
ALLOCATOR HAS ALREADY GIVEN `rbx` TO A VALUE AND THE PROLOGUE NOW HAS
TO SAVE IT -- A PROLOGUE THE BACKEND DID NOT PLAN FOR, AND §KEEP§ON
x86-64 THAT MEANS A STACK FRAME WHERE THE FUNCTION LOOKED LIKE IT
NEEDED NONE.
Read the last clause slowly, because it is the sharpest thing on this page. If the allocator has already given rbx to a value, the function now has to save and restore rbx — and on x86-64 that means a stack frame. A function that had no frame now has one, with the push and the pop and the frame pointer arithmetic that goes with them, for the sake of a register the backend chose freely and then discovered it could not keep.
This is the same class of failure as the two pages before, and it is worth naming the class once. A selector that dispatches on operand kind produced a program that computed a different number and never failed. An allocator built on half-open live ranges reported a better spill count and never failed. A backend that learns the ABI after allocating produces a function with a frame it did not plan for, and never fails either. In all three cases the output is a plausible program and the damage is in something nobody printed. That is what a constraint you consulted too late looks like from the inside.
The register names in the next two blocks are QUOTED, with a document and a section for each line, because no register name is something a measurement on this host produces. Read the documents rather than trusting the table, and note that the table is one line per target on purpose.
$ python3 compback.py --run | sed -n '/THE CONVENTIONS, QUOTED, one line each/,/the a0-a7 argument registers/p'
THE CONVENTIONS, QUOTED, one line each, with the document:
x86_64 rdi rsi rdx rcx r8 r9 System V Application Binary Interface, AMD64 Architecture Processor Supplement, "The Function Call Interface", section 3.2.1
aarch64 x0 x1 x2 x3 x4 x5 Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64), 2025Q1, section 6.1, parameter registers
riscv64 a0 a1 a2 a3 a4 a5 RISC-V ABIs Specification v1.0, "Integer Calling Convention", the a0-a7 argument registers
The second block is the number the allocator has to subtract, and it is the one people forget. The argument registers tell you where values arrive. The reserved registers tell you what the machine will not let you use for your own bookkeeping.
$ python3 compback.py --run | sed -n '/AND WHAT EACH ONE RESERVES/,/x1 is the return address re/p'
AND WHAT EACH ONE RESERVES, which is the number the allocator has to
SUBTRACT, QUOTED from the same documents:
x86_64 rsp rbp and, for a function that makes a call, rax
SysV AMD64 supplement 3.2.2: %rsp is the stack pointer; %rbp is th
aarch64 x29 (fp) x30 (lr) and, when used, x16 x17
AAPCS64 6.4.1: x29 is the frame record chain, x30 the link registe
riscv64 x2 (sp) and, for a function that makes a call, x1 (ra)
riscv-cc "Integer Calling Convention": x1 is the return address re
Notice the conditional in two of the three rows. rax is reserved on x86-64 only for a function that makes a call, and x1 only for the same reason on RISC-V. A backend that reserves them unconditionally is leaving registers on the table in every leaf function, and a backend that never reserves them corrupts the return address of every one that calls. That is not a detail of the convention; it is a fact about the order of your passes, because whether a function calls is something you learn from the IR and something you need to know before you allocate.
The first measurement counts argument registers by name in the disassembly. This is MEASURED-ON-BYTES: words were emitted and read back.
$ python3 compback.py --run | sed -n '/ target arg registers the compiler READ/,/SO IT IS IN THE LIMITS AND/p'
target arg registers the compiler READ insns bytes
x86_64 6 of 6: rdi rsi rdx rcx r8 r9 9 31
aarch64 6 of 6: x0 x1 x2 x3 x4 x5 9 9
riscv64 6 of 6: a0 a1 a2 a3 a4 a5 9 9
§KEEP§THE SIXTH ARGUMENT IS STILL IN A REGISTER ON ALL THREE, AND
THAT IS THE POINT OF THE EXAMPLE. §KEEP§IT IS ALSO WHY THE EXAMPLE
HAS SIX ARGUMENTS AND NOT SEVEN: §KEEP§THE SEVENTH IS THE ONE THAT
MOVES TO THE STACK, AND §KEEP§HOW MANY ARGUMENTS SPILL IS A FACT
ABOUT THE ABI AND NOT ABOUT THE BACKEND, SO IT IS IN THE LIMITS AND
NOT HERE.
Six of six on all three targets. And the artifact is explicit about why the example stops at six rather than seven, and the reason is the kind of thing worth internalising: the seventh argument is the one that moves to the stack, and how many arguments spill is a fact about the ABI and not about the backend — so it belongs in the limits, where a reader has to go looking for it, and not in the table where it would look like a measurement of the compiler.
Two numbers in that table are doing quiet work. The x86-64 function is 9 instructions and 31 bytes where the other two are 9 instructions and 9 bytes — four bytes each, and one instruction at four bytes apiece. The x86-64 build is spending 22 bytes on something the other two encode in nothing at all, and cb-isel already showed you what those bytes are: leaq chains doing arithmetic in the address. The ABI did not get more expensive; the instruction set did.
The measurement is also a lower bound, not an upper one, and the artifact says why in its ninth numbered limit: an operand may be printed twice by a destructive mnemonic, and a memory operand names a register that is not an argument. Counting register names in disassembly overcounts, so “6 of 6” is the weakest reading of the number and is still correct.
Now the same registers read out of the words by a program that has never heard of this course. Eleven of twelve rows disagree, and the disagreements are printed rather than summarised — because a decoder printing (undefined) is claiming the architecture defines no meaning for a word the architecture defines precisely.
$ python3 compback.py --run | sed -n '/ target word the artifact.s own reading/,/COULD BE WRONG IN THE SAME WAY AS THE OTHER THREE, SO THE ROWS/p'
target word the artifact's own reading the sibling decoder says
aarch64 8b010408 add x8, x0, x1, lsl #1 add x8, x0, x1, lsl, #1 <-- DISAGREES
aarch64 8b020449 add x9, x2, x2, lsl #1 add x9, x2, x2, lsl, #1 <-- DISAGREES
aarch64 528000ca movz w10, #6 movz w10, #0x6 <-- DISAGREES
aarch64 8b090108 add x8, x8, x9 add x8, x8, x9
aarch64 8b040889 add x9, x4, x4, lsl #2 add x9, x4, x4, lsl, #2 <-- DISAGREES
aarch64 8b030908 add x8, x8, x3, lsl #2 add x8, x8, x3, lsl, #2 <-- DISAGREES
riscv64 20a5a533 add x10, x11, x10 (undefined) (undefined) <-- DISAGREES
riscv64 20c625b3 add x11, x12, x12 (undefined) (undefined) <-- DISAGREES
riscv64 20e74633 add x12, x14, x14 (undefined) (undefined) <-- DISAGREES
riscv64 0000952e unknown op=0x2e c.add a0, a1 <-- DISAGREES
riscv64 20a6c533 add x10, x13, x10 (undefined) (undefined) <-- DISAGREES
riscv64 00009532 unknown op=0x32 c.add a0, a2 <-- DISAGREES
1 of 12 agree. §KEEP§THE SIBLING DECODERS ARE a64asm'S AND
rvasm'S OWN FILES, AND NEITHER HAS HEARD OF cbenc.py OR
cbabi.py. §KEEP§AND THE ARTIFACT'S OWN READING OF A WORD IS
`_our_render`, WHICH IS A FOURTH RENDERER AND THE ONE THAT
COULD BE WRONG IN THE SAME WAY AS THE OTHER THREE, SO THE ROWS
ARE PRINTED RATHER THAN SUMMED.
Eleven of twelve rows disagree and one agrees. Read what the disagreements actually are before you draw a conclusion, because the naive one is wrong: nine of the eleven are spelling — lsl, #1 against lsl #1, #0x6 against #6. The same word, printed two ways by two programs with no agreement on commas or on the base of a hexadecimal literal.
And two of the twelve rows are disagreements about the architecture, not about punctuation: the two this artifact reads as unknown op=0x2e and unknown op=0x32, which the RISC-V decoder from rvasm names c.add a0, a1 and c.add a0, a2. Those are the compressed forms, and a decoder that does not implement C prints unknown for a word the base ISA defines perfectly well.
And the artifact names its own weakest reader rather than only the sibling’s. Its reading of a word is _our_render, which is a fourth renderer and the one that could be wrong in the same way as the other three — it shares an author with cbenc.py. Two independent decoders agreeing is evidence; one decoder agreeing with itself is a restatement, which is the same distinction cb-isel drew when it checked seven hand-encoded words against a real assembler.
The artifact ends the section with the obligation enumerated rather than described, and the choice of a list is the content: every item is a thing a reader can go and look for in a document or in a disassembly, and a paragraph would have made five of them unverifiable.
$ python3 compback.py --run | sed -n '/ 1. the argument registers, IN ORDER/,/ORDINARY ONE/p'
1. the argument registers, IN ORDER, with their count
2. the return value register, and whether there is more than one
3. which registers are callee-saved, so the prologue knows what to save
4. the stack alignment the callee must establish, and WHO pays for it
5. whether varargs exist, because on x86-64 they consume a register AL
6. the unwind shape, so a debugger can walk the frame
§KEEP§AND ITEM 5 IS THE ONE THAT BITES, BECAUSE AL IS AN ARGUMENT
REGISTER THAT BECOMES THE VECTOR-COUNT REGISTER, SO A VARARGS CALL
CANNOT BE ENCODED BY THE SAME PROLOGUE AS AN ORDINARY ONE.
Two of those six deserve more than the list gives them, because both are the same mistake wearing different clothes.
Item 4 — the stack alignment, and who pays for it. This is a question about who is responsible, not about a number, and it is the item on this list whose answer the artifact names but does not resolve. If the caller must establish the alignment, the callee may assume it. If the callee must establish it, every leaf pays a prologue to fix something it never disturbed. Which of those two it is has to be read out of the document, because nothing on this host measures it — and getting it backwards is the same failure as the rbx clause at the top of the page, arrived at from the other end: a frame in every function, for a condition the function never created.
Item 5 — varargs, and the register that changes jobs. On x86-64, al is an argument register. It is also the vector-count register, and it is where the number of vector arguments goes. So a varargs call cannot be encoded by the same prologue as an ordinary one, and a backend whose prologue is a fixed block of instructions has a real problem the moment it selects a call it did not expect.
That is the sentence to carry out of the page. The ABI is fixed before instruction selection starts. Not before code generation, not before register allocation — before selection, which is the very first of the three questions this course asked. A selector that needs a scratch register the ABI forbids does not have a lower-priority problem to solve later. It has a constraint it was never told about, and the evidence of it will be a miscompiled function rather than a diagnostic.
Item 6 is the one that belongs to the next page. “The unwind shape, so a debugger can walk the frame” is a whole discipline in its own right, and x86-unwind and a64-unwind teach it. What a backend must do about it is small and worth stating: emit the prologue in the shape the unwind information describes, and emit the unwind information. A frame you cannot walk is still a frame — it just costs you your stack traces.
Set the quoted tables beside each other and one structural difference shows up that is not about register numbers at all: on x86-64 the last argument register is r9, on AArch64 it is x5, and on RISC-V the argument registers are a0 through a7 — eight, not six. That is why this course’s example function has six arguments and not seven or eight: it is a number that fits in all three files without a stack argument on any of them, and the artifact says so in the block above.
Here is the honest summary of what was measured and what was not, because the page’s numbers and the page’s knowledge come from different places and a reader deserves to know which is which:
That last pair of absences is the reason the section stops at six instructions of reading registers. The measurement proves that the compiler read six arguments from the six registers the document names. It does not prove that a call to a function written by someone else would arrive correctly — and that is a different claim, needing a different experiment, and one this host cannot run for two of the three targets.
One more thing to notice while the tables are in front of you, because it is the kind of defect that only shows up if you read the block rather than the block’s point. The third column of the reserved table ends mid-word on all three rows — %rbp is th, the link registe, the return address re. The sentences are not short; they are clipped, and the full text is in cbabi.py’s RESERVED table. Nothing in the recorded output says so, and that is a small failure with a large shadow: a quotation that ends mid-word cannot be distinguished from a complete one, so a reader who quotes %rbp is th from this report has quoted a document they have not finished reading.
Reading the full sentence is worth it, because it turns item 5 from a note into a citation. The untruncated x86-64 reserved-set text reads: “%rsp is the stack pointer; %rbp is the frame pointer when used; %rax holds the number of vector registers used by a variadic call”. So the artifact’s own quotation names rax as the register a variadic call is different because of — which is exactly why item 5 says a varargs call cannot be encoded by the same prologue as an ordinary one, and why the reserved-set row above lists rax as reserved only for a function that makes a call. The clip hid the sentence that ties the two halves of the section together.
cbreg.py, write down for x86-64: the six argument registers, the reserved set, who establishes stack alignment, and which register varargs overwrites. Then open cbreg.py and find where it would need each of those answers. Every one of those four places is a place where the answer has to already exist, and the exercise is not the writing down — it is discovering that you cannot even express “save the registers you were given” until you know which registers you were given.)a6 and a7 free, keeps it in a register. That difference is the ABI, not the backend, and it is why the artifact deliberately stops at six: a table that ran off the end of the register file would have been a measurement of the compiler when it is a fact about three documents.)al collision in your own toolchain.(Compile a variadic function taking a double, and disassemble the caller. On x86-64 the number of vector arguments goes into the low byte of rax before the call — the register that is also the sixth argument register. Now find where a naive prologue would put a saved-register copy of rax and check whether it is clobbering the count. This is a real bug in real hand-written assembly and it is item 5 of the list above, and thirty seconds with a disassembler is enough to see it.)rvdec.py over the RISC-V words and a64dec.py over the AArch64 ones, and put the two renderings side by side. Then sort the disagreements into spelling, missing extension, and wrong value. Expect spelling to dominate, expect at least one row to be an extension the decoder does not implement, and expect your own count of “agree” to be lower than the cross-check’s, because the cross-check normalises before comparing and you will not. Then write down which category each disagreement belongs to — a count with no classification is the failure 2.2.114 in this collection found, and it is the reason the artifact prints the rows.)Backwards, cb-regalloc is the page this one constrains, and the link between them is one sentence: register allocation assigns names to values, and it needs to know the names it is not allowed to hand out. That page’s finding — that the metric points the wrong way under a whole class of bugs, because a wrong allocator spills fewer — has an exact sibling here: a backend that learns the ABI late spends registers it did not have to spend and reports a frame it did not plan. a64-frame and x86-frame teach what a frame is once one exists; this page is about the order that decides whether one does.
Across architectures, this page deliberately teaches no convention. x86-calling, a64-aapcs and rv-calling each teach their own in full, and rv-noflags is where the RISC-V section showed that a calling convention can be defined by the register that is not there. What a backend must know before it allocates is the same question on all three targets, and it is the question none of those three pages answers.
Forwards, cb-verify takes every decision this course made — selection, allocation, scheduling, and the frame this page is about — and reads them back out of a stripped binary with no symbol table. Its second finding is that a prologue is visible because it is a frame and not because it saves registers, which is the direct consequence of the clause at the top of this page: the frame is the observable, the saved registers are the allocator’s business, and only the first of the two is recoverable from a disassembly alone.
Outward, two limits, both printed in the artifact rather than implied. The argument-register count is a LOWER BOUND, because a destructive mnemonic can print an operand twice and a memory operand names a register that is not an argument — so “6 of 6” is the weakest reading of the number and the correct one. And nothing about a call was executed: no aarch64-linux-gnu-ld, no riscv64-linux-gnu-ld, no emulator, so the bytes were emitted and read back and that is all. What this page measures is that the compiler read the registers the documents name — which is a real thing and a narrower one than “the call works”, and it is worth being able to say which of the two you have.
And the reusable lesson, which is the second time this course has needed it in this form: a constraint you consulted after the pass that depends on it has already run is not a constraint you violated, it is a cost you paid without knowing. The selector that dispatched on operand kind, the allocator that read half-open ranges, and the backend that learned the ABI second all produced correct-looking output and all cost something nobody printed. Print the frame. Name the policy. State the order. A number without the decision behind it is not a result.