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/cleanupretand parent tokens, andWinEHPreparecolors and clones blocks until every block belongs to exactly one funclet; WebAssembly EH — no unwinder and no tables:try/catch/delegate/rethroware instructions of a structured control-flow language, the engine unwinds, andWasmEHPreparerewrites the funclet IR so the C++ personality can be called from the handler; EH-aware optimization — provingnounwindbottom-up over the call graph (FunctionAttrs, GCCipa-pure-const), turninginvokeintocalland 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
withintoken names an enclosing pad ornone). - 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 theip2statetable. - 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
catchpadis followed byllvm.wasm.get.exception(token)andllvm.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 byunreachable. - Precondition: every
catchpadhas 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
cgsccadaptor visits it; Lesson 24.1, Algorithm 24.1.7). - Output: the
nounwindattribute 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 %lpadin a caller;callee's body \(C\), possibly with its owninvokes, pads andresumes. - 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
%lpadwith the same exception object; executions that returned reach%okwith the same value. - Invariant: every may-throw call in the copy is an
invokewhose 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__exceptis outlined as a funclet that runs during phase 1, withllvm.eh.exceptionpointerandllvm.localescape/llvm.localrecoverto reach the parent's locals; this is the case the "phase-one unwind" flag inmayThrowexists 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
exnrefproposal (2023–) replacesdelegateandrethrowby first-class exception references (try_table,throw_ref); LLVM 23'sllckeeps 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 sameWasmEHPrepare; onlyCFGStackify's nesting changes. ipa-pure-const'spropagate_nothrow(GCC) is Algorithm 24.6.7 phrased as a dataflow over the call-graph SCCs computed byipa_reduced_postorder; the per-function summary bit iscan_throw, set bystmt_can_throw_external. GCC then lowersRESX(itsresume) intree-eh.cc'slower_resx, and removes handlers no throw can reach inremove_unreachable_handlersand empty ones incleanup_empty_eh.- Inlining and
nounwindinteract both ways (Lesson 24.1): inline first and the caller sees the body, so Algorithm 24.6.7 proves more; infer first and moreinvokes becomecalls, so the inliner's EH rewrite (Algorithm 24.6.8) has less to do. LLVM'scgsccpipeline runsfunction-attrsafter 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:
throwwalks the engine's own frame records to the innermosttrywith 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 fieldcan_throwset fromstmt_can_throw_external;gcc/tree-eh.cc—lower_resx,remove_unreachable_handlers,cleanup_empty_eh, thepass_refactor_ehthat turns nestedtry/finallyintotry/catchchains;gcc/except.cc—finish_eh_generationchoosesdw2_build_landing_padsorsjlj_build_landing_pads(Lesson 11.7's two models) andcollect_one_action_chainbuilds the action table. - Rust has no
nounwindinference problem of this kind at the source level:extern "C"functions arenounwindby declaration, panics unwind throughinvokes to landing pads that run drops (Lesson 24.5's drop elaboration), andpanic=abortdeletes 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). Tagsfunclets,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-tableandnounwind-marked-setare 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), whosenounwindhalf 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.