Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

f9: a weighted sum of nine arguments, on three ISAs

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: ten calls of f9 C · f9_main.c
// 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;
}
f9 on AArch64, and the x86-64 and RISC-V listings Python · f9_session.py
"""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

  1. Question 1 of 2 Under Microsoft x64, which register carries the third integer argument?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: r8

    Microsoft x64 passes the first four arguments in rcx, rdx, r8 and r9, in that order, so the third one arrives in r8. System V would put it in rdx, the third register of its own list.

  2. Question 2 of 2 Under System V, void blur(unsigned char *pixels, long long width, long long height) is called. Which register holds height?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: rdx

    A pointer is an address, and every convention here passes it like an integer. So pixels, width and height fill the first three System V registers, rdi, rsi and rdx.

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 and the script that compiles it for one target

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:

call9 compiled for System V and for Microsoft x64 Python · call9_x86_session.py
"""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:

Interactive · step by step Nine arguments at the call: System V on the left, Microsoft x64 on the right Step through the stack each f9 finds when it starts. Offsets are from rsp after call has pushed the return address; higher addresses are higher up.
Nine arguments at the call: System V on the left, Microsoft x64 on the right, step 7 of 7. Showing Argument 9, Argument 8, Argument 7, Argument 6, Argument 5, Shadow space, Return address, Argument 9, Argument 8, Argument 7, Return address. In focus: Argument 7, Argument 7.Argument 9rsp + 72Argument 8rsp + 64Argument 7rsp + 56Argument 6rsp + 48Argument 5rsp + 40Shadow space32 bytes at rsp + 8Return addressrsp, Microsoft x64Argument 9rsp + 24Argument 8rsp + 16Argument 7rsp + 8Return addressrsp, System V

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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:

