ABI Stability: What Breaks Binary Compatibility
What the C++ ABI is, what changes break it, how name mangling and vtables work, and how to design shared libraries that can evolve without breaking callers.
The ABI (Application Binary Interface) is the contract between compiled binaries. When you ship a shared library (libfoo.so, foo.dll), changing its ABI means all callers must recompile β even if the API (header files) looks unchanged.
Understanding ABI is essential for library authors, plugin systems, and any project that ships .so/.dll files without demanding recompiles.
What the ABI covers
The ABI specifies:
- Name mangling β how C++ symbol names are encoded for the linker
- Calling convention β how arguments are passed (registers, stack) and who cleans the stack
- Object layout β where each member is in memory (
offsetof) - Vtable layout β order and location of virtual function pointers
- Exception handling β how stack unwinding finds destructors
- Data type sizes β
int,long, pointer widths
All of these are platform and compiler specific. GCC/Clang on Linux use the Itanium C++ ABI. MSVC uses its own ABI. They are not compatible with each other.
Name mangling: why extern "C" matters
The linker sees symbols, not C++ types. C++ compiles void Widget::draw(int) to a mangled name that encodes the class, function name, and argument types:
# Inspect symbols in a shared library
nm -D libwidget.so | grep draw
# Typical output:
_ZN6Widget4drawEi # Itanium mangling: Widget::draw(int)
_ZN6Widget4drawEf # Widget::draw(float) β distinct symbol# Demangle:
c++filt _ZN6Widget4drawEi
# outputs: Widget::draw(int)What this means: changing a function's signature changes its mangled symbol. Old callers linked to _ZN6Widget4drawEi won't find _ZN6Widget4drawEv (the void version). They get a runtime linker error β or worse, call the wrong function if a symbol with the same name but wrong layout happens to exist.
extern "C" disables mangling:
// C++ name: _ZN6Widget4drawEi β ABI-fragile
void Widget::draw(int x);
// C name: draw β stable, same across all C++ compilers
extern "C" void widget_draw(Widget* w, int x);C-linkage functions can cross compiler boundaries and remain stable. This is why all stable plugin APIs (OpenGL, POSIX, etc.) use C linkage.
What breaks ABI
Adding or removing data members
// Version 1 β ABI stable since v1
class Config {
int timeout_; // offset 0
bool debug_; // offset 4
}; // sizeof(Config) == 8
// Version 2 β BREAKS ABI: inserting a member changes all offsets
class Config {
int timeout_; // offset 0
int retries_; // NEW β debug_ now at offset 8, not 4!
bool debug_;
}; // sizeof(Config) == 12
// Code compiled against v1 writes debug_ at offset 4.
// The v2 library reads debug_ from offset 8.
// Result: silent data corruption.Changing virtual function order
// Version 1 vtable layout:
// [0] = &Widget::draw
// [1] = &Widget::resize
// [2] = &Widget::~Widget
// Version 2 β BREAKS ABI: inserting a virtual
class Widget {
virtual void setup(); // NEW: shifts all subsequent entries
virtual void draw(); // now at vtable[1], was [0]
virtual void resize(); // now at vtable[2], was [1]
virtual ~Widget();
};
// Old caller does vtable[0]() expecting draw().
// Gets setup() instead.Making a non-virtual function virtual (or vice versa)
Adding virtual to an existing function changes the vtable layout and potentially the calling convention. It is always an ABI break.
Changing function signatures
// Version 1:
void Config::set_timeout(int ms); // symbol: _ZN6Config11set_timeoutEi
// Version 2: added parameter β BREAKS ABI (new symbol):
void Config::set_timeout(int ms, bool reset = false); // _ZN6Config11set_timeoutEib
// Old callers still link to the old symbol, which no longer exists.Making a function inline
Moving a function body into the header makes it inline. The symbol is now emitted in each caller's translation unit and may be called without going through the shared library. Old callers compiled without the inline body call the old out-of-line function β which may not exist in a later version.
What does NOT break ABI
You can safely:
- Add new non-virtual member functions
- Add new static data members
- Add new non-virtual, non-overloading free functions
- Add new derived classes (that callers don't inherit from)
- Add new enumerators to an existing enum (with caution β see below)
- Reimplement an existing virtual function (same signature)
- Make a function
noexcept(adds information; callers don't need to know)
PIMPL: the ABI firewall idiom
The PIMPL (Pointer to Implementation) idiom lets you add private data members without breaking ABI:
// widget.hpp β stable across versions
class Widget {
public:
explicit Widget(int id);
~Widget(); // must be out-of-line (Impl is incomplete here)
Widget(Widget&&) noexcept;
Widget& operator=(Widget&&) noexcept;
void draw();
int id() const;
// sizeof(Widget) == sizeof(void*) β fixed forever
Widget(const Widget&) = delete;
Widget& operator=(const Widget&) = delete;
private:
struct Impl; // forward-declared
std::unique_ptr<Impl> impl_; // the only non-static data member
};
// widget.cpp β can grow freely without breaking ABI
struct Widget::Impl {
int id;
// Add fields here in v2, v3, v4... ABI never changes!
std::string cache;
std::vector<int> data;
};
Widget::Widget(int id) : impl_(std::make_unique<Impl>(id)) {}
Widget::~Widget() = default;
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;
void Widget::draw() { /* use impl_-> */ }
int Widget::id() const { return impl_->id; }sizeof(Widget) is always sizeof(unique_ptr<Impl>) = sizeof(void*) = 8 bytes. Future versions can add fields to Impl freely β callers never see Impl's layout.
Trade-off: one extra heap allocation per Widget; one extra pointer indirection per method call.
Symbol visibility: hiding implementation symbols
On Linux/macOS, all symbols are exported by default unless you opt out. Hidden symbols:
- Can't be called from outside the library
- Enable the linker to inline them
- Reduce the
.sosymbol table (fasterdlopen, less memory)
// Mark default visibility:
#if defined(__GNUC__) || defined(__clang__)
#define API_EXPORT __attribute__((visibility("default")))
#define API_LOCAL __attribute__((visibility("hidden")))
#elif defined(_MSC_VER)
#define API_EXPORT __declspec(dllexport)
#define API_LOCAL
#endif
// Only public API is exported:
class API_EXPORT Config {
public:
void set_timeout(int ms);
private:
int timeout_ = 30; // internal fields don't need visibility markup
};
// Internal helper β not exported:
API_LOCAL void internal_validate(int x);CMake:
set_target_properties(mylib PROPERTIES
CXX_VISIBILITY_PRESET hidden # default: hidden
VISIBILITY_INLINES_HIDDEN YES
)
# Then manually mark public API with API_EXPORTCheck what's exported:
nm -D --defined-only libmylib.so | grep ' T ' | head -20
# 'T' = exported text (code) symbol
# 't' = local (hidden) symbolDetecting ABI breaks: abidiff
abidiff (from libabigail) compares two shared libraries and reports ABI changes:
# Build two versions of your library
cmake -B build_v1 && cmake --build build_v1
# (make ABI change)
cmake -B build_v2 && cmake --build build_v2
# Compare:
abidiff build_v1/libmylib.so build_v2/libmylib.so
# Example output:
# Functions changes summary:
# 1 Removed function
# 1 Changed function
# Type changes summary:
# 1 Changed type: struct Config
# Added data member 'int retries_' (ABI break)Also useful: abi-compliance-checker (produces HTML reports) and abi-dumper.
ABI across compilers and OSes
| Platform | ABI standard | Compatible compilers |
|---|---|---|
| Linux/macOS x86-64 | Itanium C++ ABI | GCC 3.4+, Clang (all) |
| Windows x86-64 | MSVC ABI | MSVC; MinGW uses its own |
| Windows ARM64 | MSVC ARM64 ABI | MSVC |
| Linux ARM64 | ARM64 Itanium variant | GCC, Clang |
Mixing GCC and MSVC binaries on Windows is not supported without a C boundary (extern "C"). GCC on Windows (MinGW) uses yet another ABI β don't mix MinGW .dll files with MSVC code.
ABI stability checklist for shared libraries
Safe to do between releases:
- Add new free functions or static methods
- Add new non-virtual methods to existing classes
- Add new classes, structs, enums
- Change function bodies (not inline)
- Reorder private members (only if class uses PIMPL)
Requires a major version bump (ABI break):
- Add/remove/reorder data members in any exported class
- Add/remove/reorder virtual functions
- Change any function signature
- Make a function inline that was out-of-line
- Change a class hierarchy (add/remove base classes)
- Change enum underlying type
Versioning strategy:
# Linux: embed the SO version
set_target_properties(mylib PROPERTIES
VERSION "2.1.0" # full version
SOVERSION "2" # ABI version (bump on break)
)
# Generates: libmylib.so.2.1.0 β libmylib.so.2 β libmylib.so (symlinks)Symbol versioning with linker scripts
Symbol versioning lets a shared library export multiple implementations of the same function under different version tags, allowing old binaries and new binaries to coexist against the same .so without a full ABI break. This is how glibc ships a single libc.so.6 that can satisfy binaries compiled against glibc 2.5 through glibc 2.38 simultaneously. Each exported symbol carries a version tag (e.g., @@GLIBC_2.17) baked into the ELF symbol table at link time, and the dynamic linker selects the correct implementation at load time.
Define versions in a linker version script:
# mylib.map β linker version script
MYLIB_1.0 {
global:
widget_create; # exported with MYLIB_1.0 tag
widget_destroy;
local:
*; # everything else: hidden
};
MYLIB_2.0 {
global:
widget_create; # new signature in v2.0
} MYLIB_1.0; # MYLIB_2.0 depends on (inherits) MYLIB_1.0In C source (or via __asm__ in C++):
// Provide the old v1 implementation under the MYLIB_1.0 tag:
extern "C" Widget* widget_create_v1(int id) {
return new Widget{id, /*retries=*/3};
}
__asm__(".symver widget_create_v1, widget_create@@MYLIB_1.0");
// Provide the new v2 implementation as the default:
extern "C" Widget* widget_create_v2(int id, int retries) {
return new Widget{id, retries};
}
__asm__(".symver widget_create_v2, widget_create@@MYLIB_2.0");Link the library with the version script:
g++ -shared -Wl,--version-script=mylib.map -o libmylib.so.2 src/*.oBinaries compiled against MYLIB_1.0 continue to call widget_create_v1; new binaries compiled against MYLIB_2.0 call widget_create_v2. Both load the same .so at runtime. Inspect version bindings with readelf -V libmylib.so.
[[nodiscard]] and other attributes: ABI-safe additions
Adding standard C++ attributes such as [[nodiscard]], [[deprecated]], and [[likely]] to existing declarations does not change the ABI. Attributes affect compiler diagnostics and code generation hints, but they do not alter name mangling, calling convention, symbol export, or object layout. You can freely annotate existing APIs without requiring a recompile of callers β the binary contract is unchanged.
// Version 1 β shipped without [[nodiscard]]
Widget* widget_create(int id);
// Version 2 β added [[nodiscard]]: still the same mangled symbol
// Callers compiled against v1 headers continue to work; new callers
// get a compiler warning if they discard the return value.
[[nodiscard]] Widget* widget_create(int id);Similarly safe:
[[deprecated("use widget_create_ex")]]β emits a warning, not an error; binary unchangednoexcepton a function not in anoexceptcontext (adding it is technically an ABI concern in some edge cases β see below)[[gnu::pure]]/[[gnu::const]]β optimisation hints only
The one noexcept caution: if the function appears in a noexcept expression in a caller's template (noexcept(f())), adding noexcept may change which branch of a if constexpr the caller takes. This is a source-level concern, not a binary-level ABI break in the usual sense, but it can silently change behaviour.
Verifying ABI compatibility with tooling
Never rely on code review alone to catch ABI breaks β use dedicated tools that inspect the compiled binary. Human reviewers miss subtle breaks (padding changes, implicit inline, changed enum size) that tools catch reliably. The correct workflow is to check ABI compatibility as part of CI, comparing the current build against the previous released version.
abidiff (libabigail) β byte-level diff of two .so files:
# Install: apt install libabigail-tools / brew install libabigail
abidiff --no-added-syms libmylib.so.1 libmylib.so.2
# Example output flagging a struct layout change:
# [C] 'struct Config' changed:
# size of type changed: from 64 to 96 bits
# 1 data member insertion:
# 'int Config::retries_', at offset 32 (in bits)abi-compliance-checker β generates an HTML report with per-symbol status:
# Generate ABI dumps first:
abi-dumper libmylib.so.1 -o ABI-1.dump -lver 1.0
abi-dumper libmylib.so.2 -o ABI-2.dump -lver 2.0
# Compare:
abi-compliance-checker -lib mylib -old ABI-1.dump -new ABI-2.dump
# Output: compat_reports/mylib/1.0_to_2.0/compat_report.html
# Shows: 98.3% compatible, 2 ABI-breaking changesCI integration pattern:
# GitHub Actions excerpt
- name: Check ABI compatibility
run: |
git checkout ${{ github.base_ref }} -- .
cmake --build build_old
git checkout ${{ github.sha }} -- .
cmake --build build_new
abidiff --no-added-syms build_old/libmylib.so build_new/libmylib.so
# Exit code 0 = compatible; non-zero = ABI break detectedEmbedding version metadata in binaries
Embedding version and build metadata directly into a shared library binary means any tool that can read ELF or PE headers can identify exactly what is installed, without consulting package metadata or file names. This is essential for debugging fleet deployments: strings libmylib.so | grep BUILD can instantly identify whether the correct version is deployed on a remote host.
ELF .note.gnu.build-id is inserted automatically by the linker:
# Enable build-id (usually the default on Linux):
g++ -Wl,--build-id=sha1 -shared -o libmylib.so src/*.o
# Inspect:
readelf -n libmylib.so | grep "Build ID"
# Build ID: 3a7f91e2c4b05d68... (SHA-1 of content)Embedding a custom version string readable by strings or dlsym:
// version.cpp β compiled into the library
extern "C" {
// Readable with: strings libmylib.so | grep MYLIB_VERSION
const char mylib_version_string[] =
"@(#)MYLIB_VERSION: 2.1.0 built " __DATE__ " " __TIME__;
// Callable at runtime:
const char* mylib_version() { return "2.1.0"; }
int mylib_version_major() { return 2; }
int mylib_version_minor() { return 1; }
}pkg-config .pc file β the standard mechanism for exposing library version to build systems:
# /usr/lib/pkgconfig/mylib.pc
Name: mylib
Description: My stable C++ library
Version: 2.1.0
Cflags: -I/usr/include/mylib
Libs: -L/usr/lib -lmylibpkg-config --modversion mylib # β 2.1.0
pkg-config --cflags --libs mylibThe libstdc++ dual ABI
The libstdc++ dual ABI is the real-world consequence of an ABI break that GCC had to ship in GCC 5.1 (2015), and understanding it explains why mixing old and new Linux binaries sometimes produces mysterious link or runtime failures. Prior to GCC 5, std::string used a copy-on-write (COW) implementation. C++11 made COW non-conforming, so libstdc++ switched to a small-string-optimized (SSO) layout β but that changed sizeof(std::string) and its internal layout, breaking ABI.
GCC's solution: ship both implementations simultaneously and let the user choose via a macro:
// The controlling macro:
// _GLIBCXX_USE_CXX11_ABI=1 β new ABI (SSO std::string) β default since GCC 5
// _GLIBCXX_USE_CXX11_ABI=0 β old ABI (COW std::string) β for linking against old .so files
// The switch affects mangling:
// new ABI: std::string β std::__cxx11::basic_string<char,...>
// old ABI: std::string β std::basic_string<char,...>In practice:
# Check which ABI a prebuilt .so uses:
nm -D libthirdparty.so | grep "__cxx11"
# If symbols contain __cxx11:: β new ABI (=1)
# If they don't β old ABI (=0)
# Force old ABI when linking against a legacy third-party library:
g++ -D_GLIBCXX_USE_CXX11_ABI=0 myapp.cpp -lthirdparty -o myappMixing both ABIs in one process is possible but dangerous: you can have one translation unit compiled with the new ABI and another with the old ABI, but you must never pass a std::string across the boundary. Wrap the boundary with extern "C" functions that pass const char* instead.
This dual-ABI situation is not unique to libstdc++. The Boost library went through a similar transition. Any time a widely-used library fixes an ABI bug in a shipped type, downstream users face this same compatibility matrix. The lesson: version your shared libraries with SOVERSION from day one, and avoid passing STL types across shared library boundaries unless you can guarantee a single ABI throughout the entire process.