Keyboard Shortcuts

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

No Red Zone, and the Two Instructions That Is Worth

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

Why this matters

The x86-64 frame concept has a surprise in it, and if you have read that course you already know the shape of this one. On x86-64 a function can put a local in the 128 bytes below the stack pointer without moving the stack pointer at all, because the System V ABI promises that an interrupt will not touch those bytes. The region is called the red zone. A leaf function with three locals on x86-64 is 8 instructions and does not move %rsp once.

AArch64 has no such promise, and the standard does not merely omit it — it says the opposite, in the same section that states the alignment rule:

AAPCS64 5.2.2.1  Universal stack constraints, at all times
        during the execution of a thread T:

          - No thread is permitted to access (for reading or for
            writing) the inactive region of S.

        and, two bullets later, the alignment rule that every
        other concept in this course depends on:

          - SP mod 16 = 0.  The stack must be quad-word aligned.

        ... where the inactive region of T's stack is the area of
        memory denoted by the half-open interval [T.limit, T.SP).
                

Accessing it is forbidden, not merely unwise — and the sentence is in the same list as the alignment rule, which is the section that defines what a stack pointer owes the rest of the system. So there is no region of memory a function may use without first telling the world it is using it, and the way you tell the world is by moving the stack pointer.

That is not a small inconvenience and it is not a style preference. It is the reason the prologue of essentially every AArch64 function that has locals is two instructions longer than the x86-64 equivalent, and it is measurable in a corpus, at four optimisation levels, with no clock involved. This concept is the measurement.

The model: the frame is a request, and a request is visible

Think of the stack pointer as a published fact. Other code can read it, and an interrupt handler will use it to find your locals. AArch64's rule is: everything at or above SP is yours and is described; everything below SP belongs to somebody else. So to get memory you must move SP down, which is a publication, and to give it back you must move SP up, which is a retraction of the publication.

And the prologue has to be a linked list, because the AAPCS64 mandates it in §5.2.3:

AAPCS64 5.2.3  Conforming code shall construct a linked list of
        stack-frames. Each frame shall link to the frame of its
        caller by means of a frame record of two 64-bit values
        on the stack (independent of the data model).  The frame
        record for the innermost frame (belonging to the most
        recent routine invocation) shall be pointed to by the
        frame pointer register (FP).  The lowest addressed
        double-word shall point to the previous frame record and
        the highest addressed double-word shall contain the value
        passed in LR on entry to the current function.

        ... and in the register table, 5.2.3's own FP:
           | r30 | LR | The Link Register.
           | r29 | FP | The Frame Pointer
           | r19...r28  |   | Callee-saved
                

Three claims in one sentence, and each one is a design decision rather than a convenience. The frame is a linked list, so it can be walked by arithmetic instead of being searched. The record is two 64-bit values, ordered — the lowest address holds the previous frame record and the highest holds the return address from LR — so the order of a stp is not arbitrary. And the standard names the register: r29 is FP and r30 is LR, which is why the prologue below can be written without a comment and a reader can look up whether the compiler kept the promise. Here is the whole of it, in the disassembly of one function:

