One More Way to Leave a Function

Every C++ feature in this chapter so far has compiled down to things you already knew how to read: hidden pointer arguments, ordinary calls, tables of function pointers. Exceptions are the last stop, and they are the strangest, because they add something genuinely new: a way for control to leave a function that is not a ret. When a throw executes, the runtime walks back up the stack, frame by frame, until it finds a catch that matches, running destructors along the way.

Like the heap lesson, this is a glimpse, not a full tour. The machinery that performs that stack walk, the unwinder, is a small library with its own data formats, and reading it in detail is beyond the scope of this course. What you can learn quickly, and what this lesson teaches, is how to recognise exception code in a listing: what throw compiles to, what try/catch adds, and where the famous "zero-cost" claim comes from.

throw Is a Pair of Calls

Here is a function that throws the int it was given:

void fail(int code)
{
    throw code;
}

At -O2:

fail(int):
    push rbx
    mov  ebx, edi
    mov  edi, 4
    call __cxa_allocate_exception@PLT
    mov  DWORD PTR [rax], ebx
    xor  edx, edx
    mov  esi, OFFSET FLAT:_ZTIi
    mov  rdi, rax
    call __cxa_throw@PLT

This should feel familiar, because it is almost exactly the shape of new from the heap lesson. __cxa_allocate_exception is called with a size (mov edi, 4, since we are throwing a 4-byte int) and returns the address of a fresh exception object in rax. The next instruction stores the thrown value into it, an ordinary pointer store.

Then comes the interesting part. __cxa_throw takes three arguments: the exception object in rdi, a pointer to the thrown type's type info in rsi, and a destructor pointer in rdx (null here, because an int needs no destructor). The label _ZTIi demangles to typeinfo for int; you saw _ZTV... labels for vtables in the virtual functions lesson, and _ZTI... is their sibling. This type info is how the runtime later decides which catch clause matches: the match is done at run time, by comparing type descriptors, not at compile time.

Notice what is missing: there is no ret. __cxa_throw never returns normally. Control leaves this function through the unwinder, not through the return address.

What try/catch Adds

Now a caller that catches:

int attempt()
{
    try
    {
        fail(42);
        return 0;
    }
    catch (int error)
    {
        return error;
    }
}
attempt():
    push rbx
    mov  edi, 42
    call fail(int)
    xor  eax, eax
    pop  rbx
    ret
.L5:
    cmp  rdx, 1
    jne  .L3
    mov  rdi, rax
    call __cxa_begin_catch@PLT
    mov  ebx, DWORD PTR [rax]
    call __cxa_end_catch@PLT
    mov  eax, ebx
    pop  rbx
    ret
.L3:
    mov  rdi, rax
    call _Unwind_Resume@PLT

Look at the top half first. The try body is completely ordinary: call fail, zero eax for return 0, restore, return. There is no "enter try block" instruction, no flag being set, nothing. If you covered up everything after the first ret, you could not tell a try block was there at all.

The catch machinery lives in the blocks after the normal code. .L5 is a landing pad: an address that no instruction in the function ever jumps to. Control only arrives there if fail throws, because the unwinder looks up this function in a table and transfers control to the landing pad directly. There the code checks which handler matched (the cmp rdx, 1 tests a selector value the unwinder supplies), then calls __cxa_begin_catch, which returns a pointer to the exception object so the handler can read the thrown int out of it, does the handler's work, and calls __cxa_end_catch to tell the runtime the exception is fully handled and its object can be freed. If the type had not matched, the .L3 path would call _Unwind_Resume to keep the exception propagating up to the next frame.

So the reading rule is simple: __cxa_begin_catch and __cxa_end_catch bracket a catch handler, and any labelled block sitting after a function's final ret, reachable by no jump, is a landing pad.

The Zero-Cost Model

This layout is the visible half of what is called the zero-cost exception model, and the name means something precise: on the path where nothing is thrown, zero extra instructions execute. Older schemes registered handlers dynamically, doing real work at every try entry. Modern g++ instead uses table-driven unwinding: the compiler emits static tables, in sections named .gcc_except_table and .eh_frame, that record for every call site which landing pad to use and how to restore each frame's registers. Those tables just sit in the binary. If no exception is ever thrown, they are never even read.

The cost has not vanished; it has moved. You pay in binary size (the tables and the landing-pad code), and you pay heavily at throw time, when the runtime must allocate the exception object, parse those tables, match type infos, and walk the stack one frame at a time. A throw is easily thousands of times slower than a ret. Error codes and std::expected make the opposite trade: a small, constant cost (a comparison and branch) on every single call, whether it failed or not. That is the whole performance argument in one sentence: exceptions are for exceptional paths.

Unwinding Meets RAII

The unwinder does one more job as it walks the stack, and it connects directly to the RAII lesson. There you saw that the compiler emits a destructor call on every exit path from a function. An exception propagating through a function is an exit path too, so functions with local objects but no catch still get landing pads: cleanup blocks that call the locals' destructors and then call _Unwind_Resume to send the exception onward. That is the mechanism behind the promise that RAII cleanup happens even when an exception flies through: the compiler planted a destructor-calling landing pad in every frame the exception might cross, and the unwinder visits each one in turn.

That is as deep as we will go. The personality routine, the encoding of the tables, and the two-phase search that the unwinder actually performs are real and documented, but they belong to a course on ABIs, not on reading listings. For reading purposes, the symbols are the story.

Key Takeaways

  • throw compiles to a call to __cxa_allocate_exception (sized like a new), a store of the thrown value, and a call to __cxa_throw with the exception object and its type info (a _ZTI... label); __cxa_throw never returns.
  • The try body itself compiles to completely normal code; the catch machinery appears as landing pads after the function's normal code, bracketed by __cxa_begin_catch and __cxa_end_catch.
  • Under the zero-cost model, no instructions execute for a try block on the happy path; the cost lives in static unwind tables (.gcc_except_table, .eh_frame) and extra code size, and is paid only when a throw actually happens.
  • During propagation, the unwinder walks stack frames and jumps to cleanup landing pads that call local objects' destructors, which is how RAII holds on the exceptional exit path.
  • Throwing is expensive; try is nearly free. Error codes and std::expected invert that trade, paying a small check on every call instead.
  • Full unwinder detail is beyond this course; recognising __cxa_* calls, _ZTI... labels, and post-ret landing pads is what you need to read listings.