Ch19.7: Ban Legacy Exceptions With Your Build Flags
1. The Flags
Saying "we do not use legacy exceptions" in a document changes nothing: in six months somebody will include a header that throws, the unwinder will come back into the link, and nobody will notice until a target that cannot unwind fails to build. Make it mechanical. Every translation unit you compile, always:
-fno-exceptions -fno-rtti -fno-unwind-tables -fno-asynchronous-unwind-tables -fno-ident
What each one removes:
| Flag | What it makes impossible |
|---|---|
-fno-exceptions |
throw, try, catch anywhere in the build,
and with them the whole __cxa_* runtime and the personality routine.
Herbceptions still work: throws and catch throws are a
different mechanism and are unaffected |
-fno-rtti |
typeid, dynamic_cast, and the type-info objects that
legacy handler matching is built from. Herbceptions do not use RTTI, so nothing on
the throws channel is affected |
-fno-unwind-tables |
The .eh_frame frame tables that a stack walker needs |
-fno-asynchronous-unwind-tables |
The same tables in their asynchronous form, which debuggers and profilers also use |
-fno-ident |
The compiler's identifying strings in .comment, which leak build
machine details and are pure noise in a binary |
These are not stylistic. Each one deletes a mechanism, and the build fails loudly the moment somebody tries to use it.
2. What It Actually Removes
One small program, one herbception, compiled twice: once with the default flags, once with the list above.
#include <fast_io.h>
int divide(int a, int b) throws
{
if (b == 0)
{
throw throws ::std::errc::invalid_argument;
}
return a / b;
}
int main()
{
int result{};
try
{
result = divide(84, 2);
}
catch throws (::std::error e)
{
::fast_io::io::print("error ", e, "\n");
return 1;
}
::fast_io::io::print("84/2 = ", result, "\n");
}
84/2 = 42
Same output, smaller binary, and the difference is exactly the machinery of a feature the program never used:
| With default flags | With the ban list | What it is |
|---|---|---|
5,869 bytes of .text |
4,859 bytes | Code, including the personality-routine reachable parts |
.eh_frame 644 bytes |
128 bytes | The frame tables the unwinder walks |
.eh_frame_hdr 116 bytes |
44 bytes | The binary-search index into those tables |
.gcc_except_table 16 bytes |
gone | The language exception table: ranges, landing pads, handler types |
| 13 FDEs | 4 FDEs | How many functions carry unwind information at all |
.comment 111 bytes |
69 bytes | Compiler version strings |
The four remaining FDEs come from the C runtime startup objects, not from your code, so this is the floor. On a target without a loader there is nothing to register them with, and that is the point: the flags make your program's requirements match the platforms it has to run on.
3. The Diagnostic You Want
With the ban list in place, a legacy exception is a compile error at the line that introduces it, not a mysterious runtime failure on a target that cannot unwind:
legacy_demo.cc: In function 'int main()':
legacy_demo.cc:13:78: error: exception handling disabled, use '-fexceptions' to enable
13 | throw ::std::runtime_error("division by zero");
| ^
4. Put It in the Build, Not in a Wiki
With a plain makefile:
CXXFLAGS += -std=c++26 -fherbceptions \
-fno-exceptions -fno-rtti -fno-unwind-tables \
-fno-asynchronous-unwind-tables -fno-ident
LDFLAGS += -lherbceptions
With CMake, applied to every target in the project and not only to the executable:
add_compile_options(
-fherbceptions
-fno-exceptions -fno-rtti
-fno-unwind-tables -fno-asynchronous-unwind-tables
-fno-ident)
add_compile_options is deliberately global: a static library compiled without
the flags can reintroduce the behaviour into an executable compiled with them, and the
failure then shows up at link time in a target that looks unrelated. One list, every
target, including tests and benchmarks.
5. What You Give Up, and Why It Is Not a Loss
- Third-party libraries that throw. You cannot compile them with these flags at all, which is the honest outcome: they do not run where you need to run. If a dependency of yours ships a legacy-EH binary blob, it will drag the unwinder into your link, and that is a dependency to remove rather than to wrap.
-
typeidanddynamic_cast. Both were already ruled out in Ch18.5: type erasure over a function-pointer table does the job, with no type-info objects and no runtime search. -
Nothing about errors. Every failure in this book travels on the
throwschannel (Ch3.16), which does not need any of the five mechanisms above. That is why the whole ban list is possible at all.
If you take one line from this chapter, take the flag list. A project that compiles with it cannot drift back into legacy exceptions, and every target that has no unwinder, no loader and no C++ runtime stays a target you can build for.