Loweringimplementation

Exception Handling

At the source level, a non-local jump out of an arbitrary depth of calls. At the implementation level, a choice between paying nothing until a throw and looking the answer up in a table, or paying a little on every entry and jumping straight there.

The question

What does try/catch compile to, and is it true that exceptions cost nothing until one is thrown?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

Before: a function body with try, catch and throw in it. After: ordinary code, plus two artifacts that are not code — a per-function *unwind table* describing how to restore the caller's state, and a *language-specific data area* mapping each code range to the cleanups and handlers that apply in it. The tables exist to answer a question the instruction stream cannot: given that we are at this address and something has gone wrong, what must be destroyed and where does control go.

What this phase may assume or do

A throw must transfer control to the nearest dynamically enclosing handler whose type matches, after running every cleanup — destructors, finally blocks, defers — for every frame between, in reverse order of construction. A compiler may reorder or delete code around a try only if it preserves that set and that order: an optimization that sinks a destructor past a call which may throw changes which cleanups run. On the non-throwing path a compiler may assume nothing throws only where it can prove it, which is why noexcept and -fno-exceptions change the generated code rather than merely the diagnostics.

Key points

  • A throw targets a handler chosen by the dynamic call chain, which is why the target cannot be resolved at compile time.
  • Every frame in between must run its cleanups in reverse construction order, even though those functions neither throw nor catch.
  • SJLJ pays instructions on every try entry and throws cheaply; table-driven pays nothing on entry and throws expensively.
  • Table-driven implementations emit no code at try entry and instead emit tables indexed by return address, plus landing pads in a cold section.
  • In LLVM IR the distinction is visible: a call inside a try is an invoke with an unwind successor, which is a real CFG edge.
  • "Zero-cost" refers to the non-throwing instruction stream only — tables cost size, the unwind edge costs optimization, and a throw is genuinely slow.
  • noexcept and -fno-exceptions change generated code, because they remove edges and tables rather than merely changing what is allowed.
  • Go defers, Java exception tables and Rust panics are the same design applied at different levels.

What the source construct promises

A throw is a jump, but not a jump the compiler can resolve. Its target depends on the dynamic call chain: which handler catches depends on who called this function, which is not knowable when the function is compiled. That single fact is what makes exceptions an implementation problem rather than a syntax problem — everything else follows from needing to answer a question at run time that is normally answered at compile time.

The second promise is cleanup. Every frame between the throw and the handler is destroyed, and everything with a destructor or a finally in those frames must run, in reverse construction order, before the handler executes. A mechanism that only jumped would leak every file handle, lock and buffer in between.

The third, easy to overlook, is that the intervening functions did not agree to any of this. A function that neither throws nor catches still has to have its locals cleaned up when an exception passes through it. So the obligation is not on the try and the throw; it is on every function in the program, which is why exceptions affect code generation everywhere and not just where they are mentioned.

Two implementations, opposite bills

implementationThe names matter when reading a toolchain. GCC and Clang on ELF and Mach-O targets implement the Itanium C++ ABI: .eh_frame for unwinding, .gcc_except_table for the language data, __gxx_personality_v0 as the personality routine. MSVC on x64 uses table-driven unwinding with its own format (.pdata/.xdata), while 32-bit x86 MSVC used SEH with a linked list pushed at frame entry — a structure much closer to SJLJ. Rust panics use the same platform unwinder as C++ unless built with panic=abort. Java exception tables are per-method bytecode-range tables consulted by the VM: the same design, one level up.

The old approach — setjmp/longjmp, or SJLJ — makes the dynamic chain explicit at run time. Entering a try pushes a record onto a thread-local list saying "if something is thrown here, come back to this point"; leaving pops it. A throw walks the list, finds the innermost matching entry, and does a longjmp to it. Finding the handler is then trivial, because the list is exactly the answer.

The cost is that every try entry and exit executes instructions, whether or not anything is thrown, and the saved context inhibits optimization around it: values must be in memory rather than registers at the point where a longjmp could land. A program that never throws pays on every entry to every guarded region, forever.

