Part 2 · The calling contract
The ABI is a contract nothing enforces
Identical hardware, different rules. Which register holds your first argument is a political decision, not a physical one — and violating it fails silently.
What this lesson is trying to break (3)
- Argument registers are determined by the hardware.
- The same machine code works anywhere the CPU is the same.
- Struct arguments are passed on the stack.
This is a threshold concept: troublesome, transformative, and irreversible. You should expect to be genuinely stuck here, and being stuck is the mechanism rather than a sign that you are failing. Budget several sessions. Do not move on because you are bored — move on when you can explain it out loud without notes.
Everything in Part 1 was about the machine. This lesson is about an agreement, and the difference matters: an agreement can be violated, and nothing will stop you.
The CPU knows about function calls. There must be designated argument registers, because otherwise how would a function know where to look?
The CPU has no concept of a function argument. It executes
call, which pushes an address and jumps. It has no idea anything
was passed. rdi is not special in any way the silicon knows about.
The caller and the callee agree in advance, and the agreement is written down in a document called an ABI. Both sides were compiled against the same document, so it works. Compile them against different documents and you get silent corruption — no error, no crash at the boundary, just wrong values.
Three agreements for one machine
Varies: only the ABI — the CPU is identical in the first two columns
| SysV AMD64 Linux, macOS | Windows x64 | AAPCS64 ARM64 | |
|---|---|---|---|
| Integer args | rdi rsi rdx rcx r8 r9(6) | rcx rdx r8 r9(4) | x0–x7(8) |
| Float args | xmm0–xmm7 (8) | xmm0–xmm3 (4) | v0–v7 (8) |
| Can int and float args share slots? | No — counted separately | Yes — one shared position count | No — counted separately |
| Return value | rax (+ rdx) | rax | x0 (+ x1) |
| Return address | pushed on stack | pushed on stack | link register x30 |
| Scratch space for callee | red zone, 128 bytes | shadow space, 32 bytes — caller allocates | none |
The first two columns run on the same silicon. Everything that differs between them is pure convention — which is the whole point of this lesson.
The Windows row about shared slots is the nastiest difference: in Windows x64, a
function taking (int, double, int) puts them in rcx, xmm1, r8 — the
positions are shared, so the float consumes slot 2 and the second int takes slot
3. Under SysV the same function uses edi, xmm0, esi. Identical source,
identical CPU, incompatible machine code.
struct Pair { long a; long b; };
long sum(struct Pair p) {
return p.a + p.b;
}A 16-byte struct, passed by value. Does it arrive in memory or in registers?
In two registers — rdi and rsi on SysV. No memory involved at all.
The SysV rules classify each 8-byte chunk of a small aggregate independently; two
INTEGER chunks means two integer registers. So sum is one add and a ret.
This matters for a belief many people carry: “passing a struct by value copies it, so pass a pointer instead.” For small structs that is often wrong — the by-value version can be cheaper, because a pointer forces the data into memory so there is something to point at, and then the callee has to load it back.
The threshold is ABI-specific and worth knowing roughly: on SysV, aggregates up to
16 bytes can go in registers; larger ones are passed in memory with a hidden
pointer. Add a third long to that struct and compile again — you will see the
mechanism switch, and that transition is the useful thing to have seen.
Where the contract shows up in a listing
Three things become readable once you know the convention:
Argument count and types. A function that reads edi, esi, and xmm0 on
entry took two integers and a float. You can recover a signature from an
unnamed function.
Whether a call happened. Before any call, arguments are marshalled into the
convention’s registers. A cluster of movs into rdi/rsi/edx immediately
before a call is argument setup, and reading it tells you what was passed.
Whether a function is a leaf. A leaf makes no calls, so it can use
caller-saved registers freely and skip most of the prologue. On ARM64 the tell is
whether x30 is saved. That is 2.3.
a run of movs into rdi, rsi, edx immediately before a call
Argument marshalling. Read it backwards to recover the call: the value going
into rdi is argument one.
This is the single most useful pattern for navigating unfamiliar disassembly, because it lets you reconstruct the call graph and the data flowing through it without any symbols beyond the call target.
Why you should care beyond reading
Two practical consequences a tech lead actually meets.
ABI stability is a real constraint on library design. Changing a struct’s
layout in a published header changes how it is passed and where its fields are.
Recompiling one side and not the other produces silent corruption. This is why C
libraries version so carefully, why Rust has no stable ABI and passes everything
through extern "C" at boundaries, and why “just add a field to that struct” is
sometimes a breaking change.
FFI is where this bites. When you call C from Rust, or anything from JavaScript through a native module, both sides must agree on the convention. Most FFI bugs that produce garbage values rather than crashes are ABI mismatches.
A colleague has a native module that returns correct results on their Linux CI and garbage on a developer’s Windows machine. Same architecture, same compiler version, same source.
Give the most likely explanation and the specific thing you would ask them to check first.
What you should now be able to say
- The CPU has no concept of an argument. The ABI is a document, not hardware.
- SysV uses
rdi rsi rdx rcx r8 r9; Windows x64 uses four registers and shares positions between integer and float args; ARM64 usesx0–x7. - Violations fail silently — wrong values, not errors.
- Small structs are often passed in registers, so by-value can beat by-pointer.
- Argument marshalling before a
calllets you reconstruct signatures from unnamed code. - ABI stability constrains library design, and ABI mismatch is the usual cause of garbage-value FFI bugs.