Concept 1 said which register argument four goes in. Concept 3 said how a function gets memory. Neither said what happens to a register's contents across the call — and that is the question a function actually has to answer, because a value the compiler put in a register before a call is dead unless somebody saves it.
The split is a bargain, and it is the same bargain on every architecture:
Why the split exists at all: a fixed rule is the only kind of rule that can be checked. “The callee preserves x19–x28 unless it uses them, in which case it restores them” is a sentence two compilers can both obey without exchanging a word. “The callee preserves the registers it used” is not, because each side would have to know what the other did.
On this architecture the list is short, the vector half has a clause that everybody drops, and two of the rules people quote for AArch64 do not exist. All three of those are measured below.
Quoted, with section numbers, because three of these four rows are places a first draft of this course was wrong:
AAPCS64 6.1.1 Registers r19-r29 and SP are Callee-saved. All
64 bits of each value stored in r19-r29 are
Callee-saved.
6.1.2 Registers v8-v15 are Callee-saved and the
remaining registers (v0-v7, v16-v31) are
Caller-saved. Additionally, only the bottom 64
bits of each value stored in v8-v15 need to be
Callee-saved; it is the responsibility of the
caller to preserve larger values.
5.1.1 The role of register r18 is platform specific.
If a platform ABI has need of a dedicated
general-purpose register to carry inter-procedural
state then it should use this register for that
purpose. If the platform ABI has no such
requirements, then it should use r18 as an
additional Caller-saved register.
5.1.1 The N, Z, C and V flags are undefined on entry
to and return from a public interface.
Four rows and four different things, so read them one at a time.
x19–x28, plus x29, x30 and SP — all 64 bits. Six rows collapsed into one, and the “all 64 bits” is not decoration: it is what separates this from the vector row, which is a half-clause. Note also that SP is in the list, which is why concept 3's add sp, sp, #48 is a restoration and not a detail.v8–v15 — and ONLY THE BOTTOM 64 BITS of each. This is the row that gets dropped, and dropping it changes what you expect to see in a disassembly. It is not that v8–v15 must be preserved; it is that the top 64 bits of them are the CALLER'S problem. A compiler that saves q8 when it only needed d8 has obeyed the standard and wasted a store, and a compiler that saves d8 when it needed q8 has broken it. The clause is a permission and an obligation at once, and it is the only place in the AAPCS64 where a register is partly callee-saved.r18 is platform specific — the standard does not say what it is, it says the platform ABI specification must say. And it gives the platform the choice: a dedicated register for inter-procedural state, or an additional caller-saved temporary. On Linux, the platform ABI specification does not reserve it, so it is an ordinary caller-saved temporary. The two clauses are not alternatives in the abstract; they are a decision the platform makes, and this course's platform made the second one.NZCV is UNDEFINED across a public interface. Undefined is not “preserved” and not “destroyed”: a callee may leave the flags in any state, and a caller may not read them across a call. Three words, and all three matter.And a row that is not in the document and that everybody quotes anyway: there is no flag register to save. More on that below, because the measurable consequence of the difference between undefined and preserved is a count of zero, and a course that asserted a cost without printing the zero would be describing an optimisation rather than a fact.
The corpus has two functions built specifically to force this question. many keeps twelve values live across twelve calls; fmany keeps ten doubles live across ten calls. Neither can keep them in the caller-saved temporaries, and the only way to find out what a compiler does when it has to reach further down the file is to write one that has to.
$ python3 a64abi.py --run | sed -n '/O0 many gpr:/,/Os fmany/p'
O0 many gpr: saved none restored none
O0 fmany gpr: saved none restored none
O1 many gpr: saved x19-x28 restored x19-x28
O1 fmany gpr: saved none restored none | fp: saved d8-d15 restored d8-d15
O2 many gpr: saved x19-x28 restored x19-x28
O2 fmany gpr: saved none restored none | fp: saved d8-d15 restored d8-d15
Os many gpr: saved x19-x28 restored x19-x28
Os fmany gpr: saved none restored none | fp: saved d8-d15 restored d8-d15
Two rows are the whole concept, so read them carefully.
many at -O2 saves x19–x28 — all ten — in five stp pairs, and restores them in five ldp pairs. Twenty instructions, ten of them pairs, to hold twelve values across twelve calls. And the reason it needs ten callee-saved registers is a count and not a preference:
That is the useful thing about the count: it says the callee-saved registers exist to absorb an overcommitment, and it predicts what a function with seventeen live values across a call would do — save ten callee-saved registers and spill two to the stack, because there are only ten. A reader who thinks of x19–x28 as ten preferred temporaries will predict wrong in both directions.
And fmany saves d8–d15 — the 64-bit view, not q8–q15. The compiler obeyed the “bottom 64 bits” rule exactly. A reader who assumed it would save 128-bit registers would read the disassembly and conclude the compiler had made an error — and the error would be in the reading, not in the code. This is the one place in the whole course where doing less work is the correct behaviour, and it is invisible in the disassembly except as the absence of a wider instruction.
The platform register. The plan for this course said r18 “is preserved only across a call that uses the stack — measure how often the compiler saves it.” The measurement is the way to find out, and it is this:
$ python3 a64abi.py --run | sed -n '/THE PLATFORM REGISTER/,/^$/p'
[MEASURED] Occurrences of w18/x18 in the whole corpus at all four
levels: 0.
Zero. Not because the compiler is lazy, but because there is nothing to save. Linux does not reserve x18, so the AAPCS64's second clause applies: it is an additional caller-saved temporary, and a caller-saved temporary is not saved by anybody. A conditional rule that a compiler never implements is indistinguishable from a rule that does not exist, and the measurement is the only thing that tells them apart. The rule in the plan is an AArch32 and Windows x86-64 idea: on 32-bit Arm, r13 is the platform register and software that uses it must save it around a call that might touch the stack, and on Windows x64 the thread information block lives in the GS segment rather than in a register at all. Retraction R5, and it is retracted rather than softened.
The flags. The AAPCS64 says NZCV is undefined across a public interface, and:
$ python3 a64abi.py --run | sed -n '/THE FLAG REGISTER, OR THE ABSENCE/,/^$/p'
[MEASURED] mrs/msr of a flag register in the corpus: 0. There
are none, and there is no assembly mnemonic for "save the
flags" on this architecture, because the flags live in no
register that a normal instruction can name. x86-64's answer
is PUSHFQ, one instruction that writes 8 bytes onto the
stack, and the x86abi course counted 271 of them in 94
functions. AArch64's answer is that there is nothing to
count: the flags are a side effect of a bit in an
instruction, and the sentence that makes them not your
problem is a sentence rather than a store.
Read that last sentence twice, because it is the difference between an ABI and a habit. On x86-64 the flags are a register, so preserving them is a memory operation a compiler emits and a reader can count. On AArch64 the flags are a side effect of a bit in the instruction word — the S and C bits of the condition field, which is bits[15:12] in a data-processing instruction and nowhere else — so there is nothing to save and no instruction to save it with. The measurable consequence is a count of zero, and a course that asserted a cost without printing the zero would be describing an optimisation rather than a fact. That is retraction R6.
The plan for this course said the AAPCS64 preserves the lower 64 bits of v0–v7. It preserves the lower 64 bits of v8–v15. This is retraction R4, and it is worth dwelling on because the consequence is not a detail:
v0–v7 are the argument and result registers.Concept 1 measured it: a double argument arrives in d0–d7, and so does a double result. They are entirely caller-saved.v8–v15, against thirty-two in the whole file. There are no v18–v31 callee-saved registers, and the reason is a count: fmany keeps ten doubles live across ten calls and saves eight of them, so the two that do not fit have to go somewhere else — and there is nowhere else in the vector bank, so they are recomputed or spilled.The pattern across all four rows of the model is worth stating once, because it is the thing a reader should carry out of this page: on this architecture almost nothing is callee-saved, and every one of the exceptions is a place where the cost is paid by a pair of instructions and a stack slot. Ten general registers and eight vector registers out of 64, plus the stack pointer, the link register and the frame pointer. Everything else is a temporary you are free to destroy, and the price of the arrangement is that a function with a lot of live state pays for a save area whether it wants to or not.
Now the practical consequence, which is the reason a backend author reads this page. To preserve a caller-saved value across a call, a compiler must either spill it to its own frame — and on AArch64 a frame is a request, so the request is made once and can hold all of them — or move it to a callee-saved register, which costs the pair of instructions. There is no third option, and there is no red zone to spill into for free. A compiler that chose spill-always would be correct and would pay for a frame in every function; a compiler that chose callee-saved-always would be correct and would spend twenty instructions in many. many did the second at -O2 and the first at -O0, and the difference in instruction count is 84 to 67 in the other direction — at -O0 clang spilled everything and needed no callee-saved registers at all, and at -O2 it used the registers and needed no spills.
double and a long, for aarch64-linux-gnu at -O2. (Expect it to reach for d8–d15 and x19–x28, and expect the vector saves to be d-width and not q-width. If they are q-width, look again — the compiler may have a live 128-bit value, and the way to tell is whether anything writes the top half. A q save where only a d is live is a wasted store, and a d save where a q is live is a bug.)long live across a call. (Expect: ten registers saved, the rest spilled to the frame, and a frame that is a multiple of 16 because of the spill slots and not because of the call. Now count the caller-saved temporaries the same function uses — seven, x9–x15 — and check the arithmetic in the box above. The number is the rule.)x18 to assemble and to behave as an ordinary caller-saved temporary on Linux: put a value in it, make a call, and read it back in hand-written assembly, and it will not survive, which is legal and is the point. And expect there to be no mnemonic for the flags at all — there is no pushfq equivalent, and searching the assembler for one is a five-minute experiment that ends in a diagnostic. The x86-64 saved-registers concept is the direct contrast, and it counts 271 PUSHFQ across a corpus.)Backwards, both neighbours. Concept 3 is where the frame this page fills is created, and the stp x29, x30 that saves the frame record is itself an instance of the rule on this page — x29 and x30 are callee-saved, so they are saved in a pair and restored in a pair, and that is the only reason the prologue has the shape it has. Concept 2 is where x29 and x30 stop being letters: 29 and 30 are numbers in five-bit fields, and the AAPCS64 gives them names (FP and LR) in the same document that gives sp a name.
Sideways, and this is the sibling reference: The x86-64 saved registers has the same bargain with a different partition — six GPRs and sixteen XMM against ten and eight, and a flags register that must be pushed. The stack canary is a value in the frame for the same reason all of this exists: it must survive a call, and a frame is the only region of memory the ABI promises will. The dynamic-linking course is where the loader's own frame sits, and it is a frame like any other, built by the same rules, and it is the reason a program starts with a stack at all.
Forwards, one concept, and it is the one that uses everything above. Concept 5 is about the second description of a frame — and the callee-saved registers are exactly what a second description has to record. many at -O2 holds the same four CFI rows as at -O0 and its table grows from 40 to 64 bytes, because the unwind table has to say where twelve saved registers are, and there is no PUSHFQ-shaped shortcut for a register the architecture does not let you name.