As it turns out there’s not really a good in depth explanation of how exceptions work. There are disparate explanations of how separate parts of exceptions in C++ work, but not really the whole picture. So I’m writing this down here as a reference to how everything in C++ exceptions work.
Disclaimer
My experiences are exclusively in GCC. Broadly, Clang should follow the same Architecture as GCC, but may vary in implementation details.
Architecture
The architecture of C++ exception handling can be thought of as two parts: C++ specific, and “language independent.” The architecture of exception handling is laid out in the Itanium ABI, specifically the section on C++ exception handling.
When an exception is thrown, the compiler transforms the throw expression as
laid out by the abi. First, it calls __cxa_allocate_exception()
, which is
either a wrapper for malloc
or accesses a static buffer of memory, depending
on how the compiler compiler configured it. 1 Obviously, this function and
all others cannot throw exceptions themselves. The exception is then constructed
into the buffer, typically but not necessarily bypassing the copy constructor.
Then __cxa_throw()
is called with the pointer to the allocated exception, its
type_info
, and the function pointer to its destructor. __cxa_throw()
never
returns.
After that, the C++ exception runtime creates the __cxa_exception
object,
which contains the aforementioned type info and destructor, as well as a variety
of other state used in control flow. It then hands control over to the language
independent runtime.
The language independent runtime, which I will refer to as libunwind
for
reasons that shall become readily apparent, has the job of actually unwinding
exceptions. The ABI describes many functions, but only three are actually called
externally. _Unwind_RaiseException()
is called by __cxa_throw()
to start the
unwinding process. If it was a longjmp
instead of an unwind, it might instead
decide to call _Unwind_ForcedUnwind()
, which is very similar and also
irrelevant at the moment. _Unwind_Resume()
is called by the personality to
resume unwinding. _Unwind_DeleteException
is used to cleanup the libUnwind
specific exception object. It just calls a function pointer to
__gxx_exception_cleanup
, which manages the internal reference counting in the
exception object.
libUnwind
unwinds the stack by interpreting information held in the eh_frame
section, which contains the offsets of saved registers as well as the stack
usage of a function. This information is used to undo the prologue of a
function, in effect uncalling it. But before unwinding anything, the unwinder
first needs to check if the language specific runtime needs to do anything. The
part of the language specific runtime responsible for this is called the
personality function, which is fed data from the Language Specific Data Area
(LSDA). The Frame Descriptor Entry (FDE) for the function contains a pointer to
the specific LSDA for the function, if there is any. From there, the
personality, in the case of C++, checks for destructors and try blocks, and if
there are any, jumps to the “landing pad”. (I’ll explain later) If the exception
is caught, unwinding just stops. Otherwise, it calls _Unwind_Resume
, which
passes control back to the unwinder, which loads any saved registers and removes
space from the stack.
In reality, the LSDA is actually language independent, and is compiler specific rather than language specific. It contains a list of try blocks and catch blocks. If the LSDA contains no types but only has a single action, then the action is to run destructors. Alternatively, if there are types and actions, then they correspond to catch statements and their caught types. In any case, we have found which destructors and catch block to run here.
This is done by setting the registers to the state they were in the function,
then jumping directly into a “landing pad”. The landing pad is a pseudo function
which has no prologue, and only takes two register arguments. The arguments are
the pointer to the current exception object and an index for which catch block
activated. The destructors are run first, then it branches to the catch block.
Depending on whether the exception was caught, it simply jumps back to normal
control flow, or calls _Unwind_Resume
, which resumes exception handling as
before.
See
--enable-libstdcxx-static-eh-pool
in [https://gcc.gnu.org/onlinedocs/libstdc++/manual/configure.html] ↩︎