Skip to content

Case 37: Base Class Changes

Field Value
Verdict ๐Ÿ”ด BREAKING
Category Breaking
Platforms Linux, macOS, Windows
Flags ABI break, API break
Detected ChangeKinds type_base_changed
Source files examples/case37_base_class/

Category: Type Layout | Verdict: ๐Ÿ”ด BREAKING

Verdict and consumer impact

Three independent base-class changes land in one release: ReorderDemo swaps its base order, VirtualDemo's base becomes virtually inherited, and AddBaseDemo gains a second base class. Each one changes the derived object's layout โ€” which sub-object sits at offset 0, where this-pointer adjustments land, and the object's total size. Any binary built against v1 that casts through a base pointer, calls a virtual method, or allocates one of these types is working with stale offsets once the new library loads; recompilation is mandatory.

Old/new diff

v1.hpp v2.hpp
class ReorderDemo : public Logger, public Serializer class ReorderDemo : public Serializer, public Logger (order swapped)
class VirtualDemo : public Logger class VirtualDemo : public virtual Logger (became virtual)
class AddBaseDemo : public Logger class AddBaseDemo : public Logger, public Serializer (base added)

abicheck command

g++ -shared -fPIC -g -femit-class-debug-always v1.cpp -o libfoo_v1.so
g++ -shared -fPIC -g -femit-class-debug-always v2.cpp -o libfoo_v2.so
abicheck compare libfoo_v1.so libfoo_v2.so

-femit-class-debug-always matters here: none of ReorderDemo, VirtualDemo, or AddBaseDemo declares its own virtual function (their virtuals are all inherited), so GCC has no "key function" to anchor a complete class DIE to and โ€” by default โ€” emits only a forward-declaration stub for them in this translation unit, dropping the DW_TAG_inheritance entries abicheck needs. The flag forces GCC to always emit the complete class layout in DWARF.

Expected abicheck finding

Verdict: BREAKING (exit 4)

- type_size_changed: Size changed: VirtualDemo (128 -> 192 bits)
  > Old code allocates or copies the type with the old size;
    heap/stack corruption, out-of-bounds access.
- type_base_changed: Base classes changed: VirtualDemo (['Logger'] -> [])
  > Base class layout change shifts derived member offsets and vtable
    pointers; this-pointer arithmetic breaks.
- type_size_changed: Size changed: AddBaseDemo (128 -> 256 bits)
- type_base_changed: Base classes changed: AddBaseDemo
  (['Logger'] -> ['Logger', 'Serializer'])
- base_class_offset_changed: Base class 'Serializer' moved within
  'ReorderDemo' (128 -> 0 bits)
  > A base-class subobject moved to a different offset within the derived
    object. The this-pointer adjustment for that base and every field
    after it shifts; old binaries read the wrong addresses.
- base_class_offset_changed: Base class 'Logger' moved within 'ReorderDemo'
  (0 -> 128 bits)
- base_class_position_changed: Base class order reordered: ReorderDemo โ€”
  this-pointer adjustments changed (['Logger', 'Serializer'] ->
  ['Serializer', 'Logger'])
- base_class_virtual_changed: Base class virtual inheritance changed:
  VirtualDemo โ€” became virtual: ['Logger'] ([] -> ['Logger'])

Minimum evidence

min_evidence: L1 โ€” DWARF's DW_TAG_inheritance entries (base type, offset, and virtual-ness) plus each class's DW_AT_byte_size are enough to detect all three changes; no public headers required. The caveat above (-femit-class-debug-always) is about getting complete DWARF out of GCC for classes with no class-local key function, not about needing a higher evidence tier โ€” headers are still not required.

Why abicheck catches it

DWARF encodes each base class as a DW_TAG_inheritance child of the derived DW_TAG_class_type, carrying the base's type, its DW_AT_data_member_location offset, and a DW_AT_virtuality flag for virtual bases; abicheck compares each derived type's base list, per-base offsets, and virtual-ness directly from debug info in both versions.

Runtime failure demonstration

Severity: CRITICAL

Scenario: app builds ReorderDemo/VirtualDemo/AddBaseDemo against v1's layout, calls their methods through base pointers and direct calls, then the library is swapped for v2 without recompiling the app.

# Build old library + app
g++ -shared -fPIC -g -femit-class-debug-always v1.cpp -o libfoo.so
g++ -g app.cpp -I. -L. -lfoo -Wl,-rpath,. -o app
./app
# โ†’ ReorderDemo: log_level=11 format=21
# โ†’ VirtualDemo: log_level=77
# โ†’ AddBaseDemo: log_level=33
# โ†’ exit 0

# Swap in new library (no recompile)
g++ -shared -fPIC -g -femit-class-debug-always v2.cpp -o libfoo.so
./app
# โ†’ Segmentation fault
# โ†’ exit 139

Why CRITICAL: the app's this-pointer adjustments, vtable slot assumptions, and object-size allocations are all baked in for v1's layout. Against v2's reordered/virtual/widened bases those assumptions are wrong from the first virtual call, crashing the process outright rather than producing recoverable bad output.

Safe redesign

Never reorder, virtualize, or add a base class to a public class after release โ€” each is a layout change with no source-compatible mitigation short of a SONAME bump. If a type genuinely needs a new capability, add it as a non-base member or a separate interface obtained through a factory function, and keep object layout behind an opaque pointer (PIMPL) for anything callers allocate directly.

Real-world example: Qt's public classes went through this exact problem historically, which is why Qt adopted the d-pointer (PIMPL) idiom project-wide โ€” base-class and member layout of QObject-derived classes can never change without breaking every compiled plugin.

Cross-tool comparison

abidiff/abi-compliance-checker also detect base-class layout changes (reordering, virtual-inheritance conversion, added base) via their own DWARF-based inheritance-graph comparison:

abidw --out-file v1.xml libfoo_v1.so
abidw --out-file v2.xml libfoo_v2.so
abidiff v1.xml v2.xml

References


Source files

  • CMakeLists.txt
  • app.cpp
  • v1.cpp
  • v1.hpp
  • v2.cpp
  • v2.hpp

See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.