Passing Arguments in Registers
Watch several arguments land in rdi, rsi, rdx and friends just before a call.
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:
edigets7— the first argument,a.esigets8— the second argument,b.edxgets9— 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 registersrdi,rsi,rdx,rcx,r8,r9(32-bit namesedi,esi, ... forints). - The order of those
movs does not matter; only that each value is in the correct register before thecall. - A
callpreceded by a cluster of moves into the argument registers is the canonical function-call pattern. - At
-O0the callee immediately spills each argument to a stack slot; the spill order matches the argument order. - At
-O2the spilling disappears and the callee uses the arguments straight out of their registers; the convention is unchanged.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
Passing Arguments in Registers - Quiz
Test your understanding of the lesson.
Practice Exercises
Put the Arguments in the Right Registers
The buildNumber function assembles a three-digit number from its hundreds, tens, and ones parameters, in that order. Both calls in main pass the digits in the wrong order, so the numbers come out reversed. Fix the argument order so the program prints 123 and 456, then switch the assembly panel to -O0 and watch main stage each value into its argument register right before the call: edi carries the first argument, esi the second, edx the third.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!