Hello! Welcome to the final lesson in our "Advanced ARM Architectures" module.
In our last session, we explored the significant architectural shift from ARMv7 to ARMv8-A, focusing on the new AArch64 execution state and the hierarchical exception levels (EL0-EL3). You now have a solid understanding of the high-level structure that enables the security and virtualization features of modern ARM processors.
Today, we're going to zoom in from the architectural overview to the machine-code level. We will examine exactly how software functions communicate with each other in the AArch64 world. For someone with your deep experience in C and C++, this lesson will peel back the curtain on what the compiler does for every single function call you write, a process governed by a strict set of rules.
Our learning goal is to analyze AArch64 assembly code to explain the procedure call standard for passing parameters and returning values. This is a fundamental topic for low-level debugging, optimization, and interfacing C/C++ with assembly, making it a common subject in interviews for embedded driver roles.
Why We Need a "Standard" for Calling Functions
When you compile different parts of a large project, perhaps with different compilers or linking against pre-compiled libraries, how does it all work together? How does main() know how to pass arguments to calculate_crc() if they were compiled separately?
The answer is the Application Binary Interface (ABI). The ABI is a low-level contract that defines standards for data types, memory alignment, and, most importantly for us, how functions are called. The part of the ABI that specifies the rules for function calls is called the Procedure Call Standard (PCS). For AArch64, this is officially known as the "Procedure Call Standard for the Arm 64-bit Architecture," or AAPCS64.
To get a clear understanding of these concepts and their importance, please start by reading this introductory material.
AArch64 Procedure Call Standard (AAPCS64): ABI, ...
The article 'AArch64 Procedure Call Standard (AAPCS64)' on Medium provides an excellent, easy-to-understand introduction to why ABIs and calling conventions are necessary for software interoperability.
Please read Section I, 'Calling Conventions, API and ABI'. This will set the stage by explaining the difference between an API (which you work with in C/C++) and an ABI (which the compiler and CPU work with).
The Rules of Engagement: AAPCS64
Now that you understand the why, let's dive into the what. The AAPCS64 defines a clear set of rules for register usage during function calls. Understanding these rules is like having a decoder ring for assembly language.
The definitive source for these rules is the Arm architecture documentation.
Armv8-A Instruction Set Architecture
This official document from Arm, 'Armv8-A Instruction Set Architecture', contains the authoritative description of the Procedure Call Standard.
Please read Section 11, 'Procedure Call Standard'. Pay close attention to the table that categorizes the registers. This section is concise and packed with the core rules we'll be discussing.
Let's break down and expand on the key rules from that document.
1. Parameter Passing and Return Values
- Arguments (Parameters): The first eight integer or pointer arguments to a function are passed in registers
X0throughX7.- If an argument is 32 bits (like
intoruint32_t), the correspondingWregister (W0-W7) is used. - If a function has more than eight arguments, the additional ones are passed on the stack.
- If an argument is 32 bits (like
- Return Value: A single integer or pointer result is returned in register
X0(orW0for a 32-bit value). - C++
thispointer: In C++, the implicitthispointer is passed inX0, so the first explicit argument to a member function would start inX1. - Large Return Values: If a function returns a large struct (too big for registers), the caller allocates space for it on the stack and passes a pointer to this space in register
X8.
2. Register Roles: Caller-Saved vs. Callee-Saved
This is the most critical concept in the PCS. The standard divides the general-purpose registers into two groups based on who is responsible for preserving their value across a function call.
PCS Register Rules Table from ARM documentation
This table from the ARM documentation categorizes registers X0-X30 according to the Procedure Call Standard.
-
Caller-Saved (Volatile / Corruptible) Registers:
X0-X18- A function being called (a "callee") is free to modify these registers for its own calculations without restriction.
- If the function that made the call (the "caller") needs the value in one of these registers to be preserved across the call, it is the caller's responsibility to save it (e.g., push it to the stack) before making the call and restore it afterward.
X9-X15are the primary temporary or "scratch" registers.
-
Callee-Saved (Non-Volatile) Registers:
X19-X28- A callee function must ensure that these registers have the same value when it returns as they did when it was called.
- If the callee needs to use one of these registers, it is the callee's responsibility to save the original value at the start of the function (in its prologue) and restore it just before returning (in its epilogue).
-
Special Registers:
X29(FP): Frame Pointer. It points to a fixed location within the current function's stack frame, allowing stable access to local variables even as the stack pointer moves.X30(LR): Link Register. Before branching to a function, the address of the instruction after the branch is placed inLR. TheRETinstruction uses this address to return to the caller.
Test your understanding!
You are writing a function in AArch64 assembly. You need a few registers for temporary calculations. Your function also calls another sub-routine. To maximize performance, which group of registers should you prefer to use for your temporary values, and why?
Show answer
You should prefer to use the caller-saved registers (X9-X15). Since your function is the "caller" when it calls the sub-routine, you know that the sub-routine is allowed to corrupt these registers. If your temporary values are not needed after the sub-routine call, you don't have to do anything. If they are needed, you save and restore them around the sub-routine call.
Using the callee-saved registers (X19-X28) would require you to save and restore them in your function's prologue and epilogue, which adds overhead even if the sub-routine you call doesn't touch them. You should only use callee-saved registers for values that need to persist across calls to other functions.
From C to Assembly: The "Rosetta Stone"
The best way to understand the PCS is to see it in action. A great technique is to write a small, targeted C function and then look at the assembly code the compiler generates.
This video demonstrates this "Rosetta Stone" technique brilliantly.
you can learn assembly FAST with this technique (arm64 breakdown)
The video 'you can learn assembly FAST with this technique' from the Low Level channel shows a practical, hands-on way to deduce a platform's calling convention by examining compiled C code.
Please watch from 04:04 to 07:38. This segment uses a C function to perfectly illustrate how arguments are passed into registers (x0, W1, W2, W3) before a function call and how the return value is placed in w0 within the called function.
Let's apply this ourselves. Consider this simple C function:
// C Code
#include <stdint.h>
uint64_t add_and_scale(uint64_t a, uint64_t b, uint64_t scale) {
uint64_t temp_val = a + b;
uint64_t result = temp_val * scale;
return result;
}
If we compile this for AArch64 (e.g., using aarch64-linux-gnu-gcc -S), we get the following assembly:
// AArch64 Assembly
add_and_scale:
// Function Prologue (none needed here as we don't use callee-saved registers)
// Body
add x9, x0, x1 // temp_val = a + b. Arguments a and b are in x0 and x1.
// Result is stored in a caller-saved register x9.
mul x0, x9, x2 // result = temp_val * scale. Argument scale is in x2.
// The final result is placed in x0 for returning.
// Function Epilogue
ret // Return to the address stored in the Link Register (LR/X30)
This simple example clearly shows:
- The input arguments
a,b, andscaleare expected to be in registersx0,x1, andx2, respectively. - The calculation uses a temporary (caller-saved) register,
x9. - The final result is placed in
x0just before returning. - The
retinstruction performs the return.
Now, let's look at a slightly more complex example involving callee-saved registers and the stack.
// C Code
#include <stdint.h>
// A dummy function for us to call
extern uint64_t some_other_function(uint64_t);
uint64_t complex_function(uint64_t a, uint64_t b) {
uint64_t persistent_value = a * 10;
uint64_t sub_result = some_other_function(b);
return persistent_value + sub_result;
}
The assembly for complex_function would look something like this:
// AArch64 Assembly
complex_function:
// --- PROLOGUE ---
// Save callee-saved registers we will use (x19) and the Link Register (x30)
// to the stack. Also adjusts the stack pointer.
stp x19, x30, [sp, #-16]!
// --- BODY ---
mov x19, x0 // Move argument 'a' (from x0) into a callee-saved register x19
// because we need its value AFTER the call to some_other_function.
mov x0, x1 // Prepare the argument for some_other_function.
// Move 'b' (from x1) into x0.
bl some_other_function // Call the function. x0-x18 might be changed. x19 is safe.
// The return address is saved in x30 (LR).
// After the call, the return value of some_other_function is in x0.
// The original 'a' is still safe in x19.
mov x1, #10 // Load immediate value 10 into a temporary register.
mul x19, x19, x1 // persistent_value = a * 10
add x0, x19, x0 // result = persistent_value + sub_result
// --- EPILOGUE ---
// Restore the original values of x19 and the Link Register from the stack.
// Adjusts the stack pointer back.
ldp x19, x30, [sp], #16
ret // Return
This example shows the full picture:
- Prologue: The function saves
x19(which it plans to use) andx30(the Link Register, because it's making a call itself) to the stack.stpmeans "Store Pair" of registers. - Callee-Saved Usage: It stores the value of
ainx19to protect it across thebl(Branch with Link) instruction. - Epilogue: It restores
x19andx30usingldp("Load Pair") before returning.
Interview Corner
Your grasp of the procedure call standard is a direct indicator of your comfort with low-level coding and debugging. Expect questions like these:
Q1: You're debugging a C function. You have a local variable that holds an important value. After you call a library function, your local variable's value is corrupted. What is the likely cause?
A: The compiler likely allocated that local variable to a caller-saved register (e.g., X9-X15). The library function, as the "callee," is permitted to overwrite these registers. When the library function returned, the original value was gone. This indicates the compiler didn't correctly save the value to the stack before the call, or the variable should have been allocated to a callee-saved register if its value needed to persist across the call.
Q2: What is the difference between the BL and RET instructions?
A: BL (Branch with Link) is used to call a function. It does two things: it stores the address of the next instruction into the Link Register (X30/LR), and then it branches to the target function's address. RET is used to return from a function. It simply branches to the address stored in the Link Register, effectively returning control to the caller.
Q3: According to the AAPCS64, for a function uint32_t foo(uint64_t a, uint32_t b), which registers are used to pass in a and b, and to return the result?
A: a is a 64-bit value, so it will be passed in X0. b is a 32-bit value and the second argument, so it will be passed in W1. The function returns a 32-bit value, so the result will be in W0. (This question is adapted from the LINK resource).
Conclusion
You've now connected high-level C/C++ function calls to the low-level machine instructions that execute them. This knowledge is not just academic; it's a practical tool for debugging tricky bugs, optimizing performance-critical code, and writing robust software that interfaces with assembly.
Key Takeaways:
- The AAPCS64 is the contract that enables function interoperability in AArch64.
- Arguments are passed in
X0-X7, and the return value is inX0. - Caller-saved registers (
X0-X18) are volatile and can be freely modified by a callee. - Callee-saved registers (
X19-X28) are non-volatile, and their values must be preserved by a callee. - The function prologue saves necessary registers; the epilogue restores them.
- The Link Register (
X30) is crucial for storing the return address.
Next Lesson Preview:
This lesson concludes our module on advanced ARM architectures. We have moved from the high-level exception model down to the instruction-by-instruction flow of a function call.
In our next module, "Multi-Core Programming Concepts," we will broaden our scope again. We'll move beyond the world of a single CPU core and explore the challenges and strategies involved in making multiple cores work together. Our first lesson will be to contrast Symmetric Multiprocessing (SMP) and Asymmetric Multiprocessing (AMP) architectures, which are foundational designs for the multi-core SoCs you see in complex automotive systems today.