“Static linking” is three different things wearing one name, and the name is why most explanations of it are unsatisfying. The program to measure is one line:
$ printf 'int main(void){ return 0; }\n' > tiny.c
$ clang -O1 -o s_dyn tiny.c
$ clang -O1 -static -o s_static tiny.c
$ for f in s_dyn s_static; do
printf " %-9s size=%-8s e_type=%-5s PT_INTERP=%s PT_LOADs=%s\n" $f \
"$(stat -c%s $f)" \
"$(readelf -hW $f|sed -n 's/.*Type: *\([A-Z]*\).*/\1/p')" \
"$(readelf -lW $f|grep -c INTERP)" \
"$(readelf -lW $f|grep -c '^ LOAD')"
done
s_dyn size=15880 e_type=DYN PT_INTERP=1 PT_LOADs=4
s_static size=825160 e_type=EXEC PT_INTERP=0 PT_LOADs=4
52 times bigger, and the e_type changed. Those are the two headline facts and they are not independent — and the connection between them runs straight through a linker script, which is why this concept is in this course.
Three distinct meanings, and only one of them is usually meant.
So the flag does two separable things: it stops recording references to libc.so and pulling in libc’s code, and it stops emitting PT_INTERP. The second is what makes the binary runnable by the kernel alone. A file can be in an odd middle state — statically linked against libc but still with an interpreter — and that is worth knowing exists, because it is how you get an error about a missing dynamic loader on a system that obviously has one.
And here is the link to this course: that .text is at 0x400000 because of a line in the script.
The segments, side by side, and the number that ties the two builds to one file:
$ for f in s_dyn s_static; do
printf " %s:\n" $f
readelf -lW $f | awk '/^ [A-Z]/ {
t=$1; v=$3; fs=$5; fl=$6; if ($7=="E") fl=fl" E";
printf " %-7s vaddr=%-18s filesz=%-9s %s\n", t, v, fs, fl }'
done
s_dyn:
PHDR vaddr=0x0000000000000040 filesz=0x000310 R
INTERP vaddr=0x0000000000000374 filesz=0x00001c R
LOAD vaddr=0x0000000000000000 filesz=0x0005d0 R
LOAD vaddr=0x0000000000001000 filesz=0x000141 R E
LOAD vaddr=0x0000000000002000 filesz=0x0000f8 R
LOAD vaddr=0x0000000000003e00 filesz=0x000210 R W
DYNAMIC vaddr=0x0000000000003e10 filesz=0x0001b0 R W
NOTE / GNU_PROPERTY / GNU_EH_FRAME / GNU_STACK / GNU_RELRO
s_static:
LOAD vaddr=0x0000000000400000 filesz=0x000518 R
LOAD vaddr=0x0000000000401000 filesz=0x084a8d R E
LOAD vaddr=0x0000000000486000 filesz=0x027f30 R
LOAD vaddr=0x00000000004ae108 filesz=0x00b180 R W
Three things, and the third is the one that ties the module together.
One: the dynamic binary has all four PT_LOADs at addresses near zero — 0, 0x1000, 0x2000, 0x3e00. That is because it is ET_DYN: the file records offsets relative to zero and the loader adds the base at run time. The Relocations course measured that base moving across runs; this is the same fact seen in the file rather than in a process.
Two: the static binary is at 0x400000, and that is the script’s number.-static did not choose an address. It changed the e_type to ET_EXEC, and ld laid the image out at the fixed address the default script asks for:
$ grep -n 'SEGMENT_START("text-segment"' default.ld | head -1
13: . = SEGMENT_START("text-segment", 0x400000) + SIZEOF_HEADERS;
and the proof that -T moves it, from module 2:
base=0x800000 first LOAD=0x0000000000800000 ran, exit=0
Three: both have four PT_LOADs. The size changed by 52× and the segment count did not move at all, because the script’s very first line says why:
$ head -1 default.ld /* Script for -z combreloc -z separate-code */
-z separate-code is why a 52×-larger binary has the same number of segments. It forces the read-only headers, the executable code, the read-only data, and the writable data into four separate mappings, so the loader can apply W^X: code is never in a writable page. It costs a page of address space and one more mmap, and it is on by default. The default script you have never read is a different script depending on your flags, and the comment on line 1 is the only place that says so.
And the honest cost that nobody puts in the man page:
a static glibc binary does NOT work for:
- getaddrinfo / NSS (name resolution needs dlopen
of libnss_*.so at RUN TIME)
- anything using dlopen
- locales loaded on demand
- gethostbyname, getpwnam, and friends
and you find out at RUN TIME, on a machine that has
every one of those libraries installed, because the
failure is "cannot open shared object file" from a
program that does not otherwise use shared objects.
This is the real limit of -static on glibc and it is architectural, not a bug. Name resolution is specified to consult files in /etc at run time; making that work in a static binary requires dlopen, which requires a dynamic loader, which -static just removed. You cannot have both with this C library. The lesson is not “static is bad” — it is that a large fraction of “static linking” is really “which dynamic features am I giving up”.
Where -static genuinely is the right answer, and what the linker does that is not obvious — building a static binary with a script that knows about the absence of an interpreter:
# the eleven-line script, extended for static
OUTPUT_FORMAT("elf64-x86-64", "elf64-x86-64", "elf64-x86-64")
OUTPUT_ARCH(i386:x86-64)
ENTRY(_start)
SECTIONS
{
. = 0x400000 + SIZEOF_HEADERS;
.text : { *(.text .stub .text.*) }
. = ALIGN(0x1000);
.rodata : { *(.rodata .rodata.*) }
.data : { *(.data .data.*) }
.bss : { *(.bss .bss.*) }
/DISCARD/ : { *(.note.GNU-stack) *(.comment) }
}
$ clang -O1 -static hello.c -o hi -T mine.ld
$ ./hi; echo "exit=$?"
exit=0
$ readelf -lW hi | grep -c INTERP
0
It works, and the /DISCARD/ line is doing real work. With a script you own, you can drop the section-name comments and the note sections outright, rather than letting the fallback place them. The previous concept measured what the fallback does with orphans you did not mention; here you mention everything, so nothing is guessed.
And the honest comparison of the two ways to build a static binary, which is a decision people actually face:
-static with the DEFAULT script
+ 0 lines of script to maintain
+ the layout is whatever 800+ people maintain
- you cannot change anything
- every section the script orphans is placed by
a table inside the binary
-static with YOUR script
+ full control, and nothing is guessed
+ you can drop sections, reorder, place absolutely
- you own the maintenance, and you will be wrong
about a section name eventually
- -T implies EXEC, which is what you want here but
is a trap if you forget it (module 2)
the deciding question is not "which is better" but
"do I need to change the layout". if no, use the
default. if yes, you have no option.
One more measurement, because it is the honest counterweight to the 52× and nobody mentions it. A static binary is not one binary in practice — it is one binary per libc. Linking against glibc statically binds you to that glibc, including its versioned symbol set. Build on a newer distribution, ship the binary, run it on an older one, and the kernel can load it — and libc’s copy of memcpy may not know about a kernel feature the CPU now offers. That is the cost, and it is a distribution problem rather than a linker problem, which is why “static binaries are portable” is true in the sense that matters and false in the sense people usually mean.
$ cd courses/link/assets/samples $ ./build_samples.sh 2>&2 | sed -n '/F12/,$p' $ python3 crosscheck.py 2>&2 | sed -n '/\\[12\\]/p'
Then measure the thing nobody measures, which is what static actually costs at run time:
1. Run ldd on both. Then run them under a
strace that only counts OPENAT. How many files
does each open before main()? (This is the
real cost of dynamic linking and it is not the
size of the binary.)
2. Compile a program that calls getaddrinfo, once
-static and once not. Run both. Does the static
one fail? (If your distro's static glibc has NSS
built in, it may work -- which is a MORE
interesting result, and worth finding out which
you have.)
3. Now the experiment that ties the module together.
Build s_static with YOUR script, change the base
to 0x10000000, and run it. (It works. Then ask
why -- which line of the script, and which
concept measured it.)
4. Build s_static with -Wl,-z,noseparate-code, and
then check what ld --verbose says its script is.
How many PT_LOADs now? (TWO, not three -- see
below.) And which of the two builds has its
READ-ONLY DATA in an executable segment? That is
the whole security difference, and it is one flag.
5. Strip both binaries. How much of the 52x is the
symbol table rather than the code? (Hint:
objcopy --strip-all, then compare .text.)
Exercise 4 is the one that closes the loop on the first finding in this concept, and it is a one-flag experiment with a clean answer. -z noseparate-code takes four PT_LOADs down to three — the read-only data segment merges into the headers segment — and the script you were given changes accordingly, because the comment on line 1 literally names the flag. ld --verbose gives you a different 276-line script depending on your -z flags, and that is a fact worth holding onto before you go editing either one.
Exercise 2 is the one that teaches the real constraint. Whether the static getaddrinfo works or fails tells you what your distribution’s static libc can do, and either answer is instructive: if it fails, you have the classic limitation; if it works, you have a build that silently links against a libc that will disagree with a different one. Both outcomes are things you need to know before shipping.
This is the first of the two closing concepts, and it is where the course’s earlier threads converge on one number. The Flags, and Which One Is Which Kind drew the 3×3 matrix of codegen against link flags and found four cells where the two disagree. PHDRS and the -T Surprise found that a script is a third participant which overrides both. This concept shows what that override is for:-static changes the e_type to ET_EXEC, the script then lays the image at a fixed address, and the kernel maps it with no loader involved. The 0x400000 is not a compiler decision or a linker decision in isolation — it is the script answering a question the code model asked.
The connection to the ELF course's entry-point concept is the mechanism that makes a static binary work at all, and it is worth restating as the reason the whole thing is possible. PT_INTERP is a program header whose contents are a pathname. A dynamic binary is executed by a two-step process: the kernel maps the interpreter, the interpreter maps the program, and the interpreter reads e_entry from the program and jumps there. A static binary is one step: the kernel maps it and jumps to e_entry. Everything -static removes is the first half of that two-step process, and the segment measurements above are simply the absence of one header in a fourteen-header file.
And the -z separate-code finding connects to the randomisation concept in a way that is easy to miss. That concept measured that a PIE gets a random base and argued that attacks (a) hard-coded addresses and (b) code pointers in .data stop working. Attack (d) — a vtable or function pointer in read-only data — was listed as not defeated by PIE, and the fix given was RELRO. Now you can see why the flag that produces four segments instead of two is the same decision:separate-code exists so that the read-only data segment — where those function pointers live — is in pages that are not writable, and merging it back with the headers would put it somewhere the loader might have to treat differently. Layout and security policy are the same lever.
Forward to the last concept, which is the buildable artifact and the reason this course exists. Everything so far has been reading a 276-line file and running flags. Writing One Yourself makes you generate one, verify its effect, and run the result — and the tool for it, linklab.py, is deliberately shaped around the three things this course established: that a script is a program you can edit, that its effect is checkable rather than assumed, and that a linker will not tell you when your edit did nothing.
One connection outward, and it is the honest limit of everything in this module. Every number on this page was measured on a hosted Linux target with a dynamic loader present. The e_type change, the removed PT_INTERP, the four segments, the 52× — all real, all reproducible from one script. But the question that matters for a static binary is “does it run on the machine I did not build it on”, and that is a question this toolchain cannot answer and this course does not claim to. It is the same limit the Relocations course recorded for AArch64: the mechanism is measured, the execution is not.