Prerequisite:


Compilation does not produce a runnable program - it produces object files. Each object file is an island: it contains machine code and data, but its external references are unresolved and its internal addresses are relative to zero. The linker connects these islands into an executable, and the loader brings that executable to life at runtime. Together they are responsible for everything that happens between gcc finishing and your main being called.

Object Files and ELF

On Linux, object files and executables use the ELF (Executable and Linkable Format) container. An ELF file is divided into sections:

  • .text - executable machine code
  • .data - initialised global and static variables
  • .bss - uninitialised global and static variables (occupies no space in the file; the OS zeroes this region)
  • .rodata - read-only data (string literals, const globals)

Each object file also contains a symbol table - a list of names (functions, globals) that this file defines (exported symbols) or references but does not define (undefined symbols). External references are placeholders; their addresses are listed in a relocation table that says “patch this address once you know where printf lives.”

What the Linker Does

The linker’s job reduces to two tasks.

Symbol resolution. The linker collects all symbol tables from all object files and libraries. For each undefined symbol reference, it finds a definition. If a symbol is defined in multiple files, the linker uses the first definition it finds (or errors if there are multiple strong definitions). If a required symbol is defined in a static library (.a file), the linker extracts only the object files from the archive that provide needed symbols.

Relocation. Object files are compiled with the assumption that their code starts at address zero. The linker assigns each section a final virtual address and then patches every relocation entry: every place in the code that contains a placeholder address gets replaced with the real computed address.

Link order matters: the linker resolves symbols left to right across the command line. If foo.o references bar which is defined in libfoo.a, then foo.o must appear before -lfoo on the linker command line, or bar will be considered unused when libfoo.a is scanned.

Static vs Dynamic Linking

Static linking bundles every library the program uses directly into the executable. The resulting binary is self-contained: it runs on any compatible system without needing external library files. The binary is larger, and if a library has a bug, every statically linked binary must be recompiled and redistributed to get the fix.

Dynamic linking produces a smaller executable that records which shared libraries (.so on Linux, .dylib on macOS, .dll on Windows) it depends on but does not include them. The libraries are loaded into memory at runtime and can be shared between all processes that use them - if five programs use libc.so, there is one copy in physical memory.

The trade-off: dynamic linking enables library updates without recompilation, reduces binary size, and saves memory across processes. Static linking enables standalone deployment with no runtime dependencies.

The PLT and GOT

Dynamic linking requires that the address of a shared library function be unknown at link time - the library may be loaded at a different address each run (ASLR randomises load addresses). The linker solves this with two indirection tables.

The Global Offset Table (GOT) is a table of absolute addresses, filled in at runtime by the dynamic linker. When code needs the address of a global variable from a shared library, it reads it from the GOT.

The Procedure Linkage Table (PLT) adds a second layer for function calls. The first time a dynamically linked function is called, the PLT stub calls into the dynamic linker, which resolves the symbol, writes the real address into the GOT, and transfers control to the function. Subsequent calls skip the resolution step and jump directly via the GOT entry. This is lazy binding - symbol resolution is deferred until first use.

You can disable lazy binding with LD_BIND_NOW=1 or -z now, which forces all symbols to be resolved at startup. This is preferred in security-sensitive applications because it allows the GOT to be made read-only after startup (RELRO - Relocation Read-Only), preventing a class of GOT overwrite attacks.

Symbol Visibility

By default, all non-static symbols in a shared library are exported and visible to anyone who links against it. This has costs: the dynamic linker must process every exported symbol, and exported symbols cannot be optimised across the library boundary.

static at file scope restricts a symbol to its translation unit. __attribute__((visibility("hidden"))) in GCC/Clang (or -fvisibility=hidden globally) hides a symbol from external consumers while still allowing cross-file use within the same library. Controlling visibility is an important part of shared library design.

Essential Tools

nm binary - lists symbols: type (defined/undefined, function/data), name, and address.

ldd binary - lists the shared libraries a binary depends on and the paths where they will be loaded.

readelf -S binary - displays all ELF sections with their sizes and offsets.

objdump -d binary - disassembles the .text section.

readelf -r binary - shows the relocation table entries.

The Loader

When you run a program, the OS does not just memcpy the file into RAM and jump to it. The loader (part of the kernel, on Linux) reads the ELF program headers, maps each segment into the process’s virtual address space (mmap), sets up the stack, and then transfers control to the dynamic linker (ld.so on Linux) rather than to main directly.

ld.so finishes what the linker started: it maps all required shared libraries into the process address space, runs through the relocations, resolves PLT/GOT entries (if using eager binding), and then calls each library’s initialisation functions (.init_array / __attribute__((constructor))). Only then does control pass to the program’s _start stub, which sets up argc/argv/environment and calls main.

LD_PRELOAD

LD_PRELOAD is an environment variable naming shared libraries to load before all others. Symbols in the preloaded library take precedence over those in later libraries, including libc. This makes it possible to intercept and wrap any dynamically linked function without recompiling the target.

LD_PRELOAD=./mymalloc.so ./myprogram

mymalloc.so can define its own malloc that logs every allocation, then calls the real malloc (retrieved with dlsym(RTLD_NEXT, "malloc")). This technique is used for memory profilers, allocation trackers, and security wrappers.

Examples

ldd output. Running ldd /bin/ls shows something like:

linux-vdso.so.1 => (vdso)
libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
/lib64/ld-linux-x86-64.so.2

linux-vdso.so.1 is the virtual dynamic shared object - a kernel-provided library mapped into every process address space to make certain syscalls (like gettimeofday) callable as a normal function without a full syscall crossing.

nm to inspect symbols. nm -u binary lists all undefined (externally referenced) symbols. nm -D library.so lists all dynamically exported symbols. An uppercase letter in the type column means the symbol is defined in this file; lowercase means it’s external. T is code in .text; D is initialised data; U is undefined.

LD_PRELOAD malloc wrapper. A minimal wrapper that counts allocations:

#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>

static int count = 0;

void *malloc(size_t size) {
    static void *(*real_malloc)(size_t) = NULL;
    if (!real_malloc) real_malloc = dlsym(RTLD_NEXT, "malloc");
    count++;
    fprintf(stderr, "malloc #%d: %zu bytes\n", count, size);
    return real_malloc(size);
}

Compile as a shared library with -shared -fPIC, then run any program with LD_PRELOAD=./wrap.so ./program to see every allocation logged.

The linker and loader are often treated as black boxes. They are not. Once you understand symbol resolution, relocation, and lazy binding, build errors with missing symbols, multiply-defined symbols, and load-order surprises stop being mysterious and become straightforward to diagnose.


Read Next: