Language Runtimes · FFI and Interop
Symbol Mangling and Linkage in Cross-Language Calls
Why C++ and Rust rename functions before the linker sees them, how name mangling differs between compilers, and what dlsym requires of an exported symbol.
A linker resolves calls by string equality. When one object file contains a reference to a function named area and another object file defines a function named area, the linker connects them; when the names differ by a single character, the reference is unresolved and the link fails with an error that names the symbol. This is the entire basis of cross-translation-unit calls, and it is the reason language designers stopped using human-readable function names as linker keys.
C++ needs to distinguish overloaded functions. area(Circle) and area(Square) cannot both be emitted as area if the linker only compares strings. So the compiler encodes the parameter types into the symbol itself, producing names like _Z4area6Circle under the Itanium ABI used by GCC and Clang on Linux and macOS, or ?area@@YAHUCircle@@@Z under the Microsoft ABI. The encoding is a grammar, not a hash: types are serialized with single-letter codes and length-prefixed identifiers, so the mangled name can be decoded back to the signature.
Rust takes the same step for a different reason. Generic functions are monomorphized, and each instantiation needs its own symbol; the compiler therefore encodes the crate, the module path, the item name, and the full set of generic arguments into a legacy symbol such as _ZN4core3fmt5write17h...E, with a trailing hash disambiguating otherwise-identical instantiations. Namespaces and modules, which have no runtime representation, exist in the symbol because the linker needs them to keep distinct items distinct.
Neither scheme is a stable interface. The Itanium mangling is documented and followed consistently by multiple compilers, but the Microsoft scheme is documented loosely and has changed across versions, and Rust explicitly does not guarantee that its legacy symbol format will remain the same between compiler releases. Linking to a mangled name is therefore linking to a compiler’s private decision.
What the linker actually records
On ELF platforms a symbol is an entry in a table with a name, a value, a size, a binding, and a type. Binding is STB_LOCAL, STB_GLOBAL, or STB_WEAK; visibility is STV_DEFAULT, STV_HIDDEN, STV_PROTECTED, or STV_INTERNAL. A default-visibility global symbol is exported from a shared object and participates in the dynamic symbol table that the run-time loader searches. A hidden symbol is resolved entirely at static link time and is invisible to dlsym.
This distinction matters more than the naming scheme for practical interop. A function compiled with default visibility and C linkage can be found by name at run time with a single call, which is how plugin systems and language bindings that load libraries dynamically work. A function compiled with hidden visibility cannot, even though its name is plain ASCII and its convention is correct, because the loader’s search walks the dynamic symbol table and the symbol is not there. The usual symptom is a successful compile, a successful link, and a run-time failure reporting that the symbol is undefined.
On Mach-O the analogue is the export trie, and on Windows PE it is the export directory with either an ordinal or a name entry. The mechanisms differ in format, but all three answer the same question: which names does this image offer to a caller who arrives with nothing but a string.
Versioning adds a layer on ELF. A shared object may define foo at version VERS_1.0 and foo at VERS_2.0, and a reference records which version it expects. This is how a library can change a function’s behaviour without breaking binaries that were linked against the earlier definition. It also means that a symbol name found by dlsym is not necessarily the definition a statically linked caller would have received, and that a dynamically loaded plugin can be bound to a stale definition when two libraries in the process export the same name at different versions.
Demangling is a decoder, not an identity
Tools that print stack traces run the mangled name through a demangler and produce a readable signature. The result is a reconstruction, and it loses information the linker used: c++filt output for two functions that differ only in a top-level cv-qualifier or in a template’s non-type parameter may look similar, and Rust’s legacy scheme encodes a hash that no demangler can invert. Reading a stack trace is therefore reading a lossy rendering of the symbol, not the symbol itself.
The practical consequence is that a mangled name must be copied from the object file, not from a demangled trace. nm on the object prints the linker’s view; objdump -T prints the dynamic symbol table with versions. A name typed by hand from a debugger display will often be wrong in a way that is invisible, because the demangler normalizes whitespace and abbreviates.
Three ways to cross the boundary
The first is to declare the function extern "C" in the producing language. The compiler then emits the bare identifier as the symbol, with no type encoding, and the linker matches it against any declaration of the same name. This is what every #[no_mangle] attribute and every extern "C" block is buying: a symbol name that is a stable, human-readable string and does not depend on the compiler’s internal grammar.
The second is to define a stable exported name alongside the mangled one. A C++ header can declare a wrapper with C linkage that forwards to the templated or overloaded implementation. This keeps the type-safe interface for native callers and gives foreign callers a fixed entry point. It costs one extra call frame and a hand-maintained list of entry points, which is why bindings generators produce such wrappers automatically rather than by hand.
The third is to look symbols up at run time, which is the only option when the library is not known at link time. On POSIX the call is dlsym, on Windows GetProcAddress. Both take a handle and a string and return a function pointer, and both require the name to be present in the image’s export table under exactly that spelling.
#include <dlfcn.h>
#include <stdio.h>
int main(void) {
void *h = dlopen("libz.so.1", RTLD_NOW | RTLD_LOCAL);
if (!h) { fprintf(stderr, "dlopen: %s\n", dlerror()); return 1; }
/* dlsym returns void *; converting to a function pointer is
permitted by POSIX but not by ISO C. */
unsigned long (*compressBound)(unsigned long) =
(unsigned long (*)(unsigned long))dlsym(h, "compressBound");
if (!compressBound) { fprintf(stderr, "dlsym: %s\n", dlerror()); return 1; }
printf("%lu\n", compressBound(1024));
dlclose(h);
return 0;
}
Three details in that fragment are part of the contract rather than incidental. RTLD_LOCAL keeps the loaded library’s symbols out of the global namespace, so a second library loaded later cannot accidentally bind to them. RTLD_NOW resolves all relocations at load time instead of deferring them, which converts a missing symbol into a failure at dlopen rather than a crash at first call. And the cast from void * to a function pointer is a POSIX concession: ISO C does not guarantee that object pointers and function pointers have the same representation, and on architectures with separate instruction and data address spaces they do not.
The signature is not checked. dlsym returns a pointer to whatever code carries that name; if the caller’s function type disagrees with the definition about argument count or class, the call proceeds and corrupts the stack or reads the wrong registers. The name is the only thing that was verified, and the name says nothing about types once C linkage has stripped the encoding.
Why C is the lingua franca despite the loss
C linkage discards exactly the information that makes overload resolution and generics work, and that is the property that makes it usable as an interop layer. A C symbol is a flat string in a flat namespace with a convention fixed by the platform, the calling convention that every producer and consumer must share. Any language can emit one, any language can consume one, and the loader can resolve one without understanding the producer’s type system.
The cost is that everything the type system knew must be restated somewhere else: in a header, in a binding declaration, in documentation, or not at all. When it is restated incorrectly, the linker has no way to notice. The mitigation is to make the exported surface small and to keep the encoding of types in the names where it can be checked, the approach taken by designing a stable C ABI.
One further wrinkle belongs here rather than to calling conventions. Because a C symbol occupies a single global namespace, two libraries that both export a generically named function will collide when both are loaded with global visibility. The collision is silent when the signatures match and corrupting when they do not, and the resolution order depends on load order. Prefixing every exported symbol with a library-specific string is the standard defence, and it is why the C convention alone is not sufficient for a library that expects to coexist with others in one process.
References
- Itanium C++ ABI: Name Mangling — Itanium C++ ABI Committee
- Microsoft C++ Name Mangling — Microsoft Learn
- Tool Interface Standard (TIS) Executable and Linking Format (ELF) Specification — TIS Committee