Skip to content

Case 140: Empty Base Optimization Lost (base subobject moved)

Field Value
Verdict ๐Ÿ”ด BREAKING
Category Breaking
Platforms Linux
Flags ABI break
Detected ChangeKinds base_class_offset_changed, type_size_changed, type_field_offset_changed
Source files examples/case140_empty_base_optimization_lost/

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

Verdict and consumer impact

Any binary that upcasts a Widget* to its Payload base โ€” or otherwise relies on Widget's layout โ€” is broken without recompilation. The upcast offset is baked in at compile time: under v1 it's 0, under v2 it's 8. A prebuilt caller keeps using the old (now wrong) offset and silently reads the wrong bytes; sizeof(Widget) also grows, so every stack/heap allocation, array, and embedding of Widget is mis-sized too.

Old/new diff

// v1                                    // v2
struct Tag {};                           struct Tag { long state; };  // <- gained a member
struct Payload { long value; };          struct Payload { long value; };
struct Widget : Tag, Payload {           struct Widget : Tag, Payload {
    long extra;                              long extra;
};                                       };

The only source edit is that Tag gained one data member. Tag was an empty class, so under the Itanium C++ ABI the Empty Base Optimization (EBO) folded it to offset 0 at zero cost, and the Payload base began at offset 0 too. Once Tag has a member it is no longer empty, EBO no longer applies, and the Payload base subobject moves from offset 0 to offset 8:

Widget (v1):  [Payload::value @0][extra @8]                  sizeof = 16
Widget (v2):  [Tag::state @0][Payload::value @8][extra @16]   sizeof = 24

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)

- type_size_changed: Size changed: Tag (8 -> 64 bits)
- type_size_changed: Size changed: Widget (128 -> 192 bits)
  > Affected symbols: make_widget, widget_payload_value
- type_field_offset_changed: Field offset changed: Widget::extra (64 -> 128 bits)
  > Old code reads/writes fields at stale offsets; silent data corruption.
- base_class_offset_changed: Base class 'Payload' moved within 'Widget'
  (0 -> 64 bits). The this-pointer adjustment for that base and the offset
  of every field after it shift; existing binaries read the wrong
  addresses.

Additions:
- type_field_added_compatible: Field added: Tag::state

Minimum evidence

min_evidence: L1 โ€” DWARF's DW_TAG_inheritance entries record each base class's computed offset within the derived type for both versions, so -g alone (no public headers) is enough; with headers supplied the same fact is also visible via the castxml record layout (L2).

Why abicheck catches it

abicheck reads each base's DW_TAG_inheritance offset (or, when headers are available, the castxml/clang AST record layout) from DWARF and compares base_offsets["Payload"] directly between the two snapshots โ€” base_class_offset_changed fires on the computed offset shift itself, not on the declaration order of the bases changing.

Runtime failure demonstration

Severity: BREAKING / OBJECT CORRUPTION

Scenario: app upcasts Widget* to Payload* and reads Payload::value through that pointer โ€” the compile-time offset is baked into the upcast.

# Build old library + app
g++ -shared -fPIC -g v1.cpp -o libwidget.so
g++ -g app.cpp -I. -L. -lwidget -Wl,-rpath,. -o app
./app
# โ†’ Payload::value via base cast = 42 (expected 42)
# โ†’ Payload::value via accessor  = 42 (expected 42)

# Swap in new library (no recompile)
g++ -shared -fPIC -g v2.cpp -o libwidget.so
./app
# โ†’ Payload::value via base cast = 0 (expected 42)   โœ—  CORRUPTION
# โ†’ Payload::value via accessor  = 42 (expected 42)  โœ“  (library-side, always correct)
# โ†’ CORRUPTION: base-subobject offset shifted (EBO lost)

Why BREAKING: the app's upcast uses the v1 offset (0) baked in at compile time; under v2 that offset lands on Tag::state instead of Payload::value, while the library's own accessor (which recomputes the offset at v2's compile time) still returns the right answer โ€” a textbook silent-corruption signature.

Safe redesign

Do not expose concrete classes with public base classes across an ABI boundary; hide layout behind an opaque handle / pimpl. Treat "adding a field to an empty base/tag type" as an ABI-review event โ€” EBO makes it a layout change, not a local edit.

Real-world example: libc++'s unique_ptr/optional and similar empty-base-heavy templates are a known EBO trap โ€” adding any state to a policy/tag type used as a base silently grows and reshuffles every instantiation.

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 also models base-class layout via its DWARF DIE walker and would flag the size and offset changes; it doesn't have a dedicated "EBO lost" label, so the underlying cause (an empty base gaining a member) is less immediately named than abicheck's base_class_offset_changed.

References


Source files

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

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