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.

Left: the static sum file, 754 416 bytes, with an ELF header, your .text, a large block of 431 libc.a members including printf at 0x4047a0, and data. Right: the dynamic sum file, 16 088 bytes, with an ELF header and .interp, .dynamic NEEDED libc.so.6, your .text, a printf@plt stub and a GOT slot that points to printf in the separate file libc.so.6
The static file contains libc's code; the dynamic file contains only a reference to libc.so.6 and a stub plus a slot to reach printf.

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:

  1. call printf@plt (jump 1); the stub does jmp *0x4000 (jump 2), which lands on the stub's next line, 0x1036.
  2. The stub pushes the relocation number 0 and jumps to PLT0 (3), which jumps through GOT[2] into _dl_runtime_resolve (4).
  3. _dl_fixup looks printf up in libc's symbol table, writes 0x7ffff7e04880 into slot 0x4000 and 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.

Bars of libc code in RAM for 1, 2 and 3 running programs. Static: 740, 1 650 and 2 640 KB, one private copy per program. Dynamic: 1 012, 1 294 and 1 456 KB, one shared libc.so.6 plus ld.so and 12 KB per process
One program: static is smaller. Every further program adds a whole private copy, while libc.so.6 stays in RAM once.

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 sum754 416 B16 088 B
start-upkernel → _startkernel → ld.so (map libc, 90 relocations) → _start
a call to printf1 direct call1st: 5 jumps + a lookup; later: 2 jumps
a fix in libcrelink every programreplace libc.so.6, restart
libc code in RAM, 3 different programsone copy each: 2 640 KBshared once: 1 456 KB
runs on an older systemyes, it needs only the kernelonly with a new enough libc
good forsingle-file tools, scratch containers, rescue systems, embeddeda 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.