AArch64 Branches, Stack Frames, and Calling Convention

AArch64 control flow is explicit: branch instructions change the program counter, and a call records a return address in a register. Correct assembly also follows the ABI chosen by the operating system. This lesson introduces the common AAPCS64 model used by many Arm64 environments; it is not a substitute for a platform’s current ABI and linker documentation.

Branches and calls

InstructionMeaningTypical use
b labelPC-relative unconditional branchLoop or local jump
bl labelBranch and place return address in x30Direct call
br xNBranch to address in a registerValidated dispatch or veneer
blr xNIndirect call, setting LRFunction pointer call
retReturn, normally through x30Function exit

ret xN can name a different return register, but ordinary code uses ret. A non-leaf function that issues bl must preserve its own incoming LR before the next call if it will later return. br does not set LR, so confusing it with blr loses the return path. Targets should arise from controlled program data, not untrusted pointers.

Conditional branches interpret NZCV. After cmp x0, x1, b.eq tests equality, b.lt signed less-than, and b.lo unsigned lower-than. The signed/unsigned distinction is essential: the same bit pattern can be negative as a signed integer and enormous as an unsigned integer. cbz/cbnz test a register directly, while tbz/tbnz test one bit. These do not need a preceding cmp.

// w0 contains a non-negative count. Return the sum 1..count.
sum_to:
    mov w1, wzr
.loop:
    cbz w0, .done
    add w1, w1, w0
    sub w0, w0, #1
    b .loop
.done:
    mov w0, w1
    ret

The example is a pure function and deliberately uses only caller-clobbered registers. Test only with known inputs: fixed-width arithmetic wraps, so production code needs a stated overflow policy rather than assuming the mathematical sum is representable.

AAPCS64 at a working level

Under the base AAPCS64 convention, integer and pointer arguments use x0x7, and scalar results normally return in x0 (or w0 for a 32-bit result). Floating-point and vector arguments use v0v7. Additional arguments are placed by the caller on the stack according to the full classification rules. Structures, variadic calls, homogeneous aggregates, and large indirect results have details that require the specification rather than guesswork.

Register setABI statusResponsibility
x0x18Mostly temporary/specialCaller cannot expect values to survive a call
x19x28Callee-savedCallee restores any it changes
x29/x30FP/LR conventional pairSave LR in non-leaf code; FP policy depends on frame needs
spStack pointer16-byte aligned whenever used for memory access and at public interfaces
v8v15Partly callee-savedRead the vector preservation rule carefully

Maintain the AAPCS64 stack invariant: SP must be 16-byte aligned whenever it is used to access memory and at all public interfaces. In ordinary conforming code, keep SP aligned throughout rather than treating alignment as a requirement only at calls. Stacks conventionally grow toward lower addresses. A typical prologue saves FP and LR in one 16-byte allocation, then establishes FP; its inverse restores them:

