Skip to content
Courses

Anatomy of a Stack Frame

Stack SmashingLesson 3 of 745 minutesLessonNo lab next

When you call a function in C, you expect it to do its work and return to the line you were on. It’s an easy thing to take for granted.

The CPU has moved to another part of the program, though, and that function may have used registers and created local variables along the way. Something has to remember where to go back and how to pick up the work of the function that made the call.

In The Computer as a State Machine, we followed a load that copied 7 from memory into rax. We can describe a function call using the same memory and registers, but now there’s more to keep track of.

The caller needs its information back when the new function finishes, and the CPU needs to know where to continue.

Let’s follow a small program and see where it keeps those things.

You’ll need to be comfortable reading a small C function and compiling it in the Linux course environment. The assembly instructions can wait until we reach them; you don’t need to learn a whole instruction set before following one call.

In the Linux course environment, create a working directory and enter it:

Terminal window
mkdir -p ~/underdunn-course/stack-frame ~/underdunn-course/notes
cd ~/underdunn-course/stack-frame

Save this complete program as frame.c there. Keep the terminal in this directory when compiling and running it:

long add(long a, long b)
{
long sum = a + b;
return sum;
}
int main(void)
{
long total = add(4, 7);
return (int)total;
}

main calls add with 4 and 7. add stores their sum in a local variable and returns it. Back in main, the result becomes total, which we return as the program’s exit status.

We’re using long so the arguments and local values each occupy eight bytes in this Linux x86-64 example. The cast to int matches the return type of main.

Compile it in the terminal:

Terminal window
gcc -g -O0 -fno-omit-frame-pointer -mno-red-zone frame.c -o frame

These options make the compiled program easier to follow:

  • -g includes debugger information.
  • -O0 avoids optimizing our small calculation away.
  • -fno-omit-frame-pointer asks GCC to keep the frame pointer.
  • -mno-red-zone makes the function reserve its stack space explicitly instead of using temporary space below the stack pointer, giving us a straightforward frame to draw. GCC documents that stack option here.

The walkthrough was checked with GCC 13.3.0 on Ubuntu 24.04 x86-64 under WSL. Another compiler or different options can arrange the frame differently, so keep those options when following along.

The addresses in the diagrams are examples, not numbers you should expect your own process to use.

Before we reach our first line of C, some other code has already done quite a bit of work.

The operating system creates the process and arranges its memory, then runtime startup code prepares the program and calls main. So main is a useful place for us to start reading, but it isn’t where the CPU started executing.

While add is running, main still needs somewhere to keep the information it will use afterward. We can’t simply throw the caller’s working space away every time it calls another function.

The stack is the part of the process’s memory used for local values and saved information, which we touched on in the previous lesson’s discussion of program data.

Each call uses a portion of memory called a stack frame. main’s saved information stays in its frame while add gets working space next to it.

Two registers help us find our way around that space:

  • rsp, the stack pointer, marks the current low-address end of the stack.
  • rbp, the frame pointer, provides a reference address for the current function’s frame.

Despite the different names, both hold ordinary addresses, just as rbx did when we used it to find the value 7. We can follow either one into the same memory space; the difference is which position each register keeps track of.

By the time main is ready to call add, it has:

  • Saved the startup code’s frame pointer and set up its own.
  • Reserved 16 bytes for its work: one eight-byte slot for total and another that’s unused in this build.

Reserving space means setting those bytes aside for this call; it doesn’t put a value into them.

We’ll follow the setup operations closely when add performs them.

Before the call, the compiler’s instructions put the arguments where add will look for them:

  • 4 goes into rdi.
  • 7 goes into rsi.

Both functions need to agree on those locations: if main put 4 in one register and add looked in another, it would use the wrong value.

A calling convention is the set of rules that makes this handoff work. The compiler generates instructions that follow those rules, so separately compiled functions can work together.

Let’s stop just before the call. The arguments are ready, but rip still points to the call instruction in main, so we can see what the caller has set up before add changes anything.

Let’s put main’s frame pointer at 0x7fff0100 for our diagrams.

The 0x prefix marks hexadecimal notation, which uses the digits 0 through 9 and a through f. Eight bytes below that address is 0x7fff00f8, then 0x7fff00f0.

