Skip to content

Instantly share code, notes, and snippets.

@apsun
Last active April 26, 2022 04:09
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save apsun/fc4c71cbd85cf9b65edf815a6105d7e3 to your computer and use it in GitHub Desktop.
Save apsun/fc4c71cbd85cf9b65edf815a6105d7e3 to your computer and use it in GitHub Desktop.
C++ Itanium ABI for exception handling

This aims to be a brief introduction to how C++ exceptions are implemented on Linux. It's based on an afternoon of reading and may contain serious inaccuracies, so read at your own risk.

We start from the point where an exception is thrown. First, we allocate an exception object on the heap using __cxa_allocate_exception. This exception object contains:

  • the actual object you're throwing (hereafter referred to as the "user exception")
  • a C++ exception library header (__cxa_exception) which contains:
    • the std::type_info and destructor of the user exception
    • a reference count for the exception
    • some other stuff which I'll gloss over for the sake of brevity
  • a language-agnostic unwind library header (_Unwind_Exception) which contains:
    • a tag specifying whether this is a C++ exception (it is!)
    • a destructor for the exception object that has a stable ABI (so it can be called if the exception crosses language boundaries, e.g. C++ -> Rust)

In memory, this is laid out sequentially as:

struct {
    __cxa_exception;  // C++ exception library header
    _Unwind_Exception;  // Unwind library header
    user_exception_object;  // The object you just threw
}

Notice that we can trivially convert back and forth between the headers using pointer arithmetic.

Once the exception object has been allocated and the user exception is initialized from the throw expression, we call __cxa_throw with the exception object, which initializes the __cxa_exception and _Unwind_Exception headers, then calls the unwind library (_Unwind_RaiseException).

_Unwind_RaiseException has two phases. The first phase will walk up the call stack, and for each frame (here, "frame" includes try-catch blocks, but also implicit "try-finally" blocks generated by declaring objects with destructors) it will try to find a matching entry in the unwind table using the instruction pointer. This table is generated by the compiler and placed in the .eh_frame section of the binary. It contains a pointer to an "exception handler personality" function, which is basically the bridge between the C++ exception library and the language-agnostic unwind library, and an "exception table", which contains information for the C++ exception library to determine whether it should handle an exception (e.g. the types that can be caught). For each frame, the unwind library will call the personality function corresponding to that frame and ask, "do you want to handle this exception?" (i.e. whether there a catch block for this exception type, code _UA_SEARCH_PHASE). The personality function will consult the exception table (which it can get by calling _Unwind_GetLanguageSpecificData) to find a matching handler, if one exists. Once a personality function says "yes" (_URC_HANDLER_FOUND), the first phase ends. If we walk the entire stack and find no handlers, _Unwind_RaiseException returns and __cxa_throw calls std::terminate.

In the second phase (assuming a handler was found), _Unwind_RaiseException will walk up the call stack again, but this time, the personality function is instead responsible for checking if there is any cleanup work to be done (i.e. running destructors for variables going out of scope, code _UA_CLEANUP_PHASE). If there is, it can either perform the cleanup work itself and tell the unwind library to continue to the next frame (_URC_CONTINUE_UNWIND), or it can tell the unwind library how to call the cleanup code (referred to in the documentation as a "landing pad", using _Unwind_SetGR + return _URC_INSTALL_CONTEXT), and the cleanup code must call _Unwind_Resume to continue to the next frame once it is finished.

Eventually, we reach the frame for which the personality function said "yes" during the first phase. This is similar to cleanup, except that _URC_INSTALL_CONTEXT must be used to tell the unwind library how to call the catch code, and the catch code must not call _Unwind_Resume. The catch code must begin with a call to __cxa_get_exception_ptr + __cxa_begin_catch, and end with a call to __cxa_end_catch. Between those calls is the body of the catch block. __cxa_end_catch is responsible for decrementing the reference count of the exception, which should go to zero if the exception is not rethrown. At this point, the exception object is freed and control resumes at the code following the catch block.

Notice that throughout this entire process, no extra code is needed to set up the try block. Everything needed to recover from exceptions is generated at compile time and placed in static global tables. This is what it means when we say C++ exceptions are zero-cost: they do not add any overhead to the happy path (well ok, technically they can add a little bit of overhead by influencing how the compiler lays out code to accomodate recovery, but this is generally negligible). However, as you can see, actually handling an exception is quite slow.

References:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment