The Core Distinction: ABI vs. Hardware

When a function call occurs on AArch64, the CPU itself doesn't automatically save registers based on a pre-defined table. The instruction responsible for function calls, BL (Branch with Link), is oblivious to the concepts of caller-saved or callee-saved registers. These categories are not hardware behaviors but are rules imposed by the Application Binary Interface (ABI) on the software.

Recognizing this fundamental difference simplifies understanding the entire calling convention. The ABI defines which party—the caller or the callee—is responsible for preserving the contents of specific registers. This is why some registers are designated as caller-saved and others as callee-saved. Crucially, a register's ABI classification offers no definitive statement on the longevity of the value it holds; that is ultimately a compiler's optimization decision.

Diagram illustrating AArch64 register usage in function calls

Caller-Saved Registers (X0-X18)

Registers X0 through X18 are classified as caller-saved. This means that if a caller function wishes to retain the value stored in one of these registers across a function call, it must explicitly save that value before making the call. The callee function has no obligation to preserve these registers. If the callee modifies a caller-saved register, the original value held by the caller is lost unless the caller took steps to back it up.

The caller typically saves these registers onto the stack if their values are important for subsequent computations after the function returns. This is a common strategy in compiled code, where the compiler manages the stack frame to ensure necessary values are preserved.

Callee-Saved Registers (X19-X30)

Conversely, registers X19 through X30 are designated as callee-saved. When a function (the callee) uses these registers, it is responsible for preserving their original values before returning to the caller. The caller can assume that if it doesn't need to save a value in these registers, they will retain their original contents after the function call completes.

A callee function that needs to use a callee-saved register must first save its current value, typically on the stack. After it has finished its operations and is about to return, it must restore the original value from the stack before returning control to the caller. This convention ensures that functions can use these registers for their internal computations without interfering with the caller's state.

Argument and Return Value Registers (X0-X7)

Registers X0 through X7 serve a dual purpose. They are used for passing the first eight arguments to a function. If a function is called with more than eight arguments, the excess arguments are passed on the stack. These same registers are also used to return values from a function. A function can return up to four 64-bit values (or equivalent) in X0-X3. If more return values are needed, they are typically returned via memory pointed to by a register.

Importantly, X0-X7 are also classified as caller-saved. This means that if a caller needs the value of an argument passed in X0-X7 after the function call, it must save it beforehand. The callee is free to overwrite these registers with its own return values or intermediate computations.

Special Purpose Registers

Several other registers have specific roles within the AArch64 architecture and its calling convention:

  • SP (Stack Pointer): This register (X28) always points to the top of the stack. It is crucial for managing stack frames, allocating local variables, and saving registers.
  • LR (Link Register): This register (X30) stores the return address when a function is called using BL. The callee must preserve the LR if it makes further function calls, typically by pushing it onto the stack.
  • PC (Program Counter): This register holds the address of the next instruction to be executed. It is not directly accessible or modifiable by standard function calls in the way other registers are.
  • XZR/WZR (Zero Register): This is a read-only register that always returns zero when read. It is useful for explicitly setting a register to zero.

The Compiler's Role in Register Allocation

While the ABI defines the rules, the compiler plays a pivotal role in how these rules are applied in practice. When you write code, especially in higher-level languages like C++, the compiler performs extensive optimizations. It determines whether a variable's value can be kept in a register throughout its lifetime or if it needs to be spilled to the stack.

The compiler leverages its knowledge of the calling convention to make these decisions. For instance, if a value is needed after a function call and resides in a caller-saved register, the compiler will generate code to save it before the call and restore it afterward. Similarly, it ensures that callee-saved registers are properly managed when a function uses them.

Understanding these conventions is not just an academic exercise for assembly programmers. For developers working with performance-critical code, embedded systems, or low-level debugging, a grasp of register allocation and the ABI's implications can unlock significant performance gains and provide deeper insights into program execution. It allows for more informed manual optimizations and a better understanding of the machine code generated by compilers.

Why Does This Matter?

The distinction between caller-saved and callee-saved registers is fundamental to writing correct and efficient code for AArch64. It dictates the responsibilities of functions when interacting with each other. A violation of these conventions can lead to subtle bugs that are notoriously difficult to track down, often manifesting as corrupted data or incorrect program flow.

For developers, this knowledge is essential when:

  • Writing assembly code or inline assembly.
  • Debugging complex issues, especially those involving function calls and register state.
  • Optimizing performance-critical routines.
  • Understanding the output of compilers and linkers.

Ultimately, the ABI provides a contract between functions. Adhering to this contract ensures interoperability and predictable behavior across different parts of a program and potentially across different compilation units or even different compilers.