Case 167: Base Class Became Virtual (: public Device โ : public virtual Device)¶
| Field | Value |
|---|---|
| Verdict | ๐ด BREAKING |
| Category | Breaking |
| Platforms | Linux, macOS, Windows |
| Flags | ABI break |
Detected ChangeKinds |
base_class_virtual_changed |
| Source files | examples/case167_base_became_virtual/ |
Category: Class Layout / Virtual Inheritance | Verdict: ๐ด BREAKING
Verdict and consumer impact¶
v2 turns Stream's base into a virtual base โ the classic preparation
for a diamond hierarchy ("a future DuplexStream : InStream, OutStream
must share one Device"). One keyword, and the entire object model is
rewritten: the Device subobject moves from offset 0 to the end of the
object, bytes_moved shifts from offset 16 to offset 8, sizeof(Stream)
grows from 24 to 32 bytes, and Stream gains a VTT plus vbase-offset
machinery to locate its own base at runtime. Source compatibility is
perfect โ no call site changes, everything recompiles cleanly โ but every
consumer binary compiled against v1 reads fields at dead offsets.
Recompilation is mandatory.
Old/new diff¶
| v1.h | v2.h |
|---|---|
class Stream : public Device { ... long bytes_moved; }; |
class Stream : public virtual Device { ... long bytes_moved; }; |
Device @ offset 0, bytes_moved @ offset 16, sizeof(Stream)=24 |
Device @ offset 16 (end), bytes_moved @ offset 8, sizeof(Stream)=32 |
abicheck command¶
g++ -shared -fPIC -g v1.cpp -o libv1.so
g++ -shared -fPIC -g v2.cpp -o libv2.so
abicheck compare libv1.so libv2.so
Expected abicheck finding¶
Verdict: BREAKING (exit 4)
- type_size_changed: Size changed: Stream (192 -> 256 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: Stream::bytes_moved (128 -> 64 bits)
> Old code reads/writes fields at stale offsets; silent data corruption.
- type_base_changed: Base classes changed: Stream (['Device'] -> [])
> Base class layout change shifts derived member offsets and vtable
pointers; this-pointer arithmetic breaks.
- vtable_slot_count_changed: Vtable for 'Stream' changed size: 40 -> 104 bytes
- rtti_inheritance_changed: RTTI typeinfo for 'Stream' changed size: 24 -> 40 bytes
(single base (__si_class_type_info) -> 2 bases (__vmi_class_type_info))
- vtable_thunk_set_changed: thunk added for Stream::~Stream() and Stream::kind() const
- base_class_virtual_changed: Base class virtual inheritance changed:
Stream โ became virtual: ['Device']
Deployment Risk Changes:
- imported_symbol_added: New imported symbol: vtable for
__cxxabiv1::__vmi_class_type_info@CXXABI_1.3
base_class_virtual_changed is the fact that names the actual source
change; the layout/vtable/RTTI findings are the fallout โ visible even
without headers.
Minimum evidence¶
min_evidence: L1 โ DWARF's DW_AT_virtuality on the inheritance DIE
records whether Device is a virtual base for both versions, and DWARF's
struct-layout info corroborates the size/offset fallout. No public headers
are required; the binary-only _ZTV/_ZTI layer independently corroborates
the same break even on a stripped binary.
Why abicheck catches it¶
DWARF records each inheritance relationship's virtuality attribute directly;
abicheck compares the base-class lists (virtual vs. non-virtual) for Stream
between the two snapshots and reports the move as
base_class_virtual_changed. The size, offset, vtable-slot-count, and RTTI
findings are independently derived from the same DWARF/ELF evidence and
corroborate the single root cause from different angles.
Runtime failure demonstration¶
Severity: CRITICAL
Scenario: compile app against v1 (direct field access at offset 16),
link to v2 .so without recompiling.
# Build old library + app
g++ -shared -fPIC -g v1.cpp -o liblib.so
g++ -g app.cpp -L. -llib -Wl,-rpath,. -o app
./app
# โ bytes via library = 4096 (expected 4096)
# โ bytes direct = 4096 (expected 4096)
# Swap in new library (no recompile)
g++ -shared -fPIC -g v2.cpp -o liblib.so
./app
# โ bytes via library = 4096 (expected 4096)
# โ bytes direct = 140027503222112 (expected 4096)
# โ CORRUPTION: direct access used the v1 (non-virtual base) offset
# and read the virtual-base machinery!
Why CRITICAL: the app reads s->bytes_moved at v1's offset 16 โ where
v2 now stores the virtual Device base's vtable pointer โ and interprets
an address as a byte count. Writes through the same offset would corrupt
the vtable pointer and crash on the next virtual call.
Safe redesign¶
- Design the hierarchy up front: if a class may ever sit in a diamond, make the base virtual in the first released version.
- Prefer composition or interfaces (pure-virtual, data-free bases) at ABI boundaries โ data-free virtual bases still change layout, but hierarchies that never need a shared-state base avoid the diamond entirely.
- SONAME bump when the refactor is unavoidable โ this cannot be shipped compatibly.
Real-world example: the iostreams hierarchy is the canonical shape:
std::basic_ios is a virtual base of basic_istream/basic_ostream
precisely so that basic_iostream contains a single copy โ a decision that
had to be made before the ABI froze, because retrofitting it would have
rewritten the layout of every stream object. The KDE binary-compatibility
policy forbids "changing the class hierarchy in any way" for exactly this
reason.
References¶
- Itanium C++ ABI ยง2.4/ยง2.6 โ virtual base offsets, VTT and construction vtables
- KDE ABI Policy โ "you cannot change the class hierarchy"
Source files¶
CMakeLists.txtapp.cppv1.cppv1.hv2.cppv2.h
See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.