MediumDemystifying the Call Stack: A Visual x86–64 Walkthrough | by Devanshu Pathak | Medium
Sitemap

Demystifying the Call Stack: A Visual x86–64 Walkthrough

13 min readMar 22, 2026

--

Every time you call a function in C, a small but precisely orchestrated sequence of events fires beneath the surface. Memory is carved out, registers are shuffled, a return address is stashed, and control is handed off — all in the span of a handful of instructions. Most programmers never need to think about it. But understanding it is the difference between reading assembly like a foreign language and reading it like a map.

This write-up walks through a dead-simple C program — two integers, an addition, a return — and traces every single instruction from source code down to the stack. By the end, you’ll know exactly what rbp and rsp are doing at every moment, why the compiler allocates 16 bytes when you only asked for 12, and how call, leave, and ret chain together to make function calls work.

1. Registers

Registers are ultra-fast storage locations inside the CPU, not RAM, not cache, but on the chip itself. In x86–64, general-purpose registers are 64 bits wide. You can access the lower 32 bits using a shorter name: rax is the full 64-bit register, while eax refers to its lower half. Writing to eax zero-extends into rax — they share the same physical location.

  • rip — Instruction Pointer. Holds the address of the next instruction to execute.
  • rbp — Base Pointer. Anchors the current stack frame. Stays fixed for the entire duration of a function call. All local variables are accessed as offsets from rbp.
  • rsp — Stack Pointer. Always points to the top of the stack (the lowest used address). Moves on every push and pop.
  • rax/eax — Accumulator. Holds return values and is used for arithmetic. When a function returns an integer, it lands in rax.
  • rdi/edi — 1st function argument.
  • rsi/esi — 2nd function argument.
  • rdx/edx — 3rd function argument; also used in multiply/divide.

2. The Stack and Stack Frames

The stack is a region of memory used for function calls. It operates on a Last In, First Out (LIFO) principle — the last item pushed is the first popped.

The most important thing to internalize: the stack grows downward, toward lower memory addresses. When you push something, rsp decreases. When you pop, rsp increases.

Each function call creates a stack frame — a dedicated sectionof the stack that lives for the lifetime of that call and contains:

  1. The saved rbp of the caller (so it can be restored on return)
  2. Local variables — x, y, sum
  3. The return address — pushed by call, telling the CPU where to come back after the function returns
High addresses (top of diagram)
┌──────────────────────────┐
│ ...caller's frame... │
├──────────────────────────┤ ← rbp (base of current frame)
│ saved old rbp │
│ local var 1 │
│ local var 2 │
│ padding (alignment) │
└──────────────────────────┘ ← rsp (top of stack, lowest address)
Low addresses (bottom of diagram)

3. Stack Alignment

The System V AMD64 ABI — the calling convention used on Linux — requires the stack to be 16-byte aligned at the moment of any call instruction. rsp must be a multiple of 16 just before calling a function.

This matters because certain CPU instructions, especially SSE and AVX floating-point operations, require their memory operands to sit at 16-byte-aligned addresses. A misaligned stack causes a segmentation fault.

Practical consequence: Even if your local variables only require 12 bytes, the compiler will always allocate 16 — rounding up to the next multiple. The extra 4 bytes are unused padding. You’ll see this in action in Step 3.

4. Memory Access Size Specifiers

When reading from or writing to memory, x86 needs to know how many bytes to touch. The PTR Keywords specify this size:

Keyword Bytes Bits Typical C type


| Keyword | Bytes | Bits | Typical C type |
|-------------|-------|------|----------------|
| `BYTE PTR` | 1 | 8 | `char` |
| `WORD PTR` | 2 | 16 | `short` |
| `DWORD PTR` | 4 | 32 | `int` |
| `QWORD PTR` | 8 | 64 | `long`, pointer|

Example: mov DWORD PTR[rbp-0x4], 5 writes the 32-bit integer 5 into the 4 bytes of memory starting at the address rbp - 4.

5. Key Instructions: call, leave and ret

call

call It's not a single operation — it's two. First, it pushes the return address (the address of the instruction immediately after the call) onto the stack. Then it jumps to the target function. This is how the callee knows where to come back.

call 0x401136 <add>
; Equivalent to:
push rip ; push the address of NEXT instruction (the one immediately after the call)
jmp 0x401136 ; jumps to the add function.

leave

leave tears down the current stack frame. It resets rsp to rbp — discarding all locals with no zeroing, just pointer movement — then pops the caller's rbp back from the stack.