The modern approach — table-driven, often called zero-cost — inverts this. Nothing is emitted at try entry at all. Instead the compiler records, in a separate section of the binary, a table saying for each range of instruction addresses: which cleanups are live, and which handlers apply. When a throw happens, the runtime walks the physical stack, and for each return address it looks the address up in the tables to discover what to do. The information that SJLJ maintained at run time is precomputed at compile time and indexed by program counter.

The trade is exact and worth memorising: SJLJ pays on entry and throws cheaply; table-driven pays nothing on entry and throws expensively, because a throw now involves a table lookup and an indirect call per frame. Which is better depends entirely on whether exceptions are exceptional — and the fact that mainstream toolchains all chose the second is a statement about how they expect the feature to be used.

The two mechanisms, and who uses whichimplementation
setjmp/longjmp (SJLJ)Table-driven ("zero-cost")
Cost on entering a tryPush a context record; save registers to memoryNone — no instructions are emitted
Cost of a throwWalk a short list, longjmp — fast and roughly constant per framePer frame: look up the return address in a table, consult the personality routine, run cleanups
Effect on non-throwing codeValues pinned to memory across the guarded region; optimization inhibitedAlmost none; calls gain an edge to a landing pad which slightly constrains the CFG
Binary sizeSmall tables, more codeNo extra code, substantial read-only tables
Where it is usedimplementationOlder GCC targets, some embedded toolchains, 32-bit MinGW historicallyItanium C++ ABI: GCC and Clang on Linux, macOS and 64-bit Windows targets; MSVC x64; Rust panics; Java exception tables are the same idea in a VM

What the compiler actually emits

Under the table-driven scheme the generated code for a try block looks almost exactly like the code without it. The difference is in the CFG: every call inside a try region becomes a two-way branch — normal return, or *unwind* to a landing pad. The landing pad is real code, emitted in a cold section, containing the cleanups for that region and the dispatch to the matching catch.

LLVM makes this explicit in the IR, which is the clearest way to see it. An ordinary call is call; a call inside a try is invoke, which names two successor blocks. The landing pad block starts with a landingpad instruction listing the types it is prepared to catch. Nothing about this costs anything on the normal path — the invoke compiles to the same call instruction — but the CFG edge is there, and the optimizer has to respect it.

That edge is where the "zero-cost" claim gets its asterisks. The optimizer cannot move a side effect across a call that may throw, because there is now a path out at that point. It cannot always keep a value in a register across it, because the landing pad may need it. And every one of those calls contributes an entry to the tables, which is why binaries with exceptions enabled are meaningfully larger even when nothing ever throws.

The same call, outside and inside a try region
No exception region
%r = call i32 @might_throw(ptr %buf)
call void @use(i32 %r)
call void @Buffer_dtor(ptr %buf)
ret void
Inside a try, with a local needing destruction
%r = invoke i32 @might_throw(ptr %buf)
to label %normal unwind label %pad
normal:
call void @use(i32 %r)
call void @Buffer_dtor(ptr %buf)
ret void
pad: ; cold section
%e = landingpad { ptr, i32 } catch ptr @typeinfo_for_IOError
call void @Buffer_dtor(ptr %buf) ; cleanup runs on this path too
br label %handler

Read it asThe instruction that executes on the normal path is identical — invoke and call emit the same call. What changed is that the block has two successors, so Buffer_dtor is emitted twice: once on the normal path and once in the landing pad. That duplication is where the code-size cost lives, and the extra CFG edge is what constrains the optimizer. Nothing here executes unless a throw happens, which is the accurate version of "zero-cost".

The honest accounting

"Zero-cost exceptions cost nothing" is true of the instruction stream on the non-throwing path and false of everything else. Three costs remain, and all three are measurable.

Binary size. The unwind tables and the language-specific data areas are real bytes in the binary, and for C++ code they are frequently in the range of ten to twenty percent of the text they describe. Cleanup code duplicated into landing pads adds more. This is why -fno-exceptions is a size optimization on embedded targets, and why it is a hard requirement in some of them.

