The stack, call and ret
Almost every function you read starts by adjusting rsp and ends by putting it back. Once the stack is clear in your head, prologues and epilogues become noise you skip over, and local variables become easy to name.
The basics
The stack grows down, towards lower addresses. rsp points at the last value pushed.
push rax:rsp -= 8, then storeraxat[rsp]pop rax: load[rsp]intorax, thenrsp += 8call f: push the address of the next instruction (the return address), then jump tofret: pop the return address intorip
So at the first instruction of any function, [rsp] holds the address it will return to.
A frame at -O0
int total(int *v, int n);
int average3(int a, int b, int c)
{
int v[3] = { a, b, c };
return total(v, 3) / 3;
}GCC 13, -O0:
0000: push rbp
0001: mov rbp, rsp
0004: sub rsp, 0x20 ; room for locals
0008: mov DWORD PTR [rbp-0x14], edi ; a
000b: mov DWORD PTR [rbp-0x18], esi ; b
000e: mov DWORD PTR [rbp-0x1c], edx ; c
0011: mov eax, DWORD PTR [rbp-0x14]
0014: mov DWORD PTR [rbp-0xc], eax ; v[0] = a
0017: mov eax, DWORD PTR [rbp-0x18]
001a: mov DWORD PTR [rbp-0x8], eax ; v[1] = b
001d: mov eax, DWORD PTR [rbp-0x1c]
0020: mov DWORD PTR [rbp-0x4], eax ; v[2] = c
0023: lea rax, [rbp-0xc] ; &v[0]
0027: mov esi, 0x3
002c: mov rdi, rax
002f: call total
0034: ... ; / 3, see the bit-tricks post
0049: leave
004a: retDrawn as memory, higher addresses at the top:
rbp+0x8 return address (pushed by call)
rbp saved rbp (pushed by the prologue)
rbp-0x4 v[2]
rbp-0x8 v[1]
rbp-0xc v[0] <- the array, in increasing addresses
rbp-0x14 a
rbp-0x18 b
rbp-0x1c c
rsp = rbp-0x20leave is shorthand for mov rsp, rbp followed by pop rbp: it undoes the whole frame in one instruction.
Notice that v[0] is at the lowest address of the array. Arrays and structs always grow upwards in memory, even though the stack as a whole grows down. It's also why compilers add stack-protector checks (a guard value tested before ret) to functions with local arrays: you'll often see a __stack_chk_fail call at the end of such functions.
The same function at -O1
0000: sub rsp, 0x18
0004: mov DWORD PTR [rsp+0x4], edi
0008: mov DWORD PTR [rsp+0x8], esi
000c: mov DWORD PTR [rsp+0xc], edx
0010: lea rdi, [rsp+0x4]
0015: mov esi, 0x3
001a: call total
001f: ...
0033: add rsp, 0x18
0037: retNo rbp frame: locals are addressed from rsp directly. The array still has to live in memory, because its address is passed to total.
Why 0x18, not 0x10?
The ABI requires rsp to be a multiple of 16 at every call. On entry to a function, the call that got us here has just pushed 8 bytes, so rsp is 8 more than a multiple of 16. Subtracting 0x18 (24) makes it aligned again, with 12 bytes used for v and the rest padding. In the -O0 version, push rbp (8) + sub rsp, 0x20 (32) achieves the same thing. Odd-looking stack adjustments are almost always alignment.
The red zone
On Linux, leaf functions (ones that call nothing) may use the 128 bytes below rsp without moving it. If a small function writes to [rsp-0x10] with no sub rsp, that's the red zone, not a bug. Windows has no red zone; it has a 32-byte "shadow space" that the caller reserves above the return address instead.
Reading habit
Skip the prologue and epilogue, then make a table of every [rbp-N] or [rsp+N] slot and what it holds. Decompilers do exactly this; doing it by hand a few times makes their output much easier to trust or correct.