Module 4 Lessons — Functions, the Stack, and the C Boundary

Back to the Course 3 syllabus · Practice: Module 4 exercises

This page teaches the calling convention — the AAPCS64 and Apple’s documented divergences from it — plus the machinery underneath: link register, stack frames, frame-pointer chains, and what it takes to call C from assembly and be called by it. This is the module where “my code works” stops being enough: ABI code must interoperate, and the rules are exact.


1 · Calls at the instruction level

Instruction Effect
BL label Branch and link: x30 = return address, pc = label
BLR xn Same, target from a register — function pointers, C callbacks (Exercise 4.3)
RET Branch to x30 (an xn operand is allowed; nobody uses it)
B label Plain branch — no link; also how tail calls look

That’s the whole mechanism: a call is a branch that remembers. Everything else — where arguments go, who saves what — is convention, not hardware. The hardware doesn’t even have a “call stack”; the convention builds one.

The immediate consequence: x30 holds only the most recent return address. A function that makes its own call must save x30 first — forgetting this is the classic “returns into itself forever” bug, and it’s why even the hello-world of Module 0 wrapped its bl _puts in an stp x29, x30 / ldp pair.

2 · AAPCS64: the register contract

Registers Role Across a call…
x0x7 Arguments 1–8; results in x0 (x0/x1 for 128-bit) clobbered
x8 Indirect-result pointer (large struct returns) clobbered
x9x15 Scratch / temporaries caller-saved: assume destroyed
x16, x17 Intra-call scratch (IP0/IP1 — the linker itself may use them between you and your callee) clobbered, and never carry values across a call
x18 Platform register — reserved on Apple: never touch, even as scratch
x19x28 Callee-saved: use freely, but restore before RET preserved
x29 Frame pointer preserved
x30 Link register see §1
sp 16-byte aligned at every external call preserved
v0v7 FP/vector arguments and results clobbered
v8v15 Callee-saved in the low 64 bits only preserved (low half)
v16v31 Scratch clobbered

Working rules that fall out of the table:

  • Leaf function (calls nothing): live entirely in x0x15, touch nothing else, RET. Zero stack traffic — that’s the “leaf” speed of Exercise 4.6.
  • Non-leaf: anything you need alive across your calls goes in x19+ (after saving them) or on the stack. Anything left in x9x15 across a bl is a bug that surfaces whenever the callee feels like it.
  • Ints and pointers ride the x file; float/double ride the v file (Module 5) — a mixed-signature call fills both independently.

3 · Stack frames: the shape of a function

The stack grows downward; sp points at the live edge. The canonical non-leaf prologue/epilogue:

