Skip to content

Case 69: Trivially Copyable to Non-Trivial (Calling Convention Change)

Field Value
Verdict ๐Ÿ”ด BREAKING
Category Breaking
Platforms Linux, macOS
Flags ABI break
Detected ChangeKinds value_abi_trait_changed
Source files browse on GitHub

Category: Calling Convention | Verdict: BREAKING

What breaks

A user-defined destructor ~Point() {} is added to struct Point, changing it from trivially copyable to non-trivially copyable. Under the Itanium C++ ABI (used by GCC and Clang on Linux/macOS), this fundamentally changes how the struct is passed to functions:

  • v1 (trivial): Point is passed in registers (%xmm0/%xmm1 for the two doubles on x86-64 System V)
  • v2 (non-trivial): Point is passed via hidden pointer โ€” the caller allocates stack space, copies the struct there, and passes a pointer in %rdi

The function signature (double distance(Point, Point)) and the mangled symbol name are identical in v1 and v2. The struct size is also unchanged. Yet the binary calling convention is completely different. The callee reads registers that contain garbage (addresses instead of doubles) or vice versa.

This is unlike any existing case: it's not a type size change (case07/14), not a signature change (case02/10), not a qualifier change (case22), and not a visibility change (case06). The break is invisible to header diffing tools that don't analyze the trivially-copyable trait.

Why abicheck catches it

The type analysis detects that Point gained a non-trivial destructor, changing its ABI classification. This is reported as a value-ABI trait change (value_abi_trait_changed) โ€” the struct's calling convention changed even though its layout didn't.

Code diff

// v1: trivially copyable โ€” passed in registers
struct Point {
    double x;
    double y;
};

// v2: non-trivially copyable โ€” passed via hidden pointer
struct Point {
    double x;
    double y;
    ~Point() {}   // โ† this single line changes the calling convention
};

Real Failure Demo

Severity: CRITICAL

Scenario: compile app against v1, swap in v2 .so without recompile.

# Build v1 (trivially copyable) and app
g++ -shared -fPIC -g v1.cpp -o libpoint.so -lm
g++ -g app.cpp -L. -lpoint -Wl,-rpath,. -o app
./app
# -> distance = 5.0
# -> Expected: 5.0

# Build v2 (non-trivially copyable)
g++ -shared -fPIC -g v2.cpp -o libpoint.so -lm
./app
# -> distance = <garbage or crash>
# v2 expects hidden pointers in %rdi/%rsi; app passes doubles in %xmm registers

Why CRITICAL: The mangled name is identical, so the dynamic linker happily resolves the symbol. But the caller passes Point values in FP registers while the callee reads them as pointers from integer registers. The function dereferences what it thinks are pointers, causing a segfault or reading garbage memory. There is no warning whatsoever.

How to fix

The trivially-copyable property is part of the ABI contract. Never add user-defined special member functions to types that are passed by value across library boundaries:

  1. Use a separate Pimpl class for non-trivial cleanup
  2. Pass by pointer/reference instead of by value across ABI boundaries
  3. If a destructor is needed, add it from day one so the ABI is established as non-trivial from the start
/* Safe: pass by pointer, immune to trivially-copyable changes */
double distance(const Point *a, const Point *b);

Real-world example

This is the core issue behind the C++ ABI stability debate. The std::string and std::unique_ptr types in libstdc++ have specific trivially-copyable properties that constrain their implementation. Boost.Asio's ip::address type had this issue when executor properties were added. The Chromium project explicitly documents which types must remain trivially copyable for their IPC serialization ABI.

LLVM's ABI testing suite specifically tests for trivially-copyable changes because they are invisible to most diff tools but cause silent corruption at runtime.

References


Source files

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