Skip to content

Case 203: Class Gained a Vtable Pointer

Field Value
Verdict 🔴 BREAKING
Category Breaking
Classification Rule
Platforms Linux
Flags ABI break
Detected ChangeKinds vptr_introduced, type_size_changed, type_field_offset_changed
Source files catalog/cases/case203_class_gained_vtable_pointer/
Rule family class-gained-vtable-pointer
Subject Vtable and virtual dispatch changes

Category: Breaking | Verdict: ❌ BREAKING

Verdict and consumer impact

Handle::close() becomes virtual — the class's first virtual member. The compiler responds by giving Handle a vtable pointer at offset 0, so sizeof(Handle) grows from 4 to 16 bytes, its alignment rises from 4 to 8, and id_ moves from offset 0 to offset 8. Every consumer that embeds a Handle by value, derives from it, or allocates one on its own stack was compiled for the old, smaller, vptr-less layout.

The existing vtable cases in the catalog all start from an already-polymorphic class and change its slot set; this one crosses the non-polymorphic → polymorphic boundary, which is the mechanism vptr_introduced names. Its negative control is case204_class_gained_non_virtual_method, where the same class gains a method that is not virtual and therefore adds no vptr. The pair proves two things about these two fixtures: the first virtual function is flagged, and an ordinary method addition is not.

Old/new diff

v1.h v2.h
void close(); virtual void close();

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 --header old=v1.h --header new=v2.h

Expected abicheck finding

Verdict: BREAKING (exit 4)

vptr_introduced: 'Handle' gained a vtable pointer (became polymorphic).
type_size_changed: Size changed: Handle (32 → 128 bits)
type_field_offset_changed: Field offset changed: Handle::id_ (0 → 64 bits)

(type_alignment_changed, type_vtable_changed and func_virtual_added also fire, along with imported_symbol_added for the type_info vtable the new polymorphic class pulls in from libstdc++.)

Minimum evidence

min_evidence: L1 — a -g build records the record's size, its members' offsets and its vtable holder in DWARF, which is everything the finding rests on. L0 sees new _ZTV/_ZTI symbols appear but cannot attribute the member-offset shift to them.

Why abicheck catches it

diff_types.py compares the matched Handle records' size, alignment and per-member offsets, and vptr_introduced is emitted specifically for the non-polymorphic → polymorphic transition so the cause of the shift is named rather than left as three unrelated layout findings. That distinction is what makes the finding actionable: the fix is not "pad the struct" but "do not add the first virtual function to a released non-polymorphic type."

Runtime failure demonstration

Severity: stack corruption / misread members.

g++ -shared -fPIC -g v1.cpp -o libv1.so
g++ -g app.cpp -L. -lv1 -Wl,-rpath,. -o app
./app
# → via factory: id() = 7

g++ -shared -fPIC -g v2.cpp -o libv1.so   # swap in v2, no recompile
./app
# → via factory: id() = <garbage>, and the stack canary check may trip

Safe redesign

Decide polymorphism at the point a type is first published. If a type may ever need virtual dispatch, give it a virtual destructor from release one (paying the vptr cost up front); otherwise keep it non-polymorphic and add behaviour through free functions or a separate interface type.

Cross-tool comparison

abidiff reports the size, alignment and member-offset changes plus the new vtable; ABICC reports "size of the type has changed" and the added virtual method. Neither names the non-polymorphic → polymorphic transition as its own finding, which is what vptr_introduced adds.


Source files

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

See also: Compatibility Catalog · All BREAKING cases · Category: Breaking · Rule: Class gained a vtable pointer · Subject: Vtable and virtual dispatch changes.