leave
; Equivalent to:
mov rsp, rbp ; restore rsp to base of frame → discards locals
pop rbp ; restore caller's rbp from stack

ret

ret returns control to the caller by popping the return address into rip. The CPU then jumps to that address — right back to where the caller left off.

ret; Equivalent to:
pop rip ; pop return address into instruction pointer → jump there

6. The Code

C Source

#include <stdio.h>

int add(int x, int y) {
int sum = x + y;
return sum;
}

int main() {
int x = 5;
int y = 7;
int sum = add(x, y);
printf("%d", sum);
return 0;
}

7. Disassembly

main():

main:
0x401150 push rbp ; save caller's base pointer onto stack
0x401151 mov rbp, rsp ; rbp = rsp → base of main's frame
0x401154 sub rsp, 0x10 ; allocate 16 bytes for locals (x, y, sum + 4 pad)
0x401158 mov DWORD PTR[rbp-0x4], 0x5 ; x = 5 (at rbp-0x4)
0x40115f mov DWORD PTR[rbp-0x8], 0x7 ; y = 7 (at rbp-0x8)
0x401166 mov eax, DWORD PTR[rbp-0x4] ; eax = x = 5
0x401169 mov edi, eax ; edi = 5 (1st argument)
; some compilers: mov edi, DWORD PTR[rbp-0x4]
0x40116b mov eax, DWORD PTR[rbp-0x8] ; eax = y = 7
0x40116e mov esi, eax ; esi = 7 (2nd argument)
0x401170 call 0x401136 <add> ; push 0x401175 (return addr), jmp to add
0x401175 mov DWORD PTR[rbp-0xc], eax ; sum = eax (return value = 12)
0x401178 mov eax, DWORD PTR[rbp-0xc] ; load sum into eax (for printf arg)
... printf call ...
0x40118a mov rsp, rbp ; } leave
0x40118c pop rbp ; }
0x40118d ret ; return 0 → pops rip, jumps to _start

add():

