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.

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.

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
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, and const/constexpr constant 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 L0L5 evidence layers (what it sees + authority) and the s0s6 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