ABI/API Handling — A Learning Series¶
This is the conceptual hub for understanding ABI/API compatibility — written to teach the subject, not just catalog it. It is the front door to a nine-part learning series that starts from first principles ("what is a symbol? what does the loader do?") and builds up to the design patterns that keep a C/C++ shared library compatible across releases.
The series is for two audiences at once: developers who maintain or consume shared libraries, and AI agents reasoning about whether a change is safe to ship. Every break is explained as a mechanism — what the compiler baked in, what the loader does, what byte moves — and then as a fix. abicheck's verdicts and change kinds are woven in throughout, so the same page that teaches you why a struct-field insertion corrupts memory also tells you what abicheck will report when it sees one.
Looking for something faster? For a 2-minute scannable card, see the ABI Cheat Sheet. For per-case runnable reproductions with code and a real failure demo, see the Examples & Case Encyclopedia. For verdict semantics and CI exit codes, see Verdicts. For unfamiliar terms (SONAME, vtable, IFUNC, install name, TLS model…), see the Glossary.
Going deep on class layout? The Class Layout ABI & API guide is the single page that maps every class-layout change (base offsets, EBO, vptr, vtable slots, RTTI, standard-layout / trivially-copyable, packing) to the exact
ChangeKindabicheck emits, the evidence tier that reveals it, and a worked example.
Scope & assumptions
- Examples are mostly ELF/Linux and Itanium-C++-ABI flavored unless a section says otherwise. PE/COFF (Windows) and Mach-O (macOS) have their own loader, export, and versioning rules — see the per-platform parallels in Part 5 and the Platform Support reference. For example, the "lookup by name" model in Part 2 is exact for ELF and for most C/C++ exports, but Windows DLLs can also export/import by ordinal, where the contract is a number, not a name.
- Detectability depends on the inputs you give abicheck — symbols only,
DWARF/PDB debug info, or public headers. Some changes (e.g.
#definemacros, inline/template bodies, uninstantiated templates) are invisible to any artifact comparison. See the per-change matrix in Limitations.
How to read this series¶
The parts are ordered. If you're new to ABI compatibility, read them in sequence — each builds on the mental models established by the last. If you're here for a specific problem, jump straight to the relevant part.
| Part | Page | What it covers | Read it when… |
|---|---|---|---|
| 0 | Compatibility as a Product Contract | Public surface, SemVer mapping, contract shapes — the framing | …before anything else: a change is only a "break" if it breaks a promise |
| 1 | Foundations | Source → object → link → load; what a symbol is; API vs ABI | …you want the ground-up mental model (start here) |
| 2 | Symbol Contracts | Removal, rename, signature, pointer-level, globals | …a symbol disappeared or changed meaning |
| 3 | Type Layout | Struct size/offset, alignment, enums, unions, bitfields | …you changed a struct, enum, or union |
| 4 | C++ ABI | Vtables, mangling, templates, noexcept, trivial→non-trivial, bases |
…you maintain a C++ library |
| 5 | Linker & ELF | SONAME, visibility, versioning, calling conv., TLS, security metadata | …a load-time/linker contract changed |
| 6 | Transitive Breaks | Dependency leaks, anonymous structs, type-kind swaps, reserved fields | …the symbol table looks identical but consumers still break |
| 7 | Designing for Stability | Opaque handles, Pimpl, version scripts, CI gating — with full code | …you're designing an API to evolve safely |
| 8 | Detecting Breaks | Tracking approaches, evidence each break family needs, why single-method checkers miss whole families | …you're deciding how to catch all of the above in CI |
flowchart LR
P0["0 · Product<br/>Contract"] --> P1["1 · Foundations"]
P1 --> P2["2 · Symbol<br/>Contracts"]
P1 --> P3["3 · Type<br/>Layout"]
P2 --> P4["4 · C++ ABI"]
P3 --> P4
P4 --> P5["5 · Linker<br/>& ELF"]
P3 --> P6["6 · Transitive<br/>Breaks"]
P5 --> P7["7 · Designing<br/>for Stability"]
P6 --> P7
P7 --> P8["8 · Detecting<br/>Breaks"]
Cross-cutting companion: Evidence & Detectability explains which inputs (symbols, debug info, headers, app, bundle) let a tool see a given change at all — read it alongside any part when you're wondering "why did the tool catch this but not that?"
Pick a reading path for your role¶
The series is ordered, but you rarely need all of it at once. These paths get each audience to the pages that matter for them fastest:
| Audience | Recommended path |
|---|---|
| New C/C++ library author | Product Contract → Foundations → Symbol Contracts → Type Layout → Designing for Stability |
| C++ library maintainer | Foundations → C++ ABI → Type Layout → Transitive Breaks → Designing for Stability |
| CI / release engineer | Product Contract → Detecting Breaks → Tool Comparison → Policy Profiles → Baselines → Exit Codes → Output Formats |
| Distribution / package maintainer | Linker & ELF → Transitive Breaks → Multi-Binary Releases → Application Compatibility |
| Plugin / SDK author | Symbol Contracts → Plugin Systems → Policy Profiles → Product Contract §4 |
| AI agent / automated reviewer | Overview → Evidence & Detectability → Examples Encyclopedia → Change Kind Reference |
Break families at a glance¶
Every detected change maps to one of these families. The verdict column shows the
typical classification; the exact verdict per fixture lives in
examples/ground_truth.json and the Examples Encyclopedia.
The Part column points to where the mechanism is explained.
Case numbers link straight to the generated example page; the Typical verdict column says "mixed" where the verdict is case-dependent (the per-fixture verdict is the source of truth).
| Family | Representative cases | Typical verdict | Explained in |
|---|---|---|---|
| Symbol/function removal & rename | 01, 12, 58, 66 | 🔴 BREAKING | Part 2 |
| Signature changes (params, return, pointer level) | 02, 10, 33, 46 | 🔴 BREAKING | Part 2 |
| Global variable type/qualifier/removal | 11, 39, 58 | 🔴 BREAKING | Part 2 |
| Struct/class layout, alignment & packing | 07, 14, 40, 42, 43, 56, 117 | 🔴 BREAKING | Part 3 |
| Enum value/underlying changes | 08, 19, 20, 57 | 🔴 BREAKING | Part 3 |
| Union layout | 24, 26 (grows) · 26b (no growth) | mixed — 🔴 if size grows, else 🟢 | Part 3 |
| C++ vtable & virtual methods | 09, 23, 38, 68, 72 | 🔴 BREAKING | Part 4 |
| C++ qualifiers, mangling & ABI tags | 21, 22, 30, 71, 86, 101, 113 | mixed — 🔴 BREAKING or 🟠 API_BREAK | Part 4 |
| Trivial → non-trivial (calling convention) | 64, 69 | 🔴 BREAKING | Part 4 |
| Templates, inline & ODR | 16, 17, 47, 59, 79, 85, 87 | mixed — 🔴 BREAKING or 🟢 COMPATIBLE | Part 4 |
| Modern C/C++ contract shifts (char8_t, _BitInt, _Atomic, concepts) | 105, 114, 115, 116 | mixed — 🔴 BREAKING or 🟢 COMPATIBLE | Part 4 §Modern |
| ELF/linker metadata (SONAME, visibility, versioning, RPATH, TLS) | 05, 06, 13, 49, 51, 52, 65, 67 | mixed — 🔴 BREAKING or 🟢 COMPATIBLE | Part 5 |
Transitive/dependency & detail:: leaks |
18, 48, 74, 75, 76, 77, 80, 97, 104, 112 | 🔴 BREAKING | Part 6 |
| Source-only / API-level (rename, access, explicit, default args, hidden friends) | 31, 34, 96, 106, 123, 124 | 🟠 API_BREAK | Part 6 §Source-only API breaks |
| Deployment risk (noexcept, ISA dispatch, version-require) | 15, 83 | 🟡 COMPATIBLE_WITH_RISK | Part 4 |
| Compatible additions & quality signals | 03, 25, 26b, 27, 29, 61, 62, 99 | 🟢 COMPATIBLE | Part 7 |
| Scoped/non-public internal changes | 118, 119, 120 | ✅ NO_CHANGE | Part 6 |
The one idea to carry through the whole series¶
If you remember nothing else:
The compiler bakes the library's ABI facts — sizes, offsets, register choices, vtable slot numbers, symbol names — into every caller, as immediate constants, and never re-checks them. When the library changes one of those facts in a later release, the old caller keeps using the old number. Nobody re-validates it. That is why an ABI break is silent: no linker error, often no crash, just wrong bytes at the wrong address.
Every fix in Part 7 is therefore a variation on a single move: stop publishing the fact — hide it behind a pointer, a version node, or hidden visibility — so you stay free to change it.
abicheck exists to catch these breaks before they ship: it dumps a snapshot of each binary, diffs them structurally, and classifies every difference into one of five verdicts mapped to CI exit codes. See Part 1 §7 for how that pipeline works, and Verdicts for the exit-code semantics.
Runtime calls are not the same as ABI dependencies¶
A public entry point may call a long chain of private helpers at runtime. That runtime call graph is not automatically the consumer's ABI contract. Existing binaries are bound only to the symbols, types, constants, layouts, and inline code that cross the compile / link / load boundary: what appears in installed public headers, what the consumer object directly references, and what the loader must resolve.
Safe runtime call chain:
app -> public_func
public_func -> hidden internal_helper
Consumer binary depends on public_func only. internal_helper can change because
it is not exported, not referenced by public headers, and not part of public
layout or inline code.
The same private helper becomes an ABI dependency if the boundary shifts:
Unsafe link-time dependency:
inline public_func in an installed header -> detail::internal_helper
The consumer object now directly references detail::internal_helper. Removing,
renaming, hiding, or changing that helper can break already-built consumers.
Private types follow the same rule. A helper struct is safely private while it is fully hidden behind an opaque pointer or implementation file, but not when the public header exposes it by value:
Unsafe compile-time layout dependency:
public header exposes InternalType by value
The consumer bakes sizeof(InternalType), alignment, field offsets, base-class
layout, and calling-convention facts into its own object code.
Use this checklist before calling an internal change ABI-safe. A private change is safe only when all of these remain true:
- the private symbol is not exported or otherwise load-resolvable by consumers;
- public inline, template,
constexpr, or macro bodies do not reference it; - it is not part of any public struct/class layout, base class, field, parameter, return value, exception specification, allocator/deallocator rule, or calling convention;
- it is absent from installed public headers except behind an opaque declaration that reveals no size, members, bases, or required helper symbols;
- no plugin, callback, subclassing, serialization, or user-extension model promises that consumers may provide or observe the changed detail;
- the public behavior contract remains compatible, even if the binary boundary is intact.
This distinction is why Part 5 treats leaked private exports as dangerous, Part 4 treats inline/template bodies as part of the contract, and Part 6 treats exposed dependency types as transitive ABI.
App-swap (ASW): the consumer-scoped runtime check¶
The most realistic consumer-level test is application software swap (ASW) —
build an app against the old library, drop in the new one, and run it. abicheck
exposes this as appcompat: it parses the app's
required symbols, compares old/new in full mode, and filters findings to the
changes that affect that app. ASW is powerful and narrow at the same time:
- It proves real loader/linker behavior and tested execution paths — "this app does not import the removed symbol", "this app needs symbol version X the new lib lacks".
- It cannot speak for the whole contract: untested public API, future consumers, silent layout corruption a test never exercises, and source-only (recompile) breaks all stay invisible.
So ASW is consumer-scoped compatibility; library compare/scan is
contract-scoped. Use both — compare/scan protect the library contract,
ASW protects a specific deployment. ASW is one of several methods, each seeing
different evidence; the full comparison of methods (libabigail, ABICC, app-swap,
bundle scan) is in
Evidence & Detectability.
Feed abicheck .so + debug info + headers for the best result¶
abicheck's three analysis tiers are additive, and the highest-coverage setup is a single comparison of debug-enabled libraries with their public headers supplied:
abicheck compare libfoo_v1.so libfoo_v2.so \
--old-header include/v1/foo.h --new-header include/v2/foo.h # both built with -g
.so+ DWARF (-g//Zi) gives the ground-truth emitted ABI — struct layout, field offsets, alignment/packing, enum values, calling convention.- public headers (castxml or clang,
--ast-frontend) add the source-level API surface the binary cannot carry —final, access, ref-qualifiers,noexcept/explicit, default-argument values, andconst/constexprconstant values (the last two have no symbol, so only header analysis can reach them).
Comparing a stripped binary with no headers yields only symbol add/remove
coverage and silently misses every layout and source-level break. If you ship
stripped, build a debug copy purely as an analysis input and compare that with
headers. A handful of changes remain invisible to any artifact comparison
(#define macros, inline/template bodies, uninstantiated templates) — see
Limitations → Source-only changes
for the full per-change detectability matrix.
These three tiers are artifact layers L0–L2. Two optional layers go
further without overriding an artifact-proven break: L3 build context
(-p build/) pins the exact ABI-affecting flags, and L4 source/evidence
packs recover several of the otherwise-invisible source-only facts above
(macro/constexpr values, uninstantiated templates). See Build & Source Packs and the full L0–L4
model.
Which input proves which family¶
The minimum input needed to detect each family — and the most common reason a real change is missed:
| Change family | Symbols only | + DWARF/PDB | + Headers | Common false negative |
|---|---|---|---|---|
| Exported function/variable removed or renamed | ✅ | ✅ | ✅ | symbol filtered as non-public (visibility/scope) |
| Parameter / return / pointer-level signature change | ⚠️ partial¹ | ✅ | ✅ | stripped binary, C symbol carries no type |
| Struct/class layout, alignment, packing, bitfields | ❌ | ✅ | ✅ | stripped and no headers → reported NO_CHANGE |
| Enum value / underlying-type change | ❌ | ✅ | ✅ | no debug info and no headers |
| C++ vtable / virtual-method change | ❌ | ✅ | ✅ | mangled symbols stripped or demangled-away |
| Calling convention (trivial→non-trivial) | ⚠️ | ✅ | ⚠️ | no debug info to see triviality |
Source-only API (access, explicit, default args, renames, constants) |
❌ | ❌² | ✅ | no headers supplied (no symbol exists at all) |
| Templates / inline bodies | ⚠️ instantiated only | ⚠️ | ⚠️ | uninstantiated / header-only body — invisible to any artifact |
Modern C/C++ (dual-ABI, ABI tags, char8_t, _BitInt, _Atomic) |
⚠️ mangling only | ✅ | ✅ | demangled view hides the tag/ABI flip |
| SONAME / visibility / versioning / RPATH / TLS metadata | ✅ | ✅ | ✅ | platform-specific (PE/Mach-O differ — see Part 5) |
¹ C++ mangled names encode parameter types, so symbol-only catches many C++
signature changes; C symbols do not. ² A few source-only changes (e.g. enum/field
renames) are visible in DWARF too; most (default args, explicit, const
values) leave no binary trace and require headers. The authoritative per-change
table is in Limitations.
Going deeper than artifacts: the source scan¶
Artifact comparison (L0–L2) proves what the shipped binary did. To recover the
source-only facts it cannot see — #define macros, constexpr values,
default-argument values, inline/template bodies, uninstantiated templates —
abicheck can read the build's compile database (L3) and replay the sources
(L4), and fold a source/build reachability graph (L5). The one-shot
driver is abicheck scan. It exposes two orthogonal "level" axes — the L0–L5
evidence layers (what it sees + authority) and the s0–s6 source-analysis
methods (how it gathers L3–L5) — fully explained in
Scan Levels (S vs L). The governing authority
rule: source/build evidence (L3/L4/L5) explains, localizes, scopes, or raises
its own source-/API-level findings, but never deletes an artifact-proven
break.
scan --mode picks a fixed depth: pr (the cheap per-PR gate, diff-seeded L4),
pr-deep (PR + the full L5 graph), baseline (a full-depth release snapshot),
and audit — an intra-version single-build hygiene lint that needs no previous
version. Audit surfaces "bad ABI hygiene" visible from one build: accidental
exports, private-header leaks, unversioned symbols, exported RTTI for internal
types, and cross-source mismatches. Worked example cases:
| Family | Example case | What it shows |
|---|---|---|
Accidental export (exported_not_public) |
case143 | symbol exported but in no public header |
Private-header leak (private_header_leak) |
case144 | public API pulls an unshipped header |
Unversioned export (unversioned_exported_symbol) |
case145 | export with no version node though a scheme exists |
Exported RTTI for internal type (rtti_for_internal_type) |
case146 | _ZTI/_ZTV leaked for a private-header type |
Header/build mismatch (header_build_context_mismatch) |
case148 | L2 macros ↔ L3 flags disagree |
ODR type variant (odr_type_variant) |
case149 | one type, two per-TU layouts (L4 ↔ L4) |
Bidirectional export ↔ decl (exported_not_public/public_not_exported) |
case150 | L0 exports ↔ L2 decls, both directions |
| Provider corroboration | case151 | confidence grows with how many sources agree |
| Depth ladder | case147 | same input answered at S3 vs deeper, honest coverage |
The throughline of the cross-source cases: a finding invisible or ambiguous to
any single source resolves only by crosschecking two — and case147 shows
the scan stating the depth it actually reached, never a bare "scan failed".
Practical recipes are in the Source-Scan Levels guide.
Detection coverage and roadmap¶
abicheck detects 254 change kinds today (see the
Change Kind Reference), spanning every family in
the table above — including the calling-convention, alignment/packing, bit-field,
dual-ABI (_GLIBCXX_USE_CXX11_ABI), ABI-tag, char8_t, _BitInt, _Atomic,
and CPU-dispatch cases. Areas still deepening: richer cross-compiler ABI-drift
modelling (GCC vs Clang vs MSVC for the same headers) and LTO/visibility
interactions where an inlined symbol disappears. The authoritative, always-current
taxonomy is the generated Change Kind Reference
and Examples Encyclopedia.
➡️ Start the series: Part 1 — Foundations