[The World of Linkers—Theory 02] An Object File Is a Program with Unfinished Business
main.c can compile without add.c. That convenience creates a file-format obligation: main.o must preserve the call's machine code and enough information to finish the call later. A list of bytes cannot explain which name is missing, which field needs changing, or which calculation belongs there.
The file also has to distinguish executable instructions, writable variables, read-only constants, and information used only by tools. A large zero-initialized array should not require a large run of zero bytes on disk. An ELF1 object is therefore both compiled material and a structured handoff.
This chapter follows that handoff from assembly directives to raw ELF bytes, explaining how ELF preserves code, data, names, and unfinished address calculations. The examples use little-endian x86-64 ELF64; generic file structures and architecture-specific encodings are treated separately.
Follow an entry from the file to its payload
The first row is one contiguous file. The ELF header locates the whole section table:
e_shoff=256, e_shnum=3, and e_shstrndx=2. Each entry occupies
e_shentsize=64 bytes. Entry 1 starts at 256 + 1 * 64 = 320, so its
descriptor occupies [320,384). Reading that entry's sh_offset=64 and sh_size=128
then identifies the .text payload at [64,192). Each of those fields occupies
8 bytes; the stored value describes a different region. Every entry shares the
same 64-byte layout, with special SHT_NULL semantics for entry zero.
Names are not inline in the headers. e_shstrndx selects the header describing
.shstrtab; that header locates the name bytes. Each entry's sh_name is an
offset into those bytes. Keep the two origins distinct: sh_offset is relative
to the file start, whereas sh_name is relative to the string-table start.
The section table need not be last, and payload order can differ from table
order. SHT_NOBITS, commonly used for .bss, describes memory without storing
payload bytes. Three headers take 192 bytes even in this tiny example, so the
table can be larger than the payloads it describes.
Start before the object file exists
Use two source files. The caller contains several kinds of storage so that the resulting sections have something concrete to describe:
// main.cint add(int a, int b);
int counter = 42;int zeros[64];const int table[4] = {1, 2, 3, 4};const char *msg = "hello";
int main(void) { return add(counter, table[2]) * 2;}// add.cint add(int a, int b) { return a + b;}The command name cc2 does not identify a particular implementation. Here we select Clang explicitly and ask it to show the compilation phases:
$ clang -O1 -ccc-print-phases -c add.c +- 0: input, "add.c", c +- 1: preprocessor, {0}, cpp-output +- 2: compiler, {1}, ir+- 3: backend, {2}, assembler4: assembler, {3}, objectPreprocessing expands includes and macros. The compiler produces IR3, optimization works on that representation, the backend selects target instructions, and assembly forms the object file. Save the intermediate products to inspect them:
$ clang -O1 -save-temps -c add.c$ ls add.*add.bc add.c add.i add.o add.sThe .i file is preprocessed C, .bc is LLVM4 bitcode, .s is assembly text, and .o is the object. These files make the stages visible; they are not proof that every ordinary compilation writes each intermediate file. Clang can use an integrated assembler. GCC5 commonly invokes as6, with a temporary file or a pipe carrying assembly between stages.
The generated add.s, with a few incidental comments removed, is short:
.file "add.c" .text .globl add .p2align 4 .type add,@functionadd: .cfi_startproc leal (%rdi,%rsi), %eax retq.Lfunc_end0: .size add, .Lfunc_end0-add .cfi_endproc .ident "Ubuntu clang version 21.1.8 (6ubuntu1)" .section ".note.GNU-stack","",@progbits .addrsigOnly leal and retq are CPU instructions. In this AT&T syntax, the destination operand is on the right. The x86-64 calling convention supplies the first integer arguments in the argument registers; the result is returned in %eax.
The other lines tell the assembler how to organize the file. A label assigns a name to the current location. .file records a source filename. .ident supplies a compiler-identification string. .cfi_* describes unwind state. .addrsig requests address-significance metadata used when deciding whether merging functions would alter observable address identity. Their consumers differ, even though they arrive in one assembly stream.
The assembler has a current section and a current offset
A useful working model is a collection of growing buffers, one per section. An instruction appends its encoding to the current buffer. A data directive appends data. A label records a section and an offset. Switching away from a section and later returning resumes that same section; it does not create a new copy.
Write an object directly in assembly to see the model without C in front of it:
# hand.S: construct an object directly .section .text,"ax",@progbits .globl add .type add,@functionadd: leal (%rdi,%rsi), %eax ret .size add, . - add
.section .data,"aw",@progbits .balign 4 .globl answer .type answer,@objectanswer: .long 42 .size answer, 4
.section .rodata,"a",@progbitsgreeting: .asciz "hi" .byte 0xff .balign 8 .uleb128 624485 .sleb128 -123456
# Macro: substitute the parameters \name and \value .macro CONST name, value .globl \name\name: .long \value .endm
#ifdef BIG CONST limit, 100000#else CONST limit, 100#endif
.section .note.GNU-stack,"",@progbitsThe main directives express different kinds of information:
| Directive | Effect |
|---|---|
.section name,"flags",@type | Select a section, creating it if necessary |
.globl name | Give a name global binding |
.type name,@function or @object | Describe what the symbol represents |
.size name, expression | Record a size; . denotes the current location |
.long, .quad, .byte | Emit fixed-width data of 4, 8, or 1 byte |
.asciz | Emit a string and a terminating zero |
.zero n | Reserve or emit zero-filled storage according to the section type |
.balign 8 | Advance to a multiple of eight, adding padding |
.p2align 4 | Align to bytes |
For the section flags, a means allocated at runtime, w writable, and x executable instructions. @progbits stores contents in the file; @nobits describes storage without corresponding contents. Usual .text, .data, and .bss directives select conventional sections with appropriate defaults. See the assembler's section directive documentation for the complete rules.
In .size add, . - add, both locations belong to the same section, so their difference is the function length. The CPU does not read that size to execute ret; tools use it to identify the function's range.
Alignment can add bytes without adding a source-level object. Data padding here is zero; code alignment may use NOP encodings. The macro body substitutes \name and \value at each invocation. CONST limit, 100 emits a global label followed by a four-byte integer; it does not create a runtime macro mechanism.
For the first pass, retain three ideas: sections group contents, labels name section offsets, and alignment can add padding. The LEB128 and preprocessing directives in hand.S are explained in the optional section near the end.
Which references can the assembler finish?
Knowing that a label occurs in this source file does not always mean its address can be committed now. Consider a local function, a public function, and a global variable:
// calls.cstatic __attribute__((noinline)) int helper(int x) { return x * 3; }__attribute__((noinline)) int pub(int x) { return x + 1; }int count;
int caller(int x) { return helper(x) + pub(x) + count;}noinline keeps the calls visible for inspection. Add -r to disassembly so relocation records appear beside their fields:
$ clang -O1 -c calls.c -o calls.o$ llvm-objdump -dr calls.o[excerpt]0000000000000010 <caller>: 10: 55 pushq %rbp 11: 53 pushq %rbx 12: 50 pushq %rax 13: 89 fb movl %edi, %ebx 15: e8 26 00 00 00 callq 0x40 <helper> 1a: 89 c5 movl %eax, %ebp 1c: 89 df movl %ebx, %edi 1e: e8 00 00 00 00 callq 0x23 <caller+0x13> 000000000000001f: R_X86_64_PLT32 pub-0x4 23: 01 e8 addl %ebp, %eax 25: 03 05 00 00 00 00 addl (%rip), %eax # 0x2b <caller+0x1b> 0000000000000027: R_X86_64_PC32 count-0x4[excerpt]0000000000000040 <helper>: 40: 8d 04 7f leal (%rdi,%rdi,2), %eax 43: c3 retqThe call to helper already has displacement 0x26: the next instruction is at 0x1a, and 0x1a + 0x26 = 0x40. Moving the entire .text section preserves that distance. The call to pub retains a relocation, as does the access to count.
A forward reference such as helper requires layout before final encoding. Even layout can require iteration on x86 because jumps have short and long forms:
$ cat jmp100.s .text jmp .Ldone .fill 100, 1, 0x90.Ldone: ret$ clang -c jmp100.s -o jmp100.o$ llvm-objdump -d jmp100.o | grep -E 'jmp|ret' 0: eb 64 jmp 0x66 <.text+0x66> 66: c3 retqA jump over 100 bytes fits the short form eb 64. Change the gap to 200 and the assembler needs the five-byte form e9 c8 00 00 00; the destination itself consequently moves. Multiple branches and alignment directives can influence one another. Iteration finds a stable layout before final fixups are applied.
The assembler initially represents unresolved expressions as fixups. After layout, it can evaluate an expression, reject an invalid expression, or emit a relocation for the linker. Three cases explain this example:
- A same-section relative reference with established local binding can become a constant displacement.
- A reference across sections needs their eventual relative placement, even if the target is local.
- A reference through a global or weak name may require the linker's binding decision, even if a definition is currently visible in the same section.
Give every function its own section and the local call now needs a relocation too:
$ clang -O1 -ffunction-sections -c calls.c -o calls-fs.o$ llvm-objdump -dr calls-fs.o | grep -A1 'callq' 5: e8 00 00 00 00 callq 0xa <caller+0xa> 0000000000000006: R_X86_64_PLT32 .text.helper-0x4-- e: e8 00 00 00 00 callq 0x13 <caller+0x13> 000000000000000f: R_X86_64_PLT32 pub-0x4The reference to helper is expressed as a section symbol plus an offset. Public pub retains its name because symbol resolution and interposition can determine a different binding. By contrast, a same-section difference used for .size can still be evaluated even when one endpoint has global binding: it describes this assembled range.
The rule is about what remains undecided, not whether a human can see both labels in the file.
Read the header before interpreting the contents
Keep a map in view: the ELF header locates the section-header table; section headers identify contents and byte ranges; .symtab gets names through .strtab; relocation entries refer to symbol-table indices. Each field below describes either a range, a name, or a relationship between tables.
Compile the original inputs:
$ clang -O1 -c main.c -o main.o$ clang -O1 -c add.c -o add.o$ file main.o add.omain.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not strippedadd.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not strippedfile identifies class, byte order, machine, and relocatable type from the header. For deeper inspection, GNU readelf7 prints ELF fields directly; llvm-objdump is especially useful for disassembly; nm8 focuses on symbols. Independent readers help cross-check an interpretation. The short Python reader below is an explanatory tool for this known ELF64 little-endian input, not a hardened parser for arbitrary files.
The first 64 bytes are the ELF64 header:
$ xxd -l 64 main.o00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............00000010: 0100 3e00 0100 0000 0000 0000 0000 0000 ..>.............00000020: 0000 0000 0000 0000 e002 0000 0000 0000 ................00000030: 0000 0000 4000 0000 0000 4000 0f00 0100 ....@.....@.....The ELF64 structure Elf64_Ehdr occupies 64 bytes; the ELF Header specification defines its fields. The gABI defines the generic file structures, while a psABI9 supplies processor-specific conventions. Class and byte order come from the file's e_ident, rather than from the machine running the parser.
The first 16 bytes, e_ident, can be interpreted before word size or byte order is known. The remaining multibyte fields follow the byte order it specifies. This table accounts for all 64 bytes; offsets are relative to the file start, widths describe the fields themselves, and values are decoded from the example above.
| Offset: decimal / hexadecimal | Width | Field | Example value | Meaning |
|---|---|---|---|---|
0 / 0x00 | 4 | EI_MAG0..EI_MAG3 | 7f 45 4c 46 | ELF magic |
4 / 0x04 | 1 | EI_CLASS | 2 | ELF64 format |
5 / 0x05 | 1 | EI_DATA | 1 | Little-endian encoding |
6 / 0x06 | 1 | EI_VERSION | 1 | Format version in the identification array |
7 / 0x07 | 1 | EI_OSABI | 0 | OS/ABI extension identity |
8 / 0x08 | 1 | EI_ABIVERSION | 0 | Version of the identified ABI |
9 / 0x09 | 7 | Reserved area beginning at EI_PAD | All zero | Reserved bytes |
16 / 0x10 | 2 | e_type | 1 | ET_REL: relocatable file |
18 / 0x12 | 2 | e_machine | 62 | EM_X86_64: target architecture |
20 / 0x14 | 4 | e_version | 1 | Format version in the header structure |
24 / 0x18 | 8 | e_entry | 0 | Entry virtual address; zero if none |
32 / 0x20 | 8 | e_phoff | 0 | Program-header table file offset; zero if absent |
40 / 0x28 | 8 | e_shoff | 736 | Section-header table file offset |
48 / 0x30 | 4 | e_flags | 0 | Processor-specific flags |
52 / 0x34 | 2 | e_ehsize | 64 | Size of the ELF header itself |
54 / 0x36 | 2 | e_phentsize | 0 | Size of one program header |
56 / 0x38 | 2 | e_phnum | 0 | Number of program headers |
58 / 0x3a | 2 | e_shentsize | 64 | Size of one section header |
60 / 0x3c | 2 | e_shnum | 15 | Number of section headers |
62 / 0x3e | 2 | e_shstrndx | 1 | Section-header index of the section-name string table |
The two version fields are distinct: EI_VERSION at offset 6 is one byte, while e_version at offset 20 is four bytes. Both contain EV_CURRENT=1 here. Likewise, e_ehsize=64 describes the file header and e_shentsize=64 describes one section header. Equal sizes do not make them the same structure. With ordinary numbering, section indices start at zero and must be less than e_shnum.
Ordinary section counts are below SHN_LORESERVE=0xff00. At that threshold, e_shnum holds zero and section zero's sh_size carries the actual count; the large count is not stored directly in the 16-bit field. A name-table index at the same threshold uses SHN_XINDEX=0xffff in e_shstrndx and section zero's sh_link for the actual index. See the gABI ELF header definition.
ELF also permits an absent section table, an absent section-name table, and extended numbering. A zero e_shnum can indicate no table or require the actual count to be read from entry zero's sh_size; when e_shstrndx=SHN_XINDEX, entry zero's sh_link holds the actual index. A parser can explicitly support only a subset of these encodings without declaring the other encodings invalid ELF.
Other familiar types include ET_EXEC and ET_DYN; the latter covers shared objects and PIE10 executables. Table location, entry count, and the contents described by those entries remain separate concepts.
The section header table ends at , exactly the recorded file size:
$ wc -c main.o1696 main.oProving that a table range can be read
A table position does not prove its bytes exist. Treat a file of length L as a byte sequence and use half-open intervals [offset, end): the start is included, the end is excluded, and the length is end − offset. Reading s bytes at o requires calculating end = o + s without overflow, then proving end ≤ L. With a nonnegative size and valid addition, the start is also within the file. The empty interval [L,L) is valid; [L+1,L+1) is outside the file despite containing no bytes.
The table length is entry count × entry width, not just the count. Here 15 × 64 = 960 bytes, so the table occupies [736,1696). Checking only 736 + 15 ≤ L establishes at most the first fifteen bytes, not fifteen complete headers.
Selecting entry i requires two independent checks:
i < e_shnum: the index belongs to this table. Additional file bytes cannot turn the following content into another table entry.- The entire interval
[e_shoff + i × e_shentsize, e_shoff + (i+1) × e_shentsize)lies within the file. A valid index can still refer to truncated input.
Validation of another byte sequence does not establish these facts if the caller supplies a shorter input or constructs a table position itself. Perform arithmetic in a type wide enough for file coordinates. The count and ordinary index fields occupy two bytes, but multiplication need not use a 16-bit type: 1024 × 64 = 65536 cannot be represented in u16. Widen before multiplying rather than reporting a valid count as overflow.
Arithmetic overflow and file bounds are different failures. At offset u64::MAX − 63, a 128-byte table has a mathematical end beyond u64::MAX; addition cannot express its range. At offset 160 with length 64 in a 192-byte file, the end 224 is representable but outside the file. Casting a large offset to host usize first can truncate high bits on a narrower machine and turn an invalid position into an apparently readable small index. Prove the range first, then use checked conversions.
Range checks establish safe reading, not correct record semantics. Name indices, symbol-table associations, and relocation ownership need their own validation; the following sections explain those relationships.
The header summary and empty execution view agree:
$ llvm-objdump -f main.o
main.o: file format elf64-x86-64architecture: x86_64start address: 0x0000000000000000$ llvm-objdump -p main.o
main.o: file format elf64-x86-64
Program Header:
Dynamic Section:The section view, however, is already populated:
$ llvm-objdump -h main.o
main.o: file format elf64-x86-64
Sections:Idx Name Size VMA Type 0 00000000 0000000000000000 1 .strtab 000000a1 0000000000000000 2 .text 00000015 0000000000000000 TEXT 3 .rela.text 00000030 0000000000000000 4 .data 00000010 0000000000000000 DATA 5 .rela.data 00000018 0000000000000000 6 .rodata 00000010 0000000000000000 DATA 7 .rodata.str1.1 00000006 0000000000000000 DATA 8 .bss 00000100 0000000000000000 BSS 9 .comment 00000028 0000000000000000 10 .note.GNU-stack 00000000 0000000000000000 11 .eh_frame 00000030 0000000000000000 DATA 12 .rela.eh_frame 00000018 0000000000000000 13 .llvm_addrsig 00000000 0000000000000000 14 .symtab 000000f0 0000000000000000Zero section virtual addresses are expected here: these input sections have not received final runtime placement.
Section headers describe relationships as well as ranges
Each section header uses the 64-byte Elf64_Shdr layout. Offsets here are relative to the beginning of that entry, rather than to the beginning of the file.
| Entry offset: decimal / hexadecimal | Width | Field | Meaning |
|---|---|---|---|
0 / 0x00 | 4 | sh_name | Byte offset within the section-name string table |
4 / 0x04 | 4 | sh_type | Section type |
8 / 0x08 | 8 | sh_flags | Section attribute bits |
16 / 0x10 | 8 | sh_addr | Memory address; relocatable inputs usually have no assigned address |
24 / 0x18 | 8 | sh_offset | Content location relative to the file start |
32 / 0x20 | 8 | sh_size | Content size in bytes; for NOBITS, required memory size |
40 / 0x28 | 4 | sh_link | Association with another section, interpreted according to section type |
44 / 0x2c | 4 | sh_info | Additional information, interpreted according to section type |
48 / 0x30 | 8 | sh_addralign | Address alignment; zero and one impose no additional alignment |
56 / 0x38 | 8 | sh_entsize | Size of each record in a fixed-record section |
To locate entry i, calculate e_shoff + i × e_shentsize. Read the descriptor there, then use its sh_offset and sh_size to locate its payload. A NOBITS descriptor gives a memory requirement without supplying that many bytes in the file. Symbol and relocation tables also require following associations such as sh_link, as the next sections explain.
This Python reader explains a known-valid ELF64 little-endian input. It does not fully validate table bounds, extended numbering, string termination, or associated indices. A general-purpose parser must validate these conditions before performing the same lookups:
import struct, sys
data = open(sys.argv[1], "rb").read()# ELF64: e_shoff at 0x28; e_shentsize/e_shnum/e_shstrndx at 0x3ashoff = struct.unpack_from("<Q", data, 0x28)[0]shentsize, shnum, shstrndx = struct.unpack_from("<HHH", data, 0x3a)
TYPES = {0: "NULL", 1: "PROGBITS", 2: "SYMTAB", 3: "STRTAB", 4: "RELA", 8: "NOBITS", 0x70000001: "X86_64_UNWIND", 0x6fff4c03: "LLVM_ADDRSIG"}FLAGS = [(0x1, "W"), (0x2, "A"), (0x4, "X"), (0x10, "M"), (0x20, "S"), (0x40, "I")]
def shdr(i): # sh_name, sh_type, sh_flags, sh_addr, sh_offset, sh_size, # sh_link, sh_info, sh_addralign, sh_entsize return struct.unpack_from("<IIQQQQIIQQ", data, shoff + i * shentsize)
names_off = shdr(shstrndx)[4]def name(off): end = data.index(b"\0", names_off + off) return data[names_off + off:end].decode()
print("Nr Name Type Flg Off Size Lk Inf Al ES")for i in range(shnum): n, t, f, _, off, size, link, info, align, es = shdr(i) flg = "".join(c for bit, c in FLAGS if f & bit) print(f"{i:2} {name(n):16} {TYPES.get(t, hex(t)):13} {flg:3} " f"{off:06x} {size:06x} {link:2} {info:3} {align:2} {es:2}")The format <IIQQQQIIQQ selects little-endian integers with the widths required by the ELF64 structure. The result is:
$ python3 shdr.py main.oNr Name Type Flg Off Size Lk Inf Al ES 0 NULL 000000 000000 0 0 0 0 1 .strtab STRTAB 000238 0000a1 0 0 1 0 2 .text PROGBITS AX 000040 000015 0 0 16 0 3 .rela.text RELA I 0001d8 000030 14 2 8 24 4 .data PROGBITS WA 000058 000010 0 0 8 0 5 .rela.data RELA I 000208 000018 14 4 8 24 6 .rodata PROGBITS A 000070 000010 0 0 16 0 7 .rodata.str1.1 PROGBITS AMS 000080 000006 0 0 1 1 8 .bss NOBITS WA 000090 000100 0 0 16 0 9 .comment PROGBITS MS 000090 000028 0 0 1 110 .note.GNU-stack PROGBITS 0000b8 000000 0 0 1 011 .eh_frame X86_64_UNWIND A 0000b8 000030 0 0 8 012 .rela.eh_frame RELA I 000220 000018 14 11 8 2413 .llvm_addrsig LLVM_ADDRSIG 000238 000000 14 0 1 014 .symtab SYMTAB 0000e8 0000f0 1 4 8 24Types describe representation. PROGBITS stores ordinary contents; NOBITS reserves storage without contents; SYMTAB, STRTAB, and RELA identify structured tables. X86_64_UNWIND is a processor-specific type used for unwind information. LLVM_ADDRSIG is an LLVM extension supporting decisions about observable address identity, including safe ICF11.
Flags express other properties:
| Flag | Value | Meaning |
|---|---|---|
| W | 0x1 | Writable |
| A | 0x2 | Allocated at runtime |
| X | 0x4 | Executable instructions |
| M | 0x10 | Mergeable elements |
| S | 0x20 | Elements are zero-terminated strings |
| I | 0x40 | sh_info is a section index |
Thus .text is AX, .data and .bss are WA, and .rodata is A. Nonallocated tables serve tools rather than becoming ordinary mapped program storage.
.bss has size 0x100 for zeros[64], yet shares file offset 0x90 with the following .comment. No 256-byte file range is consumed by its storage. The assembler describes the reservation; the linker ultimately expresses the zero-filled runtime tail through segment sizes. With the toolchain's -fno-common behavior, this tentative C definition already belongs to .bss; common symbols are the next chapter's topic.
The relationships in sh_link and sh_info depend on section type. .rela.text links to symbol table 14 and targets section 2. .rela.data targets section 4. .rela.eh_frame targets section 11. .symtab links to string table 1; its info value 4 marks the first nonlocal symbol. Do not interpret all sh_info fields as section indices.
Section zero is SHT_NULL. It is all zero in this file, but large files may use it to hold extended numbering information. Likewise, the name .shstrtab is a convention rather than a requirement: LLVM here shares .strtab between section names and symbol names. Follow indices instead of guessing relationships from names.
$ llvm-objdump -s -j .strtab main.o
main.o: file format elf64-x86-64Contents of section .strtab: 0000 002e7265 6c612e74 65787400 2e636f6d ..rela.text..com 0010 6d656e74 002e6273 73007a65 726f7300 ment..bss.zeros. 0020 636f756e 74657200 6d61696e 002e6e6f counter.main..no 0030 74652e47 4e552d73 7461636b 006d7367 te.GNU-stack.msg 0040 002e6c6c 766d5f61 64647273 6967002e ..llvm_addrsig.. 0050 72656c61 2e65685f 6672616d 65007461 rela.eh_frame.ta 0060 626c6500 61646400 6d61696e 2e63002e ble.add.main.c.. 0070 73747274 6162002e 73796d74 6162002e strtab..symtab.. 0080 726f6461 7461002e 72656c61 2e646174 rodata..rela.dat 0090 61002e72 6f646174 612e7374 72312e31 a..rodata.str1.1 00a0 00 .A string table is a byte sequence of zero-terminated names, referenced by byte offset. counter starts at 0x20, main at 0x28, and add at 0x64. A name may begin inside another stored string: .text starts at offset 6, inside .rela.text. This suffix sharing is legal because the index identifies bytes, not a numbered string record.
Match the stored bytes to the source
Generate main.s with clang -O1 -S main.c. Its data directives expose the intended organization:
.type counter,@object .data .globl counter .p2align 2, 0x0counter: .long 42 .size counter, 4 .type table,@object .section .rodata,"a",@progbits .globl table .p2align 4, 0x0table: .long 1 .long 2 .long 3 .long 4 .size table, 16 .type .L.str,@object .section .rodata.str1.1,"aMS",@progbits,1.L.str: .asciz "hello" .size .L.str, 6 .type msg,@object .data .globl msg .p2align 3, 0x0msg: .quad .L.str .size msg, 8 .type zeros,@object .bss .globl zeros .p2align 4, 0x0zeros: .zero 256 .size zeros, 256Returning to .data appends msg after counter and alignment padding. The generated code is:
$ llvm-objdump -d main.o
main.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>: 0: 50 pushq %rax 1: 8b 3d 00 00 00 00 movl (%rip), %edi # 0x7 <main+0x7> 7: be 03 00 00 00 movl $0x3, %esi c: e8 00 00 00 00 callq 0x11 <main+0x11> 11: 01 c0 addl %eax, %eax 13: 59 popq %rcx 14: c3 retqtable[2] became immediate 3. The compiler knew the const array's value and eliminated the load. The push and pop maintain the call-site stack alignment; they are not preserving a meaningful C variable here. The two zero displacement fields still await linking.
$ llvm-objdump -s -j .data main.o
main.o: file format elf64-x86-64Contents of section .data: 0000 2a000000 00000000 00000000 00000000 *...............The first four data bytes encode 42, the next four are padding, and the final eight are the unresolved pointer msg. Declaring const char *msg makes the pointed-to characters const; it does not make the pointer object itself const.
The ordinary read-only section holds the four integers of table. The string has a separate mergeable-string input section with one-byte character units. The linker may combine identical strings, which is why references to pieces of mergeable sections require care.
$ llvm-objdump -s -j .bss main.o[file header omitted]Contents of section .bss:<skipping contents of bss section at [0000, 0100)>No .bss bytes are available to dump. The version string does have file contents but no allocation flag:
$ llvm-objdump -s -j .comment main.o
main.o: file format elf64-x86-64Contents of section .comment: 0000 00556275 6e747520 636c616e 67207665 .Ubuntu clang ve 0010 7273696f 6e203231 2e312e38 20283675 rsion 21.1.8 (6u 0020 62756e74 75312900 buntu1)..note.GNU-stack is another useful contrast: it has no contents and no allocation flag, yet communicates a requirement used in producing the executable. .eh_frame does have allocated contents and its own relocations, because unwind records must refer to the final code ranges.
A symbol record is more than a name
$ llvm-objdump -t main.o
main.o: file format elf64-x86-64
SYMBOL TABLE:0000000000000000 l df *ABS* 0000000000000000 main.c0000000000000000 l d .text 0000000000000000 .text0000000000000000 l d .rodata.str1.1 0000000000000000 .rodata.str1.10000000000000000 g F .text 0000000000000015 main0000000000000000 g O .data 0000000000000004 counter0000000000000000 *UND* 0000000000000000 add0000000000000000 g O .rodata 0000000000000010 table0000000000000008 g O .data 0000000000000008 msg0000000000000000 g O .bss 0000000000000100 zerosEvery ELF64 symbol occupies 24 bytes:
typedef struct { Elf64_Word st_name; /* 4 bytes: name offset in the linked string table */ unsigned char st_info; /* 1 byte: binding in high nibble, type in low nibble */ unsigned char st_other; /* 1 byte: includes visibility (Theory 03) */ Elf64_Half st_shndx; /* 2 bytes: section index */ Elf64_Addr st_value; /* 8 bytes: value; normally a section offset in ET_REL */ Elf64_Xword st_size; /* 8 bytes: size */} Elf64_Sym;The table contains ten records, including the reserved all-zero entry that this objdump listing omits. Inspect its encoding:
$ llvm-objdump -s -j .symtab main.o
main.o: file format elf64-x86-64Contents of section .symtab: 0000 00000000 00000000 00000000 00000000 ................ 0010 00000000 00000000 68000000 0400f1ff ........h....... 0020 00000000 00000000 00000000 00000000 ................ 0030 00000000 03000200 00000000 00000000 ................ 0040 00000000 00000000 00000000 03000700 ................ [intermediate rows omitted] 0060 28000000 12000200 00000000 00000000 (............... 0070 15000000 00000000 20000000 11000400 ........ ....... 0080 00000000 00000000 04000000 00000000 ................ 0090 64000000 10000000 00000000 00000000 d............... 00a0 00000000 00000000 5e000000 11000600 ........^....... [remaining output omitted]At offset 0x60, symbol 4 has name offset 0x28 (main), info 0x12, section index 2, value zero, and size 0x15. Split st_info into high and low nibbles: binding 1 is STB_GLOBAL; type 2 is STT_FUNC. Its value is a section-relative offset in this relocatable file, not a final process address.
Binding controls participation in name resolution. Local symbols belong to one input. Global symbols participate across inputs. Weak definitions have lower precedence than ordinary global definitions. This small example makes the categories visible:
// bind.cstatic int hits; /* local to this input */__attribute__((weak)) int hook(void) { /* weak definition; a strong definition can replace it */ return 0;}extern int maybe(void) __attribute__((weak)); /* weak reference */
int poke(void) { hits++; return hook() + (maybe ? maybe() : 0) + hits;}$ clang -O1 -c bind.c -o bind.o$ llvm-objdump -t bind.o
bind.o: file format elf64-x86-64
SYMBOL TABLE:0000000000000000 l df *ABS* 0000000000000000 bind.c0000000000000000 l d .text 0000000000000000 .text0000000000000000 l O .bss 0000000000000004 hits0000000000000000 l d .bss 0000000000000000 .bss0000000000000000 w F .text 0000000000000003 hook0000000000000010 g F .text 000000000000002b poke0000000000000000 w *UND* 0000000000000000 maybeThe C static object becomes a local symbol. hook is a weak definition; maybe is a weak undefined reference. The compiler emits .weak to express those bindings in assembly. The handwritten object's symbols provide another comparison:
$ llvm-objdump -t hand-small.o
hand-small.o: file format elf64-x86-64
SYMBOL TABLE:0000000000000000 l .rodata 0000000000000000 greeting0000000000000000 g F .text 0000000000000004 add0000000000000000 g O .data 0000000000000004 answer000000000000000e g .rodata 0000000000000000 limitWithout .type or .size, a symbol can remain STT_NOTYPE with size zero and still be useful for linking. Those missing attributes reduce what debuggers or profiling tools can infer about its extent.
File symbols use STT_FILE and the special SHN_ABS index. Section symbols use STT_SECTION; their stored name can be empty even when an inspection tool displays the associated section's name. They let relocations describe a local location without a global name lookup:
$ llvm-objdump -dr bind.o[excerpt]0000000000000010 <poke>: 10: 53 pushq %rbx 11: ff 05 00 00 00 00 incl (%rip) # 0x17 <poke+0x7> 0000000000000013: R_X86_64_PC32 .bss-0x4[remaining output omitted]The reference to local hits becomes .bss plus the appropriate offset. A compiler-private .L label can similarly disappear from the symbol table when a section-relative expression represents it adequately. As the exercises show, references into mergeable sections can require retaining such a label.
For add, st_shndx = SHN_UNDEF records the missing definition. NOTYPE in that record does not mean the C compiler forgot that add was declared as a function; it means this ELF record does not specify a symbol type. nm makes the unfinished relationship particularly clear:
$ nm main.o U add0000000000000000 D counter0000000000000000 T main0000000000000008 D msg0000000000000000 R table0000000000000000 B zeros$ llvm-objdump -t add.o
add.o: file format elf64-x86-64
SYMBOL TABLE:0000000000000000 l df *ABS* 0000000000000000 add.c0000000000000000 l d .text 0000000000000000 .text0000000000000000 g F .text 0000000000000004 addThe definition in add.o supplies the section, value, type, and size. Local records precede global and weak records in the ELF table. The .symtab header's sh_info = 4 therefore lets the linker skip directly to the first nonlocal record when building its global resolution table.
A relocation is a request, not a search for zero bytes
Now inspect the instructions together with their pending requests:
$ llvm-objdump -dr main.o[file header omitted]0000000000000000 <main>: 0: 50 pushq %rax 1: 8b 3d 00 00 00 00 movl (%rip), %edi # 0x7 <main+0x7> 0000000000000003: R_X86_64_PC32 counter-0x4 7: be 03 00 00 00 movl $0x3, %esi c: e8 00 00 00 00 callq 0x11 <main+0x11> 000000000000000d: R_X86_64_PLT32 add-0x4 11: 01 c0 addl %eax, %eax 13: 59 popq %rcx 14: c3 retqThe field for counter starts at .text+3; the call field starts at .text+0xd. Listing all relocations also reveals the pointer and unwind reference:
$ llvm-objdump -r main.o
main.o: file format elf64-x86-64
RELOCATION RECORDS FOR [.text]:OFFSET TYPE VALUE0000000000000003 R_X86_64_PC32 counter-0x4000000000000000d R_X86_64_PLT32 add-0x4
RELOCATION RECORDS FOR [.data]:OFFSET TYPE VALUE0000000000000008 R_X86_64_64 .rodata.str1.1
RELOCATION RECORDS FOR [.eh_frame]:OFFSET TYPE VALUE0000000000000020 R_X86_64_PC32 .textAn Elf64_Rela contains three eight-byte fields: r_offset, r_info, and signed r_addend. The relocation section's own header identifies the target section and symbol table. Decode the two text records:
$ llvm-objdump -s -j .rela.text main.o
main.o: file format elf64-x86-64Contents of section .rela.text: 0000 03000000 00000000 02000000 05000000 ................ 0010 fcffffff ffffffff 0d000000 00000000 ................ 0020 04000000 06000000 fcffffff ffffffff ................The first r_info is 0x0000000500000002: symbol index 5 (counter) in the upper half and type 2 (R_X86_64_PC32) in the lower half. Its addend is −4. The next record names symbol 6 (add), uses type 4 (R_X86_64_PLT32), and has the same addend.
Read each record as a concrete request:
- At
.text+3, write the appropriate four-byte relative value forcounterwith addend −4. - At
.text+0xd, write the call displacement foradd, subject to the binding rules ofPLT32. - At
.data+8, write the eight-byte address of the beginning of.rodata.str1.1. - In
.eh_frame, repair the reference identifying the code range.
The zero bytes in these examples are placeholders. A linker does not scan for zeros and guess which ones are missing addresses. Records identify the fields and formulas, and some relocation formats take an addend from the existing field itself. Before evaluating a name-based relocation, the linker must decide which definition that name denotes.
Linking adds the loader's view
Sections provide the detailed units used in construction. Segments describe how to establish the runtime image. To see both views in one file, provide a minimal entry point without a C runtime:
# start.S: entry without a C runtime .text .globl _start_start: call main movl %eax, %edi # use the return value of main as the exit status movl $60, %eax # 60 is the x86-64 Linux exit syscall number syscall .section .note.GNU-stack,"",@progbitsLink the three objects and inspect the result:
$ clang -c start.S -o start.o$ ld.lld -o prog start.o main.o add.o$ readelf -lW prog
Elf file type is EXEC (Executable file)Entry point 0x2011d0There are 5 program headers, starting at offset 64
Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align PHDR 0x000040 0x0000000000200040 0x0000000000200040 0x000118 0x000118 R 0x8 LOAD 0x000000 0x0000000000200000 0x0000000000200000 0x0001c4 0x0001c4 R 0x1000 LOAD 0x0001d0 0x00000000002011d0 0x00000000002011d0 0x000034 0x000034 R E 0x1000 LOAD 0x000208 0x0000000000202208 0x0000000000202208 0x000010 0x000118 RW 0x1000 GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0
Section to Segment mapping: Segment Sections... 00 01 .rodata .eh_frame 02 .text 03 .data .bss 04Each ELF64 program header is a 56-byte Elf64_Phdr. Offsets below are relative to the start of the current entry; widths describe the fields themselves:
| Entry offset | Width | Field | Meaning |
|---|---|---|---|
| 0 | 4 | p_type | The program header's role |
| 4 | 4 | p_flags | Permission bits: X=1, W=2, R=4 |
| 8 | 8 | p_offset | Payload offset from the beginning of the file |
| 16 | 8 | p_vaddr | Segment virtual address |
| 24 | 8 | p_paddr | Physical address; unused for ordinary user programs |
| 32 | 8 | p_filesz | Payload size in the file |
| 40 | 8 | p_memsz | Payload size in memory |
| 48 | 8 | p_align | Alignment requirement |
The p_filesz field always occupies eight bytes. A value of 16 describes sixteen payload bytes; field width and represented size are different quantities. Entry i starts at e_phoff + i × e_phentsize. Its p_offset points to content elsewhere in the file: the entry itself is not that content. Segment permissions differ from section SHF_* flags: RX is 5, while a code section's ALLOC|EXECINSTR is 6.
For PT_LOAD, file offset and virtual address describe the mapping, file and memory sizes distinguish stored bytes from total storage, and alignment constrains their relationship. PT_PHDR describes the program header table itself, while PT_GNU_STACK communicates stack permissions without describing a mapped file payload.
This output groups read-only constants and unwind data in an R segment, code in an RE segment, and writable data plus zero-filled storage in an RW segment. The RW segment has file size 0x10 but memory size 0x118. The latter includes the gap before the aligned .bss and its 256 bytes: .bss ends at 0x202320, and subtracting the segment base 0x202208 gives 0x118.
The input relocations have been applied and their tables are absent from this particular output. That is not a rule that executables never contain relocations: dynamic linking and --emit-relocs provide obvious counterexamples. The loader needs the program headers to establish the image; it does not need the detailed input-section history.
We can now identify an object file's unfinished work directly. The next chapter examines the first decision needed to finish it: choosing definitions when several inputs offer, request, or weakly reference the same name.
Optional detour: compact integers and assembly preprocessing
The main ELF walkthrough is complete. Return to hand.S here to inspect its variable-length integers and preprocessing; neither is required to follow the first header, symbol, or relocation record.
Small integers deserve small encodings
Fixed-width .long uses four bytes whether the value is 3 or a million. Metadata often contains many small integers and only occasional large ones. LEB12812 uses seven payload bits per byte, with bit 7 indicating whether another byte follows. Low-order groups come first.
For unsigned 624485:
- The low seven bits are
0x65; more remain, so emit0xe5. - The next group is
0x0e; more remain, so emit0x8e. - The final group is
0x26; emit it without the continuation bit.
The result is e5 8e 26. Signed LEB128 stops when the remaining high bits are all sign bits and bit 6 of the final payload agrees with that sign. For −123456, the groups produce c0 bb 78; the decoder sign-extends from the final group's bit 6. Merely reaching a remaining value of −1 is not sufficient unless the emitted group's sign bit agrees.
DWARF 5, section 7.6 specifies these encodings. They also appear in WebAssembly and DEX, so recognizing them pays off beyond ELF.
Inspect the assembled read-only data:
$ clang -c hand.S -o hand-small.o$ llvm-objdump -s -j .rodata hand-small.o
hand-small.o: file format elf64-x86-64Contents of section .rodata: 0000 686900ff 00000000 e58e26c0 bb786400 hi........&..xd. 0010 0000 ..Offsets 0–2 hold hi and a zero terminator. Offset 3 holds ff. Four padding bytes advance the next value to offset 8. The two LEB128 values occupy three bytes each, and the macro's integer begins at offset 0xe. Its 64 00 00 00 is the little-endian encoding of 100. Total size: 0x12 bytes.
Uppercase .S requests preprocessing
The #ifdef belongs to the C preprocessor, not the assembler's macro system. Driver conventions normally preprocess .S before assembly; lowercase .s normally skips that step. Define BIG to select the other macro invocation:
$ clang -DBIG -c hand.S -o hand-big.o$ llvm-objdump -s -j .rodata hand-big.o
hand-big.o: file format elf64-x86-64Contents of section .rodata: 0000 686900ff 00000000 e58e26c0 bb78a086 hi........&..x.. 0010 0100 ..The final integer changes to a0 86 01 00, or 100000. Now deliberately use the lowercase suffix:
$ cp hand.S plain.s$ clang -DBIG -c plain.s -o plain.oclang: warning: argument unused during compilation: '-D BIG' [-Wunused-command-line-argument]<instantiation>:2:1: error: symbol 'limit' is already definedlimit:^The unused-option warning tells us preprocessing did not occur. In this x86 assembly syntax, lines beginning with # are comments, so both CONST invocations remain and both define limit. Explicitly selecting the language restores preprocessing:
$ clang -x assembler-with-cpp -DBIG -c plain.s -o plain.o$ llvm-objdump -s -j .rodata plain.o | tail -2 0000 686900ff 00000000 e58e26c0 bb78a086 hi........&..x.. 0010 0100 ..Kernel and runtime assembly often uses .S to share constants and select architecture features. Whatever the source-level machinery, the linker receives the resulting object file.
Exercises
1. Predict the symbol table
Compile with clang -O0 -c quiz.c, but classify the source names before inspecting the object:
// quiz.cint printf(const char *fmt, ...);extern int limit;extern int unused;int total = 0;__attribute__((weak)) int verbose = 1;static int square(int v) { return v * v; }
int report(int n) { static int calls; int sum = square(n) + total + limit; if (verbose) printf("%d: %d\n", ++calls, sum); return sum;}For printf, limit, unused, total, verbose, square, v, report, n, calls, sum, and the string literal, predict whether an ELF symbol appears. For each that does, predict binding, type, and definition section or undefined status. Check with readelf -sW quiz.o, then repeat at -O1.
2. Predict every fixup
# predict.s .text .globl entry .type entry,@functionentry: movl counter(%rip), %eax call bump call ext_func leaq .Lmsg(%rip), %rdi jmp .Lout.Lout: ret .size entry, . - entry
.section .text.cold,"ax",@progbitsbump: ret
.data .globl countercounter: .long 7 .long .Lout - entryslot: .quad counter + 8 .quad slot
.section .rodata.str1.1,"aMS",@progbits,1.Lmsg: .asciz "ok" .section .note.GNU-stack,"",@progbitsList the sections, symbols, and relocation records before assembling. For each relocation give the target section, field offset, type, symbol, and addend. Which expressions can the assembler finish? The RIP-relative movl prefix occupies two bytes, call one, and the leaq prefix three. Assemble with Clang and GNU as; identify the relocation type that differs.
3. Change permissions without changing instructions
.section .boot,"ax",@progbits .globl _start_start: movl $60, %eax movl $42, %edi syscall .section .note.GNU-stack,"",@progbitsAssemble and link with LLD and GNU ld, then run natively:
$ ./prog; echo $?After observing 42 with "ax", try "a" and "x". Predict linker diagnostics, program headers, entry address, and runtime behavior. Restore "ax" afterward.
Answers and recorded results
1. Source names and linker symbols are different sets
$ clang -O0 -c quiz.c -o quiz.o$ readelf -sW quiz.o
Symbol table '.symtab' contains 12 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 FILE LOCAL DEFAULT ABS quiz.c 2: 0000000000000000 0 SECTION LOCAL DEFAULT 2 .text 3: 0000000000000060 16 FUNC LOCAL DEFAULT 2 square 4: 0000000000000004 4 OBJECT LOCAL DEFAULT 4 report.calls 5: 0000000000000000 8 OBJECT LOCAL DEFAULT 6 .L.str 6: 0000000000000000 0 SECTION LOCAL DEFAULT 4 .bss 7: 0000000000000000 87 FUNC GLOBAL DEFAULT 2 report 8: 0000000000000000 4 OBJECT GLOBAL DEFAULT 4 total 9: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND limit 10: 0000000000000000 4 OBJECT WEAK DEFAULT 5 verbose 11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printfprintf and limit are global undefined symbols. unused is only a declaration and produces no reference. total is a global object in .bss, despite explicitly spelling its zero initializer. verbose is a weak object in .data. square is a local function; report a global function. The function-local static becomes local object report.calls in .bss; unlike an automatic variable, it has persistent storage.
Parameters v and n, and automatic sum, do not become ordinary linker symbols. Their register or stack locations belong to individual calls and, when available, debug descriptions. “Local symbol” and “local C variable” describe different concepts.
The string appears here as local .L.str. The PC-relative reference has addend −4 and targets a mergeable-string section. Replacing that reference naively with a section symbol plus an offset can lose the association with the correct string when pieces are merged. The assembler retains the label. The earlier absolute reference with zero addend could be represented through the section symbol. GCC's placement into ordinary .rodata produces a different representation again. Predict from the actual relocation and section properties, not from a blanket rule that all .L names disappear.
At -O1, square is inlined and its standalone symbol disappears in the recorded result.
2. Six relocations, two resolved expressions
$ clang -c predict.s -o predict.o$ readelf -sW predict.o
Symbol table '.symtab' contains 9 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 NOTYPE LOCAL DEFAULT 4 bump 2: 0000000000000000 0 NOTYPE LOCAL DEFAULT 7 .Lmsg 3: 0000000000000000 0 SECTION LOCAL DEFAULT 4 .text.cold 4: 0000000000000000 0 SECTION LOCAL DEFAULT 5 .data 5: 0000000000000008 0 NOTYPE LOCAL DEFAULT 5 slot 6: 0000000000000000 26 FUNC GLOBAL DEFAULT 2 entry 7: 0000000000000000 0 NOTYPE GLOBAL DEFAULT 5 counter 8: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND ext_func$ llvm-objdump -dr predict.o[file header omitted]0000000000000000 <entry>: 0: 8b 05 00 00 00 00 movl (%rip), %eax # 0x6 <entry+0x6> 0000000000000002: R_X86_64_PC32 counter-0x4 6: e8 00 00 00 00 callq 0xb <entry+0xb> 0000000000000007: R_X86_64_PLT32 .text.cold-0x4 b: e8 00 00 00 00 callq 0x10 <entry+0x10> 000000000000000c: R_X86_64_PLT32 ext_func-0x4 10: 48 8d 3d 00 00 00 00 leaq (%rip), %rdi # 0x17 <entry+0x17> 0000000000000013: R_X86_64_PC32 .Lmsg-0x4 17: eb 00 jmp 0x19 <entry+0x19> 19: c3 retq[excerpt]$ llvm-objdump -r predict.o | tail -4RELOCATION RECORDS FOR [.data]:OFFSET TYPE VALUE0000000000000008 R_X86_64_64 counter+0x80000000000000010 R_X86_64_64 .data+0x8$ llvm-objdump -s -j .data predict.o | tail -2 0000 07000000 19000000 00000000 00000000 ................ 0010 00000000 00000000 ........The content sections are .text (0x1a bytes), .text.cold (one byte), .data (0x18), .rodata.str1.1 (three), and the empty stack note. Symbol, string, and relocation tables accompany them. Additional empty or property sections vary with the assembler and its configuration.
entry is a global function of size 26. counter is global but has no explicit type or size. ext_func is undefined. bump and slot are local. .Lmsg is retained for the mergeable-section reference; .Lout is unnecessary as a stored symbol. Section symbols describe local relocation targets.
| Target field | Type | Referenced location |
|---|---|---|
.text+0x2 | PC32 | counter - 4 |
.text+0x7 | PLT32 in LLVM output | .text.cold - 4 |
.text+0xc | PLT32 | ext_func - 4 |
.text+0x13 | PC32 | .Lmsg - 4 |
.data+0x8 | 64 | counter + 8 |
.data+0x10 | 64 | .data + 8 |
The short jump to the following instruction is already eb 00. The same-section difference .Lout - entry is 0x19, stored at .data+4 as 19 00 00 00.
GNU as uses PC32 for the local cross-section call:
$ as predict.s -o predict-gas.o$ llvm-objdump -dr predict-gas.o | grep -A1 'callq' 6: e8 00 00 00 00 callq 0xb <entry+0xb> 0000000000000007: R_X86_64_PC32 .text.cold-0x4-- b: e8 00 00 00 00 callq 0x10 <entry+0x10> 000000000000000c: R_X86_64_PLT32 ext_func-0x4Because that local target cannot bind through a shared-library PLT, the two recorded relocation types lead to the same displacement in this case. Their general semantics still differ.
3. Successful linking does not prove executable permissions
$ ld.lld -o lld-ax flag-ax.o; ld -o gnu-ax flag-ax.o$ readelf -lW lld-ax | grep -E 'Entry|LOAD'Entry point 0x201120 LOAD 0x000000 0x0000000000200000 0x0000000000200000 0x000120 0x000120 R 0x1000 LOAD 0x000120 0x0000000000201120 0x0000000000201120 0x00000c 0x00000c R E 0x1000$ readelf -lW lld-a | grep -E 'Entry|LOAD'Entry point 0x200120 LOAD 0x000000 0x0000000000200000 0x0000000000200000 0x00012c 0x00012c R 0x1000$ readelf -lW lld-x | grep -E 'Entry|LOAD'Entry point 0x0 LOAD 0x000000 0x0000000000200000 0x0000000000200000 0x000120 0x000120 R 0x1000$ readelf -lW gnu-x | grep -E 'Entry|LOAD'Entry point 0x0With "ax", both linkers place .boot in an executable load segment and both native executions return 42. With "a", both links succeed without warning, but the entry is in a read-only, non-executable mapping. Execution fails with SIGSEGV; the recorded shell status is 139.
With "x", the section lacks allocation. It is absent from PT_LOAD, and the recorded entry address is zero. LLD retains a header-only read-only load segment; GNU ld emits no load segment. Both recorded native executions fail with SIGSEGV and shell status 139. The exact entry and layout are observations of these versions, while the underlying mistake is the missing allocation requirement.
A linker organizes sections according to their declared properties. It does not infer that bytes resembling instructions must be executable. The object file is a contract, including the parts its author got wrong.
References and terminology
The ELF section specification, symbol-table specification, GNU assembler manual, LLVM toolchain description, and DWARF 5 standard define the structures and encodings used here. The symbol-classification exercise follows the teaching pattern used in CMU 15-213; Princeton COS 217's assembler/linker material offers a complementary introduction to fixups.
Appendix: terms and tools
-
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. ↩
-
cc is the conventional C compiler command. It may select GCC or Clang; inspect
cc --versionor name the implementation explicitly for reproducible experiments. Toolchain overview. ↩ -
IR means intermediate representation. LLVM IR describes computation between source translation and machine-code generation; its textual and bitcode forms represent the same intermediate language. LLVM language reference. ↩
-
LLVM names a collection of compiler and toolchain projects, including optimization and code-generation infrastructure. Clang, LLD, and LLVM IR are related but have different roles. LLVM. ↩
-
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. ↩ -
GNU
asencodes assembly into object files. Instructions describe machine operations, while directives such as.sectionand.globlorganize sections and symbols. Assembler manual. ↩ -
readelfinspects ELF headers, sections, segments, symbols, and relocations without executing the input program. GNU and LLVM variants need not format their output identically. Manual. ↩ -
nmlists symbols. Its letter codes summarize attributes such as section and binding; inspect the ELF symbol fields when the precise semantics matter. Manual. ↩ -
psABI, processor-specific ABI, defines the binary contract for one architecture. Architectures can share ELF containers while differing in instruction encodings, calling conventions, and relocations. RISC-V psABI. ↩
-
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. ↩
-
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. ↩
-
LEB128 means Little Endian Base 128. It encodes seven payload bits per byte and has distinct signed and unsigned forms. DWARF specification. ↩