CppCon
Table-based C++ exceptions function as binary code compression on microcontrollers because stripping distributed error-check branches outpaces unwind metadata overhead once a firmware project surpasses 550 fallible operations.
AI-assisted summary of C++ Exceptions for Smaller Firmware - Khalil Estell - CppCon 2024 — original source. Check the original for full context.
Source published:
Key ideas
- Embedded C++ dogma routinely bans exceptions due to perceived binary bloat, yet default GCC runtime bloat (~150 KB) is primarily caused by verbose terminate handlers that can be reduced to ~13 KB by overriding weak symbols. (17:22)
- Code-size modeling demonstrates that table-based exceptions function as code compression, breaking even against error-return checks (such as std::expected) at roughly 4 to 7.5 fallible calls per function. (67:19)
- Dynamic memory allocation and RTTI bloat can be completely avoided by overriding __cxa_allocate_exception with static storage and compiling with -fno-rtti, which retains type info only for explicitly thrown types. (19:05)
- A customized ARM unwinder cuts GCC libunwind runtime size down to ~4.5 KB while reducing unwinding latency from a 27x penalty over std::expected down to 4.77x at a 6-frame depth. (71:05)
Detailed notes
## Background Published on YouTube by CppCon on December 6, 2024, this presentation features Khalil Estell, an ISO C++ standards committee member with extensive production firmware experience at Google. The talk explores how C++ exceptions—long avoided in embedded development—can be properly configured to reduce firmware binary sizes compared to value-based error handling.
## TL;DR Khalil Estell challenges the long-standing embedded systems dogma of compiling with `-fno-exceptions` by demonstrating that C++ exceptions can serve as an effective form of code compression. While trivial programs experience an initial runtime overhead, real-world firmware with extensive error-checking boilerplate (`std::expected` or return codes) scales poorly because distributed check branches add code at every call site. By dissecting the ARM Exception Handling ABI, `.ARM.exidx`, `.ARM.extab`, and eliminating bloat through custom weak symbol overrides like `__cxa_allocate_exception`, Estell reveals that exceptions beat return codes in code density across larger codebases. He also introduces an ELF-inspecting static analysis tool, Exception Insights, to resolve the safety and visibility concerns traditionally associated with exceptions in bare-metal environments.
## Atmosphere & style The presentation is energetic, irreverent, and deeply technical, blending self-deprecating humor with razor-sharp disassembly analysis. Estell commands the stage like an enthusiastic hacker-educator, playfully bantering with audience members, reacting to the dread of assembly math, and poking fun at modern language rivalries with Rust. The talk transitions seamlessly from high-level architectural frustrations down to single-byte LEB128 encodings, creating an atmosphere of an eye-opening deep dive that dismantles established programming dogma.
## Bulletin - [17:22](https://www.youtube.com/watch?v=bY2FlayomlE&t=1042s) Embedded C++ dogma routinely bans exceptions due to perceived binary bloat, yet default GCC runtime bloat (~150 KB) is primarily caused by verbose terminate handlers that can be reduced to ~13 KB by overriding weak symbols. - [01:07:19](https://www.youtube.com/watch?v=bY2FlayomlE&t=4039s) Code-size modeling demonstrates that table-based exceptions function as code compression, breaking even against error-return checks (such as std::expected) at roughly 4 to 7.5 fallible calls per function. - [19:05](https://www.youtube.com/watch?v=bY2FlayomlE&t=1145s) Dynamic memory allocation and RTTI bloat can be completely avoided by overriding __cxa_allocate_exception with static storage and compiling with -fno-rtti, which retains type info only for explicitly thrown types. - [01:11:05](https://www.youtube.com/watch?v=bY2FlayomlE&t=4265s) A customized ARM unwinder cuts GCC libunwind runtime size down to ~4.5 KB while reducing unwinding latency from a 27x penalty over std::expected down to 4.77x at a 6-frame depth.
## Key takeaways - [01:04](https://www.youtube.com/watch?v=bY2FlayomlE&t=64s) Bare-metal microcontroller environments operate under severe flash and RAM constraints, routinely prohibiting dynamic heap allocation and large runtime libraries. - [04:46](https://www.youtube.com/watch?v=bY2FlayomlE&t=286s) Abstracting fallible hardware peripherals (like I2C IO expanders) behind clean, infallible void interfaces creates architectural conflicts that error-code propagation handles poorly. - [10:53](https://www.youtube.com/watch?v=bY2FlayomlE&t=653s) Propagating error values across codebases via std::expected or result types introduces widespread boilerplate that tempts developers to silently discard errors. - [17:42](https://www.youtube.com/watch?v=bY2FlayomlE&t=1062s) The massive binary bloat observed when first enabling exceptions on ARM is caused by formatting utilities in __gnu_cxx::__verbose_terminate_handler, which can be eliminated with a trivial weak symbol override. - [19:05](https://www.youtube.com/watch?v=bY2FlayomlE&t=1145s) Overriding __cxa_allocate_exception allows exception payloads and headers to reside in deterministic, pre-allocated static storage, removing malloc requirements. - [20:01](https://www.youtube.com/watch?v=bY2FlayomlE&t=1201s) Compiling with -fno-rtti does not trigger undefined behavior with exceptions; it safely removes type information for all types except those explicitly thrown or caught. - [24:49](https://www.youtube.com/watch?v=bY2FlayomlE&t=1489s) Error-as-value propagation imposes a distributed tax scaling with call sites (N), whereas exception tables scale primarily with non-leaf function definitions (M). - [27:22](https://www.youtube.com/watch?v=bY2FlayomlE&t=1642s) In controlled benchmarks, exception-based code crossed over to become smaller than result-based code at approximately 550 checked call sites. - [37:28](https://www.youtube.com/watch?v=bY2FlayomlE&t=2248s) ARM Exception Index (.ARM.exidx) and Exception Table (.ARM.extab) structures allow trivially unwindable functions to encode return adjustments in as few as 8 bytes. - [50:23](https://www.youtube.com/watch?v=bY2FlayomlE&t=3023s) Catch matching under the Itanium ABI relies on fast pointer equality between type descriptors rather than runtime string comparisons. - [01:02:39](https://www.youtube.com/watch?v=bY2FlayomlE&t=3759s) In real-world firmware, functions requiring catch blocks or complex automatic destructors are rare, meaning most functions pay only the minimal 8-byte unwind table cost. - [01:08:28](https://www.youtube.com/watch?v=bY2FlayomlE&t=4108s) Because exceptions eliminate branch instructions and epilogue handling from call sites, table-based unwinding acts effectively as binary code compression. - [01:11:05](https://www.youtube.com/watch?v=bY2FlayomlE&t=4265s) GCC's ARM unwinder has suffered from decades of stagnation, but streamlined implementations significantly reduce unwinding overhead and runtime size. - [01:12:25](https://www.youtube.com/watch?v=bY2FlayomlE&t=4345s) The 'Exception Insights' binary analysis tool resolves the invisibility problem of exceptions by deducing throw sites, catch targets, and noexcept violations directly from compiled ELF binaries.
## Detailed walkthrough ### [00:08](https://www.youtube.com/watch?v=bY2FlayomlE&t=8s) Introduction and Embedded Background
Khalil Estell introduces himself and his goal for the session: demonstrating how developers can actually make their binaries smaller by embracing C++ exceptions, running directly counter to prevailing embedded dogma. Estell graduated from San Jose State University in 2017 and is known online by his handle across various platforms. Before his current work, he was an engineer at Google working on consumer hardware. On the original Google Pixel Watch, he served as the directly responsible engineer across several core hardware domains—including haptics, the bootloader, the speaker, and the microphone—before transitioning to the Google Pixel Buds team to focus on low-level code optimization to reclaim enough flash space for new features and bug fixes. In addition to serving on the ISO C++ standards committee, he continues to volunteer at SJSU, primarily mentoring their student robotics team.
Estell emphasizes his deep background in resource-constrained systems to frame the talk's thesis: > “I was the directly responsible engineer for the haptics and the bootloader and the speaker and the microphone and eventually when I got tired of that I moved over to the Google Pixel Buds and worked on code optimization so we could fit more features and bug fixes into the device.”
[01:04](https://www.youtube.com/watch?v=bY2FlayomlE&t=64s) Bare-Metal Reality: No OS, Limited Flash, and Restricted RAM
The motivation for this research originated six years prior while Estell was teaching Computer Engineering 146, an introductory embedded systems course at SJSU. For the class, he authored an educational C++ firmware framework built around the C++ Core Guidelines and designed a custom development board for students. A central lesson for engineers entering bare-metal development is the complete absence of an operating system. Without an underlying OS, developers do not get standard POSIX utilities: there is no `fopen`, no filesystem, no network stack, no serial terminal to receive standard output, no system clock, and no threading primitives. Any of these capabilities must either be hand-rolled or supplied by external libraries, but external libraries run immediately into brutal hardware storage walls.
A real-time search on electronic distributor DigiKey illustrates the steep memory cliff in the microcontroller market. Flash memory and RAM distributions show that the vast majority of available microcontrollers sit far below desktop-scale capacities:
| Resource Metric | Below Threshold | Above Threshold | Dominant Tier | |:--- |:--- |:--- |:--- | | **Flash Memory** (1 MB threshold) | ~33,000 devices | ~5,000 devices | < 1 MB | | **SRAM** (100 KB threshold) | ~31,000 devices | ~7,000 devices | < 100 KB |
To avoid out-of-memory crashes and heap fragmentation across these tiny memories, firmware guidelines routinely ban dynamic allocation primitives such as `malloc`, `free`, `calloc`, `new`, and `delete`. This restriction also disqualifies standard heap-allocating STL containers. While embedded practitioners often argue that allocation during boot is acceptable so long as memory is never reallocated or freed at runtime, general-purpose containers remain problematic. Developers turn instead to static-storage libraries like the Embedded Template Library (ETL), or the newly standardized `std::inplace_vector` voted into C++26 at the St. Louis meeting, which provides fixed-capacity contiguous storage alongside safety-checking APIs like `try_push_back`.
> “The first thing that I teach a lot of my students is that when you're developing for microcontrollers and doing bare-metal development you don't have an operating system that means you don't have access to fopen, you don't have a file system, you don't have networking, you don't have a terminal to print to, you don't even have a clock or even threads.”
[03:28](https://www.youtube.com/watch?v=bY2FlayomlE&t=208s) Why Firmware Developers Reject C++ Exceptions
Given these physical constraints, firmware teams almost universally compile with `-fno-exceptions`. Estell catalogs the litany of common objections raised against exception handling in embedded engineering: - **Heap Allocation Requirements:** Standard exceptions are widely assumed to require `malloc` to instantiate exception objects, introducing the risk of unbounded heap usage during fault conditions. - **Binary Bloat and Transitive Dependencies:** There is a persistent belief that enabling exceptions drags in heavyweight runtime overhead. One developer warned Estell that flipping the switch pulls in the entire standard library, including `std::string` and `std::vector`. - **RTTI Overhead:** Exception matching relies on runtime type information (RTTI), adding metadata tables that consume precious flash. - **Table and Runtime Complexity:** Exception unwinding relies on `.eh_frame` exception tables and personality routines that demand significant flash footprint. - **Non-Determinism and Latency:** The unwinding process requires binary searching through tables, calculating frame offsets, and evaluating landing pads, making worst-case execution time hard to bound for real-time safety.
Estell notes that while these concerns are pervasive, many of them stem from misunderstandings or historical implementation details that can be engineered around.
> “I had one individual tell me that if you use exceptions it brings in the entire STL, all string, vector, everything that get brought into your binary, can't handle that, it's too much memory being used up.”
[04:46](https://www.youtube.com/watch?v=bY2FlayomlE&t=286s) An Infallible Abstraction Meets a Fallible Bus: The IO Expander Problem
To demonstrate how error handling challenges manifest in firmware design, Estell recalls drafting basic hardware driver abstractions for his students. The simplest abstraction is an `output_pin`, which controls a physical microcontroller pin by toggling its electrical state between low voltage (ground, 0V) and high voltage (VCC). With an LED connected between the pin and ground, pulling the pin high establishes a potential difference, driving current and illuminating the LED. Estell modeled this with a minimalist interface: a single member function `void level(bool)`, where `true` maps to high and `false` maps to low. Because setting an on-chip register bit is practically infallible, a `void` return type felt natural.
The interface breaks down when the pin is not an on-chip GPIO, but located across a serial communication bus such as I2C (Inter-Integrated Circuit). I2C uses two open-drain physical lines—serial clock (SCL) and serial data (SDA)—to connect a bus controller to multiple peripheral devices:
```mermaid flowchart TD accTitle: Infallible Output Pin Interface Over a Fallible I2C Bus accDescr: Flowchart showing an application invoking a void level function which translates into an I2C transaction that can fail due to NACK or bus errors.
subgraph Host["Microcontroller Host"] App["Application Logic"] Pin["output_pin Interface<br/>void level(bool)"] I2CDrv["I2C Controller Driver"] App --> Pin Pin --> I2CDrv end
subgraph Bus["I2C Communication Bus"] Start["Start Signal + Address + R/W"] Ack{"Device ACK?"} Data["Write Data Byte + ACK"] Start --> Ack Ack -->|"ACK"| Data end
subgraph Peripheral["Off-Chip Hardware"] Expander["I2C IO Expander"] LED["Physical Pin Output (LED)"] Expander --> LED end
I2CDrv --> Start Data --> Expander Ack -.->|"NACK (Fault)"| Err["I2C Error State"] ```
I2C operations are fundamentally fallible. The controller issues a start condition, broadcasts a 7-bit device address with a read/write bit, and waits for the target device to pull SDA low for an acknowledge (ACK) bit. If the target is absent or unresponsive, a non-acknowledge (NACK) occurs. While a missing device may be benign in hot-swappable modular systems, losing communication with a flight-controller IMU on a drone causes catastrophic failure. Furthermore, bus lines can experience physical IO errors due to electromagnetic interference (EMI) or rogue peripherals locking the lines.
When an engineer runs out of native GPIOs and adds an external I2C-driven IO expander, an architectural conflict arises: controlling the pin now requires an I2C transaction that can fail with NACKs or IO errors, yet the existing `output_pin` interface is modeled as an infallible `void level(bool)`.
> “But here comes the rub: the I2C side is a fallible interface and the output pin is an infallible interface, at least the way that I wrote it prior.”
[08:50](https://www.youtube.com/watch?v=bY2FlayomlE&t=530s) The Pain of In-Band Error Reporting and Moving to `std::expected`
When exceptions are turned off, handling failure in embedded firmware becomes messy very quickly. If a function cannot return an error code directly, developers are forced into bad patterns: setting an internal state variable on the object or setting a global `errno`-style flag, requiring the caller to check a separate status function. “Setting up `errno` is not a good option—we all kind of figured that part out—and I don't think that using an internal variable and having to call an out-of-line function is any better than `errno`.”
```mermaid flowchart TD accTitle: Hardware Failure Severity Spectrum accDescr: Spectrum of embedded failure modes from mild LED indicators up to critical emergency stop relays. subgraph NonCritical["Mild Impact"] LED["Status LED fails to light"] end subgraph MediumCritical["System Impact"] CEL["Check-engine light fails"] Heat["Heating element fails during chemical reaction"] end subgraph HighCritical["Critical Impact"] Relay["Emergency cut-off relay fails to trip"] end LED --> CEL --> Heat --> Relay ```
Khalil Estell considered whether this is genuinely a serious problem by walking through what his students might actually control with a GPIO output pin: - **Toggling a generic indicator LED:** If it fails to turn on, the user might be mildly annoyed, but it is rarely catastrophic. - **A check-engine warning light:** If an alert LED fails to light up when an engine is failing, the consequences escalate immediately. - **A heating element:** If that pin drives a heater regulating a chemical reaction, failing to switch or report failure can ruin a batch or cause dangerous runaway conditions. - **An emergency cut-off relay:** Even if the software cannot physically recover when a hardware fault prevents a relay from tripping, “at the very least we can tell you that something went wrong when you tried to attempt this action.”
Recognizing that silent failures are unacceptable, Estell decided to migrate all his APIs to functional error handling using `std::expected`. He defined a custom type alias—`status`—where the expected value type is `void` and the error type is a custom error enumeration. For functions producing values, the return type becomes a `result<T>`, holding either the requested data (such as a device version number) or an error created via `std::unexpected` when a precondition is violated.
[10:53](https://www.youtube.com/watch?v=bY2FlayomlE&t=653s) Syntactic Noise, Propagation Virality, and Student Backlash
Adopting `std::expected` fundamentally transformed what user code looked like. An operation as simple as blinking an LED—setting a pin high, delaying, setting it low, and delaying again—is clean and readable when using exceptions or assuming an infallible world. With `std::expected`, however, every single call must be followed by an explicit check on the returned status; if an error is present, the function must unpack it and return an unexpected value upward.
```mermaid flowchart LR accTitle: Macro-based Error Propagation Flow accDescr: Demonstrates how sj_check intercepts failures and returns them early out of the current function. Call["Invoke fallible API"] --> Check{"Result ok?"} Check -- "Success" --> Unpack["Extract value / continue happy path"] Check -- "Failure" --> EarlyRet["Early return std::unexpected(err)"] ```
To reduce the mountain of boilerplate, Estell looked to paper P2616R1, which proposed an error propagation operator using `??` (very similar to the `?` operator in Rust) to automatically propagate errors up the call stack. He even emailed the author of the proposal, though he never heard back—a fact he now views as a blessing in disguise, noting that had it been accepted, “it would have caused me to not go and do the research that I did currently.” In the absence of native language support, he wrote a macro named `SJ_CHECK` to evaluate the expression and early-return any failure.
Yet the problem went deeper than syntax. Because hardware protocols like I²C are inherently fallible, and virtually anything—from motor controllers to graphical displays—can be connected over an I²C bus, almost every interface had to be marked fallible. This triggered an explosion of error types across the codebase. Over two and a half years of forcing this paradigm on students, the cracks showed: - **Discarding errors:** Students hated writing repetitive `if` statements so intensely that they began casting error returns to `(void)` to bypass them. Estell recalled spending a long debugging session with a student who was stuck for a week, only to discover that her code ignored an address failure on an I²C device that proper error propagation would have highlighted immediately. - **Pervasive virality:** Students grew frustrated that fallibility infected every layer: “Man, I got to make every single thing return back status or results every single time; if I want to call anything that could potentially be fallible, I got to put it everywhere—it's everywhere and it touches everything.” - **Visual disruption:** Macros obscured the primary execution path. Developers had to mentally block out lines filled with macro wrappers just to see the happy path before turning their attention to the sad path.
[14:11](https://www.youtube.com/watch?v=bY2FlayomlE&t=851s) The Shift to Re-examining C++ Exceptions
Inevitably, students asked the obvious question: “Why not just use C++ exceptions? They're built into the language, they're meant for error handling, wouldn't that get rid of a lot of the problems that we're dealing with?”
For years, Estell gave them the standard embedded dogma. He would present diagrams, cite articles, and switch compiler flags from `-fno-exceptions` to `-fexceptions` to watch the resulting binary explode in size, asking them rhetorically why anyone would want such bloat. Most students accepted this, but some pressed deeper: - Why are exceptions non-deterministic? - Why do they have to be slow? - Why do they bloat the binary so severely? - Why do they require dynamic heap allocation?
Answering those questions made Estell increasingly uneasy. “I did not like the idea that there was that I was telling my students this information about why you shouldn't use something that I don't know how it works—it's a black box to me, I just know what I've been told from other people.” Feeling guilty about propagating dogma without firsthand technical proof, he decided to give exceptions a fair, rigorous evaluation on bare-metal ARM microcontrollers.
[15:24](https://www.youtube.com/watch?v=bY2FlayomlE&t=924s) Enabling Exceptions on Bare-Metal ARM and the Bloat Shock
Getting C++ exceptions to actually execute on ARM hardware is not straightforward:
```mermaid flowchart TD accTitle: Steps and Roadblocks Enabling ARM Exceptions accDescr: Sequence showing the barriers encountered when turning on exceptions in an ARM toolchain. subgraph Obstacle1["Barrier 1: Toolchain Defaults"] A["Default GNU ARM toolchain"] --> B["Throws trigger std::terminate immediately"] end subgraph Solution1["Recompilation & Runtime"] B --> C["Rebuild runtime libraries with -fexceptions"] C --> D["Implement sbrk stub for malloc backing"] end subgraph Obstacle2["Barrier 2: Massive Code Size"] D --> E["Throw & Catch successfully executes"] E --> F["Binary balloons from 2.6 KB to ~150 KB"] end ```
The first obstacle is that the precompiled GNU ARM toolchains disable exceptions by default in the runtime libraries. If code attempts to throw an exception, it immediately calls `std::terminate`, regardless of how many valid `try`/`catch` blocks are in place. Worse, the toolchain still links in substantial runtime machinery needed for exceptions, causing binary bloat without providing functional error handling.
To get around this, Estell had to download the runtime source, locate where `-fno-exceptions` was defined, flip it to `-fexceptions`, and recompile the toolchain runtime libraries. Additionally, because the standard exception runtime allocates exception objects dynamically, he had to implement `_sbrk`, the low-level system call stub that supplies raw heap memory pages to `malloc`.
Once those obstacles were cleared, a minimal program—a `main()` calling a `[[gnu::noinline]]` `start()` function that throws an object and lands in a `catch` block—finally worked. But the cost was staggering: - The minimal binary jumped from **2.6 KB** to approximately **150,000 bytes** (~150 KB). - On a microcontroller with only 512 KB (half a megabyte) of total flash memory, the exception machinery alone consumed roughly **30%** of the entire device capacity just to execute a basic throw and catch.
Refusing to accept this overhead at face value, Estell set out to eliminate the bloat. He disassembled the generated ELF binary to inspect the assembly instructions, hunting for symbols inserted by the compiler and runtime libraries. Among the unexpected symbols dominating the output, one immediately stood out: `__gnu_cxx::__verbose_terminate_handler` and a symbol called `Global string callback`.
[17:42](https://www.youtube.com/watch?v=bY2FlayomlE&t=1062s) Eliminating Binary Bloat with Weak Symbols
While inspecting the binary bloat caused by enabling exceptions, Khalil Estell discovered unexpected standard library components embedded in his build: string adapters, `append_num`, `sprintf`, and `strlen`. Tracing these symbols led to `__gnu_cxx::__verbose_terminate_handler()`, the routine responsible for printing human-readable panic messages such as `"terminate called after throwing an instance of my_error"`. The verbose formatting and string manipulation required for this output were the direct cause of the massive binary size.
```mermaid flowchart TD accTitle: Replacing Default Verbose Terminate Handler accDescr: GCC default verbose terminate pulls in heavy string dependencies, but overriding it with a custom weak symbol reduces binary size significantly.
subgraph Default ["Default GCC Behavior (~150 KB)"] A["Default __verbose_terminate_handler()"] --> B["String Adapters & Sprintf"] B --> C["Heavy I/O & Formatting Bloat"] end
subgraph Custom ["Overridden Implementation (~13 KB)"] D["User Definition (Weak Symbol Override)"] --> E["Empty while(1) Infinite Loop"] E --> F["91% Flash Savings"] end ```
To eliminate this overhead, Estell took advantage of weak symbols in GCC and Clang. A weak symbol allows developers to define a variable or function with the same name that overrides the default implementation provided by the standard runtime libraries without causing linker collisions. By replacing the verbose terminate handler with a custom function containing a trivial infinite `while` loop, the compiler omitted the formatting dependencies entirely.
- Overriding the terminate handler reduced the overall binary footprint by 91%, shrinking the image from 150 KB down to approximately 13 KB. - Estell remarked on the result: “In five simple lines you can eliminate all of this... 150k nah it's not okay but 13 kilobytes I can do a lot with that.”
---
[19:05](https://www.youtube.com/watch?v=bY2FlayomlE&t=1145s) Overriding Dynamic Allocation via `__cxa_allocate_exception`
Despite the massive space savings, a significant architectural obstacle remained: C++ exceptions depend on dynamic memory allocation, pulling in `sbrk` and `malloc`. Estell noted the pedagogical dilemma this created: “It feels weird to teach my students not to use dynamic memory allocation and then my mechanism that is in their software development kit uses dynamic memory allocation.”
Investigating the throw assembly revealed that the runtime relies on `__cxa_allocate_exception` to provision storage for in-flight exception objects. Because this runtime function is also declared as a weak symbol, it can be replaced with an application-defined allocator. Overriding this entry point allowed Estell to supply a custom, fixed pre-allocated buffer with known bounds, bypassing the general-purpose heap entirely and giving embedded developers deterministic control over the memory overhead.
---
[20:01](https://www.youtube.com/watch?v=bY2FlayomlE&t=1201s) The Myth of RTTI Bloat and `-fno-rtti`
The next major hurdle was Run-Time Type Information (RTTI). While RTTI overhead was manageable in small experiments, Estell was concerned that adding more types and abstractions to larger applications would cause RTTI metadata to scale uncontrollably. To test what happens under `-fno-rtti`, Estell presented the audience with three possible compiler reactions when combining `-fno-rtti` with active exception handling:
1. The compiler throws a hard compilation error because exceptions require RTTI. 2. The compiler strips all RTTI metadata except for types specifically involved in exception handling. 3. The compiler silently strips RTTI metadata, resulting in undefined behavior when `throw` is called.
The majority of the audience voted for option 3, assuming C++ would quietly break at runtime. Estell joked about their cynicism: “Do all are you guys all Rustaceans? Are you guys all infiltrated CppCon? Because here's the thing, why do you guys all assume that C++ defaults to UB? No, the answer is number two.”
Compiling with `-fno-rtti` selectively strips type descriptors for every object in the program except those explicitly thrown or caught. Furthermore, the true memory layout cost of RTTI descriptors is far smaller than commonly assumed:
| Class Inheritance Hierarchy | Descriptor Size (Pointers) | |:--- |:--- | | Standalone object (no inheritance) | 2 pointers | | Single inheritance (inherits 1 parent) | 3 pointers | | Multiple inheritance (inherits $N$ parents) | $3 + N$ pointers |
Because embedded applications throw relatively few unique exception types, RTTI costs proved practically negligible. Estell noted that while the runtime once dragged in `iostream`, `locale`, and basic string machinery totaling 150 KB, modern ARM toolchains have improved significantly, dropping default costs down to around 84 KB to 87 KB before manual pruning.
---
[23:04](https://www.youtube.com/watch?v=bY2FlayomlE&t=1384s) Real-World Embedded Results: The MP3 Player Surprise
Confident that heap allocation and RTTI were manageable, Estell migrated the `sjsu-dev2` educational framework to C++ exceptions, removing the legacy error macros. Concurrently, he maintained an alternative pull request refactoring the same codebase to functional result types so he could directly compare real-world size and usability metrics.
```mermaid flowchart LR accTitle: Codebase Transition to Exceptions vs Result Types accDescr: Small demos gained size with exceptions, but complex projects like an MP3 player shrank from 56 KB to the 40s KB range.
subgraph Evaluation ["Architectural Paths Tested"] A["Legacy Macro Codebase"] --> B["C++ Exceptions Engine"] A --> C["Functional Result Types PR"] end
subgraph Impact ["Binary Size Trajectory"] B --> D["Tiny Demos (Blinky): Size Increased"] B --> E["Complex Applications (MP3 Player): Size Decreased (56 KB down to 40s KB)"] end ```
The results showed a clear split based on project size: - **Trivial projects** (such as LED blinkers and "Hello World" demos) grew larger with exceptions due to the fixed baseline cost of the unwinding machinery. - **Complex applications** saw their binary footprints contract noticeably. A student final project—an MP3 player targeting a microcontroller with 64 KB of flash—shrank from approximately 56 KB under macro/error return values down to the 40 KB range under C++ exceptions.
Reflecting on the findings, Estell wrote: “It was decided to transition to exception handling rather than error return types for large enough projects exceptions are small in code size and faster when exceptions are not being thrown.” However, he sat on the discovery for years, unsure if anyone would care and half-jokingly admitting he feared blowback from online communities: “I was also afraid of the Rust community coming after me, because if I showed anything positive for C++ I was certain they'd come after me and I'd get just completely washed on any social media.”
---
[24:49](https://www.youtube.com/watch?v=bY2FlayomlE&t=1489s) The Mathematical Hypothesis: Result Types vs. Exceptions
The silence ended at CppCon 2023 during an ISO C++ committee gathering. Patrice Roy gave a presentation on SG14 activities, debating whether `std::pmr::vector` could be safely used in embedded systems given that `push_back` lacks an in-band failure channel and cannot notify developers of allocation failures without panicking or terminating. Estell mentioned that embedded systems could resolve this by turning on exceptions to achieve smaller binary footprints. Roy challenged the assertion: “Hold on, hold on, that goes against all of common knowledge, what do you mean?” Roy then asked for concrete numbers and invited Estell to present his findings at the Wednesday ISO working meeting.
To demonstrate why exceptions outperform result types as programs grow, Estell formalized a cost model for binary size:
- **Result Types Cost Formula:** $$\text{Cost} = A \cdot N$$ Where $A$ is the average size overhead (in bytes) of branches and status checks required per function call, and $N$ is the total count of checked function calls across the codebase. - **Exception Handling Cost Formula:** $$\text{Cost} = B \cdot M + E$$ Where $E$ represents the static footprint of the `libunwind` engine, $B$ is the average exception metadata overhead per function, and $M$ is the total count of non-leaf functions in the binary.
Because any non-trivial application invokes many more checked calls ($N$) than it has distinct non-leaf functions ($M$), the linear growth of error propagation sites ($A \cdot N$) will eventually outpace the fixed overhead of the unwinding engine ($E$) alongside metadata tables ($B \cdot M$), making exceptions the more compact solution at scale.
[27:22](https://www.youtube.com/watch?v=bY2FlayomlE&t=1642s) Code Size Crossing Point: Exceptions vs. Result Types
To rigorously test the binary size trade-off between exceptions and value-based error handling (such as `std::expected` or result types), Khalil Estell devised an automated code-generation experiment. Because firmware often contains extensive error propagation rather than local recovery, he generated large batches of isomorphic C++ code across two files: `except.cpp` and `result.cpp`. Both files performed the exact same operations—randomly defining classes with trivial and non-trivial destructors, instantiating objects, invoking APIs, and calling sequences of functions—diverging solely in how they propagated errors. Estell purposefully isolated error propagation: “From my experience, just to be real, I see more error propagation code than I see error handling code.” To prevent GCC and Clang from aggressively optimizing away the code or inlining everything across a single translation unit, volatile variables and side effects were added to force realistic code generation.
```mermaid flowchart LR accTitle: Binary Size Comparison Model accDescr: Graph showing where exceptions become smaller than return-based error checking. subgraph Growth ["Binary Size Growth"] R["Result / Expected Code<br/>Linear growth per check<br/>(+16.5 B/check)"] E["Exception Code<br/>Flat metadata tables<br/>(~867 B fixed runtime overhead)"] end Cross{"Crossing Point<br/>(~550 checks)"} R --> Cross E --> Cross Cross --> Win["Exceptions achieve smaller footprint"] ```
When compiling down the generated files and tracking binary growth against the number of checks, the data revealed a clear crossover point: - The baseline runtime overhead of the custom exception mechanism started at approximately 867 bytes, which closely mirrors the footprint of a minimal `libunwind`. - Result-based code grew significantly faster with each check, calculated by Google Sheets at approximately 16.5 bytes per check (`-16.5 * X` in the delta equation). - The crossover point occurred at roughly 550 error checks. Beyond this threshold, exception-based code resulted in a smaller overall binary size than return-value propagation. - Estell observed that 550 checks across an entire firmware application is remarkably small, noting: “I've definitely made single classes with up to 50 checks on its own.”
[29:43](https://www.youtube.com/watch?v=bY2FlayomlE&t=1783s) Demystifying Exceptions: Function Classifications
Estell emphasized that showing aggregate size graphs is insufficient to overcome long-held industry skepticism: “None of those graphs were meant to convince you at all. I don't think they're very convincing. I think they're there to show you something. If I want to convince you, I need to show you how exceptions work from throw to catch.” To do so, he restricted the scope to table-based unwinding models (explicitly omitting nested exceptions and alternative schemes like setjmp/longjmp), stating flatly: “To be honest, I think that table-based exceptions are the best.”
```mermaid flowchart TD accTitle: Exception Handling Function Classifications accDescr: Three categories of functions encountered during table-based exception unwinding. subgraph Types ["Function Classifications"] direction TB L["Leaf Functions<br/>• Do not call other functions<br/>• No destructors<br/>• No try/catch blocks<br/>• Never unwind"] T["Transparent / Trivial Functions<br/>• Call other functions<br/>• No destructors<br/>• No try/catch blocks<br/>• Frame skipped during unwind"] R["Reentrant Functions<br/>• Call other functions<br/>• Non-trivial destructors OR try/catch<br/>• Must be re-entered to clean up or handle"] end ```
To demonstrate the runtime mechanics, Estell uses a minimal sample program where function `foo` calls `bar` within a `try`/`catch` block, `bar` instantiates a destructible object and calls `baz`, and `baz` executes a `throw`: - **Leaf functions**: Do not invoke any other functions, contain no destructors, and have no `try`/`catch` blocks. Because they do not participate in error unwinding, they are irrelevant to exception runtime costs. - **Trivial / Transparent functions**: Call other functions but contain no local destructors and no `try`/`catch` handlers. In the example, `baz` is transparent; the unwinder simply steps through its frame without running cleanup code. - **Reentrant functions**: Call other functions and contain either active local destructors or `try`/`catch` blocks (or both). These functions must be re-entered during unwinding to execute cleanup routines or transfer control to a catch handler. In the sample code, both `foo` (handler) and `bar` (destructor) are reentrant.
[31:14](https://www.youtube.com/watch?v=bY2FlayomlE&t=1874s) ARM Calling Conventions and Register Preservation
Understanding exception unwinding requires understanding the target calling convention. On 32-bit ARM architectures: - Registers `r0` through `r3` serve as scratch registers, function argument inputs, and return values. - Registers `r4` through `r11` are callee-preserved (non-volatile) registers; any function that overwrites them must restore their exact initial state before returning to its caller. - `SP` (Stack Pointer / `r13`) tracks the top of the active stack frame. - `LR` (Link Register / `r14`) holds the return address where execution resumes once a function completes. - `PC` (Program Counter / `r15`) points to the instruction currently being executed.
In standard ARM assembly, a function prologue typically executes `push {r4, lr}`. This preserves the caller's `r4` value and saves the return address onto the stack. After the function performs its computations (often placing a return value in `r0` or using `r4` for scratch calculations), the epilogue executes `pop {r4, pc}`. Popping the link register directly into the program counter performs an immediate branch back to the caller while restoring preserved registers in a single instruction.
[32:50](https://www.youtube.com/watch?v=bY2FlayomlE&t=1970s) Throw Allocation and the Exception Header
When execution reaches a `throw` statement under the Itanium C++ ABI, the compiler emits a call to `__cxa_allocate_exception` passing the byte size of the exception object. Rather than allocating only the payload object, `__cxa_allocate_exception` allocates sufficient space for the user payload preceded immediately by an internal control structure called the exception header.
```mermaid flowchart LR accTitle: Exception Memory Allocation Layout accDescr: Memory layout showing the exception header placed directly before the thrown object. subgraph AllocatedBlock ["Single Memory Allocation"] direction LR subgraph Header ["Exception Header"] CPU["Virtual CPU Context<br/>(Preserved Registers)"] TI["std::type_info*"] DTOR["Destructor fn*"] Cache["Unwind Cache"] end subgraph Payload ["User Payload"] Obj["Thrown Object<br/>(e.g., 1-byte error enum)"] end end Header --- Payload ```
Estell notes that this header structure is his custom, compact implementation optimized for embedded systems rather than the stock Itanium header. The header stores: - A virtual representation of the CPU state (saved registers), because “exceptions, all they really do is calculate the previous state of what the CPU would have been if you had taken a different pathway.” - A pointer to the `std::type_info` descriptor of the thrown object to match against `catch` clauses. - A pointer to the object's destructor (or `nullptr` if trivially destructible) to clean up the exception object once handled. - Cache fields to accelerate subsequent frame lookups during propagation.
Estell explained why allocating the object and header together through `__cxa_allocate_exception` is preferable to a standard `malloc`: - **ABI stability and agility**: If the unwinder library changes the header size across releases (e.g., stripping unneeded registers or adding cache entries), code calling `__cxa_allocate_exception` remains binary-compatible. Hardcoding a raw `malloc` size directly into compiled application call sites would cause header offsets to break if the runtime changed. - **Allocation efficiency**: Combining both the internal runtime metadata and the exception object into a single allocation avoids heap fragmentation and eliminates the need for a secondary runtime allocation inside `throw`.
[35:01](https://www.youtube.com/watch?v=bY2FlayomlE&t=2101s) Entering `__cxa_throw` and Initiating the Unwind
Once the user object is constructed inside the allocated memory block, the compiler emits a call to `__cxa_throw`, passing three parameters: a pointer to the thrown payload, the `type_info` pointer, and the payload's destructor pointer.
Within `__cxa_throw`: - The active exception pointer is stored in thread-local storage (TLS) so that `std::current_exception` can reference it. Estell quipped about feedback from committee colleagues regarding TLS: “I know Ben Craig told me I need to get rid of TLS. I am working on it, don't worry.” - The runtime computes the location of the exception header by taking the address of the thrown type pointer and subtracting the size of the header: `header_ptr = object_ptr - sizeof(exception_header)`. - The `type_info` and destructor pointers are written into the header fields. - An immediate unwinding step is performed on `__cxa_throw` itself, capturing the CPU state of the throwing function (`baz`). The Link Register is moved into the Program Counter state so unwinding can evaluate the call site. - The runtime initiates unwinding (`_Unwind_RaiseException`). The unwinder enters an evaluation loop, searching the binary's exception tables for the exception entry corresponding to the current Program Counter to determine whether the enclosing function contains cleanup destructors or matching `catch` clauses.
[36:42](https://www.youtube.com/watch?v=bY2FlayomlE&t=2202s) The Unwind Decision Tree and Transitioning Paths
Khalil Estell steps through the logical flow executed during stack unwinding once an exception is active. The unwinder looks up the entry corresponding to the current execution state and begins evaluating questions: first, should unwinding be refused? For instance, if a function is marked `noexcept`, encountering an exception inside it requires an immediate transition to `std::terminate`. If not refused, the unwinder evaluates whether the function is reentrant. A transparent function with no cleanup needs can simply have its frame unwound immediately. If the function is reentrant—meaning it contains cleanup actions like destructor calls or local `catch` blocks—the unwinder must inspect it closely.
```mermaid flowchart TD accTitle: Exception Handling Evaluation Flow accDescr: Flowchart detailing how the exception unwinder decides between termination, entering the sad path, and resuming happy path execution.
subgraph Evaluation ["Frame Inspection"] A["Evaluate Frame Entry"] --> B{"Refuse Unwind?\n(e.g., noexcept)"} B -- "Yes" --> C["Call std::terminate"] B -- "No" --> D{"Reentrant?\n(Destructors / Catch)"} D -- "No" --> E["Unwind Frame"] end
subgraph SadPath ["The Sad Path"] D -- "Yes" --> F["Enter Sad Path"] F --> G{"Target Block"} G -- "Destructor cleanup" --> H["__cxa_cleanup_end"] G -- "Matching catch" --> I["__cxa_end_catch"] end
subgraph Resolution ["Return Flow"] H -->|"Resume unwinding"| A I -->|"Resume normal execution"| J["Happy Path"] end ```
When a matching `catch` or a pending destructor scope is found, execution branches into what Estell calls "the sad path." Leaving the sad path occurs via two standard runtime calls: - `__cxa_cleanup_end`: invoked after running local destructors so control can re-enter the unwinding mechanism to continue searching up the call stack. - `__cxa_end_catch`: invoked when a `catch` block finishes handling the exception, finally returning control to normal application code on the "happy path."
---
[37:28](https://www.youtube.com/watch?v=bY2FlayomlE&t=2248s) Structure of the Arm Exception Index Table
Estell moves to the concrete details of locating exception metadata, examining the exception index section bounded by the linker symbols `__exidx_start` and `__exidx_end`. Under the Arm Exception Handling ABI, every entry in this index table occupies exactly eight bytes. The first 4-byte word provides the function's start address—represented as a relative offset for relocatability. The second 4-byte word points to the exception data, or contains inline instructions directly.
Crucially, entries in the exception index are laid out in strictly ascending order corresponding to the physical memory layout of functions in the text segment. If function `foo` is located at address 0, followed immediately by `bar` and then `baz`, the index mimics that precise ordering:
| Offset Field | Size | Purpose | |:--- |:--- |:--- | | First 32-bit word | 4 bytes | Relative offset pointing to function start address | | Second 32-bit word | 4 bytes | Tagged union: offset to exception data table OR inline unwinding instructions |
Because functions are guaranteed to be sorted monotonically in the index, the runtime does not need linear scans or hash lookups. Given any arbitrary program counter (PC) value sampled during a throw, the runtime performs a fast binary search over the table. If the PC falls strictly between the start address of one entry and the start address of the subsequent entry, the runtime knows with certainty that execution was inside the function to the left.
The second 32-bit field is a tagged union distinguished by its most significant bit (bit 31): - Bit 31 = `0`: The field is a signed relative offset pointing into the secondary exception data table (`.ARM.extab`). - Bit 31 = `1`: The field holds compact, inline unwind personality data directly inside the index table itself.
---
[39:02](https://www.youtube.com/watch?v=bY2FlayomlE&t=2342s) Inline Personality Data and Unwind Bytecode
To conserve ROM on memory-constrained systems, the Arm specification permits inlining instructions directly into the index whenever a function's requirements are minimal. If a transparent function (one lacking destructors or catch blocks) is marked `noexcept`, the offset field is replaced with the sentinel value `0x1` (cantunwind). Encountering `0x1` directs the unwinder to halt immediately and call `std::terminate`.
For transparent functions that do need unwinding, the runtime bytecode can be embedded directly into the lower bits of the entry if the function requires three or fewer instruction bytes. Estell points out that Arm machine code uses either 32-bit instructions (standard Arm or Arm64) or mixed 16/32-bit instructions (Thumb-2), but exception unwind instructions are far more compact: > “Unwind instructions don't have to do much. They have to subtract the stack, pop some registers, and that's about it. So they only need like a byte of information in order to represent them, so they're a compressed version of a simulated version of returning from a function.”
The Arm exception ABI establishes that the vast majority of compiled functions require three or fewer bytes of unwind instructions, and the absolute maximum required by any conceivable function generated by an Arm compiler is seven bytes. These single-byte operations encode actions like adjusting the stack pointer, popping saved registers (`R4`–`R11`, `LR`), completing unwinding (`FINISH`), or refusing to unwind.
The bit layout of inline personality data in the 32-bit word is structured as: - **Bit 31**: Set to `1` (identifies the entry as inline personality data). - **Bits 30–28**: Reserved. - **Bits 27–24**: Personality index. Index `0` defines a short layout where the remaining three bytes of the word contain up to three unwind instructions (instruction 1, instruction 2, instruction 3). - Indexes `1` and `2` denote long personality formats where an embedded length byte specifies the number of additional 32-bit words that follow.
Estell notes with visible frustration that because the ABI capped the maximum number of instructions at seven, the explicit length byte is redundant: > “This can actually only be one to two, because they found out that you only needed seven instructions maximum, which is actually kind of sad because I look at this and I think to myself: we don't need to have the length there. If we had removed the length, then it would have moved up one, and we wouldn't need the padding we need of the finished bytes here that could have saved us four additional bytes, but you know, we can't have nice things all the time.”
- Demonstrating with the function `baz`: The binary search finds `baz`'s index entry, inspects bit 31 (`1`), sees personality format `0`, and reads the instruction to pop registers `R4` through `LR`. Executing this moves `bar`'s return address into the link register, restores `bar`'s preserved `R4` value, and increments the stack pointer. - Moving up the call chain to `bar`: The unwinder discovers that `bar` contains a local object requiring destruction, making it reentrant; inline metadata is insufficient, and execution must consult the detailed exception table.
---
[42:47](https://www.youtube.com/watch?v=bY2FlayomlE&t=2567s) Language Specific Data Area (LSDA) and LEB128 Encoding
When inline entries are insufficient, the index entry points via a relative offset (such as `-2304`) into the exception table (`.ARM.extab`). This table contains long personality data and language-specific data areas (LSDA). Estell explains that the LSDA concept was originally introduced by the Itanium C++ ABI to facilitate language interoperability: > “Language Specific Data Area is something that the Itanium ABI added in so that you could support, or we could support, throwing exceptions through Java and C and C++ and have them all cooperate with each other. Yeah, we have support for that—it's one of the reasons why the normal GCC exceptions is kind of slow. They have to handle Java and other languages, even though that might not be anything you might not be using Java in your code.”
A C++ program can call across a foreign function interface into Java; if Java throws an exception, the unwind runtime propagates the unwinding state through C++, into Java runtimes, and back to outer C++ frames seamlessly. GCC retained this flexible LSDA architecture to construct a unified exception model across architectures.
The LSDA structure contains several critical sub-sections: 1. **Personality Function Pointer**: A reference to the routine that knows how to interpret the tables below it (Estell notes he overrides this routine in his custom lightweight embedded runtime). 2. **Personality Data**: The unwinding bytecode stream. 3. **LSDA Header**: Specifies encoding formats and size offsets for the subsequent tables. 4. **Call Site Table**: Records program scopes relevant to exceptions, demarcating active `try` blocks and cleanup regions needing destructors. 5. **Action Table**: Chains and orders candidate `catch` handlers, preserving evaluation order. 6. **Type Table**: Contains unique references to types handled by catch blocks (for example, if `std::exception` is caught at five distinct locations across a function, only one entry exists in this table).
Estell highlights the **LEB128** (Little-Endian Base 128) variable-length encoding format used throughout the LSDA headers and tables. In LEB128, each byte dedicates its most significant bit as a continuation flag: a `1` indicates that further bytes follow, while a `0` indicates the terminal byte. Stripping these flags and concatenating the remaining 7-bit payloads yields the full integer. This guarantees small offsets consume only a single byte of flash memory, keeping metadata compact.
In embedded environments, the first byte of the LSDA header is almost universally `0xFF`, which signals that Dwarf debugging and landing-pad metadata are omitted, as these sections are stripped from production embedded binaries. The subsequent byte defines the type table encoding—typically program-counter-relative offsets rather than absolute 32-bit pointers—followed by an offset specifying the exact distance from the header to the start of the type table located at the end of the LSDA.
[46:23](https://www.youtube.com/watch?v=bY2FlayomlE&t=2783s) Decoding the Call Site Table and Triggering Destructors
Decoding the call site table in the Language Specific Data Area (LSDA) begins right after the header. Depending on the encoding, call site offsets and lengths can be encoded as ULEB128 or fixed types such as `uint8_t` or `int16_t`. Following the header offset comes the total byte length of the call site table. Each call site record provides four essential fields: `start`, `length`, `landing_pad`, and `action_record`.
```mermaid flowchart TD accTitle: Call Site Record Evaluation accDescr: Flowchart illustrating how a call site record is parsed to trigger destructors or proceed with unwinding. subgraph RangeCheck["PC Range Check"] A["Read Call Site Record<br>(start, length, landing pad, action)"] --> B{"Is PC in<br>[start, start + length)?"} B -- "No" --> C["Check Next Record"] B -- "Yes" --> D{"Landing Pad == 0?"} end subgraph Dispatch["Action Execution"] D -- "Yes" --> E["No cleanup here:<br>Unwind frame"] D -- "No" --> F{"Action == 0?"} F -- "Yes" --> G["Unconditional cleanup:<br>Call Destructors"] F -- "No" --> H["Inspect Action Table<br>for Catch Handler"] end ```
The `start` field indicates an offset from the beginning of the function's assembly code, and `length` specifies the valid range from that offset. If the current program counter (PC) falls within `[start, start + length)`, the CPU was executing within that protected scope when the exception occurred, mapping execution directly to that record's landing pad and action record.
The landing pad defines the code address to resume execution within the function, while the action record determines what must occur: - An action value of `0` denotes unconditional unwinding (calling destructors for objects going out of scope), or indicates that no type table exists. - A non-zero action value provides an offset into the action table to match catch handlers. - A landing pad of `0` indicates that the current scope contains no cleanup code, meaning the runtime should bypass landing pads and unwind through the function frame immediately.
In the example walk-through, the runtime evaluates `bar`, where the active PC sits at `bar + 14`. With a record showing `start = 0`, `length = 26`, and `landing_pad = 30`, the PC falls comfortably within the 0 to 26 byte range. To execute the destructor cleanup, the unwinder calculates the resume target as the function's base address plus the landing pad offset of 30 bytes, setting the least significant bit (LSB) to 1 to satisfy the ARM Thumb execution mode requirements:
```cpp // Pseudocode for landing pad jump setup auto destination = (function_start + landing_pad) | 1; cpu_context.pc = destination; restore_cpu_state(cpu_context); ```
As Khalil Estell explains regarding register setup and cleanup: > “We're just setting up the location of that object from the stack in the first parameter so we can call this destructor.”
The generated cleanup landing pad loads the object address into `r0` (via `add r0, sp, #4`), invokes the object's destructor, and subsequently calls `__cxa_end_cleanup` to retrieve the active exception object, restore the frame state, and continue propagating the in-flight exception.
- Destructors are allowed to utilize stack memory, which means any stack frame contents below the executing destructor's frame are considered undefined and subject to being overwritten. - Compilers can emit multiple landing pads within a single function depending on execution progress; an exception thrown early might only clean up a single constructed object, whereas an exception thrown later will unwind multiple objects in reverse order.
---
[50:23](https://www.youtube.com/watch?v=bY2FlayomlE&t=3023s) Restoring the Caller Frame and Matching Catch Blocks
Once the destructors for the inner function (`bar`) complete, unwinding transitions to the caller (`foo`). The runtime reads the unwind bytecodes within the LSDA to adjust the stack frame. It encounters Personality Index 0, adds 12 bytes back to the stack pointer to deallocate the space previously occupied by local objects, and pops saved registers `r4`, `r5`, and the link register (`lr`) off the stack. With the stack adjusted and the saved program counter pointing into `foo`, the runtime consults the LSDA's type table and action table to locate an appropriate catch handler.
The type table consists of a unique list of RTTI pointers for catchable types, indexed in reverse: - A `nullptr` entry in the type table represents a catch-all block (`catch (...)`). - Types are matched using pointer equality between the thrown exception's `type_info` and the type table entry. - Complex type inheritance checks are only invoked when polymorphic class hierarchies are caught across types.
Khalil Estell dispels a widespread myth regarding RTTI matching overhead: > “I think people think that you have to do like string comparisons to do like catch comparisons no you just got to check the pointers.”
The action table encodes the sequence of catch handlers associated with each try block via a linked list format: 1. **Filter Number**: A positive integer that serves as a negative offset when indexing backward from the end of the type table (e.g., filter `1` corresponds to `type_table[-1]`). A filter of `0` denotes a catch-all. 2. **Next Record Offset**: A signed offset pointing to the next chained action record. A value of `0` signals the end of the action chain for that try block.
```mermaid flowchart LR accTitle: Action Table Filter and Type Resolution accDescr: Diagram showing how an action record references a filter number to look up a type info pointer from the reverse type table. subgraph ActionRecord["Action Table Entry"] F["Filter Number = 1"] N["Next Record Offset = 0"] end subgraph TypeTable["Type Table (Reverse Indexed)"] direction TB T0["End of Type Table Base"] T1["type_table[-1]: target type_info*"] T2["type_table[-2]: alternate type_info*"] T0 --- T1 T1 --- T2 end F -->|"indexes backward"| T1 ```
If the action chain is exhausted without a type match, the runtime unwinds past `foo` to the next caller. When a match is found: - The active exception pointer is placed into `r0`. - The matching filter number is loaded into `r1`. - The PC is updated to the function start plus the landing pad address (with LSB set to 1). - `restore_cpu_state` transfers control to the catch landing pad.
---
[53:53](https://www.youtube.com/watch?v=bY2FlayomlE&t=3233s) Catch Blocks as Switch Statements and Handler Teardown
Under the Itanium C++ ABI and its ARM counterpart, catch blocks are emitted internally as switch-case constructs: > “I don't know if anyone knows this but catch blocks are switch case statements at least in Itanium they're just switch case statements so the filter number is your switch value to which case that you are.”
Upon entering the landing pad, the code evaluates `r1` against the expected filter number: - If `r1 == 1`, execution branches into the body of the catch block. - It invokes `__cxa_begin_catch`, passing the exception object in `r0`. This helper marks the exception as handled and returns a pointer to the thrown payload. - Once the user's catch handler logic finishes, execution enters `__cxa_end_catch`, which runs the thrown object's destructor and frees its allocated storage. - If the filter number does not match (an unexpected fallthrough state), the code branches to an unwind continuation label to resume rethrowing up the call tree.
---
[55:00](https://www.youtube.com/watch?v=bY2FlayomlE&t=3300s) Modeling Real-World Space Costs of Exception Handling
With the complete unwind mechanism mapped out, Estell transitions to analyzing the exact space overhead that exception handling tables impose on firmware binaries. To produce a realistic baseline comparison, the analysis models its assumptions around real-world code compiled strictly for size (`-Os` / `-Oz`):
1. **Size-Optimized Compilation**: Unwind benchmarks only reflect real-world firmware if all components are compiled with size optimization flags enabled. 2. **Upper Bound on Function Size**: It is assumed that practical functions do not exceed 16 KB of machine instructions. To substantiate this claim, Estell analyzed a full debug build of the Firefox codebase: out of hundreds of thousands of functions, only 253 functions exceeded 16 KB, while over 325,000 fell below that threshold. 3. **Upper Bound on Scopes**: The number of catch blocks and automatic objects requiring destruction within any single function is capped at 63. 4. **ULEB128 Encoding**: All variable-length quantities are assumed to use standard ULEB128 compression, minimizing metadata byte overhead across LSDA sections.
[56:03](https://www.youtube.com/watch?v=bY2FlayomlE&t=3363s) Calculating the Concrete Byte Costs of Exception Tables
To demonstrate precisely how much metadata exceptions demand, Khalil Estell breaks down the exact byte footprint mandated by the ARM Exception Handling ABI (EHABI) and generated assembly:
* **Exception Index Table (`.ARM.exidx`):** Exactly 8 bytes per entry as specified by the ABI. * **Personality Data:** Exactly 4 bytes per entry according to the ABI. * **Inline Unwind Data:** A maximum of 12 bytes. * **Catch Blocks:** Because catch blocks function as a `switch`/`case` construct, each catch site requires a comparison instruction (2 bytes), a conditional branch-if-not-equal instruction (2 bytes), a branch-and-link call to `__cxa_begin_catch` (4 bytes), and another 4-byte call to `__cxa_end_catch`. This sums to approximately 12 bytes of assembly per catch block. * **Cleanup Regions (Destructors):** Calculated as $6D + 4$ bytes (where $D$ represents the number of objects needing destruction). Each destructor requires 2 bytes to set up stack or input parameters plus a 4-byte branch-and-link call to the destructor itself, along with an additional 4 bytes for the terminal call to `__cxa_end_cleanup`. * **Action Table:** Sized at $2 \times A$ bytes, where $A$ is the number of catch blocks in the function. Because a signed LEB128 (SLEB128) value spanning $-64$ to $63$ fits within a single byte, under Estell's working constraints the filter number never exceeds this range. Consequently, each filter number takes only 1 byte, and the subsequent record offset never needs to exceed $-3$ (consistently represented as `-3, -3, -3`). * **Type Table:** Consumes 4 bytes per pointer multiplied by $T$, the number of unique exception types referenced in the function. * **Call Site Table:** Ranges between $4 \times C$ and $7 \times C$, where $C$ is the number of call sites. Under the applied architectural constraint that no function exceeds 16 KB ($2^{14}$ bytes, which can be encoded in two unsigned LEB128 bytes), the call site start offset, length, and landing pad start can never exceed 16 KB. The action record index inside this table is constrained to a single unsigned LEB128 byte because the action table tops out at 128 bytes: “Got lucky there; could have been off by one.”
Estell purposefully bypasses an in-depth derivation of the exception table header math on stage: “I'm going to skip this section because I hate every single time I repractice this one; I hate explaining it because it has a lot of math included in it.” He notes that he is preparing a comprehensive formal paper detailing how the header is mathematically proven to be bounded between 5 and 7 bytes under these embedded constraints.
```mermaid flowchart TD accTitle: Exception Handling Metadata Byte Breakdown accDescr: Diagram illustrating the byte cost of ARM exception tables and catch block code.
subgraph StaticTables["Fixed Metadata Tables"] Idx["Exception Index (.ARM.exidx)<br>8 Bytes"] Pers["Personality Routine Pointer<br>4 Bytes"] Hdr["LSDA Header<br>5 to 7 Bytes"] TypeTbl["Type Table<br>4 Bytes × Unique Types (T)"] end
subgraph DynamicTables["Variable Metadata Tables"] CallSite["Call Site Table<br>4 to 7 Bytes × Call Sites (C)"] ActionTbl["Action Table<br>2 Bytes × Catch Blocks (A)"] end
subgraph CodeCosts["Assembly Generation Costs"] CatchCode["Catch Block Routine<br>12 Bytes each (cmp + bne + begin + end)"] CleanCode["Cleanup Destructors<br>(6 Bytes × D) + 4 Bytes (__cxa_end_cleanup)"] end ```
Synthesizing these mathematical models produces realistic real-world footprints: - **Trivially Unwindable Function:** A function with no destructors, no `try`/`catch` blocks, and no extra register handling adds an overhead of only 8 bytes in the index table over its native function code. - **Function with a Single Destructor Scope:** Requires approximately 34 bytes of overhead: 8 bytes for the `.ARM.exidx` entry, 5 bytes for the header, 4 bytes for the personality routine pointer, 4 bytes for the call table, up to 3 padding bytes for word alignment, and 10 bytes for the landing pad code. - **Function with a `try`/`catch` Block:** Reaches a minimum overhead of 47 bytes once the landing pad, action records, type pointers, and runtime calls are tallied. “So yeah, try/catch is a little, little expensive.”
---
[01:00:22](https://www.youtube.com/watch?v=bY2FlayomlE&t=3622s) The Hidden Code Bloat of Return-Type Error Handling
Estell contrasts the fixed metadata overhead of zero-cost exceptions with the code-bloat characteristics of return-type error handling (such as `std::expected`, error codes, and boolean flags). Starting with an ideal baseline function (`none`) that invokes three fallible operations without checking errors (relying purely on standard exception unwinding), the generated machine code requires only 8 instructions.
When the same function is rewritten to use `std::expected<T, E>`, the disassembly swells substantially: - Even when annotating the error paths with `[[unlikely]]`, the generated machine code does not shrink: “I even put unlikely here hoping that it would do something; I removed it, didn't change a darn thing.” - For every checked call site, the assembly must invoke the function (`bl`), load the returned status word from the stack pointer (`ldr`), and execute a compare-and-branch (`cbz` or `cmp`/`bne`) to jump to a local error return path. - This creates an inescapable tax of at least 6 bytes per call site: 4 bytes for the load instruction, and 2 bytes for the branch instruction.
Replacing `std::expected` with a plain `bool` success flag slightly reduces the burden to roughly 4 bytes per call site. While certain 2-byte branch instructions appear in the disassembly, the compiler cannot use them universally across larger scopes due to branch offset range limits.
Examining Rust's canonical `Result<T, E>` types reveals an identical phenomenon. Despite Rust's reputation for highly optimized pattern matching, its generated assembly is essentially equivalent in byte cost to C++'s `std::expected`. Interestingly, altering the Rust payload type from `u32` to `u64` triggers an optimization that collapses the check down to 4 bytes per site (identical to returning a raw boolean). Regardless of language syntax, returning errors as values incurs a distributed penalty of 4 to 6 bytes for every single fallible function invocation.
---
[01:02:39](https://www.youtube.com/watch?v=bY2FlayomlE&t=3759s) Centralized vs. Distributed Error Handling
Estell categorizes error architectures into two fundamental models:
* **Distributed Error Handling:** Error management is scattered across the entire codebase via `if`/`else` control flow, return codes, `std::expected`, out-parameters, or manual propagation. Every intermediate function that invokes a fallible callee must actively spend instructions to inspect the return state and either handle the failure or manually forward it upward. * **Centralized Error Handling:** Propagation is delegated to an external, out-of-band engine (such as the C++ exception unwinder). Intermediate code on the call stack remains completely oblivious to error-routing mechanics and focuses entirely on the "happy path." Estell analogizes this to a municipal infrastructure: “I like to think about this kind of in the same vein as the USPS or like having postal systems: rather than having everyone else manage delivering their own stuff, we have a system for that.”
Estell puts forward his core thesis: “Centralized error handling schemes have an advantage in terms of code size compared to distributed error handling.”
This structural advantage stems from simple arithmetic: - **Branches are not free:** Every distributed check consumes 4 to 6 bytes of flash memory every single time an operation is invoked. If a function calls 5 fallible functions sequentially, manual propagation consumes 20 to 30 bytes of instructions. Under an exception model, that same trivially unwindable function incurs only 8 bytes of table overhead in total. - **The Catch Break-Even Tradeoff:** While a `try`/`catch` block introduces a heavier baseline tax (at least 47 bytes, roughly equivalent to checking 11 to 12 fallible return types), catching is intended to be rare. - **Embedded code is overwhelmingly trivially unwindable:** “Most functions are trivially unwindable. Think about this for a sec: at least for the code that I've written and the code I've seen around, I have found that there's far more code that actually propagates errors versus handling them.” In typical firmware environments, most application logic merely passes errors up the stack, and very few stack-allocated objects require non-trivial destructors since dynamic heap allocations are routinely avoided.
[01:05:12](https://www.youtube.com/watch?v=bY2FlayomlE&t=3912s) Audacity Case Study and Modeling Firmware Binary Costs
Khalil Estell discusses how resource cleanup, like mutex locks, tends to be used sparingly in firmware code bases. Seeking real-world data to evaluate how frequently exceptions and cleanups appear in practice, he recalls reaching out to Jason Turner a couple of months prior for realistic open-source benchmarks. Turner suggested analyzing Audacity. Audacity contains 1,554 C++ files, yet only 39 of those files contain `catch` blocks, totaling just 60 to 69 `catch` blocks across the entire code base. Furthermore, out of approximately 17,000 functions, only 50 contain an explicit `try`/`catch` block. The total memory overhead required for exception handling across the entire Audacity application amounts to roughly 15 kilobytes.
To ensure his analysis does not rely on overly optimistic numbers, Estell constructs an intentionally extreme theoretical firmware scenario. Rather than assuming the 0.28% catch-block frequency seen in real code, he models a worst-case scenario where 5% of functions contain `try`/`catch` blocks, 25% allocate objects requiring cleanup, 55% are trivial unwindable functions, and 10% are leaf functions. Leaf functions require zero bytes of metadata because they never participate in stack unwinding. Assigning generous average metadata sizes—64 bytes for `try`/`catch` blocks, 46 bytes for cleanup functions, 8 bytes for trivial functions, and an 8,000-byte runtime overhead for `libunwind`—the total exception-handling footprint reaches 27,100 bytes across 900 functions.
```mermaid flowchart TD accTitle: Exception Handling Overhead Breakdown accDescr: Visual breakdown of memory cost in bytes for an extreme 900-function model. subgraph FunctionTypes["Function Distribution (900 Total Functions)"] TC["5% Try/Catch (64 B avg)"] CL["25% Cleanup (46 B avg)"] TR["55% Trivial Unwind (8 B avg)"] LF["10% Leaf (0 B)"] end subgraph AggregatedCost["Total Space Cost"] UW["libunwind Runtime: 8,000 B"] Total["Total Footprint: 27,100 Bytes"] end FunctionTypes --> AggregatedCost ```
- Audacity metrics: 1,554 C++ files, 39 files with `catch` blocks, ~60–69 catch blocks total, 50 `try`/`catch` instances across ~17,000 functions, ~15 KB total exception metadata cost. - Extreme model parameters: 5% `try`/`catch` (64 bytes average), 25% cleanup (46 bytes average), 55% trivial functions (8 bytes average), 10% leaf functions (0 bytes). - Base runtime overhead: 8,000 bytes allocated for `libunwind`. - Total modeled exception memory consumption: 27,100 bytes.
[01:07:19](https://www.youtube.com/watch?v=bY2FlayomlE&t=4039s) Comparing Metadata Size to Error-Code Check Overheads
Estell evaluates what happens when replacing exceptions with return-type error handling, such as `std::expected` or result types. If checking a return code consumes a minimum of 4 bytes per call site, a budget of 27,100 bytes permits 6,775 total checks. When distributed across 900 functions, that equates to roughly 7.5 fallible calls per function before manual error checking consumes more binary flash than the entire exception infrastructure. Crucially, that 4-byte estimate is an absolute best-case lower bound that excludes the function epilogue and cleanup return paths.
As the error-checking mechanism becomes more realistic, the breakeven threshold drops sharply. Switching to `std::expected` raises the cost to approximately 6 bytes per check, reducing the budget to five checks per function. Factoring in an extra 2 bytes for the function epilogue needed to propagate an error brings the threshold below four checks per function call. Estell emphasizes that having to worry about calling more than three or four functions without blowing a size budget places unreasonable constraints on everyday software development.
- Best case (4 bytes per check): 6,775 total checks allowed across the binary, or ~7.5 fallible calls per function. - Mid case (`std::expected` at 6 bytes per check): ~5 checks per function. - Realistic case (6 bytes check + 2 bytes epilogue = 8 bytes): under 4 checks per function call before exceptions become smaller.
[01:08:28](https://www.youtube.com/watch?v=bY2FlayomlE&t=4108s) Exceptions as Code Compression
Synthesizing these findings, Estell details the exact architectural reasons why C++ exceptions yield smaller firmware binaries. First, exceptions drastically reduce codegen at fallible call sites because no branch instructions or local validation logic are inserted into the hot path. Second, unwind metadata scales strictly with the total number of function definitions, whereas error-return checks scale with the total number of function *calls*, which is substantially higher in any real program. Third, exceptions eliminate repetitive function epilogues and manual error-return paths. Furthermore, decades of work in both the Itanium ABI and the Arm Exception Handling ABI have tuned the instruction encoding to be as dense as possible. Finally, the runtime unwinder cost is paid only once—8 KB in stock implementations, or around 4.5 KB to 4.7 KB with Estell's custom unwinder.
Estell argues that existing ABIs have not even reached their most compact potential. Current formats still emit unneeded DWARF sections, call-site encodings, and type table encodings that could be eliminated by defining custom personality routines. By replacing verbose runtime routines like `__cxa_end_catch` (invoked via a 4-byte `BL` call) with a simple 2-byte unconditional `B` branch instruction, two bytes could be reclaimed from every catch block. Reflecting on this disassembly analysis, he delivers his central takeaway: “C++ exceptions are code compression.” Rather than being an architectural liability, the exception table and its metadata serve as an effective binary compression scheme that transforms bloat into minimal code and a handful of metadata bytes.
> “After so much time looking at the disassembly, looking at the metadata, and working with these data structures, I came to one conclusion, and that is C++ exceptions are code compression.” — Khalil Estell
[01:11:05](https://www.youtube.com/watch?v=bY2FlayomlE&t=4265s) Unwinder Optimization and Generational Stagnation
Anticipating objections from developers who prioritize execution speed over code footprint, Estell previews his subsequent research on optimizing stack unwinding speed. He notes that while his earlier ACCU presentation demonstrated a 76% reduction in exception handling time, ongoing development pushed that reduction to 88%, with targets of 92% to 95%. Investigating GCC's `libunwind` source code reveals that its Arm implementation has remained virtually untouched for decades: approximately eight people in total have contributed to it, the most recent minor updates date back roughly 15 years, and the core implementation was authored 25 years ago.
Because the standard implementation has languished without modern optimization, stock GCC exhibits severe execution overhead compared to manual unwinding with `std::expected`. Across a call depth of 6 frames, GCC stack unwinding is roughly 27 times slower than returning an expected type, whereas Estell's optimized unwinder reduces that penalty to 4.77 times. Over an extreme unwinding depth of 96 frames, stock GCC runs 21 times slower than explicit return codes, while his optimized implementation is only 2.56 times slower than standard function returns.
| Unwind Depth | GCC `libunwind` vs `std::expected` | Estell Custom Unwinder vs `std::expected` | |:--- |:--- |:--- | | **6 Frames** | ~27× slower | 4.77× slower | | **96 Frames** | ~21× slower | 2.56× slower |
[01:12:25](https://www.youtube.com/watch?v=bY2FlayomlE&t=4345s) The Fundamental Flaw of Exceptions and Binary Inspection
Estell pivots to address the primary downside that drives embedded engineers away from exceptions: invisibility. He bluntly concedes, “exceptions effing suck,” demonstrating that looking at a function signature gives a developer no indication whatsoever of what types it might throw. Developers are forced to rely on out-of-date documentation or spend days emailing third-party vendors for clarification on vendor binary blobs. Even if a vendor confirms that a function throws no unexpected exceptions in version 5.1.0, upgrading to version 5.1.1 or 5.2.0 leaves the developer completely blind as to whether that contract still holds.
This lack of visibility and predictable behavior is the root cause of why developers distrust exceptions and gravitate toward local error handling like result types. However, Estell notes that this problem can be resolved through static inspection tools. While inspecting a binary to identify all `bool` flags or `std::expected` error returns in disassembly is extraordinarily difficult, tracking exceptions is straightforward: throwing an exception requires an unmistakable, explicit call to `__cxa_throw`. This realization serves as the foundation for his static analysis tooling, the "Exceptions Insights Tool."
> “Because of this, it's what get makes people concerned about their code and how how it will behave is because of this that people are trying to push away from exception so much that's why we want to handle errors locally.” — Khalil Estell
[01:14:20](https://www.youtube.com/watch?v=bY2FlayomlE&t=4460s) How the Exception Insights Tool Works
Khalil details the architecture and operational flow of his planned static analysis tool, dubbed the Exception Insights tool. He emphasizes that the tool operates on the final compiled binary rather than source code, bluntly explaining the reasoning: “your source code doesn't know jack—give me your binary thing you're going to actually execute.” By parsing the `.text` section of the binary, the tool locates every single branch and link instruction to `__cxa_throw` and `__cxa_allocate_exception`. Because calls to `__cxa_allocate_exception` reveal allocation arguments, the tool can deduce the exact size and type of every thrown object throughout the application, alongside their precise throw sites.
```mermaid flowchart TD accTitle: Binary Exception Analysis Flow accDescr: Pipeline showing how the tool analyzes an ELF binary to trace thrown exceptions and verify try-catch handlers.
subgraph Parsing["Binary Parsing"] A["ELF Binary Code (.text)"] --> B["Identify branch-and-link to __cxa_allocate & __cxa_throw"] B --> C["Extract throw object types, sizes, and locations"] end
subgraph Mapping["Index & Handler Mapping"] D["Exception Index (.ARM.exidx /.ARM.extab)"] --> E["Locate all exception-participating functions"] E --> F["Map try/catch blocks and catch handlers"] end
subgraph Simulation["Call Graph Simulation"] C --> G["Simulate Exception Propagation"] F --> G G --> H{"Outcome"} H --> I["Caught by Handler"] H --> J["Hits noexcept Function"] H --> K["Escapes to main (Uncaught)"] end ```
The tool then scans the exception index tables (`.ARM.exidx` / `.ARM.extab`) to discover which functions participate in exception handling and where every `try`/`catch` block resides. With this data coupled to a call graph—either supplied by the user or generated automatically—the tool simulates the raising and unwinding of every exception through every execution path. This reveals which exceptions are caught, which ones hit `noexcept` functions, and which ones escape uncaught all the way to `main`. Khalil stresses that this is not an impossible engineering feat and announces that he has dedicated time to building it, with students present at the conference assisting on the project.
[01:15:26](https://www.youtube.com/watch?v=bY2FlayomlE&t=4526s) Acknowledgments and Project Call for Support
Before opening the floor to questions, Khalil pauses to deliver heartfelt personal and professional acknowledgments. He thanks his mother, who traveled from California to see him speak, drawing applause from the room, as well as his fiancée, Malia Laor, a fellow C++ developer who contributed to the slides and is attending the conference. He highlights Herb Sutter's work on error handling and alternative exception designs as the primary catalyst that ignited his interest in the subject. Furthermore, he points to Bjarne Stroustrup's paper *C++ Exceptions and Alternatives*, quoting Bjarne's observation that “there's been little serious research on the topics of performance of exceptions and reliability of the resulting code in C++,” noting that this challenge prompted him to pursue that research himself.
Khalil shares his contact information and project links, clarifying that he is currently independent: “I am not employed; I am working on this on my own accord—this is my own research.” He invites sponsorships via GitHub and direct funding outreach from grant-awarding organizations like the National Science Foundation (NSF) or the Department of Energy at `estell.exceptions@gmail.com`. Additionally, he invites companies using or struggling with exceptions to supply real-world telemetry, requirements, and use cases to help shape community tools. Emphasizing his open-source commitment, he declares: “I believe that the Exception Insight tool and the other work that I'm doing will help promote C++ and keep it alive going into the future.”
[01:17:44](https://www.youtube.com/watch?v=bY2FlayomlE&t=4664s) Q&A: Buffer Upper Bounds, Safety Certifications, and Monadic `std::expected`
The question-and-answer period opens with an audience member addressing the static emergency allocation buffer for `__cxa_allocate_exception` described earlier in the talk, inquiring whether buffer exhaustion could terminate embedded systems and whether alternatives exist. Khalil responds that the Exception Insights tool combined with deterministic allocation structures can calculate an exact upper bound: by knowing the allocation blocks required per thread and every throw site across the system, developers can allocate just enough static storage to ensure out-of-memory crashes never occur during exception handling.
Another attendee working in aerospace and robotics raises strict regulatory requirements: aerospace standards demand full on-target branch coverage tracing back to formal requirements, prompting concern over how hidden compiler-generated exception paths can be audited. Khalil clarifies that the Exception Insights tool aims to solve that exact issue: - **Binary-level inspection**: Analyzes compiled ELF images and closed-source vendor binary blobs directly, eliminating blind spots when source code is unavailable. - **Uncaught exception detection**: Emits stack traces for any unhandled exception path in CLI output. - **Intentional termination tracking**: Provides explicit configuration to allow safety-critical termination while catching and handling recoverable operational errors. - **Symbol mapping**: Maps offending throw sites back to debug symbols when available, identifying the precise originating functions.
A subsequent question asks about usability and code-size comparisons when using monadic operations on `std::expected` (such as `.and_then()` and `.or_else()`), and whether students found them helpful. Khalil remarks that his students “found it too confusing” and preferred traditional `if` statements, noting that heavy lambda usage does not provide code-size advantages over normal branches. When asked whether he plans to upstream his runtime improvements into LLVM Clang or GCC, he indicates a desire to target Clang first, followed by GCC. He adds that his custom exception runtime is packaged as a standard drop-in library (via Conan) that overrides GCC's default exception-handling symbols at link time without requiring code modifications or newer compiler toolchains. Finally, in response to a question regarding shared libraries, Khalil confirms that analyzing dynamic `.so` boundaries requires running the tool on the target machine with runtime ground truth, a feature that remains on their roadmap.
[01:22:57](https://www.youtube.com/watch?v=bY2FlayomlE&t=4977s) Audience Collaboration and Alternative System Call Layers
An audience member shares their own engineering effort to eliminate C runtime dependencies in favor of a pure C++ abstraction layer for system calls. The attendee notes that their momentum had stalled precisely because of the exhaustive unwinding and runtime research required, remarking, “The place where I'm kind of stopped right now is all the research that would be needed to do what you just did so thanks.” Khalil Estell welcomes the appreciation and immediately follows up by asking whether the attendee would find the open-source lightweight unwinding library he has been developing genuinely useful once completed. The attendee affirms that it would be potentially very helpful, prompting Estell to suggest that they connect after the talk to discuss the details and explore collaboration.
- The attendee's project aimed to bypass standard C runtime libraries and replace them with a pure C++ layer targeting system call interfaces. - Progress had been blocked by the deep technical overhead of reverse-engineering and implementing exception runtime unwinding tables. - Estell expressed interest in seeing whether his experimental unwinding implementation could serve as a direct drop-in solution for such embedded frameworks.
[01:23:25](https://www.youtube.com/watch?v=bY2FlayomlE&t=5005s) Architecture Support: x86, RISC-V, and Windows
The Q&A shifts to platform support when another attendee asks whether Estell has investigated porting the implementation to x86, x86-64, or Windows environments. Estell outlines his current priorities, confirming that his roadmap is laser-focused on embedded architectures—specifically ARM and RISC-V. He explains that expanding to x86 is not entirely out of the question, but it would require external backing: “For x86 that will require some support from the community for us to have the resources to work on that so just making that clear.”
When addressing Windows, Estell draws a clear distinction between the MSVC ABI and alternative toolchains. For MSVC, which uses its own distinct Structured Exception Handling (SEH) mechanism and runtime tables, Estell jokes that Microsoft would have to reach out directly to coordinate an effort: “As for Windows Microsoft's going to have to contact me and talk to me about that because if it's for MSVC that's their own thing.” He concedes that targeting Windows through Clang or MinGW using standard DWARF/Itanium unwinder models might be technically feasible, but he has not yet verified how it would behave in practice. With no further questions from the room, Estell wraps up the session to enthusiastic applause from the audience.
```mermaid flowchart TD accTitle: Architecture Support Roadmap accDescr: Diagram illustrating current architecture targets versus future conditional targets for the custom exception library.
subgraph PrimaryTargets["Primary Focus (Embedded)"] ARM["ARM (Cortex-M)"] RISCV["RISC-V"] end
subgraph CommunityTargets["Conditional Support"] X86["x86 / x86-64"] WIN_CLANG["Windows (Clang / MinGW)"] WIN_MSVC["Windows (MSVC)"] end
PrimaryTargets -->|"Requires Community Funding/Resources"| X86 PrimaryTargets -->|"Needs Further Investigation"| WIN_CLANG PrimaryTargets -->|"Requires Microsoft Direct Collaboration"| WIN_MSVC ```
- **ARM and RISC-V**: Main active targets designed for constrained bare-metal microcontrollers and embedded firmware. - **x86 / x86-64**: Dependent on community interest, pull requests, and additional resource allocation. - **Windows / MSVC**: Highly divergent runtime model; supporting MSVC would necessitate proprietary ABI adaptations, though Clang-based Windows builds might have a simpler path forward.
## Notable quotes > [01:08:28](https://www.youtube.com/watch?v=bY2FlayomlE&t=4108s) After so much time looking at the disassembly, looking at the metadata, and working with these data structures, I came to one conclusion, and that is C++ exceptions are code compression. — Khalil Estell
> [01:02:39](https://www.youtube.com/watch?v=bY2FlayomlE&t=3759s) Centralized error handling schemes have an advantage in terms of code size compared to distributed error handling. — Khalil Estell
> [27:22](https://www.youtube.com/watch?v=bY2FlayomlE&t=1642s) From my experience, just to be real, I see more error propagation code than I see error handling code. — Khalil Estell
## Mentioned ARM, ARM Exception Handling ABI (EHABI), Audacity, Ben Craig, Bjarne Stroustrup, Clang, Conan, CppCon, Department of Energy, DigiKey, Embedded Template Library (ETL), Exception Insights tool, Firefox, GCC, Google, Google Pixel Buds, Google Pixel Watch, Herb Sutter, ISO C++ Committee, Itanium C++ ABI, Jason Turner, Java, Khalil Estell, LLVM Clang, Malia Laor, MinGW, MSVC, National Science Foundation (NSF), Patrice Roy, RISC-V, Rust, SG14, San Jose State University, libunwind
## Video description https://cppcon.org CppCon 2024 Early Access: https://cppcon.org/early-access Access All 2024 Session Videos Ahead of Their Official Release To YouTube. At least 30 days exclusive access through the Early Access system. Videos will be released to the CppCon channel on a schedule of one video per business day, with initial releases starting in November. ---
C++ Exceptions for Smaller Firmware - Khalil Estell - CppCon 2024 ---
For years, developers have overlooked a powerful tool for reducing binary size: C++ exceptions. Join me on a deep dive into the world of exceptions and discover how they can be harnessed to create more space efficient firmware. We’ll explore the requirements and best practices of embedded development, and show what is required to use exceptions in that environment. By the end of this talk, you’ll have a thorough understanding of how exceptions are handled, what their space costs are, and how exceptions compare to functional errors as values. ---
Slides: https://github.com/CppCon/CppCon2024/blob/main/Presentations/Cpp_Exceptions_For_Smaller_Firmware.pdf
Work at Hudson River Trading (HRT): https://tinyurl.com/safxfctf ---
Khalil Estell
Khalil is a ISO C++ Committee Member and has extensive experience writing production firmware. ---
CppCon is the annual, week-long, face-to-face gathering for the entire C++ community. The conference is organized by the C++ community for the community. You will enjoy inspirational talks and a friendly atmosphere designed to help attendees learn from each other, meet interesting people, and generally have a stimulating experience. Taking place this year in Aurora, Colorado, near the Denver airport, and including multiple diverse tracks, the conference will appeal to anyone from C++ novices to experts. Annual CppCon Conference - https://www.cppcon.org https://www.linkedin.com/company/cppcon https://x.com/cppcon https://www.facebook.com/CppConference https://www.reddit.com/r/cppcon/ https://mastodon.social/@CppCon ---
Videos Filmed & Edited by Bash Films: http://www.BashFilms.com YouTube Channel Managed by Digital Medium Ltd: https://events.digital-medium.co.uk ---
#exceptionhandling #firmware #firmwareupdate #cpp #cplusplus #cppcon #cppprogramming #cplusplusprogramming #softwaredevelopment #softwareengineering #coding #code #technology #programming
[Source](https://www.youtube.com/watch?v=bY2FlayomlE)