Stack Unwinding
The mechanism underneath exceptions: walk the physical stack, and for each frame use compiler-emitted tables to restore the caller's registers, run that frame's cleanups, and decide whether it handles the exception. This is where "zero-cost" is paid for.
How does the runtime know what to destroy and where to jump when an exception propagates through a frame that never mentions exceptions?
A live stack of frames, plus two static tables describing them. The *unwind table* (CFI in .eh_frame on ELF) says, for any instruction address, how to recover the caller's stack pointer, return address and callee-saved registers — it makes the stack walkable without frame pointers. The *language-specific data area* says, for each address range, which landing pad applies and which types it accepts. Together they let a runtime reconstruct, from a program counter alone, what the compiler knew about that point.
Unwinding is defined only through frames for which unwind information exists and is accurate at the instruction where the walk resumes. A compiler may reorder or elide code freely only where it can still describe the register and stack state at every address that can appear as a return address in the walk — which is every call. Cleanups must run in reverse construction order, and an object whose constructor has not completed must not be destroyed, so the tables must distinguish the state before, during and after each construction. Unwinding through a frame with no tables, or with tables invalidated by hand-written prologue code, is undefined rather than diagnosed.
Key points
- Unwinding walks the physical stack using compiler-emitted tables that describe how to recover the caller's state from any address.
- CFI is keyed to address ranges, not to functions, because the recovery rules change through the prologue.
- The Itanium ABI unwinds in two passes so that an unhandled exception terminates with the stack intact.
- The personality routine is the language plug-in that reads the language-specific data area and decides whether a frame handles the exception.
- A landing pad contains the frame's cleanups and, if it has any, its catch dispatch — and frames with no handlers still get one if they own destructible objects.
- The generated code tracks how far construction got, so a partially-constructed scope destroys only what exists.
- The same tables drive profilers, debuggers and crash reporters, which is why
-fasynchronous-unwind-tablesis on by default in places where exceptions are not used. - "Zero-cost" is zero on the non-throwing instruction stream and is paid in table size, duplicated cleanup code and optimizer freedom.
Walking a stack you were not given a map to
.pdata/.xdata rather than DWARF CFI. Nothing about the encoding transfers between them; only the idea does.The runtime starts with one thing: the current program counter and register state. To reach the caller it must know where the return address is, how much the stack pointer moved, and which callee-saved registers this function overwrote and where it stashed them. None of that is in the instruction stream in a form anything can read, and a frame pointer — the old way — is exactly what optimizers delete first.
So the compiler writes it down. For each function, and in fact for each *region* of each function, it emits call-frame information describing the recovery rules at that point: the canonical frame address as an offset from some register, the location of the return address, and the saved location of each preserved register. On ELF this is DWARF CFI in .eh_frame; the same information also drives debuggers and profilers, which is why an unwinder and a stack sampler consume the same tables.
The regions matter. Inside a function prologue the rules change instruction by instruction — before the push, after the push, after the frame is set up — so CFI is a sequence of deltas keyed to addresses, not one record per function. That is what makes it possible to unwind correctly from a signal that arrived in the middle of a prologue, and it is why hand-written assembly needs explicit .cfi_ directives to be unwindable at all.
Two phases, and why there are two
The Itanium ABI unwinder walks the stack twice. The first pass — the search phase — asks each frame's personality routine "would you handle this exception?" without changing anything: no registers restored, no destructors run, the stack still intact. It stops at the first frame that says yes. The second pass — the cleanup phase — walks again from the top, this time actually unwinding: running each frame's cleanups and finally transferring control into the handling frame's landing pad.
The reason for two passes is a language guarantee that is easy to miss. If no handler exists anywhere, the program must terminate *with the stack intact*, so that a debugger or a crash reporter sees the frames where the exception was raised rather than an unwound stack. A single-pass unwinder that destroyed as it went would have already demolished the evidence by the time it discovered there was no handler. Two phases buy that property, and they cost a second walk on every throw.
The personality routine is the language's plug-in point. It is a function named in the unwind table — __gxx_personality_v0 for C++ under the Itanium ABI, a different one for Rust, another for Objective-C — and it is what knows how to read that language's data area, compare the thrown type against the catch types, and report back. That indirection is what lets one platform unwinder serve several languages, and it is what makes an exception thrown in C++ and caught in Rust a coherent question with a defined answer on some platforms and not others.
- Throwrun timeAn exception object allocated in a runtime-managed area, plus a call to the raise routine.A value that will outlive every frame between here and the handler.
- Search phase, frame by framerun timeProgram counter plus a virtual register context, walked upward using CFI. The real stack is untouched.The identity of the frame that will handle it — or the knowledge that none will.
- No handler foundrun timeThe original stack, complete.Termination with the raising frames still present for the debugger.Nothing — which is the point of not having unwound yet.
- Cleanup phase, frame by framerun timeThe stack being progressively destroyed as each frame is unwound.Each frame's destructors and finally blocks, in reverse construction order.The frames themselves, and with them any stack trace not captured earlier.
- Landing padrun timeOrdinary code in the handling frame, entered with the exception object and a selector value in registers.Dispatch to the matching catch clause and the handler body.
Read it asRead the third row: the reason for two passes is entirely there. The cost is that a throw walks the stack twice, so unwinding is roughly linear in stack depth with a substantial constant per frame. A stack trace captured inside a catch is therefore already too late to see the frames below the handler — they were destroyed in the cleanup phase, which is why runtimes that want good traces capture them at throw time instead.
Landing pads and the cleanup obligation
A landing pad is the block the unwinder jumps into to finish handling an exception in a given frame. It is ordinary code, emitted in a cold section, and it contains two things: the cleanups for the region the exception passed through, and — if this frame has handlers — a comparison of the exception type against each catch clause, dispatching to the matching one.
A frame with no handlers still gets landing pads if it has anything to clean up. That is the point made in [[exception-handling]] about intervening functions: a function that neither throws nor catches, but holds a lock guard, must have a landing pad that releases the lock and then resumes the unwind. This is why -fno-exceptions shrinks binaries so much in idiomatic C++ — every scope with a destructor was contributing one.
The compiler must also track *how far construction got*. If a scope constructs three objects and the second constructor throws, only the first must be destroyed. The generated code therefore maintains a small state value the landing pad reads to decide which cleanups apply — the same idea as the state tag in a coroutine, applied to cleanup rather than to resumption, and one of the reasons this transformation is difficult to get right.
finally in a language that has it is compiled the same way, and gets one additional complication: the block has to run on the normal path as well as the exceptional one. Historically Java compilers duplicated the block into every exit — every return, every break, plus the exception path — which is why a finally in a method with several returns generates surprisingly much bytecode.
- u0entryentry
construct a state = 1
After a is constructed, only a needs destruction. - u1after a
construct b state = 2
If b's constructor throws, we arrive at the pad with state = 1. - u2body
invoke may_throw()
Normal successor plus unwind successor. - u3normal exit
destroy b destroy a ret
Reverse construction order on the normal path. - u4landing pad (cold)
switch state 2: destroy b; destroy a 1: destroy a resume unwind
Same order, driven by how far construction got. No catch here, so the unwind continues upward.
- u0→u1
- u1→u2
- u1→u4ctor throws
- u2→u3normal
- u2→u4unwind
- u3→u3return
Read it asThe cleanup code exists twice — once on the normal path and once in the cold landing pad — and the landing-pad copy is guarded by a state value recording how far construction got. Both are pure cost in binary size for a program that never throws. Note that this frame contains no try and no catch: it is here purely because it owns two objects with destructors, which is what makes unwinding an obligation on nearly every function rather than on the ones that mention exceptions.
Where the tables show up in things that are not exceptions
The same unwind information serves several consumers, which is worth knowing because it explains build flags that otherwise look arbitrary. A profiler that samples stacks needs it to walk frames without frame pointers. A debugger needs it to show a backtrace in optimized code. A crash reporter needs it to symbolize a core dump. backtrace() needs it. Go's and Java's runtimes maintain their own equivalents for the same reasons.
This is why -fasynchronous-unwind-tables exists as a separate flag from exception support: it requests tables that are correct at *every* instruction, not just at call sites, so that a stack can be walked from a signal handler that interrupted arbitrary code. It is the default on x86-64 Linux precisely because profilers and crash handlers need it, and it costs size in every binary including ones that never throw.
The corollary is a practical one. If your stack traces are truncated or wrong in optimized builds, the question is whether unwind information exists and is accurate for the frames involved — hand-written assembly without .cfi_ directives, JIT-generated code that never registered its frames, and libraries stripped of .eh_frame are the three usual culprits, and none of them produces an error, only a short trace.
- Exception unwinding, stack sampling, debugger backtraces and crash symbolication all consume the same tables.
-fasynchronous-unwind-tablesdemands accuracy at every instruction, not only at call sites, so signal handlers can walk the stack.- Hand-written assembly needs explicit
.cfi_directives or it is a wall the unwinder cannot pass. - JIT-generated frames must register unwind information with the runtime or they break every consumer above.
- Stripping
.eh_frameshrinks a binary and silently truncates every stack trace through it.
How it works
The steps, in the order the compiler takes them.
- The compiler emits call-frame information for each address range of each function: the canonical frame address rule, the return-address location, and the saved location of each callee-saved register.
- It emits a language-specific data area mapping call-site address ranges to their landing pad and to the list of action records — cleanup only, or a set of catch types.
- Each potentially-throwing call is emitted with an unwind successor pointing at the enclosing region's landing pad.
- A throw allocates the exception object and calls the runtime raise routine with it.
- Search phase: the unwinder walks upward using CFI in a virtual register context, calling each frame's personality routine, which reports whether that frame handles the exception. Nothing is modified.
- If no frame handles it, the runtime terminates while the original stack is still intact.
- Cleanup phase: the unwinder walks again, restoring each frame's registers and transferring into its landing pad, which runs the cleanups its state value selects and then resumes the unwind.
- At the handling frame the landing pad receives the exception pointer and a selector identifying which catch matched, and jumps into the handler body.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A stack trace stops abruptly in the middle, because a frame — hand-written assembly, a JIT frame, or a library stripped of unwind information — had no tables and the walk could not continue.
- A destructor does not run and a lock is held forever, because the exception unwound through a frame compiled without unwind tables and the cleanup for that frame simply did not exist.
- A process aborts with a message about a failed unwind rather than about the original error, and the actual cause is invisible because it was never reported.
- A crash report from production symbolizes to the wrong functions, because the unwind information was inaccurate in the prologue where the signal arrived.
- A binary shrinks by a surprising amount when exceptions are disabled, revealing how much of it was landing pads and tables for code that never threw.
- A partially-constructed object is destroyed and a destructor runs on uninitialised memory, because the cleanup state value was not maintained across a constructor that threw.
- A profiler shows nonsense stacks in optimized builds, and the fix turns out to be a build flag about unwind tables rather than anything about the profiler.
When it helps
- Guaranteeing cleanup on every exit path, which is what makes RAII and scope guards trustworthy rather than conventional.
- Debugging and profiling optimized code, where frame pointers are gone and the tables are the only way to walk a stack.
- Crash reporting and post-mortem analysis from a core dump, where nothing else can reconstruct the call chain.
- Letting one platform unwinder serve several languages through the personality-routine indirection.
When it hurts
- On size-constrained targets, where the tables are a fixed proportion of the binary that no optimization removes.
- On hot paths that throw, where two stack walks plus a personality call per frame make the failure path orders of magnitude slower than a returned error.
- At boundaries with code that has no tables — assembly, foreign functions, JIT frames — where the failure is undefined behavior rather than a diagnostic.
- When the compiler must preserve describable state at every call, which limits code motion and register allocation in ways that are hard to attribute.
What it costs
Every one of these is paid by something.
- Precomputed tables buy an untouched non-throwing instruction stream and pay in binary size, in duplicated cleanup code, and in the compiler's obligation to be able to describe machine state at every address the walk can resume from.
- The two-phase walk buys an intact stack when nothing handles the exception — so the crash report shows the raising frames — and pays a second complete walk on every throw that is handled.
- The personality-routine indirection buys one unwinder serving many languages and pays an indirect call per frame plus a cross-language ABI that must be agreed and maintained by everyone who participates.
- Asynchronous unwind tables buy stack walks from signal handlers and profilers and pay size in every binary, including ones that never throw anything.
What else you could do
What a different compiler or language does instead, and when that is better.
- Frame pointers: keep a register chained through every frame so the stack can be walked trivially. Costs a register and a couple of instructions per call, and it is why the
-fomit-frame-pointerargument keeps returning — several large operators have re-enabled frame pointers fleet-wide precisely to get reliable profiles. - SJLJ, which maintains the answer at run time in a linked list instead of precomputing it. Cheap unwinding, cost on every guarded region entry — see
[[exception-handling]]. - No unwinding at all: abort on failure.
panic=abortin Rust,-fno-exceptionsin C++. Smallest binary, no cleanup, and error handling must be designed around the absence. - Return-based error propagation, where the "unwind" is ordinary returns and every cleanup happens on a path the compiler already generates — Go's
deferon the normal path, Rust's?onResult.
See it for yourself
The flag, dump or tool that shows you this directly.
readelf -Sshows the size of.eh_frameand.gcc_except_table; comparing a binary built with and without-fno-exceptionsquantifies the table cost directly.objdump --dwarf=framesdecodes the CFI into readable recovery rules per address range;--dwarf=frames-interpprints the interpreted table, which is much easier to read.clang -Son a function with a destructible local shows the landing pad in a cold section along with the.cfi_directives interleaved through the prologue.- Set a breakpoint in a destructor and throw:
btin gdb during the cleanup phase shows the unwinder frames themselves, which is the clearest demonstration that a throw is a call into a library. - Java:
javap -cprints the per-method exception table. Go:GOTRACEBACK=systemshows runtime frames a panic passes through, including the deferred calls it runs.
Plausible wrong readings
Stated the way a confident engineer states them.
- "The unwinder follows frame pointers." Optimized code usually has none. It follows compiler-emitted rules indexed by return address.
- "Unwinding is a jump." It is a loop of table lookups and indirect calls, one per frame, done twice under the Itanium ABI.
- "Only C++ needs unwind tables." Profilers, debuggers and crash reporters need them, which is why they are enabled by default on targets where exceptions are rarely used.
- "If I catch the exception I can get a full stack trace." By the time the handler runs, the frames below it have been destroyed. Runtimes that provide good traces capture them at throw time.
- "A function without try or throw is unaffected." If it owns anything with a destructor, it has a landing pad and table entries.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
When something is thrown, the runtime has to get from where it happened up to whoever handles it, cleaning up everything on the way. It cannot read the machine code to work out how, so the compiler leaves behind tables describing, for every address in the program, how to step up one frame and what needs destroying there. Unwinding is the process of reading those tables frame by frame.
practical
The tables are why your profiler and your crash reporter work, so treat them as infrastructure rather than as an exception feature. If backtraces are truncated in production, suspect a frame with no unwind information — hand-written assembly, a JIT, or a stripped library — rather than the tool. If binary size matters, measure .eh_frame and .gcc_except_table before deciding whether disabling exceptions is worth it. And if you want a stack trace from an error, capture it where the error is created, not where it is caught: by the time the handler runs the frames below it are gone.
advanced
The deep constraint this places on a compiler is that it must be able to *describe the machine state at every address that can appear as a return address*, and under asynchronous unwind tables, at every address at all. That is a standing obligation on every optimization: a transformation that leaves a value only in a register with no recovery rule, or that makes the frame layout indescribable at some intermediate address, is not merely a debugging inconvenience — it breaks unwinding, which breaks correctness. This is the same obligation a garbage collector imposes with stack maps and a JIT imposes with deoptimization state maps, and it is the reason all three appear in the same conversations. The general principle is worth stating on its own: any mechanism that needs to reconstruct source-level or language-level state from a machine state, at a point the compiler did not choose, converts optimizer freedom into a description problem, and the description is what limits the optimizer.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
.eh_frame on ELF, compact unwind on Mach-O with a DWARF fallback, and .pdata/.xdata unwind codes on Windows x64. The callee-saved register set, whether the return address is on the stack or in a link register, and how the canonical frame address is computed all differ between x86-64 System V, AArch64 AAPCS and Windows x64. Only the two-table idea transfers.If you were asked this in an interview
- How does a runtime walk the stack when the optimizer has removed the frame pointers?
- Why does the Itanium ABI unwind in two passes instead of one?
- A function contains no
tryand nothrowbut still generates a landing pad. Why?
Connections
- Programming Languages & Runtime Internals — The unwinder library itself, and the exception object it allocates and ownsEverything in this lesson is what the compiler writes down. The component that reads it — allocating the exception, driving the two phases, calling personality routines and finally transferring control — lives in a runtime library, and its interaction with threads, signals and the allocator is that runtime's subject.
- Testing & Reliability Engineering — Crash reporting, core-dump analysis and symbolication pipelines in productionUnwind tables are the input to every production stack trace, so a decision made in the build — stripping sections, omitting asynchronous tables, shipping assembly without CFI — silently degrades incident response months later. Owning that pipeline is a reliability concern rather than a compiler one.