Programming Languages · Objects and Modules

Symbol Visibility, Linkage, and the One Definition Rule

How exported, hidden, and local symbols differ, why an inline function in a header can violate the one definition rule across shared libraries, and what the linker can detect.

A shared library is not a black box. Its exported symbols form a global namespace shared with every other library loaded into the same process, and a name exported by two of them can be resolved to either one depending on load order and on how the dynamic linker is configured. Understanding which names a build emits, and with what binding, explains a class of failures that look like compiler bugs: a function calling an unexpected implementation, a static variable that turns out to have two copies, and a program that works in one build configuration and not another.

Three kinds of name

An object file’s symbol table distinguishes names by binding and visibility, and the two are independent.

Binding says how the name is resolved. A local symbol is resolved within its own object file and does not appear to the linker as a candidate for other files. A global symbol is visible to the linker and can satisfy references from other objects. A weak global symbol may be overridden by a strong definition of the same name, which is how header-defined templates and inline functions avoid duplicate-definition errors.

Visibility says whether the name is exported from a shared object at run time. A default-visibility symbol is placed in the dynamic symbol table and can be referenced by other shared objects. A hidden symbol is not, so references to it from inside the same shared object bind directly and cannot be interposed. A protected symbol is exported but cannot be interposed, so references within the defining shared object always bind to its own definition.

The performance consequence is concrete. A default-visibility function called from inside a shared library must go through the procedure linkage table, because the dynamic linker may have resolved the name to a definition in another library. That is an indirect jump with a lazy resolution step on first call, and it also means that the address taken in one shared object is the address of its PLT entry rather than of the target function, so an equality test against a function pointer obtained elsewhere can compare two different addresses for the same function. A hidden function can be called directly, and can be inlined, because no other definition can take its place. Compilers therefore default to hidden visibility for internal names when the build is configured that way, and expose only the declared interface. The effect is two-fold: a library’s exported set shrinks to its declared interface rather than to everything its translation units happen to define, and every internal call becomes a candidate for direct binding and inlining.

Interposition and the global namespace

The dynamic linker builds a single global namespace from the loaded shared objects, in load order, and resolves each undefined reference to the first definition it finds. This is what allows a program to override a library function by defining it itself, a technique used for wrappers around allocation functions and for test doubles. It is also what makes two libraries that export the same name interfere: whichever loads first wins, and the other library’s internal calls may silently bind to the wrong definition.

C++ name mangling makes collisions less likely by encoding namespaces and parameter types into the symbol name — return types are not part of the mangled name of an ordinary function, since C++ cannot overload on them — but it does not eliminate collisions. Two libraries that vendor the same third-party code will export the same mangled names for its functions, and the linker’s choice between them is arbitrary from the program’s point of view. The practical rule is that a shared library should export only its own interface and hide everything it vendored, which is what hidden visibility and an explicit export list achieve.

The one-definition rule sits on top of this. In C++, a function or variable with external linkage may be defined in more than one translation unit only if all definitions are identical token for token and the name is inline or a template. The linker then keeps one copy. The rule exists because the language has no way to express “this definition is the one” for header-defined code, so it relies on the definitions being interchangeable. When they are not — because two translation units were compiled with different macros, different struct layouts, or different versions of a header — the program has undefined behaviour, and the linker cannot detect it, because the symbol names and sizes may be identical.

How inline definitions leak across shared libraries

The case that produces the most confusing failures involves an inline function defined in a header and used by two shared libraries.

Library A defines inline int scale(int x) { return x * 2; } in a header and calls it. Library B includes a later version of the same header, where the body is return x * 3;, and also calls it. Each library emits a weak definition of the mangled name in its own object code. At load time, the dynamic linker resolves both libraries’ references to whichever definition it finds first. One of the two libraries now calls the other’s version, and the result depends on load order — which in turn depends on link order, on whether the program uses dlopen, and on the platform’s resolution rules.

Compilers mitigate this with semantic interposition rules and with -Bsymbolic-style binding, and some platforms default to binding references within a shared object to that object’s own definitions. None of these is portable, and all of them change which failure mode occurs rather than eliminating it. The reliable fixes are to keep inline definitions out of published headers where behaviour can change, or to hide them, or to make the function’s address irrelevant by ensuring the body never changes incompatibly.

A related leak is the static variable inside an inline function. The language requires that all translation units share one instance, which the compiler implements by emitting a weak symbol for the variable and relying on the linker to coalesce. Across shared libraries that coalescing happens only if the symbol is exported and resolved globally; if the libraries are built with hidden visibility, each gets its own instance. A function-local cache intended to be shared becomes two caches, and a counter intended to be global counts twice. The behaviour is defined by the ABI and by the visibility settings, not by the language, which is why the same source can produce different results in a static build and a shared build. The same hidden-visibility setting that gives one library its own copy of a static also decides which initialiser functions remain in the dynamic symbol table and are therefore run at load time, which is the boundary described in module initialization order and cyclic dependencies.

What the linker can and cannot check

The linker checks that every undefined symbol has exactly one definition, or that a weak definition exists. It does not check that the definition matches the declaration the caller saw: C linkage carries no type information at all, and C++ mangling encodes the parameter types but not the struct layouts those types refer to. A caller compiled against a header that declares a struct with three fields can link successfully against a library built when the struct had four, and the mismatch shows up as corrupted data rather than as a link error.

Symbol versioning is the mechanism that lets a library change an interface while keeping old clients working. A version script assigns a version label to a set of symbols, and the dynamic linker records the version a client was linked against. When the library later changes the function’s behaviour, it can export both the old and new implementations under different version labels, and old clients keep binding to the old one. The mechanism is ELF-specific and requires deliberate maintenance; without it, any change to an exported function’s semantics is a break for every client that was not recompiled.

Debug information and symbol tables are separate concerns. Stripping a binary removes the symbol table but not the dynamic symbol table, so a stripped shared library still exports its interface and still resolves at run time. A build that strips too aggressively, or that removes the dynamic symbol table, produces a library that loads but whose exported functions cannot be found.

Diagnosing a binding problem

The observable symptom is a call that reaches a function whose source the caller never included. Three tools narrow it down.

The dynamic symbol table of each library shows which names are exported: nm -D on ELF systems lists them, and comparing two libraries’ exports is the fastest way to find an accidental collision. The dynamic section shows which library a given reference was bound to at run time, and the loader’s diagnostic output lists the resolution order. A debugger breakpoint on the suspect function, with a check of which shared object contains the address, distinguishes “called the wrong function” from “called the right function with wrong data”.

The build-side fix is almost always the same: compile with hidden visibility by default, mark the public interface explicitly, and never rely on a name being resolved to the definition in the same library unless the symbol is protected or hidden. For a library that must expose C++ classes, that means an export macro applied to the class, which on the common toolchains expands to a visibility attribute and, on Windows, to a DLL import/export annotation. The annotation must be consistent between the library’s build and the client’s, or the client will import a symbol the library never exported.

Where this connects to object layout

Symbol binding and class layout are two halves of the same interface. A library that hides its symbols but publishes a class definition in a header still exposes the layout, so a client can allocate and subclass it, and the fragility described in the fragile base class problem applies regardless of visibility. A library that publishes only an opaque handle and exported functions controls both halves, which is why C interfaces remain the portable ABI even for libraries written in C++. Choosing which half to expose is the design decision; visibility settings are only the mechanism that implements it.

References

  1. System V Application Binary Interface, Generic ABI — System V ABI working group
  2. How to Write Shared Libraries — Ulrich Drepper, Red Hat
  3. GNU Compiler Collection Manual: Visibility Pragmas — GCC project