Nine concepts of reading, and this one writes. The tool is linklab.py, in this course’s sample directory, and it is deliberately shaped around the three things the course established rather than around convenience:
Point 3 is the one that came from a scar. During the writing of this course, two separate measurements produced right-sized but wrong files, and both were caught only by comparing bytes or by checking the generated script actually contained what it was supposed to. That is now a standing assertion in the build script and the crosscheck, and it is the first thing linklab.py does when you ask it to move a binary.
The tool, and what each subcommand demonstrates.
$ python3 linklab.py
show print the default linker script ld is using
base move the whole binary to an address,
verify, and RUN it
budget a MEMORY script with a real, enforced budget
order the four-cell first-match-wins experiment
gc reachability with and without --gc-sections
tour all of the above, each step verified
Every one of those is a concept in this course, expressed as an operation that ends in a PASS or a FAIL. The interesting part is what each one has to check, because the check is the content:
The whole tour, verbatim:
$ python3 linklab.py tour === linklab: a linker script is a program === The script ld is using right now is 276 lines long. Move the whole binary to 0x3000000 replaced 2 occurrence(s) PASS link succeeded PASS first PT_LOAD is at 0x3000000 PASS e_type is EXEC (a script implies a fixed model) PASS and the program RUNS at that address (exit 0) PASS some PT_LOAD is marked executable PASS and it is at or above the first load (headers come first) A MEMORY script with a 4M budget PASS LENGTH=4M links PASS .text landed at the rom ORIGIN PASS and non-allocated sections stayed at 0 PASS LENGTH=64 refuses the link PASS and the error names the region PASS and says by how much Which rule wins? Four cells, and the answer is not what I expected keep only .labnote kept: True discard only .labnote kept: False keep FIRST, discard 2nd .labnote kept: True discard 1st, keep 2nd .labnote kept: False PASS first-match-wins in all four cells so /DISCARD/ is NOT special: in cell 3 it is simply the last rule, and in cell 4 simply the first. --gc-sections is reachability, not liveness -O1 --gc-sections off lab_* survivors: lab_also_dead lab_dead lab_used .text=0x123 -O1 --gc-sections on lab_* survivors: .text=0xf3 -O0 --gc-sections off lab_* survivors: lab_also_dead lab_dead lab_used .text=0x152 -O0 --gc-sections on lab_* survivors: lab_used .text=0x122 PASS at -O1 the collector removed lab_used PASS at -O0 the collector KEPT lab_used the only difference is inlining: at -O1 main folds lab_used(41) to 42, so the out-of-line copy has no caller. PASS and .text shrank by exactly the collected functions (0x30) Every step verified.
Twenty checks, all green, from a directory containing two files. And notice the two PASS lines that are really about the tool rather than the linker: “the program RUNS at that address” and “some PT_LOAD is marked executable”. The first existed because the tool initially asserted the first load was executable, which is wrong — the first load is the read-only ELF headers. The check caught its own author.
Now the exercise, and it is a real one rather than a demonstration. Write a script from an empty file:
$ printf '/* mine */\n' > mine.ld $ clang -O1 hello.o -o out -T mine.ld ld.bfd: out: error: PHDR segment not covered by LOAD segment
Add OUTPUT_FORMAT and ENTRY, and link again — the next complaint is about your sections, not the headers. Add a SECTIONS block with .text only, and it links and runs. Then one section at a time:
. = 0x400000 + SIZEOF_HEADERS;
.text : { *(.text .stub .text.*) }
.rodata : { *(.rodata .rodata.*) }
.data : { *(.data .data.*) }
.bss : { *(.bss .bss.* ) }
Eleven lines, and it is a complete, working linker script for a hosted Linux program — smaller than a tenth of the default, and every line in it is one you chose. The exercise is to reach that file from an empty one by letting the linker’s errors tell you what is missing.
The difference between a script you wrote and a script you understand, expressed as one experiment. Add a section to your program that your script does not name:
/* four sections named. add a fifth. */
__attribute__((section(".mystuff"), used))
static int mine[4] = {1,2,3,4};
int get_mine(int i){ return mine[i & 3]; }
$ clang -O1 -T mine.ld hello.o -o out
$ ./out; echo "exit=$?"
exit=0 <-- IT WORKS. and that is the problem.
$ readelf -SW out | grep mystuff
[15] .mystuff PROGBITS 0000000000900f0 ... 00 A 0 0 8
^ the orphan fallback placed it, at the END of
everything your script placed. it ran because
nothing was wrong yet.
It works, which is exactly what makes it dangerous. The section is allocated, its address is valid, the program reads the right values, and there is no diagnostic. Now turn on the one mechanism that would have caught it:
$ clang -O1 -T mine.ld hello.o -o out -Wl,--orphan-handling=warn 2>&1 warning: orphan section `.mystuff' from `hello.o' being placed in section `.mystuff' $ clang -O1 -T mine.ld hello.o -o out -Wl,--gc-sections -Wl,--orphan-handling=warn warning: orphan section `.mystuff' from `hello.o' being placed in section `.mystuff' $ ./out; echo "exit=$?" exit=139 <-- SEGFAULT
Add --gc-sections and the program now crashes. Because the orphan fallback and garbage collection interact: the fallback placed the section, but the reachability walk did not mark it live, and --gc-sections removed it. Two features that each work correctly, composing into a failure — and the fix is one line:
.mystuff : { KEEP (*(.mystuff)) }
^^^^^
and now both mechanisms agree: your script owns the
section, it is placed where you said, and it survives.
That composition is the most useful thing in this concept, because it is the shape of real linker-script bugs. You will not be handed a script that is missing a section and a build with --gc-sections on the same day on purpose; you will add the section in March, and somebody will add the flag in September, and the program will start crashing in a build that nobody touched. Neither change was wrong. The composition was. And a one-line script that names and KEEPs the section makes the class impossible.
Which is the argument for owning your script. The default script would have handled the section by the fallback, and the fallback and --gc-sections would have disagreed in exactly the same way — so the danger is not having a custom script, it is having an implicit one. A script you wrote is a script you can read.
$ cd courses/link/assets/samples $ python3 linklab.py tour $ python3 linklab.py base 0x2000000 $ python3 crosscheck.py ALL 124 CHECKS PASS
Then extend the tool, which is the exercise rather than the demonstration:
1. Add a `stack` subcommand that puts .bss at a
fixed address with a KEEP, using a MEMORY region.
Then check that the section really is there, not
just that the link succeeded.
2. Add a `probe` subcommand that reads any
SEGMENT_START or CONSTANT out of a script and
prints it. It needs the -u trick from the first
concept -- PROVIDE is conditional, so a symbol
nothing references is never emitted. Verify the
-u is in there before you wonder why it prints 0.
3. Add a `diff` subcommand: dump two scripts and
show only the lines that differ. This is the tool
you actually want when a build changes size and
you do not know why, and it is about fifteen lines.
4. Make `order` print a warning if any of its four
generated scripts does NOT contain the rule it is
supposed to. That check is the direct descendant of
the bug that made this course briefly claim
/DISCARD/ overrides rule order -- a no-op string
replace looks exactly like a linker obeying you.
5. Finally, and this is the real test: delete
build_samples.sh, keep the three scripts, and check
that crosscheck.py still passes from an empty
directory. It should not -- the specimens are
generated -- which tells you exactly which files are
sources and which are products. Knowing that about
your own build is the same skill.
Exercise 3 is the one that becomes useful immediately and forever. A diff of two linker scripts is the fastest way to answer “why did my binary get 4 KB bigger” when you cannot remember what you changed — and it works because both scripts are text, which is the property that made this course possible. Before ld --verbose existed as a workflow, that question had exactly one answer, which was “read the linker source”.
Exercise 4 is the one that makes the tool trustworthy, and it is deliberately placed here rather than earlier. A tool that generates inputs must verify the inputs it generated, because the failure mode of a no-op edit is indistinguishable from correct behaviour. That is a lesson about verification rather than about linkers, and it is the one that has bitten this course twice.
This is the end of the course, and it is the concept that makes the other nine into a skill rather than knowledge. The script is source you can read; eight constructs; first-match-wins; the dot; budgets; segments, and the -T trap; reachability and KEEP; the fallback; what static costs. And now a tool that does all of it and checks the answer.
The chain this course sits in is almost complete, and it is worth seeing where each piece ended up. The Object Files course established what a relocation record is and, at the end, wrote a 936-byte ELF object byte by byte. The Symbol Resolution course established who decides what a name means, and traced the map file. The Relocations course established the vocabulary and wrote a relocation applier that accepted a layout as input and said so in a comment. This course is that comment's subject.apply_relocs.py had a build_layout() function and a note that deciding the layout is “a different program with a much harder problem” — and a linker script is that program, written in a language with eight constructs and no control flow.
The next link in the chain is the one this course hands on, and it is not another course. It is a thing you can run.linklab.py is 250 lines, needs nothing but a compiler and ld, and every subcommand ends in a verdict. If the mission of the collection is a learner who can build a compiler that targets any architecture and emits executables without a backend, then this is one of the small pieces of that: a placement engine you can read end to end and change, driving a real linker and verifying the result by running it.
Three debts the earlier courses left, settled here. The -. != 0 conditional inside .bss from the location-counter concept is a real unsolved question with a two-line answer written after it — which is what ordinary source code looks like, and worth knowing that the toolchain is ordinary. The PROVIDE conditionality from the linker-defined symbols concept is the -u trick, and it is now a documented feature of the tool rather than a surprise. And the map file from the resolution algorithm concept is where the linker records every decision this course makes about placement, including the ones it guessed — which makes it the tool you reach for when any of this has gone wrong in a build you did not write.
And the last connection, to the platform you are reading this on. The Underlayer server is built with this toolchain, in Chemical, and it serves these pages. Module 2’s finding — that -T overrides the code model — is a fact that applies to the binary running right now, and the fact that you can read the script driving it is a property of the toolchain, not of the language. Nothing prevents the same inspection here: the server is an ELF file, ld --verbose is one command, and the 276 lines that placed it are readable. That is the through-line of the whole collection, and it is not a metaphor.