Skip to content

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.

New to the topic? Don't start here — start with the on-ramp.

This hub is a navigator: it maps the series, the deep-dive pages, and the break families, and points each to its own page (e.g. the evidence-model walk-through, What Each Level Sees). If binary compatibility is new to you, read the five-minute on-ramp first and follow the series in order:

  1. ABI in Five Minutes — the gentlest introduction.
  2. Part 0 — Compatibility as a Product Contract — the framing.
  3. Part 1 — Foundations — symbols, linking, the loader.

Then come back here to navigate the rest of the series.

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 ChangeKind abicheck emits, the evidence tier that reveals it, and a worked example.

Shipping one binary to several OS releases? The Dependency & Runtime Floors guide covers the contract below your library — why glibc/libstdc++ version requirements decide which distros can load a release, how a mere rebuild raises that floor, the macOS/Windows parallels, and the CPU-dispatch (oneDAL/OpenBLAS) scenario where a new-hardware kernel moves the floor for every consumer.

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. #define macros, 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 ContractFoundationsSymbol ContractsType LayoutDesigning for Stability
C++ library maintainer FoundationsC++ ABIType LayoutTransitive BreaksDesigning for Stability
CI / release engineer Product ContractDetecting BreaksTool ComparisonPolicy ProfilesBaselinesExit CodesOutput Formats
Distribution / package maintainer Linker & ELFTransitive BreaksMulti-Binary ReleasesApplication Compatibility
Plugin / SDK author Symbol ContractsPlugin SystemsPolicy ProfilesProduct Contract §4
AI agent / automated reviewer OverviewEvidence & DetectabilityExamples EncyclopediaChange 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
Dependency / runtime floors & environment drift (glibc/libstdc++ floor, DT_RELR, RPATH type) 170 🟡 COMPATIBLE_WITH_RISK — 🔴 or 🟢 once a floor is declared; the 32-bit time64/LFS flip (time64_abi_changed) is always 🔴 BREAKING Dependency & Runtime Floors + Environment & Toolchain Drift
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
Security-hardening & deployment metadata (RELRO, canary, exec-stack, RUNPATH, DT_NEEDED, TLS model, symbol binding) — artifact/linker facts (L0/L3) 128, 133, 134, 135, 136, 137, 138 mixed — 🟡 risk (RELRO/canary/TLS) or 🟢 COMPATIBLE (exec-stack/RUNPATH/DT_NEEDED/binding) Part 5
Build-flag & toolchain drift (L3) — the flags the library was built with, as a finding on their own 130, 131, 132 🟡 COMPATIBLE_WITH_RISK Source & Build Data
Source-only bodies & macros (L4)#define macro values, inline/template/constexpr bodies, uninstantiated templates (none header-reachable) 122 (the documented NO_CHANGE gap — even L4 can't close it; a detected macro/body change is 🟠 API_BREAK / 🟡 risk) mixed — 🟠 API_BREAK / 🟡 risk, or ✅ NO_CHANGE (residual gap) Source & Build Data
Intra-version ABI hygiene / audit — accidental export, private-header leak, unversioned export, RTTI leak (no baseline needed) 143, 144, 145, 146 🟡 risk § source scan
Cross-source validation — one fact, two sources: header↔build mismatch, ODR variant, export↔decl pair 148, 149, 150, 151 mixed — 🟠 API_BREAK or 🟡 risk § source scan

Of these rows the security-hardening & deployment row is artifact/linker coverage (L0/L3, mixed verdicts — an object-size change like case127 is a separate 🔴 BREAKING layout finding, not a hardening risk). The last four rows are the families a plain two-version compare of L0–L2 artifacts does not produce on its own — build-flag drift needs the build data (L3), source-only bodies & macros need the sources (L4), and the intra-version hygiene and cross-source families need the scan's cross-source pass (which reads L0/L1/L2 evidence — no L4 source replay required; the audit fixtures resolve at L0/L2). All five new rows are the subject of the level-by-level walk-through and the source-scan section below.


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 — but that runtime call graph is not automatically the consumer's ABI contract. Existing binaries are bound only to what crosses the compile / link / load boundary: installed public headers, symbols the consumer object directly references, and what the loader must resolve. An internal helper is safe to change while it stays behind that boundary, and becomes contract the moment a public inline body references it or a public header exposes its type by value.

➡️ The full deep-dive — the safe/unsafe boundary shifts, the six-point private-change safety checklist, and how public-surface scoping and scan's baseline-free audit mode (just omit --against) check the boundary — is on its own page: What Is Part of Your ABI Surface?

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 compare --used-by APP (folding the former standalone appcompat command). ASW is consumer-scoped compatibility; library compare/scan is contract-scoped — use both. What app mode can and cannot conclude, and how it compares to the other methods (libabigail, ABICC, bundle scan), is in Evidence & Detectability §4.


Feed abicheck .so + debug info + headers for the best result

abicheck's 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 \
    --header old=include/v1/foo.h --header new=include/v2/foo.h   # both built with -g

Comparing a stripped binary with no headers yields only symbol add/remove coverage and silently misses every layout and source-level break. The governing principle: more evidence cuts both error kinds — each layer you add reduces false negatives and false positives, and more evidence may scope away a false positive but never hide an artifact-proven break (the authority rule). What each layer contributes, the tracked per-tier FP/FN matrix, and the --depth dial that collects the layers are all on the model page: Evidence & Detectability.

Which input proves which family — and what each level actually sees

The three artifact tiers above (L0–L2) are only half the picture, and which input first reveals a given change is worth seeing concretely rather than in the abstract. That entire story — the summary matrices (artifact L0–L2 and source-scan L3–L5) and a single tiny library walked up every evidence level so you can watch each change appear or stay invisible — now lives on its own digestible, diagram-driven page:

➡️ What Each Level Sees — a level-by-level walk-through

The short version, if you only remember one row per level:

