Skip to main content

C++ Dynamic Linking at Runtime

Follow Linux shared libraries from dependency lookup to PLT/GOT binding, then build a versioned library, load a plugin and debug runtime linking failures.

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.

Step 1 / 5

Control: Dynamic linker

Read the executable’s dependencies

  1. PT_DYNAMIC
  2. DT_NEEDED
  3. library file
  4. PT_LOAD mappings
  5. relocation
  6. 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
// 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 and inspect dependencies

# 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
# Captured stdout
constructor
main
again
DT_NEEDED and the runnable example are captured. The loading order and object fields are a source-backed teaching model; library addresses and timing are not measured here.

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:

AspectStatic LinkingDynamic Linking
Binary sizeBundles required library codeRefers to compatible shared objects
MemoryClean executable pages can be sharedClean library pages can be shared across different executables
DependenciesBundled code; platform constraints remainCompatible runtime libraries required
Startup workNo separate dynamic interpreter for a fully static binaryMaps dependencies and processes relocations
UpdatesRebuild or relink with the updated libraryUpdated library used by subsequent loads, subject to ABI compatibility
DeploymentFewer separate library filesShip 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:

nasm
; Load the runtime address of an interposable global through the GOT
mov rax, [rel global_var wrt ..got]
mov eax, [rax]
bash
# 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.

Step 1 / 5

A mapped library is not a resolved call

  1. call site
  2. puts@plt
  3. GOT slot
  4. 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.

Inspect the captured call and slot
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

Captured slot values
puts@plt: 0x1030
puts GOT: 0x4018
initial on-disk slot: 0x1036

Compare eager and lazy runs
# Compare eager and lazy runs
LD_DEBUG=bindings ./example-pie
LD_BIND_NOW=1 LD_DEBUG=bindings ./example-pie
PLT/GOT offsets come from the same compiled Linux x86-64 example as Loading. Control-flow steps are a teaching model; libc:puts is a symbolic target, not a measured runtime address. The capture uses a classic PLT, with CET code generation disabled.

Lazy 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
bash
# 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.

mylib.cpp
extern "C" int library_value() { return 42; }
main.cpp
#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:

bash
# 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.

Inspect dependencies on your own machine
# 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
These are simplified, authored dependency presets, including paths, versions and file sizes. They are not captured ldd output or measured RAM use. Actual dependencies vary by build and distribution; recent glibc integrates libpthread and libdl functionality into libc.
bash
# 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:

text
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:

  1. DT_RPATH, when present and DT_RUNPATH is absent
  2. LD_LIBRARY_PATH (environment variable)
  3. DT_RUNPATH, for the requesting object’s direct dependencies
  4. /etc/ld.so.cache (ldconfig cache)
  5. /lib, /usr/lib (default system paths)
bash
# 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.

Step 1 / 7

Choose a plugin; no handle exists yet

  1. dlopen
  2. handle
  3. dlsym + dlerror
  4. call
  5. 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.

Plugin host: one checked symbol 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

Plugin catalog and API references
# 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_version
Plugin names, versions, sizes, output and event timing are authored teaching presets. The browser does not execute dlopen. The unload state assumes one host reference, no other dependents and no NODELETE flag.

Complete Plugin Pattern with Error Handling

plugin_host.cpp
#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:

plugin.cpp
#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); }
bash
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:

puts_wrapper.c
#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);
}
bash
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

bash
# 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

bash
# 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.

bash
# 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

If you found this explanation helpful, consider sharing it with others.

Mastodon