Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 26

Stack Unwinding and RAII in Exception Handling

Recommended resources

Hello! Welcome to the next lesson in your journey into the internals of C++.

In our last lesson, we implemented an RAII wrapper for a C-style file handle. We established that RAII is the cornerstone of robust resource management in C++ because it guarantees cleanup. You saw that when an object managing a resource goes out of scope, its destructor is automatically called. We noted that this guarantee holds true even when a function exits prematurely due to an exception.

Today, we will dissect the very mechanism that provides this guarantee. Your learning outcome is to analyze stack unwinding during exception handling and how it enables RAII. We will go deep under the hood to see the step-by-step process the C++ runtime follows when an exception is thrown, revealing why your destructors are always called.

Visualizing the Goal: The "What" of Stack Unwinding

Before we dive into the "how," let's clarify "what" stack unwinding achieves. When an exception is thrown, the program's flow of control jumps from the throw statement to a suitable catch block, which may be many function calls away. During this jump, the runtime must meticulously clean up the stack frames of all the functions that are being exited. This cleanup involves calling the destructors of any fully constructed local objects in those frames.

Stack Unwinding Example in C++
This diagram illustrates the effect of stack unwinding. When `f2` throws an exception, the runtime ensures that the destructors for `myC`, `myB`, and `myA` are called in reverse order of their construction before control transfers to the `catch` block in `main`.

This automatic, ordered destruction is the essence of stack unwinding, and it's the reason RAII works. Our goal is to understand the machinery that makes this happen.


The Challenge: Non-Local Jumps and Resource Leaks

To appreciate the C++ solution, it's helpful to consider the tools available in C. You're familiar with the C stack and function calls, but C also provides a mechanism for non-local control flow: setjmp/longjmp.

  • setjmp(jmp_buf env) saves the current execution context (stack pointer, instruction pointer, registers) into the jmp_buf structure.
  • longjmp(jmp_buf env, int val) restores the context saved in env, causing the program to effectively jump back to the location of the setjmp call.

This allows you to jump out of a deeply nested function call directly back to an outer function. However, there's a critical problem: longjmp simply resets the stack pointer. It "blasts through" the intermediate stack frames, effectively discarding them without performing any cleanup. Any resources acquired in those intermediate functions are leaked. This is fundamentally incompatible with the C++ requirement of deterministic destruction.


Building a Conceptual Model for Unwinding

So, how can we implement a non-local jump that also performs cleanup? We need a way to track objects that require destruction and a mechanism to call their destructors as we unwind the stack.

The following video provides an excellent conceptual bridge. It first demonstrates the setjmp/longjmp mechanism and its shortcomings, then builds a manual C-style system to register and execute cleanup handlers. This manual approach is a great mental model for the tasks the C++ compiler and runtime automate for us.

C++ Exception Handling - The gory details of an implementation

To understand the problem C++ exceptions solve, let's first look at a C-style mechanism for non-local control flow: setjmp and longjmp. This video explains how it works but also highlights its critical limitation: it doesn't clean up resources. It then shows a conceptual way to add cleanup, which builds a fantastic mental model for what the C++ compiler must do automatically.

Watch from 02:17 to 07:28. First, focus on the explanation of setjmp and longjmp and how they discard stack state without running destructors (until 05:31). Then, pay close attention to how the speaker builds a manual linked-list of cleanup handlers to solve this problem. This manual approach is exactly what C++ automates for us.

This conceptual model of maintaining a chain of cleanup handlers is effective, but it requires explicit registration and deregistration code, which adds runtime overhead even if no exception is ever thrown. This violates the C++ principle of "you don't pay for what you don't use."


The Modern C++ Solution: "Zero-Cost" Exceptions

On modern systems like Linux, compilers like GCC and Clang use a different, more efficient approach often called "zero-cost" exception handling. The "cost" is not truly zero, but it is shifted away from the normal execution path (the "happy path").

  • No Runtime Cost on the Happy Path: If no exception is thrown, your code runs at full speed without any overhead for managing cleanup handlers.
  • Compile-Time and Exception-Time Cost: The compiler generates metadata tables that are stored in the executable. When an exception is thrown, the runtime incurs a cost to read these tables and perform the unwinding.

Instead of generating code to build a cleanup list at runtime, the compiler generates static data tables (stored in a section of the executable often called .eh_frame). These tables create a map: for any given location in the program's code, they describe what cleanup actions (i.e., which destructors to call) are necessary if an exception propagates through that point.

C++ Exception Handling - The gory details of an implementation

The manual approach has an overhead even when no exceptions are thrown. Modern C++ compilers on Linux use a 'zero-cost' approach based on metadata tables. This next segment of the video introduces this table-driven mechanism, explaining how the compiler can know at compile-time what cleanups are needed for any point in the code.

