Skip to content

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 browse on GitHub

Category: Function ABI | Verdict: BREAKING

What breaks

The extern "C" wrapper is removed from the public header. In v1, functions are exported with C linkage โ€” the symbol name in the .dynsym table is exactly parse_config. In v2, functions use C++ linkage โ€” the exported symbol name is mangled to something like _Z12parse_configPKc.

Any consumer (C or C++) that was linked against v1 has recorded parse_config (unmangled) as the needed symbol. When v2 is loaded, that symbol doesn't exist โ€” the dynamic linker fails with "undefined symbol".

Why this matters

extern "C" is the standard mechanism for C++ libraries to provide a C-compatible ABI. It suppresses C++ name mangling, making symbols accessible from C code and stable across different C++ compilers/versions. Removing it is equivalent to renaming every affected function.

This break is particularly insidious because: - The source code compiles fine with the new headers (C++ callers don't notice) - The function signatures are identical โ€” same name, same parameters - The break is only visible in the binary symbol table (nm -D) - C consumers cannot call the function at all (mangled names aren't valid C identifiers)

Code 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);

Real Failure Demo

Severity: CRITICAL

Scenario: compile 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
# โ†’ T validate_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 parse_config
# โ†’ T _Z12parse_configPKc
# โ†’ T _Z15validate_configPKc

./app
# โ†’ ./app: symbol lookup error: ./app: undefined symbol: parse_config

Why CRITICAL: The unmangled symbol parse_config no longer exists in v2's dynamic symbol table. The C++ mangled version _Z12parse_configPKc is there, but the pre-linked binary doesn't know about it. Process killed at load time.

How to fix

Always maintain extern "C" for public C-compatible APIs:

  1. Keep the extern "C" block: this is a public API contract, not an implementation detail
  2. Use a C header: keep the public header as pure C (parser.h) and use a separate C++ header for C++ consumers
  3. Enforce with CI: add a check that all public .dynsym symbols match expected names (e.g., nm -D libparser.so | grep -v '^_Z' should list all public functions)

Real-world example

This commonly happens during "modernization" refactors when a C library is rewritten in C++. The developer removes extern "C" thinking "we're C++ now" without realizing that all downstream C consumers (and pre-built C++ binaries) depend on the unmangled names.

libpng, zlib, and SQLite all maintain extern "C" blocks specifically to ensure their C ABI contract is preserved even when compiled as C++.

abicheck detection

abicheck detects this as func_language_linkage_changed (BREAKING) by comparing symbol names in the dynamic symbol table โ€” the unmangled name disappears and a mangled name appears, which is flagged as a linkage change rather than a simple removal + addition.

References


Source files

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