Dynamic Linking Internals How the dynamic linker loads .so files

 

Chapter 41 – Part 5 of 6
Dynamic Linking Internals
How the dynamic linker loads .so files, resolves symbols, handles the PLT/GOT, and manages the runtime search order
3
Code Examples
10
Interview Q&A
~25
min read

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.

Key Terms
ld-linux.so PT_INTERP DT_NEEDED PLT GOT lazy binding eager binding LD_BIND_NOW symbol interposition LD_PRELOAD

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.

Lazy Binding (default)

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.

Eager Binding (LD_BIND_NOW=1)

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
RTLD_NEXT: This special handle passed to 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.
Security: 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
Multiple constructors: You can have multiple constructor functions with different priorities: __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

Q1. What is the dynamic linker, and what is its file name on x86-64 Linux?
The dynamic linker (also called the runtime linker or loader) is a special shared library that performs the job of loading and linking at program startup. On x86-64 Linux it is /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().
Q2. What is lazy binding and why is it the default in Linux?
Lazy binding means that the address of an external function is resolved only on its first call, not at program startup. The GOT entry initially points to a PLT stub that invokes the dynamic linker’s resolver on first call, which patches the GOT with the real address. Subsequent calls go directly. It is the default because a program may import hundreds of library functions but only call a fraction of them during a typical run. Resolving all symbols upfront would waste time for symbols that are never used. The trade-off is that the very first call to each symbol has extra overhead from the resolver.
Q3. How does LD_PRELOAD work, and what is a use case for it?
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.
Q4. What does LD_DEBUG=libs do?
Setting 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).
Q5. What is RTLD_NEXT and why is it needed in interposition libraries?
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.
Q6. What is symbol interposition in Linux shared libraries?
Symbol interposition (or symbol overriding) means that if the same symbol name is defined in multiple shared libraries loaded by a process, the dynamic linker uses the one from the library that was searched first (based on the search order: RPATH, LD_PRELOAD, then the order libraries were linked). This is a feature that enables LD_PRELOAD-based debugging tools. It can also be a source of bugs when two libraries accidentally define the same symbol name. Use -fvisibility=hidden and explicit visibility attributes to avoid accidental interposition.
Q7. What are library constructors and destructors and how are they declared?
Library constructors and destructors are functions that run automatically when a library is loaded or unloaded. They are declared using GCC attributes: __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).
Q8. What is the difference between DT_NEEDED and DT_RPATH in an ELF file?
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.
Q9. A program compiles fine but crashes at runtime with “undefined symbol”. What are the steps to diagnose this?
1. Run 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.
Q10. How does the kernel know which dynamic linker to use when starting a dynamically-linked program?
Every dynamically-linked ELF executable contains a 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.

Part 6: dlopen API → ← Index

Leave a Reply

Your email address will not be published. Required fields are marked *