Here’s how those addresses fit into the picture:

  • Every box represents eight bytes.
  • Higher addresses are above lower ones.
  • Each address and pointer arrow sits at the bottom edge of its box, where that box’s lowest byte is.
Current state: main is ready to call add

Stack memory

Part of the process's flat memory space. Every box is eight bytes. Addresses mark the bottom edge.

mainadd

Higher addresses ↓ stack grows

  1. main’s saved frame pointercontents omitted
    rbp
  2. main’s totalnot assigned yet
  3. Unused spacecontents omitted
    rsp
  4. Not in use yetcontents omitted
  5. Not in use yetcontents omitted
  6. Not in use yetcontents omitted
  7. Not in use yetcontents omitted
  8. Not in use yetcontents omitted
  9. Not in use yetcontents omitted

Lower addresses

Registers

Stack addresses are hexadecimal; argument and result values are decimal.

rip
main: call add
rsp
0x7fff00f0
rbp
0x7fff0100
rdi
4
rsi
7
rax
not shown

rsp → 0x7fff00f0 in stack memory

rbp → main's reference point 0x7fff0100, at the top of the diagram

Schematic snapshots of the verified build, not raw debugger output. Stack addresses are illustrative, with main's frame pointer at 0x7fff0100. Dashed slots are outside the active stack; old bytes can remain. “Not shown” is omitted state, not zero. Instruction labels stand for addresses in program memory.

The box styles tell us how that space is being used:

  • Blue boxes are main’s frame.
  • Teal boxes will appear as the call to add uses more of the stack.
  • Dashed boxes are outside the currently active stack.
  • “Contents omitted” means we’re leaving those values out of the picture.

Find rsp = 0x7fff00f0 and follow its arrow to the low end of main’s space. rbp = 0x7fff0100 marks the frame’s reference point.

We have a slot ready for total at 0x7fff00f8, but there’s no result to put in it yet. We haven’t called add.

Our stack grows toward lower addresses, downward in these diagrams.

To put an eight-byte value on it, the CPU first subtracts eight from rsp, then writes the value at that new address. This operation is called a push.

The existing values stay where they were; moving the pointer doesn’t slide everything else through memory.

The compiler turns add(4, 7) into several machine instructions: prepare the arguments, call add, then store its result.

In the load example, rip advanced to the next instruction in order. A call needs to send it somewhere else and save enough information to come back.

The call instruction pushes the address of the instruction immediately after itself, then changes rip so execution continues in add.

That saved address is the return address. It tells us where to continue in main once add has finished.

Let’s follow the call from rsp = 0x7fff00f0 and rbp = 0x7fff0100:

call saves a return address and moves execution into add

Each box holds eight bytes. Blue underlines mark changed values; outlined pointer labels show which pointers moved.

Starting state

Before call

mainadd

Higher addresses ↓ stack grows

  1. main’s saved frame pointercontents omitted
    rbp
  2. main’s totalnot assigned yet
  3. Unused spacecontents omitted
    rsp
  4. Not in use yetcontents omitted
  5. Not in use yetcontents omitted
  6. Not in use yetcontents omitted
  7. Not in use yetcontents omitted
  8. Not in use yetcontents omitted
  9. Not in use yetcontents omitted

Lower addresses

rip
main: call
rsp
0x7fff00f0
rbp
0x7fff0100
rax
Not shown

Arguments are ready: rdi = 4, rsi = 7.

Execute call add →

After call

mainadd

Higher addresses ↓ stack grows

  1. main’s saved frame pointercontents omitted
    rbp
  2. main’s totalnot assigned yet
  3. Unused spacecontents omitted
  4. Return addressmain: after call
    rsp
  5. Not in use yetcontents omitted
  6. Not in use yetcontents omitted
  7. Not in use yetcontents omitted
  8. Not in use yetcontents omitted
  9. Not in use yetcontents omitted

Lower addresses

rip
add: entry
rsp
0x7fff00e8
rbp
0x7fff0100
rax
Not shown

rsp and rip changed; the return address was written into memory.

Each address labels the lowest byte of its box, at the bottom edge. Pointer arrows meet that address. Dashed boxes are outside the active stack; their old bytes can remain. The return-address label stands for an instruction address.