$ llvm-objdump-21 --triple=aarch64 -d abi_O0.o | sed -n '/<nonleaf>/,/ret/p'
  sub sp, sp, #48          — the request
  stp x29, x30, [sp, #32]  — the frame record, two 64-bit values
  add x29, sp, #32         — and the link to the caller's frame
  stur x0, [x29, #-8]
  str x1, [sp, #16]
  ...
  ldp x29, x30, [sp, #32]  — the frame record read back
  add sp, sp, #48          — the retraction
  ret
                

Read the four marked lines as four obligations, and notice that all four are required and none of them is an optimisation decision:

  1. Allocate.48 is a multiple of 16 because of concept 1. It is also a multiple of 8, which is what x29 needs to be 8-aligned, and a multiple of 16, which is what the q registers need.
  2. Save the frame record.x29 and x30, together, in one instruction. This is the AAPCS64's frame record and it is not optional: a function that does not save it cannot be walked.
  3. Point x29 at it.add x29, sp, #32 — x29 is the frame pointer, and this instruction is what makes the frame a record rather than a region.
  4. Restore both.ldp x29, x30, [sp, #32] then add sp, sp, #48. Note the order: the frame record must be read before the retraction, because it lives in the region being retracted.

And the part that has no analogue on x86-64: add x29, sp, #32 points into the middle of the frame, not at the top of it. On x86-64 the frame pointer is set to the frame's base and the saved return address is at 0(%rbp) and everything else is at negative offsets. Here the frame record is at +32 inside a 48-byte frame and the locals are at negative offsets from it. Both conventions work; they put the number 32 and the sign the other way round, which is why a reader porting a stack-walking habit between them gets subtly wrong answers for a long time.

The census: 25 of 25, then 14 of 25, against x86-64's 7 and then 5

The frame census is the count of who asked. Twenty-five functions, four optimisation levels, and the same C compiled for both targets. A pre-indexed [sp, #-N]! counts as an allocation, because it is one — a count of sub sp, sp, #N alone misses every function that fused the allocation into a save.

$ python3 a64abi.py --run | sed -n '/O0   25 of 25/,/Os   14 of 25/p'
  O0   25 of 25 functions allocate a frame at all.
  O1   14 of 25 functions allocate a frame at all.
  O2   14 of 25 functions allocate a frame at all.
  Os   14 of 25 functions allocate a frame at all.
                

And the same number for the same C on x86-64, with the set difference, because a count of two overlapping sets is the least informative thing a reader can be handed:

  level    a64: with a frame   x86: with a frame   a64 mean   x86 mean
  O0       25 of 25              7 of 25            63 bytes    67 bytes
  O1       14 of 25              5 of 25            55 bytes    56 bytes
  O2       14 of 25              5 of 25            55 bytes    56 bytes
  Os       14 of 25              5 of 25            55 bytes    54 bytes

  O2   a64 only: big_frame cd9 inner leaf_spill leaf_vec many
                 nonleaf use_ret use_retd
       x86 only: (none)                    both: 5
                

“x86 only: (none)” is the finding. There is no function in this corpus that x86-64 frames and AArch64 does not. At -O0 the counts are 25 against 7, and at every level above it 14 against 5 — and the nine extra are exactly the ones whose C asks for memory that does not fit in a register: a volatile local, a volatile array, a value that must survive a call. On x86-64 those go into the 128 bytes below SP and cost nothing at all. Here they cost a sub and an add.

Two rows in the artifact's per-function table are worth reading, because they explain why the -O0 number is the surprising one rather than the -O2 number:

The same function, hand-written, so it cannot be blamed on the compiler

Hand-translated to be the same C, so the difference is the architecture and not clang's mood:

  AArch64  a64_leaf_noframe          3 instructions, 0 touch SP
      mul x8, x1, x0 ;  madd x0, x0, x8, x8 ;  ret

    AArch64  a64_leaf_frame          11 instructions, 8 touch SP
      sub sp, sp, #0x10              <-- the request
      str x0, [sp, #0x8]
      str x1, [sp]
      ldr x8, [sp, #0x8]
      ldr x9, [sp]
      add x8, x8, x9
      ldr x0, [sp, #0x8]
      ldr x9, [sp]
      add x0, x8, x9
      add sp, sp, #0x10              <-- the retraction
      ret

  x86-64   x86_leaf_noframe          3 instructions, 0 touch SP
      imulq %rsi, %rax ;  addq %rdi, %rax ;  retq

    x86-64   x86_leaf_frame           8 instructions, 6 touch SP
      movq %rdi, -0x8(%rsp)          <-- -8(%rsp) is in the red zone
      movq %rsi, -0x10(%rsp)         <-- -16(%rsp) is too
      movq -0x8(%rsp), %rax
      addq -0x10(%rsp), %rax
      movq -0x8(%rsp), %rcx
      addq -0x10(%rsp), %rcx
      addq %rcx, %rax
      retq                          <-- %rsp NEVER MOVES
                

Three instructions with no frame, on both. Eight on x86-64 with a frame and no %rsp movement. Eleven on AArch64 with a frame and a sub and an add. That is the whole cost of having no red zone, and it is two instructions in every function that has locals — which is every function that is not a leaf of pure arithmetic.

Note the six x86-64 instructions that “touch the stack” and the fact that none of them moves the stack pointer. The count of instructions that touch the stack and the count that allocate a frame are different questions, and this corpus contains a row that answers both: AArch64 11 instructions with 8 touching the stack, x86-64 8 instructions with 6 touching it, and the 2-instruction gap is exactly the pair of sp movements.

Worked: the five ways to move SP, and the four that hide

This is the trap that produced a wrong number in the first version of this course's own census, so it gets its own table. Five forms, and four of them do two jobs in one instruction:

$ python3 a64abi.py --run | sed -n '/THE ONE FORM THAT HIDES/,/misses all four/p'
  form                     reads and writes           allocates
  stp x29, x30, [sp, #-16]! two registers, and SP      0xa9bf7bfd
  str x0, [sp, #-16]!      one register, and SP         0xf81f0fe0
  ldr x0, [sp], #16        one register, and SP         0xf84107e0
  ldp x29, x30, [sp], #16  two registers, and SP       0xa8c17bfd
  sub sp, sp, #16          nothing but SP               0xd10043ff

  [MEASURED-ON-BYTES]  Four of those five are ONE instruction that does
  two jobs, and a frame counter that only counts `sub sp` misses all
  four.  This is x86-64's `leave` in a different costume: x86 needs a
  separate `movq %rsp, %rbp; popq %rbp` to undo the frame, and AArch64
  folds the deallocation into the last load.
                

The ! is the pre-index form: writeback, so the address is computed, the access happens, and SP is updated. The [sp], #16 with no ! is the post-index form: the access happens at the current SP, then SP is updated. Between them they cover the entire lifecycle of a frame in four instructions — allocate, save, load-and-retract, pop.

Read the first row carefully, because it is the canonical AArch64 prologue and it looks like a stack-allocated pair: stp x29, x30, [sp, #-16]! is 0xa9bf7bfd, and it does three things at once — allocate 16 bytes, save the old frame pointer, and save the return address. A reader counting “does this function allocate a frame” by looking for sub sp finds nothing here, and a reader counting “how many registers does this function save” finds two and does not notice the allocation.

The mistake this course actually made is worth printing because it is the ordinary kind. The first version of the frame census counted sub sp, sp, #N and add sp, sp, #N only, and reported nine functions with no deallocation at -O2. Three of those nine were deallocating by folding the addition into the last load — ldr x0, [sp], #16. So the table said the compiler had leaked nine frames, which is a spectacular claim to make about a compiler, and the reason it is worth printing is that nine is exactly the number of functions that take a frame at all. When a census produces a number equal to another number in the same file, stop and check the census.

What cannot be measured here

Everything on this page is a count, not a duration. There is no AArch64 machine on the build host, no emulator and no linker, so not one instruction in this course has been run. The frame census is arithmetic over instructions a compiler emitted; the hand-written comparison is a disassembly of code nobody executed. Whether a stp/ldp pair is cheaper than two strs, and whether a real sub sp is cheaper than a fused one, are questions this course cannot answer and does not pretend to.The x86-64 ABI course measured what it could and named the rest; the same discipline, one architecture over, produces a page that has a ret in it and no cycle.

Apply it

  1. Count allocations correctly, and find the fused ones. Compile a C file with three or four leaf functions at -O2 for aarch64-linux-gnu and look for sp in every instruction, not just sub sp. (Expect the common prologue to be stp x29, x30, [sp, #-N]! — allocate, save the record, in one instruction with a writeback — and the common epilogue to be ldp x29, x30, [sp], #N followed by ret, where the deallocation is inside the load. Then compile the same file for x86-64 and check whether %rsp moves in a leaf with a volatile local. It does not.)
  2. Write a leaf function with a volatile local and count the difference yourself.(Expect two extra instructions and a frame on AArch64, zero extra on x86-64. Then make the local non-volatile and watch the AArch64 frame disappear as well — which tells you the frame is a consequence of the register pressure, not of the syntax, and that a red zone would have absorbed the same pressure for free.)
  3. Break the frame record and see what it costs. Write assembly that allocates a frame with sub sp, sp, #32 but never stores x29/x30. (Expect: the function works, and nothing at all happens — no diagnostic, no crash, no warning. Then read it with gdb and with a backtrace and notice that the two disagree, and that the disagreement starts at your frame. That is the whole argument for AAPCS64 §5.2.3 in one experiment, and it is the subject of the last concept.)

Connect

Backwards, one concept and one tool. Concept 2 is where 31-as-sp was measured, and it is the number this page moves: every sub sp and add sp in this concept is an instruction whose Rd field means the stack pointer rather than a register. And the 16-byte frame size is not a preference — it is concept 1's rule, and this page is where the two meet: a frame size that is not a multiple of 16 is not a smaller frame, it is an ABI violation in the prologue of a function that was called correctly.

Sideways, and this is the sharpest contrast in the whole section: The x86-64 frame has the red zone, measured by clobbering it. That course could deliver a SIGSEGV; this one has no machine to fault on. The stack canary is a value in this frame, and the reason it needs a frame at all is the reason this page exists — on x86-64 a canary could in principle have lived in the red zone. The dynamic-linking course is where the stack is first set up, and the frames you have been reading about are stacked on top of a region it owns.

Forwards. Concept 4 is the other half of the frame's contents: who has to save which registers, and the two counts of zero that are also rules. Concept 5 is what a frame looks like to something that was not there when it ran — and it is the concept where the ratio between the instructions and their description is the measurement, so read this page's four marked prologue lines and then read that page's two columns and notice they are the same four lines.

render_lesson_js(&mut page)