Part 4 — C++ ABI Specifics¶
Series navigation: 0. Product Contract · 1. Foundations · 2. Symbol Contracts · 3. Type Layout · 4. C++ ABI · 5. Linker & ELF · 6. Transitive Breaks · 7. Designing for Stability · 8. Detecting Breaks
What you'll learn on this page
- Why C++ is the language where ABI stability is hardest, and what the Itanium C++ ABI freezes as part of your contract.
- The seven C++ mechanisms most likely to corrupt a consumer binary: vtables,
method qualifiers, templates/inline, inline namespaces,
noexcept, trivial→non-trivial, and base-class layout. - Why some of these (like
noexcept) are risk, not hard breaks — and the precise reason. - The four C++ design patterns that give you room to evolve.
Prerequisites: Part 2 — Symbol Contracts (mangling,
name-only resolution) and Part 3 — Type Layout (offsets,
sizeof).
Why C++ is the hard case¶
Every class with a virtual method carries a hidden pointer to a statically-ordered table of function pointers. Every method name is mangled through a grammar that encodes qualifiers, namespaces, template arguments, and parameter types. Every struct with a user-defined destructor changes how it is passed between functions.
The Itanium C++ ABI — followed by GCC and Clang on Linux, macOS, the BSDs, and most embedded targets — is rigid by design: it guarantees cross-compiler interoperability at the cost of making almost any visible change to a class a potential binary break. MSVC on Windows uses a different but equally rigid ABI with the same categories of pitfall. The seven sections below tour the mechanisms most likely to bite.
\"Itanium-style\", not gospel
The Itanium C++ ABI specification states that it is not the authoritative definition for any particular platform — each vendor pins its own details on top of it (and the platform's data model). The examples below use the Itanium-style model unless noted; treat the exact mangling, slot ordering, and passing rules as illustrative of the mechanism, and consult your toolchain's ABI document for the byte-exact contract.
1. Vtables and virtual methods¶
A polymorphic class carries a hidden vptr as its first word, pointing to a
per-class vtable — a static array of function pointers indexed by the order
virtual methods are declared. The Itanium ABI fixes this slot ordering as a
public part of the class contract. A call compiles to a slot index baked into
the call site:
Insert a new virtual method before an existing one and every later slot silently shifts:
/* v1 */ struct Widget { virtual void resize(); }; // resize = slot 0
/* v2 */ struct Widget { virtual void recolor(); // recolor = slot 0
virtual void resize(); }; // resize = slot 1
Now every old call to resize() (still using slot 0) dispatches to recolor()
— wrong method, no crash.
| Variant | Effect |
|---|---|
| Insert virtual before existing (case09) | reroutes calls to the wrong slot |
| Make a method pure-virtual (case23) | slot becomes __cxa_pure_virtual → unconditional abort() |
| Add the first virtual to a non-polymorphic class (case68) | a vptr is prepended; every member shifts by sizeof(void*), sizeof grows |
Remove a virtual method (func_virtual_removed) |
every later slot shifts up by one, so every old call dispatches one slot off — the same reroute as insertion, in reverse |
The only safe addition is to append new virtual methods after every existing slot — and only when no consumer-side derived classes exist that would themselves extend the vtable.
2. Method qualifiers¶
Qualifiers are load-bearing parts of the mangled name, not cosmetic
annotations. A const member function mangles with a K marker, volatile
with V, ref-qualifiers (&/&&) with R/O. Edit one and you rename the
symbol.
/* v1 */ int Widget::get() const; // _ZNK6Widget3getEv (note the K)
/* v2 */ int Widget::get(); // _ZN6Widget3getEv (K is gone)
The old symbol vanishes from .dynsym; every consumer hits symbol lookup
error at load (case22). This is
one of the easier C++ breaks to diagnose, because it's a clean dlopen abort
rather than silent corruption.
The dangerous sibling is converting an instance method to static
(case21): the mangled name is
often identical (_ZN6Widget3barEv for both), so the linker is happy — but the
v1 caller passes an implicit this in %rdi that the static callee never reads,
and the function computes from register garbage. Treat every qualifier edit on
a public declaration as renaming the symbol.
3. Templates and inline¶
Inline and template code lives where the One Definition Rule meets the link model — and that's where ABI assumptions get baked into the consumer's binary without the library ever seeing them.
An explicitly instantiated Buffer<int> produces a mangled symbol like
_ZN6BufferIiEC1Em. Adding a capacity_ field
(case17) keeps the symbol name
identical while growing sizeof(Buffer<int>) from 16 to 24. The consumer
stack-allocates 16; the v2 constructor writes 24 — a stack smash with no
header-level signal.
Header-only inline definitions embed the body into each consumer TU, so the implementation callers run is frozen when they compile. Changing an inline body between releases produces ODR violations (LTO sometimes catches these) and silent disagreements between two consumers who pulled in different header versions.
The transition direction matters:
| Transition | Result | Verdict |
|---|---|---|
inline-in-header → outlined-in-.so (case47, case16) |
old binaries keep their inlined copy; a new export simply appears | 🟢 COMPATIBLE (addition) |
outlined-in-.so → inline-in-header (case59) |
the exported symbol vanishes from .dynsym |
🔴 BREAKING |
The compatible direction has a build-order caveat worth knowing
(case16): comparing old→new
.so it is purely an added symbol, so the verdict is 🟢 COMPATIBLE — but a
caller freshly compiled against the new header (which now expects an imported
symbol) and then linked against the old .so (which never exported it) fails
at link time. That is a downgrade/mismatched-build hazard, not a regression in
the new release, and it dissolves once the symbol exists. If you also change
the function's body in the same move, stale callers running their old inlined
copy can disagree with the new export — an ODR hazard LTO sometimes catches.
Template instantiations, inline functions, and constexpr bodies are part of
the ABI even though they never appear in readelf -Ws.
4. Covariant returns and inline namespaces¶
An inline namespace is transparent to source-level name lookup but is mangled into every symbol declared inside it — making it the canonical Itanium mechanism for generational ABI versioning.
namespace crypto { inline namespace v1 { void encrypt(/*...*/); } }
// source writes crypto::encrypt(...) ; symbol = _ZN6crypto2v17encryptE...
namespace crypto { inline namespace v2 { void encrypt(/*...*/); } }
// same source compiles ; symbol = _ZN6crypto2v27encryptE... ← different symbol
Source compiles unchanged against both, but pre-compiled callers can't resolve
the new symbol (case71). This
is exactly the device libstdc++ used for its dual ABI: GCC 5 introduced
std::__cxx11::basic_string alongside the legacy COW std::string, gated on
_GLIBCXX_USE_CXX11_ABI. Distributions spent years untangling the resulting
lookup failures.
Covariant returns interact with vtable layout directly: a Circle::clone()
returning Circle* generates a this-adjusting thunk; inserting a new
intermediate base class
(case72) shifts sub-object
offsets and invalidates hardcoded vtable slots.
The lesson: inline namespaces are a power tool — wielded deliberately they let you ship a breaking change under a new mangled surface while keeping the old one exported; switched accidentally they rename every symbol you export.
5. noexcept — why this is risk, not a hard break¶
case15 is classified 🟡 COMPATIBLE_WITH_RISK, not BREAKING, and the reasoning is worth internalizing.
Before C++17, noexcept was not part of the function type, so the Itanium
mangler ignored it: void reset() noexcept and void reset() both mangle to
_ZN6Buffer5resetEv and resolve to the same .dynsym entry. Removing
noexcept therefore does not break linkage — hence not BREAKING.
What it does break is the caller's unwinding assumption. The v1 compiler
saw noexcept, so it omitted exception landing pads, cleanup frames, and
.eh_frame entries at the call site. If v2 now throws, the exception propagates
into a frame with no unwinding metadata and std::terminate() fires
unconditionally — every destructor skipped, every catch bypassed.
This is the deployment-risk shape: binary-linkable, source-recompilable, but semantically unsafe for binaries built under the stricter old contract — the kind of change that merits review rather than a silent pass.
How abicheck sees it
abicheck classifies the bare change kinds func_noexcept_removed /
func_noexcept_added as 🟢 COMPATIBLE — on an ordinary member or free
function they alter neither layout nor the mangled symbol. case15 reaches
🟡 COMPATIBLE_WITH_RISK because introducing throw also raises a
libstdc++ version requirement, reported separately as
symbol_version_required_added (a RISK-tier deployment signal). So the risk
verdict is driven by that version-requirement finding, not by the
noexcept kind itself: a pure toggle with no new version requirement
classifies COMPATIBLE. The unwinding hazard described above is the reason
the deployment signal is worth heeding — not something abicheck infers from
the noexcept change alone.
The C++17 subtlety
C++17 made noexcept part of the function type, but under Itanium that
only changes mangling where the full function-type is encoded — function
pointers, references to functions, and templates parameterized by function
type — not the <bare-function-type> used for ordinary member/free
symbols. So toggling noexcept on a plain declaration leaves the direct
symbol unchanged (abicheck: 🟢 COMPATIBLE), but the same change escalates
to 🔴 BREAKING for callers that pass the function through a pointer or
template where the E tag now participates in the mangled name.
Exception unwinding: the machinery behind noexcept¶
The noexcept section above turned on the caller's unwinding assumption
without saying what that machinery is. It is a full ABI in its own right, and
the Itanium C++ ABI freezes it as part of your binary contract.
The unwinding tables and the personality routine¶
When you throw, control does not "return" — the runtime unwinds the stack, running each frame's destructors on the way out until it finds a matching handler. To do that without any source, it reads two compiler-emitted tables baked into the binary:
.eh_frame— call-frame information (CFI): for every code address, how to restore registers and find the caller's frame. This is what lets the unwinder walk frames it has never seen..gcc_except_table— the language-specific data area (LSDA): per function, which address ranges are covered by whichcatchclauses and which cleanups (destructors) must run.
Each frame that participates names a personality routine — for GCC/Clang
C++ this is __gxx_personality_v0. The generic unwinder (_Unwind_RaiseException
in libgcc/libunwind) walks frames using .eh_frame, and at each frame calls the
personality routine, which reads that frame's LSDA to answer "does this frame
catch this exception, or does it just have cleanups to run?" The throw itself
goes through the ABI's __cxa_allocate_exception / __cxa_throw entry points.
The consequence: these tables and the __gxx_personality_v0 reference are part
of the artifact's ABI, exactly like the vtable or a mangled symbol. A caller
compiled expecting a landing pad relies on them existing and being correct — the
same dependency that makes a silent noexcept removal (section 5) dangerous.
Throwing across a DSO boundary¶
An exception thrown in libfoo.so and caught in the main program (or another
.so) has to cross a module boundary — and catch matching is RTTI-based.
__cxa_throw carries a pointer to the exception type's std::type_info, and the
personality routine matches it against each catch's type_info. This is the
same RTTI/type_info object that backs the vtable in section 1
and that -fno-rtti strips (see the modern-hazards table).
Matching ideally compares type_info by pointer identity — but each DSO can
emit its own copy of a type's type_info. The GNU runtime therefore falls
back to comparing the mangled type-name string when the pointers differ, and
relies on the dynamic linker to merge the copies to one when the type_info
has default visibility. That is the trap:
If a type used in a cross-DSO exception has hidden visibility (or its
type_infois otherwise not exported/merged — see Part 5 §Symbol visibility), the two modules can holdtype_infocopies the runtime refuses to equate. Thecatchthen does not match, the exception unwinds past the intended handler, andstd::terminate()fires.
The rule that follows: any type thrown across a library boundary must have
default visibility on its type_info (in practice: a polymorphic type with
a key function, or an explicitly exported RTTI symbol), so both sides share one
identity.
Mixing -fexceptions and -fno-exceptions, and changing throw specs¶
-fno-exceptions tells the compiler no exception will ever pass through this
code, so it omits landing pads, cleanups, and often the .eh_frame/LSDA
detail those need. A translation unit built that way becomes a no-throw
barrier: if an exception is unwound through one of its frames, there is no
metadata to run destructors or continue — the runtime calls std::terminate().
Building half a call chain -fno-exceptions and half -fexceptions is therefore
an ABI decision, not just an optimization flag, and flipping a shipped library
between the two changes what every caller can assume.
Changing a function's exception specification is the same hazard the
noexcept section already dissects — pre-C++17 it is invisible to the mangled
name, so it links fine but silently alters the caller's unwinding contract. (C++17
removed dynamic throw(T) specifications entirely; only noexcept remains.) We
do not repeat that analysis here — see section 5.
How abicheck sees it
Be honest about the boundary: whether a given throw is actually caught
across a DSO is not decidable from the artifacts — it depends on runtime
control flow abicheck does not simulate, and on which type_info copies the
loader merges in a particular process. abicheck does not try to prove
exception-safety end-to-end.
What it can observe is structural and symbol-level:
- the
-fno-exceptions/-fno-rttibuild flags, surfaced as toolchain/deployment risk when build context is captured (same row as the modern hazards above); - the presence/removal or visibility change of RTTI symbols (
_ZTI…/_ZTS…) and vtables (_ZTV…) at the ELF level — which is exactly what lets a cross-DSOcatchstop matching; - the
noexcept-driven library version requirement from case15.
The end-to-end "will this exception escape?" question is a genuine artifact-level limitation; see Limitations for the full catalog of what binary/header analysis cannot recover.
6. Trivial → non-trivial: the invisible calling-convention flip¶
The System V AMD64 convention passes trivially-copyable aggregates directly in registers, but passes non-trivially-copyable ones by invisible reference — the caller materializes the object on the stack and hands the callee a pointer. Whether a class is trivially copyable is decided by whether it has user-provided copy/move/destructor special members. A single line flips the register/memory decision.
/* v1 */ struct Point { double x, y; }; // trivially copyable
/* v2 */ struct Point { double x, y; ~Point() {} }; // user-provided dtor → non-trivial
Layout unchanged, sizeof unchanged, mangled symbol unchanged, loader perfectly
happy. But the v1 caller passes x, y in %xmm0, %xmm1 while the v2 callee
reads %rdi, %rsi as pointers and dereferences them — segfault or silent
garbage, with no toolchain diagnostic
(case69).
How abicheck sees it
No header-diff tool that looks only at declarations catches this. abicheck
reports value_abi_trait_changed by inspecting the DWARF
trivially-copyable flag.
Design rule: pin the trivially-copyable status of any by-value type
from version 1. If cleanup might ever be needed, commit from day one to
a user-provided destructor — an empty body ~T() {} or an out-of-line
T::~T() = default; in the .cpp. An in-class ~T() = default; on the
first declaration is user-declared but not user-provided, so it does
not pin the convention.
7. Base-class position and layout¶
Multiple inheritance places each base sub-object at a specific offset, and those offsets are compiled into every upcast and virtual call.
/* v1 */ struct Widget : Drawable, Clickable { /*...*/ };
/* v2 */ struct Widget : Clickable, Drawable { /*...*/ }; // bases reordered
The type name and every method signature are identical, yet
static_cast<Drawable*>(widget) now points into the Clickable sub-object,
because the compiler applied v1's offset to v2's layout
(case60).
case37 generalizes this with three
independent hazards: reordering bases changes this-adjustments and which
vptr sits at offset 0; converting non-virtual to virtual inheritance
restructures the whole object (the virtual base moves to the end and a
vbase-offset table is inserted); appending a base grows sizeof and shifts
every member.
A related multiple-inheritance trap is overriding a virtual inherited from a
non-primary base — i.e. any base after the first. Because that base
sub-object sits at a non-zero offset, the override needs a thunk that adjusts
this back to the sub-object before dispatching. Introducing (or removing) such
an override in a later release changes the set of thunks the vtable must carry
and the this-adjustments baked into consumer call sites — a silent break even
though the method's source signature is unchanged.
How abicheck sees it
Reported as base_class_position_changed / type_base_changed when DWARF
or headers are available — ELF symbol tables alone cannot see them, which
is why C++ ABI checking requires debug info or headers.
Empty bases, tail padding, and overlap optimizations¶
Two Itanium-ABI space optimizations make class layout more fragile than the plain struct rules from Part 3, because they let a seemingly free change resize or re-pack an object:
- Empty-base optimization (EBO). An empty base class occupies zero bytes
in the derived object. The moment that base gains its first data member — even
a single
char"just for debugging" — it starts occupying real space at offset 0 of every derived class, shifting every member after it. Tag types, policy classes, and allocators are the classic victims: case94 shows an empty tag struct gaining state and silently re-laying-out everything built on it. - Tail-padding reuse. For non-POD bases, a derived class may place its first
member inside the base's tail padding. The consequence: changing a base's
members can change the derived class's layout even when
sizeof(Base)stays identical — the padding the derived class was reusing is suddenly occupied. You cannot reason about a base class "in isolation"; its padding is part of its ABI. [[no_unique_address]]opts member sub-objects into the same overlap rules: adding or removing the attribute (or changing whether the member is empty) re-packs the enclosing object (case117).
The common thread: these layouts are computed from the whole inheritance and member graph, so the break surfaces in classes the author never edited. Detection therefore needs the compiler's own record of the final layout — DWARF sizes/offsets (L1) or the header AST (L2); see Part 8 for the full evidence story.
Modern C/C++ and toolchain ABI hazards¶
The break families above predate C++11. Newer language features and toolchain flags introduce a second class of hazard: the declaration looks unchanged in the header, but the bytes the compiler emits move because a type's size, mangling, or passing rule shifted under it. These are the cases reviewers miss most often, because nothing in the diff "looks like" an ABI change.
| Hazard | What silently changes | abicheck case |
|---|---|---|
_GLIBCXX_USE_CXX11_ABI flip |
libstdc++ ships two std::string/std::list ABIs in parallel behind the __cxx11 inline namespace; flipping the macro re-mangles every symbol that touches those types. |
case104 |
ABI tags ([[gnu::abi_tag]]) |
A tag is mangled into the symbol name; adding/removing one renames the symbol with no source-visible signature change. | case113 |
char8_t (C++20) |
const char* → const char8_t* is a distinct type: different mangling, and a new overload-resolution result. |
case114 |
_BitInt(N) width |
Changing N changes size/alignment and the register/stack class the value is passed in. |
case115 |
_Atomic qualifier |
Adding/removing _Atomic can change size, alignment, and whether the object is passed by lock-free path. |
case116 |
[[no_unique_address]] |
Lets an empty member overlap the next field; adding it shrinks the struct and shifts every following offset. | case117 |
| Concept tightening (C++20) | Narrowing a constraint removes instantiations the consumer relied on — a source break with no symbol-table change for already-emitted instantiations. | case105 |
| LP64 → ILP64 / data-model drift | long/pointer widths change out from under every struct and signature — a whole-ABI shift driven by the target, not the source. |
case112 |
Several more live only in the build flags, not the source, and abicheck
surfaces them as toolchain/deployment risk when build context is captured:
-fno-exceptions / -fno-rtti (drop EH/RTTI machinery callers may rely on),
-fshort-enums (changes enum underlying size — see
Part 3), packing/alignment flags, vector-ABI flags, and
CPU-dispatch/IFUNC selection (case83,
case29).
Why these need debug info or headers
Like the rest of Part 4, every hazard above is recoverable only when DWARF/PDB or headers are supplied — and the dual-ABI and ABI-tag cases need the mangled symbol names, so a stripped, name-demangled view can hide them.
How to design C++ libraries for ABI stability¶
Design patterns for Part 4
- Pure-virtual interface + factory. Expose an abstract class (no data
members, no inline methods) and a C-linkage
create_foo(). Consumers hold only the abstract pointer, so you can evolve the implementation class without touching any consumer vtable. - Non-Virtual Interface (NVI). Public methods are non-virtual
wrappers over a small, stable set of
virtualhooks. You can add public methods (non-virtual additions are ABI-safe) without growing the vtable. - Pimpl ABI firewall. Every data member lives in an
Impldefined in the.cpp; the public class holds onestd::unique_ptr<Impl>.sizeof(Widget)never changes; offsets are invisible. - Inline namespaces for generational ABI. Wrap public declarations in
inline namespace abi_v1. For a breaking change, shipabi_v2alongside and keep the old symbols exported — consumers migrate on their schedule, mirroring libstdc++__cxx11. -fvisibility=hidden+ export macros. Shrink the exported surface to exactly what you intend to stabilize (see Part 5).
Full code for each is in Part 7 — Designing for Stability.
Next¶
Underneath the language-level ABI sits a second contract enforced purely by the
dynamic linker: SONAME identity, symbol visibility, version nodes, calling
conventions, and TLS models — all recorded in the .so itself.
➡️ Part 5 — ELF & Linker-Level Concerns
See also: ABI Cheat Sheet · Risk examples