Procedure Calling Conventions
August 3, 2026·24 min read·intermediate
When one function calls another, the two functions must agree on where arguments live, where the return value goes, and which registers the called function may destroy. Without such an agreement, a compiler…
When one function calls another, the two functions must agree on where arguments live, where the return value goes, and which registers the called function may destroy. Without such an agreement, a compiler could place the first argument in register X3 while the called function expects it in X0, and the program would silently produce wrong results. The agreement is the calling convention, and it is recorded in the platform’s ABI specification, not in the ISA manual.
This chapter examines four calling conventions in use today: the RISC-V standard (“ilp32d” and “lp64d” ABIs), the ARM AArch64 Procedure Call Standard (AAPCS64), the System V AMD64 ABI (used on Linux, macOS, and most Unix-like systems on x86-64), and the Microsoft x64 ABI (used on Windows). For each convention, the chapter describes the register partition into caller-saved and callee-saved sets, the argument-passing rules, the stack-frame layout, and the mechanics of variadic functions. A side-by-side comparison table at the end draws the four conventions together.
01.Why Conventions Exist
An ISA defines what a processor can do. A calling convention defines what the software agrees to do. The distinction is important. Nothing in the RISC-V hardware prevents a function from using register a0 as a scratch register and returning a value in t3. But if every function follows the convention that arguments arrive in a0–a7 and the return value goes back in a0, then any function compiled by any conforming compiler can call any other function, including functions in libraries compiled years earlier.
Calling conventions also interact tightly with the hardware. The partition of registers into caller-saved (scratch) and callee-saved (preserved) sets determines how much spill traffic the prologue and epilogue generate. A convention that marks too many registers as callee-saved forces every function to save and restore registers it may not use. A convention that marks too many as caller-saved forces the caller to save live values before every call. The balance is an empirical optimization that ABI designers tune using instruction-count measurements on representative workloads.
02.RISC-V Calling Convention
The RISC-V calling convention is defined by the RISC-V ELF psABI specification [1]. Two ABIs are common: ilp32d for RV32 with hardware double-precision float, and lp64d for RV64 with hardware double-precision float. The register assignments are the same in both.
Register assignments
RISC-V names its 32 integer registers with both a numeric name (x0–x31) and an ABI name that indicates purpose:
Table 1. RISC-V integer register assignments (lp64d ABI)
| Register | ABI name | Role | Saver |
|---|---|---|---|
| x0 | zero | Hardwired zero | — |
| x1 | ra | Return address | Caller |
| x2 | sp | Stack pointer | Callee |
| x3 | gp | Global pointer | — |
| x4 | tp | Thread pointer | — |
| x5–x7 | t0–t2 | Temporaries | Caller |
| x8 | s0/fp | Saved / frame pointer | Callee |
| x9 | s1 | Saved register | Callee |
| x10–x11 | a0–a1 | Args / return values | Caller |
| x12–x17 | a2–a7 | Arguments | Caller |
| x18–x27 | s2–s11 | Saved registers | Callee |
| x28–x31 | t3–t6 | Temporaries | Caller |
The 12 callee-saved registers (s0–s11) give functions generous preserved storage, and sp is preserved across calls as well. The 16 caller-saved registers (ra, t0–t6, a0–a7) give the compiler plenty of scratch space for expression evaluation and argument staging.
Floating-point registers follow a parallel split: fa0–fa7 for arguments and return values, ft0–ft11 for temporaries, and fs0–fs11 for callee-saved state.
Argument passing
Integer and pointer arguments are passed in a0 through a7 (eight registers). Floating-point arguments go in fa0 through fa7. Arguments that do not fit in registers spill to the stack in memory order, starting at the stack pointer. The return value goes in a0 (and a1 for a 128-bit return value on RV64).
Structures of up to two machine words (16 bytes on RV64) are passed in register pairs. Larger structures are passed by reference: the caller allocates space and passes a pointer.
Stack frame layout
The stack grows downward (toward lower addresses). The frame pointer (s0) points to the top of the current frame, and the stack pointer (sp) points to the bottom. A typical prologue saves ra and s0, adjusts sp, and sets s0 to the new sp value:
RISC-V function prologue saving the return address and frame pointer.
| addi sp, sp, -32 # allocate 32 bytes | |
| sd ra, 24(sp) # save return address | |
| sd s0, 16(sp) # save old frame pointer | |
| addi s0, sp, 32 # set frame pointer |
The epilogue reverses the sequence. RISC-V defines no red zone: a signal handler or interrupt may clobber memory below sp at any time.
Variadic functions
For variadic functions (functions declared with ... in C), the convention requires all variadic arguments to be passed in the integer registers a0–a7, even if some arguments are floating-point. The callee discovers argument types through a format string or a count argument, not through the ABI itself. If more than eight arguments are passed, the extras spill to the stack in the usual way.
03.AArch64 Procedure Call Standard (AAPCS64)
The AArch64 Procedure Call Standard is maintained by ARM and defined in the “Procedure Call Standard for the Arm 64-bit Architecture” document [2]. It is used on Linux, Android, macOS on Apple Silicon, and all other AArch64 operating systems except Windows on ARM (which uses its own variant).
Register assignments
Table 2. AArch64 integer register assignments (AAPCS64)
| Register | Role | Saver |
|---|---|---|
| X0–X7 | Arguments / return values | Caller |
| X8 | Indirect result location | Caller |
| X9–X15 | Temporaries | Caller |
| X16–X17 | Intra-procedure-call scratch (IP0, IP1) | Caller |
| X18 | Platform register (reserved by OS) | Special |
| X19–X28 | Callee-saved registers | Callee |
| X29 | Frame pointer (FP) | Callee |
| X30 | Link register (LR) | Caller |
| SP | Stack pointer | Callee |
The 11 callee-saved registers (X19–X28 plus FP) are fewer than RISC-V’s 12, and the 19 caller-saved registers (X0–X17 plus LR) are more. The asymmetry reflects ARM’s observation that most functions are short and do not need many preserved registers.
The SIMD and floating-point registers V0–V31 are split similarly: V0–V7 for arguments and return values, V8–V15 callee-saved (only the lower 64 bits, D8–D15, must be preserved), and V16–V31 caller-saved.
Argument passing
Arguments are classified into “Next General-purpose Register Number” (NGRN) and “Next SIMD and Floating-point Register Number” (NSRN) categories. Integer and pointer arguments fill X0–X7. Floating-point and SIMD arguments fill V0–V7. When registers are exhausted, arguments spill to the stack in natural alignment order.
Structures up to 16 bytes are passed in one or two registers. Larger structures are passed by reference (the caller copies the structure to a temporary location and passes a pointer in X8 if the structure is returned, or on the stack if it is an argument).
Stack frame layout
The stack grows downward, and the stack pointer must be 16-byte aligned at all times. AArch64 hardware enforces this alignment: a misaligned SP triggers an alignment fault. The frame pointer (X29) and link register (X30) are typically saved as a pair at the top of the frame using the STP (store pair) instruction:
AArch64 function prologue using STP to save frame pointer and link register.
| STP X29, X30, [SP, #-32]! // push FP and LR, allocate 32 bytes | |
| MOV X29, SP // set new frame pointer |
The pre-indexed writeback (!) adjusts SP in the same instruction as the store. The epilogue uses LDP with post-indexed writeback.
Variadic functions
AAPCS64 requires variadic arguments to be passed in the same registers as non-variadic arguments (X0–X7 for integer, V0–V7 for floating-point). The callee typically saves all argument registers to the stack at the start of the function and then walks the argument list through the saved region. The va_list structure records the current position in both the general-purpose and SIMD register save areas.
04.System V AMD64 ABI
The System V AMD64 ABI is the standard calling convention on Linux, macOS, FreeBSD, and most other Unix-like systems running on x86-64. It was designed by the x86-64 ABI working group and is maintained as a supplement to the System V ABI [3].
Register assignments
Table 3. System V AMD64 ABI integer register assignments
| Register | Role | Saver |
|---|---|---|
| RAX | Return value, syscall number | Caller |
| RBX | Callee-saved | Callee |
| RCX | 4th integer argument | Caller |
| RDX | 3rd integer argument, 2nd return | Caller |
| RSI | 2nd integer argument | Caller |
| RDI | 1st integer argument | Caller |
| RBP | Frame pointer (optional) | Callee |
| RSP | Stack pointer | Callee |
| R8 | 5th integer argument | Caller |
| R9 | 6th integer argument | Caller |
| R10 | Temporary, static chain pointer | Caller |
| R11 | Temporary | Caller |
| R12–R15 | Callee-saved | Callee |
The convention passes the first six integer arguments in registers (RDI, RSI, RDX, RCX, R8, R9), a design driven by the observation that the vast majority of C functions take six or fewer arguments. Floating-point arguments go in XMM0–XMM7 (eight registers). The return value goes in RAX (and RDX for 128-bit returns). Floating-point returns use XMM0 (and XMM1 for 128-bit floating-point returns).
Only six integer registers are callee-saved (RBX, RBP, R12–R15), the fewest of any of the four conventions discussed in this chapter. The aggressive caller-saved policy minimizes prologue and epilogue cost for short functions at the expense of more spills around call sites in long functions with many live variables.
The red zone
The System V AMD64 ABI defines a 128-byte red zone below the current stack pointer. A leaf function (one that makes no function calls) may use this region for local variables without adjusting RSP. The red zone saves the sub rsp, N / add rsp, N pair in the prologue and epilogue of leaf functions, which are the majority of functions in a typical program.
The red zone is safe because signal handlers and interrupts on Unix-like systems adjust the stack pointer before writing below it. Kernel code and interrupt handlers must not use the red zone because a nested interrupt could clobber it.
Stack alignment
The stack must be 16-byte aligned at the point of a CALL instruction. Because CALL pushes an 8-byte return address, the stack is 16-byte aligned minus 8 at function entry. The prologue must adjust the stack to restore 16-byte alignment before any SSE memory operations, which fault on misaligned 128-bit accesses.
Variadic functions
The System V ABI passes variadic arguments in the same registers as fixed arguments (RDI, RSI, RDX, RCX, R8, R9 for integer, XMM0– XMM7 for floating-point). The callee must save all potentially used argument registers to a “register save area” on the stack at function entry. The register AL (the low byte of RAX) is set by the caller to the number of XMM registers used for variadic floating-point arguments, allowing the callee to skip saving unused XMM registers.
05.Microsoft x64 ABI
The Microsoft x64 ABI is the calling convention on 64-bit Windows. It differs from the System V AMD64 ABI in several significant ways, and code compiled for one convention cannot directly call code compiled for the other without an explicit thunk or wrapper.
Register assignments
Table 4. Microsoft x64 ABI integer register assignments
| Register | Role | Saver |
|---|---|---|
| RAX | Return value | Caller |
| RBX | Callee-saved | Callee |
| RCX | 1st integer argument | Caller |
| RDX | 2nd integer argument | Caller |
| RSI | Callee-saved | Callee |
| RDI | Callee-saved | Callee |
| RBP | Frame pointer (optional) | Callee |
| RSP | Stack pointer | Callee |
| R8 | 3rd integer argument | Caller |
| R9 | 4th integer argument | Caller |
| R10–R11 | Temporaries | Caller |
| R12–R15 | Callee-saved | Callee |
The first four integer arguments go in RCX, RDX, R8, and R9 (not RDI, RSI, RDX, RCX as in System V). Only four register arguments are provided, compared to six on System V. Floating-point arguments go in XMM0–XMM3 (four registers, compared to eight on System V). The 5th and subsequent arguments go on the stack.
The Microsoft convention preserves RSI and RDI as callee-saved, while System V treats them as caller-saved argument registers. This difference means that a function compiled for one convention cannot safely call a function compiled for the other without saving and restoring the disputed registers.
Shadow space
The most distinctive feature of the Microsoft x64 ABI is the shadow space (also called “home space” or “register parameter area”). The caller must allocate 32 bytes (four 8-byte slots) above the return address on the stack before every call, regardless of the number of arguments. The callee may use this space to spill the four register arguments without allocating additional stack space.
The shadow space simplifies the implementation of variadic functions and structured exception handling on Windows. It also provides a uniform location for debuggers to find argument values. The cost is 32 bytes of stack consumed by every function call, even for functions that take zero arguments.
Stack alignment
The stack must be 16-byte aligned before the CALL instruction, identical to the System V requirement. After the call, the 8-byte return address plus the 32-byte shadow space puts the stack at RSP + 40 at function entry, which is 16-byte aligned minus 8. The function prologue must adjust to restore alignment.
Variadic functions
The Microsoft x64 ABI passes variadic arguments in the same four registers as fixed arguments (RCX, RDX, R8, and R9), with the fifth and later arguments on the stack. One rule is specific to variadic calls. A floating-point argument passed to a variadic function must be placed in both the XMM register and the corresponding integer register, because the callee has no prototype for the variadic part of the list and cannot know which register class to read. A function such as printf therefore finds a double third argument in R8 as well as in XMM2.
The callee implements va_start by spilling RCX, RDX, R8, and R9 into the shadow space that its caller already allocated. Because the shadow space sits immediately below the stack arguments, the spilled registers and the stack arguments form one contiguous array, and va_list is a single pointer that walks it. That is simpler than the System V arrangement, which needs a dedicated register save area and a va_list that tracks the general-purpose and SSE save areas separately.
Exception handling and unwind tables
Windows structured exception handling (SEH) requires every non-leaf function to have an entry in the .pdata section that describes its prologue and stack frame layout. The unwind information in the .xdata section allows the exception dispatcher to walk the stack without frame pointers. The Microsoft ABI therefore makes frame pointers optional: if the function has unwind data, the debugger and exception handler can reconstruct the stack without RBP.
On Unix, the equivalent mechanism is the DWARF .eh_frame section, used by both System V AMD64 and the RISC-V and AArch64 ABIs.
06.Side-by-Side Comparison
Table 5. Calling convention comparison across four ABIs
| Feature | RISC-V | AAPCS64 | SysV AMD64 | MS x64 |
|---|---|---|---|---|
| Int arg regs | a0–a7 (8) | X0–X7 (8) | 6 regs | 4 regs |
| FP arg regs | fa0–fa7 (8) | V0–V7 (8) | XMM0–7 (8) | XMM0–3 (4) |
| Int return | a0–a1 | X0–X1 | RAX, RDX | RAX |
| Callee-saved int | 11+FP | 10+FP | 5+RBP | 7+RBP |
| Red zone | None | None | 128 bytes | None |
| Shadow space | None | None | None | 32 bytes |
| Stack alignment | 16 bytes | 16 bytes | 16 bytes | 16 bytes |
| Link register | ra (x1) | LR (X30) | Stack | Stack |
| Frame pointer | s0 (x8) | X29 | RBP | RBP |
Several patterns emerge from the table. All four conventions align the stack to 16 bytes, reflecting the needs of SIMD instructions that require aligned memory operands. RISC-V and AArch64 use a dedicated link register to hold the return address, avoiding the stack push that x86-64 requires on every CALL. The Microsoft x64 ABI is the most conservative, with only four register arguments and mandatory shadow space, but this conservatism simplifies Windows exception handling.
07.Concrete Assembly Examples
To make the conventions tangible, consider the C function:
A simple function that adds two integers and returns the result.
| long add_two(long a, long b) { | |
| return a + b; | |
| } |
The following subsections show the compiled assembly for each ISA.
RISC-V
RISC-V assembly for add_two. Arguments arrive in a0 and a1, result returns in a0. A leaf function with no stack frame.
| add_two: | |
| add a0, a0, a1 | |
| ret # pseudo-instruction for jalr x0, ra, 0 |
Two instructions, no stack manipulation. The function is a leaf, so no prologue or epilogue is needed.
AArch64
AArch64 assembly for add_two. Arguments in X0 and X1, result in X0.
| add_two: | |
| ADD X0, X0, X1 | |
| RET // branches to LR (X30) |
Identical structure to the RISC-V version. The link register holds the return address.
x86-64 (System V)
x86-64 assembly for add_two under the System V ABI. First argument in RDI, second in RSI, result in RAX.
| add_two: | |
| lea rax, [rdi + rsi] | |
| ret |
The LEA (load effective address) instruction computes the sum without touching the flags register, a common idiom when the result must go into a register different from either source. The RET instruction pops the return address from the stack and jumps to it.
x86-64 (Microsoft)
x86-64 assembly for add_two under the Microsoft x64 ABI. First argument in RCX, second in RDX, result in RAX.
| add_two: | |
| lea rax, [rcx + rdx] | |
| ret |
The structure is the same as the System V version, but the argument registers differ. RCX holds the first argument instead of RDI, and RDX holds the second instead of RSI.
08.A Non-Leaf Function Example
A non-leaf function must save the return address and any callee-saved registers it uses. Consider:
A non-leaf function that calls add_two and multiplies the result by a saved value.
| long scale(long a, long b, long factor) { | |
| long sum = add_two(a, b); | |
| return sum * factor; | |
| } |
RISC-V version
RISC-V assembly for scale. Must save ra and s0 because the function calls add_two.
scale:
addi sp, sp, -16 # allocate stack frame
sd ra, 8(sp) # save return address
sd s0, 0(sp) # save s0
mv s0, a2 # factor -> s0 (callee-saved)
call add_two # sum = add_two(a, b)
mul a0, a0, s0 # a0 = sum * factor
ld ra, 8(sp) # restore return address
ld s0, 0(sp) # restore s0
addi sp, sp, 16 # deallocate frame
retThe function saves ra because the call to add_two will overwrite it. It saves s0 because it uses s0 to hold factor across the call. The third argument (a2) must be moved to a callee-saved register because add_two is free to clobber a2.
AArch64 version
AArch64 assembly for scale. STP saves the frame pointer and link register in a single instruction.
| scale: | |
| STP X29, X30, [SP, #-32]! // save FP and LR | |
| MOV X29, SP | |
| STR X19, [SP, #16] // save callee-saved X19 | |
| MOV X19, X2 // factor -> X19 (callee-saved) | |
| BL add_two // sum = add_two(X0, X1) | |
| MUL X0, X0, X19 // X0 = sum * factor | |
| LDR X19, [SP, #16] // restore X19 | |
| LDP X29, X30, [SP], #32 // restore FP, LR; deallocate | |
| RET |
The structure mirrors the RISC-V version. STP and LDP save and restore two registers in a single instruction, which is more compact than the two separate stores on RISC-V.
x86-64 System V version
x86-64 System V assembly for scale. RBX is used as the callee-saved register for factor.
| scale: | |
| push rbx ; save callee-saved rbx | |
| mov rbx, rdx ; factor -> rbx | |
| call add_two ; sum = add_two(rdi, rsi) | |
| imul rax, rbx ; rax = sum * factor | |
| pop rbx ; restore rbx | |
| ret |
The PUSH and POP instructions handle the callee-saved register. The return address is pushed by CALL and popped by RET, both implicitly.
09.Stack Unwinding and Exception Handling
When a C++ exception is thrown, the runtime must walk backward through the call stack, invoking destructors for local objects in each frame, until it finds a matching catch handler. This process is stack unwinding, and it requires knowing the layout of each frame on the stack.
Frame pointers vs. unwind tables
The traditional approach is a frame-pointer chain: each function saves the caller’s frame pointer and sets its own frame pointer to the current stack position. The debugger or exception handler follows the chain of saved frame pointers to reconstruct the call stack.
Modern compilers often omit the frame pointer (-fomit-frame-pointer on GCC and Clang) because it frees one register for general use (RBP on x86-64, X29 on AArch64, s0 on RISC-V). Without frame pointers, the runtime relies on unwind tables stored in the binary’s metadata sections.
On Unix-like systems, the DWARF .eh_frame section describes each function’s prologue instructions and the location of each saved register relative to the canonical frame address (CFA). On Windows, the .pdata and .xdata sections serve the same purpose for SEH.
The cost of unwinding
Stack unwinding is rare (exceptions are, by design, exceptional), so the cost matters only when it happens. The unwind tables add to the binary size, typically 5–10 percent of the text section. On x86-64, the variable-length instruction encoding makes unwind-table generation more complex than on fixed-length ISAs because the table must track the stack-pointer adjustments precisely across instructions of different lengths. AArch64 and RISC-V benefit from fixed-length instructions: the unwind machinery can step through the prologue instruction by instruction with a uniform stride.
10.Worked Examples
11.Exercises
References
- [1](2024). “RISC-V.”
- [2](2024). “Procedure Call Standard for the Arm.”
- [3]Matz, Michael and Hubi\v c (2024). “System V.”