Skip to content

Lesson 24.6 — Exception handling beyond the Itanium model: funclets, WebAssembly EH, EH-aware optimization

Techniques: funclet-based EH — every handler is outlined into its own little function (a funclet) that runs on the throwing frame's stack while the frame is still live; LLVM represents it with catchswitch/catchpad/cleanuppad/catchret/cleanupret and parent tokens, and WinEHPrepare colors and clones blocks until every block belongs to exactly one funclet; WebAssembly EH — no unwinder and no tables: try/catch/delegate/rethrow are instructions of a structured control-flow language, the engine unwinds, and WasmEHPrepare rewrites the funclet IR so the C++ personality can be called from the handler; EH-aware optimization — proving nounwind bottom-up over the call graph (FunctionAttrs, GCC ipa-pure-const), turning invoke into call and deleting unreachable pads (SimplifyCFG), and inlining a function that unwinds into a caller that catches (InlineFunction).

Lesson 11.7 built the table-driven Itanium model into pebblec's sibling design space: an invoke with an unwind edge, a landingpad, a call-site table read by a two-phase unwinder, and the theorem that the happy path costs nothing (Theorem 11.7.3). It also showed the SjLj alternative (Algorithm 11.7.4) and the error-return convention Swift and Rust prefer (Definition 11.7.5). This lesson covers the three things that model leaves out and that any compiler targeting Windows, the browser, or a serious optimizer must add: what to do when the platform's runtime does not successively reset register contexts (funclets), what to do when the platform has no unwinder at all (WebAssembly), and how the optimizer keeps the exceptional edges from freezing everything around them. Pebble itself traps instead of throwing (PIR spec §5, exit code 101), so the lesson is a survey with no lab; every real-world box was produced with the LLVM 23.1.2 in this container, cross-compiling the same 7-line C++ function for x86_64-pc-windows-msvc and wasm32-unknown-unknown.

1. Problem and motivation

Funclet-based EH

The Itanium model's phase 2 unwinds successively: the runtime pops the throwing frame's registers and resumes each landing pad in turn, so a landing pad is ordinary code in its own function, reached by a jump (Algorithm 11.7.2). Windows does not work this way. Its __CxxFrameHandler3 personality runs handlers while the throwing frame is still on the stack: the exception object lives in the thrower's frame, a catch handler is called by the runtime as a function with a pointer to the parent frame, and __try/__except filter expressions must be callable during phase 1. LLVM's documentation [LLVM-EH] states the difference in one sentence: "Itanium EH is designed around the idea of 'successive unwinding,' while Windows EH is not." A handler that is called must be a function: an outlined funclet with its own prologue, sharing the parent's frame through a frame pointer. Nothing in landingpad IR says which blocks belong to which handler, so LLVM added instructions that do, and a pass that recovers the nesting and duplicates any block two handlers share.

WebAssembly EH

WebAssembly has no stack the program can read and no unwinder library that can walk it; the engine owns the stack. The exception-handling proposal [Wasm-EH] therefore adds structured instructions — try … catch tag … end, throw tag, rethrow, delegate — and the engine unwinds to the innermost enclosing try whose catch names the tag. Two consequences shape the compiler: the C++ personality function, which decides which catch clause matches, cannot be called by libunwind during the unwind (there is no libunwind), so it must be called by compiler-generated code after the engine has landed in the catch; and every cleanup frame stops the engine, because the engine cannot skip a frame that has a catch_all. LLVM reuses the funclet IR (catchpad, cleanuppad) with a Wasm personality and a preparation pass, WasmEHPrepare, that inserts the personality call and a small side channel (__wasm_lpad_context) between the generated code and libunwind.

EH-aware optimization

An invoke is a terminator with two successors; a landingpad is a merge point with a hidden incoming edge from the runtime. Both block the simplest optimizations: a function full of invokes cannot be tail-called, its blocks cannot be merged, its calls cannot be reordered across the unwind edge, and its callee bodies inlined through an invoke must have every call rewritten into an invoke. The optimizer's remedy is to prove that nothing throws wherever it can (nounwind, computed bottom-up over the call graph) and to delete the unwind edges that proof makes dead, after which the landingpad block is unreachable and disappears. GCC does the same work in ipa-pure-const (propagate_nothrow) and tree-eh.cc. In the box below, this turns a function with an invoke and a landing pad into a straight-line call.

2. Definitions and algorithms

Funclet-based EH

Definition 24.6.1 (EH pad, funclet, color, parent token)

In a function using a funclet personality, an EH pad is a block whose first non-phi instruction is catchswitch, catchpad, or cleanuppad; it may be the unwind destination of an invoke (or of another pad). catchpad within %cs [args] and cleanuppad within %parent [] produce a token; catchswitch within %parent [handlers] unwind label %next dispatches to its catchpads and does not itself create a funclet. The funclet of a pad \(p\) is the set of blocks that execute between entering \(p\) and leaving it by catchret from %p to label %L, cleanupret from %p unwind ..., or an unwind to the caller. The parent token of a pad is the within operand: none for a pad nested in the function body, another pad's token otherwise. The color \(\mathrm{color}(B)\) of a block \(B\) is the innermost funclet that contains it; the function body is the color none. Every call inside a funclet carries the operand bundle [ "funclet"(token %p) ] so that the back end knows which frame the call belongs to.