call9 compiled for Apple's arm64, Arm Linux and RISC-V Python · call9_arm_session.py
"""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

  1. Question 1 of 2 A System V function takes ten long long arguments. When it starts, how many bytes above rsp is the eighth one?

    Type a number.

    A hint

    The return address is at rsp itself, and the seventh argument is right above it.

    Show the answer: Question 1 of 2

    Answer: 16 bytes above rsp

    Arguments 1 to 6 are in registers. call left the return address at rsp, the seventh argument is at rsp + 8, and each later one is 8 bytes higher, so the eighth is at rsp + 16, as f9_x86_64.s reads it.

  2. Question 2 of 2 Under Microsoft x64, how many bytes above rsp is the sixth argument when the callee starts?

    Type a number.

    A hint

    Count the return address and the shadow space before the first stack argument.

    Show the answer: Question 2 of 2

    Answer: 48 bytes above rsp

    The return address takes rsp to rsp + 7 and the shadow space the next 32 bytes, so the fifth argument is at rsp + 40 and the sixth at rsp + 48, the place the step-through showed.

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, interleaved C · mix.c
// 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; }
mix compiled for four conventions Python · mix_session.py
"""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

  1. Question 1 of 2 Under Microsoft x64, double area(double w, long long n, double h) is called. Which register carries h?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: xmm2

    Microsoft x64 gives argument n the n-th register of its kind: w in xmm0, n in rdx, h in xmm2. As in mix, the registers of the other kind at each position carry nothing.

  2. Question 2 of 2 The same double area(double w, long long n, double h) is called under System V. Which register carries h?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: xmm1

    System V keeps two counters, one per kind: w takes xmm0, n takes rdi, and h takes the next unused XMM register, xmm1. mix showed the same with j, which arrived in rsi.

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 C · results.c
// 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; }
make_pair and make_triple on four conventions Python · results_session.py
"""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

  1. Question 1 of 2 make_triple(x) returns a 24-byte struct under AAPCS64. When it starts, where are x and the address of the result?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: x in x0, the address in x8

    A result too big for two registers goes to memory the caller sets aside. AAPCS64 passes its address in x8, which is why make_triple stored through x8 while x was still in x0.

  2. Question 2 of 2 Under System V, struct big f(long long a, long long b) returns 24 bytes. When f starts, which registers hold the result address, a and b?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: rdi, rsi and rdx

    System V passes the address of the result memory in rdi as if it were the first argument and returns it in rax. Every real argument shifts one register, as x did in make_triple, which arrived in rsi.

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 and call10.c: ten 1-byte arguments

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); }
sum10 and call10 for Apple's arm64 and for Arm Linux Python · narrow_session.py
"""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

  1. Question 1 of 2 Under Apple's arm64, where does sum10 find its tenth signed char argument when it starts?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: [sp, #1]

    The first eight arguments go in w0 to w7. The ninth and tenth go on the stack, and Apple lets each take one byte, so they are at sp and sp + 1: the strb stores and ldrsb loads of the run.

  2. Question 2 of 2 An Apple arm64 caller passes an unsigned char that holds 200 in w0. What must bits 8 to 31 of w0 be?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: All 0

    Apple's document makes the caller sign- or zero-extend every argument narrower than 32 bits, by its type. An unsigned char is zero-extended, so bits 8 to 31 are 0 and the callee may use w0 as it is.

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.

  1. Copy f9_aarch64.s to dot4_aarch64.s and rewrite it as long 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 returns a * w + b * x + c * y + d * z. Copy f9_main.c to 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 hold w to z on AAPCS64 and on LP64D, and which two arguments System V would pass on the stack, at which offsets.
  2. Copy call9.c to dot4_call.c, declare dot4 instead of f9 and call it with 1 to 8. Before you run sh 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 from rsp to cover them, the shadow space and any padding that keeps rsp a multiple of 16 at the call. Then do the same for x86_64-linux-gnu, aarch64-linux-gnu and riscv64-linux-gnu; on the last two, also say which instruction takes the place of the call, and why.
  3. Copy results.c and change every long long to long. Predict how make_pair comes back on x86_64-pc-windows-msvc, where long is now 4 bytes, then check it with show.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.

  1. Question 1 of 15 Which registers carry the first four integer arguments under System V, in order?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: rdi, rsi, rdx, rcx

    System V uses rdi, rsi, rdx, rcx, r8 and r9 for integer and pointer arguments, which is why f9_x86_64.s starts from rdi and the compiled call9 loads 1 into edi.

  2. Question 2 of 15 A RISC-V LP64D function takes (char *buf, long long n, long long *out, long long start, long long step, long long count). Where does count arrive?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: a5

    Pointers and integers share a0 to a7. buf is a0, n a1, out a2, start a3, step a4 and count a5.

  3. Question 3 of 15 The last madd of f9_aarch64.s is changed to multiply x7 instead of the value loaded from [sp]. What do the lines for argument 9 alone and argument 8 alone now print?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: f9 = 0 and f9 = 17

    The driver's single 1 tests one position at a time. With a 1 in the ninth position, the loaded value is never used and every register is 0, so f9 returns 0. With a 1 in the eighth, x7 = 1 contributes 8 and then 9 again, 17 in all.

  4. Question 4 of 15 Under Microsoft x64, which argument is the first one on the stack?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: The fifth

    Microsoft x64 passes four arguments in registers. The fifth goes on the stack above the 32 bytes of shadow space, at rsp + 40 when the callee starts.

  5. Question 5 of 15 A System V caller passes eight long long arguments with push. Which stack argument does it push first?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: The eighth, then the seventh

    The psABI pushes stack arguments right to left: call9 ran push 9, push 8, push 7. The last push leaves the lowest-numbered stack argument at the lowest address, just above the return address.

  6. Question 6 of 15 A Microsoft x64 callee starts with push rbx and then reads its fifth argument. Which address must it use?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: [rsp + 48]

    Offsets in a callee count from its current rsp. On entry the fifth argument is at rsp + 40, above the return address and the shadow space; each push lowers rsp by 8, so after one push it is at rsp + 48.

  7. Question 7 of 15 Which registers carry the first two double arguments under AAPCS64?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: d0 and d1

    AAPCS64 passes floating-point arguments in v0 to v7, apart from the integers in x0 to x7. In mix, d and e arrived in d0 and d1 while i and j used x0 and x1.

  8. Question 8 of 15 Under Microsoft x64, f(double a, double b, double c, double d, double e) is called. Where is e?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: On the stack

    Microsoft x64 has four register positions whatever the types, so a, b, c and d take xmm0 to xmm3 and e goes on the stack at rsp + 40.

  9. Question 9 of 15 A function takes six long long and eight double arguments, mixed in any order. How many of the fourteen does a Microsoft x64 caller pass on the stack?

    Type a number.

    A hint

    Count the positions that have registers, whatever their types.

    Show the answer: Question 9 of 15

    Answer: 10 arguments

    Microsoft x64 counts positions, and only four have registers, so 14 - 4 = 10 go on the stack. System V counts the kinds apart, six integers in rdi to r9 and eight doubles in xmm0 to xmm7, so it puts none of them there.

  10. Question 10 of 15 Where does a RISC-V LP64D function return one 64-bit integer?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: a0

    RISC-V uses a0 and a1 to return values; a single integer comes back in a0, as rax and x0 do on the other two ISAs.

  11. Question 11 of 15 A Windows function returns struct { long long a, b; }. How does its caller get the two fields?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: From memory whose address the caller passed in rcx

    Microsoft x64 returns a struct in rax only when it is 1, 2, 4 or 8 bytes long. make_pair stored both fields through rcx and returned the same address in rax.

  12. Question 12 of 15 A RISC-V caller has called make_triple and wants the address of the 24-byte result afterwards. Where should it take it from?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: From its own copy of the address, kept in a register the call preserves or recomputed from sp

    The RISC-V psABI passes the address in a0 but states that a0 need not hold it on return. A caller that needs the address after the call uses the one it passed in.

  13. Question 13 of 15 On Apple's arm64, who widens a signed char argument to 32 bits?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: The caller

    Apple's call10 ran sxtb once before passing the value in w0 to w7, and Apple's sum10 added the registers with no extension.

  14. Question 14 of 15 Under AAPCS64 on Linux, a function's ninth and tenth arguments are both short. At which offsets from sp does it find them?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: 0 and 8

    Each stack argument under AAPCS64 takes at least 8 bytes, so the ninth is at sp and the tenth at sp + 8, the offsets the Linux sum10 read its two bytes from.

  15. Question 15 of 15 You write an AArch64 assembly function that takes one signed char and must work when called from C on both macOS and Linux. What should it do with w0?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: Sign-extend it itself, for example with sxtb w0, w0, before using it

    Widening in the callee is right under AAPCS64 and harmless under Apple's rule, where the caller has already done it. The Linux sum10 in the run did exactly this with sxtb.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A hand-written AArch64 routine calls the C function struct vec3 make_vec3(long long seed), a struct of three long long fields, 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

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

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

  3. 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].

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

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

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

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

  8. 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, 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 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, x0 or a0 and a 16-byte pair in two registers except on Windows, while larger results go through caller memory whose address arrives in rdi, rcx, x8 or a0.
  • 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.