Systems Programming · C and the ABI

Stack Unwinding and DWARF Call Frame Information

How a frame is described by bytecode in .eh_frame, what the unwinder reconstructs at each step, and why a missing unwind table turns an exception into a terminate call.

When an exception propagates out of a function, or a profiler samples a thread, or a debugger prints a backtrace, something has to answer the same question repeatedly: given the machine state at a particular instruction, what was the state of the caller? The stack pointer alone is not enough, because the caller’s frame may not be at a fixed offset, and because the callee-saved registers have been pushed in an order the unwinder has no way to guess.

The answer is a table that describes, for every instruction address in a function, how to compute the caller’s registers from the current ones. On the platforms that use DWARF unwind information, that table is a compact bytecode program in a section named .eh_frame, and the unwinder is an interpreter for it.

The row model: a register state per address range

The model is a table. Rows correspond to address ranges within a function, columns correspond to registers, and each cell says where the register’s value is now: in the register itself, at a fixed offset from the current frame’s base, or in a previously saved register. Each row also carries the value of the return address register for that range.

The critical column is the canonical frame address, usually abbreviated CFA. It is not a register in the machine’s register file; it is a computed address that serves as the base for all the offset rules in the row. On x86-64 the convention is that the CFA is the value of rsp at the call site, which is 8 bytes above the address the return address is stored at in the caller’s frame. During the prologue, rsp changes with each push, so the CFA rule changes with it; after the prologue, the CFA is usually rbp + 16 for a frame-pointer function, or a fixed offset from rsp for a frame-pointer-less one.

A row therefore says: CFA = rsp + 8; return address is at CFA - 8; rbp is at CFA - 16; rbx is in the register. The unwinder evaluates that rule, recovers the caller’s return address and saved registers, and repeats with the caller’s row.

# Hypothetical .eh_frame program for a function with one push
# Address range      Instruction
00000000 00000004    DW_CFA_def_cfa rsp, 8
00000004 00000008    DW_CFA_advance_loc 1
                     DW_CFA_def_cfa_offset 16
                     DW_CFA_offset rbp, -16
00000008 ...         DW_CFA_def_cfa_register rbp

The advance_loc instruction is what makes the table compact. Rather than storing a full row per instruction, the program advances the location counter by a delta and issues only the rules that change. A function with a simple prologue has a handful of rules; a function with a large frame, variable-length arrays, and register saves has more, and the table grows with the number of distinct states rather than with the number of instructions.

Why the frame pointer is not sufficient

A frame-pointer-based unwinder is simpler: the saved frame pointer at the top of each frame points to the previous frame, so the unwinder follows a linked list. The technique works, and it is what a simple backtrace implementation does when it has nothing else.

It fails in three situations. A function compiled with -fomit-frame-pointer has no chain to follow, and the chain is broken at that function for every caller above it. A function that uses a variable-length array or alloca moves the stack pointer by a runtime amount, so the return address is not at a fixed offset from the frame pointer even when one is present. And a signal handler is entered through a frame the kernel built, not through a call, so the saved-frame chain has no entry that corresponds to it and the walk stops there.

The table-based approach handles all three because the rules are per address range and can express a CFA that depends on the current rsp, which the unwinder knows. That is why the tables exist even on targets where the frame pointer is conventionally kept, and why the ABI requires every function to have an unwind entry for the ranges in which it can be interrupted.

Building the table from the prologue

The compiler emits the table as it generates the function. It tracks, for each instruction it emits, the current CFA rule and the location of each saved register, and it emits a rule-change instruction whenever those change.

A push rbp changes two things: rsp moves down by 8, so the CFA offset from rsp increases by 8, and the value that was in rbp is now at the new rsp, which is CFA - 16. The compiler emits DW_CFA_def_cfa_offset 16 and DW_CFA_offset rbp, -16. A sub rsp, 32 changes only the offset, so a single def_cfa_offset suffices; the saved registers’ positions relative to the CFA do not move.

When the function establishes rbp as the frame base, it emits DW_CFA_def_cfa_register rbp, after which the CFA is computed from rbp and the rsp-relative offset no longer matters. This is what makes the table valid across the body of the function even when rsp is adjusted for a call.

