Linkingtarget

Shared Libraries

`.so`, `.dll`, `.dylib` — one artifact, three platforms, three different policies. The soname is the compatibility promise, and exporting everything by default is the mistake that makes a library slow to load and impossible to change.

The question

What is in a .so, what does the soname mean, and why is -fvisibility=hidden recommended?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

A library as a loadable, position-independent image with two symbol tables: a full one for the tools and a *dynamic* one listing exactly what it offers to the outside and what it needs from it. The dynamic table is the representation that matters, because it is the library's entire published surface — everything in it is a permanent promise, and everything out of it is free to change.

What this phase may assume or do

A shared object may replace another under the same soname only if every symbol the previous version exported is still exported with a compatible definition — same name, same version tag, same layout for anything reachable through it. Adding exports is permitted; removing or changing one is not, and requires a new soname. Internally, a symbol may be hidden only if nothing outside the library needs it, which includes anything reached through a function pointer, a vtable, or a plugin's callback — cases the compiler cannot see and the author must know.

Key points

  • A shared library carries a soname that is its compatibility identity; consumers record the soname, not the file name, which is what makes in-place upgrades work.
  • An incompatible change requires a new soname, which lets both versions be installed simultaneously with no coordination between consumers.
  • ELF exports every non-static symbol by default, which costs load time, forces internal calls through the PLT, blocks optimization at the boundary, and publishes an ABI you did not intend.
  • -fvisibility=hidden plus explicit export annotations gives ELF the Windows default, and is the single highest-value flag for a shared library.
  • A shared object has initializers the loader runs, with cross-object ordering that is only partly specified — relying on it is a latent bug.
  • ELF, Mach-O and PE differ in export defaults, namespace model and versioning mechanism, so advice does not transfer between them.

Three names for one file

targetThe three-name scheme with sonames is ELF and Linux convention. macOS records an install name plus compatibility and current version numbers in the Mach-O header, and a program records the install name — an absolute path or an @rpath-relative one — rather than a bare soname. Windows has no soname at all; convention is to put the version in the DLL name (libssl-3.dll) or to use side-by-side assemblies. The problem is the same and no mechanism transfers.

A shared library on Linux is conventionally installed under three names. The *real name* is the file itself — libssl.so.3.0.13 — carrying the full version. The *soname* is the compatibility identity — libssl.so.3 — and is a symlink to the real name; it is also recorded inside the library and is the string a program records when it links against it. The *linker name* — libssl.so — is another symlink used only at build time so that -lssl finds something.

The soname is the entire versioning contract. When a program is linked, the linker copies the library's soname into the executable's DT_NEEDED entry, so at runtime the loader looks for libssl.so.3 and not for any particular file. Ship a bug fix as libssl.so.3.0.14, repoint the symlink, and every program picks it up on next start. Make an incompatible change and you must bump to libssl.so.4, which is a *different* soname — so old programs keep finding the old one and both can be installed at once.

That last property is the reason the scheme exists. It is not documentation; it is a mechanism that lets two incompatible versions coexist on one system with no coordination between the programs using them. The equivalent on macOS is a compatibility version and a current version recorded in the .dylib; on Windows it is conventionally a version in the DLL name itself or a side-by-side assembly manifest.

The three names, and what each is for
1/usr/lib/libssl.so -> libssl.so.3 # linker name: for -lssl at build time
2/usr/lib/libssl.so.3 -> libssl.so.3.0.13 # soname: what programs record
3/usr/lib/libssl.so.3.0.13 # real name: the actual file
4
5$ readelf -d libssl.so.3.0.13 | grep SONAME
6 0x000000000000000e (SONAME) Library soname: [libssl.so.3]
7 ^ baked into the library,
8 copied into every consumer
9
10$ readelf -d app | grep NEEDED
11 0x0000000000000001 (NEEDED) Shared library: [libssl.so.3]
12 ^ the loader searches for THIS,
13 never for the full version

Building without -Wl,-soname,libfoo.so.1 is the classic mistake: the library gets no soname, so consumers record whatever path they were linked against, and the indirection that makes upgrades work is gone. The symptom appears months later when a rebuild moves the file.

Exporting everything is the default and the mistake

On ELF, every non-static function and variable in a shared library is exported by default. That means every internal helper, every implementation detail, every accidentally-non-static utility is in the dynamic symbol table, visible to the world and permanently part of the library's ABI in the eyes of anyone who finds it.

