Part IIInstruction Set Architectures

Procedure Calling Conventions

September 8, 202633 min readintermediate

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.

Why the register file is split in two

Every convention in this chapter divides the general-purpose registers into two groups, and the division looks arbitrary until you ask what a compiler does with it. One group survives a call and one does not. RISC-V calls them s0–s11 and t0–t6, AArch64 calls them X19–X28 and X9–X15, and the System V ABI splits the same way without giving the groups names. In all three the callee promises to leave the first group exactly as it found it, and promises nothing at all about the second.

The reason for two groups rather than one is that a value has a lifetime, and the compiler knows what that lifetime is. Consider a function holding two live values while it makes a call. One of them is read again after the call returns. The other is dead by then, its last use already behind the call site. If every register were callee-saved, the callee would save and restore both, and the work spent on the second value would be wasted. If every register were caller-saved, the caller would spill both around the call, and the same work would be wasted in the other direction. Figure 1 draws the two lifetimes against the call and shows which group each value belongs in.

Figure 1
Figure 1. A value whose lifetime crosses a call site and a value whose lifetime does not. The compiler already knows which is which, so the convention gives it two register classes and lets it choose. Putting every value in one class would spend save and restore work on the half that does not need it.

The split turns the question into a placement problem the register allocator is already solving. A value live across a call goes in a callee-saved register and is saved once in the callee’s prologue no matter how many callers there are. A value dead before the call goes in a caller-saved register and costs nothing anywhere. Only when the allocator runs out of one class does it start paying for the other.

This is also why the two groups are roughly the same size in every convention on the list. Make the callee-saved group too large and short leaf functions pay a prologue they do not need. Make it too small and long functions spill around every call. Eight to twelve registers of each kind is where four independent committees landed, which is weak evidence that the shape of real code, rather than any one machine, is what fixes the number.

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)

RegisterABI nameRoleSaver
x0zeroHardwired zero—
x1raReturn addressCaller
x2spStack pointerCallee
x3gpGlobal pointer—
x4tpThread pointer—
x5–x7t0–t2TemporariesCaller
x8s0/fpSaved / frame pointerCallee
x9s1Saved registerCallee
x10–x11a0–a1Args / return valuesCaller
x12–x17a2–a7ArgumentsCaller
x18–x27s2–s11Saved registersCallee
x28–x31t3–t6TemporariesCaller

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

Figure 2
Figure 2. The shape of one RISC-V stack frame. The stack grows toward lower addresses, so the frame is drawn with the caller above it. Callee-saved registers are spilled at entry and restored before return, which is what makes the saver column of the table below a contract rather than a convention. Only arguments past the eighth need stack slots. The first eight travel in a0–a7.

Figure 2 shows the layout one frame takes.

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.

Riscv
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)

RegisterRoleSaver
X0–X7Arguments / return valuesCaller
X8Indirect result locationCaller
X9–X15TemporariesCaller
X16–X17Intra-procedure-call scratch (IP0, IP1)Caller
X18Platform register (reserved by OS)Special
X19–X28Callee-saved registersCallee
X29Frame pointer (FP)Callee
X30Link register (LR)Caller
SPStack pointerCallee

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.

Code
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

RegisterRoleSaver
RAXReturn value, syscall numberCaller
RBXCallee-savedCallee
RCX4th integer argumentCaller
RDX3rd integer argument, 2nd returnCaller
RSI2nd integer argumentCaller
RDI1st integer argumentCaller
RBPFrame pointer (optional)Callee
RSPStack pointerCallee
R85th integer argumentCaller
R96th integer argumentCaller
R10Temporary, static chain pointerCaller
R11TemporaryCaller
R12–R15Callee-savedCallee

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.

How a structure is assigned to registers

Passing an integer is easy because an integer fits a register. Passing a structure is not, and the System V ABI answers the question with an algorithm rather than a rule of thumb. The algorithm is worth walking through because it explains a class of performance surprise that looks like compiler mischief and is actually the ABI doing exactly what it was told.

