Skip to content
C++
Tooling
since C++11
Expert

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:

bash
# 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
bash
# 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:

cpp
// 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

cpp
// 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

cpp
// 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

cpp
// 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:

cpp
// 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 .so symbol table (faster dlopen, less memory)
cpp
// 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:

cmake
set_target_properties(mylib PROPERTIES
    CXX_VISIBILITY_PRESET hidden          # default: hidden
    VISIBILITY_INLINES_HIDDEN YES
)
# Then manually mark public API with API_EXPORT

Check what's exported:

bash
nm -D --defined-only libmylib.so | grep ' T ' | head -20
# 'T' = exported text (code) symbol
# 't' = local (hidden) symbol

Detecting ABI breaks: abidiff

abidiff (from libabigail) compares two shared libraries and reports ABI changes:

bash
# 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

PlatformABI standardCompatible compilers
Linux/macOS x86-64Itanium C++ ABIGCC 3.4+, Clang (all)
Windows x86-64MSVC ABIMSVC; MinGW uses its own
Windows ARM64MSVC ARM64 ABIMSVC
Linux ARM64ARM64 Itanium variantGCC, 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:

bash
# 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.0

In C source (or via __asm__ in C++):

cpp
// 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:

bash
g++ -shared -Wl,--version-script=mylib.map -o libmylib.so.2 src/*.o

Binaries 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.

cpp
// 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 unchanged
  • noexcept on a function not in a noexcept context (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:

bash
# 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:

bash
# 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 changes

CI integration pattern:

yaml
# 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 detected

Embedding 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:

bash
# 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:

cpp
// 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:

ini
# /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 -lmylib
bash
pkg-config --modversion mylib   # β†’ 2.1.0
pkg-config --cflags --libs mylib

The 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:

cpp
// 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:

bash
# 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 myapp

Mixing 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.