Optimization. Every potentially-throwing call is a CFG edge out of the block, which limits code motion, forces some values to be reloadable, and complicates every analysis that walks the graph. Marking a function noexcept removes the edge and measurably improves the code around it — which is exactly why the annotation exists and why the standard library is full of it.

And the throw itself, which is not fast. Unwinding one frame means finding the frame's entry in a sorted table, decoding CFI to compute the caller's registers, calling the personality routine, and — under the Itanium ABI — doing all of it twice, once in a search phase to find the handler and again in a cleanup phase to run the destructors. Costs in the tens of microseconds for a deep stack are unremarkable. That is completely acceptable for an error path and catastrophic as a control-flow mechanism, which is the entire content of the advice not to use exceptions for expected outcomes.

  • Non-throwing path: the same instructions as code without exceptions.
  • Binary: unwind tables plus duplicated cleanup code, often a double-digit percentage of text size.
  • Optimizer: an extra CFG edge per potentially-throwing call, removable with noexcept.
  • Throw: two stack walks under the Itanium ABI, a table lookup and a personality call per frame.
  • Interop: unwinding through a frame compiled without tables is undefined, which is what makes C callbacks between C++ frames hazardous.

What other languages do with the same machinery

Java compiles try/catch to a per-method exception table: ranges of bytecode indices, a handler index, and a type. The JIT then turns that into the same kind of address-indexed table a native compiler would emit. finally was historically compiled by duplicating the block into every exit path — including the exceptional one — which is why a finally in a method with many returns produces more bytecode than it looks like it should.

Go has no exceptions and has panic/recover with defer, which needs the same mechanism for the same reason: a panic must run every deferred call on the way out. Early Go implementations kept a linked list of deferred calls at run time, closer to the SJLJ shape; later versions moved to open-coded defers with a bitmask and a table, which is the table-driven design applied to cleanups rather than handlers.

Rust panics unwind through the platform unwinder, running Drop for everything in between, unless the program is built with panic=abort — in which case the tables disappear, the binary shrinks, and a panic terminates the process. That the choice is a build flag rather than a language property is the clearest available demonstration that exception handling is an implementation strategy with a bill attached.

And error-code returns, the alternative that never went away, put the same information in the normal path: every call site tests, every function threads the failure outward, and nothing is hidden. It costs a branch per call on the happy path and it costs nothing in tables — the exact inverse of the table-driven trade, which is why the argument between the two has lasted forty years.

How it works

The steps, in the order the compiler takes them.

  • The frontend marks every call that may throw and every scope whose locals require destruction, producing a region structure over the function body.
  • Code generation emits each potentially-throwing call with two successors: the normal continuation and a landing pad for its enclosing region.
  • Landing pads are emitted into a cold section and contain the cleanups for their region, followed by a comparison of the thrown type against the handlers that apply.
  • The compiler emits per-function unwind information — how to restore the caller's registers and stack pointer from any address in the function — into a dedicated section.
  • It also emits a language-specific data area mapping address ranges to their landing pad and to the list of types each handler accepts.
  • A throw allocates the exception object and calls the runtime's raise routine, which walks the stack using the unwind information.
  • For each frame the runtime calls the personality routine, which consults the language data area to decide whether this frame handles the exception or merely needs cleanup.
  • Under the Itanium ABI the walk happens twice: a search phase that finds the handling frame without changing anything, then a cleanup phase that unwinds and runs each frame's cleanups down to it.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A destructor or finally does not run and a lock stays held or a file stays open, because an exception propagated through a frame compiled without unwind information — typically a C frame between two C++ frames.
  • A program built with -fno-exceptions links against a library that throws, and the throw terminates the process instead of being caught, with a message about no handler rather than about the actual error.
  • A binary is unexpectedly large on an embedded target, and the size is in read-only sections nobody recognises — the unwind tables and language data areas.
  • A hot path using exceptions for expected outcomes is orders of magnitude slower than the equivalent with return codes, and the profile attributes the time to the unwinder rather than to any application function.
  • A destructor throws while an exception is already propagating, and the program terminates immediately with no indication of the original error.
  • Performance improves noticeably after adding noexcept to a small function, which is surprising until you realise the annotation removed a CFG edge from every call site.
  • A stack trace captured in a catch block is empty or shallow, because the search phase found the handler and the cleanup phase unwound past the frames the trace needed.

