Underlayer
LearnPathReviewProgressSearch
Your space
DashboardBookmarksNotesStudy plannerAchievementsStreaksAlertsCertificates
HelpFAQSettings
Sign in to keep your progress across lessons.Sign inor create an account
Concept 6 of 10 in Placing ThingsPrevious: MEMORY Is a BudgetNext: Reachability, and Who the Roots Are
Claim your certificate

Sign in to bookmark this lesson, keep notes on it, and send corrections.

    Back to course

    PHDRS and the -T Surprise

    ⏱22 min▲advanced☰Module 2: Placing ThingsContinues from link-memory-regions
    22 min · Module 2: Placing Things · Advanced

    Why This Matters

    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.

    A Simple Model

    Why there are two tables at all, and what each one is actually for.

    SECTIONS the LINKER's view. a partition of the input, with addresses. this is what a linker computes. PROGRAM the KERNEL's view. what to mmap, with HEADERS what permissions, and where in the file the bytes are. a hosted ld INVENTS the program headers: it looks at the output sections, works out which can share a mapping, and emits PT_LOADs. you never see that. PHDRS turns the invention off. you list the segments, and the linker emits exactly those. which is what you need when the layout is not the usual one -- a bootloader, a kernel, anything where the segments have to be exactly as written.

    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.

    THE FULL FORM PHDRS ... name type [ FILEHDR PHDRS ] [ FLAGS(n) ] [ AT ( address ) ] [ ALIGN(n) ] [ SIZE(n) ] (the block is brace-delimited; braces are legal in a script and illegal in this box, so they are written "..." here and quoted for real in the dump above) FILEHDR include the ELF header in this segment PHDRS include the program header table itself (this is why a tiny script needs it: if no segment covers the headers, you get "PHDR segment not covered by LOAD segment") FLAGS the p_flags bits AT give the segment its own address ALIGN the segment's p_align SIZE an explicit size

    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.

    Does It Actually Work?

    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:

    a script that lays the image out at a FIXED address is describing a non-relocatable output. ld takes the script as the statement of intent and sets e_type to ET_EXEC accordingly. -pie is not consulted, or is consulted and overridden. so the rule is: -pie and -T are not two ways to say the same thing. -pie asks for a relocatable output. -T asks the script to decide. and the default script decides EXEC. if you want a PIE from a script, the SCRIPT has to ask for it -- which is why a real PIE linker script names the DYN output format and sets up the segment structure itself.

    A Real Example

    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.

    Try It Yourself

    $ 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.

    Connect

    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.

    Previous: MEMORY Is a BudgetNext: Reachability, and Who the Roots Are