Algorithm 24.6.2 (Funclet coloring, after WinEHPrepare::colorFunclets)

  • Input: a function \(F\) with a funclet personality whose pads satisfy the transition rules of [LLVM-EH] ("Funclet transitions").
  • Output: \(\mathrm{colors}(B)\) for every block \(B\): the set of funclets that can reach \(B\) along paths that do not leave them (a block two funclets share gets two colors).
  • Precondition: the IR verifies (every pad's within token names an enclosing pad or none).
  • Postcondition: \(c \in \mathrm{colors}(B)\) iff some path from the entry reaches \(B\) while inside funclet \(c\) (or the body, none) without an intervening exit of \(c\).
  • Invariant: every \((B, c)\) on the worklist is reachable in color \(c\); a pair is processed at most once.
function ColorFunclets(F):
    colors(B) ← ∅ for all B;  W ← [(entry(F), none)]
    while W not empty:
        (B, c) ← pop(W)
        if c ∈ colors(B): continue
        colors(B) ← colors(B) ∪ {c}
        match terminator / first instruction of B:
            B begins with catchpad p or cleanuppad p:
                for S in successors(B): push (S, p)              # inside the new funclet
            B ends with catchret from p to L:
                push (L, parent(p))                             # the funclet is exited
            B ends with cleanupret from p unwind to U:
                push (U, parent(p))
            B ends with catchswitch cs within parent [handlers] unwind U:
                for H in handlers: push (H, cs-color)            # each catchpad block of cs
                push (U, parent)
            otherwise:
                for S in successors(B): push (S, c)

Cost: \(O(|B| \cdot |\text{funclets}|)\): a block is visited once per color it receives.

Algorithm 24.6.3 (Making colors unique, then numbering states, after cloneCommonBlocks and calculateStateNumbersForInvokes)

  • Input: \(F\) and \(\mathrm{colors}\) from Algorithm 24.6.2.
  • Output: \(F'\) in which every block has exactly one color; a state number per pad and per invoke; the unwind map (state → parent state, cleanup action), the try map (state intervals with their handlers) and the ip2state table.
  • Precondition: Algorithm 24.6.2's postcondition.
  • Postcondition: the tables describe exactly the pad nesting of the IR (Theorem 24.6.10).
  • Invariant: cloning never changes the set of paths of any color: a clone \(B_c\) receives exactly the edges that reached \(B\) in color \(c\).
function PrepareFunclets(F):
    repeat:                                                     # cloneCommonBlocks
        ColorFunclets(F)
        for B with |colors(B)| > 1, for each color c beyond the first:
            B_c ← clone(B); redirect the edges reaching B in color c to B_c; fix phis of B and B_c
    until every block has one color
    for I in instructions inside a catchpad funclet:            # removeImplausibleInstructions
        if I is ret, or an unwind to a pad of a different parent: replace I by unreachable
    state ← 0                                                    # calculateStateNumbers*
    walk the pad tree from the root:
        each cleanuppad and each catchswitch handler gets state++;
        a try region's [TryLow, TryHigh] = the states of the pads it contains; CatchHigh = its handlers' last state
        unwindmap[state] ← (parent state or -1, cleanup funclet or none)
    for each invoke I: state(I) ← state of the innermost pad I unwinds to   # calculateStateNumbersForInvokes
    back end: emit each funclet as its own .seh_proc; ip2state[IP(I)] ← state(I) for every call site

Postcondition, concretely: every block has one color; the tables $cppxdata$, $stateUnwindMap$, $tryMap$, $handlerMap$, $ip2state$ are what the box in §7 shows.

WebAssembly EH

Definition 24.6.4 (Structured exception instructions; landing-pad index)

In Wasm EH [Wasm-EH], try opens a block whose body may be followed by catch $tag (bind the tag's payload and run the handler), catch_all, or delegate $l (forward any exception thrown in the body to the try at label depth \(l\), without running any code here); throw $tag raises; rethrow $l re-raises the exception caught by the catch at depth \(l\). Control is structured: a handler cannot be entered except through its try, and the engine, not a library, transfers control. C++ exceptions use one tag, __cpp_exception, whose payload is the pointer to the _Unwind_Exception. Since the engine cannot match C++ catch clauses, each catchpad gets a landing-pad index; the generated code passes it, with the function's LSDA, to a libunwind wrapper (_Unwind_CallPersonality) that calls the real personality, which writes the selector back into the global __wasm_lpad_context.

Algorithm 24.6.5 (WasmEHPrepare: from funclet IR to engine-unwound IR)

  • Input: a function with the Wasm personality and pads prepared by Algorithm 24.6.3 (Wasm uses the same funclet IR); each catchpad is followed by llvm.wasm.get.exception(token) and llvm.wasm.get.ehselector(token).
  • Output: the same function with the intrinsics replaced by wasm.catch, the landing-pad index and LSDA stored into __wasm_lpad_context, a call to _Unwind_CallPersonality, and the selector loaded back; throws followed by unreachable.
  • Precondition: every catchpad has a unique index; the function's LSDA exists.
  • Postcondition: after the engine lands in a catch, the C++ clause matching happens exactly once, in the landing code, and the selector the handler compares is the personality's.
  • Invariant: no pad is added or removed; the CFG is unchanged except inside pad blocks.
function WasmEHPrepare(F):
    for each call to llvm.wasm.throw: insert unreachable after it        # prepareThrows
    i ← 0
    for each catchpad block P (in function order):                        # prepareEHPads
        exn ← llvm.wasm.catch(CPP_EXCEPTION)  replacing get.exception
        if P is a single catch (...): continue                           # no selector needed
        i ← i + 1; llvm.wasm.landingpad.index(i)
        __wasm_lpad_context.lpad_index ← i; __wasm_lpad_context.lsda ← llvm.wasm.lsda()
        _Unwind_CallPersonality(exn)
        selector ← load __wasm_lpad_context.selector  replacing get.ehselector
    for each cleanuppad: it gets catch_all in the back end and no personality call
back end (WebAssemblyCFGStackify): nest funclets into try/catch/end; delegate where an unwind edge
    leaves a try whose handler is not the target; rethrow where Itanium would resume

Cost: linear in the function; no tables except the LSDA (which the personality still reads).

EH-aware optimization

Proposition 24.6.6 (Soundness of nounwind inference over an SCC)

Let \(S\) be a strongly connected component of the call graph, and suppose that for every function \(f \in S\) every instruction \(I\) of \(f\) satisfies: \(I\) cannot unwind, or \(I\) is a direct call to a function \(g \in S\), or \(I\) is a call to a function already marked nounwind. Then no call to any \(f \in S\) ever unwinds, and marking every \(f \in S\) nounwind is sound.

Proof

Suppose some call to \(f_0 \in S\) unwinds. Consider the first instruction in the dynamic execution of that call, including callees, that raises an unwind reaching \(f_0\)'s caller. Its enclosing function \(h\) is either in \(S\) (reached by direct calls from \(f_0\) inside \(S\)) or a nounwind function called from one; in the second case the raise is impossible by the marking (inductive hypothesis on the SCC order: callees are processed first). In the first case \(I\) is in some \(f \in S\) and, by hypothesis, cannot unwind, unless it is a call to some \(g \in S\) — but then the raise originates below \(I\), contradicting the choice of \(I\) as first. Hence no unwind exists. The argument is InstrBreaksNonThrowing in LLVM: an instruction "breaks" the SCC's non-throwing status iff it mayThrow and is not a direct call inside the SCC.

Algorithm 24.6.7 (Bottom-up nounwind inference, after FunctionAttrs's inferAttrsFromFunctionBodies)

  • Input: a module's call graph, in SCC post-order (callees before callers, as the cgscc adaptor visits it; Lesson 24.1, Algorithm 24.1.7).
  • Output: the nounwind attribute on every function of every SCC that provably never lets an exception escape.
  • Precondition: external declarations carry only the attributes their headers declare.
  • Postcondition: every marked function satisfies Proposition 24.6.6's hypothesis; no unmarked function could have been marked from the information in the module.
  • Invariant: when an SCC is scanned, every callee outside it has already been decided.
function InferNoUnwind(CG):
    for S in SCCs of CG in post-order:
        if some f ∈ S is external, or a declaration without nounwind: continue
        ok ← true
        for f ∈ S, for I ∈ instructions(f):
            if I.mayThrow(IncludePhaseOneUnwind = true) and not (I is a direct call to g ∈ S):
                ok ← false                                          # InstrBreaksNonThrowing
        if ok: for f ∈ S: mark f nounwind
consumers:
    simplifycfg: every invoke of a nounwind callee → call + br (removeUnwindEdge);
                 a pad with no predecessors is deleted; landingpad+resume pairs that only
                 re-raise are removed (simplifyCommonResume / simplifySingleResume)

Algorithm 24.6.8 (Inlining through an invoke, after InlineFunction's HandleInlinedLandingPad)

  • Input: invoke @callee(...) to %ok unwind %lpad in a caller; callee's body \(C\), possibly with its own invokes, pads and resumes.
  • Output: the caller with \(C\) copied in place of the invoke, every escaping exception of the copy routed to %lpad.
  • Precondition: the inliner's cost model accepted the call; the personalities agree (or the callee has none).
  • Postcondition: every execution of the caller that unwound out of the callee before now reaches %lpad with the same exception object; executions that returned reach %ok with the same value.
  • Invariant: every may-throw call in the copy is an invoke whose unwind destination is a pad of the caller.
function InlineThroughInvoke(II, C):
    copy C into the caller; each ret in the copy → br %ok (with the returned value)
    for each call K in the copy that may unwind (not nounwind):              # HandleCallsInBlockInlinedThroughInvoke
        split K's block after K; K ← invoke ... to %next unwind %lpad
    for each resume R in the copy: R ← br %lpad, feeding its value into %lpad's landingpad phi
    for each landingpad P of the copy: append the caller's clauses (catch types, cleanup) to P's
    funclet personalities (HandleInlinedEHPad): rewrite the copied pads' parent tokens to the
        caller's pad and every call's "funclet" bundle

Cost: linear in \(|C|\); the value is that after inlining, Algorithm 24.6.7 may prove more, since the body is now visible.

Proposition 24.6.9 (Converting invoke to call for a nounwind callee preserves behavior)

If callee \(g\) is nounwind, then replacing invoke @g(...) to %ok unwind %pad by call @g(...); br %ok preserves the observable behavior of every execution, and every landing pad whose only predecessors were such invokes is unreachable.

Proof

The two forms differ only on executions where the call to \(g\) unwinds; by nounwind there are none (Proposition 24.6.6). On every execution the call returns and control reaches %ok in both forms, with the same value. A pad whose predecessors have all been converted has no CFG predecessor, and the runtime reaches a pad only through the call-site table entry of an invoke, of which none remains.

3. Worked example

The function of the boxes, eh.cpp:

struct Guard { ~Guard(); };
void may_throw(int);
int run(int x) {
  Guard g;
  try { may_throw(x); } catch (int e) { return e; }
  return 0;
}

Under Itanium (Lesson 11.7's box) this is one invoke, one landingpad with a catch clause for int and a cleanup, and resume when the type does not match. Under a funclet personality the IR is the one in §7's first box, and Algorithm 24.6.2 colors it:

Block First/last instruction Color
%1 (entry) invoke @may_throw to %9 unwind %4 none
%4 %5 = catchswitch within none [label %6] unwind label %11 none (a catchswitch is a dispatch point; it does not create a funclet)
%6 %7 = catchpad within %5 [...] … catchret from %7 to label %9 funclet %7
%9 phi [%8, %6], [0, %1]; call ~Guard; ret none (the catchret target takes the parent color)
%11 %12 = cleanuppad within none []; call ~Guard [ "funclet"(token %12) ]; cleanupret from %12 unwind to caller funclet %12

No block has two colors, so step 1 of Algorithm 24.6.3 clones nothing. State numbering: the cleanup pad %12 is state 0 (it runs when the catch does not match: unwind label %11 from the catchswitch), the try is states 1..1 (TryLow=TryHigh=1) with catch state 2 (CatchHigh=2), and the invoke is assigned state 1. The emitted $stateUnwindMap$ in the box reads exactly so: state 0 → ToState -1 with action ?dtor$2 (the cleanup funclet), states 1 and 2 → ToState 0 (a catch that fails, or a throw inside the handler, runs the cleanup). $tryMap$ reads TryLow 1, TryHigh 1, CatchHigh 2, and $handlerMap$ names the funclet ?catch$3 with CatchObjOffset 60: the caught int e lives in the parent's frame, at that offset from the frame pointer the runtime passes to the funclet.

For Wasm (§7, second box) the same IR gets the Wasm personality, and after Algorithm 24.6.5 the catchpad calls llvm.wasm.get.exception/get.ehselector, which WasmEHPrepare rewrites into wasm.catch, the landing-pad index, and the personality call. The assembly's structure is Definition 24.6.4 made visible: an outer try … catch_all (the cleanup: run ~Guard, rethrow 0), an inner try … catch __cpp_exception (the handler: call the personality through the wrapper, compare the selector, __cxa_begin_catch, and either return e or rethrow), and a delegate 3 around __cxa_end_catch so that a throw inside the catch handler skips the handler's own try and goes to the caller.

For EH-aware optimization, §7's third box runs cgscc(function-attrs),function(simplifycfg) on a three-function module: leaf (arithmetic only), caller (invokes leaf), other (invokes an external may_throw). Algorithm 24.6.7 visits the SCC \(\{\)leaf\(\}\) first: no instruction may throw, so leaf becomes nounwind (with nofree norecurse nosync willreturn memory(none) from the same pass). Then \(\{\)caller\(\}\): its only may-throw instruction is the invoke of leaf, now nounwind, so caller is nounwind too. simplifycfg applies Proposition 24.6.9: invoke i32 @leaf becomes %r = call i32 @leaf(i32 %x); ret i32 %r, and the lpad block is deleted. other calls an external declaration without nounwind: InstrBreaksNonThrowing is true, the invoke stays, and simplifycfg only merges the two rets into a common.ret phi. In the clang-compiled version of the same program (the box's second command) the result is identical, call noundef i32 @_Z4leafi.

4. Invariants and correctness

Funclet-based EH

Theorem 24.6.10 (Exact nesting after preparation)

After Algorithm 24.6.3, (i) every block has exactly one color, (ii) every unwind edge goes from a block of color \(c\) to a pad whose parent token is \(c\) (or to a catchswitch whose parent is \(c\)), and (iii) the state assigned to each invoke names the innermost pad that would run if the call unwinds. Hence the tables emitted from the states reproduce the IR's unwind semantics: an exception thrown at an invoke runs exactly the pads the IR's unwind chain names, innermost first, with the parent frame live.

Proof

(i) is the loop condition of cloneCommonBlocks: the pass repeats coloring and cloning until no block has two colors; termination follows because each round strictly reduces the number of (block, extra color) pairs — a clone has exactly one color and removes one from the original — and the number of colors is bounded by the number of pads. (ii) The transition rules of [LLVM-EH] are checked by the IR verifier; removeImplausibleInstructions deletes the unwind edges that cannot be encoded (an unwind from a catchpad funclet to a pad of a different parent). (iii) calculateStateNumbersForInvokes walks from each invoke's unwind destination up the parent chain; since after (i) the invoke's block has one color, its innermost enclosing pad is well defined, and the state of a pad determines, through the unwind map, the chain of cleanups to its parent. The runtime's dispatch (__CxxFrameHandler3) reads ip2state at the faulting IP and follows the unwind map, which by construction is the IR's parent chain.

Invariant: a funclet never rets (it returns to the runtime through catchret/cleanupret), and a value computed in the parent and used in a funclet must be spilled to the frame: registers are not preserved across the runtime's call into the funclet. WinEHPrepare (demotePHIsOnFunclets, insertPHIStores) demotes the phis and values that cross funclet boundaries to stack slots; the CatchObjOffset in the box is such a slot.

WebAssembly EH

Invariant: every C++ catch clause of a Wasm function compiles to a catch __cpp_exception that always matches the tag, followed by a personality call that decides; hence a frame with any handler stops the engine's unwinding even when its clauses do not match, and re-raises with rethrow. This is the cost of having no phase 1: what Theorem 11.7.3 called "free when nothing throws" still holds, but a throw across \(k\) frames with handlers costs \(k\) personality calls, not one search.

EH-aware optimization

Propositions 24.6.6 and 24.6.9 are the invariants. The subtle one is the phase-1 unwind: an instruction may not throw yet still be "seen" by phase 1 (a __try/__except filter runs during the search). That is why InstrBreaksNonThrowing calls mayThrow(/* IncludePhaseOneUnwind */ true): a callee whose filter can run during the search is not nounwind even if it always catches.

5. Complexity

Technique Compile time Run time, no throw Run time, throw Size
Itanium tables (Lesson 11.7) \(O(n)\) 0 phase 1 search + phase 2 \(O(\text{frames})\) call-site table
Funclets $O( B \cdot \text{funclets})$ coloring, cloning up to $ B
Wasm EH \(O(n)\) 0 engine unwind to each frame with a handler; a personality call per such frame LSDA only; no unwind tables
nounwind inference $O( \text{instructions} )$ per SCC, post-order saves the unwind edges

6. Variants and refinements

  • SEH __try/__except/__finally (C, Windows): the filter of __except is outlined as a funclet that runs during phase 1, with llvm.eh.exceptionpointer and llvm.localescape/llvm.localrecover to reach the parent's locals; this is the case the "phase-one unwind" flag in mayThrow exists for [LLVM-EH].
  • Funclets for Itanium. The doc says it plainly: the pad instructions "can be used to represent Itanium EH, [but] the landingpad model is strictly better for optimization purposes" — because a landing pad is ordinary SSA code, while a funclet's blocks cannot be merged with the parent's.
  • Wasm's newer exnref proposal (2023–) replaces delegate and rethrow by first-class exception references (try_table, throw_ref); LLVM 23's llc keeps the older form behind --wasm-use-legacy-eh (the box above, produced by Clang's default -mllvm -wasm-enable-eh, shows the older form). The compiler side is the same WasmEHPrepare; only CFGStackify's nesting changes.
  • ipa-pure-const's propagate_nothrow (GCC) is Algorithm 24.6.7 phrased as a dataflow over the call-graph SCCs computed by ipa_reduced_postorder; the per-function summary bit is can_throw, set by stmt_can_throw_external. GCC then lowers RESX (its resume) in tree-eh.cc's lower_resx, and removes handlers no throw can reach in remove_unreachable_handlers and empty ones in cleanup_empty_eh.
  • Inlining and nounwind interact both ways (Lesson 24.1): inline first and the caller sees the body, so Algorithm 24.6.7 proves more; infer first and more invokes become calls, so the inliner's EH rewrite (Algorithm 24.6.8) has less to do. LLVM's cgscc pipeline runs function-attrs after the inliner in the same SCC visit and re-runs on devirtualization (devirt<4>, Lesson 24.1 §7).

7. In real compilers

Funclet-based EH

The same function for the MSVC ABI: pads in the IR, funclets and tables in the assembly

Reproduce (clang++-23 23.1.2, cross-compiling on x86-64 Linux; eh.cpp is the 7-line function of §3):

clang++-23 --target=x86_64-pc-windows-msvc -O1 -S -emit-llvm eh.cpp -o - | sed -n '/^define.*run/,/^}/p'
clang++-23 --target=x86_64-pc-windows-msvc -O1 -S eh.cpp -o - | grep -n 'seh_proc\|seh_handler \|handlerdata'
clang++-23 --target=x86_64-pc-windows-msvc -O1 -S eh.cpp -o - | sed -n '/^"\$cppxdata\$/,/ParentFrameOffset/p'

Output (the IR of run complete; the assembly abridged to the funclet symbols and the tables):

define dso_local noundef i32 @"?run@@YAHH@Z"(i32 noundef %0) local_unnamed_addr #0 personality ptr @__CxxFrameHandler3 {
  %2 = alloca %struct.Guard, align 1
  %3 = alloca i32, align 4
  call void @llvm.lifetime.start.p0(ptr nonnull %2) #4
  invoke void @"?may_throw@@YAXH@Z"(i32 noundef %0)
          to label %9 unwind label %4

4:                                                ; preds = %1
  %5 = catchswitch within none [label %6] unwind label %11

6:                                                ; preds = %4
  %7 = catchpad within %5 [ptr @"??_R0H@8", i32 0, ptr %3]
  %8 = load i32, ptr %3, align 4
  catchret from %7 to label %9

9:                                                ; preds = %1, %6
  %10 = phi i32 [ %8, %6 ], [ 0, %1 ]
  call void @"??1Guard@@QEAA@XZ"(ptr noundef nonnull align 1 dereferenceable(1) %2) #4
  call void @llvm.lifetime.end.p0(ptr nonnull %2) #4
  ret i32 %10

11:                                               ; preds = %4
  %12 = cleanuppad within none []
  call void @"??1Guard@@QEAA@XZ"(ptr noundef nonnull align 1 dereferenceable(1) %2) #4 [ "funclet"(token %12) ]
  call void @llvm.lifetime.end.p0(ptr nonnull %2) #4
  cleanupret from %12 unwind to caller
}
18:.seh_proc "?run@@YAHH@Z"
19: .seh_handler __CxxFrameHandler3, @unwind, @except
44: .seh_handlerdata
54:.seh_proc "?dtor$2@?0??run@@YAHH@Z@4HA"
71: .seh_handlerdata
80:.seh_proc "?catch$3@?0??run@@YAHH@Z@4HA"
81: .seh_handler __CxxFrameHandler3, @unwind, @except
99: .seh_handlerdata
"$cppxdata$?run@@YAHH@Z":
    .long   429065506                       # MagicNumber
    .long   3                               # MaxState
    .long   ("$stateUnwindMap$?run@@YAHH@Z")@IMGREL # UnwindMap
    .long   1                               # NumTryBlocks
    .long   ("$tryMap$?run@@YAHH@Z")@IMGREL # TryBlockMap
    .long   4                               # IPMapEntries
    .long   ("$ip2state$?run@@YAHH@Z")@IMGREL # IPToStateXData
    .long   48                              # UnwindHelp
    .long   0                               # ESTypeList
    .long   1                               # EHFlags
"$stateUnwindMap$?run@@YAHH@Z":
    .long   -1                              # ToState
    .long   "?dtor$2@?0??run@@YAHH@Z@4HA"@IMGREL # Action
    .long   0                               # ToState
    .long   0                               # Action
    .long   0                               # ToState
    .long   0                               # Action
"$tryMap$?run@@YAHH@Z":
    .long   1                               # TryLow
    .long   1                               # TryHigh
    .long   2                               # CatchHigh
    .long   1                               # NumCatches
    .long   ("$handlerMap$0$?run@@YAHH@Z")@IMGREL # HandlerArray
"$handlerMap$0$?run@@YAHH@Z":
    .long   0                               # Adjectives
    .long   "??_R0H@8"@IMGREL               # Type
    .long   60                              # CatchObjOffset
    .long   "?catch$3@?0??run@@YAHH@Z@4HA"@IMGREL # Handler
    .long   56                              # ParentFrameOffset

What to notice: three .seh_procs for one C++ function: the body, the cleanup funclet ?dtor$2 (state 0's action) and the catch funclet ?catch$3 (the handler's Handler). The invoke's unwind label %4 is a catchswitch with unwind label %11: a catch that fails to match continues to the cleanup, which is the ToState 0 entries for states 1 and 2. ParentFrameOffset 56 is how the funclet, called by the runtime, finds the parent's frame; UnwindHelp 48 is the slot the runtime uses to record progress so that a second exception during the cleanup does not rerun it.

LLVM 23.1.2

llvm/lib/CodeGen/WinEHPrepare.cpp — WinEHPrepareImpl::colorFunclets (Algorithm 24.6.2), cloneCommonBlocks, removeImplausibleInstructions, calculateStateNumbersForInvokes (Algorithm 24.6.3), demotePHIsOnFunclets (the spills). llvm/docs/ExceptionHandling.rst, "Exception Handling using the Windows Runtime" [LLVM-EH]: the rules quoted above. llvm/lib/CodeGen/AsmPrinter/WinException.cpp emits the $cppxdata$ family.

Find where LLVM does it. In llvm/lib/CodeGen/WinEHPrepare.cpp, find WinEHPrepareImpl::cloneCommonBlocks. Question: what condition on a block's color set triggers cloning, and after cloning which of the two copies keeps the original's name?

WebAssembly EH

The same function for wasm32: funclet IR with a Wasm personality, and structured try/catch in the assembly

Reproduce (clang++-23 23.1.2, cross-compiling on x86-64 Linux; same eh.cpp):

clang++-23 --target=wasm32-unknown-unknown -fwasm-exceptions -O1 -S -emit-llvm eh.cpp -o - | grep -n 'catchswitch\|catchpad\|cleanuppad\|catchret\|cleanupret\|invoke\|personality\|wasm'
clang++-23 --target=wasm32-unknown-unknown -fwasm-exceptions -O1 -S eh.cpp -o - | grep -n 'try\|catch\|throw\|delegate\|end_try'

Output (complete for both greps; the three .functype lines are the declarations the second grep also matches):

4:target triple = "wasm32-unknown-unknown"
11:define hidden noundef i32 @_Z3runi(i32 noundef %0) local_unnamed_addr #0 personality ptr @__gxx_wasm_personality_v0 {
14:  invoke void @_Z9may_throwi(i32 noundef %0)
18:  %4 = catchswitch within none [label %5] unwind label %18
21:  %6 = catchpad within %4 [ptr @_ZTIi]
22:  %7 = tail call ptr @llvm.wasm.get.exception(token %6)
23:  %8 = tail call i32 @llvm.wasm.get.ehselector(token %6)
32:  catchret from %6 to label %15
35:  invoke void @llvm.wasm.rethrow() #5 [ "funclet"(token %6) ]
45:  %19 = cleanuppad within none []
48:  cleanupret from %19 unwind to caller
59:declare i32 @__gxx_wasm_personality_v0(...)
62:declare ptr @llvm.wasm.get.exception(token) #3
65:declare i32 @llvm.wasm.get.ehselector(token) #3
78:declare void @llvm.wasm.rethrow() #5
5:  .functype   _Z9may_throwi (i32) -> ()
7:  .functype   __cxa_begin_catch (i32) -> (i32)
8:  .functype   __cxa_end_catch () -> ()
25: try
26: try
29: call    _Z9may_throwi
35: catch       __cpp_exception                 # catch1:
56: call    __cxa_begin_catch
59: try
60: call    __cxa_end_catch
62: delegate     3                      # label/catch3: to caller
69: rethrow     0                               # down to catch0
71: end_try                                 # label1:
73: catch_all                               # catch0:
81: rethrow     0                               # to caller
83: end_try                                 # label0:

What to notice: the IR is the funclet IR of the previous box with a different personality and two intrinsics after the catchpad, which is where Algorithm 24.6.5 inserts the personality call and the __wasm_lpad_context traffic (invisible in -emit-llvm output because WasmEHPrepare runs in the code-generation pipeline). The assembly has no tables: two nested trys (the cleanup's catch_all outside, the handler's catch __cpp_exception inside), rethrow 0 where Itanium would resume, and delegate 3 around __cxa_end_catch so that an exception thrown while ending the catch bypasses the enclosing handlers and goes "to caller".

LLVM 23.1.2

llvm/lib/CodeGen/WasmEHPrepare.cpp — the header comment (the "Before/After" IR and the _Unwind_LandingPadContext struct quoted in Definition 24.6.4), WasmEHPrepareImpl::prepareThrows, prepareEHPads, prepareEHPad (Algorithm 24.6.5), the __wasm_lpad_context global (LPadContextGV); llvm/lib/Target/WebAssembly/WebAssemblyCFGStackify.cpp nests the try/delegate blocks.

  • Engines (V8, SpiderMonkey, Wasmtime) implement the other side: throw walks the engine's own frame records to the innermost try with a matching tag, the part libunwind does under Itanium; the host language's own handlers are never involved.

Find where LLVM does it. In llvm/lib/CodeGen/WasmEHPrepare.cpp, find WasmEHPrepareImpl::prepareEHPad. Question: for which kind of pad does the pass skip the personality call, and what wrapper function in libunwind is called otherwise?

EH-aware optimization

nounwind inference removes the invoke and the landing pad; an external callee keeps them

Reproduce (opt and clang 23.1.2, x86-64; nounwind.ll is the hand-written module below, nounwind.cpp the C++ version: leaf, caller with try { return leaf(x); } catch (...), other calling an extern may_throw):

cat nounwind.ll
opt -passes='cgscc(function-attrs),function(simplifycfg)' -S nounwind.ll | grep -v '^;\|^$'
clang++-23 -O0 -Xclang -disable-O0-optnone -S -emit-llvm nounwind.cpp -o - | opt -passes='cgscc(function-attrs),function(simplifycfg)' -S | grep -n 'define\|invoke\|call .*leaf'

Output (the input and the transformed module complete; the clang run abridged to the greps):

declare i32 @__gxx_personality_v0(...)
declare void @may_throw()

define i32 @leaf(i32 %x) {
  %r = mul i32 %x, 2
  ret i32 %r
}

define i32 @caller(i32 %x) personality ptr @__gxx_personality_v0 {
entry:
  %r = invoke i32 @leaf(i32 %x) to label %ok unwind label %lpad
ok:
  ret i32 %r
lpad:
  %lp = landingpad { ptr, i32 } catch ptr null
  ret i32 -1
}

define i32 @other(i32 %x) personality ptr @__gxx_personality_v0 {
entry:
  invoke void @may_throw() to label %ok unwind label %lpad
ok:
  ret i32 1
lpad:
  %lp = landingpad { ptr, i32 } catch ptr null
  ret i32 -1
}
declare i32 @__gxx_personality_v0(...)
declare void @may_throw()
define i32 @leaf(i32 %x) #0 {
  %r = mul i32 %x, 2
  ret i32 %r
}
define i32 @caller(i32 %x) #0 personality ptr @__gxx_personality_v0 {
entry:
  %r = call i32 @leaf(i32 %x)
  ret i32 %r
}
define noundef i32 @other(i32 %x) #1 personality ptr @__gxx_personality_v0 {
entry:
  invoke void @may_throw()
          to label %common.ret unwind label %lpad
common.ret:                                       ; preds = %entry, %lpad
  %common.ret.op = phi i32 [ -1, %lpad ], [ 1, %entry ]
  ret i32 %common.ret.op
lpad:                                             ; preds = %entry
  %lp = landingpad { ptr, i32 }
          catch ptr null
  br label %common.ret
}
attributes #0 = { mustprogress nofree norecurse nosync nounwind willreturn memory(none) }
attributes #1 = { nounwind }
7:define dso_local noundef i32 @_Z4leafi(i32 noundef %0) #0 {
16:define dso_local noundef i32 @_Z6calleri(i32 noundef %0) #0 {
20:  %4 = call noundef i32 @_Z4leafi(i32 noundef %3)
25:define dso_local noundef i32 @_Z5otheri(i32 noundef %0) #1 personality ptr @__gxx_personality_v0 {
32:  invoke void @_Z9may_throwi(i32 noundef %6)

What to notice: caller lost its invoke, its landing pad and its second ret; other kept the invoke because may_throw is a declaration without nounwind (InstrBreaksNonThrowing returns true for it). other is itself marked nounwind (#1): its landing pad catches everything (catch ptr null) and never resumes, so no exception escapes — the SCC scan found no instruction that can unwind out of other. common.ret is simplifycfg's return merging, unrelated to EH.

LLVM 23.1.2

llvm/lib/Transforms/IPO/FunctionAttrs.cpp — InstrBreaksNonThrowing (the check of Proposition 24.6.6, with mayThrow(/* IncludePhaseOneUnwind */ true)), inferAttrsFromFunctionBodies (Algorithm 24.6.7; the NumNoUnwind statistic counts successes; -disable-nounwind-inference turns it off); llvm/lib/Transforms/Utils/SimplifyCFG.cpp — removeUnwindEdge, simplifyUnreachable, simplifyCommonResume, simplifySingleResume; llvm/lib/Transforms/Utils/InlineFunction.cpp — HandleInlinedLandingPad, HandleInlinedEHPad, HandleCallsInBlockInlinedThroughInvoke (Algorithm 24.6.8).

  • GCC 15 gcc/ipa-pure-const.cc — propagate_nothrow, the summary field can_throw set from stmt_can_throw_external; gcc/tree-eh.cc — lower_resx, remove_unreachable_handlers, cleanup_empty_eh, the pass_refactor_eh that turns nested try/finally into try/catch chains; gcc/except.cc — finish_eh_generation chooses dw2_build_landing_pads or sjlj_build_landing_pads (Lesson 11.7's two models) and collect_one_action_chain builds the action table.
  • Rust has no nounwind inference problem of this kind at the source level: extern "C" functions are nounwind by declaration, panics unwind through invokes to landing pads that run drops (Lesson 24.5's drop elaboration), and panic=abort deletes every unwind edge at once.

Find where LLVM does it. In llvm/lib/Transforms/IPO/FunctionAttrs.cpp, find InstrBreaksNonThrowing. Question: which flag does it pass to Instruction::mayThrow, and what kind of call does it not count as breaking the SCC's non-throwing status? (Quiz find-instr-breaks-nonthrowing.)

8. Comparison

Technique Power / precision Speed Output / error quality Implementation effort Typical use
Itanium tables (11.7) landing pads as ordinary code; two-phase search zero cost, no throw; \(O(\text{frames})\) search best for optimization (landingpad is SSA code) moderate: tables, personality ELF/Mach-O C++, Rust, Swift errors are separate
Funclet EH handlers as outlined functions with their own frames; exact nesting (Theorem 24.6.10) zero cost on the happy path; a funclet call per handler Windows SEH interop (__CxxFrameHandler3, filters) high: WinEHPrepare coloring and outlining MSVC ABI, Clang on Windows
WebAssembly EH structured try/catch instructions; no unwinder tables zero cost; the engine unwinds; a personality call per handler frame portable across engines moderate: WasmEHPrepare Emscripten, wasm32 targets
EH-aware optimization nounwind inference removes unwind edges (Proposition 24.6.6) cheap analyses (one SCC scan) smaller code, more inlining low LLVM FunctionAttrs, GCC ipa-pure-const

The rule that sums up the table: choose the representation the platform's runtime imposes (tables, funclets, structured instructions), then spend the optimizer's effort on proving that the exceptional edges are dead, because every one removed makes the function look like Pebble again: straight-line calls that return.

9. Assessment

  • Quiz: funclet-color-table (mapping, Algorithm 24.6.2 on the worked example), unwind-map-states (mapping), wasm-try-structure (sequence), wasm-personality-call (single), nounwind-marked-set (set), invoke-to-call-witness (single), find-instr-breaks-nonthrowing (text). Tags funclets, wasm-eh, eh-optimization.
  • Drills: none. A coloring drill would need a random funclet-IR generator that respects the transition rules of [LLVM-EH], which is more machinery than the technique warrants for a survey lesson; the quiz's funclet-color-table and nounwind-marked-set are computed by hand on fixed instances, and Lesson 11.7 covers the table model on which both build.
  • Flashcards: tags funclets, wasm-eh, eh-optimization.
  • Exercises: none: Pebble traps and does not unwind. E1's pipeline runs pebble-funcattrs (Ch 20), whose nounwind half is Algorithm 24.6.7 restricted to a language where nothing throws.

Pitfall

"A function that catches everything is not nounwind." It is, if nothing escapes: other in the box carries nounwind although it contains an invoke. The attribute is about what leaves the function, not what happens inside it. The converse mistake is worse: marking a callee nounwind because you never throw through it, then linking against a library that does; the unwinder finds no call-site entry and calls std::terminate.

References

See the chapter references.