Passing Arguments in Registers

The previous lesson gave you the rulebook: the first six integer arguments travel in rdi, rsi, rdx, rcx, r8, r9. This lesson is where you watch it happen in real listings. Once you can spot the pattern, every function call you read starts to make sense at a glance.

The Caller's Side: Loading the Registers

Look at what a caller does right before a call. Here is a function that takes three arguments, and the main that calls it:

#include <iostream>

int combine(int a, int b, int c)
{
    return a * 100 + b * 10 + c;
}

int main()
{
    std::cout << combine(7, 8, 9) << '\n';
    return 0;
}

The interesting part of main at -O0:

main:
    push rbp
    mov  rbp, rsp
    mov  edx, 9
    mov  esi, 8
    mov  edi, 7
    call combine(int, int, int)
    mov  esi, eax
    ...

Read the three mov lines just before the call. The compiler is laying the arguments into their assigned registers:

  • edi gets 7 — the first argument, a.
  • esi gets 8 — the second argument, b.
  • edx gets 9 — the third argument, c.

(They are int-sized, so we see the 32-bit names edi, esi, edx rather than rdi, rsi, rdx, but they are the same registers.) Notice the compiler happened to fill them in reverse order here; the order of the movs does not matter, only that each value ends up in the right register before the call fires. This loading-the-registers dance, followed by a call, is the single most recognisable pattern in all of function-call assembly.

The Callee's Side: Reading the Registers

Now flip to the other side. What does combine do with those registers? At -O0 it immediately copies each one into its stack frame:

combine(int, int, int):
    push rbp
    mov  rbp, rsp
    mov  DWORD PTR -4[rbp], edi
    mov  DWORD PTR -8[rbp], esi
    mov  DWORD PTR -12[rbp], edx
    ...

After the prologue, the first three instructions spill the incoming arguments from their registers into local stack slots: edi into -4[rbp], esi into -8[rbp], edx into -12[rbp]. This is the mirror image of what the caller just did. The caller wrote the arguments into registers; the callee reads them straight back out. Unoptimized code does this spill-to-stack step for every argument so it has a stable home for each parameter, exactly the store-then-reload plumbing you saw in Chapter 1.

All Six at Once

To see the full register sequence, here is a function that uses all six argument slots:

#include <iostream>

int sum6(int a, int b, int c, int d, int e, int f)
{
    return a + b + c + d + e + f;
}

int main()
{
    std::cout << sum6(1, 2, 3, 4, 5, 6) << '\n';
    return 0;
}

The caller in main fills every argument register:

main:
    push rbp
    mov  rbp, rsp
    mov  r9d, 6
    mov  r8d, 5
    mov  ecx, 4
    mov  edx, 3
    mov  esi, 2
    mov  edi, 1
    call sum6(int, int, int, int, int, int)
    ...

There is the whole convention in one block. Reading the values back through the registers in argument order: edi=1, esi=2, edx=3, ecx=4, r8d=5, r9d=6. The six argument registers, in their canonical sequence, each loaded with the matching argument. The 32-bit names r8d and r9d are just the lower halves of r8 and r9, used because these arguments are int-sized.

And the callee spills all six the same way it spilled three before:

sum6(int, int, int, int, int, int):
    push rbp
    mov  rbp, rsp
    mov  DWORD PTR -4[rbp], edi
    mov  DWORD PTR -8[rbp], esi
    mov  DWORD PTR -12[rbp], edx
    mov  DWORD PTR -16[rbp], ecx
    mov  DWORD PTR -20[rbp], r8d
    mov  DWORD PTR -24[rbp], r9d
    ...

Six arguments in, six spills to the frame, one slot each. If you ever need to confirm which register carries which argument, this kind of listing spells it out: the order of the spills is the order of the arguments.

A Glance at What the Optimizer Does

All that spilling is -O0 literalism. The arguments arrive in registers and are perfectly usable there, so at -O2 the compiler simply keeps them in registers and computes directly. With optimization on, combine becomes:

combine(int, int, int):
    imul edi, edi, 100
    lea  eax, [rsi+rsi*4]
    lea  eax, [rdi+rax*2]
    add  eax, edx
    ret

No prologue, no stack slots, no spilling. The arguments stay right where the caller put them: edi (a), esi (b), edx (c) are used in place, and the result is built up in eax. The convention is identical; the optimizer has just removed the bookkeeping. Seeing the same arguments-in-registers contract with and without the plumbing is the clearest way to internalise it.

Key Takeaways

  • Right before a call, the caller loads the argument registers rdi, rsi, rdx, rcx, r8, r9 (32-bit names edi, esi, ... for ints).
  • The order of those movs does not matter; only that each value is in the correct register before the call.
  • A call preceded by a cluster of moves into the argument registers is the canonical function-call pattern.
  • At -O0 the callee immediately spills each argument to a stack slot; the spill order matches the argument order.
  • At -O2 the spilling disappears and the callee uses the arguments straight out of their registers; the convention is unchanged.