This costs four things and none of them are obvious from the source. The dynamic symbol table is larger, so the loader's hash lookups and relocation processing are slower — measurable on libraries with tens of thousands of exports. Calls *within* the library must go through the PLT, because the flat symbol scope means some other object might interpose a different definition, so the compiler cannot bind them directly. The optimizer must assume every exported function may be replaced, which blocks inlining and interprocedural analysis at the boundary. And every exported symbol is a name someone can depend on, which turns internal helpers into ABI you did not intend to promise.

-fvisibility=hidden inverts the default: nothing is exported unless annotated. The library author then marks the public surface explicitly, usually with a macro expanding to __attribute__((visibility("default"))). This is exactly the Windows model, where nothing is exported unless __declspec(dllexport) says so — which is why Windows developers find the advice obvious and ELF developers find it a discovery.

What hiding internal symbols buystarget
EffectExport everything (ELF default)Hidden by default
Dynamic symbol table sizetargetEvery non-static symbolOnly the public surface — often an order of magnitude smaller
Load timetypicalMore symbols to hash and relocateMeasurably faster for large libraries
Internal callstargetThrough the PLT, because interposition is possibleDirect — the compiler knows nothing can replace a hidden symbol
OptimizationtypicalNo inlining or IPA across the export boundaryFull optimization within the library
ABI surfaceEvery internal helper is a name someone can bind toExactly what you meant to publish
Symbol collisionstargetInternal names can collide with another library's and silently interposeHidden symbols cannot participate

The library is a program, nearly

typicalThat initializers run in dependency order is typical of ELF loaders and is only guaranteed within a single object, not across objects with no dependency relationship. C++ static initialization order across translation units is famously unspecified even within one library, which is why the function-local static idiom exists. macOS and Windows have their own ordering rules, and Windows additionally forbids most useful work inside DllMain, which is a constraint with no ELF equivalent.

A shared object is structurally very close to an executable: it has segments, relocations, a dynamic symbol table and initializers. The differences are that it has no entry point in the usual sense, it must be position-independent, and it can itself have dependencies that the loader resolves recursively.

It does have initialization: constructors of global objects, functions marked with the constructor attribute, and — on ELF — the DT_INIT and DT_INIT_ARRAY entries the loader runs after mapping. These execute before any of the library's functions are called, in an order determined by dependency relationships between objects, which is under-specified enough that relying on cross-library initialization order is a well-known way to produce a bug that appears when an unrelated dependency is added. The corresponding finalizers run at unload or exit, with the same ordering hazards.

A library can also be loaded explicitly rather than at start-up, with dlopen. That is the plugin mechanism, and it brings its own hazards: the loaded object joins the symbol scope, may bring its own copies of libraries the host already has, and its unload may or may not actually unload anything. Most production plugin systems eventually decide never to unload, because the alternative requires that no pointer into the object survives.

  • Must be position-independent — -fPIC — so its code pages can be shared and mapped anywhere. See [[relocations]].
  • Carries a soname that is its compatibility identity and the string consumers record.
  • Has initializers run by the loader after mapping and finalizers run at unload or exit, with cross-object ordering that is only partly specified.
  • Can be loaded at start-up via DT_NEEDED, or on demand via dlopen, with the same mechanics and different lifetime hazards.
  • Exports a dynamic symbol table that is its published surface, and imports one that is its requirement list.

Three platforms, three policies

The three platforms solve the same problem with materially different defaults, and the differences explain a lot of otherwise puzzling advice. ELF exports everything and has a flat symbol scope, so interposition is possible and internal names are public by default. Mach-O exports everything too, but uses a two-level namespace where every import records which library it expects, so accidental interposition largely cannot happen. PE exports nothing unless told, and resolves through a per-DLL import table.

The consequences cascade. -fvisibility=hidden is essential advice on ELF, useful on Mach-O and redundant on Windows. LD_PRELOAD works on ELF, has a heavily entitlement-restricted equivalent on macOS, and has no direct analogue on Windows. Symbol versioning is a glibc mechanism with no Mach-O or PE counterpart. And a library that dynamically links to another must, on Windows, have the exports declared at build time in an import library — which is why a Windows DLL that forgets dllexport produces a link error at build time rather than a surprise at load.

The portable conclusion is the one Windows arrives at by default: decide explicitly what your library exports, write it down, and enforce it. Every platform supports that; only one requires it.

