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
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 put at risk is the caller's unwinding assumption. The v1
compiler saw noexcept and compiled the call site on the promise that nothing
would propagate out of it — typically emitting no cleanup landing pad for that
call. If v2 now throws, an exception travels through a frame that was never
compiled to cooperate with it.
Keep four independent facts separate here, because they have different mechanisms and different verdicts:
- Linkage. On an ordinary member or free function, toggling
noexceptleaves the mangled symbol unchanged — so nothing breaks at link or load time. - Type system. Since C++17
noexceptis part of the function type, so it does participate in mangling wherever a full function type is encoded (see the C++17 note below). - Behavioral contract. Letting an exception escape where a compiled caller
was promised none is a real incompatibility — but what actually happens
(local cleanups skipped, an outer handler still reached, or
std::terminate()) depends on the compiled caller, the toolchain, and the control flow. It is not one fixed outcome, and this page deliberately does not assert one; the mechanics are owned by Exception Unwinding. - Scanner behavior. The bare
noexcepttoggle, a runtime-floor finding, and a genuine symbol/type break are three different findings from different evidence — don't explain them with one mechanism (see the note below).
This is the deployment-risk shape: binary-linkable, source-recompilable, but semantically riskier 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 — 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).
Be precise about what escalates. Simply taking the address of
reset() noexcept does not: the compiled pointer still targets the same
unchanged _ZN6Buffer5resetEv, so nothing breaks at link or load. The
🔴 BREAKING case is narrower — the function type has to be embedded in
some other ABI-visible mangled entity, so that entity's own name moves:
an exported function taking void (*)() noexcept as a parameter, or a
template specialization instantiated on the function type. The component
that encodes it is Do, not the E that terminates every function
type regardless:
Same function name, same everything else — Do is the only difference,
and dropping noexcept from the parameter type is what makes the old
symbol vanish.
The old symbol disappears and consumers of it fail to link.
Exception unwinding: the machinery behind noexcept¶
Removing noexcept turns on the caller's unwinding assumption, and that
assumption rests on a full ABI of its own (unwind tables, the personality
routine, RTTI-based catch matching across a DSO boundary) —
Exception Unwinding covers it.
6. Trivial → non-trivial: the invisible calling-convention flip¶
An aggregate that is trivial for the purposes of calls is passed according
to the platform's own C ABI — often in registers, though System V AMD64 still
classifies it as MEMORY when it is too large or has unaligned fields. A
non-trivial one is instead passed by invisible reference: the caller
materializes the object on the stack and hands the callee a pointer. Flipping
a type between the two therefore changes how every existing caller passes it. The governing property is the Itanium ABI's
non-trivial for the purposes of calls
rule — related to, but not identical with, std::is_trivially_copyable. A type
qualifies when it has a non-trivial copy constructor, move constructor, or
destructor, or when all of its eligible copy and move constructors are
deleted. Note that "non-trivial" is not the same as "user-provided": an
implicit special member is non-trivial too if the class has virtual functions
or virtual bases, or if a base or member's own corresponding member is
non-trivial. 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. Two different findings cover it, from two different evidence sources — the exact per-kind mapping is owned by Class Layout ABI & API:
value_abi_trait_changed(L1, DWARF). DWARF has no "trivially copyable" attribute; abicheck infers non-triviality-for-calls from the DIE structure (a user-provided destructor or copy/move constructor, a base class, or a member whose own type is non-trivial).trivially_copyable_lost(L2, header AST). Read from the compiler's own trait, which only the direct-clang AST path supplies — not castxml, and not DWARF.
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 fixed offset, and
those offsets — plus which vptr sits at offset 0, the this-adjusting
thunks an override of a non-primary base needs, and the empty-base and
tail-padding optimizations that let a base's change re-lay-out a derived
class the author never touched — are compiled into every upcast and
virtual call (case60,
case37,
case94). The
full treatment — every class-layout change, the ChangeKind it emits, and
the evidence tier that reveals it — is owned by
Class Layout ABI & API.
Modern C/C++ and toolchain ABI hazards¶
Newer language features and toolchain flags add a class of break where the declaration looks unchanged but the emitted bytes move — Modern C/C++ and Toolchain ABI Hazards has the case-by-case table.
How to design C++ libraries for ABI stability¶
The patterns that keep a C++ ABI evolvable — pure-virtual interface plus factory, non-virtual interface, the Pimpl firewall, inline namespaces for generational ABI, hidden visibility with export macros — are the subject of Part 7 — Designing for Stability, with full code for each.
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 · Exception Unwinding · Modern C/C++ and Toolchain ABI Hazards
Ladder: ← Part 3 — Type Layout Breaks · Step 3 · How Breaks Happen · Part 5 — ELF & Linker-Level Concerns →