Linkingimplementation

Dynamic Linking

Leave the library out and bind to it at load time. One copy in memory serves every process, and a security fix ships as one file — paid for in load-time resolution, version skew, and `GLIBC_2.34 not found`.

The question

What actually happens at load time when my program uses a shared library, and why do I get GLIBC_2.34 not found?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

The program as an incomplete image plus a manifest of what it still needs: a list of required shared objects, a table of dynamic symbols it imports and exports, and the relocations that will patch its indirection tables once bindings are known. It is deliberately unfinished, because finishing it at build time would forfeit exactly the properties — sharing and independent upgrade — the technique exists for.

What this phase may assume or do

A dynamic binding is valid only if every required shared object is found, every needed symbol is defined somewhere in the loaded scope, and — where symbol versioning is in use — at a version tag the providing object actually defines. The loader may satisfy a reference from any object in the search scope subject to the resolution order, which means the binding is a property of the runtime environment rather than of the build. What the loader cannot check is anything beyond the name and version tag: layouts, conventions and semantics are unverified, so a successful bind is not evidence of compatibility.

Key points

  • A dynamic executable is deliberately incomplete: it records which libraries it needs, which symbols it imports, and which relocations remain for the loader.
  • One physical copy of a shared library's code backs every process using it; its writable data is copy-on-write and becomes private.
  • The decisive advantage is patchability: one file replacement fixes every program on the system at next start.
  • Lazy binding resolves functions on first call; data symbols are always resolved eagerly because there is no stub to intercept them.
  • GLIBC_2.34 not found is a version-tag mismatch, not a missing library: the file is present and does not define the symbol at the demanded version.
  • The largest hidden cost is that the optimizer cannot see across a shared-library boundary, so no inlining or constant propagation crosses it.

What is deferred, and to when

A dynamically linked executable is produced by a linker that did most of its job and deliberately stopped short. It resolved everything internal, laid out its own sections, and for each external symbol recorded an import rather than a copy. It also recorded which shared objects to look in — the DT_NEEDED entries — and which interpreter to use, which is the dynamic loader itself.

At execution the kernel maps the executable, sees the interpreter path, and starts the dynamic loader instead of the program. The loader reads the needed list, finds and maps each object recursively, builds a search scope, resolves the symbols, applies the relocations that fill the GOT, runs each object's initializers, and only then jumps to the program's entry point. [[the-loader]] covers that sequence in detail; what matters here is that a substantial amount of linking is happening every time the program starts.

Lazy binding reduces the cost by resolving each function on first call rather than at load, through the PLT mechanism [[relocations]] describes. Data symbols cannot be lazy — a data reference has no stub to intercept — so they are always resolved eagerly.

Where each linking job happens, static versus dynamictypical
  1. Compilebuild time
    Object files with undefined external symbols.
    Code and relocation records. Identical in both models.
  2. Static linkbuild time
    A complete image with every symbol resolved and every relocation applied.
    Everything. Nothing remains to be decided.
    Any ability to substitute an implementation later.
  3. Dynamic linkbuild time
    An incomplete image plus a needed-library list, a dynamic symbol table and load-time relocations.
    A manifest of what is still missing and where to look.
    Self-containment, and the optimizer's ability to see across the boundary.
  4. Loadload time
    Mapped objects with a search scope built and data relocations applied.
    Real addresses, and a decision about which definition each import means.
  5. First callrun time
    A PLT slot resolved on demand and cached in the GOT.
    The function's address, paid for once per function actually called.

Read it asCompare the second and third rows: the same work is either finished at build time or converted into a manifest. Everything dynamic linking buys and costs follows from that deferral — the sharing, the patchability, the start-up cost, and the fact that the binding is decided by the machine the program runs on rather than the machine it was built on.

What the deferral buys

The memory saving is the classic argument and it is real, though narrower than usually stated. The .text of a shared library is mapped read-only and position-independent, so one physical copy backs every process using it. On a system running a hundred processes against the same C library and the same UI toolkit, that is a large amount of physical memory. It does not apply to the writable data, which is copy-on-write and becomes private per process as it is touched.

The upgrade property is the argument that actually decides distributions. A vulnerability in a library is fixed by replacing one file; every program on the system picks it up at next start, with no rebuild, no redeploy and no inventory of who uses what. There is no equivalent for a statically linked fleet, and this is why every general-purpose OS distribution links dynamically.

