Why Dynamic Linking
Your executable needs puts, but its final address in libc can change between runs. How does a call compiled yesterday reach the right function today?
Linux x86-64 · ELF
From a dependency name to a loaded object
Follow the library-loading work first. Mapping, relocation and symbol binding are separate operations.
Control: Dynamic linker
Read the executable’s dependencies
- PT_DYNAMIC
- DT_NEEDED
- library file
- PT_LOAD mappings
- relocation
- initializers
Executable dependencycaptured ELF
- DT_NEEDED
- libc.so.6
- PT_INTERP
- /lib64/ld-linux-x86-64.so.2
- Current work
- program
Library statelibc.so.6
- File
- not searched yet
- PT_LOAD mappings
- not installed
- Load-time references
- not ready
- PLT policy
- lazy slots may remain unresolved
- Initializers
- not reached in this walkthrough
PT_DYNAMIC → DT_NEEDED
The executable records a library name, not a promise that every function is already bound. This captured binary records one dependency: libc.so.6.
Runnable example and captured evidence
The source compiles locally, outside the browser. The constructor prints before main. LD_DEBUG output varies by system and is not recorded as the diagram’s timeline.
// example.cpp
#include <cstdio>
int answer = 42;
char scratch[8192];
struct BeforeMain {
BeforeMain() { std::puts("constructor"); }
};
BeforeMain before_main;
int main() {
std::puts("main");
std::puts("again");
return 0;
}
# Build
g++ -O0 -fcf-protection=none -Wl,-z,relro,-z,lazy -fPIE -pie example.cpp -o example-pie
# Inspect dependencies / observe loader diagnostics
readelf -Wd example-pie
LD_DEBUG=libs ./example-pie
# Captured stdout
constructor
main
again
In Program Loading, we followed the handoff from the kernel to the loader, runtime and main. Here we stay with the dynamic linker: finding shared objects, relocating references and binding function calls. Then we build a shared library and use dlopen to request one after startup.
The examples target Linux x86-64 with glibc. The PLT/GOT figure reuses the compiled puts example from Loading. Dependency and plugin figures label their authored presets; they do not inspect your machine or execute native code in the browser.
How Dynamic Linking Works
The interpreter named by PT_INTERP reads DT_NEEDED entries, finds shared objects and maps their loadable segments. It processes relocations and dependency initialization before handing control to the executable. Some eligible function bindings can wait until their first call.
The tradeoffs:
| Aspect | Static Linking | Dynamic Linking |
|---|---|---|
| Binary size | Bundles required library code | Refers to compatible shared objects |
| Memory | Clean executable pages can be shared | Clean library pages can be shared across different executables |
| Dependencies | Bundled code; platform constraints remain | Compatible runtime libraries required |
| Startup work | No separate dynamic interpreter for a fully static binary | Maps dependencies and processes relocations |
| Updates | Rebuild or relink with the updated library | Updated library used by subsequent loads, subject to ABI compatibility |
| Deployment | Fewer separate library files | Ship or install compatible dependencies |
Position-Independent Code (PIC)
Shared libraries can receive different load addresses across processes and runs. Position-independent code (PIC) avoids tying their instructions to one fixed base. Relative addressing and indirection let the same clean code pages serve multiple mappings.
For a preemptible global on x86-64, the compiler can use a RIP-relative reference to a Global Offset Table (GOT) slot. The loader supplies the pointer in that slot. This is a schematic assembly example in NASM syntax; locally bound symbols can use simpler direct RIP-relative addressing:
; Load the runtime address of an interposable global through the GOT
mov rax, [rel global_var wrt ..got]
mov eax, [rax]
# Compile with PIC (required for shared libraries)
g++ -fPIC -c mylib.cpp -o mylib.o
# Link the PIC object into a shared library
g++ -shared -o libmylib.so mylib.o
Why -fPIC Matters
Use -fPIC when compiling shared-library objects. Depending on the
architecture and generated references, non-PIC input may be rejected or
require text relocations. Modifying code pages at load time undermines
clean-page sharing and can conflict with memory-protection policies. The exact
failure depends on the code and toolchain; omitting the flag does not
guarantee a link error.
GOT/PLT: Symbol Resolution
In the classic x86-64 path, a call to puts@plt enters the Procedure Linkage Table (PLT), whose indirect jump reads a GOT slot. Lazy binding initially sends that jump through the resolver; the loader then patches the slot. Other compiler options, including -fno-plt, can use a different call path.
Linux x86-64 · ELF
Trace a call through the PLT and GOT
The PLT supplies the jump. The GOT supplies its target. Follow what changes on the first call, then compare the next one.
A mapped library is not a resolved call
- call site
- puts@plt
- GOT slot
- PLT fallback
Call site and PLTinstruction bytes stay fixed
- Call
- call puts@plt
- PLT offset
- 0x1030
- Indirect jump
- jmp *GOT[puts]
- Fallback
- 0x1036 (puts@plt + 6)
GOT[puts]R_X86_64_JUMP_SLOT
- Slot offset
- 0x4018
- Stored target
- 0x1036 (PLT fallback)
- State
- unresolved
- Lookup now
- no
- Pointer write now
- no
Library mapped; slot still unresolved
libc is already mapped, but this puts slot still targets the executable’s PLT fallback. Lazy binding defers the symbol lookup.
Inspect the captured call and slot
The example calls puts in a constructor, then twice in main. The first lazy lookup happens before main. ELF offsets acquire a load bias at runtime; the browser does not inspect a live GOT.
g++ -O0 -fcf-protection=none -Wl,-z,relro,-z,lazy -fPIE -pie example.cpp -o example-pie
objdump -d -j .plt example-pie
readelf -Wr example-pie
puts@plt: 0x1030
puts GOT: 0x4018
initial on-disk slot: 0x1036
# Compare eager and lazy runs
LD_DEBUG=bindings ./example-pie
LD_BIND_NOW=1 LD_DEBUG=bindings ./example-pieLazy vs Eager Binding
For eligible PLT relocations, lazy binding defers lookup until first use. The captured example is explicitly linked with -z lazy; toolchain and distribution hardening defaults vary. Libraries are already mapped, and data references still require load-time relocation.
Eager binding (LD_BIND_NOW=1 or -Wl,-z,now) moves eligible PLT lookup work to loading. This can increase startup work but:
- Detects missing symbols immediately (before
main()runs) - Required for Full RELRO security hardening
- Avoids unpredictable latency spikes from first-call resolution
# Use the captured binary built with -Wl,-z,lazy
./example-pie
# Eager binding — all symbols resolved before main()
LD_BIND_NOW=1 ./example-pie
# Separate main.cpp/mylib example from the next section: Full RELRO
g++ main.cpp -L. -lmylib -Wl,-z,relro,-z,now \
-Wl,--enable-new-dtags,-rpath,'$ORIGIN' -o program
Creating Shared Libraries
Start with a small exported function and a caller. extern "C" gives this interface stable, unmangled symbol names; it does not by itself guarantee ABI compatibility for arbitrary C++ types.
extern "C" int library_value() { return 42; }
#include <cstdio>
extern "C" int library_value();
int main() {
std::printf("%d\n", library_value());
return 0;
}
Build and run these files in the same directory:
# 1. Compile with PIC
g++ -fPIC -c mylib.cpp -o mylib.o
# 2. Create shared library with SONAME
g++ -shared -Wl,-soname,libmylib.so.1 -o libmylib.so.1.2.0 mylib.o
# 3. Create symlinks (the linker and loader use different names)
ln -sf libmylib.so.1.2.0 libmylib.so.1 # SONAME link (loader uses this)
ln -sf libmylib.so.1 libmylib.so # Linker link (g++ -lmylib uses this)
# 4. Record a RUNPATH relative to the executable
g++ main.cpp -L. -lmylib \
-Wl,--enable-new-dtags,-rpath,'$ORIGIN' -o program
# 5. Run: prints 42
./program
The build-time linker finds libmylib.so; the executable records libmylib.so.1 from the library’s SONAME. Its RUNPATH finds that name beside the executable. If installing system-wide, install the real file and both symlinks in the destination, then run ldconfig. Creating links only in the build directory does not install them.
Library Dependencies
Shared libraries can depend on other libraries. The result is a dependency graph: multiple objects can require the same dependency. Explore a simplified nested view, then compare its direct edges with your own ELF files:
Linux x86-64 · ELF
Follow a program’s dependencies
Choose a program, expand a dependency and inspect the library name that the loader must find.
python3dependency relationships
Selected librarypreset details
- Name
- libpython3.11.so.1.0
- Path preset
- /usr/lib/libpython3.11.so.1.0
- Purpose
- Python runtime
- File size preset
- 5.2 MB
- Across these presets
- shown in one program
A SONAME does not reveal the backing file’s full version. Inspect the installed symlink to find it.
6 distinct libraries · 9.0 MB of preset file sizes
Dependencies can be indirect. Different programs can map the same library’s clean code pages, while writable private data remains process-specific. The file-size sum is not a process memory measurement.
Inspect dependencies on your own machine
Use readelf on each dependency to reconstruct its direct edges. ldd lists resolved dependencies; it does not print this diagram’s nested layout.
# Flat dependency listing; actual names depend on this build
ldd /usr/bin/python3
# Direct DT_NEEDED entries
readelf -d /usr/bin/python3 | grep NEEDED
# Inspect this installed library, if this path exists
readlink -f /usr/lib/libpython3.11.so.1.0
readelf -d /usr/lib/libpython3.11.so.1.0 | grep SONAME# Show direct and transitive dependencies
ldd /usr/bin/python3
# Show only direct dependencies (no recursion)
readelf -d /usr/bin/python3 | grep NEEDED
# Find which package provides a library
dpkg -S libssl.so.3 # Debian/Ubuntu
rpm -qf /usr/lib64/libssl.so.3 # RHEL/Fedora
SONAME Versioning
Shared libraries use a three-level naming convention to handle backward compatibility:
libfoo.so.2.1.0
│ │ │ └── Patch version (bug fixes, no API changes)
│ │ └──── Minor version (new features, backward compatible)
│ └────── Major version (SONAME — breaking changes)
└────────────── Library name
The SONAME (libfoo.so.2) names an ABI family. The link editor records it in the consumer’s DT_NEEDED entry. A compatible replacement can serve that name after the installed symlink or loader cache selects it; merely placing a newer file beside the old one does not select it automatically. Major/minor/patch filenames are a convention, not an ELF-enforced compatibility rule.
An incompatible ABI should receive a new SONAME, such as libfoo.so.3. Existing consumers can continue using version 2; consumers adopting version 3 need to be rebuilt or relinked and may require source changes.
RPATH, RUNPATH, and LD_LIBRARY_PATH
For a dependency name without a slash, glibc searches in this order:
- DT_RPATH, when present and DT_RUNPATH is absent
- LD_LIBRARY_PATH (environment variable)
- DT_RUNPATH, for the requesting object’s direct dependencies
- /etc/ld.so.cache (ldconfig cache)
- /lib, /usr/lib (default system paths)
# Explicitly emit old RPATH tags
g++ main.cpp -L. -lmylib \
-Wl,--disable-new-dtags,-rpath,/opt/mylibs -o program
# Explicitly emit RUNPATH, relative to the executable
g++ main.cpp -L. -lmylib \
-Wl,--enable-new-dtags,-rpath,'$ORIGIN/../lib' -o program
# Check RPATH/RUNPATH in a binary
readelf -d program | grep -E 'RPATH|RUNPATH'
# Override at runtime
LD_LIBRARY_PATH=/custom/lib ./program
RUNPATH does not propagate to indirect dependencies: each dependent object needs an applicable search path of its own. Old RPATH has broader dependency-tree behavior. A name containing a slash is loaded as a path instead of going through this name search. System defaults and cache choices vary by architecture and configuration.
RPATH Security
RPATH is searched before LD_LIBRARY_PATH, which means a binary with RPATH can take precedence over the user’s library preferences. In secure-execution mode, including many setuid cases, LD_LIBRARY_PATH is ignored and LD_PRELOAD is heavily restricted. Do not rely on ordinary environment overrides there.
Runtime Loading: dlopen
Startup dependencies are known from DT_NEEDED. The dlopen API lets application code request an additional shared object later. A successful call returns an opaque handle after the required loading and initialization; dlsym then finds an export in that handle’s search scope.
Linux x86-64 · ELF
A handle, a symbol, a function call
Follow the plugin host’s state through a successful call, a missing file and a missing export.
Choose a plugin; no handle exists yet
- dlopen
- handle
- dlsym + dlerror
- call
- dlclose
Host stateowned reference
- handle
- nullptr / released
- Requested symbol
- format_output
- func
- nullptr / unavailable
- Loader error
- none recorded
- Output
- no call output yet
Plugin mappinglibformat_json.so
- Mapping
- absent in this scenario
- Host references
- 0
- Lookup scope
- this object and its dependencies
- Binding flags
- RTLD_NOW | RTLD_LOCAL
Closing a handle releases a reference. Other owners can keep a real library mapped.
No owned plugin reference
This host looks up one callable export. The source below clears and checks the error around that individual lookup.
void* handle = dlopen(path, RTLD_NOW | RTLD_LOCAL);if (!handle) { report(dlerror()); return; }dlerror(); // clear the previous errorauto func = (format_fn)dlsym(handle, symbol);const char* error = dlerror();if (error || !func) { report(error); dlclose(handle); return; }func(data);dlclose(handle); // release this referencehandle = nullptr; func = nullptr;Highlighted line is active
Plugin catalog and API references
Each lookup needs its own clear, lookup and error check. The complete C++ example below implements this for every required export.
dlopen and dlclose reference · dlsym and error handling
# Illustrative plugin: libformat_json.so
version: 1.2.0; file size preset: 45 KB
exports: format_output, plugin_version, plugin_name
# Illustrative plugin: libformat_xml.so
version: 2.0.1; file size preset: 82 KB
exports: format_output, plugin_version, validate_schema
# Illustrative plugin: libformat_csv.so
version: 0.9.0; file size preset: 23 KB
exports: format_output, plugin_versionComplete Plugin Pattern with Error Handling
#include <dlfcn.h>
#include <cstdio>
// Plugin interface (shared header)
typedef int (*init_fn)(void);
typedef const char* (*name_fn)(void);
typedef void (*process_fn)(const char* data);
struct Plugin {
void* handle;
init_fn init;
name_fn name;
process_fn process;
};
template <typename Function>
bool load_symbol(void* handle, const char* name, Function* result) {
dlerror(); // Clear before EACH lookup.
void* address = dlsym(handle, name);
const char* error = dlerror(); // Check this lookup immediately.
if (error || !address) {
fprintf(stderr, "%s: %s\n", name,
error ? error : "null callable address");
return false;
}
*result = reinterpret_cast<Function>(address); // POSIX dlsym interface
return true;
}
Plugin load_plugin(const char* path) {
Plugin p = {};
p.handle = dlopen(path, RTLD_NOW | RTLD_LOCAL);
if (!p.handle) {
fprintf(stderr, "dlopen: %s\n", dlerror());
return p;
}
if (!load_symbol(p.handle, "plugin_init", &p.init) ||
!load_symbol(p.handle, "plugin_name", &p.name) ||
!load_symbol(p.handle, "plugin_process", &p.process)) {
dlclose(p.handle);
return {}; // Clear every pointer on failure.
}
return p;
}
int main(int argc, char** argv) {
if (argc != 2) return 2;
Plugin p = load_plugin(argv[1]);
if (!p.handle) return 1;
if (p.init() != 0) {
dlclose(p.handle);
return 1;
}
std::printf("%s\n", p.name());
p.process("hello");
dlclose(p.handle);
return 0; // Do not use p's function pointers after releasing the library.
}
Build a small plugin that implements the same interface:
#include <cstdio>
extern "C" int plugin_init() { return 0; }
extern "C" const char* plugin_name() { return "example plugin"; }
extern "C" void plugin_process(const char* data) { std::puts(data); }
g++ -fPIC -shared plugin.cpp -o libplugin.so
g++ plugin_host.cpp -ldl -o plugin_host
./plugin_host ./libplugin.so
# example plugin
# hello
Check each dlsym before making another lookup. A later successful lookup can clear an earlier diagnostic, which makes one error check after a group of lookups unreliable. dlsym can also return a null-valued symbol without an error; this interface rejects a null pointer because it must call the result. dlclose releases a reference and does not guarantee immediate unmapping when other references or loader flags keep an object alive.
LD_PRELOAD: Function Interposition
LD_PRELOAD adds objects early in the symbol search order. It can interpose eligible, dynamically looked-up symbols. Hidden symbols, locally bound references, inlined calls and other binding choices can bypass interposition. Start with a small puts wrapper instead of an allocator hook, whose own dependencies can recursively allocate:
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
int puts(const char* text) {
dlerror();
int (*next_puts)(const char*) = dlsym(RTLD_NEXT, "puts");
const char* error = dlerror();
if (error || !next_puts) return EOF;
next_puts("[preload] intercepted puts");
return next_puts(text);
}
gcc -fPIC -shared puts_wrapper.c -ldl -o puts_wrapper.so
# The plugin example calls puts in plugin_process.
LD_PRELOAD=./puts_wrapper.so ./plugin_host ./libplugin.so
Practical uses: memory leak detectors, performance profiling, testing (mock network calls), hardware abstraction layers.
Debugging Dynamic Linking
LD_DEBUG: The Dynamic Linker’s Verbose Mode
# Show library search paths
LD_DEBUG=libs ./program
# Show every symbol lookup
LD_DEBUG=symbols ./program
# Show file operations (opens, stats)
LD_DEBUG=files ./program
# Show everything
LD_DEBUG=all ./program 2>debug.log
Common Debugging Tools
# List all dependencies (recursive)
ldd ./program
# Show dynamic section (NEEDED, SONAME, RPATH)
readelf -d ./program
# List exported symbols
nm -D libmylib.so
# List undefined symbols (what it needs)
nm -D --undefined-only ./program
# Trace library calls at runtime
ltrace ./program
# Trace system calls (open() shows which files are accessed)
strace -e openat ./program 2>&1 | grep '\.so'
Security Considerations
ASLR (Address Space Layout Randomization)
The kernel and loader can vary mapping bases between runs. PIC supports relocating shared-library code without rewriting its instructions; it is not itself the randomization mechanism. Address disclosure can weaken ASLR. Program Loading covers executable PIE and address-space layout in more detail.
RELRO (Relocation Read-Only)
Partial RELRO protects the covered relocation region after startup, while eligible lazy-binding slots remain writable. Whether it is enabled by default depends on the toolchain and distribution.
Full RELRO (-Wl,-z,relro,-z,now) combines eager PLT binding with a RELRO layout and read-only protection for the covered GOT pages. It blocks ordinary writes to those protected slots after relocation; it does not prevent every memory-corruption attack. Setting LD_BIND_NOW alone does not add a missing RELRO layout.
# Check RELRO status
# A GNU_RELRO segment alone does not prove full RELRO.
readelf -l program | grep GNU_RELRO
readelf -d program | grep -E 'BIND_NOW|FLAGS'
checksec --file=program # if checksec is installed
Further Reading
- How To Write Shared Libraries - Ulrich Drepper’s definitive guide to shared library design
- ELF Specification - The Executable and Linkable Format standard
- Dynamic Linker Man Page - ld.so/ld-linux.so reference with search path details
- dlopen and dlclose - Loading flags, reference ownership and library lifetime
- dlsym - Symbol lookup and per-call error handling
- NASM ELF relocations - GOT and PLT relocation syntax
- Anatomy of a Program in Memory - Visual guide to process memory layout
Related concepts
How Linux loads C++ programs: captured ELF segments, runtime mappings, startup handoffs, lazy binding and practical loader debugging.
How C++ object files are linked into executables. Learn symbol resolution, static vs dynamic linking, and linker optimization.
Deep dive into C++ memory allocation — stack frame internals, heap allocator mechanics, fragmentation, performance benchmarks, custom allocators, RAII, and debugging with AddressSanitizer and Valgrind.
Complete guide to C++ symbol resolution — how linkers match references to definitions, name mangling, strong vs weak symbols, ODR, template instantiation, linking order, and debugging undefined reference errors.
See how tail calls reuse stack frames, when compilers can eliminate them, and how CPython 3.14 applies tail calls to C opcode dispatch.
Deep dive into Python's asyncio library, understanding event loops, coroutines, tasks, and async/await patterns with interactive visualizations.
