CodeRigor

FREE LESSON

The AArch64 calling convention (AAPCS64)

Where AArch64 functions find their arguments and leave their results: x0–x7 and v0–v7, the x8 indirect result, callee-saved x19–x28 and d8–d15, frame records with fp and lr, 16-byte sp alignment, and how structs are passed (HFAs, ≤ 16 bytes in registers, larger by reference).

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

f(int a, double b, long c, float d): two register files, counted separately
Integers take the next x, floating point the next v, independently. A ninth integer argument would go to [sp].
f(int a, double b, long c, float d): two register files, counted separately
Integers take the next 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….)

Assembly
// 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
    ret

Unlike 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

Across a call: what survives and what doesn't
Green survives any call (the callee restores it). Everything else may be destroyed — keep a value you need later in x19–x28. Only the low 64 bits of v8–v15 survive.
Across a call: what survives and what doesn't
Green survives any call (the callee restores it). Everything else may be destroyed — keep a value you need later in x19–x28. Only the low 64 bits of v8–v15 survive.

"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:

Assembly
// 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 x30
keep's frame after its prologue
The frame record (saved fp, saved lr) sits at the bottom of the frame, and x29 points at it — so records chain into a backtrace. 32 bytes, because sp stays 16-byte aligned.
keep's frame after its prologue
The frame record (saved fp, saved lr) sits at the bottom of the frame, and x29 points at it — so records chain into a backtrace. 32 bytes, because sp stays 16-byte aligned.

A 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.

No return address on the stack: bl writes x30, ret jumps to it
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.
No return address on the stack: bl writes x30, ret jumps to it
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):

  1. Homogeneous floating-point aggregates (HFA: 1–4 members, all float or all double) 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.
  2. 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.
  3. 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).
  4. 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.
Where a struct goes depends on what it is, then on its size
Where a struct goes depends on what it is, then on its size
Assembly
// 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
    ret

5. C++ details

  • this is simply the first argument: x0. A member function long C::get() const reading the first field is ldr x0, [x0].
  • Plain char is unsigned on AArch64 Linux (x86-64: signed). Code that assumes char can be negative behaves differently on ARM.
  • Varargs on Linux: the caller passes variadic arguments exactly like named ones (no al count as on x86-64); the callee's va_start saves 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:

C++
#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.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Introduces call/return and caller/callee responsibilities using AArch32. For this AArch64 build, use the lesson’s x0–x7 argument registers, preserved registers and 16-byte stack alignment; the linked AAPCS64 specification defines the exact contract.