Stack Unwinding and DWARF: How Backtraces Reconstruct the Call Chain

2026-05-09

When your program crashes and gdb prints a 30-frame backtrace, or a C++ exception flies through a dozen functions to reach its catch, something has to walk the stack backwards. That something is the unwinder, and the map it follows is DWARF — specifically the .eh_frame and .debug_frame sections of your binary.

The naive approach uses the frame pointer (%rbp on x86-64): each frame stores the previous %rbp at a known offset, so you chase a linked list. Simple, but compilers love to omit the frame pointer (-fomit-frame-pointer is default at -O1+) because it frees a register and saves two instructions per call. Without it, the unwinder cannot guess where the previous frame begins.

DWARF Call Frame Information (CFI) solves this. For every instruction address in your binary, CFI encodes a tiny program — really a state machine — that answers two questions: where is the Canonical Frame Address (CFA, roughly the caller's stack pointer at call time)? and where are the saved registers relative to the CFA? The encoding is byte-coded opcodes like DW_CFA_def_cfa: rsp+8 and DW_CFA_offset: rbp at cfa-16, packed into Frame Description Entries (FDEs) under a Common Information Entry (CIE).

Concrete example. Compile with gcc -O2 -fno-omit-frame-pointer foo.c and run readelf --debug-dump=frames a.out. You'll see something like:

The unwinder reads the current PC, finds the matching FDE via a sorted index (.eh_frame_hdr, binary-searched in O(log n)), executes the CFI program up to the current PC, then knows exactly where the return address lives. Repeat until main.

Rule of thumb: .eh_frame is mapped at runtime and used by C++ exceptions and backtrace(); .debug_frame is debugger-only and stripped by strip --strip-debug. Budget roughly 3–8% of binary size for .eh_frame on a typical C++ binary — it is non-negotiable if you throw exceptions, because throw literally interprets DWARF to find each destructor on the way up.

This is also why async-signal-safe backtraces are hard: the unwinder may take locks inside dl_iterate_phdr. Tools like libunwind and the kernel's ORC unwinder (a simpler, faster format invented for the kernel because DWARF was too slow to interpret in NMIs) exist precisely to dodge that cost.

Key Takeaway: Stack unwinding without frame pointers works by interpreting per-PC DWARF CFI programs that describe where the CFA and saved registers live, which is why exceptions, backtraces, and profilers all depend on .eh_frame being intact.

All newsletters