The Binary and the Linker

Until now you’ve treated gcc hello.c -o hello as a black box: source in, executable out. In this file you crack that box open. You’ll learn what an ELF file actually contains, what the linker does that the compiler can’t, why static vs dynamic linking is a real design choice with real trade-offs, and how tricks like LD_PRELOAD and symbol visibility power everything from address sanitizers to Netflix’s production tooling.

This matters for you specifically because at some point you’ll debug a symbol not found: cudaDeviceSynchronize at runtime, or a multiple definition of error at link time, or a mysterious 200ms startup cost in your inference server. All three are linker questions.

ELF — the executable format

Every executable, shared library, and object file on Linux is in ELF (Executable and Linkable Format). Its structure:

[ELF header]
[Program headers] — how to load into memory (used at runtime by the loader)
[Sections]
    .text        — executable code
    .rodata      — read-only data (string literals, const globals)
    .data        — initialized read/write data
    .bss         — zero-initialized data (no space in the file, just size)
    .symtab      — full symbol table (stripped in release builds)
    .dynsym      — dynamic symbol table (needed at runtime for dynamic linking)
    .rel.*/.rela.*  — relocation entries
    .debug_*     — DWARF debug info (huge; strip for release)
    .init/.fini  — code run at load/unload
[Section headers]

Tools you should know cold:

Tool

What it shows

file myprog

Bitness, static/dynamic, stripped or not

readelf -h myprog

ELF header

readelf -l myprog

Program headers (segments as loaded)

readelf -S myprog

Section headers

readelf -s myprog

Symbols

readelf -d myprog

Dynamic section (deps, RUNPATH)

nm myprog

Symbols, more compact

objdump -d myprog

Disassembly

objdump -x myprog

Everything

ldd myprog

Shared library deps (uses the dynamic loader)

strip myprog

Remove debug/symbol info

strings myprog

Print all string literals — amazing for quick reverse-engineering

Spend an afternoon disassembling hello world. You’ll never treat a binary as opaque again.

Static vs dynamic linking

Static linking (-static) copies every library’s .o files needed by your program into the final executable. The result is a single large binary with no runtime dependencies.

Dynamic linking (default) leaves the libraries out; the executable records “needs libc.so.6, libpthread.so.0, …” in its dynamic section. At program startup, the dynamic loader (/lib64/ld-linux-x86-64.so.2 on x86-64 Linux) reads those, mmaps the shared libraries in, and resolves symbols.

Trade-off

Static

Dynamic

Binary size

Large (whole libc = ~2MB)

Small

Startup time

Instant

Loader work at startup (~1-10ms)

Memory sharing across processes

None

Shared read-only text pages across all processes

Security updates (libssl bug)

Rebuild every binary

Update the .so, everyone benefits

Deployment simplicity

Copy one file, done

Manage transitive deps, LD_LIBRARY_PATH, RPATH

glibc on target

Doesn’t matter

Must match (glibc symbol versioning)

“It works on my machine” pathology

Rare

Common

When to static-link: deployable binaries with strict portability requirements (Go binaries are static for this reason), containers where you want a from-scratch image, embedded systems.

When to dynamic-link: almost everything else, especially anything using glibc extensively (its NSS module system requires dynamic linking; a fully static glibc binary is not really supported).

Note: you can partially static-link with -Wl,-Bstatic -lfoo -Wl,-Bdynamic to statically link a specific library while dynamically linking the rest. Useful for shipping a binary that depends on a specific libc but bundles a proprietary or unstable third-party library.

PIC and PIE

  • PIC (Position-Independent Code) is code that can execute correctly at any address in memory, using PC-relative addressing and a GOT/PLT for external symbols. Required for shared libraries (.so). Compile with -fPIC.

  • PIE (Position-Independent Executable) applies the same idea to the main executable, so the OS can load it at a random address at each run (ASLR). Compile with -fPIE -pie. Enabled by default in almost every modern toolchain since ~2017.

Performance cost of PIC on x86-64 is essentially zero these days thanks to RIP-relative addressing. On i386 it was measurable (~5-10%). The security win (ASLR defeats many exploit techniques) is worth it. Leave PIE on.

LD_PRELOAD — the interposition trick

The dynamic loader resolves symbols by searching a list of libraries in order. If you set the environment variable LD_PRELOAD=/path/to/mylib.so, that library is searched first, before libc itself. Any symbol you define there wins.

Uses:

  • Sanitizers: AddressSanitizer’s runtime is a preloadable library that intercepts malloc/free.

  • Debugging heap use: write your own malloc wrapper that logs every allocation.

  • Injecting behavior into closed-source binaries: change what time() returns, override gethostname, etc.

  • Netflix, Facebook, and others: production heap profilers and syscall tracers via preload.