After the call:

  • rsp has moved to 0x7fff00e8, and memory there holds the address of the instruction after the call.
  • rip points into add.
  • rbp still holds 0x7fff0100, main’s frame-pointer address.

The call has saved a return address, but it hasn’t set up the new function’s frame pointer.

add needs somewhere to keep its working values. Its frame pointer gives the compiled instructions a reference point for finding them: a value might be eight bytes below rbp, or 24 bytes below it.

But rbp still holds main’s reference point. Our calling convention requires add to restore that value if it changes it, so we need to save the number before replacing it.

We can write machine instructions using short names instead of instruction bytes. That readable form is assembly. We’ll use three instructions for the setup:

  • push saves a value on the stack.
  • mov copies a value.
  • sub subtracts a number.

For mov and sub in the notation below, the first register named is what gets changed: mov rbp, rsp copies the number in rsp into rbp.

The compiled setup contains these three instructions:

push rbp
mov rbp, rsp
sub rsp, 32

Follow the three instructions in order:

  1. Save main’s frame pointer: push rbp. Move rsp from 0x7fff00e8 to 0x7fff00e0 and write the old value of rbp, 0x7fff0100, into that slot. We’ll need that saved number when we return.
  2. Set add’s frame pointer: mov rbp, rsp. Copy the current rsp into rbp. Both now hold 0x7fff00e0.
  3. Reserve four slots: sub rsp, 32. Subtract 32 from rsp, moving it to 0x7fff00c0. That reserves four eight-byte slots below the saved frame pointer. rbp stays at 0x7fff00e0, and none of those four slots is written to yet.

As with the load in the previous lesson, rip also advances after each instruction so the CPU can execute the next one.

From main calling add to a working stack frame

Each box holds eight bytes. Blue underlines mark changed values; outlined pointer labels show which pointers moved.

Starting state in main

Before call

mainadd

Higher addresses ↓ stack grows

  1. main’s saved frame pointercontents omitted
    rbp
  2. main’s totalnot assigned yet
  3. Unused spacecontents omitted
    rsp
  4. Not in use yetcontents omitted
  5. Not in use yetcontents omitted
  6. Not in use yetcontents omitted
  7. Not in use yetcontents omitted
  8. Not in use yetcontents omitted
  9. Not in use yetcontents omitted

Lower addresses

rip
main: call
rsp
0x7fff00f0
rbp
0x7fff0100
rax
Not shown

main is ready to call add. The address for returning to main has not been pushed yet.

After call add

Save where main will continue

mainadd

Higher addresses ↓ stack grows

  1. main’s saved frame pointercontents omitted
    rbp
  2. main’s totalnot assigned yet
  3. Unused spacecontents omitted
  4. Return addressmain: after call
    rsp
  5. Not in use yetcontents omitted
  6. Not in use yetcontents omitted
  7. Not in use yetcontents omitted
  8. Not in use yetcontents omitted
  9. Not in use yetcontents omitted

Lower addresses

rip
add: entry
rsp
0x7fff00e8
rbp
0x7fff0100
rax
Not shown

call pushes the address of the next instruction in main into the slot at 0x7fff00e8. rsp points there; rbp still belongs to main.

After push rbp

Save the caller’s pointer

mainadd

Higher addresses ↓ stack grows

  1. main’s saved frame pointercontents omitted
    rbp
  2. main’s totalnot assigned yet
  3. Unused spacecontents omitted
  4. Return addressmain: after call
  5. Saved frame pointer0x7fff0100
    rsp
  6. Not in use yetcontents omitted
  7. Not in use yetcontents omitted
  8. Not in use yetcontents omitted
  9. Not in use yetcontents omitted

Lower addresses

rip
add: setup
rsp
0x7fff00e0
rbp
0x7fff0100
rax
Not shown

rsp moved down eight bytes. Memory at 0x7fff00e0 now holds 0x7fff0100.

After mov rbp, rsp

Set the frame pointer

mainadd

Higher addresses ↓ stack grows

  1. main’s saved frame pointercontents omitted
  2. main’s totalnot assigned yet
  3. Unused spacecontents omitted
  4. Return addressmain: after call
  5. Saved frame pointer0x7fff0100
    rbprsp
  6. Not in use yetcontents omitted
  7. Not in use yetcontents omitted
  8. Not in use yetcontents omitted
  9. Not in use yetcontents omitted