Watch from 11:02 to 12:39. The key takeaway here is the shift in thinking: instead of registering cleanup functions at runtime, the compiler generates a static table that maps a program counter location to the required cleanup actions. This is the foundation of 'zero-cost' exceptions.


The Unwinding Process: A Tale of Two Phases

When an exception is thrown, the runtime uses these tables to perform the unwinding. This process, specified by the Itanium C++ ABI (a standard followed by GCC and Clang on Linux), is divided into two distinct phases.

CppCon 2017: Dave Watson “C++ Exceptions and Stack Unwinding”

The process of using these tables to handle an exception is standardized by the Itanium C++ ABI. It specifies a two-phase process: a 'search phase' to find a handler, and a 'cleanup phase' to run destructors. This CppCon talk explains this process very clearly.

Watch from 12:50 to 15:39. Focus on understanding the distinct purpose of Phase 1 (find a handler without changing anything) and Phase 2 (actually unwind and clean up once a handler is confirmed). This two-phase approach is crucial for correctness.

Let's summarize the two phases:

  1. Phase 1: The Search Phase

    • The runtime walks back up the call stack, frame by frame.
    • In each frame, it consults a language-specific "personality routine" and the exception tables to see if there is a catch block that matches the type of the thrown exception.
    • Crucially, no destructors are called in this phase. The sole purpose is to find a handler.
    • If a handler is found, the process moves to Phase 2.
    • If the top of the stack is reached and no handler is found, the program calls std::terminate.
  2. Phase 2: The Cleanup Phase

    • The unwinding process starts over from the throw location.
    • The runtime again walks up the stack, frame by frame, towards the catch block that was found in Phase 1.
    • This time, as it enters each stack frame, it consults the tables and executes all necessary cleanup code, which means calling the destructors for all fully constructed local objects in that frame.
    • Once the unwinder reaches the frame with the catch block, control is transferred to the catch block, and normal execution resumes.

This two-phase process ensures that cleanup only happens if a valid handler for the exception exists.


Under the Hood: Traversing the Stack Frames

We've established that the unwinder "walks the stack," but how does it do this mechanically? In your C programming, you might be familiar with the idea of a frame pointer (like the EBP register on x86) which points to the previous frame's base, forming a simple linked list.

However, for performance, modern compilers often omit the frame pointer and use the register for other purposes. So how does the unwinder find its way? The answer lies in the same DWARF-based metadata we discussed earlier.

The following video segments explain both the traditional frame-pointer method and the modern DWARF Call Frame Information (CFI) mechanism. This is the deep internal detail you're looking for.

C++ Exception Handling - The gory details of an implementation

We've seen that the unwinder 'walks the stack', but how does it do that mechanically? Especially when compilers often optimize away the frame pointers that would form a simple linked list of stack frames. The next part of our deep-dive video explains the modern mechanism: DWARF Call Frame Information (CFI). This is where the true 'under the hood' magic happens.

This is the most detailed part of the lesson. Watch from 19:43 to 40:21. First (19:43 - 25:00), understand the traditional way of walking the stack using frame pointers (EBP/BP) to form a linked list. This is a good baseline given your C background. Then (26:37 - 40:21), focus on the modern DWARF CFI approach. You don't need to memorize every detail, but grasp the core concept: a compact, table-based 'virtual machine' is used to reconstruct the state of the previous frame at any instruction pointer, eliminating the need for a dedicated frame pointer register. This is what enables stack unwinding in highly optimized code.

The key idea of CFI is that for any instruction in a function, the compiler can precisely describe how to find the previous stack frame and restore any registers that were saved on the stack. This information is encoded in a compact format in the .eh_frame section and is interpreted by the runtime unwinder when an exception is thrown.


Tying It All Together

Let's solidify this with a textual resource that summarizes the entire layered architecture, from the C++-specific ABI functions down to the language-agnostic unwinder.

How do exceptions work in C++ on Linux?

Let's consolidate everything we've seen in the videos with a textual summary. This article walks through the layers of the exception handling mechanism on Linux, from the C++ ABI functions like __cxa_throw down to the _Unwind_* functions that perform the unwinding.

