Skip to content

Case 174: Secondary Vtable Group Changed

Field Value
Verdict ๐Ÿ”ด BREAKING
Category Breaking
Platforms Linux
Flags ABI break
Detected ChangeKinds secondary_vtable_group_changed
Source files examples/case174_secondary_vtable_group_changed/

Category: C++ Layout | Verdict: ๐Ÿ”ด BREAKING

Verdict and consumer impact

Derived's own declaration โ€” struct Derived : Base1, Base2 {} โ€” is byte-for-byte identical in v1 and v2. Only Base2's own declaration changes: it gains a virtual method. Under the Itanium C++ ABI that makes Base2 polymorphic, so Derived now needs a new secondary vtable group to dispatch through a Base2*. Any binary compiled against v1 that embeds, copies, or derives from Derived (or takes its sizeof) is laid out incompatibly against v2 โ€” even though nothing in Derived's own source changed. Recompilation against v2 is mandatory.

Old/new diff

v1.h v2.h
struct Base1 { virtual int f1(); virtual ~Base1(); }; struct Base1 { virtual int f1(); virtual ~Base1(); }; (unchanged)
struct Base2 { int helper(); }; struct Base2 { virtual int helper(); virtual ~Base2(); };
struct Derived : Base1, Base2 {}; struct Derived : Base1, Base2 {}; (byte-identical)

abicheck command

g++ -shared -fPIC -g v1.cpp -o libfoo_v1.so
g++ -shared -fPIC -g v2.cpp -o libfoo_v2.so
abicheck compare libfoo_v1.so libfoo_v2.so

Expected abicheck finding

Verdict: BREAKING (exit 4)

- secondary_vtable_group_changed: Secondary vtable groups changed for
  'Derived': (none) -> Base2
  > A base's polymorphism changed, restructuring the derived vtable even
    though Derived's own base declaration list never moved.
- vptr_introduced: 'Base2' gained a vtable pointer (became polymorphic)
- type_size_changed: Base2 (8 -> 64 bits); Derived (64 -> 128 bits)
- vtable_slot_count_changed: Vtable for 'Derived' changed size: 40 -> 80
  bytes (~3 -> ~8 virtual slots)
- base_class_offset_changed: Base class 'Base2' moved within 'Derived'
  (0 -> 64 bits)
- vtable_thunk_set_changed: Derived::~Derived() thunk added
- type_vtable_changed: vtable populated for Base2

8 breaking findings total (1 flagged as RTTI/internal churn; 7 genuine
public-surface breaks).

Minimum evidence

min_evidence: L1 โ€” reconstructing Derived's secondary vtable groups requires DWARF's base/vtable metadata (bases, virtual_bases, vtable on RecordType) on both sides, since the fact that changed lives on Base2's own declaration, not Derived's. Unlike cases 172/173, this is not visible on a stripped binary โ€” Base2's polymorphism has to be known from debug info, not inferred from a symbol's size alone.

Why abicheck catches it

A per-type diff of Derived alone finds nothing โ€” same bases, same members. abicheck's secondary_vtable_group_changed detector instead recomputes each class's ordered list of secondary vtable groups from the current snapshot's DWARF inheritance metadata on both sides and reports when that list changes for a class whose own base declaration list did not move โ€” a cross-type effect that only becomes visible by also inspecting Base2's declaration.

Runtime failure demonstration

Severity: BREAKING / silent layout mismatch

Scenario: compile app against v1's Derived (Base2 non-polymorphic), swap in v2's .so without recompile.

# Build old library + app
g++ -shared -fPIC -g v1.cpp -o libfoo.so
g++ -g app.cpp -I. -L. -lfoo -Wl,-rpath,. -o app
./app
# โ†’ app compiled with sizeof(Derived) = 8
# โ†’ loaded library reports sizeof(Derived) = 8
# โ†’ layouts agree

# Swap in new library (no recompile)
g++ -shared -fPIC -g v2.cpp -o libfoo.so
./app
# โ†’ app compiled with sizeof(Derived) = 8
# โ†’ loaded library reports sizeof(Derived) = 16
# โ†’ MISMATCH: Base2 gained a vtable pointer underneath an unchanged
#   Derived declaration -- Derived's own diff is a no-op, yet its layout
#   changed.

Why BREAKING: the app's copy of Derived was sized and laid out assuming Base2 contributes no vtable pointer; the loaded v2 library's Base2 subobject now starts with one, shifting every subsequent byte and silently desynchronizing the two sides' idea of the object's layout.

Safe redesign

Adding a virtual method to a base class is a break for every class that derives from it, not just a local change to that base โ€” audit derived classes, not just the base being edited. If a base might need virtual dispatch later, declare at least one virtual method (even a virtual destructor) from the start, so its polymorphism status is part of the class's original, versioned contract.

Real-world example: this pattern shows up whenever a previously-plain mixin or interface base is retrofitted with virtual methods โ€” e.g. adding a virtual on_event() hook to a formerly non-polymorphic utility base โ€” every class that multiply-inherits from it needs a rebuild, even though the derived class's own source never changed.

Cross-tool comparison

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

abidiff/abidw are not installed in this environment, so no output is reproduced here.

References


Source files

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

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