When it helps

  • Errors that must not be ignored and are rare: the throw cannot be silently dropped the way a returned code can.
  • Deep call chains where the failure originates far from where it can be handled, and threading a result through every intermediate signature would dominate the code.
  • Constructors and operators, which have no return channel for an error and therefore have no alternative.
  • Guaranteeing cleanup on every exit path, which the same machinery provides regardless of how the scope is left.

When it hurts

  • On any path where the exceptional case is expected rather than exceptional, where unwinding cost becomes throughput cost.
  • On size-constrained targets, where the tables are a fixed tax that cannot be optimized away without disabling the feature.
  • Across language boundaries, where unwinding through frames that carry no tables is undefined behavior rather than an error.
  • In code where the set of possible failures matters to the reader, since a throw is invisible at the call site in most languages that have it.

What it costs

Every one of these is paid by something.

  • Table-driven handling buys an untouched non-throwing instruction stream and pays in binary size — tables plus duplicated cleanup code — and in a throw that costs a stack walk plus a table lookup and personality call per frame.
  • SJLJ buys a cheap, predictable throw and pays instructions at every guarded region entry and exit plus inhibited optimization across them, on every execution whether or not anything is thrown.
  • Exceptions in general buy invisible error propagation through intermediate frames, and pay exactly that invisibility: a call site does not show what it can throw, so the reader loses the information the return channel would have carried.
  • Disabling exceptions buys the size and the removed CFG edges and pays the ability to report failure from constructors, operators and deep chains, forcing a different error design across the whole codebase rather than in one place.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Error codes or result types returned in the normal channel — Go, Rust, C. The cost moves to the happy path as a branch per call and to every signature, and the benefit is that the failure set is visible in the type.
  • Checked exceptions, as in Java, which put the throw set back into the signature. This recovers the visibility and is widely disliked because the annotation burden falls on every intermediate function.
  • Terminate on error — panic=abort, or a fatal assertion. Correct where no recovery is meaningful, and it removes the entire mechanism from the binary.
  • Effect systems, where the ability to fail is part of the type and the compiler tracks it structurally — see [[effect-systems]]. Strictly more informative, and it requires the whole language to be designed around it.

See it for yourself

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

  • Look at the sections: readelf -S binary | grep -E "eh_frame|gcc_except" on ELF shows the unwind tables and their size, which is the size cost made concrete. objdump --dwarf=frames decodes the CFI.
  • LLVM IR: clang -S -emit-llvm on a function with a try shows invoke and landingpad directly. Compile the same file with -fno-exceptions and diff — every invoke becomes a call.
  • Compiler Explorer with -fno-exceptions versus without, on a function containing an object with a destructor, shows the cold landing-pad section appearing and disappearing.
  • Java: javap -c prints the exception table for each method — start pc, end pc, handler pc, catch type — which is the same structure the native tables carry.
  • Go: go build -gcflags=-m reports open-coded defers; GOTRACEBACK=system shows the runtime frames a panic unwinds through.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "Zero-cost exceptions are free." Free on the non-throwing instruction stream. The tables, the duplicated cleanups and the constrained optimizer are all real, and the throw is slow.
  • "try/catch adds a branch at runtime." Under the table-driven scheme it adds no instructions at all. It adds a CFG edge and a table entry.
  • "Exceptions are slow, so avoid them." Throwing is slow; having them costs size and some optimization. Using them for genuinely rare failures is usually the right call, and using them for control flow is not.
  • "The compiler knows what a function can throw." In C++ it does not unless you tell it. That information lives in the source in Java and in the type in Rust, and nowhere at all in the default C++ case.
  • "An exception passing through my function does not affect it." It does: your locals must be destroyed, so your function carries tables and landing pads even though it never mentions exceptions.

