14.4 Shared Libraries and Symbol Visibility
A shared library is not merely a static archive with a different suffix. It is a loadable image with a public binary contract. Its exported symbol names, versions, types, object layouts, calling conventions, exception rules, and initialization behavior may be depended upon by programs compiled years apart. Designing this boundary deliberately improves compatibility, optimization, startup cost, and security.
In ELF, the full .symtab is primarily for linking and debugging and can often be stripped from a shipped library. The smaller .dynsym contains symbols needed by dynamic linking. Exporting every global definition bloats .dynsym and hash tables, increases relocation and lookup work, exposes internal implementation names, and limits optimization because another module might interpose those names.
Visibility is an optimization and compatibility tool
A strong default is hidden by default: compile internal definitions with hidden visibility and explicitly annotate the intended API as default-visible. A linker version script or export list can then provide a second allowlist at link time. On Windows, analogous control comes from __declspec(dllexport) or a module-definition file. The goal is not the fewest possible symbols; it is a small, reviewed, stable set.
Default-visible ELF symbols are generally preemptible: a definition from another object may replace the library's own reference according to dynamic resolution scope. This supports mechanisms such as LD_PRELOAD and historical interposition, but it prevents the compiler or linker from assuming that an internal call reaches the local function. Hidden or protected/internalized definitions allow direct calls, inlining, constant propagation, and removal when unused. Options such as -Bsymbolic change binding within a shared object, but can surprise programs that expected interposition and must therefore be an explicit ABI choice.
Stable ABIs require more than stable function names
Changing int draw(Point*) to long draw(Point*) may retain the source name in C yet change return-value convention or semantics. Reordering fields in a public structure changes offsets and size. Altering C++ compiler settings can change mangled names, vtable layout, RTTI, exceptions, or standard-library types. Removing an enum value, global variable, or constructor side effect can also break existing consumers.
Common design techniques include opaque handles, creation/destruction functions, explicit structure-size fields, reserved slots, and versioned entry points. An opaque struct widget; keeps its layout private so the library can evolve it without recompiling clients. A public options structure can start with size and require callers to zero unknown fields, allowing compatible extension. Symbol versioning can preserve an old implementation while giving new links an updated contract, but it does not repair every data-layout break automatically.
GOT, PLT, resolution scope, and hardening
Position-independent code usually reaches external data through the Global Offset Table (GOT). External function calls may pass through a Procedure Linkage Table (PLT) stub that loads a destination from a GOT slot. With lazy binding, the slot initially points to a dynamic-resolver path. The first call identifies the symbol, searches the permitted object scopes and versions, patches the slot, and continues to the resolved function; later calls use the cached address. Eager binding resolves the slot before main.
Resolution order can include the main executable, preloaded objects, direct dependencies, and their dependency scopes, depending on the platform and loading mode. dlopen flags can make a new object's symbols local or global. A weak symbol, version requirement, RTLD_NEXT, or -Bsymbolic changes the search. Debugging therefore requires more than asking “which library defines this name?”; inspect which objects are loaded, their order and namespaces, requested versions, visibility, and the exact relocation.
Hardening reduces the interval and locations in which attacker-controlled writes can redirect dispatch. RELRO makes relocation tables read-only after fixups; eager binding enables full protection earlier. W^X separates writable and executable memory, and control-flow technologies can constrain indirect branches. These mechanisms complement rather than replace a narrow export surface. A practical library pipeline should compare exported ABI against a checked-in baseline, fail on accidental additions/removals, test old clients against the new library, test new clients against the oldest supported library, inspect runtime dependency search paths, and measure both startup relocations and steady-state call cost.