The epilogue reverses the process. The compiler must emit rules that describe the state during the epilogue’s restore sequence, because an asynchronous signal can arrive between the first pop and the last. A function that pops three registers and then returns has intermediate states in which some registers are restored and others are not, and each state needs a rule. The DW_CFA_remember_state and DW_CFA_restore_state instructions exist to avoid duplicating a long rule sequence for an epilogue that appears at several return points.

What the unwinder does at a step

The unwinder’s algorithm is a loop. Given a program counter and a register set, find the table entry covering that address, run its program up to the current address, evaluate the resulting rules, and produce the caller’s register set. The return address register’s recovered value becomes the caller’s program counter, and the loop continues.

Two details matter for correctness. The first is that the CFA is computed from the current frame’s registers before any are restored, so a rule that says rbp is at CFA - 16 must be evaluated with the CFA derived from the current rsp, not the restored one. The second is that the return address is read from memory at the address the rules give, which may be in a frame that has already been partially overwritten if the unwinding is happening during exception propagation through a frame whose stack has been reused.

The Itanium C++ ABI’s exception handling defines a two-phase process that uses these tables. In the search phase, the unwinder walks the stack calling personality routines without modifying any state, to determine whether a handler exists and where. In the cleanup phase, it walks again and actually transfers control. The two-phase design is what allows a catch clause to be found before any destructor runs, and it requires that the unwind tables be accurate for every frame between the throw and the handler. A frame with no table entry ends the search, which is why a C function compiled without unwind tables in the middle of a C++ call chain causes the exception to terminate rather than propagate. The same limitation appears when an exception crosses a foreign function boundary, which is one of the hazards treated in cross-language calls and structure passing.

Frames that break the model

Three kinds of code do not fit the table model cleanly.

Hand-written assembly without a .cfi directive has no entry. The assembler emits a default entry only when told to, and a function that adjusts the stack without describing it produces a backtrace that stops at that frame. The .cfi_startproc, .cfi_def_cfa_offset, and .cfi_endproc directives exist to generate the table by hand, and a function that omits them is invisible to every unwinder.

Code generated at runtime has no static table at all. A JIT that emits machine code must register unwind information with the runtime for each region it produces, or exceptions cannot pass through it. The interfaces for doing so are platform-specific and are part of the runtime’s contract with the unwinder rather than of the language.

Signal handlers are a separate case. The kernel delivers a signal by building a frame on the current stack and transferring control to the handler; the handler’s return goes through a trampoline that restores the interrupted context. An unwinder walking a stack that contains a signal frame sees a frame that does not correspond to any call, and the ABI defines a way to describe it so that the walk can continue. Without that description, a backtrace taken inside a signal handler stops at the handler.

Table size against frame-pointer chains

The tables occupy space in the binary, and the space is not negligible: a typical entry costs a few dozen bytes per function, plus the program bytes for functions with complex prologues. The compiler’s -fno-asynchronous-unwind-tables option removes them, at the cost of losing backtraces and of making the function unable to propagate exceptions. The option is used in size-constrained environments and in code that must not have any unwind metadata.

The runtime cost is paid only when unwinding happens. The normal path through a function does not read the table; the table is consulted by the unwinder and by nothing else. This is why the mechanism is affordable: a function that never throws and is never sampled pays for the table’s bytes but not for its interpretation.

The alternative to table-driven unwinding is the frame-pointer chain, which costs one register and one store per call and nothing at unwind time, but breaks under the three conditions above. The two mechanisms can coexist: a build can keep frame pointers for the simple cases and tables for the rest, and an unwinder can try the chain first and fall back to the table. The choice is a build configuration rather than a language feature, and it interacts with the register allocation described in the x86-64 calling convention, since every register used to hold the chain is one the register allocator cannot use for values.

References

  1. DWARF Debugging Information Format, Version 5 — DWARF Standards Committee
  2. Itanium C++ ABI — Exception Handling — Itanium C++ ABI project
  3. System V Application Binary Interface, AMD64 Architecture Processor Supplement — x86-64 psABI committee
  4. libunwind Documentation — LLVM Project