Skip to content

Case 129: Struct-Return Convention Change

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

Category: Calling Convention | Verdict: ๐Ÿ”ด BREAKING

Verdict and consumer impact

Result compute() returns a small aggregate by value. In v1, Result is trivially copyable, so the System V AMD64 ABI returns it in registers (RAX:XMM0). In v2, Result gains a user-declared destructor, making it non-trivial โ€” the ABI now returns it through a hidden caller-provided pointer (sret) passed in RDI instead. The function's mangled name (_Z7computev) is byte-for-byte unchanged, so a symbol-only check sees nothing, but a caller compiled against v1's in-register convention and relinked against v2 without recompiling reads the return value from the wrong location entirely.

Old/new diff

v1.cpp v2.cpp
struct Result { int code; double value; }; struct Result { int code; double value; ~Result(); };
Result compute(); (in-register return) Result compute(); (sret return)

abicheck command

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

Expected abicheck finding

Verdict: BREAKING (exit 4)

- struct_return_convention_changed: Aggregate return convention changed:
  compute() (ret:trivial -> ret:nontrivial)
  > The aggregate return convention changed for a public function -- e.g.
    a small struct that was returned in registers is now returned via a
    hidden caller-provided pointer (sret), or vice versa. Callers and
    callee disagree on where the result lives, so the return value is
    read from the wrong location -- silent corruption or a crash.

Minimum evidence

min_evidence: L1 โ€” DWARF's per-function value-ABI facts record whether a returned aggregate is trivial or non-trivial for the purposes of calls; abicheck reads that trait directly from the compiled .so's debug info, no public headers required.

Why abicheck catches it

DWARF's return-type facts for compute flip trivial -> nontrivial, which crosses the in-register/sret boundary defined by the System V AMD64 ABI. abicheck emits struct_return_convention_changed (the return-specific refinement of the more general value_abi_trait_changed) as BREAKING, since this is a proven calling-convention fact, not just a build-flag signal.

Runtime failure demonstration

Severity: CRITICAL

# Build old library + app (app expects the in-register v1 convention)
g++ -shared -fPIC -g -Og v1.cpp -o libfoo.so
g++ -g -Og app.cpp -I. -L. -lfoo -Wl,-rpath,. -o app
./app
# -> exit: 0

# Swap in new library (no recompile)
g++ -shared -fPIC -g -Og v2.cpp -o libfoo.so
./app
# -> Segmentation fault
# -> exit: 139

Why CRITICAL: the app calls compute() expecting the result in RAX:XMM0 (v1's in-register convention). Against v2, compute() instead writes the result through a hidden sret pointer that the app's v1-compiled call site never set up โ€” the app reads garbage register state as the return value and crashes.

Safe redesign

Treat a triviality change on a by-value public return type as an ABI break: keep the type trivially copyable, or bump the SONAME and rebuild all consumers. If a destructor is genuinely required, return through an opaque handle or an out-parameter instead of by value so the convention stays explicit and stable.

Real-world example: adding a std::unique_ptr member, a user-declared destructor, or any non-trivial member to a small struct that a public factory returns by value is a common form of this break in C++ libraries โ€” it looks like an innocuous source edit but silently changes the call ABI of every function returning the type by value.

Cross-tool comparison

abidiff also reads DWARF and can see the same triviality flip in principle, but its handling of the specific in-register/sret ABI transition is not independently verified in this sandbox (no abidw/abidiff installed here); treat this section as an invocation reference rather than a verified result:

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

References


Source files

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

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