Anatomy of a Stack Frame
Call a function, then come back
Section titled “Call a function, then come back”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.
A program small enough to follow
Section titled “A program small enough to follow”In the Linux course environment, create a working directory and enter it:
mkdir -p ~/underdunn-course/stack-frame ~/underdunn-course/notescd ~/underdunn-course/stack-frameSave 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:
gcc -g -O0 -fno-omit-frame-pointer -mno-red-zone frame.c -o frameThese options make the compiled program easier to follow:
-gincludes debugger information.-O0avoids optimizing our small calculation away.-fno-omit-frame-pointerasks GCC to keep the frame pointer.-mno-red-zonemakes 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.
Getting to main
Section titled “Getting to main”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.
Somewhere to keep main’s work
Section titled “Somewhere to keep main’s work”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
totaland 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.
Passing the arguments
Section titled “Passing the arguments”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.
Reading the stack diagrams
Section titled “Reading the stack diagrams”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.
Stack memory
Part of the process's flat memory space. Every box is eight bytes. Addresses mark the bottom edge.
Higher addresses ↓ stack grows
- main’s saved frame pointercontents omittedrbp
- main’s totalnot assigned yet
- Unused spacecontents omittedrsp
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- Not in use yetcontents omitted
Lower addresses
Registers
Stack addresses are hexadecimal; argument and result values are decimal.
ripmain: call addrsp0x7fff00f0rbp0x7fff0100rdi4rsi7raxnot 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
adduses 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.
What call changes
Section titled “What call changes”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:
Each box holds eight bytes. Blue underlines mark changed values; outlined pointer labels show which pointers moved.
Starting state
Before call
Higher addresses ↓ stack grows
- main’s saved frame pointercontents omittedrbp
- main’s totalnot assigned yet
- Unused spacecontents omittedrsp
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- 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
Higher addresses ↓ stack grows
- main’s saved frame pointercontents omittedrbp
- main’s totalnot assigned yet
- Unused spacecontents omitted
- Return addressmain: after callrsp
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- 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:
rsphas moved to0x7fff00e8, and memory there holds the address of the instruction after the call.rippoints intoadd.rbpstill holds0x7fff0100, main’s frame-pointer address.
The call has saved a return address, but it hasn’t set up the new function’s frame pointer.
Give the function a frame pointer
Section titled “Give the function a 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:
pushsaves a value on the stack.movcopies a value.subsubtracts 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 rbpmov rbp, rspsub rsp, 32Follow the three instructions in order:
- Save main’s frame pointer:
push rbp. Moverspfrom0x7fff00e8to0x7fff00e0and write the old value ofrbp,0x7fff0100, into that slot. We’ll need that saved number when we return. - Set add’s frame pointer:
mov rbp, rsp. Copy the currentrspintorbp. Both now hold0x7fff00e0. - Reserve four slots:
sub rsp, 32. Subtract 32 fromrsp, moving it to0x7fff00c0. That reserves four eight-byte slots below the saved frame pointer.rbpstays at0x7fff00e0, 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.
Each box holds eight bytes. Blue underlines mark changed values; outlined pointer labels show which pointers moved.
Starting state in main
Before call
Higher addresses ↓ stack grows
- main’s saved frame pointercontents omittedrbp
- main’s totalnot assigned yet
- Unused spacecontents omittedrsp
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- 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
Higher addresses ↓ stack grows
- main’s saved frame pointercontents omittedrbp
- main’s totalnot assigned yet
- Unused spacecontents omitted
- Return addressmain: after callrsp
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- 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
Higher addresses ↓ stack grows
- main’s saved frame pointercontents omittedrbp
- main’s totalnot assigned yet
- Unused spacecontents omitted
- Return addressmain: after call
- Saved frame pointer0x7fff0100rsp
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- 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
Higher addresses ↓ stack grows
- main’s saved frame pointercontents omitted
- main’s totalnot assigned yet
- Unused spacecontents omitted
- Return addressmain: after call
- Saved frame pointer0x7fff0100rbprsp
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- Not in use yetcontents omitted
- 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
Higher addresses ↓ stack grows
- main’s saved frame pointercontents omitted
- main’s totalnot assigned yet
- Unused spacecontents omitted
- Return addressmain: after call
- Saved frame pointer0x7fff0100rbp
- sumcontents omitted
- Unused spacecontents omitted
- Reserved spacecontents omitted
- Reserved spacecontents omittedrsp
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.
Finding the local values
Section titled “Finding the local values”The next instructions copy the arguments into the frame:
- 4 goes from
rdiinto the slot at0x7fff00c8, fora. - 7 goes from
rsiinto the slot at0x7fff00c0, forb.
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:
Stack memory
Part of the process's flat memory space. Every box is eight bytes. Addresses mark the bottom edge.
Higher addresses ↓ stack grows
- main’s saved frame pointercontents omitted
- main’s totalnot assigned yet
- Unused spacecontents omitted
- Return addressmain: after call
- Saved frame pointer0x7fff0100rbp
- sumcontents omitted
- Unused spacecontents omitted
- Copy of a4
- Copy of b7rsp
Lower addresses
Registers
Stack addresses are hexadecimal; argument and result values are decimal.
ripadd: calculationrsp0x7fff00c0rbp0x7fff00e0rdi4rsi7raxnot 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:
rbpcontains0x7fff00e0. That’s whereadd’s frame reference point is now.- Memory at that address contains
0x7fff0100. That’s the old number we saved formain, 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.
Do the calculation
Section titled “Do the calculation”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:
- Read the saved values of
aandbfrom memory into registers. - Add 4 and 7 to produce 11.
- Write 11 into the slot for
sum. - Copy the value of
sumintorax, wheremainwill look for the result.
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
Higher addresses ↓ stack grows
- main’s saved frame pointercontents omitted
- main’s totalnot assigned yet
- Unused spacecontents omitted
- Return addressmain: after call
- Saved frame pointer0x7fff0100rbp
- sumcontents omitted
- Unused spacecontents omitted
- Copy of a4
- Copy of b7rsp
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
Higher addresses ↓ stack grows
- main’s saved frame pointercontents omitted
- main’s totalnot assigned yet
- Unused spacecontents omitted
- Return addressmain: after call
- Saved frame pointer0x7fff0100rbp
- sum11
- Unused spacecontents omitted
- Copy of a4
- Copy of b7rsp
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.
Restore the caller’s stack
Section titled “Restore the caller’s stack”Once the result is ready, add needs to put the caller’s stack state back. This build finishes with:
leaveretTo 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:
- Copy
rbpintorsp. Both now hold0x7fff00e0. The four working slots below that address are released:addis finished using them. - Pop the saved frame pointer into
rbp. Read0x7fff0100from memory at0x7fff00e0and put that saved number back inrbp, then advancerspby 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.
Each box holds eight bytes. Blue underlines mark changed values; outlined pointer labels show which pointers moved.
Before leave
Result ready
Higher addresses ↓ stack grows
- main’s saved frame pointercontents omitted
- main’s totalnot assigned yet
- Unused spacecontents omitted
- Return addressmain: after call
- Saved frame pointer0x7fff0100rbp
- sum11
- Unused spacecontents omitted
- Copy of a4
- Copy of b7rsp
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
Higher addresses ↓ stack grows
- main’s saved frame pointercontents omitted
- main’s totalnot assigned yet
- Unused spacecontents omitted
- Return addressmain: after call
- Saved frame pointer0x7fff0100rbprsp
- sum11
- Unused spacecontents omitted
- Copy of a4
- 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
Higher addresses ↓ stack grows
- main’s saved frame pointercontents omittedrbp
- main’s totalnot assigned yet
- Unused spacecontents omitted
- Return addressmain: after callrsp
- Saved frame pointer0x7fff0100
- sum11
- Unused spacecontents omitted
- Copy of a4
- 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
Higher addresses ↓ stack grows
- main’s saved frame pointercontents omittedrbp
- main’s totalnot assigned yet
- Unused spacecontents omittedrsp
- Return addressmain: after call
- Saved frame pointer0x7fff0100
- sum11
- Unused spacecontents omitted
- Copy of a4
- 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
leaverelease the local space and restore the frame pointer beforeretrestored the execution address? - Does
raxstill hold the result? Neither operation puts a return address there.
Finish the program
Section titled “Finish the program”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:
./frameecho $?Expected output:
11The 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.
Try another run
Section titled “Try another run”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
Practice complete
You've checked the key ideas. Revisit any explanation you need, then continue when it makes sense.
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.