Linux Function Calls and Stack Frames
Understand calling conventions, stack frames, call/ret behavior, debugging observation, and security implications from the assembly view.
This article starts with a minimal C example and expands into how Linux function calls build stack frames, pass parameters, store return addresses, follow register conventions, and can be observed in objdump/gdb. If you care about binary analysis, crash triage, performance tuning, or security work (e.g., overflows and ROP), the call stack is foundational.
You’ll likely reach for this knowledge in cases like:
- Crash debugging: you need a trustworthy backtrace and want to understand what
btis (and isn’t) showing. - Perf investigations: you’re trying to explain call overhead, inlining, or why profiles look “flat”.
- Security analysis: return addresses and stack layout are the ground truth behind overflow/ROP discussions.
1. Example and goal
A small three-level call chain:
int bar(int c, int d) {
int e = c + d;
return e;
}
int foo(int a, int b) {
return bar(a, b);
}
int main(void) {
foo(2, 3);
return 0;
}
Recommended build flags:
gcc -g -O0 -fno-omit-frame-pointer foo_bar.c -o a.out
-gkeeps debug info for mixed source/asm.-O0avoids optimizations that reshape the frame.-fno-omit-frame-pointerkeepsebp/rbpfor easier inspection.
2. How the stack grows
On x86 (32-bit), the stack grows from high addresses to low addresses. esp points to the top of the stack, ebp points to the base of the current frame. Each call creates a new frame; each return destroys it. The core items stored on the stack are:
- Return address (pushed by
call). - Previous frame pointer (saved old
ebp). - Parameters and local variables.
Simplified frame layout (high addresses at the top):
High addresses
| arg b | <- ebp+12
| arg a | <- ebp+8
| return addr | <- ebp+4
| old ebp | <- ebp
| local var e | <- ebp-4
Low addresses (esp grows downward)
These offsets come from the common cdecl convention, but other conventions exist.
3. What call/ret really do
call performs two actions:
- Push the next instruction address (return address).
- Jump to the callee entry address (update
eip).
ret does the reverse:
- Pop the return address into
eip. - Move
espup by one word.
This explains why return addresses appear on the stack and why stack overflows can hijack control flow.
4. Function prologue/epilogue
Typical function entry:
push %ebp
mov %esp, %ebp
sub $0x10, %esp
Meaning:
- Save old
ebp. - Establish a new frame base.
- Reserve space for locals.
Typical function exit:
leave
ret
leave is equivalent to:
mov %ebp, %esp
pop %ebp
Restores the previous frame, then ret returns.
4.1 Fuller objdump output (excerpt)
Below is a longer disassembly excerpt to show the call chain end-to-end:
$ objdump -dS a.out
...
080483dc <bar>:
80483dc: 55 push %ebp
80483dd: 89 e5 mov %esp,%ebp
80483df: 83 ec 10 sub $0x10,%esp
80483e2: 8b 45 0c mov 0xc(%ebp),%eax
80483e5: 8b 55 08 mov 0x8(%ebp),%edx
80483e8: 01 d0 add %edx,%eax
80483ea: 89 45 fc mov %eax,-0x4(%ebp)
80483ed: 8b 45 fc mov -0x4(%ebp),%eax
80483f0: c9 leave
80483f1: c3 ret
080483f2 <foo>:
80483f2: 55 push %ebp
80483f3: 89 e5 mov %esp,%ebp
80483f5: 83 ec 08 sub $0x8,%esp
80483f8: 8b 45 0c mov 0xc(%ebp),%eax
80483fb: 89 44 24 04 mov %eax,0x4(%esp)
80483ff: 8b 45 08 mov 0x8(%ebp),%eax
8048402: 89 04 24 mov %eax,(%esp)
8048405: e8 d2 ff ff ff call 80483dc <bar>
804840a: c9 leave
804840b: c3 ret
0804840c <main>:
804840c: 55 push %ebp
804840d: 89 e5 mov %esp,%ebp
804840f: 83 ec 08 sub $0x8,%esp
8048412: c7 44 24 04 03 movl $0x3,0x4(%esp)
804841a: c7 04 24 02 00 movl $0x2,(%esp)
8048421: e8 cc ff ff ff call 80483f2 <foo>
8048426: b8 00 00 00 00 mov $0x0,%eax
804842b: c9 leave
804842c: c3 ret
...
5. Parameters: the ABI difference
The example above uses 32-bit cdecl: arguments pushed right-to-left on the stack, return value in eax, caller cleans up. main places 3 at esp+4, 2 at esp, then calls foo. Inside foo, a and b are read at ebp+8 and ebp+12.
On x86-64 (SysV ABI), the first six integer arguments go in registers (rdi, rsi, rdx, rcx, r8, r9). Only the seventh onward hit the stack. This makes the stack layout cleaner but the disassembly harder to follow — you have to track register values instead of reading offsets. If you’re debugging a crash on a 64-bit system and the backtrace doesn’t match what you expect from the source, check whether the arguments might be in registers, not on the stack.
6. How frames chain together
Each frame stores the previous ebp, forming a linked list. bt in gdb walks this chain to show the call stack. If frame pointers are omitted (e.g., -fomit-frame-pointer), backtraces can be incomplete and rely on DWARF or heuristics.
6.1 The hidden frame: main is called by libc
main is not the first function on the call chain. The kernel’s startup code calls __libc_start_main, which sets up libc internals and then calls main. You can observe this by setting a breakpoint at main’s entry:
(gdb) b *0x0804840c
Breakpoint 1 at 0x804840c: file foo_bar.c, line 21.
(gdb) r
(gdb) x/x $esp
0xbffff6ac: 0xb7e394d3 <- return address from main
(gdb) x/5i 0xb7e394d3-10
0xb7e394c9 <__libc_start_main+233>: ...
0xb7e394cf <__libc_start_main+239>: call *0x70(%esp)
0xb7e394d3 <__libc_start_main+243>: mov %eax,(%esp) <- after main returns
0xb7e394d6 <__libc_start_main+246>: call 0xb7e52fb0 <__GI_exit>
The return address on the stack (0xb7e394d3) points into __libc_start_main. When main finishes, execution returns there and exit() is called. This means the real call chain is:
__libc_start_main -> main -> foo -> bar
Understanding this matters because:
- Overwriting
main’s return address redirects control to somewhere inside libc (useful for return-to-libc attacks). - The
__libc_start_mainframe contains pointers an attacker might reuse.
7. Observing the real stack with gdb
Useful commands:
start
disas
bt
info registers
x/20x $esp
btshowsmain -> foo -> bar.info registersshowsesp/ebp/eip.x/20x $espdumps the stack words so you can locate return addresses and arguments.
7.1 Longer gdb session (excerpt)
Below is a longer debug session excerpt (addresses vary by build/runtime):
$ gdb a.out
(gdb) start
Temporary breakpoint 1 at 0x8048412: file foo_bar.c, line 20.
Starting program: ./a.out
Temporary breakpoint 1, main () at foo_bar.c:20
20 foo(2, 3);
(gdb) s
foo (a=2, b=3) at foo_bar.c:14
14 return bar(a, b);
(gdb) s
bar (c=2, d=3) at foo_bar.c:8
8 int e = c + d;
(gdb) disas
Dump of assembler code for function bar:
0x080483dc <+0>: push %ebp
0x080483dd <+1>: mov %esp,%ebp
0x080483df <+3>: sub $0x10,%esp
=> 0x080483e2 <+6>: mov 0xc(%ebp),%eax
0x080483e5 <+9>: mov 0x8(%ebp),%edx
0x080483e8 <+12>: add %edx,%eax
0x080483ea <+14>: mov %eax,-0x4(%ebp)
0x080483ed <+17>: mov -0x4(%ebp),%eax
0x080483f0 <+20>: leave
0x080483f1 <+21>: ret
End of assembler dump.
(gdb) bt
#0 bar (c=2, d=3) at foo_bar.c:9
#1 0x0804840a in foo (a=2, b=3) at foo_bar.c:14
#2 0x08048426 in main () at foo_bar.c:20
(gdb) info registers
eax 0x5
esp 0xbffff678
ebp 0xbffff688
eip 0x080483f0
(gdb) x/20x $esp
0xbffff678: 0x0804840a 0x00000002 0x00000003 0xbffff698
0xbffff688: 0xbffff6a8 0x08048426 0x00000002 0x00000003
...
7.2 Stack memory diagram (illustrative)
Mapping the stack words into a simple diagram (values are illustrative):
High addresses
0xbffff6a8 [0xbffff6b8] saved ebp (main)
0xbffff6a4 [0x08048426] return -> main
0xbffff6a0 [0x00000002] arg a (foo)
0xbffff69c [0x00000003] arg b (foo)
0xbffff698 [0xbffff6a8] saved ebp (foo)
0xbffff694 [0x0804840a] return -> foo
0xbffff690 [0x00000002] arg c (bar)
0xbffff68c [0x00000003] arg d (bar)
0xbffff688 [0x00000005] local e
Low addresses (esp grows downward)
Actual addresses and values vary with compiler, optimization, and alignment, but the structural relationship is consistent.
8. Call flow breakdown (matching the example)
8.1 main calls foo
main prepares arguments, then executes call foo:
- Arguments are pushed right-to-left.
callpushes the return address.eipjumps tofoo.
8.2 foo calls bar
foo has its own frame, then pushes bar’s arguments and calls it. The stack now contains:
barreturn address (back tofoo).fooreturn address (back tomain).mainreturn address (back to libc startup).
8.3 bar returns
bar writes the local e into eax, then leave; ret restores foo’s frame and jumps back.
9. x86-64 and the SysV ABI differences
On 64-bit:
- Parameters go to registers (
rdi, rsi, rdx, rcx, r8, r9). - Return value is in
rax. - The stack still stores return addresses and locals, but fewer args land on the stack.
- There is a red zone (128 bytes below
rspfor leaf functions).
The same C code therefore produces cleaner stack layouts on x86-64, so always confirm the target architecture when reading disassembly.
10. Optimization and what it breaks
Compiler optimization can:
- Omit frame pointers.
- Keep locals in registers.
- Apply tail-call optimization, reusing frames.
- Reorder instructions to reduce stack traffic.
For learning or debugging, prefer -O0 and keep frame pointers.
11.1 A real-world failure mode: your backtrace is missing frames
It’s common to see something like this in optimized builds:
#0 foo()
#1 main()
Even though you know there was an intermediate call (for example bar()), the frame is gone. The usual reasons are:
- frame pointers were omitted (
-fomit-frame-pointeris often the default at higher-Olevels) - aggressive inlining removed the function boundary
- tail-call optimization reused the current frame
- debug info is missing or stripped
Practical fixes:
- Rebuild with
-g -fno-omit-frame-pointer(and ideally lower optimization while reproducing). - If you can’t rebuild, rely on DWARF unwind info (if present) instead of frame-pointer chaining.
- Confirm with disassembly whether the call was inlined or optimized away.
12. Security relevance
Because return addresses and locals live on the stack:
- Overwriting past a buffer can overwrite a return address.
- Modern defenses (canary, NX/DEP, ASLR, PIE) mitigate exploitation.
- Still, understanding frames is necessary for analyzing overflows and ROP.
12.1 From stack layout to shellcode
The classic stack buffer overflow exploits the layout described in this article. When a local buffer (say char buf[64]) overflows, data is written past the buffer boundary toward higher addresses, eventually overwriting:
[buf (64 bytes)] [saved ebp (4 bytes)] [return addr (4 bytes)]
^
overwrite this
If an attacker controls the return address, they can redirect eip to shellcode placed in the buffer itself (on older systems without NX) or to a libc function like system("/bin/sh") (return-to-libc).
The same layout also enables ROP (Return-Oriented Programming): instead of a single return address, the attacker chains multiple return addresses pointing to short instruction sequences (gadgets) already present in the binary or loaded libraries. Each gadget ends with ret, which pops the next gadget address off the stack.
Understanding the frame chain, the role of ebp, and how call/ret manipulate the stack is therefore essential for both exploiting and defending against these attacks.
13. Practical checklist
When debugging:
- Check build flags (frame pointers or not).
- Use
btfirst, then inspect registers. - Dump the stack with
x/20x $esp. - Use
disasnear the call site to verify flow.
13.1 On-call reality: you only have a core (or just raw addresses)
In production you often don’t get a nice repro + debugger. You get:
- a core file
- or a crash log with instruction pointers
- and sometimes a stripped binary
The practical workflow is: turn absolute addresses into relative addresses, then resolve them back to symbols/lines.
- First check whether the binary is PIE (this decides if addresses are directly usable):
readelf -h ./a.out | grep Type
# Type: EXEC -> non-PIE (addresses are usually stable)
# Type: DYN -> PIE (you must subtract the load base first)
- Find the load base:
- with a core: inspect mappings in gdb (
info proc mappings) - without a core: you need
/proc/<pid>/maps(or an equivalent mapping snapshot) from the exact run
- Resolve addresses to functions/lines:
addr2line -e ./a.out -f -C 0x<addr>
# alternative on some distros
# eu-addr2line -e ./a.out -f -C 0x<addr>
- If the address belongs to a shared library, resolve against that
.so:
addr2line -e /path/to/libfoo.so -f -C 0x<addr>
Two notes:
- Line numbers require matching debug info. Without it, you can still usually identify the crashing module/function.
- If you suspect wrong library loaded, use
LD_DEBUG=libsto see the real loader decisions.