Assembly (x86-64, AArch64, RISC-V) Module 7 – Functions, the stack and calling conventions
Calling conventions: arguments and returns
Where System V, Microsoft x64, AAPCS64 and RISC-V LP64D put each argument and result: registers, stack slots, shadow space and hidden result pointers.
What you will learn
- Pass integer, pointer and floating-point arguments under System V, Microsoft x64, AAPCS64 and RISC-V LP64D
- Find where the arguments after the register ones go on each ABI, shadow space included
- Explain how a function returns one or two values in registers and when the caller passes a hidden result pointer
- Identify how Apple's arm64 ABI differs from AAPCS64 for narrow and stack-passed arguments
Before you start
What you’ll learn · Before you start
What you will learn
- Pass integer, pointer and floating-point arguments under System V, Microsoft x64, AAPCS64 and RISC-V LP64D
- Find where the arguments after the register ones go on each ABI, shadow space included
- Explain how a function returns one or two values in registers and when the caller passes a hidden result pointer
- Identify how Apple's arm64 ABI differs from AAPCS64 for narrow and stack-passed arguments
Before you start
On this page
Compile one call, f9(1, 2, 3, 4, 5, 6, 7, 8, 9), for x86-64 Linux and for x86-64 Windows, and the seventh argument
lands 8 bytes above the return address in one program and 56 bytes above it in the other. Both are right: each
follows its platform’s calling convention, and assembly written for the other one reads the wrong registers and
slots without a single error message.
Below, a nine-argument function written in assembly runs on AArch64, and the C that calls it is compiled for five targets, so every claim about System V, Microsoft x64, AAPCS64 and RISC-V LP64D comes with the instructions that show it. You will place integer, pointer and floating-point arguments, find the ones that go to the stack, see results come back in one register, in two or through memory the caller provides, and name two ways in which Apple’s arm64 passes narrow arguments differently from the Arm standard.
A calling convention fixes which register carries each argument
The C driver below calls f9, a function written by hand in assembly, with nine numbers. The two halves are built
separately and meet only at the call, so nothing in the code tells f9 where the driver put each number. They agree
because both follow one application binary interface (ABI): The rules that let separately compiled machine code work together on one platform, from the size of each C type and the calling convention to the format of object files. (ABI): the platform’s rules for how
separately built machine code works together, from the size of each C type to the format of object files. Its part
about calls is the calling convention: The part of an ABI that governs calls: which registers carry the arguments and the result, where arguments that do not fit go on the stack, which registers a callee must preserve and how the stack is aligned at a call.. It fixes which registers carry the
arguments and the result, where arguments go when those registers run out, which registers a callee must hand back
unchanged (the subject of callee-saved registers and stack frames), and
how the stack must be aligned at a call, which has detailed rules of its own.
Each convention lists its argument register: A register that a calling convention assigns, in a fixed order, to pass an argument. Integers and pointers take rdi, rsi, rdx, rcx, r8 and r9 on System V, rcx, rdx, r8 and r9 on Microsoft x64, x0 to x7 on AAPCS64 and a0 to a7 on RISC-V; floating-point values have a set of their own. in a fixed order, and integer arguments take them from the first. A pointer is an address, which all four conventions pass like an integer, so it takes the next register of the same list. Here are the lists, from each convention’s own document:
| Integer argument | System V | Microsoft x64 | AAPCS64 | RISC-V LP64D |
|---|---|---|---|---|
| 1st | rdi |
rcx |
x0 |
a0 |
| 2nd | rsi |
rdx |
x1 |
a1 |
| 3rd | rdx |
r8 |
x2 |
a2 |
| 4th | rcx |
r9 |
x3 |
a3 |
| 5th, 6th | r8, r9 |
stack | x4, x5 |
a4, a5 |
| 7th, 8th | stack | stack | x6, x7 |
a6, a7 |
| 9th and on | stack | stack | stack | stack |
| result | rax |
rax |
x0 |
a0 |
System V is the ABI of x86-64 Linux and Microsoft x64 that of 64-bit Windows. AAPCS64 is Arm’s standard for 64-bit
Arm, which Linux follows and Apple adapts, and LP64D is the 64-bit RISC-V ABI that passes doubles in floating-point
registers. The two x86-64 conventions already disagree on the first argument. AAPCS64 names its registers r0 to r7,
written x0 to x7 in assembly, and RISC-V’s a0 to a7 are the ABI names of x10 to x17.
f9 takes long long arguments, not long, because long has two sizes on these platforms. The size of each C
type is part of a platform’s data model: The sizes a platform gives C's integer types and pointers. 64-bit Linux and macOS use LP64, where long and pointers are 64 bits; 64-bit Windows uses LLP64, where long stays 32 bits and long long and pointers are 64.: 64-bit Linux and macOS use LP64, where long
and pointers are 64 bits, while 64-bit Windows uses LLP64, where pointers and long long have 64 bits but long stays at 32.
f9 multiplies each argument by its position and adds the products, so a value read from the wrong place changes
the sum. Here it is for the three ISAs, each version reading its arguments where its convention puts them:
AArch64 · f9_aarch64.s
// f9 for AArch64: the weighted sum 1*arg1 + 2*arg2 + ... + 9*arg9 of nine 64-bit integers.
// AAPCS64 passes arguments 1 to 8 in x0..x7 and the ninth on the stack: when f9 starts, it is at [sp].
// The result leaves in x0, which already holds arg1. macOS puts an underscore before C names (_f9).
.text
.p2align 2
.globl _f9
_f9: // long long f9(long long arg1, ..., long long arg9)
mov x9, #2
madd x0, x1, x9, x0 // x0 = arg2 * 2 + x0
mov x9, #3
madd x0, x2, x9, x0 // + arg3 * 3
mov x9, #4
madd x0, x3, x9, x0 // + arg4 * 4
mov x9, #5
madd x0, x4, x9, x0 // + arg5 * 5
mov x9, #6
madd x0, x5, x9, x0 // + arg6 * 6
mov x9, #7
madd x0, x6, x9, x0 // + arg7 * 7
mov x9, #8
madd x0, x7, x9, x0 // + arg8 * 8: the last register argument
ldr x10, [sp] // arg9: the first argument the caller left on the stack
mov x9, #9
madd x0, x10, x9, x0 // + arg9 * 9
ret x86-64 · f9_x86_64.s
# f9 for x86-64 Linux: the weighted sum 1*arg1 + 2*arg2 + ... + 9*arg9 of nine 64-bit integers.
# System V passes arguments 1 to 6 in rdi, rsi, rdx, rcx, r8 and r9, and the rest on the stack. When f9 starts,
# [rsp] holds the return address that call pushed, so argument 7 is at [rsp + 8], 8 at [rsp + 16], 9 at [rsp + 24].
# The result leaves in rax. GNU as syntax with Intel operand order.
.intel_syntax noprefix
.text
.globl f9
f9: # long long f9(long long arg1, ..., long long arg9)
mov rax, rdi # arg1
imul r10, rsi, 2
add rax, r10 # + arg2 * 2
imul r10, rdx, 3
add rax, r10 # + arg3 * 3
imul r10, rcx, 4
add rax, r10 # + arg4 * 4
imul r10, r8, 5
add rax, r10 # + arg5 * 5
imul r10, r9, 6
add rax, r10 # + arg6 * 6: the last register argument
imul r10, qword ptr [rsp + 8], 7
add rax, r10 # + arg7 * 7: the first stack argument, just above the return address
imul r10, qword ptr [rsp + 16], 8
add rax, r10 # + arg8 * 8
imul r10, qword ptr [rsp + 24], 9
add rax, r10 # + arg9 * 9
ret RISC-V · f9_riscv64.s
# f9 for RV64 Linux: the weighted sum 1*arg1 + 2*arg2 + ... + 9*arg9 of nine 64-bit integers.
# LP64D passes arguments 1 to 8 in a0..a7 and the ninth on the stack: when f9 starts, it is at 0(sp).
# The result leaves in a0, which already holds arg1. mul belongs to the M extension, which the assembler is
# told about with -mattr=+m; every RV64GC system has it.
.text
.globl f9
f9: # long long f9(long long arg1, ..., long long arg9)
li t1, 2
mul t0, a1, t1
add a0, a0, t0 # + arg2 * 2
li t1, 3
mul t0, a2, t1
add a0, a0, t0 # + arg3 * 3
li t1, 4
mul t0, a3, t1
add a0, a0, t0 # + arg4 * 4
li t1, 5
mul t0, a4, t1
add a0, a0, t0 # + arg5 * 5
li t1, 6
mul t0, a5, t1
add a0, a0, t0 # + arg6 * 6
li t1, 7
mul t0, a6, t1
add a0, a0, t0 # + arg7 * 7
li t1, 8
mul t0, a7, t1
add a0, a0, t0 # + arg8 * 8: the last register argument
ld t0, 0(sp) # arg9: the first argument the caller left on the stack
li t1, 9
mul t0, t0, t1
add a0, a0, t0 # + arg9 * 9
ret The driver calls it once with 1 to 9, then nine times with a single 1 among zeros. The session builds the driver with the AArch64 version and runs it on Apple silicon, then assembles the other two versions into Linux object files and lists them:
// f9_main.c: calls the assembly f9 with 1, 2, ..., 9, then nine more times with a 1 in one position and 0 in all
// the others. Each position has its own weight, so each result names the position f9 found the 1 in.
#include <stdio.h>
long long f9(long long arg1, long long arg2, long long arg3, long long arg4, long long arg5,
long long arg6, long long arg7, long long arg8, long long arg9);
int main(void) {
printf("f9(1, 2, ..., 9) = %lld\n", f9(1, 2, 3, 4, 5, 6, 7, 8, 9));
for (int k = 1; k <= 9; k++) {
long long a[9] = {0};
a[k - 1] = 1;
printf("argument %d alone is 1: f9 = %lld\n", k, f9(a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8]));
}
return 0;
} """Builds f9 for AArch64 with its C driver and runs it, then assembles the x86-64 and RISC-V versions and lists them."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session # noqa: E402
session.run("""
# AArch64: assemble f9_aarch64.s, compile the C driver, link them into one program and run it
clang -O1 -o f9 f9_main.c f9_aarch64.s
./f9
# x86-64: assemble f9_x86_64.s into a 64-bit Linux object file and list its instructions
clang --target=x86_64-linux-gnu -c -o f9_x86_64.o f9_x86_64.s
llvm-objdump -d --no-show-raw-insn -M intel f9_x86_64.o
# RISC-V: the same for f9_riscv64.s, with the M extension that mul needs
llvm-mc --triple=riscv64-linux-gnu -mattr=+m -filetype=obj -o f9_riscv64.o f9_riscv64.s
llvm-objdump -d --no-show-raw-insn f9_riscv64.o
""", max_lines=50) Output
# AArch64: assemble f9_aarch64.s, compile the C driver, link them into one program and run it
$ clang -O1 -o f9 f9_main.c f9_aarch64.s
$ ./f9
f9(1, 2, ..., 9) = 285
argument 1 alone is 1: f9 = 1
argument 2 alone is 1: f9 = 2
argument 3 alone is 1: f9 = 3
argument 4 alone is 1: f9 = 4
argument 5 alone is 1: f9 = 5
argument 6 alone is 1: f9 = 6
argument 7 alone is 1: f9 = 7
argument 8 alone is 1: f9 = 8
argument 9 alone is 1: f9 = 9
# x86-64: assemble f9_x86_64.s into a 64-bit Linux object file and list its instructions
$ clang --target=x86_64-linux-gnu -c -o f9_x86_64.o f9_x86_64.s
$ llvm-objdump -d --no-show-raw-insn -M intel f9_x86_64.o
f9_x86_64.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <f9>:
0: mov rax, rdi
3: imul r10, rsi, 0x2
7: add rax, r10
a: imul r10, rdx, 0x3
e: add rax, r10
11: imul r10, rcx, 0x4
15: add rax, r10
18: imul r10, r8, 0x5
1c: add rax, r10
1f: imul r10, r9, 0x6
23: add rax, r10
26: imul r10, qword ptr [rsp + 0x8], 0x7
2c: add rax, r10
2f: imul r10, qword ptr [rsp + 0x10], 0x8
35: add rax, r10
38: imul r10, qword ptr [rsp + 0x18], 0x9
3e: add rax, r10
41: ret
# RISC-V: the same for f9_riscv64.s, with the M extension that mul needs
$ llvm-mc --triple=riscv64-linux-gnu -mattr=+m -filetype=obj -o f9_riscv64.o f9_riscv64.s
$ llvm-objdump -d --no-show-raw-insn f9_riscv64.o
f9_riscv64.o: file format elf64-littleriscv
Disassembly of section .text:
0000000000000000 <f9>:
0: li t1, 0x2
4: mul t0, a1, t1
8: add a0, a0, t0
c: li t1, 0x3
10: mul t0, a2, t1
14: add a0, a0, t0
18: li t1, 0x4
1c: mul t0, a3, t1
20: add a0, a0, t0
24: li t1, 0x5
28: mul t0, a4, t1
2c: add a0, a0, t0
30: li t1, 0x6
34: mul t0, a5, t1
38: add a0, a0, t0
3c: li t1, 0x7
40: mul t0, a6, t1
44: add a0, a0, t0
48: li t1, 0x8
4c: mul t0, a7, t1
50: add a0, a0, t0
54: ld t0, 0x0(sp)
58: li t1, 0x9
5c: mul t0, t0, t1
60: add a0, a0, t0
64: ret
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 f9_session.py
The first result, 285, is 1 × 1 + 2 × 2 + … + 9 × 9. The nine lines after it are the real test: each position has
its own weight, so each result names the position where f9 found the 1. Argument 9 alone gave 9, so f9 read the
ninth argument from [sp], where the caller had left it. The listings show the two other conventions at work: the
x86-64 version reads arguments 7 to 9 at [rsp + 0x8], [rsp + 0x10] and [rsp + 0x18], and the RISC-V version
reads the ninth at 0x0(sp).
Key idea
A caller and a callee built apart meet only in the registers and slots their calling convention names. Integer
and pointer arguments take rdi, rsi, rdx, rcx, r8 and r9 on System V, rcx, rdx, r8 and r9 on
Microsoft x64, x0 to x7 on AAPCS64 and a0 to a7 on RISC-V.
Checkpoint
Check: Integer and pointer argument registers
Later arguments go on the stack, and Windows adds shadow space
f9 showed where a callee looks. The caller’s side is call9.c, a function that calls f9(1, 2, …, 9). It is
compiled for each target, not run, with show.sh, a short script that keeps only the labels and instructions of
clang’s output:
call9.c
// call9.c: a caller that passes nine 64-bit integers to f9. It is compiled, never run, for five targets, so the
// output shows where each ABI puts each argument. long long is 64 bits on every one of them; long is not (Windows
// keeps long at 32 bits).
long long f9(long long a1, long long a2, long long a3, long long a4, long long a5,
long long a6, long long a7, long long a8, long long a9);
long long call9(void) { return f9(1, 2, 3, 4, 5, 6, 7, 8, 9); } show.sh
#!/bin/sh
# show.sh <target> <file.c>: what clang -O2 emits for one C file on one target, with the assembler directives and
# the compiler's comments taken out, so only labels and instructions are left. The targets of this lesson:
# x86_64-linux-gnu System V AMD64 (Linux)
# x86_64-pc-windows-msvc Microsoft x64 (Windows)
# arm64-apple-macos Apple's arm64
# aarch64-linux-gnu AAPCS64 (64-bit Arm Linux)
# riscv64-linux-gnu RISC-V LP64D (64-bit RISC-V Linux)
# Apple's clang has no RISC-V target, so RISC-V goes through LLVM's own clang, installed here as clang-23.
# The C files include no headers, so no target needs its system libraries: nothing is linked or run.
target=$1
file=$2
case $target in
x86_64-*) cc="clang -masm=intel" ;;
riscv64-*) cc="clang-23 -march=rv64gc -mabi=lp64d" ;;
*) cc=clang ;;
esac
$cc --target="$target" -O2 -S -fno-asynchronous-unwind-tables -o - "$file" |
grep -v -E '^[[:space:]]*([.#;/]|$)|^@feat' |
sed -E 's/[[:space:]]+(#|;|\/\/) .*$//' First the two x86-64 conventions. The second command shows what clang emits for x86_64-pc-windows-msvc, the target
name of 64-bit Windows:
"""Shows what clang -O2 makes of call9.c, a caller of f9(1, ..., 9), for x86-64 Linux and for x86-64 Windows."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session # noqa: E402
session.run("""
# System V (x86-64 Linux): six arguments in registers, then the call
sh show.sh x86_64-linux-gnu call9.c
# Microsoft x64 (x86-64 Windows): the same C, compiled for x86_64-pc-windows-msvc
sh show.sh x86_64-pc-windows-msvc call9.c
""") Output
# System V (x86-64 Linux): six arguments in registers, then the call $ sh show.sh x86_64-linux-gnu call9.c call9: push rax sub rsp, 8 mov edi, 1 mov esi, 2 mov edx, 3 mov ecx, 4 mov r8d, 5 mov r9d, 6 push 9 push 8 push 7 call f9@PLT add rsp, 32 pop rcx ret # Microsoft x64 (x86-64 Windows): the same C, compiled for x86_64-pc-windows-msvc $ sh show.sh x86_64-pc-windows-msvc call9.c call9: sub rsp, 72 mov qword ptr [rsp + 64], 9 mov qword ptr [rsp + 56], 8 mov qword ptr [rsp + 48], 7 mov qword ptr [rsp + 40], 6 mov qword ptr [rsp + 32], 5 mov ecx, 1 mov edx, 2 mov r8d, 3 mov r9d, 4 call f9 add rsp, 72 ret
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 call9_x86_session.py
System V fills six registers. mov edi, 1 writes the 32-bit half of rdi, and on x86-64 that clears the upper half
too, so it is the short way to put the 64-bit value 1 in rdi. Then come push 9, push 8 and push 7, each lowering
rsp by 8 before it stores, as in the stack lesson. The psABI pushes stack arguments
right to left, so the seventh ends up lowest, and call pushes the return address under it: on entry
f9 finds argument 7 at [rsp + 8], exactly where f9_x86_64.s reads it. The push rax and sub rsp, 8 at the
top carry no data. They keep rsp a multiple of 16 at the call; after it, add rsp, 32 takes back the sub and the
three pushes, and pop rcx the push rax.
Microsoft x64 fills only four registers, ecx, edx, r8d and r9d. sub rsp, 72 reserves the caller’s whole
area at once, and five mov instructions store 5 to 9 at [rsp + 32] to [rsp + 64]. The 32 bytes below them,
[rsp] to [rsp + 31], receive nothing: they are the shadow space: The 32 bytes a Microsoft x64 caller reserves on the stack just above the return address on every call, so that the callee can store rcx, rdx, r8 and r9 there if it needs to., room for the
callee to save rcx, rdx, r8 and r9 if it needs to. Microsoft requires the caller to set it aside on every
call, enough “to store four register parameters”, even when the callee takes fewer. Step through both stacks as the
two callees see them when they start:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The call left a return address. Both callers end with call, which pushes the address to come back to. So when f9 starts, rsp points at that return address on both conventions: System V in the left column, Microsoft x64 in the right.
- System V keeps six in registers. System V passed arguments 1 to 6 in rdi, rsi, rdx, rcx, r8 and r9. Argument 7 was pushed last, by push 7, so it is the stack argument nearest the return address: rsp + 8.
- Pushed right to left. push 9 and push 8 ran before push 7, so their values sit higher: argument 8 at rsp + 16 and argument 9 at rsp + 24. Pushing from the last argument backwards leaves the stack arguments in order, lowest address first.
- Windows keeps only four. Microsoft x64 passes just four arguments in registers: rcx, rdx, r8 and r9. Its caller subtracted 72 from rsp and left the lowest 32 bytes empty. Seen from f9, they are rsp + 8 to rsp + 39, one 8-byte home for each register argument.
- Argument 5 is above the shadow. The caller stored argument 5 at rsp + 32, the first slot above the shadow space. The call then pushed 8 bytes, so f9 finds the same value at rsp + 40.
- Arguments 6 to 9 follow. Arguments 6, 7, 8 and 9 follow in 8-byte steps, at rsp + 48, 56, 64 and 72. The caller wrote all five with mov into space it had reserved at once, instead of pushing them one by one.
- One argument, two addresses. The same seventh argument is at rsp + 8 under System V and at rsp + 56 under Microsoft x64. Offsets into the caller's area are part of the convention, as much as the register names are.
The return address is the only thing between what a caller writes and what its callee reads, which is why the fifth
Windows argument, stored at [rsp + 32], is found at [rsp + 40].
AArch64 and RISC-V have eight argument registers, so their ninth argument is the first one on the stack. Their calls
leave the return address in a register, x30 or ra (see calls and returns),
not on the stack, so the callee finds the ninth argument at sp itself:
"""Shows what clang -O2 makes of call9.c, a caller of f9(1, ..., 9), for two AArch64 platforms and for RISC-V."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session # noqa: E402
session.run("""
# Apple's arm64 (the platform the f9 program above ran on)
sh show.sh arm64-apple-macos call9.c
# AAPCS64 as 64-bit Arm Linux uses it
sh show.sh aarch64-linux-gnu call9.c
# RISC-V LP64D (64-bit RISC-V Linux)
sh show.sh riscv64-linux-gnu call9.c
""") Output
# Apple's arm64 (the platform the f9 program above ran on) $ sh show.sh arm64-apple-macos call9.c _call9: sub sp, sp, #32 stp x29, x30, [sp, #16] add x29, sp, #16 mov w8, #9 str x8, [sp] mov w0, #1 mov w1, #2 mov w2, #3 mov w3, #4 mov w4, #5 mov w5, #6 mov w6, #7 mov w7, #8 bl _f9 ldp x29, x30, [sp, #16] add sp, sp, #32 ret # AAPCS64 as 64-bit Arm Linux uses it $ sh show.sh aarch64-linux-gnu call9.c call9: sub sp, sp, #32 mov w8, #9 mov w0, #1 mov w1, #2 mov w2, #3 mov w3, #4 mov w4, #5 mov w5, #6 mov w6, #7 mov w7, #8 stp x29, x30, [sp, #16] add x29, sp, #16 str x8, [sp] bl f9 ldp x29, x30, [sp, #16] add sp, sp, #32 ret # RISC-V LP64D (64-bit RISC-V Linux) $ sh show.sh riscv64-linux-gnu call9.c call9: addi sp, sp, -16 sd ra, 8(sp) li t0, 9 li a0, 1 li a1, 2 li a2, 3 li a3, 4 li a4, 5 li a5, 6 li a6, 7 li a7, 8 sd t0, 0(sp) call f9 ld ra, 8(sp) addi sp, sp, 16 ret
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 call9_arm_session.py
Apple’s arm64 and Arm Linux agree here. Both put 1 to 8 in w0 to w7 and store the 9 with str x8, [sp], and
writing a w register clears the top half of its x register, just as mov edi, 1 does on x86-64. RISC-V puts 1 to 8
in a0 to a7 and stores the 9 with sd t0, 0(sp). sub sp, sp, #32 and addi sp, sp, -16 reserve that slot
together with room to save the caller’s own x29 and x30, or ra, which the next lesson takes apart. Side by
side, the first stack argument waits here when the callee starts:
| Convention | Integer registers | First stack argument | The callee finds it at |
|---|---|---|---|
| System V | 6 | the 7th | [rsp + 8] |
| Microsoft x64 | 4 | the 5th | [rsp + 40] |
| AAPCS64 | 8 | the 9th | [sp] |
| RISC-V LP64D | 8 | the 9th | 0(sp) |
Using the caller's offsets inside the callee
The caller stored the fifth Windows argument at [rsp + 32], but a callee that reads [rsp + 32] gets the
shadow slot of r9 instead. call pushed 8 bytes in between, so the callee’s offsets are 8 larger: [rsp + 40]
for the fifth argument here, [rsp + 8] for the seventh on System V. Every push in the callee moves them again.
Checkpoint
Check: Stack arguments and shadow space
Floating-point arguments use their own registers, counted two ways
mix(i, d, j, e) takes an integer, a double, another integer and another double, in that order, and returns
i * d + j * e. A floating-point number: A number stored as a sign, an exponent and a fraction, so one format covers a wide range by moving a binary point. binary32 (C float) and binary64 (C double) are the common sizes; the value is roughly 1.fraction times 2 to the exponent. More in “Floating-point numbers bit by bit” argument does not travel in a
general-purpose register: A register a program may freely use for integer values and addresses, as opposed to a special register with a fixed job. x86-64 has 16 (rax..r15), AArch64 31 (x0..x30) and RISC-V 32 (x0..x31). More in “Registers on x86-64, AArch64 and RISC-V” while registers of its own kind are left: each ISA
has a second set of registers for it, the SSE registers xmm0 to xmm15 on x86-64, v0 to v31 on AArch64 (written
d0 to d31 when they hold a double) and f0 to f31 on RISC-V. The four conventions agree on that and disagree on
how to count:
// mix.c: two integers and two doubles, one after the other. Compiled, never run, for four ABIs, to see which
// register each argument arrives in and which register carries the double result back.
double mix(long long i, double d, long long j, double e) { return i * d + j * e; } """Shows which registers mix(i, d, j, e) reads its two integers and two doubles from, on four ABIs."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session # noqa: E402
session.run("""
# System V: integers and doubles each take the next register of their own kind
sh show.sh x86_64-linux-gnu mix.c
# Microsoft x64: each argument takes the register of its position
sh show.sh x86_64-pc-windows-msvc mix.c
# AAPCS64: x registers for integers, d registers for doubles
sh show.sh aarch64-linux-gnu mix.c
# RISC-V LP64D: a registers for integers, fa registers for doubles
sh show.sh riscv64-linux-gnu mix.c
""") Output
# System V: integers and doubles each take the next register of their own kind $ sh show.sh x86_64-linux-gnu mix.c mix: cvtsi2sd xmm2, rdi cvtsi2sd xmm3, rsi mulsd xmm3, xmm1 mulsd xmm0, xmm2 addsd xmm0, xmm3 ret # Microsoft x64: each argument takes the register of its position $ sh show.sh x86_64-pc-windows-msvc mix.c mix: cvtsi2sd xmm0, rcx cvtsi2sd xmm2, r8 mulsd xmm2, xmm3 mulsd xmm0, xmm1 addsd xmm0, xmm2 ret # AAPCS64: x registers for integers, d registers for doubles $ sh show.sh aarch64-linux-gnu mix.c mix: scvtf d2, x1 scvtf d3, x0 fmul d1, d1, d2 fmadd d0, d3, d0, d1 ret # RISC-V LP64D: a registers for integers, fa registers for doubles $ sh show.sh riscv64-linux-gnu mix.c mix: fcvt.d.l fa5, a1 fmul.d fa5, fa1, fa5 fcvt.d.l fa4, a0 fmadd.d fa0, fa4, fa0, fa5 ret
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 mix_session.py
Read the conversions first, since each names the register an integer arrived in. System V converts rdi and rsi
with cvtsi2sd: j, the third argument, came in the second integer register, because integers and doubles each
take the next unused register of their own kind, and d and e came in xmm0 and xmm1. Microsoft x64 converts
rcx and r8 and multiplies by xmm1 and xmm3. There the position decides, so the third argument takes the third
integer register whatever came before it, and xmm0, xmm2, rdx and r9 carry no argument. AAPCS64 converts x0 and
x1 with scvtf and finds the doubles in d0 and d1; RISC-V converts a0 and a1 with fcvt.d.l and finds
them in fa0 and fa1. Both count each kind separately, as System V does, and all four return the double in their
first floating-point register:
mix argument |
System V | Microsoft x64 | AAPCS64 | RISC-V LP64D |
|---|---|---|---|---|
i, 1st, integer |
rdi |
rcx |
x0 |
a0 |
d, 2nd, double |
xmm0 |
xmm1 |
d0 |
fa0 |
j, 3rd, integer |
rsi |
r8 |
x1 |
a1 |
e, 4th, double |
xmm1 |
xmm3 |
d1 |
fa1 |
| result | xmm0 |
xmm0 |
d0 |
fa0 |
Counting by position caps Microsoft x64 at four register arguments of any mix of kinds; everything after them goes on
the stack. In one call, System V can pass six integers and eight doubles in registers, the doubles in xmm0 to
xmm7, and AAPCS64 and LP64D eight of each. Once its floating-point argument registers are taken, a double goes on
the stack under System V and AAPCS64, while LP64D first gives it the next unused a register.
Key idea
Floating-point arguments have registers of their own on every ABI. System V, AAPCS64 and RISC-V count integers and floating-point values separately; Microsoft x64 counts positions, so each of the first four arguments takes the register of its position in its own kind, and the matching register of the other kind goes unused.
Checkpoint
Check: Floating-point arguments
Results come back in one or two registers, or through a hidden pointer
f9 handed its sum back in x0, and its other versions in rax and a0: one integer result comes back in a single
register. Larger results need more. results.c returns a 16-byte struct of two long long fields and a 24-byte
struct of three:
// results.c: a 16-byte and a 24-byte result. Compiled, never run, for four ABIs, to see which results come back
// in registers and which ones the caller receives through memory it provides.
struct pair { long long a, b; }; // 16 bytes
struct triple { long long a, b, c; }; // 24 bytes
struct pair make_pair(long long x) { struct pair p = { x, 2 * x }; return p; }
struct triple make_triple(long long x) { struct triple t = { x, 2 * x, 3 * x }; return t; } """Shows how make_pair (16 bytes) and make_triple (24 bytes) hand their results back, on four ABIs."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session # noqa: E402
session.run("""
# System V
sh show.sh x86_64-linux-gnu results.c
# Microsoft x64
sh show.sh x86_64-pc-windows-msvc results.c
# AAPCS64
sh show.sh aarch64-linux-gnu results.c
# RISC-V LP64D
sh show.sh riscv64-linux-gnu results.c
""") Output
# System V $ sh show.sh x86_64-linux-gnu results.c make_pair: mov rax, rdi lea rdx, [rdi + rdi] ret make_triple: mov rax, rdi mov qword ptr [rdi], rsi lea rcx, [rsi + rsi] mov qword ptr [rdi + 8], rcx lea rcx, [rsi + 2*rsi] mov qword ptr [rdi + 16], rcx ret # Microsoft x64 $ sh show.sh x86_64-pc-windows-msvc results.c make_pair: mov rax, rcx mov qword ptr [rcx], rdx add rdx, rdx mov qword ptr [rcx + 8], rdx ret make_triple: mov rax, rcx mov qword ptr [rcx], rdx lea rcx, [rdx + rdx] mov qword ptr [rax + 8], rcx lea rcx, [rdx + 2*rdx] mov qword ptr [rax + 16], rcx ret # AAPCS64 $ sh show.sh aarch64-linux-gnu results.c make_pair: lsl x1, x0, #1 ret make_triple: lsl x9, x0, #1 stp x0, x9, [x8] add x9, x9, x0 str x9, [x8, #16] ret # RISC-V LP64D $ sh show.sh riscv64-linux-gnu results.c make_pair: slli a1, a0, 1 ret make_triple: slli a2, a1, 1 sd a1, 0(a0) add a1, a1, a2 sd a2, 8(a0) sd a1, 16(a0) ret
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 results_session.py
make_pair comes back in two registers on three of the four. System V writes x to rax and 2 * x to rdx
(lea rdx, [rdi + rdi]), AAPCS64 leaves x in x0 and puts the second field in x1, and RISC-V does the same with
a0 and a1. Microsoft x64 does not: its make_pair reads x from rdx, stores both fields through rcx and
copies rcx to rax. On Windows a struct comes back in rax only when it is 1, 2, 4 or 8 bytes long; anything else
goes through memory the caller provides. The caller passes the address of that memory as a hidden first argument, so
every real argument moves one register to the right, and the callee hands the address back in rax.
make_triple is too big for two registers anywhere, so all four use memory the caller provides, and they disagree
on where its address arrives. System V puts it in rdi, moves x to rsi and returns the address in rax, as its
psABI requires. RISC-V puts it in a0 and moves x to a1, but its psABI does not ask for the address back, so a
caller must not expect it in a0 afterwards. AAPCS64 alone gives the address a register of its own, x8, the
indirect result location register: On AAPCS64, x8: the register in which a caller passes the address of the memory where a function must store a result that cannot come back in registers. System V, Microsoft x64 and RISC-V pass that address as a hidden first argument instead. register: x stays in x0, and
stp x0, x9, [x8] writes the first two fields. In the table, “via” a register means through caller memory whose
address arrives in that register:
| Result | System V | Microsoft x64 | AAPCS64 | RISC-V LP64D |
|---|---|---|---|---|
| 8 bytes | rax |
rax |
x0 |
a0 |
| 16 bytes | rax, rdx |
via rcx |
x0, x1 |
a0, a1 |
| 24 bytes | via rdi |
via rcx |
via x8 |
via a0 |
| first argument, then | rsi |
rdx |
x0 |
a1 |
| address handed back | rax |
rax |
not required | not required |
Reading rax and rdx after a Windows call that returns 16 bytes
A caller written with System V in mind reads make_pair’s two fields from rax and rdx. Compiled for Windows,
rax holds the address of the result and rdx whatever the callee left in it. Under Microsoft x64, pass the
address of a 16-byte buffer in rcx and the first argument in rdx, then read both fields from the buffer.
Checkpoint
Check: Results and the hidden result pointer
Apple’s arm64 extends narrow arguments in the caller and packs the stack
Apple’s arm64 follows AAPCS64 and lists where it departs from it. Two of the departures on that list show as soon as
arguments are one byte wide. sum10 adds ten signed char values; call10, compiled in a file
of its own as separately built code always is, passes the low byte of an int ten times:
sum10.c
// sum10.c: a callee with ten 1-byte arguments. Compiled, never run, for Apple's arm64 and for 64-bit Arm Linux,
// which both follow AAPCS64 but make different choices for narrow and stack arguments.
int sum10(signed char a1, signed char a2, signed char a3, signed char a4, signed char a5,
signed char a6, signed char a7, signed char a8, signed char a9, signed char a10) {
return a1 + a2 + a3 + a4 + a5 + a6 + a7 + a8 + a9 + a10;
} call10.c
// call10.c: a caller of sum10, compiled on its own, as separately built code always is: it sees only the
// prototype. Each int x becomes a signed char argument, so only its low byte is the value passed.
int sum10(signed char a1, signed char a2, signed char a3, signed char a4, signed char a5,
signed char a6, signed char a7, signed char a8, signed char a9, signed char a10);
int call10(int x) { return sum10(x, x, x, x, x, x, x, x, x, x); } """Shows sum10 (a callee) and call10 (its caller), ten 1-byte arguments, for Apple's arm64 and for Arm Linux."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session # noqa: E402
session.run("""
# Apple's arm64: the callee, then the caller
sh show.sh arm64-apple-macos sum10.c
sh show.sh arm64-apple-macos call10.c
# AAPCS64 as 64-bit Arm Linux uses it: the same two files
sh show.sh aarch64-linux-gnu sum10.c
sh show.sh aarch64-linux-gnu call10.c
""") Output
# Apple's arm64: the callee, then the caller $ sh show.sh arm64-apple-macos sum10.c _sum10: ldrsb w8, [sp, #1] ldrsb w9, [sp] add w10, w1, w0 add w11, w2, w3 add w10, w10, w11 add w11, w4, w5 add w11, w11, w6 add w10, w10, w11 add w10, w10, w7 add w9, w10, w9 add w0, w9, w8 ret $ sh show.sh arm64-apple-macos call10.c _call10: sub sp, sp, #32 stp x29, x30, [sp, #16] add x29, sp, #16 strb w0, [sp, #1] strb w0, [sp] sxtb w0, w0 mov x1, x0 mov x2, x0 mov x3, x0 mov x4, x0 mov x5, x0 mov x6, x0 mov x7, x0 bl _sum10 ldp x29, x30, [sp, #16] add sp, sp, #32 ret # AAPCS64 as 64-bit Arm Linux uses it: the same two files $ sh show.sh aarch64-linux-gnu sum10.c sum10: sxtb w8, w1 ldrsb w9, [sp, #8] ldrsb w10, [sp] add w8, w8, w0, sxtb add w9, w10, w9 add w8, w8, w2, sxtb add w8, w8, w3, sxtb add w8, w8, w4, sxtb add w8, w8, w5, sxtb add w8, w8, w6, sxtb add w8, w8, w7, sxtb add w0, w8, w9 ret $ sh show.sh aarch64-linux-gnu call10.c call10: sub sp, sp, #32 mov w1, w0 mov w2, w0 mov w3, w0 mov w4, w0 mov w5, w0 mov w6, w0 mov w7, w0 stp x29, x30, [sp, #16] add x29, sp, #16 strb w0, [sp, #8] strb w0, [sp] bl sum10 ldp x29, x30, [sp, #16] add sp, sp, #32 ret
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 narrow_session.py
The Linux sum10 sign extension: Widening a value by copying its top bit into every new bit above it, so a two's complement number keeps its signed value: the byte 0xc8 (−56) becomes 0xffffffc8 in 32 bits. movsx, ldrsb and lb sign-extend. More in “Two's complement and sign extension” every register argument before it adds it:
sxtb w8, w1, then add w8, w8, w0, sxtb and six more adds with sxtb. AAPCS64 leaves the bits of a register above
an argument unspecified, so the callee must widen a byte itself. Apple’s sum10 adds w0 to w7 as they are,
because Apple moves that job to the caller: its document makes the caller responsible for sign- or
zero extension: Widening a value by filling every new bit above it with 0, so an unsigned number keeps its value: the byte 0xc8 (200) becomes 0x000000c8 in 32 bits. movzx, ldrb and lbu zero-extend. More in “Two's complement and sign extension” every argument narrower than 32 bits. The callers show the other half.
Apple’s call10 runs sxtb w0, w0 once and copies the result to x1 to x7, while the Linux call10 copies w0
with no extension at all.
The last two arguments show the second change. The Linux caller stores them with strb at [sp] and [sp, #8],
an 8-byte slot each, because AAPCS64 widens a stack argument smaller than 8 bytes to 8. Apple’s caller stores them at
[sp] and [sp, #1], one byte each: on Apple platforms a stack argument may take a slot that is not a multiple of 8
bytes, and padding brings the total of the stack arguments up to a multiple of 8. Both callees read the two bytes
back with ldrsb, a load that sign-extends a byte, from the offsets their own caller used. The table puts both rules
next to the other conventions:
| Convention | Who widens a char or short argument |
A 1-byte stack argument takes |
|---|---|---|
| System V | the callee: the upper bits are unspecified | 8 bytes |
| Microsoft x64 | the callee: it may read only the low bits | 8 bytes |
| AAPCS64 | the callee: the upper bits are unspecified | 8 bytes |
| Apple’s arm64 | the caller, to 32 bits | 1 byte |
| RISC-V LP64D | the caller, to 32 bits and then 64 | 8 bytes |
For 64-bit arguments, such as those of call9, Apple’s arm64 and Arm Linux put every value in the same register or
slot. With narrow ones, assembly written for one platform is wrong on the other: an Apple-style sum10 trusts bits
that a Linux caller leaves undefined, and the two look for the tenth argument in different bytes.
Key idea
Two of the changes Apple’s arm64 makes to AAPCS64 decide how narrow arguments travel: the caller, not the callee, widens a value narrower than 32 bits, and a stack argument takes only its own size instead of a whole 8-byte slot.
Checkpoint
Check: Apple's arm64 and narrow arguments
Practise
Three changes to copies of this lesson’s files. Build or compile each one with the commands of the session it comes from, and write down your prediction before you run anything.
- Copy
f9_aarch64.stodot4_aarch64.sand rewrite it aslong long dot4(long long a, long long b, long long c, long long d, long long w, long long x, long long y, long long z), which returnsa * w + b * x + c * y + d * z. Copyf9_main.cto call it with 1 to 8, which must give 70, and with(1, 0, 0, 0, 5, 6, 7, 8), which must give 5. First decide which registers holdwtozon AAPCS64 and on LP64D, and which two arguments System V would pass on the stack, at which offsets. - Copy
call9.ctodot4_call.c, declaredot4instead off9and call it with 1 to 8. Before you runsh show.sh x86_64-pc-windows-msvc dot4_call.c, write down how many arguments the caller stores on the stack, at which offsets, and how much it subtracts fromrspto cover them, the shadow space and any padding that keepsrspa multiple of 16 at the call. Then do the same forx86_64-linux-gnu,aarch64-linux-gnuandriscv64-linux-gnu; on the last two, also say which instruction takes the place of the call, and why. - Copy
results.cand change everylong longtolong. Predict howmake_paircomes back onx86_64-pc-windows-msvc, wherelongis now 4 bytes, then check it withshow.sh. Decide also which of the other three targets give different output from the original file, and why.
Practice that adapts to you
Practise this lesson
15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
What does a calling convention fix, and which registers carry the first four integer arguments under System V and under Microsoft x64?
A calling convention is the part of a platform’s ABI that governs calls: which registers carry the arguments and
the result, where the arguments that do not fit go on the stack, which registers a callee must leave unchanged and
how the stack must be aligned at the call. A caller and a callee compiled separately never see each other’s code, so
these rules are the only thing they share. Under System V, the convention of x86-64 Linux, the first four integer or
pointer arguments arrive in rdi, rsi, rdx and rcx, and two more fit in r8 and r9. Under Microsoft x64
they arrive in rcx, rdx, r8 and r9, the fifth goes on the stack, and the caller also reserves 32 bytes of
shadow space for the callee. Both return an integer result in rax.
Two questions take these rules to the places where they bite in practice: an x86-64 routine that has to move from Linux to Windows, and a hand-written caller that gets garbage back from a function returning a struct:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
An x86-64 Linux assembly routine
scale_add(long long *dst, const long long *src, long long n, double k, long long bias, long long step)is ported to Windows by renaming rdi, rsi, rdx, rcx to rcx, rdx, r8, r9 and loading the old r8 from [rsp + 40]. Results are wrong. Where does each argument arrive? -
Mid to senior
A hand-written AArch64 routine calls the C function
struct vec3 make_vec3(long long seed), a struct of threelong longfields, with seed in x0, and reads the fields from x0, x1 and x2. It prints garbage, and a variable elsewhere changes. What went wrong, and what must a caller do on AArch64, x86-64 Linux and RISC-V?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does a calling convention fix, and why does it matter?
Which registers carry arguments and results, where extra arguments go on the stack, which registers a callee must preserve and how the stack is aligned at a call. A caller and a callee built separately share nothing else.
-
Which registers carry the first integer or pointer arguments under each ABI?
rdi, rsi, rdx, rcx, r8 and r9 on System V; rcx, rdx, r8 and r9 on Microsoft x64; x0 to x7 on AAPCS64; a0 to a7 on RISC-V.
-
Where does the first argument that does not fit in registers go?
The seventh integer argument is at [rsp + 8] on System V at entry; on Windows the fifth sits at [rsp + 40], above the 32 bytes of shadow space; on AArch64 and RISC-V the ninth is at [sp].
-
What is shadow space in the Microsoft x64 convention?
32 bytes that every caller reserves on the stack just above the return address, so the callee may store rcx, rdx, r8 and r9 there if it wants to.
-
How do the ABIs count floating-point arguments?
All of them pass floating-point values in their own registers. System V, AAPCS64 and RISC-V count integers and floating-point values separately; Microsoft x64 counts positions, so the third argument always uses the third register of its kind and the other kind's third register goes unused.
-
Where do function results come back?
One integer in rax, x0 or a0, and a 16-byte pair in two registers except on Windows. A larger result goes to caller memory whose address arrives in rdi, rcx, x8 or a0.
-
How does Apple's arm64 ABI differ from AAPCS64 for narrow arguments?
The caller, not the callee, widens a value narrower than 32 bits, and a stack argument takes only its own size instead of a whole 8-byte slot. Apple and Arm Linux code therefore disagree about one-byte arguments.
-
What is the difference between the LP64 and LLP64 data models?
LP64, used by 64-bit Linux and macOS, makes long and pointers 64 bits. LLP64, used by 64-bit Windows, keeps long at 32 bits and makes long long and pointers 64.
Key takeaways
- A calling convention, the call rules of a platform’s ABI, is all that a separately built caller and callee share:
integer and pointer arguments take
rdi,rsi,rdx,rcx,r8andr9on System V,rcx,rdx,r8andr9on Microsoft x64,x0tox7on AAPCS64 anda0toa7on RISC-V LP64D. - Arguments beyond the registers go on the stack in order, lowest address first: the seventh at
[rsp + 8]on System V, the fifth at[rsp + 40]on Windows above the 32 bytes of shadow space every caller reserves, and the ninth at[sp]on AArch64 and RISC-V. - Floating-point arguments travel in registers of their own; System V, AAPCS64 and RISC-V count them apart from the integers, while Microsoft x64 gives each of the first four arguments the register of its position.
- One integer result comes back in
rax,x0ora0and a 16-byte pair in two registers except on Windows, while larger results go through caller memory whose address arrives inrdi,rcx,x8ora0. - Apple’s arm64 makes the caller widen arguments narrower than 32 bits and packs stack arguments to their own size, so Apple and Arm Linux code disagree about one-byte arguments.
Terms in this lesson
- application binary interface (ABI)
- The rules that let separately compiled machine code work together on one platform, from the size of each C type and the calling convention to the format of object files.
- argument register
- A register that a calling convention assigns, in a fixed order, to pass an argument. Integers and pointers take rdi, rsi, rdx, rcx, r8 and r9 on System V, rcx, rdx, r8 and r9 on Microsoft x64, x0 to x7 on AAPCS64 and a0 to a7 on RISC-V; floating-point values have a set of their own.
- calling convention
- The part of an ABI that governs calls: which registers carry the arguments and the result, where arguments that do not fit go on the stack, which registers a callee must preserve and how the stack is aligned at a call.
- data model
- The sizes a platform gives C's integer types and pointers. 64-bit Linux and macOS use LP64, where long and pointers are 64 bits; 64-bit Windows uses LLP64, where long stays 32 bits and long long and pointers are 64.
- indirect result location register
- On AAPCS64, x8: the register in which a caller passes the address of the memory where a function must store a result that cannot come back in registers. System V, Microsoft x64 and RISC-V pass that address as a hidden first argument instead.
- shadow space
- The 32 bytes a Microsoft x64 caller reserves on the stack just above the return address on every call, so that the callee can store rcx, rdx, r8 and r9 there if it needs to.
References
- System V Application Binary Interface, AMD64 Architecture Processor Supplement: parameter passing and returning of values (x86-64 psABI maintainers)
- x64 calling convention (Microsoft)
- x64 stack usage (Microsoft)
- Abstract data models (64-bit Windows: LLP64) (Microsoft)
- Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64) (Arm Limited)
- Writing ARM64 code for Apple platforms (Apple Inc.)
- RISC-V ELF psABI: calling convention (RISC-V International)
- Intel 64 and IA-32 Architectures Software Developer Manuals (64-bit operand size, IMUL, PUSH, CALL) (Intel Corporation)
- Arm A-profile A64 Instruction Set Architecture (MADD, LDRSB, SXTB, STRB, writes to W registers) (Arm Limited)
Report a problem with this lesson
Kept only in this browser. Your Learn progress