CodeRigor

FREE LESSON

Writing functions in AArch64 assembly

Write whole functions in AArch64 assembly (GAS): frame records with stp/ldp, saving callee-saved registers in pairs, post-index loops over arrays and strings, and calling back into C++ with blr while keeping sp 16-byte aligned.

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:

C++
#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 (nearest 1: backwards), b.eq 2f (nearest 2: forwards) — so several functions, or a header included twice, never clash;
  • extern "C" so the symbol is add3, not a mangled C++ name.

2. The register contract (AAPCS64), from the writer's side

What you may destroy, and what you must put back
Faint: destroy freely (and so may anything you call). Green: save before writing, restore before ret. Hover for details.
What you may destroy, and what you must put back
Faint: destroy freely (and so may anything you call). Green: save before writing, restore before 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:

Four ways to load through x0
start / 4
Step through: offset leaves x0 alone; pre-index moves it before the load, post-index after.
Four ways to load through x0
Step through: offset leaves x0 alone; pre-index moves it before the load, post-index after.

Post-index is the natural "read the next element" for loops. A strlen:

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

and an array sum that counts down with subs, so the branch needs no cmp:

Assembly
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:  ret
asm_sum({3, 1, 4}, 3): post-index load, count down with subs
start / 4
subs sets the flags as it decrements, so b.ne needs no cmp; x2 walks the array by itself.
asm_sum({3, 1, 4}, 3): post-index load, count down with subs
subs 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:

After stp x29, x30, [sp, -48]! ; mov x29, sp
One pre-indexed stp allocates all 48 bytes and stores the frame record; x19–x22 go in pairs above it. Every size is a multiple of 16.
After stp x29, x30, [sp, -48]! ; mov x29, sp
One pre-indexed 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:

Assembly
    ldp x21, x22, [sp, 32]
    ldp x19, x20, [sp, 16]
    ldp x29, x30, [sp], 48     // post-index: restore and free in one go
    ret

GCC does exactly the same; compare its code for a function that keeps two values alive across calls:

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

5. 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:

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

Assembly
tail:                          // return g2(x + 1);
    add x0, x0, 1
    b   g2                     // x30 still holds our caller's address

g2'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

  1. Is the result in x0 (w0 for 32-bit, d0/s0 for floating point)?
  2. Did you write any of x19–x29 or d8–d15? Then you saved them, in pairs, and restored them in reverse order.
  3. Did you call anything? Then x30 was saved in a frame record — and every sp change was a multiple of 16.
  4. Does every loop handle n = 0 (and a null pointer with n = 0)?
  5. Does every exit test use the right signedness (hs/lo for sizes and pointers, gt/lt for signed data)?

Watch & read

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