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:
abidiffonly sees this change when both.sofiles 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 thesizeofgrowth regardless of whether debug info is present.
References¶
Source files¶
CMakeLists.txtapp.cppv1.cppv1.hppv2.cppv2.hpp
See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.