Lesson 11.7 — Exception handling (overview): zero-cost tables, setjmp/longjmp, explicit error returns¶
Techniques: zero-cost (table-driven) exception handling — calls that may throw are
invokes, and out-of-line tables tell a two-phase unwinder where each call's landing pad is (Itanium C++ ABI; LLVM'sinvoke/landingpad; DWARF CFI); setjmp/longjmp (SjLj) exception handling — each function registers a context on entry and records which call site is active, and a throwlongjmps back into a dispatch block; explicit error returns — no unwinding at all: the error travels back as an ordinary value (Rust'sResult) or in a dedicated register the caller tests after every call (Swift'sthrows, LLVM'sswifterror) · Pebble implements: none — Pebble has no exceptions; its failures are traps (Lesson 11.6). This lesson is an overview for the Ch 24 extensions and for reading other compilers' IR · Prerequisites: Lesson 11.6 (the unwind policy) · Time: 3 hours
Lesson 11.6 said what unwinding does: leave frames, run their cleanups, stop at a handler. This lesson is about how a compiler makes that possible, and at what price. Three designs dominate. Table-driven EH puts all the work on the throw path ("zero cost" when nothing throws). SjLj puts a small cost on every function entry and every call site so that a throw is cheap. Explicit error returns avoid unwinding entirely and pay a test per call. The running example is one C++ function and its Swift-convention counterpart:
int parse(const char *s); // may throw
void log(int);
int safe(const char *s) {
try { return parse(s); } catch (int e) { log(e); return -1; }
}
1. Problem and motivation¶
Zero-cost exception tables¶
Early C++ compilers implemented exceptions by maintaining run-time bookkeeping on every function entry and every constructed object — a cost paid even by programs that never throw. Table-driven designs (the model of the Itanium C++ ABI [ItaniumEH], used by every Unix C++ and Rust compiler) remove it: the compiler emits, per function, a call-site table (the LSDA, language-specific data area) mapping each range of call instructions to its landing pad, plus DWARF call-frame information for restoring registers. A throw walks the stack twice: first to find a handler (search phase), then to run cleanups and transfer control (cleanup phase). LLVM expresses this with invoke (a call with a normal and an unwind successor), landingpad and resume [LLVM-EH]; the code generator produces the tables (EHStreamer) [LLVM-EHStreamer].
setjmp/longjmp¶
Where no unwinder or no CFI is available (some embedded and older ARM targets, and historically GCC's --enable-sjlj-exceptions), exceptions can be built on setjmp/longjmp: every function with handlers pushes a context record (personality, LSDA, a resume address) onto a per-thread list on entry and pops it on exit, and stores the index of the current call site before every call. A throw consults the list, longjmps to the function's dispatch block, which switches on the stored index to the right landing pad. LLVM implements it in SjLjEHPrepare [LLVM-SjLj] and selects it with -exception-model=sjlj.
Explicit error returns¶
Swift's throws is not unwinding: a throwing function returns normally with an error value, and the caller checks it after the call. The Swift calling convention reserves a register for the error (r12 on x86-64, x21 on AArch64), marked swifterror in LLVM IR, so the check is one test of a register [LLVM-SwiftError, SWIFT-Error]. Rust's Result with ? is the library-level version (Lesson 11.6). There are no tables, no landing pads, and no unwinder; the cost is a branch after every throwing call.
2. Definitions and algorithms¶
Definition 11.7.1 (EH region, landing pad, call-site table, personality)
An invoke is a call with two successors: the normal one and the unwind one, whose first
instruction is a landing pad. A landing pad has clauses: catch T (handle exceptions of type \(T\)),
filter, or cleanup (run code, then continue unwinding with resume). A function's call-site table
maps each half-open range of return addresses \([s, s + \ell)\) to a landing pad (or none) and an action
(which clauses apply). The personality function interprets the table for a given exception during
unwinding.
Algorithm 11.7.2 (Two-phase table-driven unwinding, Itanium model)
- Input: an exception object thrown at return address \(\mathit{pc}\) in the innermost frame.
- Output: control transferred to the matching handler, with the cleanups of intermediate frames run;
or
std::terminateif there is none. - Precondition: every frame has call-frame information (CFI) and, if it has landing pads, a call-site table and a personality.
- Postcondition: Theorem 11.7.3.
- Invariant: during phase 2, every frame between the thrower and the current frame has either been removed or had its cleanup landing pad run.
function Throw(exc):
# phase 1: search, without changing the stack
for each frame F from innermost outwards (use CFI to find the caller's pc):
(pad, action) ← LookupCallSite(F.lsda, F.pc)
if action has a catch clause matching exc (personality decides): handler ← F; break
if no handler: terminate()
# phase 2: cleanup
for each frame F from innermost to handler:
(pad, action) ← LookupCallSite(F.lsda, F.pc)
if pad exists: restore F's registers from CFI; jump to pad with (exc, selector)
# a cleanup pad ends in `resume`, which re-enters phase 2 from F
else: pop F
function LookupCallSite(table, pc): # the table is sorted by start address
for (s, ℓ, pad, action) in table: if s ≤ pc < s + ℓ: return (pad, action)
return (none, none) # "has no landing pad": continue unwinding
Theorem 11.7.3 (Table-driven EH: correct and free when nothing throws)
If the compiler emits, for every invoke \(I\) with unwind destination \(P\), a call-site entry covering \(I\)'s
return address and pointing to \(P\), then Algorithm 11.7.2 transfers control to exactly the landing pads the
IR specifies, in innermost-first order. No instruction is executed for exception handling on any path
that does not throw.
Proof
During unwinding each frame's current \(\mathit{pc}\) is the return address of the call that is active in
it, which is an invoke \(I\) (or a plain call). By hypothesis LookupCallSite returns \(I\)'s landing pad, so
phase 2 jumps to the pad the IR names; a plain call is not covered (or covered with "no landing pad") and
the frame is popped, which is correct because a plain call's unwind edge leaves the function. Frames are
visited from the innermost outwards, so pads run innermost first. On a non-throwing path, an invoke
compiles to an ordinary call instruction bracketed by labels (EH_LABEL) that only define table
addresses; the tables live in separate sections that are read only by the unwinder, so no extra
instruction executes.
Algorithm 11.7.4 (SjLj exception handling, as SjLjEHPrepare)
- Input: a function with
invokes. - Output: the function with a registered context and call-site indices.
- Precondition: a runtime providing
_Unwind_SjLj_Register/Unregister/Resumeand a per-thread context list. - Postcondition: a throw reaching this frame resumes at its dispatch block with the index of the active call site.
- Invariant: while the function runs, its context is on the list and \(\mathit{ctx.site}\) holds the index of the invoke that is executing (or \(-1\) outside any).
Definition 11.7.5 (Error-return convention)
A function with an error-return convention returns an additional value \(e\) (in a register or as part of
its result) that is null/None on success; after every call to such a function the caller tests \(e\) and,
if it is set, either handles it or returns it to its own caller.
3. Worked example¶
Zero-cost exception tables¶
clang++ -O1 compiles safe to (box in §7): invoke @parse with unwind label %lpad; %lpad has catch ptr @_ZTIi (the type_info of int); a nested invoke @log whose landing pad is a cleanup (it must call __cxa_end_catch). The call-site table has four entries, in address order:
| call site | range (return addresses) | landing pad | action |
|---|---|---|---|
| 1 | around call parse |
.Ltmp2 (the lpad) |
1: catch int |
| 2 | after parse up to log (__cxa_begin_catch) |
none | — |
| 3 | around call log |
.Ltmp5 (the cleanup pad) |
0: cleanup |
| 4 | the rest (__cxa_end_catch, _Unwind_Resume) |
none | — |
Throwing int out of parse: phase 1 finds site 1 in safe's frame, action 1 matches int, handler found. Phase 2 restores safe's registers and jumps to .Ltmp2 with selector 1; the code compares the selector (cmpl $1, %edx), calls __cxa_begin_catch, then log. On the path where parse returns normally, the only instructions are pushq %rbx, callq parse, popq %rbx, retq — Theorem 11.7.3.
setjmp/longjmp¶
The same IR with llc -exception-model=sjlj (box in §7): the prologue stores the personality, the LSDA and the dispatch address .LBB0_8 into the context and calls _Unwind_SjLj_Register; before parse it stores site index 1, before log index 2, and −1 after the catch; every return calls _Unwind_SjLj_Unregister; the dispatch block jumps through a table indexed by the site. A non-throwing call to safe now executes about a dozen extra instructions and two runtime calls.
Explicit error returns¶
With Clang's Swift calling convention (box in §7), the error slot is a swifterror argument; after call swiftcc @parse, safe loads the error from the slot and tests it for null (icmp eq ptr %7, null). In machine code the slot is register r12: the test is movq %r12, %rdx; negq %rdx — no memory, no tables.
4. Invariants and correctness¶
Zero-cost exception tables¶
Theorem 11.7.3's hypothesis is what LLVM's EHStreamer::computeCallSiteTable establishes from the EH_LABELs around each invoke [LLVM-EHStreamer]; it merges adjacent ranges with the same pad and action and inserts "no landing pad" gaps for plain calls that may throw (site 2 and 4 above). The personality must agree with the table format (__gxx_personality_v0 for C++, rust_eh_personality for Rust). What breaks it: code that cannot unwind through (a C function compiled without -fexceptions has no CFI in some configurations), which is why nounwind matters.
setjmp/longjmp¶
The invariant (context registered, site index current) must hold at every point a throw can happen. Registers that live across an invoke may be clobbered by the longjmp, so SjLjEHPrepare spills them (the volatile-like treatment of values used in landing pads).
Explicit error returns¶
Definition 11.7.5 is an ordinary data-flow contract; the compiler guarantees that every throwing call is followed by a test (Swift requires try at each call; LLVM's swifterror rules restrict the slot to loads, stores and calls so that the back end can keep it in the register).
5. Complexity¶
\(c\) = call sites in a function, \(d\) = frames unwound, \(t\) = the cost of decoding one frame's CFI and searching its call-site table (hundreds of instructions to microseconds, dominated by table lookups and the personality).
| Technique | Normal path (no throw) | Throw | Space | Justification |
|---|---|---|---|---|
| Zero-cost tables | 0 extra instructions | \(\Theta(d \cdot t)\), twice (two phases) | \(O(c)\) table entries + CFI per function | Theorem 11.7.3; Algorithm 11.7.2 walks \(d\) frames per phase |
| SjLj | register/unregister per call of the function + a store per call site | \(O(d)\) longjmps + a dispatch per frame | a context record per active frame | Algorithm 11.7.4 |
| Explicit error returns | a test per call of a throwing function | \(O(d)\) returns and tests | none | Definition 11.7.5 |
Pathological family. A recursion of depth \(d\) that throws once at the bottom: zero-cost EH pays nothing on the way down and \(\Theta(d \cdot t)\) on the throw — in C++ implementations throwing costs microseconds per frame, so using exceptions for control flow in a hot loop is slow; SjLj pays \(2d\) runtime calls on the way down even if nothing throws; error returns pay \(d\) tests on the way back, but also \(d\) tests on every normal return.
6. Variants and refinements¶
Zero-cost exception tables¶
- Windows funclets (
catchswitch,catchpad,cleanuppad): the MSVC ABI runs handlers as separate funclets on top of the thrower's stack; LLVM has dedicated instructions for it [LLVM-EH]. - Compact unwind (Darwin) encodes common prologues in 32 bits per function instead of full DWARF CFI.
setjmp/longjmp¶
- WebAssembly EH began with an SjLj-like lowering (
WebAssemblyLowerEmscriptenEHSjLj) before native exception instructions existed; the trade-off is the same: per-call cost for a missing unwinder. - Plain
setjmp/longjmpin C as a manual error-return mechanism (no cleanups run).
Explicit error returns¶
- Rust's
?is the library form:Resultin the return value, amatchper propagation site [RUSTC-Try]. - Herbceptions (C++ proposal P0709, not adopted) proposed exactly Swift's convention for C++; noted here as a design point only.
7. In real compilers¶
Zero-cost exception tables¶
Clang: CodeGenFunction::EmitLandingPad in clang/lib/CodeGen/CGException.cpp [CLANG-EH]. LLVM: DwarfEHPrepare (llvm/lib/CodeGen/DwarfEHPrepare.cpp) lowers resume to _Unwind_Resume [LLVM-DwarfEH]; EHStreamer::computeCallSiteTable and emitExceptionTable in llvm/lib/CodeGen/AsmPrinter/EHStreamer.cpp build the LSDA [LLVM-EHStreamer].
invoke/landingpad and the call-site table Clang emits
Reproduce (clang 23.1.2):
cat > eh.cpp <<'EOF'
int parse(const char *s); // may throw
void log(int);
int safe(const char *s) {
try {
return parse(s);
} catch (int e) {
log(e);
return -1;
}
}
EOF
clang++-23 -O1 -fno-discard-value-names -S -emit-llvm eh.cpp -o eh.ll
sed -n '/^define/,/^lpad:/p' eh.ll
clang++-23 -O1 -S eh.cpp -o - | sed -n '/Lcst_begin0:/,/Lcst_end0/p'
Output:
define dso_local noundef i32 @_Z4safePKc(ptr noundef %s) local_unnamed_addr #0 personality ptr @__gxx_personality_v0 {
entry:
%call = invoke noundef i32 @_Z5parsePKc(ptr noundef %s)
to label %return unwind label %lpad
lpad: ; preds = %entry
.Lcst_begin0:
.uleb128 .Ltmp0-.Lfunc_begin0 # >> Call Site 1 <<
.uleb128 .Ltmp1-.Ltmp0 # Call between .Ltmp0 and .Ltmp1
.uleb128 .Ltmp2-.Lfunc_begin0 # jumps to .Ltmp2
.byte 1 # On action: 1
.uleb128 .Ltmp1-.Lfunc_begin0 # >> Call Site 2 <<
.uleb128 .Ltmp3-.Ltmp1 # Call between .Ltmp1 and .Ltmp3
.byte 0 # has no landing pad
.byte 0 # On action: cleanup
.uleb128 .Ltmp3-.Lfunc_begin0 # >> Call Site 3 <<
.uleb128 .Ltmp4-.Ltmp3 # Call between .Ltmp3 and .Ltmp4
.uleb128 .Ltmp5-.Lfunc_begin0 # jumps to .Ltmp5
.byte 0 # On action: cleanup
.uleb128 .Ltmp4-.Lfunc_begin0 # >> Call Site 4 <<
.uleb128 .Lfunc_end0-.Ltmp4 # Call between .Ltmp4 and .Lfunc_end0
.byte 0 # has no landing pad
.byte 0 # On action: cleanup
.Lcst_end0:
What to notice: the four entries of the table in §3 — the invoke of parse is call site 1 with
landing pad .Ltmp2 and action 1 (catch int), log is site 3 with a cleanup pad, and the gaps in
between have "no landing pad". Nothing in the function's normal path refers to these tables
(Theorem 11.7.3).
setjmp/longjmp¶
LLVM: SjLjEHPrepareImpl in llvm/lib/CodeGen/SjLjEHPrepare.cpp inserts the context, the site-index stores and the dispatch [LLVM-SjLj]; the runtime functions are libgcc's _Unwind_SjLj_*.
The same IR compiled with the SjLj exception model
Reproduce (clang 23.1.2, llc 23.1.2):
cat > eh.cpp <<'EOF'
int parse(const char *s); // may throw
void log(int);
int safe(const char *s) {
try {
return parse(s);
} catch (int e) {
log(e);
return -1;
}
}
EOF
clang++-23 -O1 -fno-discard-value-names -S -emit-llvm eh.cpp -o eh.ll
llc -O1 -exception-model=sjlj eh.ll -o - | sed -n '/^_Z4safePKc:/,/Lfunc_end0/p' | grep -E 'SjLj|, -120\(%rbp\)|callq _Z|\$\.LBB0_8|jmpq \*'
Output:
movq $.LBB0_8, -72(%rbp)
callq _Unwind_SjLj_Register@PLT
movl $1, -120(%rbp)
callq _Z5parsePKc@PLT
callq _Unwind_SjLj_Unregister@PLT
jmpq *(%rcx,%rax,8)
movl $2, -120(%rbp)
callq _Z3logi@PLT
movl $-1, -120(%rbp)
callq _Unwind_SjLj_Resume@PLT
What to notice: Algorithm 11.7.4 instruction by instruction: the dispatch address .LBB0_8 stored in
the context, the registration, the site index 1 before parse and 2 before log (the call-site table's
rows, now as run-time stores), −1 when leaving the catch, and the dispatch jump table indexed by the site.
Every call of safe now pays the registration.
Explicit error returns¶
LLVM: SwiftErrorValueTracking (llvm/lib/CodeGen/SwiftErrorValueTracking.cpp) keeps the swifterror slot in a virtual register across the function [LLVM-SwiftError]. Swift: lib/IRGen/GenCall.cpp passes the error slot; the design is in docs/ErrorHandlingRationale.md [SWIFT-Error].
Swift's error register through Clang's swiftcall
Reproduce (clang 23.1.2):
cat > sw.c <<'EOF'
typedef struct Err *Error;
#define SWIFT __attribute__((swiftcall))
#define CTX __attribute__((swift_context))
#define ERR __attribute__((swift_error_result))
SWIFT int parse(const char *s, void *CTX ctx, Error *ERR err);
SWIFT int safe(const char *s, void *CTX ctx, Error *ERR err) {
int v = parse(s, ctx, err);
if (*err) return -1;
return v + 1;
}
EOF
clang-23 --target=x86_64-unknown-linux-gnu -O1 -S -emit-llvm sw.c -o - | sed -n '/^define/,/^}/p'
clang-23 --target=x86_64-unknown-linux-gnu -O1 -S sw.c -o - | sed -n '/^safe:/,/Lfunc_end/p' | grep -v '^\s*\.cfi'
Output:
define dso_local swiftcc range(i32 -2147483647, -2147483648) i32 @safe(ptr noundef %0, ptr noundef swiftself %1, ptr nofree noundef swifterror captures(none) %2) local_unnamed_addr #0 {
%4 = alloca swifterror ptr, align 8
%5 = load ptr, ptr %2, align 8
store ptr %5, ptr %4, align 8
%6 = call swiftcc i32 @parse(ptr noundef %0, ptr noundef swiftself %1, ptr noundef nonnull swifterror %4) #2
%7 = load ptr, ptr %4, align 8
%8 = icmp eq ptr %7, null
%9 = add nsw i32 %6, 1
%10 = select i1 %8, i32 %9, i32 -1
store ptr %7, ptr %2, align 8
ret i32 %10
}
safe: # @safe
# %bb.0:
pushq %rax
callq parse@PLT
incl %eax
xorl %ecx, %ecx
movq %r12, %rdx
negq %rdx
sbbl %ecx, %ecx
orl %ecx, %eax
popq %rcx
retq
.Lfunc_end0:
What to notice: in IR the error is a swifterror slot tested for null after the call
(Definition 11.7.5); in machine code the slot is never in memory — parse leaves the error in r12, and
safe tests it (negq/sbbl) and passes it on in r12 by not touching it. No invoke, no table.
Find where LLVM does it. In llvm/lib/CodeGen/AsmPrinter/EHStreamer.cpp, which function builds the list of call-site entries — including the "has no landing pad" gaps — from the EH_LABELs? (Quiz llvm-where-callsite-table.)
8. Comparison¶
| Technique | Power / precision | Speed (asymptotic · practical) | Output / error quality | Implementation effort | Typical use |
|---|---|---|---|---|---|
| Zero-cost exception tables | Full unwinding with cleanups and typed catch | 0 on the normal path · a throw costs a table walk per frame, microseconds | Tables and CFI make debuggers and profilers able to unwind too | High (CFI, LSDA, personality, runtime) | C++ and Rust on Unix-like systems, Java-to-native, Swift's C++ interop |
| setjmp/longjmp | The same semantics | register/unregister and a store per call site on the normal path · cheap throws | Needs no CFI | Medium (a lowering pass + small runtime) | Targets without an unwinder; old ARM ABIs; early WebAssembly |
| Explicit error returns | No cleanups by unwinding (cleanups are ordinary code on the return path); only direct callers see the error | a test per call · throws cost a return per frame | Every throwing call is visible in the source (try, ?) |
Low (a register convention or a sum type) | Swift throws, Rust Result, Go (v, err) |
Choose zero-cost tables when exceptions are rare and the normal path must be as fast as possible — the default for C++ and Rust panics. Choose SjLj when the platform has no unwinder or CFI. Choose explicit error returns when errors are part of the API and should be visible at every call; with a register convention (Swift) they cost one test per call.
9. Assessment¶
- Quiz (
./course quiz 11):eh-callsite-lookup,llvm-where-callsite-table(tagzero-cost-eh);sjlj-site-index,sjlj-cost(tagsjlj);swifterror-register,errors-cost-depth(tagexplicit-errors). - Drill: none: the lesson's computational content — which landing pad a return address maps to, and the per-call cost of each scheme on a call chain — is a table lookup and a count that the quiz questions
eh-callsite-lookupanderrors-cost-depthask on new instances; a random generator would only relabel them. - Flashcards: tags
zero-cost-eh,sjlj,explicit-errors. - Exercises: none: Pebble has no exceptions (its checks trap, Lesson 11.6).
References¶
See the chapter references.