Skip to content

Case 43: Base Class Member Added

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

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

Verdict and consumer impact

Base gains a new data member (extra_field). Because Derived inherits from Base, every field declared in Derived shifts to a higher offset โ€” Derived::value moves from byte offset 12 to byte offset 16, and sizeof(Derived) grows from 16 to 24. Any binary compiled against v1 that allocates or accesses a Derived reads/writes the wrong memory once linked against v2. Recompilation is mandatory.

Old/new diff

v1.hpp v2.hpp
class Base { public: int base_id; virtual void describe(); }; class Base { public: int base_id; int extra_field; virtual void describe(); };
class Derived : public Base { public: int value; void process(); }; (unchanged โ€” but value's offset shifts)

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 for the same reason as case37: Derived declares no virtual method of its own (its virtuals are all inherited from Base), so GCC has no class-local key function to anchor a complete class DIE to and emits only a declaration stub for Derived by default โ€” dropping the member-offset information abicheck needs to see the shift in Derived::value. The flag forces GCC to always emit the complete class layout in DWARF. Without it, only Base's own field addition is visible (Base has an out-of-line virtual method, describe(), so it already gets full DWARF either way) โ€” Derived's size/offset shift is missed.

Expected abicheck finding

Verdict: BREAKING (exit 4)

- type_field_added: Field added: Base::extra_field
  > New field shifts subsequent fields; old code reads wrong offsets
    for all fields after insertion point.
- type_size_changed: Size changed: Derived (128 -> 192 bits)
  > Old code allocates or copies the type with the old size; heap/stack
    corruption, out-of-bounds access.
- type_field_offset_changed: Field offset changed: Derived::value (96 -> 128 bits)
  > Old code reads/writes fields at stale offsets; silent data corruption.

Minimum evidence

min_evidence: L1 โ€” DWARF's per-member offsets (DW_AT_data_member_location) for both Base and Derived, plus each type's DW_AT_byte_size, are enough to detect the field addition and the resulting size/offset shift in the derived class; no public headers required. The -femit-class-debug-always caveat above is about getting complete DWARF out of GCC for a no-key-function derived class, not about needing a higher evidence tier.

Why abicheck catches it

DWARF records each struct/class member's byte offset directly (DW_AT_data_member_location); abicheck compares Base's member list (catching the added extra_field) and independently compares Derived's total size and each of its own members' offsets, catching the downstream shift even though Derived's own source didn't change.

Runtime failure demonstration

Severity: CRITICAL

Scenario: app allocates a Derived with v1's layout (value at offset 12), sets base_id and calls process() (which writes value from base_id), 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
# โ†’ value = 42
# โ†’ expected = 42
# โ†’ exit 0

# Swap in new library (no recompile)
g++ -shared -fPIC -g -femit-class-debug-always v2.cpp -o libfoo.so
./app
# โ†’ value = 0
# โ†’ expected = 42
# โ†’ CORRUPTION: base-class layout changed, Derived::value offset mismatch
# โ†’ exit 1

Why CRITICAL: v2's Derived::process() writes value at offset 16 (its correct offset under v2's layout), but the app's Derived d object was allocated with v1's 16-byte layout โ€” the write lands past what the app treats as value's slot, so the app's read of d.value sees stale/zeroed memory instead of the computed result. No crash, just silently wrong data.

Safe redesign

Never add a data member to a base class that has public derived classes โ€” every derived class's layout shifts. Put new base-class state behind a private Impl* (PIMPL) pointer instead, add the field to the specific derived class that needs it (if only one does), or bump the SONAME.

Real-world example: this is the same failure mode that motivated d-pointer/PIMPL adoption in Qt and KDE โ€” adding any base-class member, even a private one, breaks every derived class's ABI unless the base is already opaque.

Cross-tool comparison

abidiff also catches this (Added_Base_Class_Data_Member):

abidw --out-file v1.xml libv1.so
abidw --out-file v2.xml libv2.so
abidiff v1.xml v2.xml
echo "exit: $?"   # โ†’ 4 (ABI change: base class data member added)

Note on abidiff 2.4.0: exits 4 (not 12) because this is a layout change, not a symbol removal โ€” the change is still semantically breaking.


Source files

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

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