Misconceptions

The claim, and what is actually true.

Entering a try block costs something at run time.
Under table-driven handling no instructions are emitted for entering a try. The cost was moved to compile time and to the binary.
Only functions with try or throw are affected.
Every function that can have an exception pass through it needs unwind information and cleanup code, which is nearly all of them.
Exceptions and error codes are the same thing with different syntax.
They put the cost in opposite places: error codes charge the happy path a branch per call and make the failure set visible; exceptions charge the binary and the failure path and make it invisible.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

When code throws, control has to reach a handler that might be many function calls above it, and everything in between has to be cleaned up on the way. Modern compilers arrange this by emitting no extra instructions at all, and instead writing a table into the program that says, for each range of addresses, what to clean up and which handler applies. Nothing is consulted until something is thrown; when it is, the runtime walks the stack reading those tables.

practical

Three things worth knowing. Throwing is slow — tens of microseconds for a deep stack is normal — so exceptions belong on paths that are genuinely rare, and a profile that shows time in an unwinder means something is throwing in a loop. Exceptions cost binary size even when nothing throws, which is why embedded builds disable them and why doing so is a real size win. And marking small functions noexcept is not a documentation gesture: it removes a control-flow edge from every call site and can measurably improve the surrounding code.

advanced

The design is best read as a bet about frequency, and it is instructive because the same bet appears elsewhere. Table-driven unwinding precomputes at compile time an answer that SJLJ maintained at run time, indexes it by program counter, and accepts a much more expensive lookup in exchange for removing the maintenance entirely. That is exactly the trade made by garbage-collection stack maps, by deoptimization state maps in a JIT, and by the per-state cleanup tables an async state machine needs when it is dropped while suspended — all of them are "record it in a side table indexed by address, so the fast path carries nothing". Once you see the shape, the constraint it imposes becomes predictable too: the compiler must be able to describe the machine state at every point where the table might be consulted, which is a real limit on how far values can be kept in registers and how aggressively code can be moved. Zero-cost is therefore never quite zero — it is a cost paid in the optimizer's freedom rather than in instructions.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

implementationGCC and Clang on ELF and Mach-O targets use the Itanium C++ ABI with .eh_frame and .gcc_except_table and a two-phase unwind. MSVC x64 uses its own table format in .pdata/.xdata; 32-bit x86 MSVC used SEH with a run-time linked list, much closer to SJLJ. Some GCC configurations still support an SJLJ mode. A statement about what try costs is a statement about one toolchain on one target with one set of flags.
targetUnwinding depends on target-specific register-restore rules encoded as CFI, so which registers are callee-saved, where the return address lives, and how the stack pointer is recovered differ between x86-64 System V, AArch64 AAPCS and Windows x64. Unwinding through a frame built without tables — a hand-written assembly routine, a C library compiled without -fasynchronous-unwind-tables, or a JIT frame with no registered information — is undefined on all of them.
typicalMainstream C++ toolchains enable exceptions by default and emit unwind tables even for functions that never throw, because an exception may pass through them. Embedded and kernel toolchains commonly disable them, and mixing the two in one binary is where the "no handler, terminating" failure comes from. Rust defaults to unwinding panics and switches to abort with a build profile setting, so the same crate ships with and without tables depending on how it was built.

If you were asked this in an interview

  • What does a try block cost at run time in a modern C++ toolchain, and what does it cost elsewhere?
  • Why does adding noexcept to a function sometimes make the code around its call sites faster?
  • An exception passes through a C function between two C++ frames. What can go wrong?

Connections

Performanceflame-graphs
Domains that do not exist yet
  • Programming Languages & Runtime Internals — The runtime unwinder that consumes the tables the compiler emits
    The compiler produces unwind information and landing pads and then stops. The library that walks the stack, calls personality routines, allocates the exception object and manages the two-phase protocol is a runtime component, and a mismatch between what the compiler emitted and what the unwinder expects is undefined behavior rather than an error.