Part 2 · The calling contract
The stack is a convention
Not a data structure the CPU provides. A register, a direction, and an agreement — which is why it can be corrupted.
What this lesson is trying to break (3)
- The stack is a hardware data structure.
- The CPU prevents you from corrupting it.
- The stack grows upward, like an array.
The stack is a structure the CPU manages. It has push and pop, it keeps track of frames, and the hardware makes sure functions do not interfere with each other’s locals.
The CPU provides one register — rsp on x86-64,
sp on ARM64 — and a couple of instructions that adjust it.
That is the entire hardware contribution. There is no frame tracking, no
bounds checking, and no protection between one function’s space and another’s.
Read the stack as a pointer plus an agreement. Generated code
all agrees to grow downward, to leave rsp where it found it, and
to respect certain regions. Because it is agreement rather than enforcement,
it can be broken — which is the entire subject of memory-safety exploitation,
and the reason Rust’s ownership rules exist one level up.
Downward
The stack grows toward lower addresses. This trips people up every time because it inverts the usual mental picture.
high addresses
┌──────────────────┐
│ caller's frame │
├──────────────────┤
│ return address │ ← pushed by `call`
├──────────────────┤
│ our locals │
│ our spills │
├──────────────────┤ ← rsp points HERE (lowest in-use address)
│ (unused) │
▼ grows this way
low addresses
So:
push rax=rsp -= 8, then storeraxat[rsp]pop rax= loadraxfrom[rsp], thenrsp += 8sub rsp, 32= allocate 32 bytes of local spaceadd rsp, 32= free it
Allocating stack memory is a subtraction. That is the whole cost — no allocator, no bookkeeping, no free list. This is exactly why Rust could promise that stack values are cheap: putting a value on the stack costs the arithmetic that was going to happen anyway when the frame was set up.
int f(int x) {
int arr[4];
for (int i = 0; i < 4; i++) arr[i] = x + i;
return arr[0] + arr[3];
}Compile it for x86-64. Do you expect to see sub rsp, ... at all?
Probably not. At -O2 the array never reaches memory — four ints fit
comfortably in registers, so the compiler keeps them there, and with constant
indices it will likely fold the whole thing into 2*x + 3.
That is the honest lesson: the stack is what happens when registers run out,
not where locals automatically go. A local variable only gets a stack address if
something forces it — too many live values, its address is taken, it is a large
aggregate, or you compiled at -O0.
To see a real frame, force the issue: make the array 100 elements, or take its
address and pass it to a function the compiler cannot see. Then sub rsp, N
appears and you can read the layout.
call and ret are the only magic
call target does two things: push the return address, then jump.
ret does one: pop into the instruction pointer.
That is the entire mechanism. Which means the return address is data on the stack, sitting a fixed distance from your locals, writable by any code that can compute its address. Overrun an array on the stack and you can overwrite where the function will return to.
Every stack-safety mechanism you have heard of exists because of that sentence: stack canaries put a known value between locals and the return address so overwriting it can be detected; non-executable stacks stop the overwritten target being useful; ASLR makes the target hard to predict.
ARM64 does this differently and better: bl (branch-with-link) puts the return
address in the link register x30 rather than on the stack. A leaf function
never spills it, so there is nothing on the stack to overwrite. Non-leaf
functions must save x30 themselves — which is why ARM64 prologues look
different, and why you will see stp x29, x30, [sp, #-16]! as the standard
opening move.
sub rsp, N at the top of a function
A stack frame of N bytes is being allocated, and N tells you roughly how much did not fit in registers.
A large N in a hot function is worth a second look: it usually means heavy
spilling, a large local aggregate, or an address that escaped. In a loop, stores
and loads to the same [rsp+k] addresses every iteration is the signature of
spilling.
Note that N is usually rounded up for alignment (2.4), so it is a lower bound on padding, not an exact accounting.
Rust told you that moving a value is cheap and that stack allocation is free. Using this lesson, explain why both are true, mechanically — what instruction does the work in each case.
Then answer: what would have to be true of a Rust value for it to not be free to put on the stack?
What you should now be able to say
- The hardware provides one register. Everything else is agreement.
- The stack grows downward;
pushis a subtract-then-store. - Allocating stack space is
sub rsp, N— arithmetic, not allocation. - Locals reach memory only when forced to; otherwise they stay in registers.
callpushes the return address, making it overwritable data — the root of stack-smashing and of every mitigation for it.- ARM64 uses a link register instead, so leaf functions keep the return address off the stack entirely.