Level Newly reveals Blind to
L0 symbols symbol add/remove/rename, SONAME, versioning, visibility anything that keeps the symbol name
L1 debug struct/enum layout, offsets, vtables, calling convention source intent, macros, public-vs-internal
L2 headers signatures, access, noexcept, default-arg & constexpr values, public scoping #define macros, inline/template bodies
L3 build ABI-relevant flags & toolchain (-std, _GLIBCXX_USE_CXX11_ABI) anything inside the source
L4 sources macro / constexpr values, inline/template/uninstantiated bodies the layout actually emitted (L1's job)
L5 graph reachability / impact ranking proves nothing on its own — it prioritizes

Two rules the walk-through makes concrete: no single level sees every change (a stripped-binary L0 compare calls a genuinely breaking release "clean"), and the authority rule — the artifact tiers (L0–L2) set any BREAKING gate, while the build/source tiers (L3–L5) add findings and explanation but never manufacture or delete a proven binary break. abicheck tracks each layer's FP/FN contribution as a CI gate — see Evidence & Detectability → What each layer buys.

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 has one evidence dial — --depth (binary|headers|build|source) — that selects how far down the L0L5 evidence layers (what it sees + authority) to collect; fully explained in Evidence & Detectability. 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.

Orthogonal to depth, omitting --against runs scan in its intra-version single-build hygiene lint that needs no previous version: accidental exports, private-header leaks, unversioned symbols, exported RTTI for internal types, and cross-source mismatches (there is no separate --audit flag — --against alone selects between the two modes). The worked cases (case143–151) with commands are in Source-Scan Depth § single-build audit; their throughline is that a finding invisible or ambiguous to any single source resolves only by crosschecking two, and that a scan always states the depth it actually reached (case147) — never a bare "scan failed".

The L5 graph: reachability, not just structure

L0–L4 answer what changed: a struct grew a field, a function's signature changed, a macro's value differs. None of them answer a different question that turns out to matter just as much: can anything outside this library actually observe that change? A change buried in a namespace called detail/impl/internal (by convention, never part of the documented API) is usually safe to ship without a version bump — unless some public, consumer-facing declaration depends on it, in which case the "internal" label is a fiction and the change is exactly as breaking as if it were public. Telling these two cases apart requires reachability: a graph walk from the public surface, not a flat list of what's public and what isn't.

That graph is L5 — built by folding two kinds of edges on top of the L4 source-fact surface (--depth source, or the standalone abicheck scan):

  • Structural edgesTYPE_HAS_FIELD_TYPE, TYPE_INHERITS, DECL_HAS_TYPE — a public type embeds, inherits from, or is a field/base of an internal one. This is the same relationship compute_leak_paths (the type-layout leak walk) already followed before L5 existed; the graph just makes it explicit and queryable.
  • Behavioral edgesDECL_CALLS_DECL / DECL_REFERENCES_DECL — one declaration's body calls or references another. This is the genuinely new capability: it answers "does the compiled code a consumer links against actually execute a call into this internal symbol?", a question no amount of type-layout analysis can answer, because a call is not a type relationship.

Nodes carry visibility (public_header/private_header/source/…) and, where the L4 surface can tell, decl_kind (ordinary declaration vs. inline/template) — the signal that decides whether a declaration's own body is compiled into your binary or only into the library's. Edges are built from a real build (--sources/--build-info, Clang AST calls/refs) or, with zero build integration, from a header-only Clang AST tree (built automatically for --depth headers and above, since G29 Phase A) that captures in-header inline/template bodies. Either way, evidence degrades gracefully: no graph, no compiler, no relevant edges — every feature described below simply sees nothing and stays silent, per the standing authority rule (L3–L5 evidence explains or corroborates an artifact-proven break; it never manufactures or deletes one).

Why this distinction matters: the dispatcher scenario

The scenario that motivated building this (ADR-044) is a shape real numerics/ML libraries hit constantly — a public inline dispatcher:

// public header — compiled into every consumer's own translation unit
namespace mylib {
    inline Result compute(const Descriptor& d) {
        return detail::compute_avx2(d);   // dispatches to an internal specialization
    }
}

detail::compute_avx2 lives in the detail namespace — by convention, internal. A blanket suppression rule for namespace: "mylib::detail::**" looks obviously correct: nothing in detail is part of the documented API. But compute() is inline, so its body — including the call to compute_avx2— is compiled directly into every consumer's binary. If compute_avx2's signature changes or it's removed, every consumer that already compiled against the old header now calls a symbol that no longer exists (or exists with an incompatible signature) — a real, consumer-visible break, hiding behind an internal-sounding name. Type-layout analysis alone cannot catch this: nothing about compute_avx2's type changed from compute()'s point of view, because the dependency is a call, not a field or base class.

This is exactly what the L5 call-graph walk is for. abicheck walks DECL_CALLS_DECL/DECL_REFERENCES_DECL from every entry whose own body is actually compiled into consumer code, and if that walk reaches the changed detail::compute_avx2, the change is tagged public_reachable with reachability_kind: symbol_availability and a proof path (compute() --[DECL_CALLS_DECL]--> detail::compute_avx2()) before suppression rules ever run. A broad namespace: "mylib::detail::**" rule is then refused for this specific change (a suppression_would_hide_public_break diagnostic explains why, naming the exact path) unless the rule author explicitly opts in with allow_public_break: true — turning a silent, accidental hole in the compatibility contract into a reviewable decision. See Suppressions § Reachability-aware suppression for the full mechanics (the reachability rule setting, the diagnostic format) and ADR-044 for the design rationale.

What it does not flag — staying quiet on the common case

The flip side matters equally: most functions in most libraries are ordinary, out-of-line, non-inline, non-template — an ordinary ".cpp-defined" exported function whose body compiles into the library's binary only. If that function's out-of-line body calls an internal helper, and the helper is later removed, no consumer is affected: either the library's own build fails first (a vendor-side problem, caught by their own CI, invisible to anyone downstream), or the recompiled function simply stops making that call — a consumer only ever links against the function's own exported symbol and never sees, references, or embeds the helper it happens to call internally. abicheck's call-graph walk only seeds itself from entries whose own body is actually emitted into consumer code (inline functions/methods and templates) — an ordinary exported function is public, but is not one of these entries, so a private helper it calls is never reported as "reachable" through it. A project can suppress broad internal-namespace churn with confidence that the tool is not silently blocking the common case while chasing the rare one.

A safe pimpl (pointer-to-implementation) class follows the same logic from the type-layout side: an internal Impl type reached only through a public class's pointer member (std::unique_ptr<Impl>) can freely change its internal layout, because consumers never see Impl's size or fields — only the pointer, whose own size/alignment is stable. The call-graph and type-layout walks agree here: neither reaches into Impl's internals unless something public actually embeds it by value or calls into it from an inline/template body.

Other scenarios the L5 graph catches

Beyond the headline dispatcher case, the same graph backs several narrower, intra-version and cross-version findings:

  • internal_symbol_required_by_public_api — the inverse framing of the dispatcher scenario as its own finding: a public declaration's dependency on a specific internal symbol, surfaced even when nothing changed yet, so a reviewer can see the coupling before it becomes a break.
  • public_api_internal_dependency_added (crosscheck.py, intra-version) — a single-build hygiene check: a public declaration or type newly depends on an internal one, elevated when the internal side is among the files a PR actually touched. No previous version is needed for this one — it is a same-build "does your public surface quietly depend on something unexported?" audit, useful even on a first release.
  • call_graph_public_entry_reachability_changed — the set of internal declarations reachable from an exported entry point changed shape between versions, independent of whether any individual declaration's own diff triggered a break — a widening or narrowing of the effective public surface as a fact worth surfacing on its own.
  • include_graph_public_header_drift / build_option_reaches_public_symbol — the graph also folds in include-file and build-option edges, so a public header quietly pulling in a different private header, or a build flag that reaches all the way to a public symbol's ABI-relevant layout, shows up the same way a call-graph leak does.

All of these are RISK_KINDS or (for the intra-version hygiene checks) advisory findings — per the authority rule, L5 evidence never manufactures a BREAKING verdict on its own. What it changes is whether a break that is independently proven (an exported symbol actually removed, an exported type's layout actually changed) gets correctly attributed to the public surface instead of silently absorbed by an internal-looking suppression rule.

Now run it — the practical flow, plugin, and CI guides

This page is the concept. When you are ready to enable a source scan on a real project, the tool-track guides carry the exact commands, flags, and CI YAML:

You want to… Go to
Pick the right command for your situation (binary compare → full source scan → combine evidence → plugin) Choose Your Workflow
Run abicheck scan and pin a depth Source-Scan Depth
Produce the source facts — post-build replay (Full source scan), abicheck-cc wrapper (Wrapper injection), or the Clang plugin (Plugin injection) Producing Source Facts
Fold build/source evidence into a baseline snapshot Source & Build Data
Wire a full source scan into GitHub Actionssources/build-info/depth, audit, estimate, cross-check gating GitHub Action: Source Scans
Check a host↔plugin ABI contract Plugin Systems
Gate CI on the right verdict tier (binary break vs. source/API break) CI Gating

The CI recipes there go beyond the binary-only compare: a minimal PR scan is four inputs (binary + headers + sources: . + baseline), and the same guide shows enabling each source layer independently — depth: build for cheap L3 build-flag drift, depth: source for full L4 replay plus the change-scoped L5 graph, and the sources/build-info inputs auto-detecting a build-emitted (abicheck-cc / Clang plugin) abicheck_inputs/ pack directly — no separate merge step.

Detection coverage and roadmap

abicheck detects a broad and growing set of change kinds (see the Change Kind Reference for the exact, up-to-date count), 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