Reading compiler output is half the skill; the other half is writing a function yourself and having it keep every promise the ABI makes. This lesson builds whole functions in GAS AArch64 syntax: loops over strings and arrays, stack frames, saved registers and calls back into C++.
1. The shape of a top-level asm block
The function is declared in C++ and defined in assembly:
#include <cstdio>
extern "C" long add3(long a, long b, long c);
asm(R"(
.text // always: the current section is otherwise unknown
.globl add3 // visible to the linker (and to C++)
add3:
add x0, x0, x1 // arguments arrive in x0, x1, x2 …
add x0, x0, x2 // … and the result goes back in x0
ret // branch to the address in x30 (lr)
)");
int main() { std::printf("%ld\n", add3(1, 2, 3)); }The rules from the format guide:
- start with
.text; - comments are
//(#marks immediates, though GCC omits it); - use numeric local labels —
1:…b.ne 1b(nearest1:backwards),b.eq 2f(nearest2:forwards) — so several functions, or a header included twice, never clash; extern "C"so the symbol isadd3, not a mangled C++ name.
2. The register contract (AAPCS64), from the writer's side
ret. Hover for details.A leaf function (it calls nothing) that sticks to x0–x17 needs no prologue at all — x30 still holds the return address at the end. Most of the build is leaf functions.
3. Loops: post-index addressing
AArch64 loads and stores can update their base register:
Post-index is the natural "read the next element" for loops. A strlen:
asm_strlen:
mov x1, x0 // cursor
1: ldrb w2, [x1], 1 // load a byte, advance
cbnz w2, 1b // not the terminator: again
sub x0, x1, x0 // cursor is one past the '\0'
sub x0, x0, 1
retand an array sum that counts down with subs, so the branch needs no cmp:
asm_sum:
mov x2, x0
mov x0, 0
cbz x1, 2f // n == 0: nothing to add
1: ldr x3, [x2], 8
add x0, x0, x3
subs x1, x1, 1
b.ne 1b
2: retsubs sets the flags as it decrements, so b.ne needs no cmp; x2 walks the array by itself.Sizes come from the register names and the mnemonic: ldrb w2 loads one
byte zero-extended, ldrsb sign-extends, ldr w2 four bytes, ldr x2
eight. Sizes and pointers are unsigned — exit with b.hs/b.lo — while
signed data compares with gt/lt.
Nothing checks your bounds. Trap-mode UBSan only instruments compiled C++; inside hand-written assembly an off-by-one read is a wrong answer at best.
4. Non-leaf functions: the frame record
bl and blr put the return address in x30, destroying yours. So a function
that calls anything starts by saving x29 and x30 together — the frame
record — and links x29 to it:
stp allocates all 48 bytes and stores the frame record; x19–x22 go in pairs above it. Every size is a multiple of 16.stp stores a pair — 16 bytes in one instruction — and the pre-index
[sp, -48]! allocates the whole frame at once. Every sp adjustment is a
multiple of 16: AAPCS64 requires sp to be 16-byte aligned whenever it's used
for a memory access, and Linux on real hardware faults on a misaligned
sp-relative load or store (qemu doesn't check, so the tests use a probe).
The epilogue mirrors it:
ldp x21, x22, [sp, 32]
ldp x19, x20, [sp, 16]
ldp x29, x30, [sp], 48 // post-index: restore and free in one go
retGCC does exactly the same; compare its code for a function that keeps two values alive across calls:
keep: // long a = g2(x); return a + g2(x + 1) + x;
stp x29, x30, [sp, -32]!
mov x29, sp
stp x19, x20, [sp, 16]
mov x20, x0 // x survives the calls in x20
bl g2
mov x19, x0 // first result in x19
add x0, x20, 1
bl g2
add x0, x19, x0
add x0, x0, x20
ldp x19, x20, [sp, 16]
ldp x29, x30, [sp], 32
ret5. Calling back into C++
Calling through a function pointer is blr. Everything you need after the
call must be in x19–x28 (saved first), because the callee may destroy
x0–x17:
#include <crucible/abi.hpp>
#include <cstdio>
extern "C" long map_sum(const long* a, long n, long (*f)(long));
asm(R"(
.text
.globl map_sum
map_sum:
stp x29, x30, [sp, -48]!
mov x29, sp
stp x19, x20, [sp, 16]
stp x21, x22, [sp, 32]
mov x19, x0 // cursor
add x20, x0, x1, lsl 3 // end
mov x21, x2 // f
mov x22, 0 // sum
1: cmp x19, x20
b.hs 2f
ldr x0, [x19], 8
blr x21
add x22, x22, x0
b 1b
2: mov x0, x22
ldp x21, x22, [sp, 32]
ldp x19, x20, [sp, 16]
ldp x29, x30, [sp], 48
ret
)");
long square(long v) { return v * v; }
int main() {
long a[] = {1, 2, 3, 4};
auto r = crucible::abi::call(map_sum, (const long*)a, 4L, square);
std::printf("sum %ld, clobbered %zu register(s)\n", r.ret, r.clobbered.size());
crucible::abi::reset_probe();
map_sum(a, 1, crucible::abi::probe);
std::printf("sp aligned at the call: %d\n", crucible::abi::probe_aligned());
}crucible::abi::call runs the function with sentinel values in x19–x29 and
d8–d15 and reports any that changed; abi::probe is a callback that records
sp so you can check its alignment. The build's tests use both.
6. Tail calls
If a call is the last thing a function does, it can branch instead of call:
tail: // return g2(x + 1);
add x0, x0, 1
b g2 // x30 still holds our caller's addressg2's ret goes straight back to tail's caller. No frame record, no stack —
and a recursive function in tail position runs in constant space.
7. A checklist before you ret
- Is the result in x0 (w0 for 32-bit, d0/s0 for floating point)?
- Did you write any of x19–x29 or d8–d15? Then you saved them, in pairs, and restored them in reverse order.
- Did you call anything? Then x30 was saved in a frame record — and every sp change was a multiple of 16.
- Does every loop handle n = 0 (and a null pointer with n = 0)?
- Does every exit test use the right signedness (
hs/lofor sizes and pointers,gt/ltfor signed data)?