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… |
|---|---|---|
x0–x7 |
Arguments 1–8; results in x0 (x0/x1 for 128-bit) |
clobbered |
x8 |
Indirect-result pointer (large struct returns) | clobbered |
x9–x15 |
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 | — |
x19–x28 |
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 |
v0–v7 |
FP/vector arguments and results | clobbered |
v8–v15 |
Callee-saved in the low 64 bits only | preserved (low half) |
v16–v31 |
Scratch | clobbered |
Working rules that fall out of the table:
- Leaf function (calls nothing): live entirely in
x0–x15, 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 inx9–x15across ablis a bug that surfaces whenever the callee feels like it. - Ints and pointers ride the
xfile;float/doubleride thevfile (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
retSTP/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 x0–x7, 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.
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:
x18is 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 inx0butsandnat[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 #0with 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 inlinehelpers 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.