add:
0x401136 push rbp ; save main's rbp
0x401137 mov rbp, rsp ; base of add's frame
0x40113a sub rsp, 0x10 ; allocate 16 bytes (x, y locals + padding)
0x40113e mov DWORD PTR[rbp-0x4], edi ; spill arg1 (x = 5) to stack
0x401141 mov DWORD PTR[rbp-0x8], esi ; spill arg2 (y = 7) to stack
0x401144 mov edx, DWORD PTR[rbp-0x4] ; edx = x = 5
0x401147 mov eax, DWORD PTR[rbp-0x8] ; eax = y = 7
0x40114a add eax, edx ; eax = 5 + 7 = 12 ← return value in eax
0x40114c leave ; mov rsp, rbp + pop rbp (restores main's rbp)
0x40114d ret ; pop return addr into rip → back to 0x401175

8. Stack Frame Step-by-Step

Stack grows downward — higher addresses at the top of every diagram.

rbp = base of the current frame (fixed anchor).

rsp = top of stack (moves with every push/pop). Addresses are illustrative sample values.

Step 1 — push rbp [0x401150]

Save the caller’s rbp onto the stack. rsp decreases by 8.

Stack (high → low):                         Registers:
┌─────────────────────────────────┐
│ 0x7ffd0010 [ ...caller... ] │ rip : 0x401150 ← push rbp
│ 0x7ffd0008 [ old rbp ] │ ← rsp rsp : 0x7ffd0008
└─────────────────────────────────┘ rbp : 0x7ffd0010 (unchanged)
rax : —
edi : —
esi : —

Step 2 — mov rbp, rsp [0x401151]

Get Devanshu Pathak’s stories in your inbox

Join Medium for free to get updates from this writer.

Set rbp = rsp. Both now point to the same slot — this is the base of main's frame. No stack change; register operation only.

Stack (high → low):                         Registers:
┌─────────────────────────────────┐
│ 0x7ffd0010 [ ...caller... ] │ rip : 0x401151 ← mov rbp, rsp
│ 0x7ffd0008 [ old rbp ] │ ← rbp rsp : 0x7ffd0008
│ │ ← rsp rbp : 0x7ffd0008 (updated)
└─────────────────────────────────┘ rax : —
edi : —
esi : —

Step 3 — sub rsp, 0x10 [0x401154]

Allocate 16 bytes for locals. x + y + sum = 12 bytes, but the ABI requires 16-byte alignment, so the compiler rounds up. rsp moves down by 16.

Why 16 and not 12? x(4) + y(4) + sum(4) = 12 bytes needed. The ABI requires 16-byte stack alignment → compiler pads to 16. The extra 4 bytes are unused.

Stack (high → low):                         Registers:
┌─────────────────────────────────┐
│ 0x7ffd0010 [ ...caller... ] │ rip : 0x401154 ← sub rsp, 0x10
│ 0x7ffd0008 [ old rbp ] │ ← rbp rsp : 0x7ffcfff8 (updated)
│ 0x7ffd0004 [ padding ] │ rbp : 0x7ffd0008
│ 0x7ffd0000 [ sum rbp-0xc ] │ rax : —
│ 0x7ffcfffc [ y rbp-0x8 ] │ edi : —
│ 0x7ffcfff8 [ x rbp-0x4 ] │ ← rsp esi : —
└─────────────────────────────────┘

Step 4 — mov DWORD PTR[rbp-0x4], 5 [0x401158]

Write x = 5 into [rbp-0x4]. DWORD PTR = access 4 bytes. Stack changes; registers unchanged.

Stack (high → low):                         Registers:
┌─────────────────────────────────┐
│ 0x7ffd0008 [ old rbp ] │ ← rbp rip : 0x401158 ← store x
│ 0x7ffd0004 [ padding ] │ rsp : 0x7ffcfff8
│ 0x7ffd0000 [ sum rbp-0xc ] │ rbp : 0x7ffd0008
│ 0x7ffcfffc [ y rbp-0x8 ] │ rax : —
│ 0x7ffcfff8 [ x=5 rbp-0x4 ] │ ← rsp edi : —
└─────────────────────────────────┘ esi : —

Step 5 — mov DWORD PTR[rbp-0x8], 7 [0x40115f]

Write y = 7 into [rbp-0x8]. Both locals are now stored. Stack changes; registers unchanged.

Stack (high → low):                         Registers:
┌─────────────────────────────────┐
│ 0x7ffd0008 [ old rbp ] │ ← rbp rip : 0x40115f ← store y
│ 0x7ffd0004 [ padding ] │ rsp : 0x7ffcfff8
│ 0x7ffd0000 [ sum rbp-0xc ] │ rbp : 0x7ffd0008
│ 0x7ffcfffc [ y=7 rbp-0x8 ] │ rax : —
│ 0x7ffcfff8 [ x=5 rbp-0x4 ] │ ← rsp edi : —
└─────────────────────────────────┘ esi : —

Step 6 — mov eax, DWORD PTR[rbp-0x4] [0x401166]

Load x from stack into eax — staging step before moving to the argument register. No stack change.

Stack (high → low):                         Registers:
┌─────────────────────────────────┐
│ 0x7ffd0008 [ old rbp ] │ ← rbp rip : 0x401166 ← load x → eax
│ 0x7ffd0004 [ padding ] │ rsp : 0x7ffcfff8
│ 0x7ffd0000 [ sum rbp-0xc ] │ rbp : 0x7ffd0008
│ 0x7ffcfffc [ y=7 rbp-0x8 ] │ rax : 5 (updated)
│ 0x7ffcfff8 [ x=5 rbp-0x4 ] │ ← rsp edi : —
└─────────────────────────────────┘ esi : —

Step 7 — mov edi, eax [0x401169]

Copy eax → edi. edi = 1st argument register. x = 5 is now ready to pass to add. No stack change.

Stack (high → low):                         Registers:
┌─────────────────────────────────┐
│ 0x7ffd0008 [ old rbp ] │ ← rbp rip : 0x401169 ← x → arg1
│ 0x7ffd0004 [ padding ] │ rsp : 0x7ffcfff8
│ 0x7ffd0000 [ sum rbp-0xc ] │ rbp : 0x7ffd0008
│ 0x7ffcfffc [ y=7 rbp-0x8 ] │ rax : 5
│ 0x7ffcfff8 [ x=5 rbp-0x4 ] │ ← rsp edi : 5 (updated)
└─────────────────────────────────┘ esi : —

Step 8 — mov eax, DWORD PTR[rbp-0x8] [0x40116b]

Load y from stack into eax. No stack change.

Stack (high → low):                         Registers:
┌─────────────────────────────────┐
│ 0x7ffd0008 [ old rbp ] │ ← rbp rip : 0x40116b ← load y → eax
│ 0x7ffd0004 [ padding ] │ rsp : 0x7ffcfff8
│ 0x7ffd0000 [ sum rbp-0xc ] │ rbp : 0x7ffd0008
│ 0x7ffcfffc [ y=7 rbp-0x8 ] │ rax : 7 (updated)
│ 0x7ffcfff8 [ x=5 rbp-0x4 ] │ ← rsp edi : 5
└─────────────────────────────────┘ esi : —

Step 9 — mov esi, eax [0x40116e]

Copy eax → esi. esi = 2nd argument register. Both args loaded — ready to call. No stack change.

Stack (high → low):                         Registers:
┌─────────────────────────────────┐
│ 0x7ffd0008 [ old rbp ] │ ← rbp rip : 0x40116e ← y → arg2
│ 0x7ffd0004 [ padding ] │ rsp : 0x7ffcfff8
│ 0x7ffd0000 [ sum rbp-0xc ] │ rbp : 0x7ffd0008
│ 0x7ffcfffc [ y=7 rbp-0x8 ] │ rax : 7
│ 0x7ffcfff8 [ x=5 rbp-0x4 ] │ ← rsp edi : 5 ← arg1 = x
└─────────────────────────────────┘ esi : 7 ← arg2 = y

Step 10 — call 0x401136 <add> [0x401170]

call = push rip (pushes 0x401175, address of the next instruction) + jmp 0x401136. The return address is now on the stack so add knows where to come back.

0x401175 = address of mov [rbp-0xc], eax in main — the instruction that captures add's return value.

Stack (high → low):                         Registers:
┌─────────────────────────────────┐
│ 0x7ffd0008 [ old rbp ] │ ← rbp rip : 0x401136 ← jumped to add
│ 0x7ffd0004 [ padding ] │ rsp : 0x7ffcfff0 (updated)
│ 0x7ffd0000 [ sum rbp-0xc ] │ rbp : 0x7ffd0008
│ 0x7ffcfffc [ y=7 rbp-0x8 ] │ rax : 7
│ 0x7ffcfff8 [ x=5 rbp-0x4 ] │ edi : 5
│ 0x7ffcfff0 [ ret: 0x401175 ] │ ← rsp esi : 7
└─────────────────────────────────┘

Step 11 — push rbp [0x401136]

add() begins: save main's rbp before overwriting it with add's frame base.

Stack (high → low):                         Registers:
┌─────────────────────────────────┐
│ 0x7ffd0008 [ old rbp ] │ ← main rbp rip : 0x401136 ← add starts
│ .... [ ... ] │ rsp : 0x7ffcffe8 (updated)
│ 0x7ffcfff0 [ ret: 0x401175 ] │ rbp : 0x7ffd0008 (still main's)
│ 0x7ffcffe8 [ main's rbp ] │ ← rsp rax : 7
└─────────────────────────────────┘ edi : 5
esi : 7

Step 12 — mov rbp, rsp [0x401137]

rbp now points into add's frame — completely separate from main's rbp. No stack change.

Stack (high → low):                         Registers:
┌─────────────────────────────────┐
│ 0x7ffd0008 [ old rbp ] │ ← main rbp rip : 0x401137 ← add frame base
│ .... [ ... ] │ rsp : 0x7ffcffe8
│ 0x7ffcfff0 [ ret: 0x401175 ] │ rbp : 0x7ffcffe8 (updated — add's rbp)
│ 0x7ffcffe8 [ main's rbp ] │ ← rbp, rsp rax : 7
└─────────────────────────────────┘ edi : 5
esi : 7

Step 13 — sub rsp, 0x10 [0x40113a]

Allocate 16 bytes for add's locals (spilled x, y + padding). Same 16-byte alignment rule as main.

Stack (high → low):                         Registers:
┌─────────────────────────────────┐
│ 0x7ffd0008 [ old rbp ] │ ← main rbp rip : 0x40113a ← add alloc
│ .... [ ... ] │ rsp : 0x7ffcffd8 (updated)
│ 0x7ffcfff0 [ ret: 0x401175 ] │ rbp : 0x7ffcffe8
│ 0x7ffcffe8 [ main's rbp ] │ ← rbp rax : 7
│ 0x7ffcffe4 [ x rbp-0x4 ] │ edi : 5
│ 0x7ffcffe0 [ y rbp-0x8 ] │ esi : 7
│ 0x7ffcffd8 [ padding ] │ ← rsp
└─────────────────────────────────┘

Step 14 — mov [rbp-0x4], edi + mov [rbp-0x8], esi [0x40113e / 0x401141]

Spill both argument registers onto add's stack frame. Stack changes; registers unchanged.

Stack (high → low):                         Registers:
┌─────────────────────────────────┐
│ 0x7ffd0008 [ old rbp ] │ ← main rbp rip : 0x401141 ← spill args
│ .... [ ... ] │ rsp : 0x7ffcffd8
│ 0x7ffcfff0 [ ret: 0x401175 ] │ rbp : 0x7ffcffe8
│ 0x7ffcffe8 [ main's rbp ] │ ← rbp rax : 7
│ 0x7ffcffe4 [ x=5 rbp-0x4 ] │ edi : 5
│ 0x7ffcffe0 [ y=7 rbp-0x8 ] │ esi : 7
│ 0x7ffcffd8 [ padding ] │ ← rsp
└─────────────────────────────────┘

Step 15 — mov edx,[rbp-0x4] + mov eax,[rbp-0x8] + add eax,edx [0x401144–0x40114a]

Load x → edx, y → eax, then eax = eax + edx = 7 + 5 = 12. The return value lives in eax. No stack change.

Stack (high → low):                         Registers:
┌─────────────────────────────────┐
│ 0x7ffd0008 [ old rbp ] │ ← main rbp rip : 0x40114a ← result ready
│ .... [ ... ] │ rsp : 0x7ffcffd8
│ 0x7ffcfff0 [ ret: 0x401175 ] │ rbp : 0x7ffcffe8
│ 0x7ffcffe8 [ main's rbp ] │ ← rbp rax : 12 (result!)
│ 0x7ffcffe4 [ x=5 rbp-0x4 ] │ edi : 5
│ 0x7ffcffe0 [ y=7 rbp-0x8 ] │ esi : 7
│ 0x7ffcffd8 [ padding ] │ ← rsp edx : 5
└─────────────────────────────────┘

Step 16 — leave [0x40114c]

leave = mov rsp, rbp + pop rbp. rsp snaps back to add's frame base, discarding all locals — no zeroing, just pointer movement. Then pop rbp restores main's rbp.

add's locals (x=5, y=7) are now below rsp — stale but not zeroed. They will be silently overwritten by the next function call.

Stack (high → low):                         Registers:
┌─────────────────────────────────┐
│ 0x7ffd0008 [ old rbp ] │ ← rbp rip : 0x40114c ← leave
│ 0x7ffd0004 [ padding ] │ rsp : 0x7ffcfff0 (updated)
│ 0x7ffd0000 [ sum rbp-0xc ] │ rbp : 0x7ffd0008 (restored to main's!)
│ 0x7ffcfffc [ y=7 rbp-0x8 ] │ rax : 12
│ 0x7ffcfff8 [ x=5 rbp-0x4 ] │ edi : 5
│ 0x7ffcfff0 [ ret: 0x401175 ] │ ← rsp esi : 7
└─────────────────────────────────┘

Step 17 — ret [0x40114d]

ret = pop rip. Pops 0x401175 into rip — CPU jumps back into main. Stack is exactly as main left it before the call. eax = 12 carries the result.

Stack (high → low):                         Registers:
┌─────────────────────────────────┐
│ 0x7ffd0008 [ old rbp ] │ ← rbp rip : 0x401175 ← back in main!
│ 0x7ffd0004 [ padding ] │ rsp : 0x7ffcfff8 (updated)
│ 0x7ffd0000 [ sum rbp-0xc ] │ rbp : 0x7ffd0008
│ 0x7ffcfffc [ y=7 rbp-0x8 ] │ rax : 12 ← return value
│ 0x7ffcfff8 [ x=5 rbp-0x4 ] │ ← rsp edi : 5
└─────────────────────────────────┘ esi : 7

Step 18 — mov DWORD PTR[rbp-0xc], eax [0x401175]

Store the return value (eax = 12) into sum at [rbp-0xc]. All three locals are now written. main then calls printf, runs leave + ret — the same teardown pattern — and the program exits.

Stack (high → low):                         Registers:
┌─────────────────────────────────┐
│ 0x7ffd0008 [ old rbp ] │ ← rbp rip : 0x401175 ← store sum
│ 0x7ffd0004 [ padding ] │ rsp : 0x7ffcfff8
│ 0x7ffd0000 [ sum=12 rbp-0xc ] │ rbp : 0x7ffd0008
│ 0x7ffcfffc [ y=7 rbp-0x8 ] │ rax : 12
│ 0x7ffcfff8 [ x=5 rbp-0x4 ] │ ← rsp edi : 5
└─────────────────────────────────┘ esi : 7

Summary

That’s the complete picture — from a C function call to every push, pop, and register shuffle that makes it happen. The stack isn’t magic. It’s just memory, a pointer, and a set of conventions every function agrees to follow. Once you can read these diagrams, assembly stops being a wall of opcodes and starts being a story you can follow line by line.

--

--