[The World of Linkers—Theory 05] A Place for Every Section—and an Exit for Dead Code
S+A−P is a complete relocation formula, but it is not a complete answer. Someone must decide where S and P are. Change the linker, remove an unused section, or adjust an alignment requirement, and the same instruction may need different bytes.
Layout turns many files' relative positions into one image's addresses. It also decides which bytes share pages and therefore share memory permissions. This makes layout both an arithmetic problem and part of the contract between the linker and the loader.
Our native x86-64 Linux experiments use Clang1 21.1.8, GNU2 ld/readelf 2.46, and LLD3 21.1.8. The deliberately separate xv6 comparison later in the chapter inspects RISC-V4 objects generated on the same Linux host.
The same source, three addresses
This file supplies initialized data, zero-initialized data, a string, used and unused functions, and its own entry point:
int counter = 1; /* .data */int buffer[1024]; /* .bss */const char msg[] = "hi"; /* .rodata */
__attribute__((noinline)) int used(int x) { return x + counter; }__attribute__((noinline)) int unused(int x) { return x * 3; }
void _start(void) { buffer[0] = used(41); for (;;) { }}The infinite loop makes these inspection programs stay at their entry path; do not run them expecting normal C termination. noinline keeps the call visible for the later GC5 experiment.
$ clang -O1 -fno-pic -fno-asynchronous-unwind-tables -c main.c -o plain.o$ llvm-objdump -d plain.o0000000000000000 <used>: 0: 89 f8 movl %edi, %eax 2: 03 05 00 00 00 00 addl (%rip), %eax # 0x8 <used+0x8> 8: c3 retqld plain.o -o bfdld.lld plain.o -o lldclang -O1 -fno-pic \ -fno-asynchronous-unwind-tables -ffunction-sections -fdata-sections \ -c main.c -o split.old --gc-sections split.o -o bfd_gcGNU ld: addl 0x1ffc(%rip), %eax # 0x403004 <counter>GNU ld --gc-sections: addl 0xff8(%rip), %eax # 0x402000 <counter>lld: addl 0x103c(%rip), %eax # 0x2021a4 <counter>For the first output, P is 0x401004 and S is 0x403004: S−4−P=0x1ffc. The other outputs retain the relocation's meaning while changing its operands. The second input was compiled into independently removable function/data sections; the third was placed by a different default layout.
Before assigning addresses, determine what remains
A useful dependency order is: read inputs, resolve symbols, select live content, determine sizes, lay out the image, and apply relocations. It is not a promise of one pass. Relaxation and range-extension thunks can change sizes after an initial placement, requiring another layout.
Some sizes do not come directly from input headers. Consider two files that each contain the same string:
/* s1.c; s2.c replaces f1 with f2 */int printf(const char *, ...);void f1(void) { printf("hi"); }Let s0.c supply an inert printf and an _start calling both functions, keeping this a library-free inspection example.
$ clang -O1 -fno-pic -fno-asynchronous-unwind-tables -c s1.c -o s1.o$ readelf -SW s1.o | grep rodata [ 4] .rodata.str1.1 PROGBITS 0000000000000000 00004c 000003 01 AMS 0 0 1SHF_MERGE permits equal elements to share storage; SHF_STRINGS says elements are NUL-terminated strings. With strings, sh_entsize gives the character width. The name .rodata.str1.1 is a convention; the flags carry the essential semantics.
$ ld -o m_bfd s0.o s1.o s2.o$ readelf -SW m_bfd | grep rodata [ 2] .rodata PROGBITS 0000000000402000 002000 000003 01 AMS 0 0 1$ objdump -d m_bfd | grep -B1 'mov \$0x402000'0000000000401030 <f1>: 401030: bf 00 20 40 00 mov $0x402000,%edi--0000000000401040 <f2>: 401040: bf 00 20 40 00 mov $0x402000,%ediTwo three-byte inputs produce only three bytes of output string data. Both references now use the same address. Every later address depends on that merged size.
Mapping input offsets after merging
Concatenation preserves a section's internal order, so one output base plus the input offset locates every byte. Deduplication breaks that model: adjacent input elements may refer to unrelated, previously emitted output positions. A linker therefore retains a mapping for each element, rather than one translation for the entire input section.
Consider one-byte characters, alignment 1, and whole-string deduplication:
| Input | Range including the terminating NUL | Contents | Merged output range |
|---|---|---|---|
| A | [0, 6) | hello\0 | [0, 6) |
| A | [6, 12) | world\0 | [6, 12) |
| B | [0, 6) | world\0 | [6, 12) |
| B | [6, 9) | hi\0 | [12, 15) |
| B | [9, 15) | hello\0 | [0, 6) |
The output is hello\0world\0hi\0. Input B's offset 7 is one byte into hi\0, so it maps to output offset 12 + 1 = 13. Offset 9 belongs to the next element and maps to 0. Ranges are half-open: an element's end is not another byte within that element.
For an input element [i, i+n) mapped to output start o, an input position x maps to o + (x-i). Adding the merged region's output virtual address then supplies an address for relocation. If elements require alignment, o must first be aligned; output padding is not meaningful input content.
Relocations also distinguish ordinary symbols from section symbols. An ordinary symbol identifies an object; its position is generally mapped first, with the addend then applied according to the relocation expression. An STT_SECTION symbol identifies the input section, allowing an assembler to encode the selected element's position in the addend. In that case the referenced input element must be identified before mapping. Translating only the section start and then adding the old offset would misaddress B's hello\0. A PC-relative relocation's addend may also include an instruction-encoding adjustment, so it cannot indiscriminately be treated as a string index. The interpretation depends on the relocation, symbol type, and producer's representation.
Whole-element deduplication is a basic model. Production linkers may also perform suffix merging and use broader compatibility rules. The invariant survives those choices: every referenced valid input position must map to a semantically equivalent output position. Removing duplicate storage does not remove references to it.
Synthetic structures create another dependency. The linker must count GOT6 slots, PLT7 entries, exported dynamic symbols, and runtime relocations before allocating their sections. Thus relocation analysis and relocation application are separate responsibilities. Analysis can determine that a local-binding relaxation removes a GOT requirement; application later uses actual addresses and checks reachability. An implementation may allocate conservatively or iterate, but it cannot use a size it has not yet established.
Input sections become output sections
Inspect the starting material:
$ readelf -SW plain.o [Nr] Name Type Address Off Size ES Flg Lk Inf Al [ 2] .text PROGBITS 0000000000000000 000040 000042 00 AX 0 0 16 [ 3] .rela.text RELA 0000000000000000 000178 000048 18 I 10 2 8 [ 4] .data PROGBITS 0000000000000000 000084 000004 00 WA 0 0 4 [ 5] .rodata PROGBITS 0000000000000000 000088 000003 00 A 0 0 1 [ 6] .bss NOBITS 0000000000000000 000090 001000 00 WA 0 0 16 [ 7] .comment PROGBITS 0000000000000000 000090 000028 01 MS 0 0 1 [ 8] .note.GNU-stack PROGBITS 0000000000000000 0000b8 000000 00 0 0 1The three functions share one .text; its alignment is 16. .bss is NOBITS: it describes 0x1000 bytes of memory without storing those bytes in the file. Its file offset can equal the next section's offset.
Each input contribution retains its alignment. Concatenation may therefore require padding, and the output section's alignment must accommodate its members. GNU ld's default script groups related names:
.text : { *(.text.unlikely .text.*_unlikely .text.unlikely.*) *(.text.exit .text.exit.*) *(.text.startup .text.startup.*) *(.text.hot .text.hot.*) *(SORT(.text.sorted.*)) *(.text .stub .text.* .gnu.linkonce.t.*) /* .gnu.warning sections are handled specially by elf.em. */ *(.gnu.warning) }*(...) matches sections from any input file. .text.* includes per-function sections; earlier patterns collect unlikely, exit, startup, and hot code. Similar families feed .data, .rodata, and .bss. These groups can concentrate commonly executed code and keep exceptional paths together.
An unmatched orphan section is normally placed according to its attributes rather than silently discarded. LLD implements its default grouping in code rather than a built-in textual script, but accepts common GNU script constructs when explicitly supplied.
Output sections become loadable segments
The loader's primary view is the program-header table, not the section table. Sections serve linking and inspection; segments describe mappings. Since permissions apply to pages, code, immutable data, and writable data usually require separate page regions.
$ ld -o bfd plain.o$ readelf -lW bfd
Elf file type is EXEC (Executable file)Entry point 0x401020There are 5 program headers, starting at offset 64
Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align LOAD 0x000000 0x0000000000400000 0x0000000000400000 0x000158 0x000158 R 0x1000 LOAD 0x001000 0x0000000000401000 0x0000000000401000 0x000042 0x000042 R E 0x1000 LOAD 0x002000 0x0000000000402000 0x0000000000402000 0x000003 0x000003 R 0x1000 LOAD 0x002004 0x0000000000403004 0x0000000000403004 0x000004 0x00100c RW 0x1000 GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10
Section to Segment mapping: Segment Sections... 00 01 .text 02 .rodata 03 .data .bss 04Read each program header as a mapping contract:
| Field | Meaning |
|---|---|
p_type | Role, such as PT_LOAD or a stack-permission request |
p_offset | Starting file offset |
p_vaddr | Starting virtual address |
p_filesz | Number of file-backed bytes |
p_memsz | Total memory extent |
p_flags | Read, write, execute permissions |
p_align | Alignment requirement |
p_paddr is generally unused for ordinary Linux process loading. PT_GNU_STACK does not supply a separate file payload; its flags request stack permissions.
In the final LOAD, four initialized bytes grow into 0x100c bytes of memory. The additional 0x1008 includes the 4096-byte buffer and alignment padding. The loader supplies zeros for the part after p_filesz. This compact representation places ordinary .bss at the tail of its load segment; a file-backed section after it would require another representation or segment decision.
Where file and memory cursors diverge
The RW segment above has a four-byte initialized prefix, an eight-byte alignment gap, and a 4096-byte buffer. The segment covers an interval, so its zero-filled extension includes the gap as well as the section named .bss.
Placing .data advances both cursors by four bytes. Placing the subsequent zero-initialized storage advances only the memory cursor. Rounding 0x403008 up to a multiple of 16 gives 0x403010; adding 0x1000 gives memory end 0x404010. Thus p_memsz = 0x404010 − 0x403004 = 0x100c. The initialized file extent still ends at 0x2008, so p_filesz = 0x2008 − 0x2004 = 4. Later file bytes may contain section headers, symbols, or other material without becoming part of this segment's initialized payload.
For a positive alignment a, round-up selects the smallest multiple of a that is at least the current cursor x. The required padding is (a − x % a) % a. An implementation must check the additions of padding and content length for overflow and resource limits before growing a buffer. An already aligned position requires no padding; rounding up does not unconditionally add another alignment unit.
The first LOAD contains the ELF8 header and five program headers: 64+5×56=0x158 bytes. These bytes are mapped without being members of an ordinary section. Header space itself depends on the number of segments, while the first section's position depends on header space. The default script expresses the reservation as:
. = SEGMENT_START("text-segment", 0x400000) + SIZEOF_HEADERS;Allow code to follow the headers directly:
$ ld -z noseparate-code -o bfd_nosep plain.o$ readelf -sW bfd_nosep | grep -w used 3: 00000000004000f0 9 FUNC GLOBAL DEFAULT 1 usedWith three headers, 64+3×56=0xe8; align 0x4000e8 to 16 and used begins at 0x4000f0. A layout must reserve enough room, move the mapped header region, or reject an impossible script with diagnostics such as not enough room for program headers.
File offsets and virtual addresses must agree within a page
A page mapping preserves byte position within the page. Therefore a load segment requires p_offset and p_vaddr to be congruent modulo page size, and modulo p_align when it is greater than one. A file byte at page offset 4 cannot appear at virtual page offset 12 merely through mmap.
Congruence does not require each segment to begin at a page boundary. In the GNU output, .data starts at file offset 0x2004 and virtual address 0x403004. The file page beginning at 0x2000 is mapped once read-only and again writable at another virtual address. Sharing a file page does not require sharing a virtual page or its permissions.
DATA_SEGMENT_ALIGN can advance to a new virtual page while retaining an economical file-page offset. The tested maximum/common page sizes are both 0x1000; -z max-page-size changes the linker's assumption. A producer targeting kernels with larger supported pages must respect their mapping requirements.
Code separation changes the tradeoff
The tested GNU configuration defaults to -z separate-code, giving executable bytes file pages disjoint from non-executable data. The tiny example occupies 9200 bytes. Disable that policy:
$ readelf -lW bfd_nosep Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align LOAD 0x000000 0x0000000000400000 0x0000000000400000 0x000135 0x000135 R E 0x1000 LOAD 0x000138 0x0000000000401138 0x0000000000401138 0x000004 0x001008 RW 0x1000 GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10
Section to Segment mapping: Segment Sections... 00 .text .rodata 01 .data .bssThe file shrinks to 1320 bytes, but headers and constants share executable mappings with code. Page permissions apply to all mapped bytes, including incidental instruction sequences inside non-code data.
LLD's default differs:
$ ld.lld -o lld plain.o$ readelf -lW lld Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align PHDR 0x000040 0x0000000000200040 0x0000000000200040 0x000118 0x000118 R 0x8 LOAD 0x000000 0x0000000000200000 0x0000000000200000 0x00015b 0x00015b R 0x1000 LOAD 0x000160 0x0000000000201160 0x0000000000201160 0x000042 0x000042 R E 0x1000 LOAD 0x0001a4 0x00000000002021a4 0x00000000002021a4 0x000004 0x00100c RW 0x1000 GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0
Section to Segment mapping: Segment Sections... 00 01 .rodata 02 .text 03 .data .bssIts read-only segment precedes code, and --rosegment keeps read-only data logically separate from RX code. Yet the three LOADs begin in the same file page, at offsets 0, 0x160, and 0x1a4, with virtual bases separated by pages. The file is 1376 bytes in this recorded build. Mapping the RX region also maps the page prefix containing headers and constants as executable at that alias.
Request separation explicitly:
$ ld.lld -z separate-code -o lld_sep plain.o$ readelf -lW lld_sep | grep LOAD LOAD 0x000000 0x0000000000200000 0x0000000000200000 0x00015b 0x00015b R 0x1000 LOAD 0x001000 0x0000000000201000 0x0000000000201000 0x000042 0x000042 R E 0x1000 LOAD 0x002000 0x0000000000202000 0x0000000000202000 0x000004 0x001010 RW 0x1000The file grows to 9144 bytes. separate-loadable-segments is stricter still: it prevents all neighboring LOADs from sharing file pages, whereas separate-code specifically separates executable and non-executable content. File sizes include toolchain-dependent metadata and are observations of these builds.
Notes have a separate ordering policy. LLD places allocated notes early so a truncated core dump is more likely to retain them. Adjacent note sections with matching alignment can share a PT_NOTE; differing alignment starts another:
$ ld.lld --build-id -o n2 notes.o$ readelf -lW n2 Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align PHDR 0x000040 0x0000000000200040 0x0000000000200040 0x000188 0x000188 R 0x8 LOAD 0x000000 0x0000000000200000 0x0000000000200000 0x00022f 0x00022f R 0x1000 LOAD 0x000230 0x0000000000201230 0x0000000000201230 0x000005 0x000005 R E 0x1000 GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0 NOTE 0x0001c8 0x00000000002001c8 0x00000000002001c8 0x000028 0x000028 R 0x4 NOTE 0x0001f0 0x00000000002001f0 0x00000000002001f0 0x000018 0x000018 R 0x8 NOTE 0x000208 0x0000000000200208 0x0000000000200208 0x000024 0x000024 R 0x4
Section to Segment mapping: Segment Sections... 01 .note.a .note.b .note.c .note.gnu.build-id .rodata 04 .note.a .note.b 05 .note.c 06 .note.gnu.build-idThose note program headers describe bytes already present in a LOAD. They do not duplicate the payload.
Choosing the first instruction
e_entry tells the loader where execution begins after any required interpreter work. GNU ld consults -e, then ENTRY(...), target conventions, the first code byte, and finally zero. Its Linux default script names _start. A missing entry need not fail the link:
$ ld -o ne noentry.old: warning: cannot find entry symbol _start; defaulting to 0000000000401000$ ld.lld -o ne2 noentry.old.lld: warning: cannot find entry symbol _start; not setting start addressGNU ld falls back to .text; LLD leaves entry zero in this case. A successful link is therefore not proof of a runnable startup path.
Normal C builds supply startup objects through the compiler driver:
$ musl-gcc -no-pie -### hello.c -o hello... Scrt1.o crti.o crtbeginS.o hello.o libgcc.a libgcc_eh.a -lc libgcc.a libgcc_eh.a crtendS.o crtn.oThis native GCC9/musl10 configuration uses Scrt1.o, which supplies _start. Runtime startup receives process arguments, initializes the library, calls main, and terminates through exit. crti.o and crtn.o can contribute the beginning and end of an initialization function; input order matters.
$ musl-gcc -no-pie -O1 hello.c -o hello$ nm -n hello | grep -i ' t '0000000000401000 T _init0000000000401040 T _start0000000000401060 T _start_c0000000000401090 t deregister_tm_clones00000000004010c0 t register_tm_clones0000000000401100 t __do_global_dtors_aux0000000000401140 t frame_dummy0000000000401149 T main0000000000401153 T _finimain is no longer the first function. Calling ld directly omits this machinery. A custom _start must not return as though called by an ordinary C caller; it needs its own termination path or, as in the inspection example, a deliberate loop.
A fixed image base or a position-independent image
GNU ld's conventional x86-64 image base is expressed here:
PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x400000)); . = SEGMENT_START("text-segment", 0x400000) + SIZEOF_HEADERS;LLD defaults to 0x200000 instead. Both can be changed. Keeping low addresses unmapped also helps make null-pointer accesses fail promptly.
PIE11 permits the main image to be placed at a varying load base:
$ clang -O1 -fPIE -fno-asynchronous-unwind-tables -c main.c -o pie.o$ ld -pie --no-dynamic-linker -o bfd_pie pie.o$ readelf -hlW bfd_pie Type: DYN (Position-Independent Executable file) Entry point address: 0x1020 LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x000201 0x000201 R 0x1000 LOAD 0x001000 0x0000000000001000 0x0000000000001000 0x000042 0x000042 R E 0x1000 LOAD 0x002000 0x0000000000002000 0x0000000000002000 0x000008 0x000008 R 0x1000 LOAD 0x002f30 0x0000000000003f30 0x0000000000003f30 0x0000d4 0x0010e0 RW 0x1000 DYNAMIC 0x002f30 0x0000000000003f30 0x0000000000003f30 0x0000d0 0x0000d0 RW 0x8 GNU_RELRO 0x002f30 0x0000000000003f30 0x0000000000003f30 0x0000d0 0x0000d0 R 0x1The file type is ET_DYN; link-time addresses describe the image relative to its base. The kernel chooses the main image's placement under ASLR12. Runtime linking handles its remaining relocation requirements and other shared objects. PT_GNU_RELRO identifies a region that should become read-only after relocation.
This special example has only base-invariant PC-relative references, so --no-dynamic-linker works without an interpreter or runtime relocations. General static PIE can still contain absolute pointers and requires startup code to relocate itself. ET_EXEC, conversely, does not mean “no dynamic linking”: a fixed-address main program can depend on shared libraries.
Distribution compiler defaults are configurable. Inspect the produced ELF type rather than assuming that every invocation of gcc chooses the same model.
Garbage collection follows references
Ordinary section GC removes input sections, not arbitrary symbol-sized pieces. A section can contain already-resolved internal branches, embedded data, or local labels that make cutting out a function unsafe. With all functions in one .text, retaining _start also retains unused:
$ ld --gc-sections --print-gc-sections -o bfd_gc2 plain.old: removing unused section '.rodata' in file 'plain.o'Give the linker independent boundaries:
$ clang -O1 -fno-pic -fno-asynchronous-unwind-tables \ -ffunction-sections -fdata-sections -c main.c -o split.o$ readelf -SW split.o [Nr] Name Type Address Off Size ES Flg Lk Inf Al [ 2] .text PROGBITS 0000000000000000 000040 000000 00 AX 0 0 4 [ 3] .text.used PROGBITS 0000000000000000 000040 000009 00 AX 0 0 16 [ 4] .rela.text.used RELA 0000000000000000 000178 000018 18 I 14 3 8 [ 5] .text.unused PROGBITS 0000000000000000 000050 000004 00 AX 0 0 16 [ 6] .text._start PROGBITS 0000000000000000 000060 000022 00 AX 0 0 16 [ 7] .rela.text._start RELA 0000000000000000 000190 000030 18 I 14 6 8 [ 8] .data.counter PROGBITS 0000000000000000 000084 000004 00 WA 0 0 4 [ 9] .rodata.msg PROGBITS 0000000000000000 000088 000003 00 A 0 0 1 [10] .bss.buffer NOBITS 0000000000000000 000090 001000 00 WA 0 0 16$ readelf -rW split.oRelocation section '.rela.text.used' at offset 0x178 contains 1 entry: Offset Info Type Symbol's Value Symbol's Name + Addend0000000000000004 0000000300000002 R_X86_64_PC32 0000000000000000 counter - 4
Relocation section '.rela.text._start' at offset 0x190 contains 2 entries: Offset Info Type Symbol's Value Symbol's Name + Addend0000000000000007 0000000200000004 R_X86_64_PLT32 0000000000000000 used - 4000000000000000d 0000000600000002 R_X86_64_PC32 0000000000000000 buffer - 4Section identity does not depend on output addresses
GC works on input sections before layout. A vertex can be identified by its input file and section index; same-named sections from different objects remain distinct vertices. Following a relocation edge identifies the input section containing the selected definition without requiring its final address.
The chain _start → .text.used → .data.counter is reachable from a root. A separate .text.unused → .rodata.msg chain stays dead unless some root reaches it. Even a cycle within that disconnected component does not make it live: retention requires a path from a root, not merely the existence of references.
Layout merges, aligns, and assigns addresses after marking. Using output addresses as graph identities would make GC depend on a result it has not produced; removing sections would also move subsequent addresses. Stable input identities let definition selection, liveness, and address assignment retain their separate meanings.
Now relocations form a graph: _start references used and buffer; used references counter. Start from live roots, follow edges, and discard unmarked sections:
$ ld --gc-sections --print-gc-sections -o bfd_gc split.old: removing unused section '.text.unused' in file 'split.o'ld: removing unused section '.rodata.msg' in file 'split.o'$ ld.lld --gc-sections --print-gc-sections -o lld_gc split.oremoving unused section split.o:(.text)removing unused section split.o:(.text.unused)removing unused section split.o:(.rodata.msg)$ ld.lld --gc-sections --why-live=counter -o x split.olive symbol: split.o:(counter)>>> referenced by: split.o:(used)>>> referenced by: split.o:(_start) (entry point)--why-live explains a retention chain. GNU's --print-gc-sections writes removal diagnostics to stderr; this LLD version writes them to stdout.
Roots include the entry, forced undefined names requested through -u, required exports, and sections marked SHF_GNU_RETAIN. Runtime-discovered structures need special treatment: initialization arrays, .init/.fini, legacy constructor arrays, and relevant notes can be useful without an ordinary code relocation pointing to them. LLD also roots configured initializer/finalizer symbols:
$ ld.lld --gc-sections --why-live=_init --why-live=_fini -o i ini.olive symbol: ini.o:(_init) (initializer function)live symbol: ini.o:(_fini) (finalizer function)$ ld.lld --gc-sections --print-gc-sections --init=foo -o i2 ini.o | grep _initremoving unused section ini.o:(.text._init)Changing the configured initializer name changes that result. GNU's default script expresses corresponding protection with KEEP:
.init_array : { PROVIDE_HIDDEN (__init_array_start = .); KEEP (*(SORT_BY_INIT_PRIORITY(.init_array.*) SORT_BY_INIT_PRIORITY(.ctors.*))) KEEP (*(.init_array EXCLUDE_FILE (*crtbegin.o *crtbegin?.o *crtend.o *crtend?.o ) .ctors)) PROVIDE_HIDDEN (__init_array_end = .); }Not every survival rule is a root. Non-allocated metadata such as .comment and .debug_* generally survives without ordinary reachability. SHF_LINK_ORDER associates metadata with a described section; it should follow that section's survival and ordering:
$ clang -O1 -fno-pic -fno-asynchronous-unwind-tables \ -ffunction-sections -fdata-sections -fpatchable-function-entry=2 -c main.c -o pfe.o$ readelf -SW pfe.o | grep -E 'patchable_function_entries|text\.unused' [ 5] __patchable_function_entries PROGBITS 0000000000000000 000050 000008 00 WAL 3 0 8 [ 7] .text.unused PROGBITS 0000000000000000 000060 000006 00 AX 0 0 16 [ 8] __patchable_function_entries PROGBITS 0000000000000000 000068 000008 00 WAL 7 0 8 [12] __patchable_function_entries PROGBITS 0000000000000000 000098 000008 00 WAL 10 0 8$ ld --gc-sections --print-gc-sections -o pfe_b pfe.old: removing unused section '.text.unused' in file 'pfe.o'ld: removing unused section '__patchable_function_entries' in file 'pfe.o'ld: removing unused section '.rodata.msg' in file 'pfe.o'Only metadata for the two retained functions survives this patchable-entry example. .eh_frame is finer-grained again: the linker can remove an individual FDE13 for a dead function from a shared unwind section.
Shared libraries normally export many globals, making them roots. Hiding internal interfaces can improve both binding and collection. Per-function sections also cost work and relocations: even a static helper call that the assembler could once resolve may now cross a section boundary:
$ cat st.c__attribute__((noinline)) static int helper(int x) { return x * 2; }int api(int x) { return helper(x) + 1; }$ clang -O1 -fno-pic -fno-asynchronous-unwind-tables -c st.c -o st.o$ readelf -rW st.oThere are no relocations in this file.$ clang -O1 -fno-pic -fno-asynchronous-unwind-tables -ffunction-sections -c st.c -o st.o$ readelf -rW st.o | grep R_X860000000000000002 0000000300000004 R_X86_64_PLT32 0000000000000000 .text.helper - 4A retained record can describe dead code
A live allocated section's ordinary reference keeps its target alive. Debug information follows different rules, so it can survive while the described function disappears:
$ clang -O1 -g -gdwarf-4 -fno-pic -fno-asynchronous-unwind-tables \ -ffunction-sections -fdata-sections -c main.c -o dbg.o$ ld.lld --gc-sections -o dbg_lld dbg.o$ objdump --dwarf=info dbg_lld | grep -A6 DW_TAG_subprogram | grep -E 'name|low_pc' <9d> DW_AT_low_pc : 0x201160 <ab> DW_AT_name : (indirect string, offset: 0x8c): used <c4> DW_AT_low_pc : 0 <d2> DW_AT_name : (indirect string, offset: 0x85): unused <eb> DW_AT_low_pc : 0x201170 <f9> DW_AT_name : (indirect string, offset: 0x3c): _start$ readelf -x .debug_ranges dbg_lld 0x00000000 60112000 00000000 69112000 00000000 `. .....i. ..... 0x00000010 01000000 00000000 01000000 00000000 ................ 0x00000020 70112000 00000000 92112000 00000000 p. ....... ..... 0x00000030 00000000 00000000 00000000 00000000 ................The linker writes tombstones: zero for the shown low PC and an empty [1,1) range in .debug_ranges. Theory 11 explains format-specific choices.
A discarded COMDAT14 group creates another case. An external reference to a local symbol in the losing group has no valid replacement:
$ cat cb.s .section .text.foo,"axG",@progbits,foo,comdat .globl foofoo: rethelper: ret .text .globl _start_start: call foo call helper1: jmp 1b$ ld.lld -o c1 ca.o cb.old.lld: error: relocation refers to a discarded section: .text.foo>>> defined in cb.o>>> section group signature: foo>>> prevailing definition is in ca.o>>> referenced by cb.o:(.text+0x6)$ ld -o c2 ca.o cb.o`.text.foo' referenced in section `.text' of cb.o: defined in discarded section `.text.foo[foo]' of cb.oHere ca.o wins group foo; cb.o's private helper disappears with its group. A live call cannot use a debug tombstone. The linker must diagnose the invalid reference.
Registration tables need an explicit retention contract
A custom section lets independent files contribute registry entries without editing one central array. Linker-defined __start_NAME and __stop_NAME provide its bounds when NAME is a valid C identifier:
/* reg.c: two function pointers in the custom myhooks section */typedef void (*hook_t)(void);static void hook_a(void) {}static void hook_b(void) {}__attribute__((section("myhooks"), used)) static hook_t pa = hook_a;__attribute__((section("myhooks"), used)) static hook_t pb = hook_b;
/* run.c: iterate over myhooks */typedef void (*hook_t)(void);extern hook_t __start_myhooks[], __stop_myhooks[];void _start(void) { for (hook_t *p = __start_myhooks; p < __stop_myhooks; p++) (*p)(); for (;;) { }}$ clang -O1 -fno-pic -fno-asynchronous-unwind-tables \ -ffunction-sections -fdata-sections -c reg.c -o reg.o$ clang -O1 -fno-pic -fno-asynchronous-unwind-tables \ -ffunction-sections -fdata-sections -c run.c -o run.oused prevents the compiler from dropping these objects. It does not by itself prevent linker GC. The boundary reference is not an ordinary relocation to each input contribution, and policies differ:
$ ld --gc-sections --print-gc-sections -o r1 run.o reg.o$ readelf -sW r1 | grep -E 'myhooks|hook' 3: 0000000000401030 1 FUNC LOCAL DEFAULT 1 hook_a 4: 0000000000401040 1 FUNC LOCAL DEFAULT 1 hook_b 9: 0000000000402000 0 NOTYPE GLOBAL PROTECTED 2 __start_myhooks 12: 0000000000402010 0 NOTYPE GLOBAL PROTECTED 2 __stop_myhooks
$ ld.lld --gc-sections --print-gc-sections -o r2 run.o reg.oremoving unused section run.o:(.text)removing unused section reg.o:(.text)removing unused section reg.o:(.text.hook_a)removing unused section reg.o:(.text.hook_b)removing unused section reg.o:(myhooks)ld.lld: error: undefined symbol: __start_myhooks>>> referenced by run.c>>> run.o:(_start)>>> the encapsulation symbol needs to be retained under --gc-sections properly; consider -z nostart-stop-gc (see https://lld.llvm.org/ELF/start-stop-gc)
ld.lld: error: undefined symbol: __stop_myhooks...GNU ld retains the entries in this experiment; LLD's default collects them and reports missing bounds. The synthesized symbols default to protected visibility, but a hidden declaration can make the final result more restrictive:
extern char __start_myinit[] __attribute__((visibility("hidden")));extern char __stop_myinit[];$ ld.lld -o v vis.o$ readelf -sW v | grep -E 'start_|stop_' 3: 0000000000202184 0 NOTYPE LOCAL HIDDEN 2 __start_myinit 6: 0000000000202188 0 NOTYPE GLOBAL PROTECTED 2 __stop_myinitIf the output section never exists, neither do its synthesized bounds. Weak declarations permit an optional section to remain absent:
$ cat weak.cextern char __start_optsec[] __attribute__((weak));extern char __stop_optsec[] __attribute__((weak));unsigned long n;void _start(void) { n = __stop_optsec - __start_optsec; if (__start_optsec) n += 100; for (;;) { } }$ ld.lld -o w weak.o; echo $?0$ readelf -sW w | grep optsec 3: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __start_optsec 4: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __stop_optsecThe recorded disassembly uses zero for both absent bounds. This particular observation is not a general license to subtract arbitrary unrelated or null C pointers; production registry code should check existence before interpreting the range under its platform contract.
LLD 13 and later default to -z start-stop-gc, so boundary references do not conservatively retain every same-named input. A compatibility exception protects certain __libc_ sections needed by older glibc15 archives:
$ ld.lld --gc-sections --print-gc-sections -o l lrun.o lreg.oremoving unused section lrun.o:(.text)removing unused section lreg.o:(.text)removing unused section lreg.o:(myhooks)$ readelf -sW l | grep -E '__start|__stop' 6: 00000000002021a8 0 NOTYPE GLOBAL PROTECTED 2 __start___libc_hooks 7: 00000000002021b0 0 NOTYPE GLOBAL PROTECTED 2 __stop___libc_hooks 8: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __start_myhooksIn the recorded GNU ld 2.46 test, adding its similarly named option still retained these myhooks entries; do not assume options with matching names imply identical edge behavior.
Three ways to state the intended lifetime are -z nostart-stop-gc, source-level retain, or script-level KEEP. With retain:
$ readelf -SW reg2.o | grep myhooks [ 5] myhooks PROGBITS 0000000000000000 000058 000008 00 WAR 0 0 8 [ 7] myhooks PROGBITS 0000000000000000 000060 000008 00 WAR 0 0 8$ ld.lld --gc-sections --print-gc-sections -o r3 run.o reg2.oremoving unused section run.o:(.text)removing unused section reg2.o:(.text)The live pointer sections in turn keep their target functions live.
Make the layout contract visible in a script
This complete small script gives the registry explicit bounds and retention:
ENTRY(_start)SECTIONS{ . = 0x10000; .text : { *(.text .text.*) } . = ALIGN(0x1000); .data : { *(.data .data.*) . = ALIGN(8); PROVIDE(hooks_begin = .); KEEP(*(myhooks)) PROVIDE(hooks_end = .); } .bss : { *(.bss .bss.*) *(COMMON) } /DISCARD/ : { *(.comment) }}The location counter . advances as content is placed. ALIGN rounds it up. Input patterns choose contributions; KEEP makes them roots; /DISCARD/ removes selected input; *(COMMON) allocates common storage; PROVIDE supplies a referenced symbol only when no input already defines it.
The ALIGN(8) before hooks_begin is essential. If ordinary data ends at 0x11004, recording the bound before the linker's implicit pointer alignment makes the bound point into padding, while the first pointer begins at 0x11008.
Use the script's names in the reader:
/* run2.c */typedef void (*hook_t)(void);extern hook_t hooks_begin[], hooks_end[];void _start(void) { for (hook_t *p = hooks_begin; p < hooks_end; p++) (*p)(); for (;;) { }}$ clang -O1 -fno-pic -fno-asynchronous-unwind-tables \ -ffunction-sections -fdata-sections -c run2.c -o run2.o$ ld -T tiny.ld --gc-sections --print-gc-sections -o s_bfd run2.o reg.o$ readelf -lW s_bfd Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align LOAD 0x001000 0x0000000000010000 0x0000000000010000 0x000041 0x000041 R E 0x1000 LOAD 0x002000 0x0000000000011000 0x0000000000011000 0x000010 0x000010 RW 0x1000 GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10$ readelf -sW s_bfd | grep -E 'hooks|hook_|_start' 3: 0000000000010030 1 FUNC LOCAL DEFAULT 1 hook_a 4: 0000000000010040 1 FUNC LOCAL DEFAULT 1 hook_b 7: 0000000000011000 0 NOTYPE GLOBAL DEFAULT 2 hooks_begin 8: 0000000000011010 0 NOTYPE GLOBAL DEFAULT 2 hooks_end 9: 0000000000010000 34 FUNC GLOBAL DEFAULT 1 _startBoth linkers place _start at 0x10000 and data at 0x11000. This script does not map the ELF headers. Scripts can also declare MEMORY regions and distinguish VMA, the execution address, from LMA, the address holding initial bytes. Firmware commonly stores .data initializers in Flash and copies them to RAM; Theory 10 develops that contract.
A real small script: xv6 user programs
The xv616 script at revision 06aad25 is short because its runtime contract is small:
OUTPUT_ARCH( "riscv" )
SECTIONS{ . = 0x0;
.text : { *(.text .text.*) }
.rodata : { . = ALIGN(16); *(.srodata .srodata.*) /* do not need to distinguish this from .rodata */ . = ALIGN(16); *(.rodata .rodata.*) }
.eh_frame : { *(.eh_frame) *(.eh_frame.*) }
. = ALIGN(0x1000); .data : { . = ALIGN(16); *(.sdata .sdata.*) /* do not need to distinguish this from .data */ . = ALIGN(16); *(.data .data.*) }
.bss : { . = ALIGN(16); *(.sbss .sbss.*) /* do not need to distinguish this from .bss */ . = ALIGN(16); *(.bss .bss.*) }
PROVIDE(end = .);}LDFLAGS = -z max-page-size=4096 $(LD) $(LDFLAGS) -T $U/user.ld -o $@ $< $(ULIB)Its user library is four objects—ulib.o, usys.o, printf.o, umalloc.o—rather than a conventional Linux libc and startup set. Most user programs use this script; _forktest is a deliberately smaller Makefile exception.
Several choices encode the loader's assumptions. There is no mapped header space before address zero. Small-data sections are merged into ordinary data without a global-pointer layout. .data begins on a 4096-byte boundary. No dynamic-linking or TLS sections are required by these inputs. PROVIDE(end=.) gives the end of the image.
There is also an entry-policy trap. GNU's target convention recognizes start, defined by xv6's user runtime, even though this script has no ENTRY. LLD requires -e start here. The two initial compressed NOPs in this comparison place start at address four, making the chosen rule observable:
$ cat u.s .text nop nop .globl startstart: la a0, counter lw a0, 0(a0) li a7, 2 ecall .section .rodatamsg: .asciz "hi" .data .balign 4counter: .word 1 .bss .balign 4buffer: .zero 4096$ riscv64-unknown-elf-as -march=rv64gc -mabi=lp64d -mno-relax u.s -o u.o$ riscv64-unknown-elf-ld -m elf64lriscv -T user.ld -o u u.o$ riscv64-unknown-elf-readelf -hlW u | grep -E 'Entry|LOAD' Entry point address: 0x4 LOAD 0x001000 0x0000000000000000 0x0000000000000000 0x000023 0x000023 R E 0x1000 LOAD 0x002000 0x0000000000001000 0x0000000000001000 0x000004 0x001010 RW 0x1000$ riscv64-unknown-elf-ld -m elf64lriscv -z max-page-size=65536 -T user.ld -o u64 u.oriscv64-unknown-elf-ld: warning: u64 has a LOAD segment with RWX permissions$ riscv64-unknown-elf-readelf -lW u64 | grep LOAD LOAD 0x010000 0x0000000000000000 0x0000000000000000 0x001004 0x002010 RWE 0x10000xv6 allocates pages and copies file bytes into them; its loadseg requires page-aligned virtual addresses. Linux uses page mappings and permits non-aligned segment starts when file and virtual offsets are congruent. These are different loader contracts. LLD additionally needs --no-rosegment for this particular xv6 script/example, avoiding a separate read-only segment beginning at an unaligned address.
Pinning -z max-page-size=4096 matters even when a current toolchain already defaults to it. Changing the assumption to 64 KiB makes addresses 0 and 0x1000 share a modeled page; GNU ld then combines the example into RWX and increases file padding. Scripts and linker options must agree with the actual loader.
A map file makes placement reviewable
Request -Map=out.map to see the decisions:
$ ld --gc-sections -Map=out.map -o bfd_gc split.o$ cat out.mapDiscarded input sections
.text 0x0000000000000000 0x0 split.o .text.unused 0x0000000000000000 0x4 split.o .rodata.msg 0x0000000000000000 0x3 split.o .llvm_addrsig 0x0000000000000000 0x0 split.o.llvm_addrsig supplies address-significance information for ICF17 and carries SHF_EXCLUDE; it need not become output content. The map then expands each output section into its input contributions:
.text 0x0000000000401000 0x32... *(.text .stub .text.* .gnu.linkonce.t.*) .text.used 0x0000000000401000 0x9 split.o 0x0000000000401000 used *fill* 0x0000000000401009 0x7 .text._start 0x0000000000401010 0x22 split.o 0x0000000000401010 _startused occupies nine bytes, followed by seven padding bytes before the next 16-byte-aligned function. Location-counter assignments explain the data placement:
0x0000000000402000 . = DATA_SEGMENT_ALIGN (CONSTANT (MAXPAGESIZE), CONSTANT (COMMONPAGESIZE))....data 0x0000000000402000 0x4 *(.data .data.* .gnu.linkonce.d.*) .data.counter 0x0000000000402000 0x4 split.o 0x0000000000402000 counterNow the opening GC displacement is directly recoverable: 0x402000−4−0x401004=0xff8. LLD's map is more compact:
VMA LMA Size Align Out In Symbol... 0 0 42 1 .comment 0 0 42 1 <internal>:(.comment) 0 0 90 8 .symtab 0 0 90 8 <internal>:(.symtab) 0 0 35 1 .shstrtab 0 0 35 1 <internal>:(.shstrtab) 0 0 23 1 .strtab 0 0 23 1 <internal>:(.strtab)<internal> denotes linker-generated output such as symbol/string tables or a newly merged .comment. When a binary grows, comparing maps identifies which input contributions changed, rather than merely observing a larger file.
The completed image hands another component a precise set of mapping requests. The next chapter follows those requests into a running process.
Exercises
The commands and expected invariants are stated in this chapter.
- Produce LLD's map and both ELF views:
$ ld.lld -Map=lld.map -o lld plain.o$ readelf -lW lld$ readelf -SW lldIdentify each LOAD's sections, their input contributions, and symbols. Explain p_memsz−p_filesz for RW, and prove from the section table that .bss stores no file bytes.
- Calculate layout for these four input sections:
Section Type Size Alignment.text PROGBITS 0x1a3 16.rodata PROGBITS 0x2c 16.data PROGBITS 0x18 8.bss NOBITS 0x2000 64ENTRY(_start)PHDRS { ro PT_LOAD; rx PT_LOAD; rw PT_LOAD; }SECTIONS{ . = 0x400100; .rodata : { *(.rodata) } :ro . = ALIGN(0x1000) + (. & 0xfff); .text : { *(.text) } :rx . = ALIGN(0x1000) + (. & 0xfff); .data : { *(.data) } :rw .bss : { *(.bss) } :rw /DISCARD/ : { *(.comment) *(.note.GNU-stack) }}The header occupies 64+3×56 bytes and is not mapped. For each output section, align the current location. For each segment's first file offset, choose the smallest value no earlier than the current file end that is congruent with its virtual address modulo 0x1000. Calculate all section addresses and LOAD fields.
- Remove
msgandunusedfrom the original file. Start.textat 0x10000 and data/BSS on the next page. Confirm the result with both linkers, then independently (a) force data to 0x10010; (b) force output.textto 0x10004; (c) force data to 0x10800. Predict diagnostics. For (c), replace the loop with a native Linux exit syscall and test the resulting page permissions.
Answers
1. Memory contains more than the file
$ readelf -lW lld Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align PHDR 0x000040 0x0000000000200040 0x0000000000200040 0x000118 0x000118 R 0x8 LOAD 0x000000 0x0000000000200000 0x0000000000200000 0x00015b 0x00015b R 0x1000 LOAD 0x000160 0x0000000000201160 0x0000000000201160 0x000042 0x000042 R E 0x1000 LOAD 0x0001a4 0x00000000002021a4 0x00000000002021a4 0x000004 0x00100c RW 0x1000 GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0
Section to Segment mapping: Segment Sections... 00 01 .rodata 02 .text 03 .data .bss 04$ cat lld.map VMA LMA Size Align Out In Symbol 200158 200158 3 1 .rodata 200158 200158 3 1 plain.o:(.rodata) 200158 200158 3 1 msg 201160 201160 42 16 .text 201160 201160 42 16 plain.o:(.text) 201160 201160 9 1 used 201170 201170 4 1 unused 201180 201180 22 1 _start 2021a4 2021a4 4 4 .data 2021a4 2021a4 4 4 plain.o:(.data) 2021a4 2021a4 4 1 counter 2021b0 2021b0 1000 16 .bss 2021b0 2021b0 1000 16 plain.o:(.bss) 2021b0 2021b0 1000 1 buffer ...$ readelf -SW lld | grep -E '\.data|\.bss|\.comment' [ 3] .data PROGBITS 00000000002021a4 0001a4 000004 00 WA 0 0 4 [ 4] .bss NOBITS 00000000002021b0 0001a8 001000 00 WA 0 0 16 [ 5] .comment PROGBITS 0000000000000000 0001a8 000042 01 MS 0 0 1The R LOAD contains headers and .rodata (msg); RX contains .text (used, unused, _start); RW contains .data (counter) and .bss (buffer). GC was not enabled, so unused remains.
RW has 0x100c−4=0x1008 extra memory bytes: 0x1000 for the buffer and eight bytes of alignment. .bss is NOBITS; its offset 0x1a8 equals the following .comment offset, despite its memory size. The loader must supply the zero-filled extension.
2. Keep address and offset calculations separate
The header ends at 0xe8. .rodata starts at address 0x400100, file offset 0x100, and ends at 0x40012c. The next-page/same-offset expression yields 0x40112c; 16-byte alignment moves .text to 0x401130, file offset 0x130, ending at 0x4012d3. Data aligns from 0x4022d3 to 0x4022d8, file offset 0x2d8, ending at 0x4022f0. BSS aligns to 0x402300 and ends at 0x404300.
Seg p_offset p_vaddr p_filesz p_memszro 0x100 0x400100 0x2c 0x2crx 0x130 0x401130 0x1a3 0x1a3rw 0x2d8 0x4022d8 0x18 0x404300 − 0x4022d8 = 0x2028Assembly-sized sections verify the calculation:
$ cat calc.s .section .text,"ax",@progbits .balign 16 .globl _start_start: .zero 0x1a3, 0x90 .section .rodata,"a",@progbits .balign 16 .zero 0x2c .section .data,"aw",@progbits .balign 8 .zero 0x18 .section .bss,"aw",@nobits .balign 64 .zero 0x2000$ clang -c calc.s -o calc.o$ ld.lld -T calc.ld -o calc_lld calc.o$ readelf -lW calc_lld Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align LOAD 0x000100 0x0000000000400100 0x0000000000400100 0x00002c 0x00002c R 0x1000 LOAD 0x000130 0x0000000000401130 0x0000000000401130 0x0001a3 0x0001a3 R E 0x1000 LOAD 0x0002d8 0x00000000004022d8 0x00000000004022d8 0x000018 0x002028 RW 0x1000$ readelf -SW calc_lld | grep bss [ 4] .bss NOBITS 0000000000402300 0002f0 002000 00 WA 0 0 64$ ld -T calc.ld -o calc_bfd calc.o # readelf -lW matches the output aboveBoth recorded files occupy 1376 bytes.
3. A successful link can still violate page permissions
The initial script is:
ENTRY(_start)SECTIONS{ . = 0x10000; .text : { *(.text .text.*) } . = ALIGN(0x1000); .data : { *(.data .data.*) } .bss : { *(.bss .bss.*) *(COMMON) }} LOAD 0x001000 0x0000000000010000 0x0000000000010000 0x000032 0x000032 R E 0x1000 LOAD 0x002000 0x0000000000011000 0x0000000000011000 0x000004 0x001010 RW 0x1000used begins at 0x10000. Overlapping data at 0x10010 is rejected:
$ ld.lld -T overlap.ld -o ov_lld t.old.lld: error: section .text virtual address range overlaps with .data>>> .text range is [0x10000, 0x10031]>>> .data range is [0x10010, 0x10013]
ld.lld: error: section .text load address range overlaps with .data>>> .text range is [0x10000, 0x10031]>>> .data range is [0x10010, 0x10013]$ ld -T overlap.ld -o ov_bfd t.old: section .data LMA [0000000000010010,0000000000010013] overlaps section .text LMA [0000000000010000,0000000000010031]An explicitly misaligned output-section address behaves differently:
$ ld.lld -T mis.ld -o mis_lld t.old.lld: warning: address (0x10004) of section .text is not a multiple of alignment (16)$ ld -T mis.ld -o mis_bfd t.o$ readelf -SW mis_lld | grep ' .text' [ 1] .text PROGBITS 0000000000010004 001004 00003e 00 AX 0 0 16$ readelf -SW mis_bfd | grep ' .text' [ 1] .text PROGBITS 0000000000010004 001004 00003e 00 AX 0 0 4Both place input code at 0x10010 by adding 12 bytes inside the output section. LLD retains alignment 16 and warns; GNU lowers the output alignment to four.
Separate segments at different byte addresses can still overlap in pages:
$ ld.lld -T samepg.ld -o sp_lld t.o$ readelf -lW sp_lld | grep LOAD LOAD 0x001000 0x0000000000010000 0x0000000000010000 0x000032 0x000032 R E 0x1000 LOAD 0x001800 0x0000000000010800 0x0000000000010800 0x000004 0x001010 RW 0x1000$ ld -T samepg.ld -o sp_bfd t.old: warning: sp_bfd has a LOAD segment with RWX permissions$ readelf -lW sp_bfd | grep LOAD LOAD 0x001000 0x0000000000010000 0x0000000000010000 0x000804 0x001810 RWE 0x1000GNU combines this case into RWX and warns. LLD emits two LOADs sharing the same virtual page. Replace the entry loop with:
void _start(void) { buffer[0] = used(41); __asm__ volatile("syscall" : : "a"(60L), "D"((long)buffer[0]) : "rcx", "r11", "memory"); __builtin_unreachable();}$ clang -O1 -fno-pic -fno-asynchronous-unwind-tables -c a.c -o a.o$ ld.lld -z max-page-size=4096 -T samepg.ld -o a_sp a.o$ ld -z max-page-size=4096 -T samepg.ld -o a_sp_bfd a.old: warning: a_sp_bfd has a LOAD segment with RWX permissions$ ld.lld -T ok.ld a.o -o a_ok$ ld -T ok.ld a.o -o a_ok_bfd$ ./a_ok; echo $? # lld with the original ok.ld42$ ./a_ok_bfd; echo $? # GNU ld with the original ok.ld42$ ./a_sp; echo $? # lld, same virtual pageSegmentation fault (core dumped)139$ strace ./a_sp 2>&1 | tail -2--- SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_ACCERR, si_addr=0x10030} ---+++ killed by SIGSEGV (core dumped) +++$ ./a_sp_bfd; echo $? # GNU ld, same virtual page, RWX42On the recorded 4096-byte-page Linux host, the normal outputs return 42. LLD's same-page image faults at entry 0x10030 with SEGV_ACCERR: the later RW mapping has replaced the RX mapping, removing execute permission. GNU's same-page image runs, but its code is writable. Congruence makes each individual mapping possible; a script must also keep incompatible virtual-page permissions apart.
References and terminology
- ELF sections and program headers.
- GNU ld options, entry points, and LLD start/stop GC.
- LLD 21.1.8 writer and liveness analysis.
- xv6 user script, revision 06aad25 and Linux x86 kernel script.
- Exercise designs draw on CMU 15-213, CS 7.11, Stanford CS140e, MIT JOS Lab 1, and the storage-allocation project in Levine's Linkers and Loaders.
Appendix: terms and tools
-
Clang provides C-family language frontends and a compiler driver within the LLVM project. It commonly uses an integrated assembler; linker selection still depends on the target and configuration. Clang toolchain documentation. ↩
-
GNU is the recursive acronym “GNU's Not Unix,” the name of the free-software operating-system project. GCC, binutils, and glibc are distinct GNU projects with different responsibilities. GNU's introduction. ↩
-
LLD is LLVM's linker. ELF tools commonly invoke it as
ld.lld;lld-linkprovides a Windows-compatible interface. LLD. ↩ -
RISC-V is an open instruction-set architecture. The core course builds a linker on native x86-64 Linux; RV64 appears in architecture comparisons and kernel examples. Use the RISC-V psABI for those examples rather than applying x86-64 encodings or relocation rules. ↩
-
Section GC, section garbage collection, retains sections reachable from the entry and other roots and discards unused sections during linking. It is distinct from runtime heap garbage collection. GNU ld options. ↩
-
GOT, the Global Offset Table, stores addresses or related offsets used through indirection. It lets some address-dependent updates happen in data rather than instruction bytes; relocation types and the ABI define each entry's role. Dynamic linking. ↩
-
PLT, the Procedure Linkage Table, contains instruction sequences used as call stubs, often together with the GOT and dynamic symbol binding. It is not merely another table of addresses. Dynamic linking. ↩
-
ELF, the Executable and Linkable Format, specifies object files, executables, and shared objects. The gABI supplies generic rules; a processor-specific ABI supplies architecture-dependent rules such as relocation encodings. ELF specification. ↩
-
GCC, the GNU Compiler Collection, provides compilers for several languages. The
gcccommand is a driver that coordinates compilation, assembly, and linking; it need not perform all those operations in one process. Overall options. ↩ -
musl is a C-library implementation for Linux, providing standard functions and runtime support. We use it when inspecting or linking a compact static runtime; an ordinary Linux server need not have it installed. Project. ↩
-
PIE, a position-independent executable, can run at different load bases. Compiler and linker choices must cooperate; static PIE also needs a startup path that performs its required relocations. GCC link options. ↩
-
ASLR, Address Space Layout Randomization, varies the placement of selected process regions. It is an operating-system loading policy; formats such as PIE make the corresponding address movement possible. Linux configuration. ↩
-
FDE, Frame Description Entry, associates a code-address range with unwind instructions. Moving code or rebuilding
.eh_framerequires updating addresses and inter-record references. Exception-frame format. ↩ -
COMDAT identifies duplicate definition groups from which the linker may retain one copy. ELF expresses this with section groups and signatures; related group members must be selected consistently. ELF section groups. ↩
-
glibc, the GNU C Library, is the default C library in many Linux distributions. Startup files, shared libraries, and the dynamic linker all participate in building and running programs. Project. ↩
-
xv6 is MIT's small Unix-style teaching operating system. The series uses its RISC-V version to examine the handoff from ELF files to processes. Source. ↩
-
ICF, Identical Code Folding, merges code judged equivalent. Matching bytes alone may be insufficient: relocation targets, observable function addresses, and associated runtime metadata also matter. LLD. ↩