And it is the enabling mechanism for plugins. dlopen loads a shared object into a running process and dlsym finds a symbol in it, which is how every editor extension, database driver, codec and language-runtime native module works. Static linking cannot provide this at all.

  • One physical copy of a library's code serves every process using it; writable data is still per-process.
  • A library fix is one file replacement, applied to everything at next start, with no rebuild of any consumer.
  • Plugins and dlopen are possible, which is an architectural capability rather than an optimization.
  • Binaries are smaller to distribute, which matters when the libraries are already present.
  • The system integrator, rather than each application author, decides which implementation is used — for better and for worse.

Version skew, and reading `GLIBC_2.34 not found`

implementationSymbol versioning as described is a glibc and GNU ld mechanism on ELF. musl does not use it at all and takes a strict never-break-ABI position instead. macOS solves the equivalent problem with deployment targets and availability annotations checked at compile time, and Windows uses side-by-side assemblies and separate DLL names. The failure mode is glibc-specific even though every platform faces the underlying problem.

The cost is that the program's behavior now depends on files it does not contain. Two programs on the same machine may need incompatible versions of a library. A machine may have an older version than the program was built against. And a program built on a newer system may reference symbols the older system has never heard of — which is precisely the glibc error.

That message is worth decoding carefully because it is so often misread as a missing library. glibc uses symbol versioning: each exported symbol carries a version tag, and a binary records not just memcpy but memcpy@GLIBC_2.14. This exists so that one libc.so.6 can host several historical implementations of a function whose behavior changed, and old binaries keep getting the old one. When you build on a newer distribution, the linker binds to whatever tags that glibc offers; running on an older one, the loader finds libc.so.6 present and correct and simply does not have a definition at the requested tag. It refuses to bind, because binding to a different version would be exactly the silent-corruption failure [[abi]] describes.

The remedies all amount to building against the oldest glibc you intend to support: build in a container matching the oldest target, use a purpose-built old-toolchain image, or link statically against musl instead. There is no flag that makes a newer glibc emit older version tags for everything, because the tags encode genuine behavioral differences.

Version tags, and what the error is really saying
1$ objdump -T app | grep GLIBC_2.3
20000000000000000 DF *UND* 0000000000000000 GLIBC_2.34 pthread_create
30000000000000000 DF *UND* 0000000000000000 GLIBC_2.2.5 printf
4 ^^^^^^^^^^ the version tag
5 the binary demands
6
7$ ./app # on an older system
8./app: /lib/x86_64-linux-gnu/libc.so.6: version 'GLIBC_2.34' not found
9 ^ the library is present and is the right file. It simply does not
10 define pthread_create at that version tag, and binding it to an
11 older definition would be an ABI guess the loader refuses to make.
12
13$ readelf -V app | head # the full version requirement table

The pthread_create@GLIBC_2.34 entry is a real historical example: glibc 2.34 merged libpthread into libc, so the symbol acquired a new version. A binary built after that merge asks for the new tag; a system from before it has the function but not the tag. Nothing is missing — the two sides simply disagree about which contract is in force, and the loader treats that as fatal rather than guessing.

What it costs at run time

Load time grows with the number of objects and symbols. Each shared object must be found — a filesystem search through several directories — mapped, and relocated, and its symbols resolved into the scope. For a program with a handful of dependencies this is microseconds. For a large desktop application with a hundred shared objects it is a visible fraction of start-up, which is why prelinking, RELR relocation compression and eager binding with -z now all exist as attempts to reduce it.

Steady-state cost is one indirect jump per cross-module call through the PLT, plus a GOT load per cross-module data access. Individually negligible; collectively measurable in code that crosses library boundaries in a hot loop, which is one reason numerical libraries are often statically linked into the programs that pound on them.

The larger performance cost is invisible in the disassembly: the optimizer cannot see across the boundary. A function in a shared library cannot be inlined into a caller in the executable, its constants cannot be propagated, and its behavior cannot inform any transformation. [[link-time-optimization]] operates within a link unit, and a shared library is a different link unit by construction. This is a real ceiling, and it is why the fastest builds of performance-sensitive software tend to be static.

Where the runtime cost of dynamic linking actually istypical
CostMagnitudeWhat reduces it
Finding and mapping objectstypicalMicroseconds each, plus filesystem lookups through the search pathFewer dependencies; a cached loader; absolute RUNPATH entries
Load-time relocationstypicalProportional to the number of pointers in data; thousands in a large PIERELR compression, fewer absolute pointers, -z now moves rather than removes it
Symbol resolutiontypicalProportional to symbols and to scope size; lazy binding defers most of itHidden visibility to shrink the dynamic symbol table; -Bsymbolic for internal calls
PLT indirection per calltargetOne indirect jump; predicted well after the first call-Bsymbolic or static linking; usually not worth acting on
No cross-boundary inliningtypicalUnbounded in principle — this is the one that can matter mostStatic linking, or moving the hot code inside the boundary