Lower addresses

rip
add: setup
rsp
0x7fff00e0
rbp
0x7fff00e0
rax
Not shown

rbp now points to 0x7fff00e0. No memory contents changed.

After sub rsp, 32

Reserve working space

mainadd

Higher addresses ↓ stack grows

  1. main’s saved frame pointercontents omitted
  2. main’s totalnot assigned yet
  3. Unused spacecontents omitted
  4. Return addressmain: after call
  5. Saved frame pointer0x7fff0100
    rbp
  6. sumcontents omitted
  7. Unused spacecontents omitted
  8. Reserved spacecontents omitted
  9. Reserved spacecontents omitted
    rsp

Lower addresses

rip
add: argument copies next
rsp
0x7fff00c0
rbp
0x7fff00e0
rax
Not shown

rsp moved to 0x7fff00c0, reserving four eight-byte slots. rbp and memory contents stayed the same.

Each address labels the lowest byte of its box, at the bottom edge. Pointer arrows meet that address. Dashed boxes are outside the active stack; their old bytes can remain. The return-address label stands for an instruction address.

Now we can see why we kept two pointers. When sub reserves the working space, rsp moves but rbp stays put, so the function still has a fixed point from which to find its local values.

The mov and sub change these register values without writing anything into the reserved slots. We’ve made room for the calculation, but haven’t done it yet.

The next instructions copy the arguments into the frame:

  • 4 goes from rdi into the slot at 0x7fff00c8, for a.
  • 7 goes from rsi into the slot at 0x7fff00c0, for b.

Now add has its own copies of a and b, with the original values still in the source registers.

We’ve also kept rax in view because that’s where the result will go once there’s a result to return:

Frame established: rbp is a reference point; rsp marks the low end

Stack memory

Part of the process's flat memory space. Every box is eight bytes. Addresses mark the bottom edge.

mainadd

Higher addresses ↓ stack grows

  1. main’s saved frame pointercontents omitted
  2. main’s totalnot assigned yet
  3. Unused spacecontents omitted
  4. Return addressmain: after call
  5. Saved frame pointer0x7fff0100
    rbp
  6. sumcontents omitted
  7. Unused spacecontents omitted
  8. Copy of a4
  9. Copy of b7
    rsp

Lower addresses

Registers

Stack addresses are hexadecimal; argument and result values are decimal.

rip
add: calculation
rsp
0x7fff00c0
rbp
0x7fff00e0
rdi
4
rsi
7
rax
not shown

rsp → 0x7fff00c0 in stack memory

rbp → 0x7fff00e0, the saved caller frame pointer

Schematic snapshots of the verified build, not raw debugger output. Stack addresses are illustrative, with main's frame pointer at 0x7fff0100. Dashed slots are outside the active stack; old bytes can remain. “Not shown” is omitted state, not zero. Instruction labels stand for addresses in program memory.

Below the saved return address and frame pointer is space for sum, followed farther down by the copies of a and b. The row between the argument copies and sum is unused in this build. Reserving space doesn’t mean every byte in it corresponds to a C variable.

There’s a slightly confusing pair of numbers here:

  • rbp contains 0x7fff00e0. That’s where add’s frame reference point is now.
  • Memory at that address contains 0x7fff0100. That’s the old number we saved for main, waiting until we need it again.

This is the address-versus-contents distinction from the last lesson, except that the contents happen to be another address. Following the pointer doesn’t make the two numbers the same.

The compiler can refer to local values relative to rbp. For example, sum is eight bytes below add’s frame pointer: start at 0x7fff00e0, subtract eight, and you get its address, 0x7fff00d8.

The Linux x86-64 calling-convention reference describes the calling convention and frame-pointer convention. A frame pointer is useful for following the layout, but compilers can also generate functions that manage without one.

Now the function can execute long sum = a + b; and return the result. Our calling convention puts that result in rax, where main knows to look for it.

The instructions in our build do the following:

  1. Read the saved values of a and b from memory into registers.
  2. Add 4 and 7 to produce 11.
  3. Write 11 into the slot for sum.
  4. Copy the value of sum into rax, where main will look for the result.
The calculation changes the result, not the frame layout

