Skip to content

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].

When an exception is thrown, the runtime:

  1. Allocates the exception object (on a dedicated heap or in a pre-allocated buffer).
  2. Copies or moves the thrown expression into that object. The runtime uses a dedicated allocation mechanism for exception objects.
  3. 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):

TablePurpose
LSDA (Language-Specific Data Area)Describes which PC ranges map to which try/catch blocks.
Unwind tableLists every call site in the function so the unwinder can determine whether the function has a cleanup (destructor call) at each point.
### Alternative Exception Models
ModelDescriptionNormal-Path CostPlatforms
Table-based (zero-cost)Static tables describe handlers; unwinder walks stack at throw time~0 instructionsGCC, Clang, MSVC x64
Setjmp/Longjmp (SJLJ)setjmp/longjmp at each try entry/exit~10-20 instructions per tryEmbedded, older compilers
DWARF CFIDWARF Call Frame Information used for unwinding~0 instructionsGCC/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.

The search algorithm [N4950 §14.2] proceeds as follows:

  1. The exception object is associated with a std::type_info structure describing its dynamic type.
  2. Starting from the throw site, the unwinder examines the LSDA of each frame on the call stack.
  3. For each catch clause, 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.
  1. The first matching clause in the innermost scope wins.
  2. 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 refused

The 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.

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 stops

The unwinder performs the following steps:

  1. The exception object is allocated and initialized.
  2. The unwinder (_Unwind_RaiseException) is called.
  3. 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 matching catch clause. 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.
  4. When a matching catch is found in main()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: fail

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 try entry.
  • No per-function “has_exception” global.
  • No code-size penalty in the hot path (the tables live in read-only data sections).

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:

  1. 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.
  2. 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.
  3. 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.
  4. Therefore, the instruction count and execution time of the normal path are identical to code compiled without exception support.

\square

## 1.5 Performance Comparison: Throw/Catch vs Error Codes
MetricException (throw path)Error-code check
Normal-path cost~0 instructions1 branch + compare per call
Throw-path cost~5-20 μs\mu s (unwinding + alloc)N/A
Code size+LSDA tables (~1-5% of binary)No overhead
Cognitive costImplicit control flowExplicit, 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 ns

Relevance: 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.

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 catch

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