Assembly (x86-64, AArch64, RISC-V) Module 7 – Functions, the stack and calling conventions
Callee-saved registers and stack frames
Which registers a call must preserve on System V, Microsoft x64, AAPCS64 and RISC-V, how prologues save them, and how frame records keep stack walks whole.
What you will learn
- Classify registers as caller-saved or callee-saved under System V, Microsoft x64, AAPCS64 and RISC-V
- Diagnose a function that does not restore a callee-saved register with a register-integrity harness
- Write a prologue and an epilogue that save callee-saved registers and build a frame record
- Explain frame pointers and why Apple requires x29 to address a valid frame record
Before you start
What you’ll learn · Before you start
What you will learn
- Classify registers as caller-saved or callee-saved under System V, Microsoft x64, AAPCS64 and RISC-V
- Diagnose a function that does not restore a callee-saved register with a register-integrity harness
- Write a prologue and an epilogue that save callee-saved registers and build a frame record
- Explain frame pointers and why Apple requires x29 to address a valid frame record
Before you start
On this page
keep_two(a, b) calls step twice and needs a and b after both calls, but each call may overwrite x0 to
x17, 18 of AArch64’s 31 general-purpose register: A register a program may freely use for integer values and addresses, as opposed to a special register with a fixed job. x86-64 has 16 (rax..r15), AArch64 31 (x0..x30) and RISC-V 32 (x0..x31). More in “Registers on x86-64, AArch64 and RISC-V”, without a trace.
In a register, a value survives a call only if the callee has promised to hand that register back unchanged. On
Apple silicon, a function that broke that promise for x19 still returned the right answer, 42, and its caller’s
value in x19 was gone.
Every ABI splits its registers into the ones a callee must preserve and the ones any call may destroy, and System V, Microsoft x64, AAPCS64 and RISC-V draw the line in different places. Here you sort registers into the two classes on all four, catch a broken function with a harness that checks 18 registers after a call, read the prologue: The instructions at the start of a function that set up its stack frame before the body runs: they save the callee-saved registers the body writes, save the link register when the function makes calls (on x86-64, call has already put the return address on the stack), and reserve its stack space. and epilogue: The instructions at the end of a function that undo its prologue: they reload the saved registers (pops come in the reverse order of the pushes), give the stack space back so the stack pointer is where it was on entry, and return. clang writes for five targets, and follow the frame record: Two 64-bit values a function stores in its stack frame: the address of its caller's frame record and its own return address. Linked by the frame pointer, the records let a debugger or profiler walk every active call. that let a stack walk name every caller.
A value that must survive a call needs a callee-saved register
keep_two returns step(a) + step(b) + a * b. When the first call returns, the compiler still needs a for the
product and b for the second call; after the second call it needs a, b and the result of the first. keep.c
declares step without defining it, so the compiler cannot see which registers step touches. It relies on the
ABI’s contract instead, which puts every register in one of two classes:
- A callee-saved register: A register whose value a called function must hand back unchanged: if it writes the register, it saves the old value first and restores it before it returns. rbx on x86-64, x19 on AArch64 and s0 on RISC-V are callee-saved. holds the same value after a call as before it. A function that wants to write one saves the old value first and restores it before it returns, so its caller can keep a value there across any call.
- A caller-saved register: A register that any call may change. A caller that still needs the value after the call saves it first or keeps it elsewhere. rdi on x86-64 System V, x9 on AArch64 and t0 on RISC-V are caller-saved. may be changed by any call. A caller that still needs its value saves it before the call or keeps the value somewhere else.
Arm’s procedure call standard uses these two names; Microsoft calls the same classes nonvolatile and volatile. Both
exist because each puts the cost of saving where it is cheapest. Arguments and the temporaries of one expression live
in caller-saved registers and need no save when no call comes between their write and their last read. Values that
live across calls go in callee-saved registers, and each function saves only the ones it writes, once, at its start.
listing.sh compiles keep.c at -O2 for one target with clang -S and prints only the instructions:
keep.c
/* keep_two(a, b): a and b are still needed after the first call to step, and b after the second,
so the compiler must keep them somewhere a call cannot destroy.
long long is 64 bits on every ABI here (long is only 32 bits on Windows). */
long long step(long long x);
long long keep_two(long long a, long long b) {
return step(a) + step(b) + a * b;
} listing.sh
#!/bin/sh
# listing.sh TARGET FILE.c [FLAGS...]
# Compiles FILE.c at -O2 for TARGET with clang -S and prints only the instructions and labels:
# assembler directives, comments and blank lines are dropped, and runs of tabs become one space.
target=$1
file=$2
shift 2
case $target in
riscv64-*) set -- -march=rv64gc -mabi=lp64d "$@"; cc=clang-23 ;; # Apple clang has no RISC-V target
x86_64-*) set -- -masm=intel "$@"; cc=clang ;;
*) cc=clang ;;
esac
"$cc" --target="$target" -O2 -S -fno-asynchronous-unwind-tables "$@" -o - "$file" |
grep -v -E '^[[:space:]]*([.#;/]|$)|^@feat' |
sed -E 's/[[:space:]]+(;|\/\/|#) .*$//' |
tr -s '\t' ' ' """Compiles keep_two at -O2 for five targets and prints the instructions of each, with clang -S."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session # noqa: E402
session.run("""
# x86-64 Linux, System V ABI
sh listing.sh x86_64-linux-gnu keep.c
# x86-64 Windows, Microsoft x64 convention (compiler output only: nothing is linked or run)
sh listing.sh x86_64-pc-windows-msvc keep.c
# AArch64 on macOS, Apple's arm64 ABI
sh listing.sh arm64-apple-macos keep.c
# AArch64 Linux, AAPCS64
sh listing.sh aarch64-linux-gnu keep.c
# 64-bit RISC-V Linux, LP64D (Homebrew's clang-23: Apple's clang has no RISC-V target)
sh listing.sh riscv64-linux-gnu keep.c
""") Output
# x86-64 Linux, System V ABI $ sh listing.sh x86_64-linux-gnu keep.c keep_two: push r15 push r14 push rbx mov rbx, rsi mov r14, rdi call step@PLT mov r15, rax mov rdi, rbx call step@PLT imul rbx, r14 add rax, r15 add rax, rbx pop rbx pop r14 pop r15 ret # x86-64 Windows, Microsoft x64 convention (compiler output only: nothing is linked or run) $ sh listing.sh x86_64-pc-windows-msvc keep.c keep_two: push rsi push rdi push rbx sub rsp, 32 mov rsi, rdx mov rdi, rcx call step mov rbx, rax mov rcx, rsi call step imul rsi, rdi add rax, rbx add rax, rsi add rsp, 32 pop rbx pop rdi pop rsi ret # AArch64 on macOS, Apple's arm64 ABI $ sh listing.sh arm64-apple-macos keep.c _keep_two: stp x22, x21, [sp, #-48]! stp x20, x19, [sp, #16] stp x29, x30, [sp, #32] add x29, sp, #32 mov x19, x1 mov x20, x0 bl _step mov x21, x0 mov x0, x19 bl _step madd x8, x19, x20, x21 add x0, x8, x0 ldp x29, x30, [sp, #32] ldp x20, x19, [sp, #16] ldp x22, x21, [sp], #48 ret # AArch64 Linux, AAPCS64 $ sh listing.sh aarch64-linux-gnu keep.c keep_two: stp x29, x30, [sp, #-48]! str x21, [sp, #16] mov x29, sp stp x20, x19, [sp, #32] mov x19, x1 mov x20, x0 bl step mov x21, x0 mov x0, x19 bl step madd x8, x19, x20, x21 ldp x20, x19, [sp, #32] ldr x21, [sp, #16] add x0, x8, x0 ldp x29, x30, [sp], #48 ret # 64-bit RISC-V Linux, LP64D (Homebrew's clang-23: Apple's clang has no RISC-V target) $ sh listing.sh riscv64-linux-gnu keep.c keep_two: addi sp, sp, -32 sd ra, 24(sp) sd s0, 16(sp) sd s1, 8(sp) sd s2, 0(sp) mv s0, a1 mv s1, a0 call step mv s2, a0 mv a0, s0 call step mul a1, s0, s1 add a0, a0, s2 add a0, a0, a1 ld ra, 24(sp) ld s0, 16(sp) ld s1, 8(sp) ld s2, 0(sp) addi sp, sp, 32 ret
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 keep_session.py
Every build keeps a and b in callee-saved registers, and every build saves those registers before it writes them:
| Target | a, b and step(a) live in | Saved before the body |
|---|---|---|
| x86-64 Linux | r14, rbx, r15 |
push r15, push r14, push rbx |
| x86-64 Windows | rdi, rsi, rbx |
push rsi, push rdi, push rbx |
| AArch64 macOS | x20, x19, x21 |
stp x22, x21, stp x20, x19, stp x29, x30 |
| AArch64 Linux | x20, x19, x21 |
stp x29, x30, str x21, stp x20, x19 |
| RISC-V Linux | s1, s0, s2 |
sd ra, sd s0, sd s1, sd s2 |
Only the Windows build keeps values in rdi and rsi. Microsoft x64 makes both nonvolatile, while System V passes
the first two arguments in them and lets every call destroy them: the Linux build keeps b in rbx, not in rsi
where it arrived, and copies it into rdi with mov rdi, rbx only when the second call needs it as its argument. The
Windows listing is what clang emits for x86_64-pc-windows-msvc; nothing was linked or run for Windows. Its
sub rsp, 32 reserves the shadow space that calling conventions
describes. Each convention draws the line in its own document:
| ABI | Callee-saved: the callee restores them | Caller-saved: any call may change them |
|---|---|---|
| System V AMD64 | rbx, rbp, r12-r15, rsp |
rax, rcx, rdx, rsi, rdi, r8-r11, every xmm register |
| Microsoft x64 | rbx, rbp, rdi, rsi, rsp, r12-r15, xmm6-xmm15 |
rax, rcx, rdx, r8-r11, xmm0-xmm5 |
| AAPCS64 | x19-x29, sp, the low 64 bits of v8-v15 |
x0-x17, v0-v7, v16-v31, the upper half of v8-v15 |
| RISC-V LP64D | s0-s11, fs0-fs11, sp |
ra, t0-t6, a0-a7, ft0-ft11, fa0-fa7 |
A few details sit behind the table. AAPCS64 promises only the bottom 64 bits of v8 to v15, the part named d8 to
d15; a caller that keeps a 128-bit vector there saves the rest itself. RISC-V preserves a value in fs0 to fs11
only if it is no wider than the floating-point registers of the ABI in use, so under LP64D a double survives a call
there. AAPCS64 lists x30 only by its role as the link register: The register that holds the return address a call left behind: x30 on AArch64 and ra (x1) on RISC-V. A leaf function can return through it without using the stack; x86-64 instead keeps the return address on the stack. More in “Registers on x86-64, AArch64 and RISC-V”, so it sits in
neither column. And x18, gp and tp are left out: AAPCS64 makes x18 a caller-saved register only where the
platform does not reserve it, and Apple reserves it, while RISC-V gives gp and tp fixed roles, as
register files explains.
Key idea
Keep a value that must outlive a call in a callee-saved register, and save every callee-saved register you write; a value left in a caller-saved register is gone after the next call.
Checkpoint
Check: Caller-saved and callee-saved registers
A callee that skips one restore breaks its caller silently, and a harness catches it
triple_bad returns 3 * x and uses x19 for the intermediate 2 * x. On its own it is correct: triple_bad(14)
returns 42 every time. The damage lands in its caller. A caller that kept a value in x19 across the call, as
keep_two keeps b, finds 28 there afterwards, and nothing reports the change: no fault, no
signal: A notification the operating system delivers to a process, such as SIGFPE after an integer divide fault; unhandled, it ends the process, and the shell reports status 128 plus the signal number. More in “Division: traps, defined results, remainders”, only a wrong value that some later instruction uses, perhaps far from triple_bad.
A test of the result cannot catch this, because the result is right. A register-integrity harness tests the contract
instead: it puts a known value in every callee-saved register, calls the function and compares the registers with
what it put there. check_saved does this for AArch64. It saves its own caller’s x19 to x28 and d8 to d15,
because it is bound by the rule it tests; it loads 18 known values; it calls the function under test through a
register with blr; it stores what the 18 registers hold after the call; and it hands its caller’s values back. The
C driver gives every register its own number repeated in each byte, so x19 starts as 0x1919191919191919, and it
names every register that came back different:
harness_a64.s
// long check_saved(long (*fn)(long), long arg, const unsigned long fill[18], unsigned long seen[18])
// A register-integrity harness for AAPCS64 on Apple silicon (Mach-O names start with an underscore).
// It puts fill[0..9] in x19-x28 and fill[10..17] in d8-d15, calls fn(arg) through a register,
// stores what those 18 callee-saved registers hold after the call in seen[0..17], and returns
// fn's result. The C driver compares fill with seen. The harness keeps the rule it tests: its own
// caller's x19-x28 and d8-d15 are saved first and put back before ret.
.text
.globl _check_saved
.p2align 2
_check_saved:
stp x29, x30, [sp, #-176]! // a 176-byte frame with its frame record at the bottom
mov x29, sp // x29 addresses this function's frame record
stp x19, x20, [sp, #16] // save the caller's callee-saved registers
stp x21, x22, [sp, #32]
stp x23, x24, [sp, #48]
stp x25, x26, [sp, #64]
stp x27, x28, [sp, #80]
stp d8, d9, [sp, #96] // only the low 64 bits of v8-v15 are callee-saved
stp d10, d11, [sp, #112]
stp d12, d13, [sp, #128]
stp d14, d15, [sp, #144]
str x3, [sp, #160] // keep the address of seen: x3 is caller-saved, so fn may change it
mov x9, x0 // fn
mov x0, x1 // its one argument
ldp x19, x20, [x2] // fill: the values these registers must still hold afterwards
ldp x21, x22, [x2, #16]
ldp x23, x24, [x2, #32]
ldp x25, x26, [x2, #48]
ldp x27, x28, [x2, #64]
ldp d8, d9, [x2, #80]
ldp d10, d11, [x2, #96]
ldp d12, d13, [x2, #112]
ldp d14, d15, [x2, #128]
blr x9 // the call under test; its result comes back in x0
ldr x3, [sp, #160]
stp x19, x20, [x3] // seen: what the registers hold now
stp x21, x22, [x3, #16]
stp x23, x24, [x3, #32]
stp x25, x26, [x3, #48]
stp x27, x28, [x3, #64]
stp d8, d9, [x3, #80]
stp d10, d11, [x3, #96]
stp d12, d13, [x3, #112]
stp d14, d15, [x3, #128]
ldp x19, x20, [sp, #16] // give the caller its own values back, from the same slots
ldp x21, x22, [sp, #32]
ldp x23, x24, [sp, #48]
ldp x25, x26, [sp, #64]
ldp x27, x28, [sp, #80]
ldp d8, d9, [sp, #96]
ldp d10, d11, [sp, #112]
ldp d12, d13, [sp, #128]
ldp d14, d15, [sp, #144]
ldp x29, x30, [sp], #176 // the frame record last, and sp back where it was
ret triple_a64.s
// Three AArch64 functions that each return 3 * x, for Apple silicon. Only one keeps the rules.
.text
// long triple_bad(long x): uses x19 as a scratch register and never restores it
.globl _triple_bad
.p2align 2
_triple_bad:
add x19, x0, x0 // x19 = 2x: the caller's value in x19 is gone
add x0, x19, x0 // x0 = 3x, the right result
ret
// long triple_good(long x): the same work, with x19 saved before its first write
.globl _triple_good
.p2align 2
_triple_good:
str x19, [sp, #-16]! // save x19 in a 16-byte slot, so sp stays 16-byte aligned
add x19, x0, x0
add x0, x19, x0
ldr x19, [sp], #16 // restore it before ret
ret
// long triple_fp(long x): the same result through floating point, with d8 as an unsaved scratch register
.globl _triple_fp
.p2align 2
_triple_fp:
scvtf d8, x0 // d8 = (double)x: the low 64 bits of v8 are callee-saved
fadd d0, d8, d8 // d0 = 2x
fadd d0, d0, d8 // d0 = 3x
fcvtzs x0, d0 // back to an integer
ret harness_main.c
#include <stdio.h>
/* harness_a64.s fills x19-x28 and d8-d15 from fill[], calls fn(arg) and stores what those 18 registers
hold after the call in seen[]. triple_a64.s has three functions that each return 3 * x. */
long check_saved(long (*fn)(long), long arg, const unsigned long fill[18], unsigned long seen[18]);
long triple_bad(long x);
long triple_good(long x);
long triple_fp(long x);
/* Each register gets its own number in every byte, so x19 starts as 0x1919191919191919. */
static const char *const NAME[18] = {"x19", "x20", "x21", "x22", "x23", "x24", "x25", "x26", "x27",
"x28", "d8", "d9", "d10", "d11", "d12", "d13", "d14", "d15"};
static const unsigned long TAG[18] = {0x19, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27,
0x28, 0x08, 0x09, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15};
static void report(const char *name, long (*fn)(long)) {
unsigned long fill[18], seen[18];
for (int i = 0; i < 18; i++) fill[i] = TAG[i] * 0x0101010101010101UL;
long result = check_saved(fn, 14, fill, seen);
printf("%-11s returned %ld: ", name, result);
int changed = 0;
for (int i = 0; i < 18; i++) {
if (seen[i] != fill[i]) {
printf("%s%s changed to %#lx", changed ? ", " : "FAIL, ", NAME[i], seen[i]);
changed++;
}
}
puts(changed ? "" : "PASS, x19-x28 and d8-d15 kept");
}
int main(void) {
report("triple_bad", triple_bad);
report("triple_good", triple_good);
report("triple_fp", triple_fp);
return 0;
} The same pair of functions for x86-64 System V and for RISC-V, where rbx and s1 play the part of x19:
x86-64 · triple_x86_64.s
# triple_bad and triple_good for x86-64 Linux (System V ABI), where rbx is callee-saved
.intel_syntax noprefix
.text
.globl triple_bad
triple_bad: # long triple_bad(long x): x arrives in rdi
lea rbx, [rdi + rdi] # rbx = 2x: the caller's value in rbx is gone
lea rax, [rbx + rdi] # rax = 3x, the right result
ret
.globl triple_good
triple_good:
push rbx # save the caller's rbx before the first write
lea rbx, [rdi + rdi]
lea rax, [rbx + rdi]
pop rbx # restore it before ret
ret RISC-V · triple_riscv64.s
# triple_bad and triple_good for 64-bit RISC-V Linux (LP64D), where s1 is callee-saved
.text
.globl triple_bad
triple_bad: # long triple_bad(long x): x arrives in a0
add s1, a0, a0 # s1 = 2x: the caller's value in s1 is gone
add a0, s1, a0 # a0 = 3x, the right result
ret
.globl triple_good
triple_good:
addi sp, sp, -16 # a 16-byte slot keeps sp 16-byte aligned
sd s1, 0(sp) # save the caller's s1 before the first write
add s1, a0, a0
add a0, s1, a0
ld s1, 0(sp) # restore it before ret
addi sp, sp, 16
ret The session builds the AArch64 program and runs it, then assembles the other two files into ELF: The Executable and Linkable Format of the generic System V ABI, used by Linux and most Unix systems. It has a section header table for the linker and a program header table for the loader. More in “From source to executable: objects and linking” objects and lists their machine code:
"""Runs the register-integrity harness on AArch64, then assembles and lists the x86-64 and RISC-V versions."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session # noqa: E402
session.run("""
# AArch64: the harness, the three functions and the C driver become one program, which runs
clang -O1 -o harness harness_main.c harness_a64.s triple_a64.s
./harness
# x86-64 Linux: assembled into an ELF object and listed, not run
clang --target=x86_64-linux-gnu -c -o triple_x86_64.o triple_x86_64.s
llvm-objdump -d -M intel triple_x86_64.o
# RISC-V Linux: assembled into an ELF object and listed, not run
llvm-mc --triple=riscv64-linux-gnu -filetype=obj -o triple_riscv64.o triple_riscv64.s
llvm-objdump -d triple_riscv64.o
""") Output
# AArch64: the harness, the three functions and the C driver become one program, which runs
$ clang -O1 -o harness harness_main.c harness_a64.s triple_a64.s
$ ./harness
triple_bad returned 42: FAIL, x19 changed to 0x1c
triple_good returned 42: PASS, x19-x28 and d8-d15 kept
triple_fp returned 42: FAIL, d8 changed to 0x402c000000000000
# x86-64 Linux: assembled into an ELF object and listed, not run
$ clang --target=x86_64-linux-gnu -c -o triple_x86_64.o triple_x86_64.s
$ llvm-objdump -d -M intel triple_x86_64.o
triple_x86_64.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <triple_bad>:
0: 48 8d 1c 3f lea rbx, [rdi + rdi]
4: 48 8d 04 3b lea rax, [rbx + rdi]
8: c3 ret
0000000000000009 <triple_good>:
9: 53 push rbx
a: 48 8d 1c 3f lea rbx, [rdi + rdi]
e: 48 8d 04 3b lea rax, [rbx + rdi]
12: 5b pop rbx
13: c3 ret
# RISC-V Linux: assembled into an ELF object and listed, not run
$ llvm-mc --triple=riscv64-linux-gnu -filetype=obj -o triple_riscv64.o triple_riscv64.s
$ llvm-objdump -d triple_riscv64.o
triple_riscv64.o: file format elf64-littleriscv
Disassembly of section .text:
0000000000000000 <triple_bad>:
0: 00a504b3 add s1, a0, a0
4: 00a48533 add a0, s1, a0
8: 00008067 ret
000000000000000c <triple_good>:
c: ff010113 addi sp, sp, -0x10
10: 00913023 sd s1, 0x0(sp)
14: 00a504b3 add s1, a0, a0
18: 00a48533 add a0, s1, a0
1c: 00013483 ld s1, 0x0(sp)
20: 01010113 addi sp, sp, 0x10
24: 00008067 ret
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 harness_session.py
All three functions returned 42, and the harness told them apart anyway. triple_bad left 0x1c, which is 28, in
x19. triple_good stored x19 with str before its first write and loaded it back before ret, and all 18
registers came back as they went in. triple_fp computes in floating point and leaves 14.0 in d8, whose bits are
0x402c000000000000: a floating-point register is as much part of the contract as x19. The listings show what the
fix costs elsewhere: on x86-64 one push rbx and one pop rbx, a byte each (53 and 5b); on RISC-V four
instructions, because sd and ld do not move the stack pointer: The register that addresses the top of the stack: rsp on x86-64, sp on AArch64 and sp (x2) on RISC-V. Push, pop and function prologues move it; losing it loses the stack. More in “Registers on x86-64, AArch64 and RISC-V”, so two addi
instructions move it around them.
Step through one round of the harness for each function, with the values of the run:
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- Save the caller's registers. check_saved is a function with a caller too, so it keeps the rule it tests: before it touches x19-x28 and d8-d15, it stores its own caller's values in its 176-byte frame.
- Fill with known values. It loads 18 values from fill: every register gets its own number in each byte, so x19 holds 0x1919191919191919 and d8 holds 0x0808080808080808. A changed value cannot hide among them.
- Call triple_bad(14). blr x9 calls the function under test with 14 in x0. triple_bad writes 2 × 14 = 28 into x19 and returns 3 × 14 = 42, the right answer.
- Store what is left. Straight after the call, before anything else can touch them, the harness stores all 18 registers in seen. x19 now holds 0x1c; the other 17 still hold their fill values.
- Restore, then return. The harness reloads its caller's 18 values from its frame and returns 42 to the C driver. Skipping this step would make the harness break its own caller.
- Compare and report. The C driver compares seen with fill, one register at a time. One differs, so it prints the line of the run: triple_bad returned 42, FAIL, x19 changed to 0x1c.
- The next function. report() calls check_saved again, now for triple_good, which stores x19 with str before its first write and loads it back with ldr before ret.
- Every register kept. This time all 18 registers come back with their fill values, so the driver prints PASS. The result was 42 both times: only the registers tell the two functions apart.
- A floating-point register. triple_fp computes the same 42 in floating point and leaves 14.0 in d8. Its bits, 0x402c000000000000, are not 0x0808080808080808, so the driver reports d8.
The harness reads d8 to d15, the low halves, and never the full 128-bit q8 to q15, because AAPCS64 promises
only the low halves: a check of whole vector registers would report correct functions. A harness for another ABI
checks that ABI’s list from the table above and nothing else.
Testing only what the function returns
An assertion that triple_bad(14) returns 42 succeeds, and so does every other test of its output. A missing
restore changes the caller’s registers, not the result, so the test must look at the registers after the call:
fill every callee-saved register of the ABI, call once, and compare all of them.
Checkpoint
Check: Catching a missing restore with a harness
The prologue saves before the body writes, and the epilogue undoes every save
The instructions before the body of keep_two are its prologue, and the ones after it its epilogue. Together they
build and take apart the function’s stack frame: The part of the stack that one call of a function owns while it runs, set up by its prologue and given back by its epilogue: saved registers, the saved return address where the ISA needs one, and local storage., the part of the stack this call owns
while it runs. In the five listings of the first run every prologue does the same three jobs: it saves each
callee-saved register the body will write, it makes sure the return address outlives the body’s calls, and it reserves
the frame’s space. Every epilogue undoes all three and ends with ret:
| Target | Prologue | Epilogue |
|---|---|---|
| x86-64 Linux | push r15, push r14, push rbx |
pop rbx, pop r14, pop r15, ret |
| x86-64 Windows | push rsi, push rdi, push rbx, sub rsp, 32 |
add rsp, 32, pop rbx, pop rdi, pop rsi, ret |
| AArch64 macOS | stp x22, x21, [sp, #-48]!, stp x20, x19, [sp, #16], stp x29, x30, [sp, #32], add x29, sp, #32 |
ldp x29, x30, [sp, #32], ldp x20, x19, [sp, #16], ldp x22, x21, [sp], #48, ret |
| RISC-V Linux | addi sp, sp, -32, then sd of ra, s0, s1, s2 |
ld of ra, s0, s1, s2, then addi sp, sp, 32, ret |
On x86-64 the return address is already on the stack, pushed by call, so the prologue pushes only registers. On
AArch64 and RISC-V it arrives in x30 or ra, and a function that calls step must store it before the first call
overwrites it; calls and returns shows the hang when it does not. Each push moves
rsp by 8, while the AArch64 prologue lowers sp by the whole 48 bytes in its first stp and stores the other pairs
at offsets, and RISC-V moves sp once with addi and stores each register with sd;
the stack covers all three.
The order matters where the stack decides it. A stack hands back the last value pushed first, so pop rbx comes
first because push rbx came last. AArch64 and RISC-V load each slot from its own offset, so only the load or addi
that moves the stack pointer back has to come last, and the other loads may come in any order. The listings show
both choices: the two AArch64 builds mirror their prologues, the Linux one with an add between two of its loads,
while the RISC-V build reloads ra, s0, s1 and s2 in the order it stored them. Apple’s build also saves x22,
which the body never writes: it stores x22 beside x21 in one stp. Saving more than the body needs is always
allowed; saving less is the bug of the previous section.
Windows adds rules of its own, because its unwinder must be able to undo a function from any instruction: unwind data
describes each prolog, and an epilog is recognised by its instructions. A prolog may save the nonvolatile registers
in any order, but its first use of each one must be its save, and an epilog may hold only an add rsp or lea rsp,
a run of register pops, and a ret or jmp, exactly the shape of the Windows build of keep_two.
Popping in the order you pushed
After push rbx and push r12, the epilogue pop rbx, pop r12 swaps the two values: the caller gets its
r12 in rbx and the other way round, and a harness for System V reports both. Undo the saves in reverse: pop r12,
then pop rbx.
Step through Apple’s prologue and epilogue slot by slot. S stands for the value of sp on entry, and lower slots are
lower addresses:
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- On entry. bl _keep_two has put the return address in x30. x29 still addresses the caller's frame record, and sp holds some address S. keep_two needs a and b after two calls, so it must save before it writes.
- stp x22, x21, [sp, #-48]!. One instruction lowers sp by 48, the whole frame at once, and stores x22 and x21 in the two lowest slots. The body never writes x22; this build simply stores it beside x21 in one stp.
- stp x20, x19, [sp, #16]. x20 and x19 go 16 bytes higher. These are the caller's values, and from here on keep_two may overwrite x19, x20, x21 and x22.
- stp x29, x30, [sp, #32]. The top pair is the frame record: the caller's x29, which is the address of the caller's record, and then the return address. Its first word links to the caller's record.
- add x29, sp, #32. x29 now holds S - 16, the address of keep_two's own record. Following its first word leads to the caller's record: the chain has grown by one link.
- The body. mov x19, x1 and mov x20, x0 keep b and a, and x21 receives step(a). Both bl _step instructions overwrite x30, which is safe now that the return address is in the record.
- ldp x29, x30, [sp, #32]. The epilogue starts with the record: x29 addresses the caller's record again and x30 holds the return address once more.
- The other two pairs. ldp x20, x19, [sp, #16] and then ldp x22, x21, [sp], #48 load the caller's values from the same slots, and the last load adds 48 to sp, which is S again.
- ret. ret branches to x30. The caller gets sp, x19 to x22 and x29 back exactly as it left them, which is all a callee-saved register promises.
The pair at S - 16 is the one the next section follows: the caller’s x29 and the return address, with x29
pointing at it. Everything else in the frame is there to be put back.
Key idea
A prologue saves every callee-saved register the body writes, and the link register when the body makes calls, before the first write; the epilogue loads every saved value back, in reverse order wherever it pops them, and leaves the stack pointer where it found it.
Checkpoint
Check: Prologues and epilogues
Frame records chain the frames together, and Apple keeps x29 on the chain
Apple’s prologue for keep_two stored two words at S - 16, the caller’s x29 and the return address, and pointed
x29 at them. That pair is a frame record, and x29 is the frame pointer: A register that locates the current function's frame record, so that each frame links to its caller's: x29 on AArch64 and rbp on x86-64 address the record, and s0 on RISC-V points just above it. Compilers may omit it, and Apple requires x29 to address a valid frame record.. AAPCS64
asks conforming code to link every frame to its caller’s this way: each record holds two 64-bit values, the address
of the previous record at the lower address and the return address above it, and the frame pointer addresses the
newest record. Starting from x29, code can visit every active call by following first words, and each record’s
return address points into the caller of the function that stored it. A zero in place of a previous record’s address
ends the chain.
The standard leaves the strictness to each platform, from a frame pointer that must address a valid record at all
times down to no chain at all, with x29 used as one more callee-saved register. Apple takes the strict end: on its
platforms x29 must always address a valid frame record. leaf function: A function that makes no calls of its own. Nothing overwrites its link register while it runs, so it needs no save of x30 or ra, and at -O2 compilers often give it no stack space at all. More in “Calls, returns and the link register” and
tail call: A call that is the last thing a function does, compiled as a plain jump (b, jmp or RISC-V's tail) instead of a call and a return. The caller's return address stays in place, so the callee returns straight to the caller's caller. More in “Calls, returns and the link register” may skip creating one, and the result, in Apple’s words, is that “stack
traces are always meaningful, even without debug information”. x86-64 and RISC-V build the same pair when they keep a
frame pointer:
| ISA | Frame pointer | Previous record at | Return address at |
|---|---|---|---|
| AArch64 | x29 |
[x29] |
[x29 + 8] |
| x86-64 | rbp |
[rbp] |
[rbp + 8] |
| RISC-V | s0, also called fp |
[s0 - 16] |
[s0 - 8] |
On x86-64, push rbp and mov rbp, rsp leave the caller’s rbp at [rbp], just below the return address that
call pushed, which is the psABI’s picture of a frame. On RISC-V the frame pointer stays callee-saved and points at
the top of the frame, the value sp had on entry, so the psABI puts the pair just below it. Off Apple platforms the
frame pointer is often left out: GCC documents -fomit-frame-pointer as enabled by default at -O1 and higher,
which drops the instructions that save, set and restore it and, on many targets, lets the register hold other values.
The x86-64 Linux build of keep_two in the first run has no rbp at all, while clang’s AArch64 Linux build did
build a record. This run compiles keep_two twice more, keeping the frame pointer on x86-64 Linux and dropping it on
AArch64 Linux:
"""Compiles keep_two again with the frame-pointer flags switched: kept on x86-64 Linux, omitted on AArch64 Linux."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session # noqa: E402
session.run("""
# x86-64 Linux with the frame pointer kept: rbp now addresses the frame
sh listing.sh x86_64-linux-gnu keep.c -fno-omit-frame-pointer
# AArch64 Linux with the frame pointer omitted: no frame record is built
sh listing.sh aarch64-linux-gnu keep.c -fomit-frame-pointer
""") Output
# x86-64 Linux with the frame pointer kept: rbp now addresses the frame $ sh listing.sh x86_64-linux-gnu keep.c -fno-omit-frame-pointer keep_two: push rbp mov rbp, rsp push r15 push r14 push rbx push rax mov rbx, rsi mov r14, rdi call step@PLT mov r15, rax mov rdi, rbx call step@PLT imul rbx, r14 add rax, r15 add rax, rbx add rsp, 8 pop rbx pop r14 pop r15 pop rbp ret # AArch64 Linux with the frame pointer omitted: no frame record is built $ sh listing.sh aarch64-linux-gnu keep.c -fomit-frame-pointer keep_two: stp x30, x21, [sp, #-32]! stp x20, x19, [sp, #16] mov x19, x1 mov x20, x0 bl step mov x21, x0 mov x0, x19 bl step madd x8, x19, x20, x21 ldp x20, x19, [sp, #16] add x0, x8, x0 ldp x30, x21, [sp], #32 ret
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 frames_session.py
With -fno-omit-frame-pointer the x86-64 prologue starts with push rbp and mov rbp, rsp, and the epilogue’s last
instruction before ret is pop rbp. The stack must stay 16-byte aligned at every call, which has rules of its own, so the extra push also
brings a push rax and an add rsp, 8: five instructions in all. The AArch64 Linux build without a frame pointer
stores x30 beside x21 and leaves x29 alone: the return address is safe, but no record is built, and x29 still
addresses the caller’s.
How much does one missing record matter? with_frame and without_frame both call show_stack in the C driver,
which takes the stack with backtrace() and prints the name dladdr() finds for each address, from the program’s
own symbols and no debug information. with_frame builds a record; without_frame saves only x30:
bt_a64.s
// with_frame and without_frame for AArch64 on Apple silicon. Both call show_stack in the C driver,
// so both save x30 first. Only with_frame builds a frame record and points x29 at it.
.text
.globl _with_frame
.p2align 2
_with_frame:
stp x29, x30, [sp, #-16]! // the frame record: the caller's x29, then the return address
mov x29, sp // x29 now addresses this function's record
bl _show_stack
ldp x29, x30, [sp], #16
ret
.globl _without_frame
.p2align 2
_without_frame:
str x30, [sp, #-16]! // the return address is safe, but x29 still addresses the caller's record
bl _show_stack
ldr x30, [sp], #16
ret bt_main.c
#include <dlfcn.h>
#include <execinfo.h>
#include <stdio.h>
void with_frame(void); /* bt_a64.s */
void without_frame(void); /* bt_a64.s */
/* Takes the current stack with backtrace() and prints, innermost first, the name of the function that
each address lies in. dladdr() reads the names from the program's own symbols: no debug information. */
void show_stack(void) {
void *frames[8];
int n = backtrace(frames, 8);
for (int i = 0; i < n; i++) {
Dl_info info;
const char *name = dladdr(frames[i], &info) && info.dli_sname ? info.dli_sname : "?";
printf(" %d %s\n", i, name);
}
}
int main(void) {
puts("through with_frame:");
with_frame();
puts("through without_frame:");
without_frame();
return 0;
} """Builds the backtrace driver with with_frame and without_frame, and runs it on AArch64."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session # noqa: E402
session.run("""
# The C driver and the two assembly functions become one program; each call prints the stack walk
clang -O1 -o bt bt_main.c bt_a64.s
./bt
""") Output
# The C driver and the two assembly functions become one program; each call prints the stack walk $ clang -O1 -o bt bt_main.c bt_a64.s $ ./bt through with_frame: 0 show_stack 1 with_frame 2 main 3 start through without_frame: 0 show_stack 1 without_frame 2 start
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bt_session.py
The first walk names four functions and the second only three, and the name it lost is main, not without_frame.
Step through both walks to see why:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Entry 0: show_stack. backtrace() is called from show_stack, so entry 0 of the walk is show_stack itself. From there the walk follows frame records, starting with show_stack's own.
- Entry 1: with_frame. show_stack's record holds two words: first the saved x29, the address of the next record, then the return address into with_frame, which gives entry 1.
- Entry 2: main. with_frame built its record with stp x29, x30 and pointed x29 at it with mov x29, sp, so the saved x29 leads there. Its return address points into main: entry 2.
- Entry 3: start. main's record gives entry 3, start, the code that called main. That makes the four entries the first run printed.
- The second run. without_frame stores x30 in a plain slot with str x30, [sp, #-16]! and builds no record, so x29 still addresses main's record when it calls show_stack.
- Entry 1: without_frame. show_stack's prologue saves that x29 in its own record, next to the return address into without_frame. Entry 1 is without_frame, as it was with_frame before.
- The link skips a frame. The saved x29 is the address of main's record, so the walk jumps straight there. The return address into main sits in without_frame's slot, which no record points at.
- main is missing. main's record still gives start, so the walk prints show_stack, without_frame and start: three entries. The name that disappeared is main, the caller of the function that built no record.
show_stack built its own record, so it still named without_frame. What without_frame skipped was the record
that names its caller: the return address into main sat safely in a plain stack slot, but no record pointed at that
slot, so the walk went from show_stack’s record straight to main’s. Read from these records alone, a debugger’s
backtrace would have the same gap. Apple’s rule exists to prevent that gap, and it is why the macOS build of
keep_two stored x29 and x30 and pointed x29 at them even at -O2.
Key idea
A frame record is the caller’s frame pointer and the return address, linked from the frame pointer; a function that calls others without building one does not drop itself from a stack walk, it drops its caller.
Checkpoint
Check: Frame records and frame pointers
Practise
Three changes to copies of the lesson’s files. Build each with the commands its session prints above; the x86-64 and RISC-V parts are assembled and listed, as in the harness run.
- Write
keep_four(x)for AArch64. It keepsx,2 * x,3 * xand4 * xin callee-saved registers, calls a C helperlong helper(long x) { return x + 1; }once withx, and returns the helper’s result plus the four values. Add the helper andreport("keep_four", keep_four);to a copy ofharness_main.c. Decide first how large the frame must be and where the frame record and the two pairs ofx19tox22go; the harness’s line for it should then readreturned 155: PASS. Write the same function for x86-64 System V and for RISC-V and assemble both. Which registers would a harness for each of them fill? - In a copy of
bt_a64.s, deletemov x29, spfromwith_frameand keep itsstpandldp. Before you run it, predict what the first walk prints and which name, if any, goes missing. - Run
listing.shonkeep.cforx86_64-linux-gnuwith and without-fno-omit-frame-pointerand count the instructions the flag adds. Then run it forriscv64-linux-gnuwith the flag and forarm64-apple-macoswith-fomit-frame-pointer. Which instruction sets the RISC-V frame pointer, which callee-saved register does that build use in addition tos0tos2, and which instruction disappears from Apple’s prologue?
Practice that adapts to you
Practise this lesson
12 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Who preserves a caller-saved register and who a callee-saved one? Name one register of each kind on System V and on AAPCS64.
The two classes differ in who keeps the value. A callee-saved register must hold the same value when a function
returns as when it was called, so a function that wants to write one saves the old value first and restores it
before it returns, and its caller may leave a value there across any call. A caller-saved register may be changed
by any call, so a caller that still needs its value saves it before the call or keeps it somewhere else. Under
System V on x86-64, rbx is callee-saved and rdi, the first argument register, is caller-saved. Under AAPCS64,
x19 is callee-saved and x9 is caller-saved. Compilers put values that live across calls in callee-saved
registers and short-lived values in caller-saved ones, which then need no save at all.
The module’s question bank carries these rules into two real jobs: deciding whether a Linux service should keep its frame pointers, and moving a System V routine into a Windows program.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to staff
Your team runs a latency-sensitive C service on x86-64 Linux, built at -O2, and wants CPU profiles with call stacks from every machine. Someone proposes rebuilding everything with -fno-omit-frame-pointer. What does that flag cost per function, what does it buy a sampling profiler, and what would you measure before deciding?
-
Mid to senior
An x86-64 assembly routine from a Linux library uses rdi and rsi as pointers and keeps a total in xmm6; it calls nothing. Porting it to Windows x64, you copy its arguments from rcx and rdx into rdi and rsi. Its results are right, yet its callers misbehave. Why, and what must its prologue and epilogue do?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Who preserves a callee-saved register, and who a caller-saved one?
A callee-saved register must hold the same value on return, so a function that writes one saves and restores it. A caller-saved register may change in any call, so a caller that still needs the value saves it or keeps it elsewhere.
-
Which general registers are callee-saved under System V, AAPCS64 and RISC-V?
rbx, rbp and r12 to r15 under System V; x19 to x29 under AAPCS64, plus the bottom 64-bit halves of vector registers v8 to v15; s0 to s11 on RISC-V, plus fs0 to fs11. Microsoft x64 adds rdi, rsi and xmm6 to xmm15 to the System V set.
-
Why does a missed restore of a callee-saved register so often go unnoticed, and what catches it?
The function still returns the right result; only its caller, later, finds a wrong value. A harness that fills every callee-saved register, makes one call and compares them all afterwards catches it at once.
-
What must a prologue save, and what must the epilogue undo?
The prologue saves every callee-saved register the body will write, and the link register if the body makes calls, before anything overwrites them. The epilogue reloads each saved value, pops in reverse order, and puts the stack pointer back where it was on entry.
-
What is a frame record, and what links the records together?
Two 64-bit values in a frame: the caller's frame-record address and the return address. The frame pointer, x29 on AArch64 or rbp on x86-64, points at the record, so a debugger or profiler can walk every active call.
-
What does a function that calls others without building a frame record do to a stack walk?
It does not drop itself from the walk, it drops its caller. Apple requires x29 to address a valid record at all times, while GCC omits the frame pointer by default from -O1.
Key takeaways
- A value that must survive a call goes in a callee-saved register, which a callee saves and restores if it writes it; a caller-saved register may be changed by any call, so a value left there is gone after the next one.
- The line differs by ABI: System V preserves
rbx,rbpandr12tor15, Microsoft x64 addsrdi,rsiandxmm6toxmm15, AAPCS64 preservesx19tox29and the low 64 bits ofv8tov15, and RISC-V preservess0tos11andfs0tofs11. - A function that skips one restore still returns the right result, so only a harness that fills every callee-saved
register, calls once and compares them all catches it, as it caught
x19andd8. - A prologue saves each callee-saved register the body will write, plus the link register in a function that makes calls, before anything overwrites them; the epilogue reloads every saved value, with pops in the reverse order of the pushes, and gives the stack space back.
- Frame records link each frame to its caller’s through the frame pointer; Apple requires
x29to address a valid record at all times, GCC turns on-fomit-frame-pointerby default from-O1, and a function that calls others without a record drops its caller from a stack walk.
Terms in this lesson
- callee-saved register
- A register whose value a called function must hand back unchanged: if it writes the register, it saves the old value first and restores it before it returns. rbx on x86-64, x19 on AArch64 and s0 on RISC-V are callee-saved.
- caller-saved register
- A register that any call may change. A caller that still needs the value after the call saves it first or keeps it elsewhere. rdi on x86-64 System V, x9 on AArch64 and t0 on RISC-V are caller-saved.
- epilogue
- The instructions at the end of a function that undo its prologue: they reload the saved registers (pops come in the reverse order of the pushes), give the stack space back so the stack pointer is where it was on entry, and return.
- frame pointer
- A register that locates the current function's frame record, so that each frame links to its caller's: x29 on AArch64 and rbp on x86-64 address the record, and s0 on RISC-V points just above it. Compilers may omit it, and Apple requires x29 to address a valid frame record.
- frame record
- Two 64-bit values a function stores in its stack frame: the address of its caller's frame record and its own return address. Linked by the frame pointer, the records let a debugger or profiler walk every active call.
- prologue
- The instructions at the start of a function that set up its stack frame before the body runs: they save the callee-saved registers the body writes, save the link register when the function makes calls (on x86-64, call has already put the return address on the stack), and reserve its stack space.
- stack frame
- The part of the stack that one call of a function owns while it runs, set up by its prologue and given back by its epilogue: saved registers, the saved return address where the ISA needs one, and local storage.
References
- System V Application Binary Interface, AMD64 Architecture Processor Supplement (register usage, the stack frame) (x86-64 psABI maintainers)
- x64 calling convention (volatile and nonvolatile registers) (Microsoft)
- x64 prolog and epilog (Microsoft)
- Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64): register roles and the frame pointer (Arm Limited)
- Writing ARM64 code for Apple platforms (x29 and frame records) (Apple Inc.)
- RISC-V ELF psABI: calling convention (register convention, frame pointer convention) (RISC-V International)
- GCC manual: Options That Control Optimization (-fomit-frame-pointer) (GCC project)
- Arm A-profile A64 Instruction Set Architecture (STP, LDP, BLR) (Arm Limited)
Report a problem with this lesson
Kept only in this browser. Your Learn progress