func:
    stp     x29, x30, [sp, #-16]!   ; push FP+LR (pre-index: sp -= 16 first)
    mov     x29, sp                 ; my frame starts here
    stp     x19, x20, [sp, #-16]!   ; save any callee-saved I'll use
    sub     sp, sp, #32             ; locals, if registers don't suffice
    ; ... body — sp stays 16-aligned at every bl ...
    add     sp, sp, #32
    ldp     x19, x20, [sp], #16     ; (post-index: restore then sp += 16)
    ldp     x29, x30, [sp], #16
    ret

STP/LDP move a pair per instruction — that’s why they, and 16-byte pushes, are the idiom. After the prologue, memory looks like:

 higher addresses
┌──────────────────────┐
│  caller's frame      │
├──────────────────────┤ ← x29 points here
│ saved x29 (caller's) │      ┐ the frame record:
│ saved x30 (ret addr) │      ┘ {old FP, return address}
├──────────────────────┤
│ saved x19, x20       │
├──────────────────────┤
│ locals (32 bytes)    │
└──────────────────────┘ ← sp
 lower addresses

The frame-pointer chain. Because every frame stores the caller’s x29 at the address its own x29 points to, the saved-FP slots form a linked list threading every active call, each node sitting next to its return address. Walk it — x29 → [x29] → [[x29]] → …, reading the adjacent x30 slot each hop — and you’ve reconstructed the backtrace by hand. That’s Exercise 4.5, it’s what bt, every profiler, and every crash reporter do, and it’s exactly the skill Course 2’s hard-fault handler needs on the Cortex-M (Module 6 §7, where hardware pushes the first frame).

Recursion (Exercise 4.1) needs no new ideas: each activation pushes its own frame; depth = frames; the argument that must survive the recursive call sits in a callee-saved register or the frame — the convention scales from fact(6) to any depth the stack budget allows.

4 · Crossing the C boundary

Both directions work with zero glue if and only if the convention is respected — the compiler assumes it blindly:

Assembly called from C (Exercise 4.2): export _myfunc, take arguments as x0x7, return in x0, preserve the callee-saved set, keep sp aligned. The C side just declares the prototype — size_t my_strlen(const char *s); — and calls.

C called from assembly (Exercise 4.3): load the target (or receive the function pointer), marshal arguments into x0–…, BLR, and assume every caller-saved register died. Anything precious lives in x19+ around the call.

Mixed builds are just clang harness.c routine.s -o test — the driver assembles and compiles as needed and links both.

TipDebugging ABI bugs

Convention violations don’t fail where you made them (Module 0 §8) — a clobbered x19 crashes the caller, later, somewhere unrelated. The systematic hunt: break at your function’s entry and exit, register read the callee-saved set at both, and diff. Five minutes of lldb beats an afternoon of staring.

5 · The Apple divergences

Apple documents its ABI as AAPCS64 with deltas (Writing ARM64 code for Apple platforms — the reference this course keeps open). The ones that bite:

  • x18 is reserved. The OS may use it at any moment. Never read, never write, not even briefly.
  • Variadic functions pass all variadic arguments on the stack, in 8-byte slots — unlike Linux AAPCS64, where the first eight still ride in registers. So printf("%s %ld\n", s, n) takes the format in x0 but s and n at [sp] and [sp, #8]. Register-passed variadics — every Linux book example, Smith’s included — print garbage on the Mac. Exercise 4.4 demonstrates both ways deliberately; this is the single most common porting failure in A64 materials.
  • 16-byte SP alignment at every external call — enforced by hardware on SP-based accesses, and the crash lands inside the callee (Module 0 §8’s third signature).
  • No stable raw-syscall ABI. Linux’s svc #0 with a syscall number is a supported interface; on macOS the numbers are private and can change. The OS interface is libc: _write, _read, _exit, _printf. Smith’s ch. 7 syscall material is read for concepts (what a system call is, user/kernel crossing); the code pattern to internalize is “call libSystem like any other function.”

The same discipline — “the documented ABI is the interface, not what happened to work” — is exactly what CMSIS and the AAPCS32 give you on the Cortex-M side in Module 6.

6 · Cost model: what the convention charges

The AAPCS isn’t free, and Exercise 4.6 measures its price tag:

  • A leaf can be nearly call-overhead-only: bl, a few instructions, ret — modern predictors even hide most of the branch cost via the return-address stack.
  • A non-leaf that saves/restores a full callee-saved set pays ~10 paired memory ops per call — trivial per call, real when a hot loop calls 10 M times.
  • The compiler’s answer is inlining (Exercise 2.3 showed it) — which is also why static inline helpers cost nothing (Module 7 §6), and why “why is my per-sample ISR budget gone?” in Course 2 is often answered by an un-inlined helper inside the ISR: on the Cortex-M4, the hardware pushes a frame’s worth of registers before your handler even starts (Module 6 §7).

Where the exercises hook in

Lesson section Exercise
§1–3 frames, recursion 4.1 — recursion with real frames
§4 assembly under C 4.2 — my_strlen vs. libc
§1, §4 function pointers, BLR 4.3 — calling back into C
§5 variadics 4.4 — the variadic gotcha
§3 frame chain 4.5 — walking the frame chain
§6 cost model 4.6 — leaf vs. non-leaf cost

Reference shelf: Apple’s ABI page (authoritative for §5 — especially “Pass arguments to functions correctly”); the AAPCS64 document itself (Arm’s aapcs64 on GitHub) when a corner case matters; Smith 6, 7, 9 and Plantz 11, 14–15 (stack frames, subfunctions) as book-paced retellings.