Skip to content

Case 17: Template Instantiation ABI Change

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

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

Verdict and consumer impact

Buffer<int> is an explicitly-instantiated C++ template, compiled into the .so like an ordinary class โ€” its mangled symbols (e.g. _ZN6BufferIiEC1Em) are identical in both versions, so a linker-level check alone would miss this. A caller compiled against v1 headers allocates sizeof(Buffer<int>) = 16 bytes (stack, heap, or embedded in another struct). The v2 constructor writes 24 bytes โ€” 8 bytes past the allocated region โ€” corrupting whatever follows. Any binary that constructs, copies, or embeds Buffer<int> by value is broken without recompilation.

Old/new diff

v1.hpp v2.hpp
T* data_; std::size_t size_; (16 bytes) T* data_; std::size_t size_; std::size_t capacity_; (24 bytes)

abicheck command

g++ -shared -fPIC -std=c++17 -g v1.cpp -o libv1.so
g++ -shared -fPIC -std=c++17 -g v2.cpp -o libv2.so
abicheck compare libv1.so libv2.so

Expected abicheck finding

Verdict: BREAKING (exit 4)

- type_size_changed: Size changed: Buffer<int> (128 -> 192 bits)
  > Old code allocates or copies the type with the old size; heap/stack
    corruption, out-of-bounds access.
- type_field_added: Field added: Buffer<int>::capacity_
  > New field shifts subsequent fields; old code reads wrong offsets for
    all fields after insertion point.

Additions:
- func_added: New public function: Buffer<int>::capacity() const

Minimum evidence

min_evidence: L1 โ€” DWARF records the explicit instantiation Buffer<int> as a concrete type with its own DW_TAG_structure_type size and member offsets, exactly like a non-template class. -g alone (no public headers) is enough; the mangled symbol names never change, so L0 symbol-table evidence alone would miss this.

Why abicheck catches it

Explicit template instantiation means Buffer<int> exists in the compiled .so's debug info as a concrete, fully-laid-out type. abicheck reads its DWARF size and member offsets the same way it would for any struct/class, so the 16โ†’24 byte growth and the new capacity_ member are detected without any template-specific handling.

Runtime failure demonstration

Severity: CRITICAL

Scenario: app embeds Buffer<int> (sized at compile time against v1, 16 bytes) directly before a sentinel field in a struct, then constructs it via a .so swapped to v2 without recompiling.

# Build old library + app
g++ -shared -fPIC -std=c++17 -g v1.cpp -o libbuf.so
g++ -std=c++17 -g -O0 app.cpp -I. -L. -lbuf -Wl,-rpath,. -o app
./app
# โ†’ sizeof(Buffer<int>) at compile time = 16
# โ†’ before ctor: sentinel = SENTINEL
# โ†’ after  ctor: sentinel = SENTINEL

# Swap in new library (no recompile)
g++ -shared -fPIC -std=c++17 -g v2.cpp -o libbuf.so
./app
# โ†’ sizeof(Buffer<int>) at compile time = 16
# โ†’ before ctor: sentinel = SENTINEL
# โ†’ after  ctor: sentinel = (empty)
# โ†’ CORRUPTION: v2 constructor wrote past Buffer slot!

Why CRITICAL: the v2 constructor writes a new capacity_ field at byte offset 16, but the app only reserved 16 bytes for the object. The adjacent sentinel field is overwritten โ€” the same mechanism can corrupt heap metadata, adjacent struct fields, or return addresses depending on where the object is allocated.

Safe redesign

Never add data members to an explicitly-instantiated template class once it's part of the public ABI. Use the opaque-pointer (PIMPL) idiom, or reserve unused padding/bytes up front for anticipated growth, so new internal state doesn't change sizeof.

Real-world example: large numeric libraries that explicitly instantiate templated classes (e.g. HomogenNumericTable<float>) into their .so hit this exact bug when a private member is added for bookkeeping โ€” downstream bindings compiled against old headers write past buffers, a class of bug typically only caught by ASan in integration tests.

Cross-tool comparison

abidw --out-file v1.xml libv1.so
abidw --out-file v2.xml libv2.so
abidiff v1.xml v2.xml
echo "exit: $?"   # โ†’ 4 (with -g / DWARF present)

Note on abidiff and debug info: abidiff only sees this change when both .so files carry DWARF (-g); with debug info stripped it falls back to the symbol table alone, where the mangled names are identical and the layout change is invisible. abi-compliance-checker, which parses the header AST directly, catches the sizeof growth regardless of whether debug info is present.

References


Source files

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

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