Shared library policy on three platformstarget
AspectELF (`.so`)Mach-O (`.dylib`)PE (`.dll`)
Export defaulttargetEverything non-staticEverything non-staticNothing without __declspec(dllexport)
Compatibility identitytargetSoname recorded in the fileInstall name plus compatibility versionConvention in the file name, or a side-by-side manifest
Symbol namespacetargetFlat — first definition in scope winsTwo-level — each import names its libraryPer-DLL import table
InterpositiontargetLD_PRELOAD, by designDYLD_INSERT_LIBRARIES, entitlement-gatedImport table patching, not a supported mechanism
Per-symbol versioningimplementationglibc symbol versioning with version scriptsNone; availability is checked at build timeNone; side-by-side assemblies instead
Explicit loadingtargetdlopen / dlsymdlopen / dlsymLoadLibrary / GetProcAddress

How it works

The steps, in the order the compiler takes them.

  • The library is compiled position-independent and linked with -shared and an explicit -Wl,-soname,libfoo.so.N.
  • The linker writes a dynamic symbol table containing every symbol with default visibility, and records the soname and the library's own dependencies.
  • A consumer links against it and records the soname in a DT_NEEDED entry, with imports recorded in its own dynamic symbol table.
  • At load, the loader searches for a file matching the soname, maps it, resolves its dependencies recursively and adds its exports to the process symbol scope.
  • Relocations are applied so that the consumer's GOT entries point at the resolved definitions, and the library's initializers run.
  • Function calls into the library go through the PLT, and — unless internal symbols were hidden or -Bsymbolic was used — so do the library's calls to its own functions.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A library is built without a soname, so consumers record whatever path they linked against, and a later rebuild or relocation of the file breaks every one of them.
  • An incompatible change ships under the same soname, and every program using the library begins misbehaving after an ordinary package update.
  • A library exports tens of thousands of internal symbols, and process start-up is measurably slow with the time spent in the dynamic loader's symbol resolution.
  • Two unrelated libraries both export a common name — init, log, parse — and on ELF the first in scope silently satisfies both, so one library calls the other's function.
  • A symbol is hidden that something outside genuinely needed, and the failure is an undefined reference at link time or a null dlsym result at runtime, in a code path only some consumers use.
  • Cross-library static initialization order changes when an unrelated dependency is added, and a library uses another's global before it has been constructed.
  • A plugin is unloaded while a pointer into its code or data is still held, and the next call jumps into unmapped memory.

When it helps

  • Publishing a library that many programs will use, where the code sharing and the single-file upgrade path are the whole point.
  • Any plugin or extension architecture, which requires loading code into a running process.
  • Shipping a library whose internals you intend to keep changing: hiding everything but the published surface is what makes that possible.

When it hurts

  • Internal code with exactly one consumer, where the shared object adds load-time cost, blocks cross-boundary optimization, and provides no sharing benefit at all.
  • Deployment into environments you do not control, where the soname you need may resolve to a file you have never tested against.

What it costs

Every one of these is paid by something.

  • A shared library buys code sharing across processes and in-place upgrades, and costs load-time resolution, an optimization barrier at the boundary, and a published surface that constrains every future version.
  • Hidden visibility buys a small symbol table, faster loading, direct internal calls and full internal optimization, and costs an explicit annotation on every public entity plus a class of "the symbol is there and I cannot use it" confusion.
  • A soname buys coexisting incompatible versions and costs the discipline of actually bumping it, which requires knowing when a change is incompatible — a judgement [[abi-stability]] shows is easy to get wrong.
  • Symbol versioning buys incompatible change without a new soname, and costs a growing table of historical implementations plus a mechanism that exists on exactly one platform.

What else you could do

What a different compiler or language does instead, and when that is better.

  • A static archive, which gives up sharing and plugins in exchange for determinism and cross-boundary optimization. See [[static-linking]].
  • Bundling a private copy of the library with the application and finding it through RUNPATH, which is dynamic mechanics with application-controlled versioning — see [[symbol-resolution-order]].
  • A plain C interface with opaque handles and an explicit export list, which is what long-lived native SDKs converge on because it minimises the frozen surface.
  • An out-of-process component with an IPC interface, which replaces the symbol table with a message schema — vastly more robust to version skew and vastly more expensive per call.
  • Language-level modules loaded by a runtime — a Python package, a JVM jar — where versioning and isolation are the runtime's problem and multiple versions can genuinely coexist.

See it for yourself

