Everything so far has been about sections: which input sections go into which output section, and where. A linker script also controls the segments, and that is a different table in a different part of the file, written by a different command.
Here is a script that builds a complete executable and says so explicitly:
PHDRS {
code PT_LOAD FLAGS(5);
data PT_LOAD FLAGS(6);
}
SECTIONS {
.text 0x08000000 : { *(.text .text.*) } :code
.data 0x20000000 : { *(.data .data.*) } :data
}
Two commands, and they are the whole of segment control. PHDRSdeclares the segments that will exist. The :code and :data at the end of each rule assign sections to them. And FLAGS(5) is not a mnemonic: it is the literal p_flags bitmask, where PF_X = 1 and PF_W = 2. 5 is R|X. 6 is R|W.
Why there are two tables at all, and what each one is actually for.
So PHDRS is not an optimisation. It is a handover. It is the point where you stop describing sections and start telling a program that has never seen your script what to map into memory. The ELF course taught what a PT_LOAD is; this is where you write one.
That PHDRS keyword is worth pausing on, because it explains an error you will otherwise hit within an hour of writing a minimal script. Without a segment that covers the program header table, the file is malformed — the kernel reads the headers to find out where the headers are.
Two segments requested, two segments delivered, at the addresses requested:
$ ld -T ph.ld -o ph.out hello.o -e helper $ readelf -lW ph.out | sed -n '/^ LOAD/p' LOAD 0x001000 0x0000000008000000 0x0000000008000000 0x000058 0x000058 R E 0x1000 LOAD 0x0000b0 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x1000
One R E at 0x80000000 and one RW at zero, exactly as declared. Note that the second is empty — hello.o has no .data, so the segment has nothing in it. The linker emitted it anyway, because PHDRS says the segment exists. You asked for two, you got two, and one of them is a segment with no contents.
That is the honest difference between a script that lists segments and one that lets the linker invent them, and it is worth making explicit: with PHDRS the segment list is a specification, not a summary. The default script has no PHDRS block at all and produces fourteen program headers for a trivial program, every one of them inferred.
Now the part of this concept that will actually cost you an afternoon, and it is not about PHDRS at all:
$ for args in "-fPIE -pie" "-fPIE -pie -T default.ld" "-fPIE -T default.ld -pie"; do
clang -O1 $args hello.o -o pie.out
printf " %-28s e_type=%-5s first LOAD=%s\n" "$args" \
"$(readelf -hW pie.out|sed -n 's/.*Type: *\([A-Z]*\).*/\1/p')" \
"$(readelf -lW pie.out|awk '/LOAD/{print $3;exit}')"
done
-fPIE -pie e_type=DYN first LOAD=0x0000000000000000
-fPIE -pie -T default.ld e_type=EXEC first LOAD=0x0000000000400000
-fPIE -T default.ld -pie e_type=EXEC first LOAD=0x0000000000400000
-T turns a PIE into a non-PIE, and the order of the flags does not matter. Not “mostly ignores” — overrides. -fPIE -pie -T default.ld and -fPIE -T default.ld -pie produce the same ET_EXEC at 0x400000, and putting -pie first does not help.
This is the same effect that made the round trip fail to reproduce a PIE build byte-for-byte, and it is the single most surprising property of -T I found. The reason is that the code model is not a linker flag at all on this target — it is inferred from what the script and the emulation describe:
The minimal script that does not work, and why — because the failure is the fastest way to learn what a linker script owes the format.
$ printf '/* nothing but this */\n' > empty.ld $ clang -O1 hello.o -o out -T empty.ld /usr/bin/x86_64-linux-gnu-ld.bfd: out: error: PHDR segment not covered by LOAD segment clang: error: linker command failed with exit code 1
The very first complaint, before it says anything about your sections, is about the program headers. That is the phase order from the language concept showing up as an error message: ld tried to build the program header table, found that no segment covered it, and stopped. An empty script does not mean “no headers” — it means “headers I cannot account for”.
Three ways to fix it, and the differences matter:
1. SAY IT
PHDRS { headers PT_PHDR PHDRS; text PT_LOAD FLAGS(5); }
SECTIONS { .text : { *(.text) } :text }
^ and add :headers
^ to something, or use
FILEHDR PT_PHDR PHDRS
on the LOAD
2. DELEGATE
SECTIONS {
. = 0x400000 + SIZEOF_HEADERS;
.text : { *(.text) }
}
no PHDRS block at all, so ld infers the headers
as it normally does. THIS is what the eleven-line
script does, and it links.
3. EXTEND THE DEFAULT
SECTIONS { .mytext : { *(.mytext) } }
INCLUDE
^ and this DOES NOT WORK. INCLUDE with no argument
is a syntax error in GNU ld 2.46:
$ printf 'INCLUDE\n' > justinc.ld
$ ld -T justinc.ld ...
ld.bfd:justinc.ld:0: syntax error
THERE IS NO "extend the default script" MECHANISM.
-T REPLACES it. the only way to extend is to
save the default and edit the copy, which is what
this course's build script does:
ld --verbose | sed -n '/^====/,$p' | sed '1d;$d' > my.ld
That last one is the practical lesson of this concept and it is worth internalising. There is no INCLUDE-the-default, no --extend-script, and no partial override. -T gives the script total authority over layout, which is exactly what you want for firmware and exactly what surprises people who expected an override mechanism.
And because there is no extend mechanism, the round-trip identity from the first concept becomes the tool you actually want. Save ld --verbose, edit one number, and you have a script that is provably equivalent to the default except where you changed it:
$ ld --verbose 2>/dev/null | sed -n '/^====/,$p' | sed '1d;$d' > default.ld
$ sed 's/SEGMENT_START("text-segment", 0x400000)/SEGMENT_START("text-segment", 0x800000)/g' \
default.ld > mine.ld
$ clang -O1 -fno-pie -no-pie hello.o -o a.out
$ clang -O1 -fno-pie -no-pie hello.o -o b.out -T mine.ld
$ cmp a.out b.out && echo IDENTICAL
IDENTICAL
$ ./b.out; echo " and it runs, exit=$?"
and it runs, exit=0
Identical to the default build, and running from the address you chose. That is the whole workflow for changing one thing about a link, and it is available because the linker ships its own program as text.
$ cd courses/link/assets/samples $ ./build_samples.sh 2>&2 | sed -n '/F8/,/F10/p' $ python3 crosscheck.py 2>&2 | sed -n '/\\[8\\]/,/\\[10\\]/p' $ python3 linklab.py base 0x3000000
Then break the header table, which is the fastest route to understanding it:
1. Take the two-segment ph.ld and add a third:
bss PT_LOAD FLAGS(6);
but do NOT add a :bss to any rule. What does the
linker say, and does it link? (Compare with the
empty data segment in the main example.)
2. Add PHDRS to the code segment and re-link. Then
add FILEHDR. What changes in the offsets? Watch
p_offset of the first LOAD -- it should move.
3. Write a script with PHDRS but NO SECTIONS block
at all. What is the error, and is it the same one
as the empty script?
4. Now the important one. Take default.ld, and add
a PHDRS block declaring exactly the segments it
already produces (4 LOADs: R, RE, R, RW). Does
the binary still come out identical? If not, what
is the smallest difference, and which part of the
default's inferred layout is PHDRS making
explicit?
5. Try to get a PIE out of a script. Start from
default.ld, add PHDRS, and see how far you get.
(You will need the DYN output format AND you will
find the script's fixed 0x400000 has to go. The
full fix is in the ld manual under "PIC" and it
is genuinely fiddly -- which is why people write
PIE binaries without scripts.)
Exercise 4 is the one that closes the module, and its result is the payoff. If a hand-written PHDRS block that matches the default’s inferred layout produces an identical binary, then the default layout is fully specified by what a script could say — the linker is not making choices a script could not have made, and everything ld does is available to you. If it does not produce an identical binary, the difference tells you exactly which of ld’s choices are conveniences rather than requirements, and that is genuinely useful knowledge.
Exercise 5 is the honest one to attempt and the honest one to give up on partway. “How do I make a script that produces a PIE” is a real question with an awkward answer, and discovering that is more instructive than being handed the answer, because it shows that the default script is not a template you can adapt — it is a specific choice of fixed-layout output, and PIE is a different choice with a different set of requirements.
This closes the placement module, and the three concepts in it are one argument in three parts. The Location Counter gave you the variable. MEMORY Is a Budget gave it a limit, and showed the only diagnostic a link produces about layout. This one gave the layout away from the linker entirely, with PHDRS for the table and the -T finding for who decides the code model.
The connection to the ELF course's memory-mapping concept is the deepest in the course, and it is a loop closing. That concept measured how a Linux kernel maps a PT_LOAD: page granularity, one set of permissions per mapping, and the consequence that segments must be page-aligned. This concept is the place you write the PT_LOAD that has to satisfy those rules — and the ALIGN calls that the previous concept justified are visible in the measured output above as the 0x1000 in the last column of every segment. The rule and the mechanism that enforces it are now in the same head, which is where a format stops being a table of fields and becomes something you can construct.
The -T finding is a correction to the PIE module of the Relocations course, and it is worth being precise about what it does and does not change. The Flags, and Which One Is Which Kind drew a 3×3 matrix of -f codegen flags against -pie/-no-pie link flags, and found four cells where the two disagree and the result is neither thing. That matrix has a fifth column it did not know about: the script. A -T flag is a third participant, it is not a codegen flag or a link flag, and it can override both. The cost measurement in that course showed that -fno-pie -no-pie is “the worst of both worlds” because you pay the PIC cost and get no ASLR; supplying a script is the same failure with a different mechanism, and this concept is where it is explained.
Forward into the selection module, this concept completes the picture of what a script can decide. Placement is done — addresses, budgets, segments. What is left is membership: which sections are in the output at all, and which of those are kept when the linker throws most of them away. Reachability, and Who the Roots Are is that question, and it is the one place where a single word inside a rule — KEEP — is the only reason a piece of code exists in your binary.
And the connection to the round trip is the practical one. Because -T replaces rather than extends, the only maintainable way to change a link is to start from ld’s own script and edit it — and the reason that works is the byte-identical round trip. Without that identity you would be maintaining a fork of a 276-line program with no way to tell whether it had drifted; with it, a one-line diff is provably the only change. That is what an applier you can verify by hand buys you, applied to a linker rather than to a relocation.