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.
What is in a .so, what does the soname mean, and why is -fvisibility=hidden recommended?
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.
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=hiddenplus 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
@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.
1/usr/lib/libssl.so -> libssl.so.3 # linker name: for -lssl at build time2/usr/lib/libssl.so.3 -> libssl.so.3.0.13 # soname: what programs record3/usr/lib/libssl.so.3.0.13 # real name: the actual file4 5$ readelf -d libssl.so.3.0.13 | grep SONAME6 0x000000000000000e (SONAME) Library soname: [libssl.so.3]7 ^ baked into the library,8 copied into every consumer9 10$ readelf -d app | grep NEEDED11 0x0000000000000001 (NEEDED) Shared library: [libssl.so.3]12 ^ the loader searches for THIS,13 never for the full versionBuilding 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.
| Effect | Export everything (ELF default) | Hidden by default |
|---|---|---|
| Dynamic symbol table sizetarget | Every non-static symbol | Only the public surface — often an order of magnitude smaller |
| Load timetypical | More symbols to hash and relocate | Measurably faster for large libraries |
| Internal callstarget | Through the PLT, because interposition is possible | Direct — the compiler knows nothing can replace a hidden symbol |
| Optimizationtypical | No inlining or IPA across the export boundary | Full optimization within the library |
| ABI surface | Every internal helper is a name someone can bind to | Exactly what you meant to publish |
| Symbol collisionstarget | Internal names can collide with another library's and silently interpose | Hidden symbols cannot participate |
The library is a program, nearly
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 viadlopen, 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.
| Aspect | ELF (`.so`) | Mach-O (`.dylib`) | PE (`.dll`) |
|---|---|---|---|
| Export defaulttarget | Everything non-static | Everything non-static | Nothing without __declspec(dllexport) |
| Compatibility identitytarget | Soname recorded in the file | Install name plus compatibility version | Convention in the file name, or a side-by-side manifest |
| Symbol namespacetarget | Flat — first definition in scope wins | Two-level — each import names its library | Per-DLL import table |
| Interpositiontarget | LD_PRELOAD, by design | DYLD_INSERT_LIBRARIES, entitlement-gated | Import table patching, not a supported mechanism |
| Per-symbol versioningimplementation | glibc symbol versioning with version scripts | None; availability is checked at build time | None; side-by-side assemblies instead |
| Explicit loadingtarget | dlopen / dlsym | dlopen / dlsym | LoadLibrary / GetProcAddress |
How it works
The steps, in the order the compiler takes them.
- The library is compiled position-independent and linked with
-sharedand 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_NEEDEDentry, 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
-Bsymbolicwas 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
dlsymresult 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.soshowsSONAMEandNEEDED;otool -Dandotool -Lare the macOS equivalents. - The published surface:
nm -DC lib.soorreadelf --dyn-syms lib.so— note this differs fromnmalone, which includes symbols that are not exported. - Whether hiding worked: count
nm -D lib.so | wc -lbefore 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 ./apptraces every search and binding;DYLD_PRINT_LIBRARIES=1on macOS. - On Windows:
dumpbin /exports lib.dllfor the export table anddumpbin /dependents app.exefor 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
.sois a.athat 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.
dlclose reliably frees it.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.
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=hiddenrecommended 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
- Programming Languages & Runtime Internals — Plugin lifetimes: what it takes to unload code from a running process safelyA 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.