Compare the beginning and end of the calculation, not a single instruction. Each box holds eight bytes; blue underlines mark changed values.

Arguments copied into the frame

Before the calculation

mainadd

Higher addresses ↓ stack grows

  1. main’s saved frame pointercontents omitted
  2. main’s totalnot assigned yet
  3. Unused spacecontents omitted
  4. Return addressmain: after call
  5. Saved frame pointer0x7fff0100
    rbp
  6. sumcontents omitted
  7. Unused spacecontents omitted
  8. Copy of a4
  9. Copy of b7
    rsp

Lower addresses

rip
add: calculation
rsp
0x7fff00c0
rbp
0x7fff00e0
rax
Not shown

The argument copies hold 4 and 7. sum has not been assigned; the current rax value is not shown.

After the calculation and result copy

Result ready

mainadd

Higher addresses ↓ stack grows

  1. main’s saved frame pointercontents omitted
  2. main’s totalnot assigned yet
  3. Unused spacecontents omitted
  4. Return addressmain: after call
  5. Saved frame pointer0x7fff0100
    rbp
  6. sum11
  7. Unused spacecontents omitted
  8. Copy of a4
  9. Copy of b7
    rsp

Lower addresses

rip
add: teardown
rsp
0x7fff00c0
rbp
0x7fff00e0
rax
11

sum and rax now hold 11, and rip has advanced to teardown. rsp, rbp and the saved return address have not changed.

Each address labels the lowest byte of its box, at the bottom edge. Pointer arrows meet that address. Dashed boxes are outside the active stack; their old bytes can remain. The return-address label stands for an instruction address.

We have our answer: 11 is in the slot for sum, and rax holds a copy ready for main. Neither the stack pointer nor the frame pointer needed to move to calculate it.

The diagram compares the beginning and end of that calculation; the instructions in between also advance rip and use temporary registers.

There are two quite different things we might casually call “the return” here:

  • What did the function calculate? That’s the return value, 11, which travels back in rax.
  • Where should execution continue? That’s what the return address, saved on the stack by call, tells the CPU.

The names sound similar, but adding 4 and 7 didn’t calculate a place to go in the program.

Find both in the diagram: the result in rax, and the saved instruction address in memory. Trace where each will be used when we return to the caller. We need both to finish the call, but they have different jobs.

Once the result is ready, add needs to put the caller’s stack state back. This build finishes with:

leave
ret

To take a value back off the stack, the CPU reads the value at rsp, then adds eight to rsp to move past it. That’s a pop, the counterpart to a push.

A pop changes where the active stack ends; it doesn’t erase the bytes it read.

leave combines two operations:

  1. Copy rbp into rsp. Both now hold 0x7fff00e0. The four working slots below that address are released: add is finished using them.
  2. Pop the saved frame pointer into rbp. Read 0x7fff0100 from memory at 0x7fff00e0 and put that saved number back in rbp, then advance rsp by eight bytes.

After leave, rbp holds 0x7fff0100 again, and rsp points at 0x7fff00e8, the saved return address.

We haven’t returned to main yet. Restoring its frame pointer and restoring its execution position are separate changes.

ret reads the saved return address into rip and advances rsp by another eight bytes. Execution resumes immediately after the call in main, with rsp back at 0x7fff00f0.

First leave releases add’s working space and restores main’s frame pointer, then ret takes us back to its code. The diagram shows the two operations within leave separately, although the program executes them as one instruction.

leave restores the frame pointer; ret restores execution

Each box holds eight bytes. Blue underlines mark changed values; outlined pointer labels show which pointers moved.

Before leave

Result ready

mainadd

Higher addresses ↓ stack grows

  1. main’s saved frame pointercontents omitted
  2. main’s totalnot assigned yet
  3. Unused spacecontents omitted
  4. Return addressmain: after call
  5. Saved frame pointer0x7fff0100
    rbp
  6. sum11
  7. Unused spacecontents omitted
  8. Copy of a4
  9. Copy of b7
    rsp

Lower addresses

rip
add: leave
rsp
0x7fff00c0
rbp
0x7fff00e0
rax
11

rax = 11. The caller’s frame pointer and return address are still saved.

Inside leave: first copy rbp into rsp

Back at the saved frame pointer

mainadd

