Calling Conventions & ABIs (System V)
The System V AMD64 ABI is the treaty every function call on Linux honors — master it and disassembly becomes a conversation you can read fluently.
Overview
When a C function calls another C function, something remarkable happens beneath the source code: registers are shuffled, the stack is carefully aligned, arguments travel in a precise order, and when the callee returns, the caller's state is perfectly restored. None of this is magic — it is all governed by an Application Binary Interface (ABI), and on modern 64-bit Linux, that ABI is the System V AMD64 ABI.
The ABI is the treaty between compilers, linkers, operating systems, and hardware. It answers questions like: which register holds the first argument? Who is responsible for saving rbx before using it? What happens if the stack is misaligned when you call printf? Where can a leaf function stash temporary data without touching the stack pointer? Without a shared ABI, a function compiled by GCC and a function compiled by Clang could never call each other — even if both were written in standard C.
This module dissects the System V AMD64 ABI from first principles. You will learn to read the disassembly of any C function and understand every single instruction the compiler emits for function call setup and teardown. You will understand why the kernel itself must follow (and sometimes deliberately break) the same conventions, and why misalignment causes mysterious crashes exactly at the boundary of SSE instructions. By the end, reading raw assembly will feel like reading a conversation — one where you know all the rules both parties agreed to follow.
A word on scope: we focus on the x86-64 (AMD64) System V ABI, used on Linux, macOS, and most Unix-like systems. Windows uses a different ABI (Microsoft x64) with different register assignments — we will note key differences where they matter, but the System V ABI is what the kernel uses, what GCC targets by default, and what you need to read kernel disassembly.
The Linux kernel is written in C, but it lives at the boundary between C and hardware. Every system call, every interrupt handler, every context switch — all of them involve crossing between different layers of trust, and those crossings require meticulous attention to the ABI. When you read kernel entry code like arch/x86/entry/entry_64.S, every pushq, movq, and SAVE_REGS macro makes sense only if you know the calling convention. When you write an inline assembly block with asm volatile, the constraint strings ("=r", "&r", "memory") are the compiler's way of communicating ABI obligations. When you debug a kernel oops, the register dump maps directly to the ABI's definition of which registers hold what.
For contributions specifically: most kernel subsystems are C, but the architecture-specific glue — syscall entry, signal delivery, ptrace, kprobes, ftrace trampolines — is assembly that implements or wraps the ABI. Understanding the ABI is also essential for writing correct BPF programs, since BPF has its own calling convention that mirrors the kernel's, and for understanding how tools like perf, gdb, and DWARF unwinding use the ABI's frame pointer and red-zone conventions to walk the call stack. You cannot read a crash dump, write a reliable tracer, or contribute to arch/x86 without this knowledge being second nature.
System V AMD64 Register Roles in a Function Call
Data flow through a System V AMD64 function call: arguments enter via specific registers, the call instruction pushes the return address, the callee manages its frame and register preservation obligations, and the return value travels back in rax.
The concepts
01What an ABI Actually Is (and Why It Exists)
An ABI (Application Binary Interface) is a set of rules that governs how compiled binary code interacts with other compiled binary code and with the operating system. It is the binary-level analogue of an API: where an API specifies function signatures in source code, an ABI specifies the actual machine-level protocol for calling those functions.
The System V AMD64 ABI is defined in a formal document (maintained on the GitLab of the ABI working group) and covers:
- Calling conventions: which registers carry arguments, return values, and who saves what.
- Data type sizes and alignment:
intis 4 bytes,longis 8 bytes on 64-bit Linux (LP64 model), structs have specific padding rules. - Object file format: ELF (Executable and Linkable Format) sections, symbol visibility, relocation types.
- Dynamic linking: the PLT/GOT mechanism, TLS (thread-local storage) layout.
- Stack layout: how frames are organized, where the return address lives, what the red zone is.
For this module we focus entirely on the calling convention subset, since that is what you encounter in every assembly listing.
Why does it have to be written down? Because compilers are independent programs. GCC, Clang, and the Intel compiler all need to emit code that can call each other's compiled functions. The ABI is the shared contract. If GCC puts argument 1 in rdi but Clang reads argument 1 from rsi, every cross-compiled call would silently pass wrong values — a catastrophic and undetectable bug. The ABI prevents this.
# Verify the ABI on your own system:
# objdump -d shows compiled calling convention
gcc -O0 -o /tmp/demo demo.c && objdump -d /tmp/demo | grep -A20 '<main>'
02The Six Argument Registers (and the Order That Matters)
The System V AMD64 ABI assigns the first six integer/pointer arguments to fixed registers, in this exact order:
| Argument # | Register |
|---|---|
| 1st | rdi |
| 2nd | rsi |
| 3rd | rdx |
| 4th | rcx |
| 5th | r8 |
| 6th | r9 |
Arguments 7 and beyond go on the stack, pushed right-to-left (so that argument 7 is at the lowest address, closest to the top of stack after the return address).
Floating-point arguments travel in xmm0 through xmm7 (up to 8 FP args in registers). Integer and FP argument slots are counted separately: void f(int a, double b, int c) puts a in rdi, b in xmm0, and c in rsi.
The memory trick: a mnemonic used by many — "Diane's Silk Dress Cost Real Money" — maps to rdi, rsi, rdx, rcx, r8, r9 in order. You will memorize these within a week of reading assembly regularly.
Why these registers? Historical accident plus optimization. The original i386 ABI pushed all arguments on the stack. When AMD designed the 64-bit extension, they added eight new general-purpose registers (r8–r15), and the ABI designers took the opportunity to pass arguments in registers — far faster than memory, since modern CPUs have many more physical registers than the 16 architectural ones, and register arguments can often be optimized away entirely by the compiler.
You can observe this live with Compiler Explorer (godbolt.org): write any C function with 1–8 parameters and watch which registers the compiler assigns.
// Six arguments: observe register assignments in the disassembly
// Compile: gcc -O0 -S -o demo.s demo.c
#include <stdio.h>
void six_args(long a, long b, long c, long d, long e, long f) {
printf("%ld %ld %ld %ld %ld %ld\n", a, b, c, d, e, f);
}
int main(void) {
six_args(1, 2, 3, 4, 5, 6);
return 0;
}
/* Expected: the call site emits something like:
movl $1, %edi <- arg 1 -> rdi
movl $2, %esi <- arg 2 -> rsi
movl $3, %edx <- arg 3 -> rdx
movl $4, %ecx <- arg 4 -> rcx
movl $5, %r8d <- arg 5 -> r8
movl $6, %r9d <- arg 6 -> r9
call six_args
*/
03Return Values: What Comes Back and How
A function's return value travels back in rax (for integers and pointers up to 64 bits). For a 128-bit integer, rax holds the low 64 bits and rdx holds the high 64 bits. Floating-point return values use xmm0 (with xmm1 for a complex float pair).
Larger return types (structs, large arrays) trigger a hidden pointer mechanism: the caller allocates space for the return value (on its stack or wherever it sees fit), then passes a hidden first argument in rdi pointing to that space. The callee writes its result there and returns the same pointer in rax. This is why the calling convention effectively shifts all explicit arguments by one register when the return type is a large struct — argument 1 goes to rsi, not rdi.
Small structs (up to 16 bytes) can be returned in rax:rdx if their fields fit the integer or pointer class. The exact classification rules are detailed in the ABI document's §3.2.3 and are surprisingly nuanced — a 16-byte struct of two long values returns in rax:rdx, but a 16-byte struct containing a double and a long returns in xmm0:rax.
Practical implication for kernel work: the kernel's own syscall return convention also uses rax, but with negative values in the range [-4095, -1] representing -errno. This is not the C ABI but the kernel's internal syscall ABI; the C library (glibc/musl) translates between the two by checking if rax is in the error range and setting errno.
// Observe return value conventions
#include <stdint.h>
long return_long(void) { return 42; } // rax
double return_double(void) { return 3.14; } // xmm0
typedef struct { long x; long y; } Point;
Point return_point(void) { return (Point){1, 2}; } // rax:rdx
typedef struct { long x[3]; } Big;
Big return_big(void) { // hidden pointer in rdi
return (Big){{10, 20, 30}};
}
/* Disassemble with:
gcc -O1 -S -o ret.s ret.c && cat ret.s
Look for: movq, movaps, movq + movq (for Point), or memcpy into [rdi] (for Big) */
04Caller-Saved vs. Callee-Saved Registers (The Preservation Contract)
The ABI divides the 16 general-purpose registers into two groups based on who is responsible for preserving them across a call:
Caller-saved (a.k.a. "scratch" or "volatile") registers — the callee may freely overwrite these. If the caller needs their values after the call, the caller must save them before the call and restore them after.
| Caller-saved |
|---|
rax, rcx, rdx, rsi, rdi, r8, r9, r10, r11 |
Notice that all six argument registers are caller-saved. This makes sense: once you have called the function, the argument values are no longer needed by the caller (the callee has already consumed them), so there is no cost to letting the callee destroy them.
Callee-saved (a.k.a. "non-volatile" or "preserved") registers — if a callee wants to use these, it must push them on the stack at function entry and pop them before returning. The caller can assume these registers are unchanged after any call.
| Callee-saved |
|---|
rbx, rbp, r12, r13, r14, r15 |
Also: rsp must be restored to its entry value before ret (obviously, since ret pops the return address from the stack).
Analogy: imagine a shared whiteboard in an office. Caller-saved registers are like a section of whiteboard you own during a meeting — if someone else (the callee) needs to borrow the room, they might erase your notes. You must photograph them (save to stack) before leaving. Callee-saved registers are like the permanent marker section: anyone who borrows the room must restore exactly what was there.
Why does this division exist? It is a performance optimization. If all registers were callee-saved, every function would need to push/pop all of them — massive overhead for simple leaf functions. If all were caller-saved, every call site would need to save every live register. The ABI's split is a pragmatic compromise: argument/return registers need no saving (they carry transient data), while long-lived variables kept in rbx/r12–r15 can survive calls without spilling to memory.
// Callee-saved demonstration: look at the prologue/epilogue
void uses_callee_saved(long *arr, int n) {
long sum = 0; // compiler will keep 'sum' in rbx or r12
for (int i = 0; i < n; i++) {
sum += arr[i]; // call to external func inside loop would
} // force save/restore of sum's register
arr[0] = sum;
}
/* With -O2, compiler emits approximately:
pushq %rbx <- save rbx (callee-saved)
movq $0, %rbx <- sum = 0 in rbx
...loop...
movq %rbx, (%rdi) <- arr[0] = sum
popq %rbx <- restore rbx
ret
*/
// Compile and check:
// gcc -O2 -S -o cs.s cs.c && grep -A30 'uses_callee' cs.s
05The Stack Frame: Layout, Frame Pointer, and the Return Address
When a function is called, the CPU executes a call instruction which does two things atomically: it pushes the return address (the address of the next instruction in the caller) onto the stack, then jumps to the callee. The stack pointer rsp thus decrements by 8 (pointing to the return address).
A typical function prologue then does:
pushq %rbp ; save caller's frame pointer (rbp is callee-saved)
movq %rsp, %rbp ; establish our own frame pointer
subq $N, %rsp ; reserve N bytes for local variables
After the prologue, the stack looks like this (addresses decreasing downward):
[caller's frame ...]
arg 7 (if any) <- rsp + 16 (relative to callee's rbp)
return address <- rbp + 8
saved rbp (caller's) <- rbp + 0
local variable 1 <- rbp - 8
local variable 2 <- rbp - 16
... <- rsp (bottom of frame)
The epilogue mirrors the prologue:
movq %rbp, %rsp ; (or 'leave' which does this + popq %rbp)
popq %rbp ; restore caller's frame pointer
ret ; pop return address into rip
Frame pointer omission (-fomit-frame-pointer): modern compilers often omit rbp as a frame pointer entirely, freeing it as a general-purpose register. They instead use DWARF .cfi_* annotations in the object file to describe how to unwind the stack without rbp. The kernel compiles with -fno-omit-frame-pointer by default for reliable stack unwinding in perf, ftrace, and crash dumps — a deliberate ABI-adjacent policy decision.
Stack growth direction: on x86-64 (and nearly all architectures), the stack grows downward — toward lower addresses. rsp decrements to allocate, increments to free. This confuses beginners who think of stacks as growing up. Push is subtract-and-store; pop is load-and-add.
// Observe a real stack frame
// Compile: gcc -O0 -fno-omit-frame-pointer -S -o frame.s frame.c
long add(long a, long b) {
long result = a + b; // stored as local variable
return result;
}
/* Expected output (AT&T syntax):
add:
pushq %rbp
movq %rsp, %rbp
movq %rdi, -24(%rbp) ; spill arg a
movq %rsi, -32(%rbp) ; spill arg b
movq -24(%rbp), %rdx
movq -32(%rbp), %rax
addq %rdx, %rax
movq %rax, -8(%rbp) ; result = a+b
movq -8(%rbp), %rax ; return value in rax
popq %rbp
ret
*/
06Stack Alignment: The 16-Byte Rule and SSE Crashes
The System V AMD64 ABI requires that rsp is 16-byte aligned at the point of a call instruction. This means that on entry to any function (after the call has pushed the 8-byte return address), rsp is 16-byte aligned minus 8, i.e., rsp % 16 == 8.
Why 16 bytes? Because SSE and AVX instructions (like movaps, movdqa, vmovaps) operate on 128-bit or 256-bit values and require their memory operands to be naturally aligned. If you try to movaps [rsp], xmm0 with rsp % 16 != 0, you get a general protection fault — a crash that looks inexplicable unless you know this rule.
The convention in practice: before any call, you must ensure rsp % 16 == 0. Since call subtracts 8 (for the return address), rsp will be 16-byte aligned on entry to the callee (i.e., rsp % 16 == 8 after the push). The callee's pushq %rbp then restores alignment to 16. When the callee allocates local variables with subq $N, %rsp, it typically rounds N up to the nearest multiple of 16.
Common alignment bugs in hand-written assembly: if you push an odd number of 8-byte values before a call, you break alignment. The fix is to either push a dummy value (pushq $0) or subtract an extra 8 from rsp manually.
Verification: you can observe this with gdb: set a breakpoint at any function entry and print rsp % 16 — you should always see 8 (because the call already pushed the return address).
; Hand-written assembly: correct alignment before call
; File: align_demo.asm (NASM syntax)
section .text
global _start
extern printf
_start:
; Before this call, rsp is 16-byte aligned (OS guarantees it).
; 'call' will push 8 bytes -> rsp % 16 == 8 inside the callee.
; We need rsp % 16 == 0 RIGHT BEFORE the call instruction.
; Currently rsp % 16 == 0, so no adjustment needed.
lea rdi, [rel fmt] ; arg 1: format string
mov rsi, 42 ; arg 2: value
xor eax, eax ; al = 0: no xmm args
call printf ; rsp goes from 0-aligned to 8-aligned inside printf
; Exit
mov eax, 60
xor edi, edi
syscall
section .data
fmt: db "Value: %ld", 10, 0
; Build: nasm -f elf64 align_demo.asm && ld -o align_demo align_demo.o -lc --dynamic-linker /lib64/ld-linux-x86-64.so.2
07The Red Zone: 128 Bytes Below the Stack Pointer
The System V AMD64 ABI defines a red zone: a 128-byte area below rsp that a leaf function (one that does not call other functions) may use as scratch space without adjusting rsp. This space is guaranteed not to be clobbered by signal handlers or asynchronous interrupts — the OS and hardware context-switching machinery will not touch the 128 bytes below rsp of a user-space function.
Why does this exist? Performance. Every subq $N, %rsp and matching addq $N, %rsp in a function prologue/epilogue costs cycles. For tiny leaf functions (the most common case), the red zone eliminates this overhead entirely: the compiler can just use [rsp-8], [rsp-16], etc., directly.
What compilers do with it: GCC and Clang aggressively use the red zone in leaf functions when optimization is enabled. You will see code like:
mov %rdi, -8(%rsp) ; store arg 1 in red zone (no pushq needed)
mov %rsi, -16(%rsp) ; store arg 2 in red zone
...without any sub $N, %rsp at all.
Critical exception — the Linux kernel: the kernel compiles with -mno-red-zone. Why? Because in kernel mode, NMIs (Non-Maskable Interrupts) and MCEs (Machine Check Exceptions) can arrive at any point and use the same stack as the interrupted code. If kernel code were using the red zone and an NMI fired, the NMI handler's stack would overwrite the very memory the interrupted function was using. The kernel uses IST (Interrupt Stack Table) entries for some vectors, but the conservative and correct solution is simply to never use the red zone in kernel code.
This is a beautiful example of a point where the kernel deliberately diverges from the user-space ABI — not arbitrarily, but for a precise and defensible reason.
// Observe red zone usage: compile as leaf function with -O2
// gcc -O2 -S -o rz.s rz.c
long compute(long a, long b) {
// No calls made, so compiler uses red zone for temporaries
long t1 = a * b;
long t2 = t1 + a;
return t2 - b;
}
/* With -O2 this will likely be register-only (no stack use at all).
Force red zone use with more temporaries and -O0:
gcc -O0 -S -o rz0.s rz.c
You'll see negative rsp offsets WITHOUT a 'sub $N, %rsp' prologue.
That's the red zone. */
// Kernel comparison: with -mno-red-zone, the compiler MUST use
// sub $N, %rsp before any local variable access.
// Check: grep -r 'mno-red-zone' /path/to/linux/arch/x86/Makefile
08Variadic Functions: How printf Knows Its Argument Count
Variadic functions like printf(const char *fmt, ...) accept a variable number of arguments. The ABI handles this with a specific rule: rax (specifically al, the low byte) must be set to the number of floating-point arguments passed in xmm registers before calling a variadic function. This allows the callee to skip scanning XMM registers it knows were not used.
The variadic mechanism in C uses the va_list type, which on AMD64 is a struct containing:
gp_offset: offset into a saved-register area for the next integer register argument.fp_offset: offset for the next FP register argument.overflow_arg_area: pointer to stack arguments (argument 7+).reg_save_area: pointer to a save area where all six integer and eight FP registers were dumped on entry.
When printf calls va_arg(ap, int), it increments gp_offset and reads from reg_save_area. When all register slots are consumed, it reads from overflow_arg_area. This is why the prologue of a variadic function looks unusually heavy — it must save all argument registers to the reg_save_area immediately, since it does not know which ones were actually passed.
Practical significance: when writing assembly that calls a variadic function, you must zero rax (or set it to the xmm argument count). Forgetting this is a classic bug: the callee may try to read XMM registers that were never set, causing garbage output or crashes.
; Calling printf from assembly (correct)
; File: variadic_call.asm (NASM syntax)
section .text
global _start
extern printf
_start:
sub rsp, 8 ; align stack (rsp was 16-byte aligned; sub 8 -> 8-aligned; call pushes 8 -> 16-aligned in printf)
lea rdi, [rel fmt] ; arg 1: format string
mov rsi, 100 ; arg 2: integer
xor eax, eax ; CRITICAL: al=0, no xmm args
call printf
add rsp, 8
mov eax, 60
xor edi, edi
syscall
section .data
fmt: db "Result: %d", 10, 0
; If you forget 'xor eax, eax', printf may look at garbage in xmm0-xmm7
09How C Struct Arguments Are Classified and Passed
Passing structs is where the ABI gets genuinely intricate. The ABI classifies each struct field into one of several classes: INTEGER (fits in a GP register), SSE (floating point), SSEUP (upper half of a 256-bit value), X87 (80-bit floats), MEMORY, and a few others.
The classification algorithm works on 8-byte chunks (called eightbytes) of the struct:
- If the total struct size exceeds 16 bytes, or if any field requires alignment greater than 16 bytes, the whole struct is passed
MEMORY— via the hidden-pointer mechanism described in the return-value section. - Otherwise, each 8-byte chunk gets the "union" of its fields' classes (INTEGER wins over SSE, MEMORY wins over all).
- If both eightbytes are classified as INTEGER, they go in two GP registers (consuming two argument register slots).
- If the first is INTEGER and second is SSE, first goes in a GP register and second in an XMM register.
Example: struct { int x; float y; } (8 bytes total, one eightbyte): x is INTEGER class, y is SSE class — INTEGER wins, so the whole struct is passed in a single GP register with the int in the low 32 bits and the float bits in the high 32 bits.
Why this matters for kernel code: the kernel passes most complex data via pointers, avoiding struct-by-value passing entirely. But understanding the classification rules is essential when writing kernel code that interacts with user-space via ptrace, seccomp, or when you are reading compiler output to understand a performance regression.
// Struct passing classification demonstration
// Compile with: gcc -O1 -S -o struct_pass.s struct_pass.c
// Small struct: fits in one register
typedef struct { int a; int b; } TwoInts; // passed in rdi (both ints packed)
// 16-byte struct: two register slots
typedef struct { long x; long y; } TwoLongs; // rdi=x, rsi=y
// Over 16 bytes: passed by hidden pointer
typedef struct { long x; long y; long z; } ThreeLongs; // rdi = hidden ptr
void accept_two_ints(TwoInts s) { (void)s; }
void accept_two_longs(TwoLongs s) { (void)s; }
void accept_three_longs(ThreeLongs s) { (void)s; }
void caller(void) {
accept_two_ints((TwoInts){1, 2});
accept_two_longs((TwoLongs){10, 20});
accept_three_longs((ThreeLongs){100, 200, 300});
}
/* Observe in the disassembly:
- TwoInts: movl $1, edi; movk $2<<32...; one call
- TwoLongs: movl $10, edi; movl $20, esi; one call
- ThreeLongs: sub rsp for temp struct, lea rdi,[rsp+...], fill, call */
10The System Call ABI: Where User-Space Meets the Kernel
The system call ABI is a closely related but distinct convention from the C function-call ABI. When user-space code invokes a Linux system call via the syscall instruction:
| Role | Register |
|---|---|
| Syscall number | rax |
| Arg 1 | rdi |
| Arg 2 | rsi |
| Arg 3 | rdx |
| Arg 4 | r10 (NOT rcx!) |
| Arg 5 | r8 |
| Arg 6 | r9 |
| Return value | rax |
The critical difference from the C ABI: argument 4 is r10, not rcx. This is because the syscall instruction itself destroys rcx (it saves the return address there so it can restore rip on sysret) and also destroys r11 (saves rflags there). The kernel therefore cannot receive argument 4 in rcx — it is already overwritten by the time the kernel entry code runs.
The kernel's entry code in arch/x86/entry/entry_64.S saves all user registers to a pt_regs struct on the kernel stack, then dispatches to the C system call handler. The C handler receives its arguments in rdi, rsi, rdx, r10, r8, r9 (via a macro that copies r10 to rcx to match the C ABI for the kernel-side function). This elegant adaptation is a miniature example of ABI translation.
Return convention: a successful syscall returns a non-negative value in rax. Errors return a negative errno value (e.g., -ENOENT is -2). The C library wrapper checks this and converts it to the user-space convention of -1 + setting errno.
; Direct system call: write(1, msg, 13) then exit(0)
; No C library involved — raw ABI
section .text
global _start
_start:
; write(fd=1, buf=msg, count=13)
mov rax, 1 ; syscall number: write
mov rdi, 1 ; arg1: fd = stdout
lea rsi, [rel msg] ; arg2: buf
mov rdx, 13 ; arg3: count
syscall ; rcx and r11 are destroyed by this instruction
; rax now holds bytes written (or -errno on error)
; exit(0)
mov rax, 60 ; syscall number: exit
xor rdi, rdi ; arg1: exit code = 0
syscall
section .data
msg: db "Hello, world", 10
; Build: nasm -f elf64 syscall_demo.asm -o syscall_demo.o && ld -o syscall_demo syscall_demo.o
; Run: ./syscall_demo
11Reading Real Disassembly: Putting It All Together
Theory becomes intuition only when you can look at real disassembly and narrate what every instruction is doing in terms of the ABI. Let us walk through a non-trivial example.
Consider this C function:
long dot_product(const long *a, const long *b, int n) {
long sum = 0;
for (int i = 0; i < n; i++) sum += a[i] * b[i];
return sum;
}
When compiled with -O2, the compiler might emit:
dot_product:
xor eax, eax ; sum = 0 (zeroing eax also zeroes rax)
test edx, edx ; n == 0?
jle .done ; if n <= 0, return 0
xor ecx, ecx ; i = 0
.loop:
movsx r8, dword [rdi + rcx*8] ; wait — actually arrays of long:
mov r8, [rdi + rcx*8] ; a[i]
mov r9, [rsi + rcx*8] ; b[i]
imul r8, r9 ; a[i] * b[i]
add rax, r8 ; sum += product
inc ecx ; i++
cmp ecx, edx ; i < n?
jl .loop
.done:
ret
ABI narration:
rdi= pointera(arg 1),rsi= pointerb(arg 2),rdx/edx=n(arg 3, int, sign-extended). Matches the register table exactly.raxis the return value register;xor eax, eaxsets it to 0 efficiently.rcx,r8,r9are all caller-saved — the compiler freely uses them as temporaries with no save/restore.- No
pushq %rbp, nosubq $N, %rsp— this is a leaf function (no calls), and the compiler may use the red zone or pure registers. - The
retat the end pops the return address pushed bycallintorip.
This narration maps every instruction to an ABI rule. After enough practice, it becomes automatic.
// Exercise: compile and annotate
// gcc -O2 -S -o dot.s dot.c && cat dot.s
// Then annotate each line with: 'ABI reason'
#include <stddef.h>
long dot_product(const long *a, const long *b, int n) {
long sum = 0;
for (int i = 0; i < n; i++)
sum += a[i] * b[i];
return sum;
}
int main(void) {
long a[] = {1, 2, 3};
long b[] = {4, 5, 6};
long result = dot_product(a, b, 3);
return (int)result; // should be 32 (1*4+2*5+3*6)
}
Key insights
- ✦The ABI is a contract, not a hardware feature — the CPU does not enforce register assignments; only the compiler and programmer do. Breaking the ABI silently produces wrong results, not crashes.
- ✦Caller-saved vs. callee-saved is a performance trade-off: the ABI puts the cheapest registers (no-one saves them) on the critical path of function arguments, and the expensive ones (someone must save them) as stable long-lived variables.
- ✦The 16-byte stack alignment requirement exists because of SSE/AVX instructions, and misalignment crashes are notorious for appearing intermittently — only when the optimizer happens to emit a movaps instruction.
- ✦The red zone is a pure optimization; the kernel disables it (-mno-red-zone) because NMIs arriving at arbitrary points would overwrite it, showing how ABI decisions have deep correctness implications in privileged code.
- ✦Syscall argument 4 is r10, not rcx, because the syscall instruction itself destroys rcx. Every divergence from the C ABI has a concrete hardware reason.
- ✦Large struct return via hidden pointer means the caller allocates memory and passes it as a secret first argument — this is why taking the address of a return value is safe and why the ABI shifts all explicit argument registers by one in this case.
- ✦xor eax, eax is the idiomatic way to zero rax because it is shorter (2 bytes) than mov rax, 0 (7 bytes) and on modern Intel CPUs it is handled by register renaming with no execution unit needed.
Common pitfalls
- ⚠Forgetting to set al=0 before calling a variadic function from assembly: printf reads al to decide how many XMM registers to scan; garbage in al causes spurious reads of uninitialized XMM registers.
- ⚠Breaking stack alignment: pushing an odd number of 8-byte values before a call, then calling a function that uses SSE instructions internally (like glibc's memcpy or printf), causing a SIGSEGV on the movaps instruction inside the library.
- ⚠Confusing the C ABI argument 4 register (rcx) with the syscall ABI argument 4 register (r10): writing a raw syscall wrapper and putting arg 4 in rcx results in silent wrong behavior since the syscall instruction has already destroyed rcx.
- ⚠Assuming the red zone is safe to use in kernel modules or eBPF programs: kernel code uses -mno-red-zone; writing kernel assembly that uses negative rsp offsets without adjusting rsp will be silently overwritten by the next interrupt.
- ⚠Forgetting that rbp is callee-saved: writing a function in assembly and using rbp as a scratch register without saving/restoring it corrupts the caller's frame pointer, causing gdb backtraces to break and potential crashes.
- ⚠Assuming struct-by-value passing is always passed by pointer: small structs (up to 16 bytes) are passed in registers, meaning modifying a local copy does not affect the caller's struct — a source of subtle correctness bugs.
- ⚠Misreading AT&T vs. Intel assembly syntax: AT&T puts the destination last (movq src, dst) while Intel puts it first (mov dst, src); mixing them up leads to total confusion when reading compiler output (which uses AT&T) vs. Intel manuals.
Practice
- 01easy
Write a C program with a function that takes exactly 7 long arguments. Compile it with gcc -O0 -S and find where argument 7 is placed relative to rsp at the call site. Then write the equivalent in NASM assembly, manually placing argument 7 on the stack. Verify with gcc -O0 -c on the C file and objdump -d to compare.
- 02easy
Write a leaf assembly function (no calls) that computes the absolute value of a 64-bit signed integer without any branch instructions, then calls it from C. Check that it uses the red zone (negative rsp offsets with no sub rsp) when compiled with optimization. Disassemble with objdump and annotate every line with its ABI role.
- 03medium
Write a raw system call program in NASM (no libc) that: (1) opens a file with sys_open, (2) reads up to 4096 bytes, (3) writes the bytes to stdout, (4) closes the file, (5) exits. Use the correct syscall numbers from /usr/include/asm/unistd_64.h. Specifically verify that argument 4 of a hypothetical 4-argument syscall would go in r10, not rcx.
- 04medium
Create a deliberate stack misalignment bug: write a NASM program that pushes one extra 8-byte value before calling printf (making rsp 8-byte aligned instead of 16-byte aligned at the call). Observe the crash or garbage output. Then fix it by either adding a second push or subtracting 8 from rsp. Use gdb to set a breakpoint at the call and inspect rsp % 16 before and after your fix.
- 05medium
Write a C function that takes a struct with 3 long fields by value. Compile with -O1 -S and locate where the hidden-pointer mechanism appears (the caller allocates temp struct on its stack, passes rdi pointing to it). Then rewrite the function to take a pointer instead, recompile, and compare the generated code size and number of memory operations. Document the difference and explain which version is more cache-friendly.
- 06hard
Write a minimal kernel module (or study arch/x86/entry/entry_64.S in the Linux source tree) that demonstrates the difference between the user-space C ABI and the kernel's system call ABI. Specifically: find the point where r10 is copied into rcx so that the C kernel function can receive argument 4 in rcx per the C ABI. Explain why this translation is necessary in a comment block.
Resources
The primary source document. Read chapter 3 (Function Calling Sequence) thoroughly — it is dense but precise and resolves all edge cases.
The single best tool for learning calling conventions: write C, see the assembly live, change -O levels, compare GCC vs. Clang. Indispensable.
Accessible introduction to AMD64 assembly including calling conventions, with Ubuntu-specific practical exercises.
Quick reference for all syscall numbers and their argument registers; pairs with the syscall ABI section of this module.
Chapter 3 is one of the best explanations of stack frames and register conventions ever written for students.
The hardware reference. Volume 1 Chapter 6 (Procedure Calls, Interrupts, and Exceptions) explains what call/ret actually do at the hardware level.
Read this once you understand the user-space ABI; the comments in this file are excellent and show exactly where the ABI transition happens.
Gentle and precise; useful for solidifying the C mental model before tackling the assembly that implements it.
Checkpoints — you can now…
- ✓You can look at any x86-64 function disassembly and immediately identify which register holds each argument without consulting a reference.
- ✓You can explain why a crash inside printf might be caused by your code forgetting to align rsp to 16 bytes before the call — and fix it.
- ✓You can write a complete NASM program that makes a Linux system call directly, placing the syscall number in rax and arguments in the correct registers including r10 for argument 4.
- ✓You can read a function prologue (pushq %rbp, movq %rsp, %rbp, subq $N, %rsp) and explain every instruction's ABI obligation.
- ✓You can explain why the Linux kernel compiles with -mno-red-zone and what would go wrong if it did not.
- ✓You can classify a struct by the ABI rules (is it passed in registers or via hidden pointer?) given its size and field types.
- ✓You can identify caller-saved vs. callee-saved registers from memory and explain the performance rationale for the distinction.