Your code can compile cleanly, against the exact same header, and still break the moment it is linked against something built with a different compiler version. The API matched. The ABI did not. These are two different contracts, enforced at two different times, and only one of them the compiler checks for you.
API vs ABI
API (Application Programming Interface) is what the compiler checks. Function signatures, types, whether your code is allowed to call something at all. If the API breaks, you get a compile error.
ABI (Application Binary Interface) is a lower-level contract that the compiler enforces at build time but cannot verify across separately compiled binaries. It specifies:
- Calling conventions: which registers carry which arguments, how the stack is laid out, who cleans up after a call
- Name mangling: how function and variable names get encoded into the linker symbols the binary actually uses
- Object layout: where each field of a struct or class sits in memory, how alignment and padding are applied, where the vtable pointer lives
- Exception handling: how the runtime unwinds the stack and which tables describe the cleanup handlers
When two pieces of code that were compiled separately are linked together and run, they have to agree on all of this without ever seeing each other’s source. Nothing verifies that they do. If they disagree, the result ranges from wrong values to silent memory corruption to a crash at an arbitrary point later.
The Itanium C++ ABI and the Windows Exception
On Linux and macOS, GCC and Clang both follow the Itanium C++ ABI. Despite the name (it was designed for Intel’s Itanium architecture in the early 2000s), it became the de facto standard for x86-64 on Unix systems. Its adoption by both compilers is why you can freely link objects built by GCC against objects built by Clang on Linux: they speak the same ABI.
The Itanium ABI specifies calling conventions (first six integer or pointer arguments go in rdi, rsi, rdx, rcx, r8, r9; floating point in xmm0 through xmm7; return value in rax), the name mangling scheme, struct layout rules including alignment and vtable placement, and the exception handling tables (.gcc_except_table and .eh_frame) that the runtime reads during stack unwinding.
Windows with MSVC uses a different, incompatible ABI. The calling convention passes the first four arguments in rcx, rdx, r8, r9 and requires the caller to allocate a shadow space on the stack. Name mangling uses a completely different encoding. Vtable layout differs. Exception handling uses SEH (Structured Exception Handling) rather than the DWARF-based tables the Itanium ABI defines. A .obj file built by MSVC cannot be linked with a .o file built by GCC. Even on the same x86-64 hardware, the object files are incompatible at the binary level. This is a common gotcha for cross-platform C++ projects that assume “same architecture, same binary format.”
The Silent Break: A Struct Field Added Across a Library Boundary
The most dangerous ABI break produces no error at all. Consider a struct defined in a shared library’s header:
// v1 header: shipped with the library
struct Config {
int mode;
int flags;
};
Your application is compiled against this header. The field flags sits at byte offset 4 from the start of Config. Every memory access to flags in your compiled binary uses that offset.
Now the library is updated and its header changes:
// v2 header: internal change, maintainer "just added a field"
struct Config {
int mode;
int version; // new field inserted before flags
int flags;
};
If you update the shared library binary but do not recompile your application, your binary still has hardcoded offset 4 for flags. The new library puts flags at offset 8. Every time your code reads config.flags, it reads the version field instead. There is no compile error. There is no linker error. The application links successfully and runs. It just reads the wrong value, silently, every time.
This is the dangerous kind of ABI break. Inserting a field, changing alignment, reordering fields, or modifying the size of any struct that crosses a shared library boundary is an ABI break that produces no diagnostic unless you also force a recompile of everything that uses it. The only protection is treating struct layout as a contract that cannot change once a library is shipped.
Name Mangling: The ABI Contract in Linker Symbols
The linker does not work with human-readable function names. It works with mangled symbols, strings encoding the full C++ qualified name including namespace, class, parameter types, and return type. Name mangling is how C++ function overloading, namespaces, and templates survive the translation to linker symbols.
void foo(int) → _Z3fooi
void foo(int, double) → _Z3fooid
namespace ns { void foo(int); } → _ZN2ns3fooEi
template<typename T> void bar(T) with T=int → _Z3barIiEvT_
The encoding is mechanical and deterministic. The _Z prefix marks a mangled C++ name. Letters encode types (i = int, d = double, v = void). Namespaces and classes introduce N...E wrappers. Template parameters add another layer.
extern "C" disables mangling for the declared function, keeping the symbol as the bare function name with no encoding. This is necessary for C interop and is how C++ libraries expose a stable C-callable interface. The cost is that extern "C" functions cannot be overloaded (since overloading depends on the mangling encoding the parameter types).
Default arguments are not encoded in the mangled name. Two overloads that differ only in whether a parameter has a default value would produce the same symbol, which is why C++ does not allow that. The default value is inserted by the compiler at the call site, not stored in the function signature. This has an ABI consequence: adding a default argument to an existing parameter does not change the mangled name and therefore does not break binary compatibility. Removing it, or changing the default, also does not change the name but does change behavior for callers compiled before the change.
The c++filt tool on Linux demangles a symbol:
c++filt _ZN2ns3fooEi # prints: ns::foo(int)
c++filt _Z3barIiEvT_ # prints: void bar<int>(int)
The GCC 5 Story: Engineering a Loud Break
GCC 5 introduced breaking ABI changes to std::string and std::list. Both were technically correct: C++11 imposed new requirements that the old implementations could not meet.
std::string in GCC 4.x used a copy-on-write (COW) strategy: multiple strings could share the same underlying character buffer, with reference counting to manage lifetime. COW is incompatible with C++11’s stricter iterator invalidation rules and threading requirements. Under C++11, modifying a string through one copy must not invalidate iterators from another copy, and COW implementations cannot guarantee this in a multi-threaded environment without per-access locking. The COW string had to be replaced with an SSO-based implementation (covered in the SSO post) where each string owns its buffer independently.
std::list in GCC 4.x did not track its own size. The .size() call walked the entire list to count elements, making it O(n). C++11 mandated O(1) .size() for all standard containers, requiring std::list to store an element count inline. Adding that count field to the list node or header changes the layout of std::list<T> in memory: an ABI break.
Both changes meant the internal memory layout of std::string and std::list differed between GCC 4.x and GCC 5. Linking old and new code that passed these types across the boundary would corrupt the layout silently, since the ABI break looked like a compatible symbol from the mangling level.
The maintainers chose to engineer this break to fail loud rather than silent. The new implementations were placed inside an inline namespace called __cxx11:
namespace std {
inline namespace __cxx11 {
template<...> class basic_string { ... }; // new implementation
}
}
An inline namespace makes its contents accessible as if they were directly in the enclosing namespace. std::string still works as a name. But the mangled symbol encodes __cxx11 as part of the qualified name:
// GCC 4.x (old ABI): std::string → _ZNSsE (simplified)
// GCC 5+ (new ABI): std::string → _ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE
Code built with the old ABI references the old mangled name. Code built with the new ABI references the new mangled name. When you try to link them together, the linker reports an undefined reference naming exactly which symbol failed. No silent corruption. An ABI break, deliberately engineered to produce a linker error instead of runtime garbage.
The macro _GLIBCXX_USE_CXX11_ABI controls which ABI a translation unit uses. Setting it to 0 builds against the old GCC 4.x ABI for std::string and std::list. Setting it to 1 (the default since GCC 5) uses the new ABI. This macro is still relevant today for anyone linking against prebuilt libraries built before GCC 5, or against any library that was built with _GLIBCXX_USE_CXX11_ABI=0. Mixing without matching this flag produces the linker errors the inline namespace was designed to produce.
Vtable Layout as Another ABI Surface
Virtual dispatch adds another ABI contract: the vtable. Every polymorphic class has a vtable, a contiguous array of function pointers laid out in declaration order. A call through a virtual function pointer is a load from a specific offset into the vtable followed by an indirect call. That offset is baked into the compiled call site.
If a virtual function is added to a class in the middle of the declaration order, every function after it shifts to a higher offset. Code compiled against the old vtable layout calls the function at the old offset and gets whatever function is now at that position: the wrong function, with no error.
// v1:
class Widget {
virtual void draw(); // vtable[0]
virtual void resize(); // vtable[1]
};
// v2: new function inserted (ABI break)
class Widget {
virtual void draw(); // vtable[0]
virtual void repaint(); // vtable[1] — shifted resize to vtable[2]
virtual void resize(); // vtable[2] — old code calls vtable[1] = repaint
};
Old code calling widget->resize() compiles a load from vtable[1]. After the update, vtable[1] is repaint. The wrong function runs. Adding virtual functions only at the end of the class avoids this. Never inserting them in the middle is a fundamental rule for maintaining vtable ABI stability.
MSVC and GCC also differ in vtable layout for multiple inheritance and virtual inheritance, making cross-compiler usage of polymorphic classes across a DLL or shared library boundary effectively impossible without an explicit ABI layer.
Practical Guidance
ABI compatibility matters at boundaries. If your entire project rebuilds from source with one compiler toolchain, ABI never surfaces. The problem appears at these specific points:
Shared libraries used across compiler versions: a .so or .dll built with GCC 4 and linked against code built with GCC 5 hits the _GLIBCXX_USE_CXX11_ABI boundary. Fix: either rebuild everything with the same compiler, match the _GLIBCXX_USE_CXX11_ABI flag, or avoid passing std::string and std::list across the boundary.
Versioned .so naming: Linux shared libraries use a naming convention (libfoo.so.1.2.3) where the major version signals an ABI break. A bump in the major version number means old code must relink. ldconfig maintains the symlinks. The convention exists precisely to make ABI breaks trackable at the filesystem level.
Plugin architectures: a plugin loaded at runtime was compiled separately and may use a different compiler. The standard mitigation is a C-style API layer, a set of extern "C" functions at the boundary that avoid C++ types entirely. The plugin and the host agree on a pure-C interface where ABI is simple (no mangling, no vtables, no std::string layout) and the C++ internals of each side stay completely separate.
Prebuilt third-party binaries: a binary-only library comes with headers but no source. If the library was built with a different compiler or compiler version, and it exposes C++ types in its API, ABI mismatch is likely. The safest path is a C wrapper if the vendor provides one, or demand source.
Quick Reference
Coming from other languages
Most compiled languages either have a single platform-defined ABI (Go, Rust with its stable C FFI, Swift), or avoid cross-binary type sharing by design (Java, Python). C++ has no standardized ABI at the language level: the Itanium ABI is a platform convention adopted by GCC and Clang, not something the C++ standard mandates. Windows MSVC defines its own. This means C++ has no portable binary interface across compilers, and cross-compiler C++ type sharing is generally unsafe without explicit ABI design.
The 90% mental model
API is what the compiler checks: function signatures, types, call validity. ABI is what the linker and runtime rely on: argument registers, struct field offsets, vtable layout, mangled symbol names. A struct field added in the middle of a shared library header is an API-compatible change that silently corrupts memory for any code that was compiled against the old layout. Name mangling encodes the full C++ signature into the linker symbol, which is why an ABI break sometimes surfaces as a linker error and sometimes does not. The GCC 5 std::string change used inline namespaces to make an unavoidable ABI break produce a linker error instead of runtime corruption. _GLIBCXX_USE_CXX11_ABI=0 is the escape hatch for linking against old prebuilt libraries. Expose only extern "C" functions at any boundary that crosses a compiler or version mismatch.