Keyboard Shortcuts

Ctrl+K
Open search
Esc
Close search / dialog
↑
Back to top

The Frame in a Second Language, and the Shape That Was Not There

24 minadvancedModule 2: The Frame: What the Callee Must Ask For, and What It Must DescribeContinues from a64-save
24 min · The AArch64 Procedure Call Standard

Why this matters

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.

THE PROBLEM, IN ONE SENTENCE A stack walk answers: "for the frame at address A, where is register R?" The answer needs two facts: where the frame's base pointer is, and what offset from it R was saved at. Neither fact is recoverable from the stack on this architecture. They have to be WRITTEN DOWN.

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.

The model: one frame, two descriptions, side by side

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:

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 measurement, and the shape that was not there

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:

THE SHAPE THIS COURSE WAS WRITTEN TO FIND "a function that needs 3 rows at -O0 and 9 at -O2 is saying something" And the measurement says: no function in this corpus does that. No function EVER gains a row. Row counts are 0, 2, 3 or 4 at every level, and nine functions go from 2 rows to 0.

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:

THE INFORMATION CONTENT OF AN UNWIND TABLE It is neither the frame nor the row count. It is the number of points at which the frame changes shape TIMES the number of registers whose location it states. And optimisation moves those two in OPPOSITE DIRECTIONS in the same function: many rows 4 -> 4 rules 2 -> 12 fmany rows 4 -> 4 rules 2 -> 10 va rows 3 -> 2 rules 1 -> 0 i9 rows 2 -> 0 rules 0 -> 0

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.

And the other half of the ratio

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.

Worked: the second reader's own account, printed verbatim

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:

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.

What cannot be measured here

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.

Apply it

  1. Turn the tables off and count what you saved. Compile a small C file for 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.)
  2. Read a 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.)
  3. Look for the row count to lie to you. Find a function in the corpus whose CFI has zero rows — 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.)

Connect

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.

render_lesson_js(&mut page)