The Itanium Exception ABI
The Itanium Exception ABI
Section titled “The Itanium Exception ABI”The dominant exception model on all major platforms (GCC, Clang, MSVC on x64) is the zero-cost Table-based model specified informally by the Itanium C++ ABI and adopted as the de-facto standard Mechanism [N4950 §14.2].
1.1 Table-Based Unwinding Model
Section titled “1.1 Table-Based Unwinding Model”When an exception is thrown, the runtime:
- Allocates the exception object (on a dedicated heap or in a pre-allocated buffer).
- Copies or moves the thrown expression into that object. The runtime uses a dedicated allocation mechanism for exception objects.
- Walks the call stack using tables generated at compile time.
Each function that may participate in exception handling has two tables embedded in the binary ( in the .eh_frame / .gcc_except_table ELF sections on Linux):
| Table | Purpose |
|---|---|
| LSDA (Language-Specific Data Area) | Describes which PC ranges map to which try/catch blocks. |
| Unwind table | Lists every call site in the function so the unwinder can determine whether the function has a cleanup (destructor call) at each point. |
| Model | Description | Normal-Path Cost | Platforms |
|---|---|---|---|
| Table-based (zero-cost) | Static tables describe handlers; unwinder walks stack at throw time | ~0 instructions | GCC, Clang, MSVC x64 |
| Setjmp/Longjmp (SJLJ) | setjmp/longjmp at each try entry/exit | ~10-20 instructions per try | Embedded, older compilers |
| DWARF CFI | DWARF Call Frame Information used for unwinding | ~0 instructions | GCC/Clang (Linux, BSD) |
The SJLJ model incurs cost on every try entry (saving registers via setjmp) and every try exit (potentially restoring via longjmp). This is why modern compilers default to the table-based model — it has zero normal-path cost.
1.2 Searching for Matching Catch Clauses
Section titled “1.2 Searching for Matching Catch Clauses”The search algorithm [N4950 §14.2] proceeds as follows:
- The exception object is associated with a
std::type_infostructure describing its dynamic type. - Starting from the throw site, the unwinder examines the LSDA of each frame on the call stack.
- For each
catchclause, the runtime performs an exception match:
- An exact type match.
- A base-class match (standard derived-to-base conversion).
- A pointer or reference conversion to
const. - An ellipsis (
catch (...)) matches everything.
- The first matching clause in the innermost scope wins.
- If no frame contains a matching handler,
std::terminate()is called [N4950 §14.7].
The match is performed using std::type_info::operator== or the RTTI comparison function. On Itanium ABI systems, the __gxx_personality_v0 personality function performs this comparison by Walking the exception”s type info chain.
#include <iostream>#include <stdexcept>
struct AppError : std::runtime_error { using std::runtime_error::runtime_error;};
struct NetworkError : AppError { using AppError::AppError;};
void try_network() { throw NetworkError{"connection refused"};}
int main() { try { try_network(); } catch (const NetworkError& e) { std::cout << "Caught NetworkError: " << e.what() << "\n"; } catch (const AppError& e) { std::cout << "Caught AppError: " << e.what() << "\n"; } catch (const std::exception& e) { std::cout << "Caught std::exception: " << e.what() << "\n"; } return 0;}// Output: Caught NetworkError: connection refusedThe Catch-All and Exception Object Slicing
Section titled “The Catch-All and Exception Object Slicing”When catching by value (not by reference), the exception object is sliced to the catch clause’s Static type. This is almost always wrong because it loses the dynamic type information and invokes An extra copy:
#include <iostream>#include <stdexcept>#include <typeinfo>
struct AppError : std::runtime_error { using std::runtime_error::runtime_error; void describe() const { std::cout << "AppError::describe\n"; }};
void throw_app() { throw AppError{"app failure"}; }
int main() { // BAD: caught by value — sliced to std::runtime_error try { throw_app(); } catch (std::runtime_error e) { std::cout << "type: " << typeid(e).name() << "\n"; // e.describe(); // ERROR: std::runtime_error has no describe() // The dynamic type is LOST }
// GOOD: caught by reference — preserves dynamic type try { throw_app(); } catch (const std::runtime_error& e) { std::cout << "type: " << typeid(e).name() << "\n"; // dynamic_cast<const AppError&>(e).describe(); // OK if AppError }}1.3 Stack Unwinding and Destructor Invocation
Section titled “1.3 Stack Unwinding and Destructor Invocation”During propagation, the unwinder calls the destructor of every automatic-duration object Constructed in each abandoned frame [N4950 §14.2]. This is what makes RAII-based resource Management exception-safe.
Stack Unwinding Walkthrough
Section titled “Stack Unwinding Walkthrough”Consider a call stack: main() calls outer()Which calls middle()Which calls inner(). When inner() throws:
Call stack (top to bottom): inner() <- throw occurs here middle() <- destructors for locals in middle() called outer() <- destructors for locals in outer() called main() <- catch clause found, unwinding stopsThe unwinder performs the following steps:
- The exception object is allocated and initialized.
- The unwinder (
_Unwind_RaiseException) is called. - For each frame on the stack (starting from
inner()): a. The personality function (__gxx_personality_v0) is called with the exception object and the frame’s context. B. The personality function checks the LSDA for a matchingcatchclause. C. If no match, the personality function identifies cleanup code (destructor calls for local variables) and the unwinder executes those cleanups. D. The frame is popped, and the unwinder moves to the next frame. - When a matching
catchis found inmain()The unwinder sets the instruction pointer to the catch clause’s entry point and transfers control.
#include <iostream>#include <stdexcept>
struct RaiiGuard { const char* name; explicit RaiiGuard(const char* n) : name(n) { std::cout << " ctor: " << name << "\n"; } ~RaiiGuard() { std::cout << " dtor: " << name << "\n"; }};
void inner() { RaiiGuard g{"inner"}; throw std::runtime_error{"fail"};}
void middle() { RaiiGuard g{"middle"}; inner();}
void outer() { RaiiGuard g{"outer"}; middle();}
int main() { try { outer(); } catch (const std::exception& e) { std::cout << "caught: " << e.what() << "\n"; } return 0;}// Output:// ctor: outer// ctor: middle// ctor: inner// dtor: inner// dtor: middle// dtor: outer// caught: fail1.4 Zero-Cost Principle
Section titled “1.4 Zero-Cost Principle”In the table-based model, the generated code for a function that uses exceptions is identical in The non-throwing path to a function that does not use exceptions at all. There are:
- No extra branches or flags tested on every
tryentry. - No per-function “has_exception” global.
- No code-size penalty in the hot path (the tables live in read-only data sections).
Proof of the Zero-Cost Principle
Section titled “Proof of the Zero-Cost Principle”Claim: In the table-based exception model, the normal (non-throwing) execution path incurs zero Runtime overhead compared to equivalent code without exception handling.
Proof:
- The compiler generates exception handling information (LSDA and unwind tables) as static data in read-only sections of the binary (
.eh_frame``.gcc_except_table). These tables are not loaded into registers or cache during normal execution. - The generated machine code for the normal path contains no instructions that reference the exception tables. There are no conditional branches to check for pending exceptions, no global flags, and no extra register saves.
- The only overhead is binary size: the tables add ~5-15% to the binary. This is a one-time cost at load time and does not affect runtime instruction count.
- Therefore, the instruction count and execution time of the normal path are identical to code compiled without exception support.
## 1.5 Performance Comparison: Throw/Catch vs Error Codes
| Metric | Exception (throw path) | Error-code check |
|---|---|---|
| Normal-path cost | ~0 instructions | 1 branch + compare per call |
| Throw-path cost | ~5-20 (unwinding + alloc) | N/A |
| Code size | +LSDA tables (~1-5% of binary) | No overhead |
| Cognitive cost | Implicit control flow | Explicit, pervasive |
#include <iostream>#include <stdexcept>#include <chrono>
void throw_error(int depth) { if (depth <= 0) throw std::runtime_error{"depth reached"}; throw_error(depth - 1);}
int return_error(int depth, int* out) { if (depth <= 0) return -1; return return_error(depth - 1, out);}
template <typename F>auto bench(const char* label, F&& f) { auto start = std::chrono::high_resolution_clock::now(); f(); auto end = std::chrono::high_resolution_clock::now(); auto dur = std::chrono::duration_cast<std::chrono::nanoseconds>(end - start); std::cout << label << ": " << dur.count() << " ns\n"; return dur;}
int main() { constexpr int DEPTH = 50; constexpr int ITERS = 100'000;
auto t1 = bench("error codes (no error) ", [&] { int v; for (int i = 0; i < ITERS; ++i) { if (return_error(DEPTH, &v) != 0) { /* handle */ } } });
auto t2 = bench("exceptions (caught) ", [&] { for (int i = 0; i < ITERS; ++i) { try { throw_error(DEPTH); } catch (...) {} } });
auto t3 = bench("exceptions (no throw) ", [&] { for (int i = 0; i < ITERS; ++i) { try { (void)0; } catch (...) {} } });
(void)t1; (void)t2; (void)t3; return 0;}// Typical output (varies by hardware and compiler):// error codes (no error) : ~6000000 ns// exceptions (caught) : ~90000000 ns// exceptions (no throw) : ~400000 nsRelevance: The no-throw path of exceptions is faster than error-code checking because it Eliminates the branch. The throw path is significantly slower. Design critical paths to avoid Throwing; use exceptions for truly exceptional conditions.
1.6 Exception Object Lifetime and Storage
Section titled “1.6 Exception Object Lifetime and Storage”The exception object is allocated by the C++ runtime, not by new. The Itanium ABI specifies that The runtime uses a dedicated allocator (often a thread-local buffer) for small exception objects, Falling back to malloc for large ones [N4950 §14.2]. The exception object is destroyed when the Last catch clause handling it exits [N4950 §14.2]:
#include <iostream>#include <stdexcept>
struct Tracked { int id; explicit Tracked(int i) : id(i) { std::cout << " Tracked(" << id << ") ctor\n"; } ~Tracked() { std::cout << " ~Tracked(" << id << ") dtor\n"; } Tracked(const Tracked& o) : id(o.id) { std::cout << " Tracked(" << id << ") copy\n"; }};
void throw_tracked() { throw Tracked{1}; }
int main() { try { throw_tracked(); } catch (const Tracked& e) { std::cout << "caught Tracked " << e.id << "\n"; } std::cout << "after catch\n";}// Output:// Tracked(1) ctor (constructed in throw expression)// caught Tracked 1 (handler executes)// ~Tracked(1) dtor (destroyed when catch exits)// after catchRethrowing with throw;
Section titled “Rethrowing with throw;”The throw; statement re-throws the currently handled exception without copying it. This is Critical for preserving the dynamic type when re-throwing from a catch clause that caught a base Class:
#include <iostream>#include <stdexcept>
struct AppError : std::runtime_error { using std::runtime_error::runtime_error;};
struct NetworkError : AppError { using AppError::AppError;};
void handle_and_rethrow() { try { throw NetworkError{"connection refused"}; } catch (const AppError& e) { std::cout << "handling: " << e.what() << "\n"; throw; // re-throws the original NetworkError, NOT sliced }}
int main() { try { handle_and_rethrow(); } catch (const NetworkError& e) { std::cout << "caught NetworkError: " << e.what() << "\n"; } catch (const AppError& e) { std::cout << "caught AppError: " << e.what() << "\n"; }}// Output:// handling: connection refused// caught NetworkError: connection refused