Case 66: Language Linkage Changed (extern "C" removed)¶
| Field | Value |
|---|---|
| Verdict | ๐ด BREAKING |
| Category | Breaking |
| Platforms | Linux, macOS, Windows |
| Flags | ABI break, API break |
Detected ChangeKinds |
func_language_linkage_changed |
| Source files | examples/case66_language_linkage_changed/ |
| Known kind gap | func_language_linkage_changed โ verdict is correct; see note below |
Category: Function ABI | Verdict: ๐ด BREAKING
Verdict and consumer impact¶
The extern "C" wrapper is removed from the public header. In v1, functions
export with C linkage โ the .dynsym name is exactly parse_config. In v2,
the same functions use C++ linkage, so the exported name is mangled to
_Z12parse_configPKc. Any consumer (C or C++) linked against v1 has
recorded the unmangled name as the symbol it needs; when v2 is loaded, that
name doesn't exist and the dynamic linker fails to resolve it. The source
still compiles fine against v2's headers โ only the binary symbol table
changes โ so this break is easy to miss without an ABI diff.
Old/new diff¶
// v1.h โ C linkage (symbol: "parse_config")
extern "C" {
int parse_config(const char *path);
}
// v2.h โ C++ linkage (symbol: "_Z12parse_configPKc")
int parse_config(const char *path);
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)
- func_language_linkage_changed: Language linkage changed: validate_config
(extern "C" -> C++)
> Language linkage changed (extern "C" <-> C++); the mangled symbol name
changes, so old binaries reference a symbol that no longer exists under
that name.
- func_language_linkage_changed: Language linkage changed: parse_config
(extern "C" -> C++)
Minimum evidence¶
min_evidence: L0 โ the exported-symbol table alone shows the unmangled
name disappear and a demangled-equivalent mangled name appear in its place;
abicheck's mangled/unmangled reconciliation reports this as a linkage
change rather than an unrelated add+remove, with no debug info or headers
required.
Why abicheck catches it¶
abicheck demangles each exported C++ symbol and, when a v1 unmangled name
and a v2 mangled name demangle to the same underlying function signature, it
reports func_language_linkage_changed instead of a plain
func_removed/func_added pair โ turning what would otherwise look like an
unrelated symbol swap into the actual linkage-change root cause.
Runtime failure demonstration¶
Severity: CRITICAL
Scenario: compile a C app against v1, swap in v2 .so without recompile.
# Build v1 (extern "C") and app
g++ -shared -fPIC -g v1.cpp -o libparser.so
gcc -g app.c -L. -lparser -Wl,-rpath,. -o app
./app
# โ parse_config = 1 (expected 1)
# โ validate_config = 1 (expected 1)
# Verify v1 exports unmangled names
nm -D libparser.so | grep parse_config
# โ T parse_config
# Build v2 (no extern "C")
g++ -shared -fPIC -g v2.cpp -o libparser.so
# Verify v2 exports mangled names
nm -D libparser.so | grep config
# โ T _Z12parse_configPKc
# โ T _Z15validate_configPKc
./app
# โ ./app: symbol lookup error: ./app: undefined symbol: validate_config
Why CRITICAL: neither unmangled name (parse_config, validate_config)
exists in v2's dynamic symbol table anymore โ only their C++-mangled
equivalents do. The dynamic linker resolves the app's needed symbols eagerly
at load time and fails on whichever one it processes first (validate_config
here); the process is killed before main() ever runs.
Safe redesign¶
Always keep a public C-compatible API inside extern "C" โ treat it as a
public contract, not an implementation detail. Keep the public header pure
C and provide a separate C++ header for C++-only consumers, and enforce it
in CI with a check that every public .dynsym symbol matches the expected
unmangled name list (nm -D libfoo.so | grep -v '^_Z').
Real-world example: this commonly happens during "modernization"
refactors when a C library is rewritten in C++ and a developer removes
extern "C" without realizing downstream C consumers (and pre-built C++
binaries) depend on the unmangled names. libpng, zlib, and SQLite all
maintain extern "C" blocks specifically to preserve their C ABI contract
even when compiled as C++.
Cross-tool comparison¶
References¶
Ground-truth provenance¶
Known kind gap: The overall verdict (BREAKING) is correct via func_removed (v1's plain-name pair has no counterpart under v2's mangled name), but func_language_linkage_changed never fires โ a real, root-caused gap, not a fixture problem, though narrower than first documented (superseding an earlier, incorrect theory about castxml never emitting extern="1"). Root cause, confirmed by direct castxml invocation: v2.h has no extern "C" and no other C++-looking construct, so dumper.py's _detect_cpp_headers() content-sniff (correctly, from the header text alone) does not classify it as C++ and castxml parses it in C mode (-x c -std=gnu11). castxml still emits a pseudo-Itanium mangled attribute even in C mode, but without parameter-type encoding (e.g. 'parse_config' -> '_Z12parse_config', missing the 'PKc' suffix a real const char* parameter would add) โ this incomplete guess matches neither v1's real plain export ('parse_config') nor v2's real, fully-mangled C++ export ('_Z12parse_configPKc', since v2.cpp is genuinely compiled as C++). The real fix needs TU-level context (this header is included from a .cpp, so it has C++ linkage) that no per-header content sniff can recover; a bare-name/real-export reconciliation (the fix applied for case141) does not help here because neither side of this pair matches by bare name.
Source files¶
CMakeLists.txtapp.cv1.cppv1.hv2.cppv2.h
See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.