What an ABI Actually Is
A calling convention plus object layout plus symbol naming plus everything else two separately compiled binaries must agree on. Breaking an ABI does not produce a link error — it produces a field read from the wrong offset, and an answer that is quietly wrong.
What is an ABI, and why does breaking one corrupt data instead of failing to build?
The program as a set of independently produced binary artifacts plus the assumptions each one makes about the others. An ABI is that assumption set written down: sizes, alignments, field offsets, register roles, symbol spellings, exception mechanics. It exists to answer the question separate compilation makes unavoidable — what may one translation unit assume about code it will never see the source of?
A change to a library is ABI-compatible only if every fact a previously compiled caller may have baked into its own machine code remains true. Concretely: no existing field may move or change size, no existing virtual function may change position in a vtable, no existing exported symbol may change its spelling or its calling convention, and no type whose layout crosses the boundary may change its size or alignment. Source compatibility is not sufficient and not necessary — a change can recompile cleanly and break every existing binary, and a change can require a source edit while leaving old binaries working perfectly.
Key points
- An ABI is a calling convention plus type layout plus symbol naming plus object model plus exception mechanics plus a runtime environment. Only one of those produces a link error when it is violated.
- Field offsets are compiled into the caller, not supplied by the library, which is why a layout change silently redirects every access.
- Source compatibility and binary compatibility are independent: a change can preserve either, both or neither.
- The C ABI is the interoperability standard because the platform ABI is specified in C terms — and everything C cannot express is excluded from every cross-language boundary as a result.
- A stable native boundary is built from opaque handles, paired create and destroy functions and fixed-width types, because those are the constructs that hide layout.
The five things an ABI has to pin down
A calling convention is the part everyone names first, and it is roughly a fifth of the problem. Two separately compiled objects also have to agree on what a type looks like in memory, what a function is called in the symbol table, how a non-local exit unwinds through frames, and what the runtime support library offers. Disagree about any of them and the linker will usually still succeed.
The reason for that final clause is worth stating plainly: an object file records symbol names, section contents and relocations. It does not record "this function expects its second argument in rsi" or "this code believes struct point is sixteen bytes". Those beliefs are compiled into the instruction stream as register numbers and constant offsets. There is nothing left for a linker to compare.
So an ABI mismatch is not a build failure. It is a program that reads field y from the offset where y used to be, gets whatever now lives there, and continues confidently. [[miscompilation]] covers the case where the compiler produced wrong code from right source; this is the case where every compiler did its job and the assembly of the parts is wrong.
| Layer | What it fixes | Symptom of a mismatch |
|---|---|---|
| Calling conventiontarget | Argument locations, return location, saved-register split, stack alignment | Arguments read from the wrong registers; a caller local corrupted across a call |
| Type layouttarget | Sizes, alignments, field offsets, padding, bitfield placement, endianness | A field reads a neighbouring field's bytes; a struct copy truncates or overruns |
| Symbol naming | How a source-level name becomes a linker symbol, including overloads and namespaces | An undefined reference at link time — the one honest failure in this table |
| Object modeltarget | Vtable layout, RTTI representation, virtual base offsets, inheritance layout | A virtual call dispatches to the wrong method, or to something that is not a method |
| Exceptions and unwindingtarget | Personality routines, unwind table format, how a throw crosses a frame | An exception crossing the boundary terminates the process instead of being caught |
| Runtime environmenttarget | Which libc, which C++ standard library, which allocator, what a FILE* is | Memory freed by an allocator that did not allocate it; a handle interpreted as the wrong struct |
The offset is in the caller, not the library
int. The layout rules themselves — where padding goes, how alignment is computed, how bitfields are packed — are part of the psABI and genuinely differ across platforms: bitfield allocation order differs between ABIs, long is 8 bytes on LP64 Unix and 4 on Windows LLP64, and long double is 80-bit extended on x86-64 Linux and 64-bit on Windows and 128-bit on AArch64 Linux.This is the mechanism behind every ABI break and it is one sentence: when a caller accesses p->y, the compiler emits an instruction containing the *numeric offset* of y, computed from the header at the caller's compile time. The library does not supply that number at runtime. It is baked into the caller's instruction stream, and it stays baked in until that caller is recompiled.
So adding a field to the front of a struct, changing an int to a long, reordering members, or inserting a virtual function into the middle of a class does not change any code in the caller. It changes the *meaning* of code the caller already contains. Recompiling the library alone gives you a library that lays out the type one way and callers that address it another way, and every access from that point on is off by the size of whatever moved.
The transform below is the whole lesson in nine lines. Note what it does *not* show: an error message. There is no point in this process at which either side can detect the disagreement.
// v1 header, compiled into every existing caller
struct point { int x; int y; };
// caller emitted: mov eax, [rdi+4] ; p->y is at offset 4// v2 header, library rebuilt
struct point { int x; int z; int y; };
// library now believes: ; p->y is at offset 8
// the old caller still executes: mov eax, [rdi+4] ; reads zAdding a field is ABI-compatible only if no existing caller has the type's layout compiled into it — which in practice means the type is never allocated, embedded, or field-accessed outside the library, only handled through an opaque pointer. Appending to the end of a struct is additionally safe only if nothing ever allocates one by value, embeds it in another type, or stores an array of them, since all three depend on sizeof.
Whenever a caller allocates the struct, embeds it, takes sizeof, or reads a field after the insertion point — all of which are exactly what a plain public struct in a header invites. Inserting a field before an existing one is unconditionally an ABI break, and so is inserting a virtual function anywhere but the end of a class, because that shifts every later vtable slot.
Source compatibility and binary compatibility are different axes
These two are constantly conflated and the confusion has real consequences. Source compatibility means existing code still compiles against the new version. Binary compatibility means existing *compiled* code still runs against the new version. Neither implies the other, and the four combinations all occur in practice.
Adding a private data member to a C++ class is source-compatible and an ABI break. Renaming a parameter is both. Changing an inline function's body is source-compatible and an ABI break for every caller that already inlined the old body — which is why inline functions and templates are the most treacherous part of a C++ library boundary. And a change that requires callers to pass an extra argument breaks source compatibility while, if done as a new symbol alongside the old one, leaving every existing binary running.
The discipline that follows is that a library with a stable ABI has to decide, deliberately, what is inside the boundary and what is on it. Everything on the boundary is frozen. This is what [[abi-stability]] is about, and it is why the techniques for it — opaque pointers, versioned symbols, reserved padding, pimpl — all amount to putting as little as possible on the boundary in the first place.
| Change | Source compatible | Binary compatible |
|---|---|---|
| Add a public method to a C++ class (non-virtual)target | Yes | Yes — non-virtual methods are ordinary symbols and do not affect layout |
| Add a private data membertarget | Yes | No — the size and every later offset change |
| Add a virtual function in the middle of a classtarget | Yes | No — every later vtable slot shifts |
| Change the body of an inline function or a templatetypical | Yes | No — callers already contain a copy of the old body |
| Add a parameter with a default valuetarget | Yes for callers, and the mangled name changes | No — the old symbol disappears, so this is an undefined reference |
| Add a new function under a new symbol, keeping the old one | Yes | Yes — this is the whole technique behind symbol versioning |
| Rename a struct field | No | Yes — the layout is unchanged and no compiled caller cares about the name |
The C ABI is the only lingua franca
Every language that talks to another language talks C. Not because C is good at it, but because the platform ABI is specified in C terms — the psABI documents describe how C types are passed and laid out — and because C's type system is small enough that everyone can implement the subset. Python's ctypes, Rust's extern "C", Go's cgo, the JVM's FFI, WebAssembly's component tooling and every plugin interface in existence all converge on the same narrow waist.
What that waist excludes is the interesting part. No overloading, because C does not mangle. No exceptions across the boundary, because unwinding is not part of the C ABI on most platforms and a throw that escapes a C frame is undefined. No templates or generics, because there is nothing to instantiate against. No ownership, because C has no notion of it — which is why every C API is accompanied by prose explaining who frees what, and why that prose is where the bugs are.
The practical shape of a stable native library boundary follows from this: opaque handles instead of structs, explicit create and destroy functions instead of constructors, integer error codes instead of exceptions, and no type on the boundary whose layout the caller can see. That is not a style preference; it is the set of things that survive the ABI constraints in this lesson.
- Opaque pointer types: the caller never learns a size or an offset, so nothing can be baked in.
- Explicit
createanddestroyin the library, so allocation and deallocation happen on the same side of the boundary with the same allocator. - Error codes rather than exceptions, because unwinding across an ABI boundary between different toolchains is not portable.
- Fixed-width integer types rather than
intandlong, whose widths differ across platform data models. - A version query function, so a caller can detect the mismatch this lesson says cannot be detected.
How it works
The steps, in the order the compiler takes them.
- The compiler reads the type declarations available in this translation unit and computes a layout — offsets, padding, size, alignment — according to the platform ABI's rules.
- Every field access, every allocation, every array index and every by-value copy is emitted using those computed constants directly in the instruction stream.
- Function symbols are emitted under names determined by the language's mangling scheme, and calls are emitted according to the platform calling convention.
- The object file records symbol names and relocations, and records nothing about layout, register roles or conventions — the assumptions are gone, absorbed into the code.
- The linker matches symbol names and patches addresses. It has no basis on which to verify any of the assumptions above, so it does not.
- At runtime the assembled program executes the caller's baked-in offsets against the library's actual layout, and agrees or corrupts silently.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A library is upgraded, a struct grew a field, and a field access in a caller that was never recompiled now reads the neighbouring member. The program produces confidently wrong values with no crash.
- Two libraries in one process were built against different versions of a shared type, and an object allocated by one and freed by the other corrupts the heap — a crash arbitrarily far from the mistake.
- A C++ exception is thrown across a boundary into a module built by a different toolchain, and the process calls
std::terminateinstead of running the handler that is visibly right there in the source. - A plugin built against a newer header is loaded by an older host, a vtable slot has shifted, and a virtual call lands on the wrong method — which sometimes runs, with the wrong arguments.
- Memory allocated by a library's allocator is freed by the caller's, because both are called
freeand they are two different heaps — the classic Windows CRT mismatch. - A build passes on Linux and produces wrong integer values on Windows, because the code assumed
longis 64 bits and LLP64 says it is 32.
When it helps
- Designing a library that other people will link against, especially one that will be distributed as a binary and upgraded independently of its callers.
- Debugging "it worked before the upgrade" reports where the code did not change: a field reading the wrong value across a version boundary is an ABI symptom and almost nothing else looks like it.
- Deciding what to put in a public header. Anything a caller can see the layout of is now frozen, and headers are where that decision is accidentally made.
When it hurts
- Inside a single build. Within one compilation everything is recompiled together, ABI stability buys nothing, and designing for it costs indirection and allocation for no return.
- When it becomes cargo cult. Pimpl on every class in an internal codebase adds a heap allocation and a pointer chase per object to protect a boundary that does not exist.
What it costs
Every one of these is paid by something.
- A frozen ABI buys callers that keep working across upgrades, including security updates delivered without recompiling the world, and costs the ability to change any type on the boundary — which accumulates as design debt that cannot be paid off.
- Hiding layout behind opaque handles buys the freedom to change internals and costs an indirection on every access, a heap allocation per object, and the loss of inlining across the boundary.
- Exposing rich types on the boundary buys performance and expressiveness and costs every future version, because each exposed type is now a promise about bytes.
- Standardising on the C ABI buys universal interoperability and costs the type system: ownership, lifetimes, error propagation and generics all have to be re-encoded in prose and integer codes on the far side.
What else you could do
What a different compiler or language does instead, and when that is better.
- No ABI stability at all, as Rust and Swift's generics and most language-internal calling conventions do: recompile everything together, and get freedom to change representations and to specialise calls. This is the right answer whenever the whole program is built at once.
- Serialisation instead of a shared layout: pass data as a length-delimited encoded message rather than as a struct. This is what an out-of-process boundary already forces, and it survives version skew by construction at the cost of encoding and copying on every call.
- A versioned virtual interface, as COM does: every published interface is immutable and new functionality arrives as a new interface obtained by query. Verbose, and it has genuinely kept binaries working for three decades.
- Recompile everything, always, as a monorepo with a hermetic build does — see
[[hermetic-compilation]]. If nothing is distributed as a binary against an independently-built dependency, most of this lesson stops applying.
See it for yourself
The flag, dump or tool that shows you this directly.
- See the layout the compiler chose:
clang -Xclang -fdump-record-layouts -c file.cppprints every struct and class with field offsets, sizes and vtable slots. - Confirm sizes and offsets directly:
pahole binaryreads DWARF and prints structures with their holes and padding, which is also how you find wasted space. - See the baked-in constants:
objdump -don the caller and look for the immediate displacements in the field accesses. That number is the entire subject of this lesson. - Check compatibility mechanically:
abi-compliance-checkerandabidifffrom libabigail compare two builds of a library and report ABI-relevant differences. - Read the specification: the System V x86-64 psABI for layout and passing rules, and the Itanium C++ ABI for vtables, mangling and RTTI — which is what every non-MSVC C++ compiler implements.
Plausible wrong readings
Stated the way a confident engineer states them.
- "If it links, the ABI matches." Linking checks symbol names and nothing else. Layout and convention mismatches are invisible to a linker by construction.
- "The ABI is the calling convention." The calling convention is one layer. Type layout, symbol naming, the object model and the exception mechanism are all part of the same contract.
- "Adding a field to the end of a struct is safe." Only if no caller allocates it, embeds it, takes its size, or stores an array of it. In a plain C header, callers do all four.
- "C++ has an ABI." C++ has several, and the standard specifies none of them. The Itanium C++ ABI is what Clang and GCC implement on Unix-likes; MSVC implements a different, incompatible one on Windows.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
An ABI is everything two separately compiled pieces of code have to agree on: where arguments go, what a struct looks like in memory, what functions are called in the symbol table, and how errors travel. The agreement is compiled into both sides, so if it changes on one side only, nothing complains — the code simply reads the wrong bytes from then on.
practical
The rule that covers most cases: anything whose layout a caller can see is frozen the moment you ship it. Put opaque handles in public headers, pair every create with a destroy on the same side of the boundary, use fixed-width integer types, and do not let exceptions cross a boundary between toolchains. When "it worked before the upgrade" arrives with no source change, check whether a shared type grew.
advanced
The deep reason this is hard is that an ABI is a distributed invariant with no runtime representation. Every other contract in a compiler is checked somewhere — types by the checker, symbols by the linker, memory by the allocator. This one is checked nowhere, because the evidence for it was consumed at compile time and turned into immediate operands. The industry's responses all amount to reintroducing a checkable artifact: symbol versioning makes an incompatible change into a name change so the linker can see it; libabigail reconstructs layouts from DWARF and diffs them; COM makes every interface immutable and adds a runtime query. Each of those is an attempt to convert an invisible failure into a visible one, and the fact that three quite different industries invented three quite different mechanisms for it says how badly the visible failure is wanted.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
long is 64-bit on LP64 Unix and 32-bit on Windows LLP64; long double is 80-bit extended on x86-64 Linux, 64-bit on Windows and 128-bit on AArch64 Linux; bitfield packing order differs between psABIs. Code that survives a port has usually stopped assuming any of them.If you were asked this in an interview
- A library adds a field to the middle of a public struct and ships a new build. The callers are not rebuilt. What happens, and at which point could anything have detected it?
- Give a change that is source-compatible and an ABI break, and one that is an ABI break but not source-compatible.
- Why does every cross-language FFI go through the C ABI, and what does that exclude?
Connections
- Programming Languages & Runtime Internals — Object representation at runtime — headers, vtables, boxed values and what a reference actually isThe ABI freezes a layout; the runtime is what lives inside it and what a garbage collector or a dynamic dispatcher expects to find at each offset. Deciding the layout is a compiler job, and using it at runtime is owned there.
- DevOps / Production Engineering — Distributing binaries and managing dependency upgrades across independently built artifactsEverything in this lesson only matters because the parts are built at different times by different people. How artifacts are versioned, pinned and rolled out is what decides how often a caller and a library are allowed to disagree.