A Glimpse of Exceptions
What throw and try/catch add to a listing, landing pads and unwind tables, and why the happy path stays almost free.
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
throwcompiles to a call to__cxa_allocate_exception(sized like anew), a store of the thrown value, and a call to__cxa_throwwith the exception object and its type info (a_ZTI...label);__cxa_thrownever returns.- The
trybody itself compiles to completely normal code; thecatchmachinery appears as landing pads after the function's normal code, bracketed by__cxa_begin_catchand__cxa_end_catch. - Under the zero-cost model, no instructions execute for a
tryblock 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 athrowactually 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;
tryis nearly free. Error codes andstd::expectedinvert that trade, paying a small check on every call instead. - Full unwinder detail is beyond this course; recognising
__cxa_*calls,_ZTI...labels, and post-retlanding pads is what you need to read listings.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
A Glimpse of Exceptions - Quiz
Test your understanding of the lesson.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!