example:
    stp x29, x30, [sp, #-16]!  // allocate 16 bytes; save FP and incoming LR
    mov x29, sp
    // body; preserve x19-x28 too if this function modifies them
    ldp x29, x30, [sp], #16    // restore, then release exactly the same space
    ret

A frame pointer is useful for debugging and unwinding but optimized functions can omit it. Leaf functions may need no frame. Do not infer a bug merely because a compiler chose a different valid layout; do require balanced stack adjustments, correct save/restore pairs, and matching unwind metadata when assembly participates in exceptions or stack traces.

Calls are contracts

Before a call, the caller classifies every argument and makes stack-passed arguments available in the required locations. After the call, it may use the result but must assume volatile registers changed. The callee may use those temporary registers freely, but must return with preserved registers and SP restored. This division permits separately compiled C, C++, Rust, and assembly to cooperate. It also means a function that “works” until it calls a logging helper may have been relying accidentally on a temporary register.

x18 deserves special caution: the base standard leaves room for platform-specific use, and some environments reserve it. Treat it as unavailable unless the target ABI explicitly says otherwise. Likewise, x16 and x17 are commonly used as intra-procedure-call scratch registers by linkers and veneers. Do not expect values in either to survive an external call. The complete AAPCS64 document, rather than a short register table, controls difficult cases such as variadic functions and aggregate classification.

Unwinding is part of the interface, not decoration. A debugger, profiler, exception mechanism, or crash reporter can need to find saved FP/LR and stack adjustments. Compiler-generated assembly normally emits the required directives and metadata. If hand-written code must participate, follow the assembler and platform unwind guidance exactly; otherwise prefer a small leaf routine or a compiler intrinsic. A plausible-looking frame that lacks correct metadata can make diagnostics misleading even when ordinary return appears to work.

Reasoning about a branch

Read a branch in three steps. First identify what produced its condition: flags, a zero test, a bit test, or an unconditional target. Next state the domain: signed integer, unsigned integer, pointer comparison permitted by the source contract, or a bit mask. Finally follow both paths until they join and list the values each path establishes. This prevents the common error of treating a mnemonic such as b.ge as a complete high-level explanation without checking the preceding flag-setting width.

Loops also have an invariant and an exit condition. In sum_to, the remaining count is in w0 and the partial sum is in w1. At cbz, the body is skipped precisely when no terms remain. A debugger can confirm this using a few known, non-secret inputs, but a hand trace should come first. Avoid stepping through code that writes arbitrary addresses or receives untrusted control-flow data.

Branch range is another linker concern. Direct branch encodings have finite PC-relative reach. When a target is farther away, a linker can introduce a veneer that uses scratch registers and an indirect transfer. This is why external-call register assumptions must respect ABI scratch designations. Do not hand-patch branch offsets in a linked executable; rebuild so the assembler and linker maintain relocations, alignment, and platform metadata.

Conditional execution is intentionally limited compared with older Arm states. Short selection often uses csel and related conditional-select instructions rather than a branch. It can reduce unpredictable branching, but it evaluates register operands already available and does not make an invalid memory access safe. Profile a real workload before replacing clear control flow solely to avoid a branch.

Finally, labels describe assembly locations, not scoped source variables. Use local labels for internal control flow and expose only intentional entry points. A clear label, a short comment describing inputs and outputs, and an ABI note make later review much safer than clever branch layouts.

Keep control-flow examples small enough to trace completely. Once an invariant is clear, compiler output from a matching high-level function provides a useful second opinion, but the documented ABI remains the authority.

Platform boundaries matter

Linux ELF targets commonly use the AAPCS64 base convention with ELF relocations and DWARF tooling. Darwin/Apple targets use Mach-O, Apple’s platform ABI rules, and toolchain conventions that may add requirements beyond a generic Linux example. Do not copy Linux startup code, syscall numbers, dynamic-linking sequences, red-zone assumptions, or object-file flags into macOS. Conversely, a Linux system-call example is not portable C-call interface guidance: direct syscalls are Linux-kernel ABI details, and library calls are normally safer.

Modern systems can employ Pointer Authentication (PAC) and Branch Target Identification (BTI). Conceptually, PAC helps authenticate sensitive pointers such as return addresses, while BTI constrains valid indirect branch destinations. Exact instructions, compiler flags, ABI notes, and OS support vary. Let a platform compiler generate its mandated entry/return sequences and preserve its metadata; do not remove instructions because they look unfamiliar, and do not claim these features make unsafe memory access harmless.

Safe practice

  1. Annotate which registers a small function receives, returns, clobbers, and preserves.
  2. Write a non-leaf wrapper around a known pure function and save LR correctly. Inspect its object file rather than running arbitrary code.
  3. Given signed -1 and unsigned 1, choose b.lt versus b.lo after a comparison and explain why.
  4. Deliberately add eight bytes to a 16-byte frame on paper; identify why using that misaligned SP for a save or restore violates the invariant.

The existing board-specific Arm64 branch/GDB tutorial and GDB tutorial are useful supplements for code you built yourself.

References