The mental model most C++ programmers carry is that calling a function pushes its arguments onto the stack — a stack frame appears, parameters live in it, the callee reads them from known offsets. That picture was largely accurate in 1990, on 32-bit x86, with cdecl. On every modern 64-bit platform it is mostly wrong.
Today’s calling conventions pass the first half- dozen or so arguments in registers and only spill the remainder to the stack. Knowing where your arguments actually live — and which ones cross the boundary from register to memory — is the difference between “I write C++” and “I understand how my C++ runs.”
Three things to take away:
- On 64-bit platforms, most function arguments are passed in registers; the stack is for overflow and for arguments too large for register passing.
- The cutoff differs by ABI: System V AMD64 (Linux, macOS, BSD) uses six integer/pointer registers; Microsoft x64 (Windows) uses four.
- Large or non-trivial-layout objects passed by value are handled specially — the caller allocates space, passes a pointer, and the callee writes through it.
The 32-bit world: arguments lived on the stack
Before the 64-bit transition, the dominant x86 calling convention (cdecl) put every argument on the stack:
caller pushes arguments right-to-left
caller executes call function
[ stack at function entry ]
+-----------------+
| arg N | <- highest address
| ... |
| arg 2 |
| arg 1 |
| return address | <- ESP
+-----------------+
The callee read its arguments from positive offsets relative to the base pointer (EBP). This is the picture taught in most introductory texts, and it’s where the term “stack frame” comes from. It was straightforward and uniform — and it was slow, because every call required a memory write per argument and a matching memory read inside the callee.
64-bit ABIs were redesigned around register passing precisely to remove that overhead.
System V AMD64: six integer registers, then the stack
The System V AMD64 ABI is what GCC and Clang use on Linux, macOS, and the BSDs — almost every Unix-derived platform. Integer and pointer arguments are passed in six registers, in this order:
| Position | Register |
|---|---|
| 1 | RDI |
| 2 | RSI |
| 3 | RDX |
| 4 | RCX |
| 5 | R8 |
| 6 | R9 |
Floating-point arguments use a separate set of eight registers (XMM0–XMM7). The two sets are filled independently — a function taking three ints and three doubles passes the ints in RDI/RSI/RDX and the doubles in XMM0/XMM1/XMM2, with no interaction.
long sum6(long a, long b, long c, long d, long e, long f){
return a + b + c + d + e + f; // a..f arrive entirely in registers
}
A seventh integer argument forces a spill. From argument seven onward, the caller pushes them onto the stack — in right-to-left order, so the leftmost stack argument appears at the lowest stack address from the callee’s view:
long sum7(long a, long b, long c, long d, long e, long f, long g);
// RDI RSI RDX RCX R8 R9 [stack]
The stack pointer (RSP) must be 16-byte aligned just before the call instruction executes. The call itself pushes the return address (8 bytes), so RSP + 8 is 16-byte aligned at function entry — a constraint compilers honour by adjusting RSP in the function prologue.
Microsoft x64: four registers, plus shadow space
Windows uses a different convention. Only four registers carry arguments:
| Position | Register |
|---|---|
| 1 | RCX |
| 2 | RDX |
| 3 | R8 |
| 4 | R9 |
And there’s a quirk: the caller must allocate 32 bytes of “shadow space” on the stack for those four register arguments, even when the callee doesn’t need it. The shadow space is reserved memory that the callee may use to spill the register arguments back into a known location — useful for debugging, varargs handling, and taking the address of a parameter. Arguments five and beyond go above the shadow space.
[ Microsoft x64 stack at function entry ]
+----------------+
| arg N | <- higher addresses
| ... |
| arg 6 |
| arg 5 |
| shadow / arg 4 | (reserved 32 bytes)
| shadow / arg 3 |
| shadow / arg 2 |
| shadow / arg 1 |
| return address | <- RSP at function entry
+----------------+
The same C++ source compiled on Linux and Windows therefore produces different argument-passing code. A function taking five ints passes four in registers on Windows and pushes the fifth above the shadow space; on Linux it passes all five in registers with no shadow space at all. This is one of the subtle reasons C++ ABIs are not interchangeable across platforms — calling convention is part of the binary contract.
Aggregates: small ones split, large ones become pointers
Passing a struct by value is where the rules get specific. Both ABIs handle small aggregates by splitting them across registers when possible:
struct Point { double x, y; }; // 16 bytes — fits in two XMMs
double distance(Point p);
// On System V: p arrives in XMM0 (x) and XMM1 (y) — no stack involved
A small POD struct that fits within the register classes is decomposed and passed in pieces. The rule is roughly: structs of up to two 8-byte chunks (16 bytes total on SysV) are split across appropriate registers; anything larger is passed indirectly. For an oversized aggregate, the caller allocates stack space, fills it with the value, and passes a pointer where the integer-argument register would normally go:
struct BigBlob { char data[64]; };
void process(BigBlob b);
// b is too large for register passing.
// Caller allocates 64 bytes of stack space, copies the value in,
// and passes the *address* of that copy in RDI (System V) or RCX (Win).
The same mechanism applies in the other direction. Returning a large object by value introduces a hidden first parameter: a pointer to caller-allocated space where the callee writes the return value. This is why “return by value” of a large type is not the disaster naive analysis would suggest — there is no extra copy, just a single in-place construction.
Hidden parameters: this and the return slot
Two parameters never appear in the C++ source but always travel through the calling convention:
thisis passed as if it were the first argument for non-static member functions. On System V it lives inRDI; on Microsoft x64 inRCX. The first explicit parameter shifts to the next register.- The return slot for large by-value returns is also a hidden first argument, passed in the same way
thiswould be. When both are needed (a member function returning a large value),thisbecomes the second hidden parameter after the return slot.
struct Vec3 { double x, y, z; }; // 24 bytes — too big for register return
class Calculator {
public:
Vec3 transform(double scale);
// Effective ABI signature on System V:
// void transform(Vec3* return_slot, Calculator* this, double scale)
// ^RDI ^RSI ^XMM0
};
The hidden parameters are why member function pointers are larger than ordinary function pointers, why sizeof is the same for an object of a class with member functions as one without, and why a debugger watching arguments may show one more than the source declared.
Why this matters for working programmers
Most of the time, the calling convention is invisible — the compiler handles it. Three cases bring it back into view:
- Performance. Passing arguments in registers is faster than pushing them. A function with seven integer arguments forces a stack write on Linux that a six-argument function doesn’t. If you see hot-path profiling showing time in call-site argument setup, the answer is sometimes literally “pass fewer arguments.”
- ABI compatibility. Linking object files compiled by different compilers, or loading libraries built for different platforms, requires the calling conventions to match. This is what
extern "C"(Nibble #067) preserves and what makes C++ ABIs platform-specific. - Debugging assembly. Reading disassembly without knowing the calling convention is reading text in a language you don’t speak. Recognising “this
RDIis the first argument” is the first step toward understanding what compiled code is doing.
Takeaway
Modern 64-bit C++ rarely puts arguments on the stack at all — most calls pass everything in registers, and the stack only sees what’s left over after the registers fill up. The cutoff is six integer registers on System V, four on Microsoft x64; floating-point arguments use their own register set; small aggregates split across registers; large ones become hidden pointers that the caller fills and the callee reads.
The rule is simple: when you read about “arguments on the stack,” check the date and the ABI. The picture from the 1990s is mostly historical; the picture from your compiler’s actual output is the one that’s running.