The problem versioning solves: one name, two meanings
Picture a wildly popular shared library — the C library itself is the classic case. A program compiled in 2005 calls a function from it, say realpath(), and expects it to behave a certain way: a certain argument list, a certain meaning for the return value. Years later the library authors need to change how realpath() works in a way that would break that old program if it suddenly got the new behaviour. But they cannot just rename the function, because thousands of already-compiled binaries out there ask for the symbol named exactly realpath. This is the ABI compatibility bind: the name is a contract, and you are not allowed to quietly change what the contract delivers.
Symbol versioning is the trick that escapes this bind. The library keeps both implementations of realpath inside the same .so file, but tags each one with a version: the old one as realpath@GLIBC_2.2.5, the new one as realpath@@GLIBC_2.3 (the double-at marks the default version that fresh links pick up). The name realpath is no longer a single symbol; it is a name plus a version label, and the two labelled variants live side by side. An old binary that was linked against the 2.2.5 version recorded that exact label, so the loader hands it the old behaviour; a freshly compiled binary records 2.3 and gets the new one. Same library, same file, one source-level name, two honoured contracts.
How a version label gets chosen and honoured
Follow the label through its whole life. When you compile and link a program against a versioned library, the static linker resolves your call to realpath() the way you already know from guide 2 — but as it records the undefined symbol in your executable, it does not just write realpath, it writes realpath plus the default version it found at link time (the @@-marked one). That version string is frozen into your binary forever. So the choice of which realpath you get was made once, at your link time, and it is permanent — recompiling against a newer library is what advances you to a newer default.
At run time the dynamic loader does the matching half. It reads the versioned reference baked into your binary — realpath, version GLIBC_2.2.5 — and looks inside the loaded library for a definition that exactly carries that label, not merely the bare name. The library publishes its versions in two small tables (you will see them as .gnu.version and .gnu.version_r in readelf); the loader uses them to pair your old reference with the old implementation even though a newer default also exists in the same file. If the library no longer offers the version your binary asked for, you get a crisp, honest failure at startup — a "version GLIBC_2.x not found" error — rather than the silent, dangerous mismatch you would have suffered without versions.
This is the deep payoff, and it is worth stating plainly: versioning lets a library evolve its behaviour without breaking old callers and without forcing a flag-day recompile of the world. The cost is real discipline on the library author's side — each released version label is a promise that can never be withdrawn, only added to — but for a foundational library shipped to billions of machines, that discipline is exactly what keeps a fifteen-year-old binary running on today's system. It is not magic; it is bookkeeping, applied with great care.
Link-Time Optimization: deferring the optimizer to the link
Now a completely different loose end, but one that lives at the same seam — the link. From guide 1 you know that ordinary compilation works one translation unit at a time: the compiler sees main.c alone, optimizes it in isolation, and emits main.o; it never sees the body of a function defined in another file. That isolation costs you optimizations. If foo() in foo.c is tiny and called once from main.c, the compiler would love to inline it — paste its body right into main and delete the call — but it cannot, because when it compiled main.c the body of foo was in a separate world it could not look into.
Link-time optimization, or LTO, breaks that wall. The trick is to not finish compiling during the per-file step. With LTO enabled (gcc -flto -O2), each .o file does not contain final machine code; instead it stashes the compiler's internal intermediate representation — the half-digested form the optimizer actually works on. Then, at link time, the linker hands all of those IR chunks back to the compiler at once, and now the optimizer can finally see the whole program together: it can inline foo into main across the old file boundary, delete code that turns out to be unreachable once everything is visible, and propagate constants from one file into another. The optimizations are the same ones you already met (inlining, dead-code elimination, constant propagation) — only now their scope is the entire program rather than one file.
Startup: what the dynamic loader does before main()
We have mentioned the dynamic loader in passing for three guides; now let us watch it work, because there is a real program running before your program runs. When you launch a dynamically linked executable, the kernel does not jump straight to your main(). It first maps in a tiny helper program — the dynamic loader itself, usually ld-linux.so — whose path was recorded in your binary in a special slot called the interpreter (you can read it: readelf -l a.out shows an INTERP line naming /lib64/ld-linux-x86-64.so.2). The kernel starts that program, and the loader, running in your process, prepares the world so that when control finally reaches main(), every library is in place.
How does the loader know which libraries to find? Your binary carries a small array of instructions for it, the dynamic section — a list of tagged entries the loader reads like a checklist. A DT_NEEDED tag names each library this binary depends on (one per .so, e.g. "libc.so.6"); a DT_RPATH or DT_RUNPATH tag may suggest extra directories to search; other tags point at the symbol tables and relocation tables the loader will need. The loader walks this list, and for each needed library it runs the library search: a fixed sequence of places — runpath, the LD_LIBRARY_PATH environment variable, the system cache, then the default directories — checked in order until the file turns up. The first match wins, which is why a stray library early in the path can shadow the one you meant.
Once each library is mapped in, the loader finishes the job guides 2 and 4 set up: it applies the deferred relocations. The data ones it does eagerly — filling each GOT slot for a global variable with the variable's now-known run-time address. The function ones it can defer through lazy binding, leaving each PLT stub to resolve its real target only on the first call, exactly as guide 4 described. Then, and only then, the loader transfers control to your program's startup code, which eventually calls main(). All of this — map, search, relocate, bind — happened in the blink before your first line ran.
One last hardening step: RELRO
There is a security wrinkle hiding in everything above, and it has a clean fix worth knowing. The GOT from guide 4 is a table of function-pointer-shaped slots that the loader writes into at startup. But "a table of pointers the program jumps through" is a juicy target for an attacker: if a bug lets them overwrite a GOT slot, they redirect a future call to code of their choosing. The tension is that the GOT must stay writable long enough for the loader to fill it in — yet ideally it should be read-only afterward, so nothing can tamper with it later.
RELRO — "relocation read-only" — resolves that tension by changing when the GOT becomes read-only. The linker groups the relocated tables into a region, and the loader, the moment it has finished writing all its relocations at startup, asks the kernel to flip that region's permissions to read-only (the same page-protection machinery the OS uses everywhere). After that flip, an attacker who finds a write bug simply cannot scribble on the GOT — the hardware faults the write. "Full RELRO" goes all the way: it forces eager binding so every function relocation is resolved up front, lets the loader lock the whole table, and gives up lazy binding's startup savings in exchange for a hardened table. It is a small, concrete example of the recurring systems trade-off between a little startup speed and a lot of safety.