/* mymalloc.c — build with: gcc -shared -fPIC -o mymalloc.so mymalloc.c -ldl */
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>

void *malloc(size_t sz) {
    static void *(*real_malloc)(size_t) = NULL;
    if (!real_malloc) real_malloc = dlsym(RTLD_NEXT, "malloc");
    void *p = real_malloc(sz);
    fprintf(stderr, "malloc(%zu) = %p\n", sz, p);
    return p;
}

Run with LD_PRELOAD=./mymalloc.so ls. Every allocation is logged. This is real magic and you should build it at least once.

Security caveat: setuid binaries ignore LD_PRELOAD (obviously; it’d be a trivial privilege escalation).

Symbol visibility

By default in Linux, every non-static function in a shared library is exported. That’s a lot of surface area: bigger .dynsym, slower link, slower load, and potential symbol collisions across libraries.

Best practice for libraries: hide by default, export explicitly.

/* build with: gcc -fvisibility=hidden -shared -fPIC ... */

#define API __attribute__((visibility("default")))

static int internal_helper(int x) { return x + 1; }        // hidden anyway (static)
int  another_internal(int x)      { return x * 2; }        // hidden by -fvisibility=hidden
API  int public_entry_point(void) { return internal_helper(another_internal(5)); }

Only public_entry_point is visible to consumers of the .so. This is how well-designed C libraries (like libcurl, libssl) manage their public surface. The relevant gcc flag is -fvisibility=hidden; the attribute macro convention is copied from libpng/libcurl.

ld vs gold vs lld vs mold — the linker wars

Historically, GNU ld was the only game in town. It’s fine and works, but slow on huge C++ codebases. Alternatives:

  • gold — GNU’s second linker, ~2-3× faster than ld on big projects. Now essentially in maintenance mode.

  • lld — LLVM’s linker. Fast, actively developed, default in Chrome, LLVM, and lots of Rust workflows.

  • mold — Rui Ueyama’s linker. The fastest in production as of 2026. Written in C++, multi-threaded, aggressively engineered. Was AGPL, relicensed to MIT in 2023 — corporate-friendly now.

Practical picks in 2026:

  • For a small C project: any of them is fine, ld is default.

  • For a large project where link time hurts: mold. Use gcc -fuse-ld=mold (GCC 12+) or clang -fuse-ld=/path/to/mold.

  • For incremental linking (rebuild+relink in <1s for one-line changes): neither mold nor lld does true incremental linking. wild (David Lattimore, HN Jan 2025) is a new linker specifically targeting this, not yet production-ready as of research date.

  • Reddit sentiment (r/rust, r/cpp): mold and lld give massive wins on debug builds; on release builds with LTO enabled, LTO dominates and the linker choice matters much less.

Caveat: mold does not support the full linker script syntax. If your project uses complex custom linker scripts (kernel work, embedded), stick with ld or lld.

What most people get wrong about this

They blame the compiler for linker errors. undefined reference to X is not a compiler error; the compiler already succeeded, produced the object file, and moved on. The linker is now telling you it can’t find X in any object or library it was told to look at. The fix is on the link line (-l<lib>, -L<path>, order matters!), not in the source. Once you internalise “compile errors are about syntax and types; link errors are about missing symbols,” a whole class of confusion evaporates.

Practice this week

  1. Write a hello world. strip it. readelf -a it. Identify each section and explain what’s in it.

  2. Build a library libgreet.so with hello() and secret_helper() (visibility hidden). Verify with nm -D that only hello is dynamically visible.

  3. Build the LD_PRELOAD malloc-logger above. Run it on ls, cat, python3 -c "print('hi')". Look at how many allocations each does.

  4. Compare static vs dynamic on the same program: gcc -static hello.c -o hello_s vs gcc hello.c -o hello_d. ls -l, strace -c both. Note the size difference and the syscall difference (dynamic does a lot of openat/mmap at startup).

  5. Install mold. Time a link with ld vs mold on any project with >20 object files.

References

  • “Linkers and Loaders” by John Levine — the canonical book. Old but foundational.

  • Ian Lance Taylor’s “Linkers” blog series (airs.com/blog) — 20 short posts by a former GNU gold maintainer, still the best free intro.

  • man ld, man ld.so — read at least the intro of both.

  • mold GitHub (rui314/mold) — the README is a good linker-internals primer.

  • Ulrich Drepper, “How to Write Shared Libraries” (2011) — dry, dense, indispensable.


Return to README.md · Next: 07_performance_intro.md