Skip to content

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's invoke/landingpad; DWARF CFI); setjmp/longjmp (SjLj) exception handling — each function registers a context on entry and records which call site is active, and a throw longjmps back into a dispatch block; explicit error returns — no unwinding at all: the error travels back as an ordinary value (Rust's Result) or in a dedicated register the caller tests after every call (Swift's throws, LLVM's swifterror) · 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::terminate if 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/Resume and 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).
function LowerSjLj(F):
    on entry: ctx.personality, ctx.lsda ← …; ctx.resume ← Dispatch; save fp/sp (setjmp)
              _Unwind_SjLj_Register(&ctx)
    before invoke number k: ctx.site ← k
    on every return: _Unwind_SjLj_Unregister(&ctx)
    Dispatch:  goto landing_pad[ctx.site]            # a jump table on the site index

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/longjmp in C as a manual error-return mechanism (no cleanups run).

Explicit error returns

  • Rust's ? is the library form: Result in the return value, a match per 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 (tag zero-cost-eh); sjlj-site-index, sjlj-cost (tag sjlj); swifterror-register, errors-cost-depth (tag explicit-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-lookup and errors-cost-depth ask 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.