The Procedure Call Standard for the Arm 64-bit Architecture — AAPCS64 — is the contract every compiled function on AArch64 Linux (and, with small differences, macOS and Windows) keeps. If you've done the x86-64 System V lesson, the questions are the same: where do arguments go, who must preserve which registers, how are structs passed, what must the stack look like. The answers are simpler and more generous: eight integer argument registers, eight vector ones, and ten callee-saved registers.
1. Arguments and results
x, floating point the next v, independently. A ninth integer argument would go to [sp].The two register files are numbered independently: in
f(int a, double b, long c, float d), a is in w0, b in d0, c in x1 and
d in s1. (x86-64 System V does the same with rdi… and xmm0….)
// long ninth(long a, …, long h, long i) { return i; } // 9 integer args
ninth:
ldr x0, [sp] // the ninth argument is the first stack slot
retUnlike x86-64 there's no return address on the stack: bl puts it in
x30, so the first stack argument is exactly at [sp] on entry.
As on x86-64, a 32-bit argument only defines the low half of its register:
int x in w0 means bits 32–63 of x0 are unspecified. AAPCS64 also says the
callee extends narrow (8/16-bit) arguments if it needs to — it can't trust
the caller to have done it.
2. Who preserves what
"Callee-saved" means a function may use the register only if it puts the caller's value back before returning. Caller-saved registers may be destroyed by any call — so a value you need after a call must live in x19–x28 (or on the stack).
d8–d15 is a detail worth remembering: only the low 64 bits of v8–v15
are preserved. A 128-bit NEON value kept across a call must be spilled.
3. Frame records and lr
A non-leaf function — one that calls something — must save x30 before the
bl overwrites it. The standard prologue pushes a frame record of
(x29, x30) and points x29 at it, so debuggers and profilers can walk the
chain of records to get a backtrace:
// long keep(long x) { return x + ext(x); }
keep:
stp x29, x30, [sp, -32]! // allocate 32 bytes and push fp, lr (pre-index)
mov x29, sp // fp -> this frame record
str x19, [sp, 16] // we use callee-saved x19: save it
mov x19, x0 // x must survive the call
bl ext // x30 := return address; x0 = ext(x)
add x0, x19, x0
ldr x19, [sp, 16] // restore x19
ldp x29, x30, [sp], 32 // restore fp, lr and free the frame (post-index)
ret // jump to x30A leaf function doesn't call anything, so x30 stays intact and it needs
no prologue at all — ret just jumps to x30. That's why so many small
AArch64 functions are a couple of instructions and ret.
sp is always 16-byte aligned — not just at calls, as on x86-64, but whenever it's used to access memory (Linux enables the hardware check). So frames are allocated in multiples of 16: one register to save still costs 16 bytes.
bl sets lr to the instruction after it. A tail call (b) leaves lr alone, so the callee's ret goes straight back to our caller.When the last thing a function does is call another, GCC turns it into a
tail call: restore the frame, then b (or br xN) instead of bl +
ret. The callee then returns straight to our caller.
4. Structs: by value, by registers, by reference
The rules for composite types (structs, classes, arrays inside them):
- Homogeneous floating-point aggregates (HFA: 1–4 members, all
floator alldouble) and homogeneous short-vector aggregates (HVA) go in consecutive v registers, as arguments and as results.struct Vec2 { double x, y; }arrives in d0, d1 and is returned in d0, d1. - Other composites of ≤ 16 bytes go in one or two x registers (as
if they were an array of 64-bit words) — even if they contain a double.
struct { long a; double b; }is returned in x0 and x1. - Composites > 16 bytes are passed by reference: the caller makes a copy and passes its address in the next x register. Returned ones go through memory the caller provides, whose address arrives in x8 (x86-64 passes that hidden pointer in rdi and returns it in rax; AArch64 uses a dedicated register, so x0 stays the first argument).
- Types that aren't trivially copyable (a user-provided destructor or copy constructor) are always passed by reference — a C++ ABI rule, same as on x86-64.
// struct Triple { long a, b, c; }; Triple make3(long a, long b, long c)
make3:
stp x0, x1, [x8] // write a, b through the hidden pointer
str x2, [x8, 16]
ret
// struct Pair { long a, b; }; Pair swap(Pair p) { return {p.b, p.a}; }
swap: // p arrives in x0, x1; the result leaves in x0, x1
mov x2, x0
mov x0, x1
mov x1, x2
ret5. C++ details
thisis simply the first argument: x0. A member functionlong C::get() constreading the first field isldr x0, [x0].- Plain
charis unsigned on AArch64 Linux (x86-64: signed). Code that assumescharcan be negative behaves differently on ARM. - Varargs on Linux: the caller passes variadic arguments exactly like
named ones (no
alcount as on x86-64); the callee'sva_startsaves x0–x7 and q0–q7 to a register save area. (Apple's AArch64 ABI differs: all variadic arguments go on the stack — a classic porting bug.)
6. Checking it in tests
Hand-written functions are tested through crucible::abi::call, which
loads sentinels into x19–x28, x29 and d8–d15, calls your function and
reports any register you failed to restore. Callbacks can use
crucible::abi::probe, which records sp so the test can check it was
16-byte aligned at the call:
#include <crucible/abi.hpp>
#include <cstdio>
extern "C" long sloppy(long x);
asm(R"(
.text
.globl sloppy
sloppy:
mov x19, x0 // x19 is callee-saved: this corrupts the caller's x19
add x0, x19, 1
ret
)");
int main() {
auto r = crucible::abi::call(sloppy, 41L);
std::printf("ret=%ld clobbered=%zu (%s)\n", r.ret, r.clobbered.size(),
r.clobbered.empty() ? "-" : r.clobbered[0].c_str());
}The build task asks you to write six functions that each exercise one rule.