Loweringtarget

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.

The question

How does the runtime know what to destroy and where to jump when an exception propagates through a frame that never mentions exceptions?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

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.

What this phase may assume or do

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-tables is 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

targetThe recovery rules are entirely target-specific. On x86-64 System V the return address is at a known offset from the canonical frame address and the callee-saved set is rbx, rbp and r12-r15; on AArch64 AAPCS the return address arrives in the link register and may never be spilled at all in a leaf function, and the callee-saved set is x19-x28 plus parts of the vector registers. Windows x64 uses its own unwind-code format in .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.

One throw, from the raise to the handlerimplementation
  1. Throwrun time
    An 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.
  2. Search phase, frame by framerun time
    Program 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.
  3. No handler foundrun time
    The original stack, complete.
    Termination with the raising frames still present for the debugger.
    Nothing — which is the point of not having unwound yet.
  4. Cleanup phase, frame by framerun time
    The 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.
  5. Landing padrun time
    Ordinary 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.

A frame with two constructed objects and one throwing call
  1. u0entryentry
    construct a
    state = 1
    After a is constructed, only a needs destruction.
  2. u1after a
    construct b
    state = 2
    If b's constructor throws, we arrive at the pad with state = 1.
  3. u2body
    invoke may_throw()
    Normal successor plus unwind successor.
  4. u3normal exit
    destroy b
    destroy a
    ret
    Reverse construction order on the normal path.
  5. 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.
Edges
  • u0u1
  • u1u2
  • u1u4ctor throws
  • u2u3normal
  • u2u4unwind
  • u3u3return

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-tables demands 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_frame shrinks 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-pointer argument 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=abort in Rust, -fno-exceptions in 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 defer on the normal path, Rust's ? on Result.

See it for yourself

The flag, dump or tool that shows you this directly.

  • readelf -S shows the size of .eh_frame and .gcc_except_table; comparing a binary built with and without -fno-exceptions quantifies the table cost directly.
  • objdump --dwarf=frames decodes the CFI into readable recovery rules per address range; --dwarf=frames-interp prints the interpreted table, which is much easier to read.
  • clang -S on 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: bt in 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 -c prints the per-method exception table. Go: GOTRACEBACK=system shows 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.

Unwinding is what makes exceptions slow, and it could be optimized away.
It is inherent to the guarantee: cleanups in every intervening frame must run, and finding them requires a walk. Making the walk cheaper is possible; removing it is not, unless the guarantee goes too.
Unwind tables are only needed if you use exceptions.
Profilers, debuggers and crash reporters consume the same tables, which is why they are enabled by default in environments where nothing throws.
The two phases are an implementation detail with no observable effect.
They are why an unhandled exception leaves the stack intact for the debugger. A one-pass unwinder would have destroyed it before discovering there was no handler.

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.

targetUnwind encodings and register-recovery rules are per-target: DWARF CFI in .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.
implementationThe two-phase search-then-cleanup protocol is the Itanium C++ ABI as implemented by GCC and Clang and reused by Rust. MSVC's x64 unwinder uses a different protocol with its own funclet-based handler representation, and the JVM does its own unwinding entirely inside the VM using per-method exception tables rather than the platform unwinder. Statements about phases and personality routines describe the first of these.
typicalMainstream compilers emit landing pads into a cold section and duplicate cleanup code between the normal and exceptional paths, and they maintain a per-scope state value so a partially-constructed scope destroys only what was built. Simpler implementations that always run all cleanups exist and are wrong in exactly the constructor-throws case, which is why that case appears in every conformance test suite.

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 try and no throw but still generates a landing pad. Why?

Connections

Computer Architectureregisters
Domains that do not exist yet
  • Programming Languages & Runtime Internals — The unwinder library itself, and the exception object it allocates and owns
    Everything 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 production
    Unwind 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.