Higher addresses ↓ stack grows

  1. main’s saved frame pointercontents omitted
  2. main’s totalnot assigned yet
  3. Unused spacecontents omitted
  4. Return addressmain: after call
  5. Saved frame pointer0x7fff0100
    rbprsp
  6. sum11
  7. Unused spacecontents omitted
  8. Copy of a4
  9. Copy of b7

Lower addresses

rip
add: leave (first part)
rsp
0x7fff00e0
rbp
0x7fff00e0
rax
11

Both pointers hold 0x7fff00e0. The four working slots are released; the saved main frame pointer is still in memory. This is an explanatory view inside one instruction.

Finish leave: pop the saved value into rbp

Caller’s frame restored

mainadd

Higher addresses ↓ stack grows

  1. main’s saved frame pointercontents omitted
    rbp
  2. main’s totalnot assigned yet
  3. Unused spacecontents omitted
  4. Return addressmain: after call
    rsp
  5. Saved frame pointer0x7fff0100
  6. sum11
  7. Unused spacecontents omitted
  8. Copy of a4
  9. Copy of b7

Lower addresses

rip
add: ret
rsp
0x7fff00e8
rbp
0x7fff0100
rax
11

rbp = 0x7fff0100 again. rsp points to the saved return address; rax is still 11.

Execute ret →

Execution back in main

mainadd

Higher addresses ↓ stack grows

  1. main’s saved frame pointercontents omitted
    rbp
  2. main’s totalnot assigned yet
  3. Unused spacecontents omitted
    rsp
  4. Return addressmain: after call
  5. Saved frame pointer0x7fff0100
  6. sum11
  7. Unused spacecontents omitted
  8. Copy of a4
  9. Copy of b7

Lower addresses

rip
main: after call
rsp
0x7fff00f0
rbp
0x7fff0100
rax
11

rip uses the saved return address. rsp is back at 0x7fff00f0; rax is still 11.

Each address labels the lowest byte of its box, at the bottom edge. Pointer arrows meet that address. Dashed boxes are outside the active stack; their old bytes can remain. The return-address label stands for an instruction address.

The pointers are back where they were before the call: rbp = 0x7fff0100 and rsp = 0x7fff00f0. rax still holds 11. main can now store that value into its own slot for total, at 0x7fff00f8.

We’re back in main, but we haven’t wound the program back to where it was before the call. rax now holds 11, which is rather important if calling add was going to be any use.

The old sum bytes still hold 11 too, because moving the stack pointer didn’t erase them.

That space is available for later calls to reuse, so the bytes being left behind doesn’t mean main can keep using add’s local variable. Its result is the copy in rax.

If your pointers don’t get back to their starting positions, I’d recover the trace from the call and check:

  • Did you account for both saved values? There’s one push for the return address, then another for the caller’s frame pointer.
  • Did leave release the local space and restore the frame pointer before ret restored the execution address?
  • Does rax still hold the result? Neither operation puts a return address there.

main stores the returned 11 in total, then returns it as an int. Its frame is taken down too, and execution returns to the startup/support code that called it.

The runtime passes the exit status to the operating system, which ends the process and releases its resources. The shell can then report the status.

Run the program and check it in the terminal:

Terminal window
./frame
echo $?

Expected output:

11

The program itself doesn’t print anything. echo $? prints the exit status of the command immediately before it.

We deliberately returned the sum here, so 11 is the expected result even though programs normally use zero to report success.

Check it immediately after ./frame; running another command first replaces the status you’re trying to inspect.

Change add(4, 7) to add(6, 8), rebuild with the earlier command, and run it. Check the exit status immediately with echo $?: it should be 14.

Look back at the return diagram. The saved frame pointer restores the caller’s rbp; the saved return address tells the CPU where to continue. The arithmetic result comes back in rax. Changing the arguments changes the sum, not those roles.

If you still get 11, check that you saved the source, rebuilt it successfully, and ran the new executable. You can sketch the frame if it helps, but no table or saved explanation is needed.

Knowledge check

Question 1 of 3

Just after call enters add, but before add sets up its frame, what does rbp hold?

The saved return address is ordinary stored data, but the CPU later uses it to choose where execution continues. An ordinary call relies on finding that address intact when it returns, alongside the saved frame pointer the caller needs to recover its working context.