What Is Dynamic Linking?
When you compile a program that uses a shared library, the compiler does not embed the library’s code into the executable. Instead it records the library’s soname and the names of the symbols (functions/variables) needed from it. At run time, a special program called the dynamic linker (ld-linux.so) loads the .so files and resolves all the symbol references before handing control to main().
Understanding dynamic linking is essential for debugging “undefined symbol” errors, understanding startup overhead, and building plugin architectures.
1. How a Dynamically Linked Program Starts
Every dynamically-linked ELF executable contains a special segment called PT_INTERP that names the dynamic linker to use. On x86-64 Linux this is /lib64/ld-linux-x86-64.so.2. The kernel reads this when loading the program and maps the dynamic linker into the process’s address space first.
| Step | What Happens | Who Does It |
|---|---|---|
| 1 | Kernel maps the executable into memory and reads PT_INTERP | Linux kernel |
| 2 | Kernel maps ld-linux.so into the process and transfers control to it | Linux kernel |
| 3 | Dynamic linker reads the DT_NEEDED list from the executable’s dynamic section | ld-linux.so |
| 4 | Looks up each needed soname in /etc/ld.so.cache → finds real path | ld-linux.so |
| 5 | Maps each .so into the process address space using mmap() | ld-linux.so |
| 6 | Resolves all symbol references (fills in GOT entries for needed symbols) | ld-linux.so |
| 7 | Calls each library’s .init section (constructors with __attribute__((constructor))) | ld-linux.so |
| 8 | Transfers control to the executable’s entry point (_start → __libc_start_main → main) | ld-linux.so → libc |
2. Lazy Binding vs Eager Binding
Symbol resolution (filling GOT entries) can happen either eagerly (all at startup) or lazily (on first use). Linux uses lazy binding by default for performance — resolving 200 library functions at startup adds measurable delay, especially if most are never called.
The GOT entry for a function initially points to a stub in the PLT. On the first call to that function, the PLT stub invokes the dynamic linker’s resolver, which looks up the real address and patches the GOT. Subsequent calls go directly to the real address.
Pro: Faster startup. Con: First call to each symbol slightly slower.
All GOT entries are resolved at startup before main() runs. If a needed symbol doesn’t exist in any loaded library, the program fails immediately at startup rather than crashing later mid-execution.
Pro: “Fail fast” — symbol errors detected at startup. Con: Slower startup.
Code Example 1 — Observing the Dynamic Linker at Work
/* prog.c — simple program using shared library */
#include <stdio.h>
#include <math.h>
#include "net_utils.h"
int main(void) {
char buf[32];
uint32_t ip = ip_to_int("192.168.1.100");
int_to_ip(ip, buf, sizeof(buf));
printf("IP roundtrip: %s\n", buf);
printf("sqrt(2) = %.6f\n", sqrt(2.0));
return 0;
}
# Compile the program
gcc -o prog prog.c -L. -lnetutils -lm
# Check what dynamic libraries it needs (DT_NEEDED entries)
readelf -d prog | grep NEEDED
# (NEEDED) Shared library: [libnetutils.so.1]
# (NEEDED) Shared library: [libm.so.6]
# (NEEDED) Shared library: [libc.so.6]
# Check the PT_INTERP (dynamic linker path)
readelf -l prog | grep interpreter
# [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
# Enable LAZY binding debug output (LD_DEBUG shows resolution)
LD_DEBUG=bindings LD_LIBRARY_PATH=. ./prog 2>&1 | head -30
# binding file prog to libnetutils.so.1: normal symbol 'ip_to_int' [GLIBC_2.17]
# binding file prog to libnetutils.so.1: normal symbol 'int_to_ip'
# binding file prog to libm.so.6: normal symbol 'sqrt'
# IP roundtrip: 192.168.1.100
# sqrt(2) = 1.414214
# Enable EAGER binding — all symbols resolved before main()
LD_BIND_NOW=1 LD_LIBRARY_PATH=. ./prog
# If a library is missing, with eager binding you get an immediate error:
LD_BIND_NOW=1 ./prog_missing_lib
# /lib64/ld-linux-x86-64.so.2: error: symbol lookup error:
# undefined symbol: ip_to_int
3. LD_PRELOAD — Symbol Interposition
LD_PRELOAD is an environment variable that tells the dynamic linker to load specified .so files before all others. Because the dynamic linker resolves symbols in order (RPATH, LD_PRELOAD, cache/system libraries), a symbol defined in an LD_PRELOAD library shadows (interposes) the same symbol from the standard library. This is powerful for debugging, testing, and patching.
Code Example 2 — LD_PRELOAD for Function Interposition
/* malloc_trace.c — intercept malloc to trace allocations */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h> /* for RTLD_NEXT */
/*
* RTLD_NEXT: find the "next" definition of malloc in the
* dynamic linker search order (i.e., the real malloc in libc)
*/
static void *(*real_malloc)(size_t) = NULL;
/* Our replacement malloc — called instead of libc malloc */
void *malloc(size_t size) {
if (!real_malloc) {
real_malloc = dlsym(RTLD_NEXT, "malloc");
}
void *ptr = real_malloc(size);
fprintf(stderr, "[TRACE] malloc(%zu) = %p\n", size, ptr);
return ptr;
}
static void (*real_free)(void *) = NULL;
void free(void *ptr) {
if (!real_free) {
real_free = dlsym(RTLD_NEXT, "free");
}
fprintf(stderr, "[TRACE] free(%p)\n", ptr);
real_free(ptr);
}
# Build the interposition library
gcc -g -shared -fPIC -o malloc_trace.so malloc_trace.c -ldl
# Run ANY program with malloc tracing (no recompile needed!)
LD_PRELOAD=./malloc_trace.so ls /tmp
# [TRACE] malloc(120) = 0x561f...
# [TRACE] malloc(8192) = 0x7f...
# [TRACE] free(0x561f...)
# ... (all malloc/free calls from 'ls' are traced)
# Trace your own program:
LD_PRELOAD=./malloc_trace.so ./prog
# [TRACE] malloc(32) = 0x...
# IP roundtrip: 192.168.1.100
dlsym() means “find the next definition of this symbol in the dynamic linker search order, starting after the current library.” It lets your interposition wrapper call the real function rather than infinitely recursing.LD_PRELOAD and LD_LIBRARY_PATH are completely ignored for setuid/setgid executables. The dynamic linker strips them to prevent privilege escalation (an attacker interposing getuid() in a setuid program, for example).Code Example 3 — Library Search Order and Debugging
# Full LD_DEBUG options for diagnosing dynamic linking issues
# LD_DEBUG=help shows all available categories
LD_DEBUG=libs LD_LIBRARY_PATH=. ./prog 2>&1 | head -20
# find library=libnetutils.so.1 [0]; searching
# search path=/lib/x86_64-linux-gnu/tls/x86_64:... (LD_LIBRARY_PATH)
# trying file=./libnetutils.so.1
# found libnetutils.so.1 at ./libnetutils.so.1.0.0
# Show all library search steps
LD_DEBUG=libs,files ./prog 2>&1
# Check if a program would successfully link its libraries
# (faster and safer than running the program directly)
ldd ./prog
# linux-vdso.so.1 (0x...)
# libnetutils.so.1 => /usr/local/lib/libnetutils.so.1.0.0
# libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
# Show "not found" when library is missing:
ldd ./prog_bad
# libmissing.so.1 => not found ← clear error
# LD_DEBUG categories (partial list):
# libs - library search and loading
# bindings - symbol binding (lazy resolution)
# symbols - symbol table lookups
# reloc - relocations applied
# files - all file opens by dynamic linker
# all - everything (very verbose)
# statistics - timing/count statistics
# Useful: see what version of a symbol is being resolved
LD_DEBUG=symbols,bindings ./prog 2>&1 | grep sqrt
# symbol=sqrt; lookup in file=prog [0]
# symbol=sqrt; lookup in file=libnetutils.so.1 [0]
# symbol=sqrt; lookup in file=libm.so.6 [0]
# binding file prog [0] to libm.so.6 [0]: normal symbol 'sqrt'
4. Library Constructors and Destructors
Shared libraries can run code automatically when they are loaded or unloaded using GCC’s constructor/destructor attributes. The dynamic linker calls constructors before main() and destructors after main() returns (or on exit()).
/* libinit_demo.c — library with constructor and destructor */
#include <stdio.h>
/* Called automatically when library is loaded */
__attribute__((constructor))
static void library_init(void) {
printf("[libinit_demo] Library loaded — running constructor\n");
/* Good place for: initializing global state, opening files,
starting background threads, etc. */
}
/* Called automatically when library is unloaded */
__attribute__((destructor))
static void library_fini(void) {
printf("[libinit_demo] Library unloaded — running destructor\n");
/* Good place for: cleanup, flushing buffers, joining threads */
}
void demo_function(void) {
printf("[libinit_demo] demo_function() called\n");
}
# Build the library
gcc -g -shared -fPIC -Wl,-soname,libinit_demo.so.1 \
-o libinit_demo.so.1.0.0 libinit_demo.c
# Run a program that uses it
cat > main_init.c << 'EOF'
#include <stdio.h>
extern void demo_function(void);
int main(void) {
printf("[main] main() started\n");
demo_function();
printf("[main] main() finishing\n");
return 0;
}
EOF
gcc -o main_init main_init.c -L. -linit_demo
LD_LIBRARY_PATH=. ./main_init
# Output:
# [libinit_demo] Library loaded — running constructor
# [main] main() started
# [libinit_demo] demo_function() called
# [main] main() finishing
# [libinit_demo] Library unloaded — running destructor
__attribute__((constructor(101))) runs before __attribute__((constructor(102))). Destructors run in reverse priority order. Priorities 0–100 are reserved for the implementation.Interview Questions & Answers
/lib64/ld-linux-x86-64.so.2. On 32-bit x86 it is /lib/ld-linux.so.2. The kernel reads the PT_INTERP segment of the ELF executable to find the dynamic linker path, maps it into the process, and transfers control to it. The dynamic linker then loads all needed .so files and resolves symbols before calling main().LD_PRELOAD specifies one or more .so files to load before all others, including the standard C library. Because symbol resolution uses a first-match-wins policy, a symbol defined in an LD_PRELOAD library shadows the same symbol from any other library. Use cases: (1) Debugging: intercept malloc/free to trace memory allocations without modifying source. (2) Mocking: replace time() with a fake version for deterministic testing. (3) Patching: override a buggy library function without waiting for the vendor to release a fix. LD_PRELOAD is ignored for setuid/setgid programs for security.LD_DEBUG=libs before running a program causes the dynamic linker to print detailed information about its library search process to stderr: which directories it searches, which paths it tries, and where it finally finds each library. It is invaluable for debugging “cannot open shared object file” errors — it shows exactly why the linker can’t find a library (wrong directory in cache, missing from LD_LIBRARY_PATH, wrong architecture, etc.). Other useful values: bindings (shows symbol resolution), all (everything), symbols (symbol lookup details).RTLD_NEXT is a special “pseudo-handle” passed to dlsym() that means: “find the next occurrence of this symbol in the dynamic linker’s search order, starting from the library after the one calling dlsym.” It is essential in interposition (LD_PRELOAD) libraries because the wrapper function needs to call the real version of the function (e.g., the real malloc in libc) without recursing into itself. Without RTLD_NEXT, calling malloc() inside the wrapper would recursively call the wrapper itself indefinitely.-fvisibility=hidden and explicit visibility attributes to avoid accidental interposition.__attribute__((constructor)) for functions that run before main() (on library load), and __attribute__((destructor)) for functions that run after main() returns or when the library is unloaded. Common uses: initializing global state, opening database connections, logging library load events, cleaning up resources. They work for both shared libraries and for executables (the constructor runs before main, destructor after).DT_NEEDED is a list of sonames that this ELF file requires (the shared libraries it depends on). Each entry is a string like libfoo.so.1. The dynamic linker must find and load each DT_NEEDED library before the program can run. DT_RPATH (or the newer DT_RUNPATH) is an embedded list of directory paths where the dynamic linker should search for libraries before checking the system cache. It is baked in at link time with -Wl,-rpath,/path. RPATH is searched before LD_LIBRARY_PATH (for backward compatibility), while RUNPATH is searched after LD_LIBRARY_PATH.ldd ./prog — check for “not found” entries (missing libraries) or wrong paths.2. Run
LD_DEBUG=symbols,bindings ./prog — see which symbol lookup fails and in which library.3. Run
nm -D libfoo.so | grep symbol_name — check if the symbol is actually exported by the library.4. Check library version: the program may have been compiled against a newer library that added the symbol, but an older version is installed.
5. Run
ldconfig -p | grep libfoo — confirm the right library version is in the cache.6. Check
LD_BIND_NOW=1 ./prog — forces eager binding so the error surfaces at startup instead of mid-run.PT_INTERP (interpreter) program header segment. This segment contains the path to the dynamic linker, e.g., /lib64/ld-linux-x86-64.so.2. The kernel reads this path during execve(), maps the dynamic linker into the new process’s address space, and sets the instruction pointer to the dynamic linker’s entry point rather than to the executable’s entry point. The dynamic linker then takes over, loads needed libraries, resolves symbols, and finally transfers control to _start in the main executable. You can see this with readelf -l prog | grep INTERP.Continue to Part 6
Learn dlopen/dlsym — loading shared libraries explicitly at runtime for plugin architectures.
