Wut is this about?
Today we’re going to talk about how the stack works in x86-64 assembly by analyzing a simple C program. We’ll examine the disassembled code to understand stack frames, function calls, and the roles of RSP and RBP registers. Yeah I know this shit is hard to understand, that’s why we’re going to tackle it deeply.
Sample Program
| |
Disassembled Code
main() Function
| |
hello() Function
| |
Data Section
| |
Key Assembly Instructions
Both functions share common patterns. Let’s break down the essential instructions:
Common Instruction Patterns
Function Prologue (Entry):
| |
Function Epilogue (Exit):
| |
Instruction Reference
push
| |
Decrements RSP by 8 bytes, then stores the value at the new stack location.
mov
| |
Common forms:
mov reg, reg- register to registermov reg, imm- immediate value to registermov reg, [mem]- memory to registermov [mem], reg- register to memory
sub
| |
Performs subtraction and updates CPU flags (zero, carry, overflow, etc.)
lea (Load Effective Address)
| |
Loads the memory address rather than the value at that address.
call
| |
Pushes the return address (next instruction) onto the stack, then jumps to the target.
leave
| |
Restores the previous stack frame.
ret/retn
| |
Pops the return address from the stack and jumps to it.
Memory Accesses
In main()
| |
Why [rbp-0x8]?
The notation [rbp-N] accesses stack memory relative to the base pointer:
- RBP points to the base of the current stack frame
- Subtracting from RBP accesses local variables within the frame
- Each local variable gets its own offset from RBP
In hello()
| |
Notice the pattern: RBP - N where N is the offset for each variable.
Understanding Stack Frames
What is a Stack Frame?
A stack frame is a region of memory within the program’s stack that stores information for a single function call. Each function call creates its own stack frame containing:
- Return address (where to return after function completes)
- Saved registers (particularly RBP)
- Function arguments (may be stored here)
- Local variables
Key Properties
- There is one stack per thread
- Each function call uses its own portion (stack frame)
- Frames are created on function entry and destroyed on function exit
Return Address Storage
When main() calls hello():
| |
The processor needs to know where to return after hello() completes:
Question: "I'm done with hello(), where do I return?"
Answer: "Return to address 0x0040118c (stored in stack frame)"
Local Variables on the Stack
When you declare a local variable:
| |
It’s stored in the stack frame:
| |
This writes the value 0x2a (42 in hexadecimal) to the stack location [rbp-0x4], which is part of hello()’s stack frame.
Stack Frames with Recursion
Consider a recursive function:
| |
Calling factorial(5) creates multiple stack frames:
| |
Each recursive call gets its own stack frame, even though it’s the same function.
The Role of RSP (Stack Pointer)
Purpose
The Stack Pointer (RSP) keeps track of the top of the stack - the boundary between used stack memory and available space.
Stack Growth Direction
In x86-64, the stack grows downward toward lower memory addresses.
To extend the stack, we subtract from RSP:
| |
Example: Stack Allocation
Before allocation:
RSP = 0x7fff1000
After sub rsp, 0x10:
RSP = 0x7fff0ff0 ; Lower address (stack grew down by 16 bytes)
Visualization
High memory (0x7fff1000) ← Old RSP
|
| Valid stack memory (allocated space)
| (This region is fragmented into different stack frames)
|
↓ Stack grows DOWN
Low memory (0x7fff0ff0) ← New RSP (after subtract)
Memory Availability
- Above RSP (higher addresses): Used stack memory
- Below RSP (lower addresses): Unused, available for future allocation
To use more memory, execute: sub rsp, N where N is the number of bytes needed.
Compiler’s Role
The compiler calculates how much stack space each function needs based on:
- Local variables
- Function call requirements
- Alignment requirements
This is why you see different values like sub rsp, 0x10 (16 bytes) in main() and sub rsp, 0x20 (32 bytes) in hello().
The Role of RBP (Base Pointer)
Purpose
The Base Pointer (RBP) provides a stable reference point for accessing the stack frame during function execution.
Why RBP is Necessary
The stack might grow or shrink during execution depending on different code paths. RSP constantly changes as we push/pop values, but RBP remains constant throughout the function’s execution.
Example: Accessing Stack Frame
Before calling hello():
| |
Step 1: push rbp
After push rbp in hello(), RSP points to the saved RBP value:
| |
Step 2: mov rbp, rsp
RBP now points to the same location as RSP:
| |
Step 3: sub rsp, 0x20
Allocate 32 bytes for hello()’s stack frame:
| |
RBP Stays Constant
Notice that RBP’s position remains fixed during the entire function execution. Even if we extend the stack further with more sub rsp instructions, RBP doesn’t move.
This stability makes RBP perfect for accessing stack variables:
| |
Why Not Use RSP for Access?
If we used RSP to access variables, the offsets would change every time we push/pop values or call other functions. RBP provides a stable frame of reference.
Function Prologue (Detailed)
The function prologue sets up the stack frame. Here’s what each instruction does:
Step 1: Save Old Base Pointer
| |
Purpose: Preserve the caller’s RBP so it can be restored later. This is crucial because RBP points to the caller’s stack frame, and we need to restore it when we return.
Step 2: Set New Base Pointer
| |
Purpose: Establish the base of the new stack frame. After this, RBP becomes our stable reference point for the current function.
Step 3: Allocate Stack Space
| |
Purpose: Create space for local variables, temporary storage, and alignment requirements.
Step 4: Use the Stack Frame
| |
Purpose: Now we can safely store and retrieve data using RBP-relative addressing.
Function Epilogue (Detailed)
The function epilogue cleans up the stack frame and returns to the caller.
Step 1: Restore Stack Frame
| |
This single instruction performs two operations:
First: mov rsp, rbp
- Restores RSP to point at the saved RBP value
- Deallocates all local variables in one instruction
Second: pop rbp
- Restores the caller’s base pointer
- RSP now points to the return address
Stack Before leave:
| |
Stack After leave:
| |
Step 2: Return to Caller
| |
Operation:
- Pop the return address from the stack into RIP (instruction pointer)
- Jump to that address (resume execution in the caller)
Complete Epilogue Flow
| |
After ret, execution continues at the instruction immediately after the call in the caller function.
Ok remember
- Stack frames store function-specific data (return address, local variables, saved registers)
- RSP (Stack Pointer) tracks the top of the stack and changes frequently
- RBP (Base Pointer) provides a stable reference for accessing the current stack frame
- Stack grows downward in x86-64 (toward lower memory addresses)
- Function prologue sets up the stack frame
- Function epilogue cleans up and returns to the caller
Register Roles
| |
Common Patterns
Function Entry:
| |
Function Exit:
| |
Accessing Local Variables:
| |
Uhhh, why did we used different allocation sizes btw
main() allocates 16 bytes:
- 8 bytes for
namepointer - 8 bytes for alignment (stack must be 16-byte aligned)
Even though main() only needs 8 bytes for data, the compiler rounds up to preserve alignment guarantees.
hello() allocates 32 bytes:
- 8 bytes for
nameparameter storage - 4 bytes for
age(int) - 4 bytes padding
- 16 bytes for potential function calls (alignment)
The compiler over-allocates to ensure alignment and leave room for optimizations. That’s why