The flag, dump or tool that shows you this directly.

  • The soname and dependencies: readelf -d lib.so shows SONAME and NEEDED; otool -D and otool -L are the macOS equivalents.
  • The published surface: nm -DC lib.so or readelf --dyn-syms lib.so — note this differs from nm alone, which includes symbols that are not exported.
  • Whether hiding worked: count nm -D lib.so | wc -l before and after -fvisibility=hidden. An order-of-magnitude drop is typical for a C++ library.
  • What the loader does with it: LD_DEBUG=libs,bindings,symbols ./app traces every search and binding; DYLD_PRINT_LIBRARIES=1 on macOS.
  • On Windows: dumpbin /exports lib.dll for the export table and dumpbin /dependents app.exe for the imports.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "The soname is documentation." It is a mechanism. It is recorded in consumers and is what the loader searches for, which is why bumping it lets two incompatible versions coexist.
  • "Hiding symbols is about encapsulation." Encapsulation is a side benefit. The measurable effects are a smaller symbol table, faster loading, direct internal calls and unblocked optimization.
  • "A .so is a .a that gets loaded at runtime." An archive is a container of object files with no code of its own. A shared object is a fully linked, position-independent image with its own dependencies, initializers and symbol tables.
  • "If the file is there, the library will load." The loader looks for the soname, not the file name. A file present under the wrong name is not found.

Misconceptions

The claim, and what is actually true.

Bumping the version number in the file name is enough.
Consumers record the soname. If the soname did not change, the loader will happily supply the new, incompatible file to old programs.
Only public API functions end up in the symbol table.
On ELF, every non-static symbol does, including internal helpers you never intended to publish. That is precisely what hidden visibility fixes.
Unloading a plugin with dlclose reliably frees it.
It may not unload at all, and if it does, any surviving pointer into it becomes a jump into unmapped memory. Most production plugin systems decide never to unload.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

A shared library is a fully compiled, relocatable image that many programs can use at once. It carries a name — the soname — that is its compatibility promise: as long as it does not change, any newer file with the same soname can replace the old one and every program picks it up. Change something incompatible and you have to change the soname, so old programs keep finding the old library.

practical

Build with -fPIC, always pass -Wl,-soname,libfoo.so.1, and build with -fvisibility=hidden plus an explicit export macro. Check the result with readelf -d for the soname and nm -DC for the exported surface — that list is your ABI, and if it contains things you did not mean to publish, fix it before anyone depends on them. Bump the soname when the exported surface changes incompatibly, not when the version number feels ready.

advanced

The design question underneath all of this is where to draw the line between what is fixed and what is free, and shared libraries are unusual in that the default draws it in the worst possible place. ELF's export-everything behavior means the boundary is defined by an implementation detail — whether someone wrote static — rather than by intent, which is how libraries acquire ABI surface nobody chose. Windows made the opposite default and the resulting discipline is visible in how much more explicit Windows library interfaces tend to be. The general principle worth taking is that a default which makes the safe choice require an annotation will produce a codebase full of unsafe choices, and that no amount of documentation compensates for it. The technical fix — hidden visibility with an export macro — is one flag and one attribute, and its adoption is nonetheless a decades-long project across the ecosystem.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

targetThe soname scheme, default-export visibility and the flat symbol namespace are ELF properties. Mach-O uses install names with compatibility versions and a two-level namespace; PE exports nothing by default and has no soname concept, versioning by file name or side-by-side assemblies instead. Advice that is essential on one platform is redundant or meaningless on another.
typicalThat hidden visibility measurably improves load time is typical of large C++ libraries with tens of thousands of otherwise-exported symbols, and negligible for a small C library with twenty. The direction is reliable; the magnitude is entirely a function of how many symbols were being exported that nobody needed.
implementationPer-symbol versioning with version scripts is a glibc and GNU ld mechanism. It does not exist on musl, macOS or Windows, each of which addresses the same problem differently — strict never-break policy, build-time availability checks, and side-by-side assemblies respectively.

If you were asked this in an interview

  • What is a soname and what breaks if a library is built without one?
  • Why is -fvisibility=hidden recommended for shared libraries, and what four things does it change?
  • Two libraries both export a function called init. What happens on ELF and what happens on macOS?

Connections

Domains that do not exist yet
  • Programming Languages & Runtime Internals — Plugin lifetimes: what it takes to unload code from a running process safely
    A shared object can be loaded at runtime and, in principle, unloaded; making that safe requires knowing that no pointer, no thread and no registered callback survives it. That reachability problem belongs to the runtime, and it is why most systems decide never to unload.