How it works

The steps, in the order the compiler takes them.

  • The linker records DT_NEEDED entries for each required shared object, an interpreter path, a dynamic symbol table of imports and exports, and the relocations the loader must apply.
  • At exec, the kernel maps the executable and transfers control to the named interpreter — the dynamic loader — rather than to the program.
  • The loader maps each needed object, recursively resolving their own dependencies, and builds a search scope in a defined order.
  • It applies relative relocations to fix internal pointers for the actual load address, and resolves data symbols eagerly by searching the scope.
  • Function symbols are either resolved eagerly (-z now, full RELRO) or left for the PLT to resolve lazily on first call.
  • Each object's initializers run in dependency order, and control passes to the executable's entry point.
  • During execution, dlopen may add further objects to the process and extend the search scope, with dlsym resolving symbols within it.

How it breaks

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

  • The program fails to start with libfoo.so.6: cannot open shared object file, because the library is absent from every directory in the search path.
  • The program fails to start with version GLIBC_2.34 not found, because it was built against a newer glibc than the system provides — the library is present and the version tag is not.
  • Two dependencies require incompatible versions of a third and only one can be loaded, so one of them misbehaves in a way that only appears when both are used in the same process.
  • The program picks up a different implementation than intended because another object earlier in the search scope defines the same symbol — the interposition hazard covered in [[symbol-resolution-order]].
  • A program that starts fine in development fails in production because LD_LIBRARY_PATH was set in one environment and not the other, making the binding depend on a variable nobody records.
  • A lazily-bound function resolves on first call inside a latency-critical path, producing an occasional outlier that never reproduces under warm-up.
  • A plugin loaded with dlopen brings its own copy of a library into the process and two versions of the same code run side by side, with objects created by one and freed by the other.

When it helps

  • System-wide deployment where many processes use the same libraries: the memory sharing and the single-file patching are both real and both large.
  • Any plugin architecture, which requires loading code at runtime and has no static equivalent.
  • Distributing an application that should follow the platform's own versions of system libraries rather than freezing its own.

When it hurts

  • Deploying to environments you do not control, where the required versions may be absent, older or different — which is exactly what containers were invented to stop happening.
  • Performance-critical code that crosses the boundary frequently, where the loss of cross-boundary inlining is a real ceiling rather than a rounding error.

What it costs

Every one of these is paid by something.

  • Dynamic linking buys shared code pages and one-file security fixes, and costs load-time resolution, a permanent version-skew hazard, and a binding that is decided by the deployment machine rather than the build.
  • Lazy binding buys start-up time proportional to what is called, and costs a writable GOT for the lifetime of the process plus unpredictable first-call latency.
  • A flat symbol scope buys interposition — LD_PRELOAD, malloc replacement, profiling shims — and costs the possibility that an unrelated library silently supplies a definition you did not intend.
  • Deferring the link buys the ability to substitute an implementation and costs every cross-boundary optimization: no inlining, no constant propagation, no whole-program analysis across the line.

What else you could do

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

  • Static linking, which resolves everything at build time and forfeits sharing, patchability and plugins in exchange for determinism and start-up speed. See [[static-linking]].
  • Bundling the shared objects with the application and using RUNPATH to find them: dynamic mechanics with application-controlled versions, which is what most desktop applications and language runtimes do.
  • Containers, which freeze the whole filesystem so the dynamic link resolves to exactly the files that were tested — the version-skew problem solved by shipping the environment rather than by changing the model.
  • Out-of-process boundaries with IPC, which replace a shared layout and symbol table with a message schema. Far more expensive per call and far more robust to version skew.
  • Runtimes that link by structured identity at load — the JVM, .NET, WebAssembly component imports — where a version mismatch is a typed, checkable error rather than a symbol that binds and misbehaves.

