Everything in the last four concepts happened while your function was running. Every instruction in it executed, in order, and the only person who could see it was the CPU. Now imagine a debugger, a profiler, a garbage collector, or an exception unwinder: something that arrives after the frame is gone and has to reconstruct where the saved registers were.
It cannot. The CPU is gone, the registers are gone, and the stack below sp is, on AArch64, the region concept 3's standard forbids anyone to read. So the frame has to describe itself, in advance, in a language that is not assembly.
That is Call Frame Information, and it is a genuinely second language: it has its own vocabulary, its own encoding inside .eh_frame, its own readers, and on this architecture it is required to agree with a named register in the ABI. The interesting measurement is the ratio between the two languages, and the second interesting thing is that the shape this course was written to find is not the shape the compiler produces.
Here is nonleaf at -O0. Left: the instructions. Right: the directives that describe the same frame to something that was not there when the function ran.
INSTRUCTIONS CFI DIRECTIVES
sub sp, sp, #48 .cfi_startproc
stp x29, x30, [sp, #32] .cfi_def_cfa_offset 48
add x29, sp, #32 .cfi_def_cfa w29, 16
stur x0, [x29, #-8] .cfi_offset w30, -8
str x1, [sp, #16] .cfi_offset w29, -16
ldur x8, [x29, #-8] .cfi_def_cfa wsp, 48
ldr x9, [sp, #16] .cfi_def_cfa_offset 0
subs x8, x8, x9 .cfi_restore w30
str x8, [sp, #8] .cfi_restore w29
ldr x0, [sp, #8] .cfi_endproc
ldur x1, [x29, #-8]
bl inner
ldr x8, [sp, #8]
add x0, x0, x8
ldp x29, x30, [sp, #32]
add sp, sp, #48
ret
Both columns describe one frame. The left is four instructions; the right is ten directives. Read the correspondence line by line, because it is the whole model:
.cfi_def_cfa_offset 48 is sub sp, sp, #48 in English. The CFA — Canonical Frame Address — is defined as a register plus a number. Initially the register is wsp and the number is 0: the canonical frame address is where the stack pointer was at the last point the table could see..cfi_def_cfa w29, 16 is add x29, sp, #32 plus arithmetic. The CFA rule has just changed: it is now x29 + 16, and the 16 is the distance from the frame record to the CFA. The number and the register can change independently, and that is the entire expressive power of the format..cfi_offset w30, -8 is the fact that x30 is in a register and was written to the stack. It is not an instruction; it is a claim about an instruction, made elsewhere, in advance..cfi_def_cfa wsp, 48 then .cfi_def_cfa_offset 0 is the epilogue — two directives to say “the stack pointer is moving back”.And the part that makes this architecture's version clearer than x86-64's: the register in .cfi_def_cfa w29, 16 is not a convention the compiler chose. It is r29, the register AAPCS64 §5.2.3 names as the frame pointer. On x86-64 the CFI reports a convention; here it is reporting which of the mandated levels of frame-record conformance the platform required, and a reader can go and look it up. The second language has a vocabulary the first language is quoted from. Read the encoding course's cross-check concept and then come back: that is the same property the first language has when a bit pattern is checked by two readers.
The question this concept exists to answer: how many CFI rows does a given function need, and how does that change with optimisation? A row is one DW_CFA_advance_loc — the unit that matters, because the unwind table is a piecewise-constant description of the frame and a row is the interval over which it is constant.
So the plan and the brief for this course both offered this as the characteristic example:
A predicted shape that the measurement does not contain is a retraction and not a nuance. Here is the whole table, all twenty-five functions, so the surprise is measured rather than narrated — and so you can find the real shape yourself before it is named:
$ python3 a64abi.py --run | sed -n '/function -O0 insn/,/va 110/p'
function -O0 insn/rows/rules/bytes -O2 insn/rows/rules/bytes what happened
i9 34 / 2 / 0 / 20 13 / 0 / 0 / 16 shrank -4 bytes, rows -2
m18 61 / 2 / 0 / 24 23 / 0 / 0 / 16 shrank -8 bytes, rows -2
c9 21 / 4 / 2 / 36 19 / 4 / 2 / 36 identical
use_big 13 / 4 / 2 / 36 11 / 4 / 2 / 36 identical
leaf_spill 22 / 2 / 0 / 20 14 / 2 / 0 / 20 identical
nonleaf 17 / 4 / 2 / 36 14 / 4 / 2 / 36 identical
leaf_vec 18 / 2 / 0 / 20 10 / 2 / 0 / 20 identical
many 84 / 4 / 2 / 40 67 / 4 / 12 / 64 TABLE GREW +24, rows +0
fmany 81 / 4 / 2 / 36 54 / 4 / 10 / 76 TABLE GREW +40, rows +0
va 110 / 3 / 1 / 32 68 / 2 / 0 / 24 shrank -8 bytes, rows -1
Two functions grew. And neither of them grew in rows. That is the real finding, and it is better than the shape that was predicted:
many holds its 4 rows from -O0 to -O2 and its table grows from 40 to 64 bytes, because at -O0 it saves x30 and x29 and at -O2 it saves ten callee-saved registers as well — and every single one of them needs a DW_CFA_offset that says where to find it. Twelve rules at -O2 against two at -O0.fmany does the same thing in the vector bank: 36 to 76 bytes for the same 4 rows, because d8–d15 became eight more rules. (Two of them are the exact DW_CFA_offset opcode and eight are the extended form — same information, different encoding, which is precisely why the rules column counts DW_CFA_offset* and the section 9B census counts only the exact opcode: 28 at -O2 rather than 36.)va is the opposite, in both columns: 3 rows and 32 bytes at -O0, 2 rows and 24 bytes at -O2. The -O0 prologue is a str x30, [sp, #304], an add x8, sp, #112 and a str x8, [sp, #40] — three separate changes of the frame. The -O2 prologue is one sub sp, sp, #224 and one add sp, sp, #224, and a frame that is entered once and left once needs a description of the interval between them and almost nothing else.So the summary sentence of this concept: the row count says how many TIMES the frame changes, and the rule count says how much STATE it has, and only the second one went up. And the harness asserts the direction of both and not their values, because every one of these numbers is a property of clang 21.1.8 and not of a standard. That is retraction R7, printed in full by the artifact in section 11, and it is the second of the two predictions this course made about this table that the measurement refused.
Row counts are per function. The cost that matters is per object, and here the number is the most quotable thing on this page:
$ python3 a64abi.py --run | grep -A5 'eh_frame ratio'
lvl .text bytes .eh_frame ratio functions
O0 2964 800 27.0% 25
O1 1776 792 44.6% 25
O2 1872 792 42.3% 25
Os 1796 792 44.1% 25
The unwind table is a fixed fraction of the code and it does not shrink when the code does. At -O0 the object spends 27.0% of .text in .eh_frame and at -O2 it spends 42.3%, because the same 25 functions need the same 25 FDEs whether each is 34 instructions or 13. Halving the code moved the table from 27.0% to 42.3% of it. The per-function floor is a CIE reference, an FDE header and at least one row, and no amount of optimisation removes it — which is the whole information-content argument in one line: the cost of the second language is a function of the NUMBER OF FUNCTIONS, not of the amount of code in them.
So the practical advice is measurable rather than a matter of taste: unwind tables are worth paying for in a library with many small functions and not worth paying for in one with few large ones, and a compiler backend that cannot turn them off is paying a per-function tax on every function it emits. gcc and clang both have a flag for it, and this table is the number the flag is worth.
Everything above was read out of .eh_frame with struct.unpack_from. Here is what llvm-readelf-21 --unwind says about the same bytes, printed as it prints them, because a second reader that is only summarised is not a second reader:
$ llvm-readelf-21 --unwind abi_O2.o | head -24
.eh_frame section at offset 0x7e8 address 0x0:
[0x0] CIE length=16
version: 1
augmentation: zR
code_alignment_factor: 1
data_alignment_factor: -4
return_address_register: 30
Program:
DW_CFA_def_cfa: reg31 +0
[0x14] FDE length=16 cie=[0x0]
initial_location: 0x0
address_range: 0x34 (end : 0x34)
Program:
DW_CFA_nop:
DW_CFA_nop:
DW_CFA_nop:
Three lines in that header are the concept, and the third is the hinge back to concept 2:
return_address_register: 30 is x30, and the CFI is naming it. This is the register the frame record's highest addressed double-word holds, per AAPCS64 §5.2.3 — so the second language is not inventing a fact, it is reporting a mandate.data_alignment_factor: -4 is why every DW_CFA_offset in the table is negative and small (-8, -16). CFI offsets are in units of the data alignment factor, not bytes, so an offset of -8 means -32 bytes. A reader who reads a DW_CFA_offset as a byte offset is wrong by a factor of four on every register in the table, and a debug session that prints x30 at rsp - 8 instead of rsp - 32 will hand you a value from somewhere else in the frame and it will look like a real number.DW_CFA_def_cfa: reg31 +0 — reg31 is the stack pointer, and it is the same number 31 that concept 2 showed being three different things in three different instruction slots. There is one number, and in the instruction file it means sp in a destination and zero in a logical source, and in the unwind table it means the stack pointer and nothing else. That is why the two pages are adjacent in the course, and it is the sharpest possible demonstration that a field's meaning is a property of the language that owns it.And one thing that is worth noticing by its absence: there is no row for the flags.Concept 4 counted zero mrs/msr of a flag register, and here is the consequence at the other end: on x86-64 the DW_CFA encoding has no register for the flags either, so an unwinder on both architectures simply cannot restore them — and on x86-64 that is a loss, because PUSHFQ put them in the frame. Same gap in the format, two different reasons for it.
Everything on this page is a count of things in a file. No unwinder was run — there is no AArch64 machine, emulator or linker on the build host, so not one instruction in this course has been executed. So the claims here are that the table contains the description, not that any tool used it correctly. The x86-64 unwinding concept is where you will find the other half of that: it ran backtrace() on real hardware and got seven frames with the flag and one frame without it, and that is a result this architecture cannot produce, at all, on this host.
aarch64-linux-gnu twice, with and without -fno-asynchronous-unwind-tables, and compare the .eh_frame size against the .text size in each. (Expect .eh_frame to vanish and the ratio to go from 42.3% to nothing at -O2, and expect the per-function cost to be roughly constant as you add functions: 792 bytes for 25 functions is about 32 bytes each, and 25 more functions will cost about 25 more. Then add the flag back and try it at -O0, where the ratio is 27.0% rather than 42.3% — the same table is a much better deal when the code it describes is long.)DW_CFA_offset as bytes and watch it be wrong by four. Take the nonleaf prologue, note .cfi_offset w30, -8, and compute the address as x29 - 8. (Expect garbage: the offset is in units of the data alignment factor, which is -4, so the register is at x29 - 32. Then confirm it against the instruction stream — stp x29, x30, [sp, #32] puts x30 at the higher address, 16 bytes above x29 itself, and the CFA is x29 + 16, so x30 is at CFA minus 32. Three different descriptions of one 16-byte store, and they agree.)i9 at -O2 has none, and nine functions have none — and check what the FDE says. (Expect the minimum 16-byte FDE: a header, a CIE pointer, and a padding DW_CFA_nop instead of rows. That is not a broken table; it is a function that allocates nothing, so there is nothing to describe, and it is the floor the 27.0%-to-42.3% argument is built on. Now find the function whose table grew — fmany, 36 to 76 bytes, same four rows — and you have the whole concept in two lookups.)Backwards, all four, and specifically concept 4: the callee-saved registers are exactly what a second description has to record, and many growing from 40 to 64 bytes at a constant four rows is a direct consequence of it saving ten more registers. Concept 2 is the hinge — reg31 in the CFI and sp in the instruction field are the same five bits with different owners. Concept 3 is the subject being described a second time, and the four marked prologue lines there are the four lines the directives on this page describe. Concept 1 is the reason the CFA is defined in terms of a stack pointer at all.
Sideways, three neighbours that own the edges. The dynamic-linking course is where a frame gets walked for the first time in a real program, because that is what the loader does before main. The stack canary is a value in the frame with no unwind entry of its own — and a reader who has read this page can now say precisely why a canary cannot be verified during an unwind. The x86-64 unwinding concept is the same second language with a frame pointer that moves, and it is the one place in the collection that could measure what happens when the description is missing.
Outward, and the general form is worth carrying out of here. The encoding course's two-reader cross-check is the same argument applied to bytes: two readers, and then one of them poisoned on purpose so that 100% means something. The x86-64 ABI course's verification concept is the same argument applied to a compiler, and it found 271 PUSHFQ in 9,255 real functions and had to explain 97 of them. A claim that two independent readings agree on is worth something a claim that one reading supports is not, and this concept is a claim that one reading supports — and says so.
And the last honest word: this course ends where the section's next courses begin. Everything on these five pages is checkable with a hex editor, which is the design. The parts of the chain that need silicon — whether a stp/ldp pair is free, whether a misaligned store traps, whether an unwinder reads the right slot — are somebody else's course, and the artifact's section 12 names every one of them in its own words.