One question, two answers
By now the picture from the last two guides is solid: your compiler turns each source file into an object file full of machine code with holes in it, the linker performs symbol resolution to fill in which definition each name refers to, and then it patches the holes with relocations. Every guide so far has quietly assumed all of that finishes before you ever run the program. But there is a second, equally legitimate choice: do the resolving and patching later, when the program is launched, or even mid-run. That single decision — now or later — is the whole of static versus dynamic linking.
Make it concrete with the smallest possible program: `int main(void) { puts("hi"); return 0; }`. The call to puts() is an undefined symbol in your object file — your code does not contain puts(), it only names it. Somebody has to supply the real bytes. Under static linking, the linker reaches into a static library (the C library archived as libc.a), copies the machine code of puts() and everything it depends on into your executable, and resolves the call right there. The result is one self-contained file. Run `ls -l a.out` and it is large, because libc came along for the ride.
Under dynamic linking the linker does something almost paradoxical: it leaves the call to puts() unresolved. Instead of copying any code, it records a promise — "this program needs a shared library called libc.so.6, and from it the symbol puts" — and stamps that promise into the executable. Your a.out is now tiny, but it is no longer self-contained: it cannot run until something, at launch time, finds libc.so.6 and wires the call up. That something is a separate program called the dynamic linker, and the rest of this rung is largely the story of what it does.
What the static linker physically does
A static library is not magic — it is barely even a format. A .a file is an archive, essentially a tar-like bundle of ordinary object files glued together with a small index. When you link against it, the linker does not blindly pull in the whole archive. It pulls in object files on demand: starting from your undefined symbols, it finds the archive member that defines each one, adds that member, and notices the member itself has new undefined symbols, which it then chases too. This is why link order can matter on the command line — the linker scans left to right, and an archive only contributes the members that resolve symbols already pending at the moment it is read.
Once the needed members are gathered, static linking is just the machinery you already met: their sections are merged with yours (all the .text together, all the .data together), the symbol tables are resolved so every undefined puts() now points at the concrete copy, and the relocations are applied to bake in final addresses. After that, the library code is indistinguishable from your own — it is just more bytes in the same .text section. There is no trace at runtime that puts() ever lived in a separate file. The program is complete, frozen, and portable in the strongest sense: copy the one file to another machine with the same architecture and it runs, no dependencies to satisfy.
What dynamic linking leaves for later
Dynamic linking splits the linker's job across two moments in time. At build time, the linker still checks that every symbol can be satisfied — it reads the shared library to confirm puts is really there, so a missing symbol is still a build error, not a nasty surprise at launch. But instead of copying code, it writes two things into your executable: a list of needed libraries (each a `DT_NEEDED` entry like libc.so.6), and a stub mechanism so that calls to puts() route through a small table that will be filled in later. The actual address of puts() is left blank — a hole that only the runtime knows how to fill, because only at runtime is it known where in memory libc landed.
Why can the build-time linker not just write the address down? Because there is no fixed address to write. The same libc.so.6 is shared by hundreds of running processes, and each process places it wherever its address-space layout happens to have room — and with address-space layout randomization that location is deliberately shuffled on every launch for security. A shared library therefore cannot hard-code where it sits; it must be built as position-independent code so the same bytes work no matter the load address. That requirement is exactly why the next guide exists, and why the GOT and PLT you have heard mentioned are about to become important.
When you launch a dynamically-linked program, the kernel notices it is not self-contained and hands control first to the dynamic linker (its path is recorded in the executable, typically /lib64/ld-linux-x86-64.so.2). The dynamic linker reads the `DT_NEEDED` list, finds and maps each shared library into memory, recursively pulls in their dependencies, and then performs the same symbol resolution and relocation work the static linker would have done — only now against the real, just-chosen load addresses. Once every promised symbol is wired up, it jumps to your code. All of this happens before your main() runs, in the blink before the first line executes.
The trade-offs, told honestly
The headline argument for dynamic linking is sharing. On a running system, dozens of programs use libc; if each were statically linked, every process would carry its own private copy of puts() and friends on disk and in memory. With a shared library, the read-only code pages of libc are mapped into many processes but backed by one physical copy in RAM — the kernel's page sharing makes this nearly free. The savings are real and large at system scale: it is why your distribution ships one libc.so and thousands of programs lean on it, rather than baking it into each.
The second argument is updates, and it cuts two ways. When a security hole is found in libc — say a buffer overflow in a parser — a dynamically-linked world fixes it by shipping one new libc.so.6; every program that uses it is patched the next time it launches, with no rebuild. A statically-linked binary, by contrast, has that vulnerable code frozen inside it forever, and must be recompiled and redistributed to be fixed. That is a genuine and serious advantage for dynamic linking. But the same coupling is also its great hazard: change the library in an incompatible way and you can break every program that depends on it at once — the dreaded "dependency hell." This is why library versioning and ABI compatibility is a discipline, not an afterthought, and why the next guide's sibling covers symbol versioning.
Static linking's counter-case is just as honest. Its binary is bigger and updates are painful, but in return it is self-contained and predictable: it runs on a bare container with no libraries installed, it cannot break because someone upgraded a shared library out from under it, and it has no per-launch cost of finding and wiring up libraries. There is also a small but real runtime difference: a dynamic call to puts() goes through an indirection (the table we mentioned), whereas a static call is a direct jump — usually negligible, but measurable in hot loops. Neither choice is universally right. The trade is portability and update-velocity (dynamic) against self-containment and reproducibility (static), and serious systems deliberately pick per case.
Seeing it for yourself
You do not have to take any of this on faith — the tools let you watch the fork happen. Compile the tiny program two ways and the difference is immediate: `gcc hi.c` gives a dynamically-linked a.out, while `gcc -static hi.c` gives a statically-linked one. Run `ls -l` on both and the static one is dramatically larger. The cleanest demonstration is the `ldd` command, which lists what a binary still needs at runtime: on the dynamic build it prints libc.so.6 and the dynamic linker; on the static build it prints "not a dynamic executable," because there is nothing left to resolve.
- Write the file: `int main(void) { puts("hi"); return 0; }` and save it as hi.c.
- Build both ways: `gcc -O2 hi.c -o hi_dyn` and `gcc -O2 -static hi.c -o hi_static`.
- Compare sizes with `ls -l hi_dyn hi_static` — watch the static binary balloon as libc rides along.
- Inspect dependencies with `ldd hi_dyn` (lists libc.so.6 and the loader) then `ldd hi_static` (says it is not dynamic).
- Trace the launch of the dynamic build with `strace ./hi_dyn` and watch the dynamic linker open and mmap libc before main() ever runs.