See it for yourself

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

  • What a binary needs: ldd app resolves the whole tree; readelf -d app | grep NEEDED shows just its direct requirements without executing anything.
  • Which version tags it demands: objdump -T app shows imported symbols with their version tags; readelf -V app prints the full version requirement table — this is what diagnoses the glibc error.
  • What actually got bound: LD_DEBUG=libs,bindings ./app prints every library search and every symbol binding as it happens. It is verbose and it is definitive.
  • Start-up cost: LD_DEBUG=statistics ./app reports time spent in relocation processing, and perf record on a short-lived process shows how much of it was the loader.
  • On macOS: otool -L for dependencies, DYLD_PRINT_LIBRARIES=1 and DYLD_PRINT_BINDINGS=1 for the trace. On Windows: dumpbin /dependents and Process Monitor.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "GLIBC_2.34 not found means glibc is missing." glibc is present and is the correct file. It does not define that symbol at that version tag, and the loader refuses to guess.
  • "Dynamic linking saves disk space." Its main saving is physical memory across processes. The disk saving is real and secondary, and disappears entirely if each application bundles its own copies.
  • "The library my program uses is decided at build time." The library is *named* at build time. Which file provides it, and which definition wins, is decided by the loader on the machine that runs it.
  • "Lazy binding is free." It defers cost rather than removing it, keeps the GOT writable, and moves the first call's resolution into whatever code path happens to reach it first.

Misconceptions

The claim, and what is actually true.

Dynamic linking happens once when the program is installed.
It happens at every start, and lazily during execution. The binding is a property of each run, not of the installation.
If ldd shows the library, the program will run.
ldd shows that a file with the right soname was found. Whether it defines every needed symbol at every needed version tag is a separate question, and the version tag is the usual failure.
Shared libraries save memory for the data too.
Only the read-only code and constants are genuinely shared. Writable data is copy-on-write and becomes private per process as it is written.

Go deeper

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

overview

A dynamically linked program is missing the libraries it uses and carries a list of what to look for. When it starts, the loader finds those files, maps them, and connects every reference. That way one copy of a library serves every program on the machine, and fixing a bug in it fixes every program at once — but the program now depends on files it does not carry, and if they are the wrong version it will not start.

practical

ldd and readelf -d tell you what a binary needs; objdump -T and readelf -V tell you at which version tags. When something fails to start on one machine and not another, LD_DEBUG=libs,bindings will show you exactly where it looked and what it bound. For GLIBC_x.y not found, the fix is to build against the oldest glibc you support — usually in a container image chosen for that — because there is no flag that emits older version tags.

advanced

The deep property is that dynamic linking makes the program's meaning depend on its environment, and every operational difficulty follows from that one fact. The same binary can bind to different implementations on different machines, or on the same machine after an unrelated package update, which converts a class of bugs from "reproducible from the artifact" to "reproducible only in the environment". Every mitigation the industry has built — sonames, symbol versioning, two-level namespaces, RPATH, containers, static linking — is a way of narrowing how much the environment is allowed to decide. Seen that way, containers are not an alternative to dynamic linking but a way of making it deterministic again: freeze the whole set of files the loader can see, and the deferred decision becomes a decision made at image build time after all.

How much this depends on

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

implementationSymbol versioning, LD_DEBUG and the GLIBC_x.y not found message are glibc and GNU ld specifics. musl does not version symbols and takes a strict compatibility position instead; macOS uses deployment targets checked at build time and a two-level namespace; Windows uses side-by-side assemblies. The underlying skew problem is universal and every mitigation named here is not.
typicalThat lazy binding is the default is typical of ELF platforms, though hardened distributions increasingly ship full RELRO with eager binding by default. Whether load time is significant depends entirely on dependency count: a handful of libraries is microseconds, a hundred is visible. Measure with LD_DEBUG=statistics rather than assuming either way.
targetThe flat symbol scope that makes LD_PRELOAD interposition possible is an ELF property. Mach-O's two-level namespace records the expected library with each import, so accidental interposition largely cannot occur and deliberate interposition requires entitlements. PE resolves through a per-DLL import table with no equivalent global scope.

If you were asked this in an interview

  • Walk through what happens between exec and the first instruction of main for a dynamically linked program.
  • Explain version GLIBC_2.34 not found precisely — what is present, what is missing, and why the loader refuses.
  • What can a static link optimize that a dynamic link cannot, and why?

Connections

Domains that do not exist yet
  • Programming Languages & Runtime Internals — Loading native extension modules into a managed runtime
    Python extension modules, Node native addons and JNI libraries are all dlopen plus a symbol lookup, executed by a runtime that then has to survive whatever that code does to the process. The loader mechanics are here; the runtime's isolation and lifecycle handling are owned there.