The structure is first cut into eight-byte pieces, which the specification calls eightbytes. Each eightbyte is then classified. If every field overlapping it is a float or a double, it is SSE and travels in a vector register. If any field overlapping it is an integer or a pointer, it is INTEGER and travels in a general register. And if the structure is larger than sixteen bytes, or contains an unaligned field, the whole thing is classified MEMORY and travels on the stack, with the caller passing a pointer to its copy. Figure 3 runs two structures through this.

Figure 3
Figure 3. Two structures classified by the System V AMD64 rules. The sixteen-byte structure fits in two registers of different kinds. The twenty-four-byte one crosses the size limit and goes to memory whole, even though all three of its fields would have fitted in vector registers.

The sixteen-byte limit is the part that catches people. A structure of three doubles is 24 bytes and goes to memory, so every call passing one by value writes 24 bytes to the stack and the callee reads them back. A structure of two doubles is 16 bytes and arrives in two vector registers with no memory traffic at all. Adding a third coordinate to a point type is a one-word source change that turns a register-resident argument into a memory round trip on every call.

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.

What va_list is actually made of

The System V treatment of variadic arguments is the one place where the ABI stops describing register assignments and starts describing a data structure. A variadic callee cannot know at compile time how many arguments arrived or what kinds they were, so it cannot know which registers hold them. The ABI solves this by having the callee spill the argument registers to a known place on entry, and by defining va_list as a cursor over that place.

A va_list on this ABI is a 24-byte structure with four fields. Two byte offsets say how far into the saved integer and vector registers the cursor has walked. Two pointers say where the saved registers live and where the arguments that never fit in registers begin. Figure 4 draws it.

Figure 4
Figure 4. The System V va_list. The two offsets walk through the register save area the callee wrote in its prologue. When an offset runs past the end of its bank, the cursor switches to the overflow area, which is where arguments beyond the register limit were placed by the caller.

Each va_arg of an integer type reads at reg_save_area + gp_offset and advances gp_offset by eight, until the offset reaches 48, at which point the six integer registers are exhausted and the read moves to overflow_arg_area. Floating-point arguments do the same against fp_offset and the 128-byte vector bank. The two cursors are independent, which is what lets a format string interleave integers and doubles in any order.

Two consequences follow from this and both are visible in ordinary code. A variadic function is more expensive to enter than a fixed one, because a call that passes two arguments still spills all six integer registers and, unless the caller signals through al that no vector register carries an argument, another 128 bytes besides. And AArch64 uses the same shape for the same reason, with its own field names, because the problem is created by the language rather than by the machine.

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

RegisterRoleSaver
RAXReturn valueCaller
RBXCallee-savedCallee
RCX1st integer argumentCaller
RDX2nd integer argumentCaller
RSICallee-savedCallee
RDICallee-savedCallee
RBPFrame pointer (optional)Callee
RSPStack pointerCallee
R83rd integer argumentCaller
R94th integer argumentCaller
R10–R11TemporariesCaller
R12–R15Callee-savedCallee

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

FeatureRISC-VAAPCS64SysV AMD64MS x64
Int arg regsa0–a7 (8)X0–X7 (8)6 regs4 regs
FP arg regsfa0–fa7 (8)V0–V7 (8)XMM0–7 (8)XMM0–3 (4)
Int returna0–a1X0–X1RAX, RDXRAX
Callee-saved int11+FP10+FP5+RBP7+RBP
Red zoneNoneNone128 bytesNone
Shadow spaceNoneNoneNone32 bytes
Stack alignment16 bytes16 bytes16 bytes16 bytes
Link registerra (x1)LR (X30)StackStack
Frame pointers0 (x8)X29RBPRBP

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.

C
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.

Riscv
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.

Code
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.

Code
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.

Code
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.

C
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.

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

The 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.

Code
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.

Code
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. [1](2024). “RISC-V.”
  2. [2](2024). “Procedure Call Standard for the Arm.”
  3. [3]Matz, Michael and Hubi\v c (2024). “System V.”
FeedbackBook mode
computer-architectureinstruction-set-architectures