The idea: copy the library in, or write down where it is
A C program almost never runs alone: printf, malloc and strlen live in the C library, libc. The linker has two ways to connect your code to it. Static linking (gcc -static) copies the library code your program uses into the executable. Dynamic linking (the default) copies nothing: it writes down that the program needs libc.so.6, and a small program, the dynamic loader ld.so, connects the two each time the program starts.
The page links one program twice and runs the same actions on both. It is sum from How a C Program Is Compiled and Run: main.c and util.c, with one printf. The sizes and addresses are the real ones from that page (gcc 14.2, GNU ld 2.44, glibc 2.41 on x86-64 Debian). The scoreboard under each lane counts the file size, the start-up work, the jumps it takes to reach printf, the relinks a libc fix needs, the libc code in RAM and whether the file runs on an older system.
Static linking
libc.a is an archive: a bundle of 2216 object files with an index of the symbols they define. When main calls the undefined printf, ld looks it up in the index and copies the whole member that defines it, printf.o. That member needs vfprintf, which needs memcpy, strlen and the locale code, and so on until nothing is undefined: 431 members, and sum grows from 16 KB to 754 416 bytes (Demo: build sum).
Everything is resolved at build time. The file is not position-independent and loads at 0x400000; printf has a fixed address, 0x4047a0, and main reaches it with one direct call. The file has no .interp and no .dynamic section, so the kernel jumps straight to _start: no loader runs and no relocations are applied (Demo: first and second printf call).
Dynamic linking
With the default gcc -o sum main.o util.o, ld finds printf in the shared library libc.so.6. It only checks that the symbol is there and records three things: a NEEDED libc.so.6 entry in .dynamic, the loader's path /lib64/ld-linux-x86-64.so.2 in .interp, and for each library function a stub in the PLT (procedure linkage table, printf@plt at 0x1030) plus a slot in the GOT (global offset table, 0x4000). The file is 16 088 bytes.
At start-up the kernel runs ld.so first. It reads NEEDED, finds libc.so.6, maps it (here at 0x7ffff7dab000) and applies the program's 90 relocations. Then it jumps to _start.
Lazy binding: the PLT and the GOT
To start faster, ld.so does not look up printf at start-up. The GOT slot first points back into the stub, so the first call takes a detour:
call printf@plt(jump 1); the stub doesjmp *0x4000(jump 2), which lands on the stub's next line,0x1036.- The stub pushes the relocation number 0 and jumps to
PLT0(3), which jumps throughGOT[2]into_dl_runtime_resolve(4). _dl_fixuplooksprintfup in libc's symbol table, writes0x7ffff7e04880into slot0x4000and jumps there (5).
Every later call is call printf@plt plus one indirect jmp *0x4000 straight to libc: 2 jumps, against 1 for the static file. LD_BIND_NOW=1 or linking with -z now resolves all slots at start-up instead, and full RELRO (-z relro -z now) then makes the GOT read-only, which closes a common exploit target. Step through the same detour with real listings on How a C Program Is Compiled and Run; How Linux Loads a Program shows execve and ld.so in detail.
Updates and memory
When glibc gets a fix in printf, a dynamic system replaces one file, libc.so.6, and every program picks it up at its next start (Demo: a bug fix in libc). A static program keeps the copy of the old printf it was built with; each one must be relinked and reinstalled. Processes already running keep the old library mapped until they restart, which is why package managers ask to restart services after a libc update.
The pages of libc.so.6 are read from the page cache and mapped into every process that uses it, so its code is in RAM once. Each static program brings its own copy. In Demo: three programs in memory (numbers illustrative), one static program needs 740 KB of libc code and the dynamic one 1 012 KB, because ld.so and whole pages of libc come with it. With three different programs it is 2 640 KB against 1 456 KB.
Three copies of the same static program share their pages too: the page cache holds one copy of each file. Static linking loses only across different programs.
The other side: a dynamic program needs a compatible library wherever it runs. glibc versions its symbols, and a program linked against glibc 2.34 or newer asks for __libc_start_main@GLIBC_2.34. On a machine with glibc 2.31, ld.so stops before main with version `GLIBC_2.34' not found (Demo: copy sum to an older system). The static file needs nothing but the kernel's system calls, which is why static binaries are popular for containers built FROM scratch and for tools copied between machines.
Which one?
Static (-static) | Dynamic (default) | |
|---|---|---|
file size of sum | 754 416 B | 16 088 B |
| start-up | kernel → _start | kernel → ld.so (map libc, 90 relocations) → _start |
a call to printf | 1 direct call | 1st: 5 jumps + a lookup; later: 2 jumps |
| a fix in libc | relink every program | replace libc.so.6, restart |
| libc code in RAM, 3 different programs | one copy each: 2 640 KB | shared once: 1 456 KB |
| runs on an older system | yes, it needs only the kernel | only with a new enough libc |
| good for | single-file tools, scratch containers, rescue systems, embedded | a whole distribution, plugins (dlopen), security updates |
Go and Rust link their own code statically by default, and Go's binaries are often fully static. Linux distributions link almost everything dynamically. Static glibc is not fully static either: functions like getaddrinfo still load NSS modules at run time, which is why musl libc is the usual choice for static binaries. For the C++ side (libstdc++.so, name mangling) see How a C/C++ Program Is Compiled and Run: What C++ Adds.
What the page leaves out
Position-independent code inside the shared library itself and its own GOT, symbol interposition and LD_PRELOAD, dlopen and plugins, RPATH/RUNPATH and how ld.so searches for libraries (ld.so.cache), sonames and library versioning (libc.so.6 vs libc.so), static PIE, IFUNC relocations that pick a CPU-specific memcpy, link-time garbage collection (--gc-sections), prelinking, the cost of ASLR, and Windows DLLs and macOS dylibs, which solve the same problem with import tables and stubs.