x86-64 Memory Addressing and Stack Frames
Trace effective addresses, alignment, stack growth, and function frames
x86-64 Memory Addressing and Stack Frames
These blog posts are written as lessons. Examples use Intel syntax. Exact stack layouts vary with the ABI, compiler, optimization, and debug information.
Assembly shows addresses instead of most variable names. player.health becomes something like [rcx+12], and values[i] becomes [rdi+rsi*4]. A local variable is not a permanent “stack box”; it is storage found relative to the current rsp or an optional frame pointer.
Effective-address grammar
The common Intel-syntax form is:
[base + index * scale + displacement]
scale can be 1, 2, 4, or 8, and any other term can be absent.
mov eax, [rcx]
mov eax, [rcx + 12]
mov eax, [rcx + rdx*4]
mov eax, [rcx + rdx*4 + 12]
The expression is evaluated as an address and the selected operand width is accessed. lea is the special case that returns the calculated address without dereferencing it.
Address and value are different states
If rcx is 0x1000 and the 64-bit value 42 lives at 0x1008:
lea rax, [rcx + 8] ; RAX = 0x1008
mov rax, [rcx + 8] ; RAX = 42
Mark each live register as “address” or “value” while debugging. Machine registers do not carry pointer type tags.
Arrays include element size in the address
For a four-byte int array:
mov eax, dword ptr [rcx + rdx*4]
The address is base + index * sizeof(int). A 12-byte structure requires extra arithmetic because the hardware scale field only supports 1, 2, 4, and 8.
lea rax, [rdx + rdx*2] ; index * 3
mov ecx, [rcx + rax*4 + 4] ; index * 12 + field offset 4
Such multipliers are clues to element size, structure stride, or a multidimensional array.
Structure offsets are binary contracts
struct Player {
uint32_t id;
float x;
float y;
int32_t health;
};
If the build proves that health is at offset 12:
mov eax, dword ptr [rcx + 12]
The number 12 is not a field name. Field order, type, packing, inheritance, or ABI changes can silently give it another meaning. Protect a C++/assembly boundary with static_assert(offsetof(Player, health) == 12) and static_assert(sizeof(Player) == 16).
RIP-relative addressing supports relocatable code
lea rdi, [rip + message]
mov eax, dword ptr [rip + counter]
The code finds data relative to the instruction location instead of embedding a full absolute address. ELF GOT/PLT or PE import-table indirection may add another load in dynamically linked programs.
Little-endian byte order
Storing 0x1122334455667788 in memory produces:
address +0 +1 +2 +3 +4 +5 +6 +7
byte 88 77 66 55 44 33 22 11
Endianness describes the byte order of a multi-byte memory representation. It does not reverse the order of arithmetic or bit operations.
Alignment has several contracts
Many x86-64 loads and stores tolerate unaligned addresses, but that does not make every unaligned access correct or free.
- Crossing a cache-line or page boundary may cost more.
- Some SIMD forms require or benefit from aligned memory.
- ABIs require stack alignment at function-call boundaries.
- Dereferencing an under-aligned typed pointer can be undefined in C or C++.
- Atomicity depends on instruction, size, alignment, and boundaries.
“x86 permits unaligned access” is not a complete safety argument.
The stack grows toward lower addresses
push rbx ; rsp -= 8; [rsp] = rbx
pop rbx ; rbx = [rsp]; rsp += 8
call target stores a return address on the stack and transfers control. ret loads that address and advances rsp.
caller immediately before call RSP = S
callee immediately on entry RSP = S - 8
after ret RSP = S
Modern x86-64 ABIs pass many arguments in registers, but call still places the return address on the stack.
A stack frame is a function-owned interval
An unoptimized function may use:
push rbp
mov rbp, rsp
sub rsp, 32
; body
add rsp, 32
pop rbp
ret
It preserves the caller's rbp, creates a stable reference point, allocates 32 bytes, and later reverses each step. A frame pointer is optional. Optimized code can use rbp as a general register, address slots from rsp, or keep every local value in registers and allocate no frame.
Source locals can disappear
int sum(int a, int b) {
int temp = a + b;
return temp * 2;
}
An optimized System V build might be:
lea eax, [rdi + rsi]
add eax, eax
ret
There is no stack address for temp because the observable result is produced entirely in registers. Never memorize a Debug stack diagram as the fixed Release layout.
What can live in a frame
- Local arrays and address-taken variables
- Register spills
- Saved nonvolatile registers
- Arguments that did not fit in registers
- Windows x64 shadow space
- Alignment padding
- Stack canaries and security state
- Dynamic allocation
Consequently, [rsp+40] is not automatically “the fifth local.” Determine the ABI and every preceding change to rsp.
Leaf and non-leaf functions
A leaf function that calls nothing else may need no stack storage.
add_three:
lea eax, [rdi + rsi]
add eax, edx
ret
A non-leaf function must preserve live state and satisfy its ABI before making another call. Windows x64 callers reserve 32 bytes of shadow space. System V has no shadow-space rule, but user-space leaf functions may use a 128-byte red zone below rsp. Windows does not provide that System V red zone.
Read a buffer loop as an address contract
; rcx = int32_t* values, rdx = count
xor eax, eax
xor r8d, r8d
loop_begin:
cmp r8, rdx
jae loop_end
movsxd r9, dword ptr [rcx + r8*4]
add rax, r9
inc r8
jmp loop_begin
loop_end:
ret
[rcx+r8*4] is the candidate address of values[r8]. movsxd sign-extends each 32-bit element. The unsigned bound check resembles a size_t contract. If count is nonzero, the caller must provide a readable buffer of at least count * 4 bytes. Assembly cannot discover the allocation size from a pointer.
Debugging worksheet
Stop at function entry and record:
| Point | rsp | Meaning of [rsp] | Saved registers |
|---|---|---|---|
| Entry | baseline | return address | none yet |
| After prologue | baseline - N | local/save area | list them |
Before nested call | verify ABI alignment | outgoing area | preserve live values |
| After epilogue | entry value | return address | all restored |
Inspect 16–32 bytes around each effective address. A single displayed value can hide an endian, width, or field-offset error.
Common symptoms of stack corruption
retjumps to an unrelated address.- A caller's nonvolatile register changes after a normal return.
- Stack walking fails only in optimized code or during an exception.
- A called function faults on an aligned vector operation.
- A local buffer overflow becomes visible much later.
Possible causes include an incorrect offset, unmatched allocation and restoration, a missing save, invalid unwind metadata, or an out-of-bounds access.
Exercise
Compile loops over int32_t, int64_t, and a 12-byte structure. Find each index scale. Treat entry rsp as S and track every push, sub rsp, call, add rsp, pop, and ret. Toggle frame-pointer omission, then change field order and verify the new offsets with both offsetof and disassembly.
Summary
x86-64 forms an effective address with [base + index*scale + displacement], then reads or writes the selected width. Scale often exposes element size; displacement often exposes a field offset. call places a return address on the stack, while functions allocate local, spill, preservation, and outgoing areas only as required. Track real rsp transitions instead of memorizing one universal frame picture.