Please read the section 'Itanium Unwind ABI'. It describes the two-phase process (unwind_phase1 and unwind_phase2) and the role of the 'personality routine'. This text reinforces how the language-specific part (C++'s rules for catch and destructors) plugs into the language-agnostic unwinding machinery.

So, the complete process that enables RAII is:

  1. Compilation: The compiler sees a local RAII object (e.g., FileHandle fh;). It generates an entry in the .eh_frame table for the function. This entry effectively says: "For the range of instructions where fh is alive, the cleanup action is to call FileHandle::~FileHandle() on it."
  2. Exception Thrown: Your code executes throw std::runtime_error("...");. The C++ runtime library calls a low-level function like _Unwind_RaiseException.
  3. Phase 1 (Search): The unwinder starts. Using the CFI data, it walks up the stack frame by frame. In each frame, it calls the C++ "personality routine" which checks if a catch(...) or catch(std::runtime_error&) exists. Let's say it finds one in main().
  4. Phase 2 (Cleanup): The unwinder starts again from the throw site.
    • It unwinds the current frame, sees the RAII object's entry in the exception table, and calls its destructor.
    • It unwinds the next frame up, again calling destructors for any local RAII objects there.
    • This continues until it reaches the main() stack frame.
  5. Handling: The personality routine for main() indicates this is the handling frame. The unwinder sets the instruction pointer to the beginning of the catch block and restores the registers. Execution resumes inside the catch block.

The resource is cleaned up automatically and deterministically because its cleanup logic is encoded by the compiler and executed by the runtime during Phase 2 of the unwind.

Test your understanding!

Consider the following code:

struct Tracer {
    std::string name;
    Tracer(std::string n) : name(std::move(n)) { std::cout << "Ctor: " << name << std::endl; }
    ~Tracer() { std::cout << "Dtor: " << name << std::endl; }
};

void func_C() {
    Tracer c("C");
    std::cout << "In func_C, throwing..." << std::endl;
    throw std::runtime_error("error");
}

void func_B() {
    Tracer b("B");
    func_C();
    std::cout << "This will not be printed." << std::endl;
}

void func_A() {
    Tracer a("A");
    try {
        func_B();
    } catch (const std::runtime_error& e) {
        std::cout << "Caught exception in A: " << e.what() << std::endl;
    }
}

Based on your understanding of the two-phase unwinding process, what will be the exact output of running a main function that just calls func_A()? Explain why the destructors are called in that specific order in relation to the catch block.

Show answer

The exact output will be:

Ctor: A
Ctor: B
Ctor: C
In func_C, throwing...
Dtor: C
Dtor: B
Caught exception in A: error
Dtor: A

Explanation based on the two-phase process:

  1. Execution & Construction: func_A is called, constructing a. func_B is called, constructing b. func_C is called, constructing c.
  2. Throw: func_C throws an exception. The unwinding process begins.
  3. Phase 1 (Search):
    • The unwinder inspects func_C's frame. No catch block.
    • It inspects func_B's frame. No catch block.
    • It inspects func_A's frame. It finds a catch (const std::runtime_error& e) block that matches the exception type. A handler has been found. The search phase ends successfully. No destructors have been called yet.
  4. Phase 2 (Cleanup):
    • The process restarts from func_C.
    • It unwinds func_C's frame. The exception tables for this frame dictate that the destructor for object c must be called. "Dtor: C" is printed.
    • It unwinds func_B's frame. The exception tables for this frame dictate that the destructor for object b must be called. "Dtor: B" is printed.
    • It reaches func_A's frame. This is the designated handler frame.
  5. Handling: Control is transferred to the catch block within func_A. "Caught exception in A: error" is printed.
  6. Normal Scope Exit: The catch block finishes. func_A continues to its end. As func_A returns, the object a goes out of scope normally. Its destructor is called. "Dtor: A" is printed.

This demonstrates that destructors for objects in frames being unwound are called before the catch block is entered.


Conclusion

You have now journeyed deep into the C++ exception handling machinery. You've seen that stack unwinding is not magic; it is a well-defined, systematic process rooted in compiler-generated metadata and a standardized two-phase runtime protocol.

Key Takeaways:

  • Stack unwinding is the process of traversing the call stack in reverse, cleaning up stack frames when an exception is thrown.
  • Simple non-local jumps like C's longjmp are insufficient as they do not call destructors.
  • Modern C++ uses a "zero-cost" exception model, where the compiler generates static exception-handling tables (.eh_frame).
  • The Itanium C++ ABI defines a two-phase unwinding process: a search phase to find a handler and a cleanup phase to execute destructors.
  • RAII works because the compiler encodes the need to call destructors into these tables, which the runtime is guaranteed to execute during the cleanup phase.

What's Next?

In the previous lesson, we were introduced to std::unique_ptr with a custom deleter as a convenient way to implement the RAII pattern for C-style handles. In the next lesson, we will focus entirely on std::unique_ptr, exploring its core design principle of exclusive ownership and its move-only semantics. This is the first of the modern C++ smart pointers we will master.