The World of Linkers/ Theory/ 17 articles
46 min readPublic

[The World of Linkers—Theory 15] Beyond ELF: The Rules Change with the File Format

This is a comparison of file formats. Its foundations are ELF's file/table/payload relationships from Theory 02 and address repair from Theory 04; dynamic loading and unwind records use the concepts of chapters 7 and 8. Mach-O, COFF, and PE encode these relationships differently. ELF field offsets and bit values cannot be carried over unchanged.

On a first reading, follow one source into three objects, see how each format locates content and symbols, and read the comparison table. dyld chained fixups, compact unwind, TLS, and Wasm are further topics to read as needed. Each numeric example belongs to its stated format and architecture. File offsets, RVA, and VA remain different coordinates despite similar names.

The same C function can become Linux, macOS, or Windows machine code, yet its object files are not interchangeable. Before a linker can connect anything, it needs to know where the file keeps its code, symbols, and unfinished address calculations. After linking, a loader must interpret the resulting contract too.

ELF1, Mach-O, and PE/COFF all express these ideas, but their differences extend beyond headers. ELF can select a group of sections as one duplicate entity. Mach-O can divide a section at symbol boundaries. COFF can attach several different duplicate-selection policies to a section. The format changes the algorithm's useful units.

One source, three objects

Avoid system headers so the same source can be compiled for each target:

// main.c: no system headers; compilable for all three targets
int printf(const char *fmt, ...);
extern int counter; // defined in add.c
int add(int a, int b);
int main(void) {
printf("%d\n", add(counter, 2));
return 0;
}
// add.c
int counter = 40;
int add(int a, int b) { return a + b; }
$ clang -O1 -c main.c -o main-elf.o
$ clang --target=arm64-apple-macos14 -O1 -c main.c -o main-macho.o
$ clang --target=x86_64-pc-windows-msvc -O1 -c main.c -o main-coff.obj

There are two independent changes here: file format and CPU. The ELF and COFF samples are x86-64; the main Mach-O sample is arm64. The scripts also produce an x86-64 Mach-O comparison when instruction-set differences need to be separated from format differences.

$ for f in main-elf.o main-macho.o main-coff.obj; do llvm-readobj --file-headers $f | grep -E 'File:|Format:|Arch:|Machine:|Magic:|CpuType:|FileType:|NumOf|SizeOf|SectionCount:|SymbolCount:|TimeDateStamp:'; done
File: main-elf.o
Format: elf64-x86-64
Arch: x86_64
Magic: (7F 45 4C 46)
Machine: EM_X86_64 (0x3E)
File: main-macho.o
Format: Mach-O arm64
Arch: aarch64
Magic: Magic64 (0xFEEDFACF)
CpuType: Arm64 (0x100000C)
FileType: Relocatable (0x1)
NumOfLoadCommands: 5
SizeOfLoadCommands: 456
File: main-coff.obj
Format: COFF-x86-64
Arch: x86_64
Machine: IMAGE_FILE_MACHINE_AMD64 (0x8664)
SectionCount: 8
TimeDateStamp: 2026-10-06 14:30:35 (0x6AC5060B)
SymbolCount: 24

ELF begins with its familiar magic. A little-endian 64-bit Mach-O begins with bytes CF FA ED FE, representing 0xFEEDFACF, and announces a load-command count and total command size. An ordinary COFF object begins with its machine identifier rather than a universal magic. Its timestamp field may encode a build time, zero, or a reproducibility convention; interpreting every value as an actual clock time is unsafe.

$ llvm-objdump -h main-elf.o main-macho.o main-coff.obj
main-elf.o: file format elf64-x86-64
Sections:
Idx Name Size VMA Type
0 00000000 0000000000000000
1 .strtab 00000087 0000000000000000
2 .text 00000028 0000000000000000 TEXT
3 .rela.text 00000060 0000000000000000
4 .rodata.str1.1 00000004 0000000000000000 DATA
5 .comment 00000028 0000000000000000
6 .note.GNU-stack 00000000 0000000000000000
7 .eh_frame 00000030 0000000000000000 DATA
8 .rela.eh_frame 00000018 0000000000000000
9 .llvm_addrsig 00000000 0000000000000000
10 .symtab 000000c0 0000000000000000
main-macho.o: file format mach-o arm64
Sections:
Idx Name Size VMA Type
0 __text 00000040 0000000000000000 TEXT
1 __cstring 00000004 0000000000000040 DATA
2 __compact_unwind 00000020 0000000000000048 DATA
main-coff.obj: file format coff-x86-64
Sections:
Idx Name Size VMA Type
0 .text 00000029 0000000000000000 TEXT
1 .data 00000000 0000000000000000 DATA
2 .bss 00000000 0000000000000000 BSS
3 .xdata 00000008 0000000000000000 DATA
4 .rdata 00000004 0000000000000000 DATA
5 .debug$S 0000005c 0000000000000000 DATA, DEBUG
6 .pdata 0000000c 0000000000000000 DATA
7 .llvm_addrsig 00000000 0000000000000000

ELF represents symbol and relocation tables as sections. Mach-O locates symbols through LC_SYMTAB and gives each section its relocation offset/count directly. COFF uses familiar .text, .data, and .bss names, with .rdata for read-only data and .pdata/.xdata for x64 unwinding. The small .debug$S here contains CodeView compiler information even without a full debug build.

$ llvm-nm main-elf.o main-macho.o main-coff.obj
main-elf.o:
0000000000000000 r .L.str
U add
U counter
0000000000000000 T main
U printf
main-macho.o:
U _add
U _counter
0000000000000000 T _main
U _printf
0000000000000040 s l_.str
0000000000000000 t ltmp0
0000000000000040 s ltmp1
0000000000000048 s ltmp2
main-coff.obj:
00000000 R ??_C@_03PMGGPEJJ@?$CFd?6?$AA@
00000000 a @feat.00
U add
U counter
00000000 T main
U printf

Mach-O adds a leading underscore to C symbols. x64 COFF does not; its 32-bit x86 conventions differ. The COFF string constant has an MSVC-generated identity and COMDAT2 selection, whereas ELF's mergeable-string section supplies another route to sharing constants.

C++ makes the ABI3 distinction even clearer:

$ llvm-nm add-x86_64-unknown-linux-gnu.o add-arm64-apple-macos14.o add-x86_64-pc-windows-msvc.o
add-x86_64-unknown-linux-gnu.o:
0000000000000000 T _Z3addii
0000000000000000 D counter
add-arm64-apple-macos14.o:
0000000000000000 T __Z3addii
0000000000000008 D _counter
0000000000000000 t ltmp0
0000000000000008 d ltmp1
0000000000000010 s ltmp2
add-x86_64-pc-windows-msvc.o:
00000000 T ?add@@YAHHH@Z
00000000 D ?counter@@3HA
00000000 a @feat.00

Mach-O adds its extra underscore to Itanium-style mangling. Microsoft's C++ encoding uses another grammar, including return types for ordinary functions and types in global-variable names. llvm-undname reads that grammar.

$ for f in main-elf.o main-macho.o main-coff.obj; do llvm-objdump -dr "$f"; done
main-elf.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: 50 pushq %rax
1: 48 8b 05 00 00 00 00 movq (%rip), %rax # 0x8 <main+0x8>
0000000000000004: R_X86_64_REX_GOTPCRELX counter-0x4
8: 8b 38 movl (%rax), %edi
a: be 02 00 00 00 movl $0x2, %esi
f: e8 00 00 00 00 callq 0x14 <main+0x14>
0000000000000010: R_X86_64_PLT32 add-0x4
14: 48 8d 3d 00 00 00 00 leaq (%rip), %rdi # 0x1b <main+0x1b>
0000000000000017: R_X86_64_PC32 .L.str-0x4
1b: 89 c6 movl %eax, %esi
1d: 31 c0 xorl %eax, %eax
1f: e8 00 00 00 00 callq 0x24 <main+0x24>
0000000000000020: R_X86_64_PLT32 printf-0x4
24: 31 c0 xorl %eax, %eax
26: 59 popq %rcx
27: c3 retq
main-macho.o: file format mach-o arm64
Disassembly of section __TEXT,__text:
0000000000000000 <ltmp0>:
0: d10083ff sub sp, sp, #0x20
4: a9017bfd stp x29, x30, [sp, #0x10]
8: 910043fd add x29, sp, #0x10
c: 90000008 adrp x8, 0x0 <ltmp0>
000000000000000c: ARM64_RELOC_GOT_LOAD_PAGE21 _counter
10: f9400108 ldr x8, [x8]
0000000000000010: ARM64_RELOC_GOT_LOAD_PAGEOFF12 _counter
14: b9400100 ldr w0, [x8]
18: 52800041 mov w1, #0x2 ; =2
1c: 94000000 bl 0x1c <ltmp0+0x1c>
000000000000001c: ARM64_RELOC_BRANCH26 _add
20: f90003e0 str x0, [sp]
24: 90000000 adrp x0, 0x0 <ltmp0>
0000000000000024: ARM64_RELOC_PAGE21 l_.str
28: 91000000 add x0, x0, #0x0
0000000000000028: ARM64_RELOC_PAGEOFF12 l_.str
2c: 94000000 bl 0x2c <ltmp0+0x2c>
000000000000002c: ARM64_RELOC_BRANCH26 _printf
30: 52800000 mov w0, #0x0 ; =0
34: a9417bfd ldp x29, x30, [sp, #0x10]
38: 910083ff add sp, sp, #0x20
3c: d65f03c0 ret
main-coff.obj: file format coff-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: 48 83 ec 28 subq $0x28, %rsp
4: 8b 0d 00 00 00 00 movl (%rip), %ecx # 0xa <main+0xa>
0000000000000006: IMAGE_REL_AMD64_REL32 counter
a: ba 02 00 00 00 movl $0x2, %edx
f: e8 00 00 00 00 callq 0x14 <main+0x14>
0000000000000010: IMAGE_REL_AMD64_REL32 add
14: 48 8d 0d 00 00 00 00 leaq (%rip), %rcx # 0x1b <main+0x1b>
0000000000000017: IMAGE_REL_AMD64_REL32 ??_C@_03PMGGPEJJ@?$CFd?6?$AA@
1b: 89 c2 movl %eax, %edx
1d: e8 00 00 00 00 callq 0x22 <main+0x22>
000000000000001e: IMAGE_REL_AMD64_REL32 printf
22: 31 c0 xorl %eax, %eax
24: 48 83 c4 28 addq $0x28, %rsp
28: c3 retq

The x86-64 ELF reference to external data uses a GOT4 relocation; the arm64 Mach-O sample uses the corresponding page-addressing sequence. COFF REL32 defines its displacement relative to the end of the four-byte field, P + 4, so its convention already accounts for the correction represented by -4 in the shown ELF RELA records.

The COFF sample also assumes ordinary directly linked symbols unless declarations specify dllimport. That affects data access as well as the name later resolved by the linker.

Mach-O: a list of commands around a hierarchy of sections

A load command is metadata after the file header, not a CPU instruction. An LC_SEGMENT_64 record contains segment metadata followed by its section descriptors. Those descriptors locate payload bytes elsewhere using file offsets. Reading a descriptor does not read the section's code or data.

What to locateNavigation rule
Load-command listStarts after the header; the header supplies count and total size.
Next commandCurrent command start plus cmdsize.
Section descriptorsArray inside LC_SEGMENT_64, with count nsects.
Section bytes and relocationsThe descriptor's offset, size, reloff, and nreloc.

ELF locates a separate section-header table with e_shoff; Mach-O organizes descriptors inside load commands. Both require a step from metadata to payload, despite the different directory structure.

The diagram follows one main-macho.o through four views: the complete file, its load commands, the section descriptions inside the first command, and the actual section contents. After the 32-byte header come 456 bytes of command records. The first command occupies 72 + 3 × 80 = 312 bytes. Its three 80-byte entries describe contents beginning at file offsets 488, 552 and 560, outside the command region. The six relocations for __text occupy a separate 6 × 8 = 48 bytes beginning at offset 592. Directory entries, contents and relocation records have distinct ranges.

Mach-O object-file regions, load commands and section contents

The offsets belong to this ARM64 example; the navigation rules belong to the format. Check the header range, then traverse exactly ncmds commands, each large enough for cmd/cmdsize. Within LC_SEGMENT_64, check the 72-byte fixed part and all nsects 80-byte descriptions against that command's bounds. Independently validate each offset + size before reading contents. A section without file storage, such as a zero-fill section, cannot be sliced like ordinary file contents. Readable directory records do not prove readable payloads. Apple's loader.h defines these structures.

A 64-bit Mach-O header occupies 32 bytes. It gives CPU, file type, command count/size, and flags. Types include object, executable, dylib, and loadable bundle:

$ llvm-otool -hv main-macho.o
Mach header
magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
MH_MAGIC_64 ARM64 ALL 0x00 OBJECT 5 456 SUBSECTIONS_VIA_SYMBOLS

Load commands follow the header. Each begins with a command kind and its byte size. A parser can bounds-check and step over a command, but a loader cannot blindly ignore every unknown command: LC_REQ_DYLD identifies required semantics. The definitions live in Apple's mach-o/loader.h.

$ llvm-otool -l main-macho.o | grep -E 'cmd |cmdsize|segname|sectname|offset|reloff|nreloc|nsects|symoff|nsyms|stroff|strsize|dataoff|datasize'
cmd LC_SEGMENT_64
cmdsize 312
segname
nsects 3
sectname __text
segname __TEXT
offset 488
reloff 592
nreloc 6
sectname __cstring
segname __TEXT
offset 552
reloff 0
nreloc 0
sectname __compact_unwind
segname __LD
offset 560
reloff 640
nreloc 1
cmd LC_BUILD_VERSION
cmdsize 24
cmd LC_LINKER_OPTIMIZATION_HINT
cmdsize 16
dataoff 648
datasize 16
cmd LC_SYMTAB
cmdsize 24
symoff 664
nsyms 8
stroff 792
strsize 56
cmd LC_DYSYMTAB
cmdsize 80
extrefsymoff 0
indirectsymoff 0
extreloff 0
locreloff 0

LC_SEGMENT_64 contains its section records. A complete section identity is a pair such as __TEXT,__text; in the relocatable sample, an unnamed segment acts as a container. An 80-byte section record includes address, size, file position, alignment, and relocation location. No separate .rela.text section is needed.

Section type occupies low flag bits. S_CSTRING_LITERALS lets the linker recognize mergeable null-terminated strings. __LD,__compact_unwind is linker input that will be transformed into output unwind metadata rather than copied literally.

LC_SYMTAB points to 16-byte nlist_64 records. Unlike Elf64_Sym, these have no symbol-size field. LC_DYSYMTAB divides symbols into contiguous local, defined external, and undefined ranges.

MH_SUBSECTIONS_VIA_SYMBOLS is the important granularity promise. It allows the linker to split suitable sections at symbol boundaries into units commonly called atoms. Dead stripping and weak-definition selection can operate on those units without one input section per function. Local labels such as ltmp0 help identify boundaries; a random symbol is not automatically permission to split arbitrary content.

LC_LINKER_OPTIMIZATION_HINT, or LOH, describes cooperating instruction sequences. The sample records an AdrpAdd for a string address and an AdrpLdrGotLdr for the external variable. Relocation tells the linker the address; the hint supplies an additional relationship that can authorize a combined rewrite.

The executable layout

The script links the two objects with ld64.lld and the generated interface stub:

$ llvm-otool -hv prog
Mach header
magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
MH_MAGIC_64 ARM64 ALL 0x00 EXECUTE 17 1192 NOUNDEFS DYLDLINK TWOLEVEL PIE
$ wc -c prog
50048 prog
$ llvm-otool -l prog | grep -E 'cmd |segname|vmaddr|vmsize|fileoff|filesize|initprot|sectname'
cmd LC_SEGMENT_64
segname __PAGEZERO
vmaddr 0x0000000000000000
vmsize 0x0000000100000000
fileoff 0
filesize 0
initprot 0x00000000
cmd LC_SEGMENT_64
segname __TEXT
vmaddr 0x0000000100000000
vmsize 0x0000000000004000
fileoff 0
filesize 16384
initprot 0x00000005
sectname __text
segname __TEXT
sectname __stubs
segname __TEXT
sectname __cstring
segname __TEXT
sectname __unwind_info
segname __TEXT
cmd LC_SEGMENT_64
segname __DATA_CONST
vmaddr 0x0000000100004000
vmsize 0x0000000000004000
fileoff 16384
filesize 16384
initprot 0x00000003
sectname __got
segname __DATA_CONST
cmd LC_SEGMENT_64
segname __DATA
vmaddr 0x0000000100008000
vmsize 0x0000000000004000
fileoff 32768
filesize 16384
initprot 0x00000003
sectname __data
segname __DATA
cmd LC_SEGMENT_64
segname __LINKEDIT
vmaddr 0x000000010000c000
vmsize 0x0000000000000380
fileoff 49152
filesize 896
initprot 0x00000001
cmd LC_DYLD_CHAINED_FIXUPS
cmd LC_DYLD_EXPORTS_TRIE
cmd LC_SYMTAB
cmd LC_DYSYMTAB
cmd LC_LOAD_DYLINKER
cmd LC_UUID
cmd LC_BUILD_VERSION
cmd LC_MAIN
cmd LC_LOAD_DYLIB
cmd LC_FUNCTION_STARTS
cmd LC_DATA_IN_CODE
cmd LC_CODE_SIGNATURE

The five segments serve different roles. __PAGEZERO reserves an inaccessible low range without file bytes. __TEXT maps headers and executable/read-only content. __DATA_CONST is intended to become read-only after fixups, resembling RELRO5. __DATA remains writable. __LINKEDIT contains symbols, strings, fixup metadata, exports, and signatures, located by commands rather than ordinary sections.

In this arm64 layout, file and virtual segment offsets align to 16 KiB. The tiny program consequently has substantial padding: only 72 bytes of code, but a 50,048-byte file. That is this target's layout, not an intrinsic size law for every Mach-O.

__PAGEZERO here reserves the low 4 GiB, catching null and truncated pointers. It is not a normal file-backed mapping. ELF generally expresses its mapping view through an independent program-header table; Linux also restricts low mappings through kernel policy. Mach-O instead embeds section records inside the segment commands themselves.

$ llvm-otool -l prog | grep -A3 -E 'LC_MAIN|LC_LOAD_DY|LC_CODE_SIG|LC_DYLD_'
cmd LC_DYLD_CHAINED_FIXUPS
cmdsize 16
dataoff 49152
datasize 96
--
cmd LC_DYLD_EXPORTS_TRIE
cmdsize 16
dataoff 49248
datasize 72
--
cmd LC_LOAD_DYLINKER
cmdsize 32
name /usr/lib/dyld (offset 12)
Load command 10
--
cmd LC_MAIN
cmdsize 24
entryoff 1256
stacksize 0
--
cmd LC_LOAD_DYLIB
cmdsize 56
name /usr/lib/libSystem.B.dylib (offset 24)
time stamp 0 Thu Jan 1 00:00:00 1970
--
cmd LC_CODE_SIGNATURE
cmdsize 16
dataoff 49504
datasize 544

LC_LOAD_DYLINKER names /usr/lib/dyld. Dependencies use LC_LOAD_DYLIB; the sample names libSystem. LC_UUID provides artifact identity, LC_BUILD_VERSION records platform/deployment information, and an export trie supports lookup. Other commands locate pointer chains and code signatures.

LC_MAIN gives entry offset 0x4e8; with this text mapping it identifies _main at 0x1000004e8. Darwin's modern dyld path performs startup and calls main, including its additional platform argument vector, then handles the return through exit. This is a platform contract described in dyld's startup code, not an execution result from Linux. Older Mach-O entry conventions used LC_UNIXTHREAD register state.

$ llvm-nm -m prog
0000000100000000 (__TEXT,__text) [referenced dynamically] external __mh_execute_header
0000000100000528 (__TEXT,__text) external _add
0000000100008000 (__DATA,__data) external _counter
00000001000004e8 (__TEXT,__text) external _main
(undefined) external _printf (from libSystem)
(undefined) external dyld_stub_binder (from libSystem)

The synthesized header symbol identifies the image header. The undefined _printf also records “from libSystem,” exposing another format difference: its ordinary dynamic reference remembers the providing library.

Relocations: similar arithmetic, different encoding

A Mach-O relocation is eight bytes:

struct relocation_info {
int32_t r_address; /* offset within the section */
uint32_t r_symbolnum:24, /* symbol index if r_extern=1; section ordinal otherwise */
r_pcrel:1, /* PC-relative flag */
r_length:2, /* 0=1 byte, 1=2 bytes, 2=4 bytes, 3=8 bytes */
r_extern:1,
r_type:4; /* architecture-specific type */
};

It identifies location, target, width, PC relativity, and an architecture-specific type, but has no general addend field. The four-bit type space encourages broader types whose interpretation may inspect the instruction.

Use matching arm64 code to isolate this from CPU differences:

// kinds.c: several arm64 reference forms
extern int table[16] __attribute__((visibility("hidden"))); // must bind within this image
extern int ext_var; // may come from a dylib: use the GOT
void callee(void);
int *p_elem = &table[5]; // stored pointer: ARM64_RELOC_UNSIGNED
int *addr_of_elem(void) { return &table[3]; } // address plus constant: needs an addend
int read_ext(void) { return ext_var; }
void call_it(void) { callee(); } // tail call: BRANCH26
$ llvm-objdump -dr kinds.o kinds-elf.o
kinds.o: file format mach-o arm64
Disassembly of section __TEXT,__text:
0000000000000000 <ltmp0>:
0: 90000000 adrp x0, 0x0 <ltmp0>
0000000000000000: ARM64_RELOC_ADDEND 0xc
0000000000000000: ARM64_RELOC_PAGE21 _table
4: 91000000 add x0, x0, #0x0
0000000000000004: ARM64_RELOC_ADDEND 0xc
0000000000000004: ARM64_RELOC_PAGEOFF12 _table
8: d65f03c0 ret
000000000000000c <_read_ext>:
c: 90000008 adrp x8, 0x0 <ltmp0>
000000000000000c: ARM64_RELOC_GOT_LOAD_PAGE21 _ext_var
10: f9400108 ldr x8, [x8]
0000000000000010: ARM64_RELOC_GOT_LOAD_PAGEOFF12 _ext_var
14: b9400100 ldr w0, [x8]
18: d65f03c0 ret
000000000000001c <_call_it>:
1c: 14000000 b 0x1c <_call_it>
000000000000001c: ARM64_RELOC_BRANCH26 _callee
kinds-elf.o: file format elf64-littleaarch64
Disassembly of section .text:
0000000000000000 <addr_of_elem>:
0: 90000000 adrp x0, 0x0 <addr_of_elem>
0000000000000000: R_AARCH64_ADR_PREL_PG_HI21 table+0xc
4: 91000000 add x0, x0, #0x0
0000000000000004: R_AARCH64_ADD_ABS_LO12_NC table+0xc
8: d65f03c0 ret
000000000000000c <read_ext>:
c: 90000008 adrp x8, 0x0 <addr_of_elem>
000000000000000c: R_AARCH64_ADR_GOT_PAGE ext_var
10: f9400108 ldr x8, [x8]
0000000000000010: R_AARCH64_LD64_GOT_LO12_NC ext_var
14: b9400100 ldr w0, [x8]
18: d65f03c0 ret
000000000000001c <call_it>:
1c: 14000000 b 0x1c <call_it>
000000000000001c: R_AARCH64_JUMP26 callee
Purpose Mach-O arm64 ELF AArch64
Stored 64-bit pointer ARM64_RELOC_UNSIGNED (0) R_AARCH64_ABS64
26-bit b / bl branch ARM64_RELOC_BRANCH26 (2) R_AARCH64_JUMP26 / CALL26
ADRP page number ARM64_RELOC_PAGE21 (3) R_AARCH64_ADR_PREL_PG_HI21
page offset ARM64_RELOC_PAGEOFF12 (4) R_AARCH64_ADD_ABS_LO12_NC, LDST{8,16,32,64,128}_ABS_LO12_NC
GOT slot page number ARM64_RELOC_GOT_LOAD_PAGE21 (5) R_AARCH64_ADR_GOT_PAGE
GOT slot page offset ARM64_RELOC_GOT_LOAD_PAGEOFF12 (6) R_AARCH64_LD64_GOT_LO12_NC
TLV descriptor ARM64_RELOC_TLVP_LOAD_PAGE21 (8) (separate TLS forms; see Theory 09)
Addend for the next record ARM64_RELOC_ADDEND (10) (not needed: r_addend field)

PAGE21 computes the page difference of S + A and P; PAGEOFF12 supplies the low page offset; BRANCH26 encodes (S + A - P) >> 2. ELF distinguishes several low-offset relocation types by operation width. Mach-O's PAGEOFF12 handler must inspect whether the instruction is an add or a load/store and scale its immediate accordingly. Its BRANCH26 covers both branch and call forms.

Three places an addend can hide

The address of table[3] needs addend 12. Mach-O arm64 prefixes the actual relocation with an ARM64_RELOC_ADDEND record:

$ llvm-otool -r kinds.o
kinds.o:
Relocation information (__TEXT,__text) 7 entries
address pcrel length extern type scattered symbolnum/value
0000001c 1 2 1 2 0 7
00000010 0 2 1 6 0 8
0000000c 1 2 1 5 0 8
00000004 0 2 0 10 0 12
00000004 0 2 1 4 0 9
00000000 0 2 0 10 0 12
00000000 1 2 1 3 0 9
Relocation information (__DATA,__data) 1 entries
address pcrel length extern type scattered symbolnum/value
00000000 0 3 1 0 0 9
Relocation information (__LD,__compact_unwind) 3 entries
address pcrel length extern type scattered symbolnum/value
00000040 0 3 0 0 0 1
00000020 0 3 0 0 0 1
00000000 0 3 0 0 0 1

That record borrows the 24-bit symbol-number field for a signed addend. It modifies no instruction by itself; the next record consumes it. The illustrated records are ordered from higher addresses downward, with each addend adjacent to its paired relocation.

A stored pointer can keep its addend in the bytes to be relocated instead:

$ llvm-objdump -s -j __data kinds.o
kinds.o: file format mach-o arm64
Contents of section __DATA,__data:
0020 14000000 00000000 ........
$ llvm-objdump -r -j .data kinds-elf.o
kinds-elf.o: file format elf64-littleaarch64

The word 0x14 is the 20-byte offset for table[5]. This is REL-style storage, while AArch64 ELF normally carries the addend in RELA. Instruction encodings cannot always hold the needed arbitrary low address bits, explaining the extra arm64 ADDEND record.

The x86-64 Mach-O comparison uses another implicit form: an initial PC-relative displacement calculated within the input object's address layout. Moving the referenced section requires adjusting that stored displacement by the placement difference. Read the architecture's relocation contract before assuming that “implicit addend” always means the same thing.

A hint permits a wider transformation

$ llvm-objdump -d prog | sed -n '/<_main>:/,/<_add>:/p'
00000001000004e8 <_main>:
1000004e8: d10083ff sub sp, sp, #0x20
1000004ec: a9017bfd stp x29, x30, [sp, #0x10]
1000004f0: 910043fd add x29, sp, #0x10
1000004f4: d503201f nop
1000004f8: d503201f nop
1000004fc: 1803d820 ldr w0, 0x100008000 <_counter>
100000500: 52800041 mov w1, #0x2 ; =2
100000504: 94000009 bl 0x100000528 <_add>
100000508: f90003e0 str x0, [sp]
10000050c: 10000180 adr x0, 0x10000053c <dyld_stub_binder+0x10000053c>
100000510: d503201f nop
100000514: 94000007 bl 0x100000530 <dyld_stub_binder+0x100000530>
100000518: 52800000 mov w0, #0x0 ; =0
10000051c: a9417bfd ldp x29, x30, [sp, #0x10]
100000520: 910083ff add sp, sp, #0x20
100000524: d65f03c0 ret
0000000100000528 <_add>:

With local binding established, LOH lets LLD combine the GOT sequence into a literal load and padding, and replace a nearby adrp/add pair with adr/nop. These fixed-width rewrites do not shift following code.

Recompile with -mllvm -aarch64-enable-collect-loh=false:

$ llvm-objdump -d prog-noloh | sed -n '/<_main>:/,/<_add>:/p'
00000001000004e8 <_main>:
1000004e8: d10083ff sub sp, sp, #0x20
1000004ec: a9017bfd stp x29, x30, [sp, #0x10]
1000004f0: 910043fd add x29, sp, #0x10
1000004f4: 90000048 adrp x8, 0x100008000 <_counter>
1000004f8: 91000108 add x8, x8, #0x0
1000004fc: b9400100 ldr w0, [x8]
100000500: 52800041 mov w1, #0x2 ; =2
100000504: 94000009 bl 0x100000528 <_add>
100000508: f90003e0 str x0, [sp]
10000050c: 90000000 adrp x0, 0x100000000 <dyld_stub_binder+0x100000000>
100000510: 9114f000 add x0, x0, #0x53c
100000514: 94000007 bl 0x100000530 <dyld_stub_binder+0x100000530>
100000518: 52800000 mov w0, #0x0 ; =0
10000051c: a9417bfd ldp x29, x30, [sp, #0x10]
100000520: 910083ff add sp, sp, #0x20
100000524: d65f03c0 ret
0000000100000528 <_add>:

Ordinary relocation-based GOT relaxation can still occur, but the extra multi-instruction optimization opportunity is gone. Knowing the final address and knowing which rewrites are authorized are separate inputs.

Dynamic binding records more than a name

Two-level and flat lookup

Two generated dylibs both export hello; only library A exports only_a. Link the main image with A before B:

$ llvm-nm -m main | grep -E 'hello|only'
(undefined) external _hello (from liba)
(undefined) external _only_a (from liba)
$ llvm-otool -L main
main:
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1.0.0)
@rpath/liba.dylib (compatibility version 0.0.0, current version 0.0.0)
@rpath/libb.dylib (compatibility version 0.0.0, current version 0.0.0)

Both references select A, and the chained imports record its ordinal. Replacing A with a library that drops hello does not instruct ordinary two-level lookup to choose B merely because B has the spelling. The file records a provider relationship; actual Darwin failure behavior still requires a Darwin run.

Flat-namespace linking writes a different request:

$ llvm-objdump --macho --chained-fixups main-flat
main-flat:
chained fixups header (LC_DYLD_CHAINED_FIXUPS)
fixups_version = 0
starts_offset = 32
imports_offset = 80
symbols_offset = 88
imports_count = 2
imports_format = 1 (DYLD_CHAINED_IMPORT)
symbols_format = 0
chained starts in image
seg_count = 4
seg_offset[0] = 0 (__PAGEZERO)
seg_offset[1] = 0 (__TEXT)
seg_offset[2] = 24 (__DATA_CONST)
seg_offset[3] = 0 (__LINKEDIT)
chained starts in segment 2 (__DATA_CONST)
size = 24
page_size = 0x4000
pointer_format = 2 (DYLD_CHAINED_PTR_64)
segment_offset = 0x4000
max_valid_pointer = 0
page_count = 1
page_start[0] = 0
dyld chained import[0]
lib_ordinal = -2 (flat-namespace)
weak_import = 0
name_offset = 0 (_hello)
dyld chained import[1]
lib_ordinal = -2 (flat-namespace)
weak_import = 0
name_offset = 7 (_only_a)

The special ordinal -2 asks for flat lookup instead of one numbered dependency. This is visible directly in Linux-side inspection.

Install names and rpaths

$ llvm-otool -D liba.dylib
liba.dylib:
@rpath/liba.dylib
$ llvm-otool -l main | grep -A2 LC_RPATH
cmd LC_RPATH
cmdsize 32
path @loader_path (offset 12)

A dylib's LC_ID_DYLIB install name becomes its consumer's load command. @executable_path is relative to the main executable; @loader_path to the image containing the reference; @rpath is resolved through the loader's applicable run-path chain.

The sample's rpath points beside the loading image. The main-norpath variant still requests @rpath/liba.dylib but supplies no corresponding local rpath, illustrating an incomplete deployment description. Moving the executable into sub/ while keeping the library above it calls for @loader_path/.. in this arrangement. ELF RUNPATH and dyld's chain rules are related ideas, not interchangeable algorithms.

Stubs and pointer chains

$ llvm-objdump --macho -d --section=__TEXT,__stubs prog
prog:
_main:
1000004e8: ff 83 00 d1 sub sp, sp, #0x20
1000004ec: fd 7b 01 a9 stp x29, x30, [sp, #0x10]
1000004f0: fd 43 00 91 add x29, sp, #0x10
1000004f4: 1f 20 03 d5 nop
1000004f8: 1f 20 03 d5 nop
1000004fc: 20 d8 03 18 ldr w0, _counter
100000500: 41 00 80 52 mov w1, #0x2
100000504: 09 00 00 94 bl _add
100000508: e0 03 00 f9 str x0, [sp]
10000050c: 80 01 00 10 adr x0, #48 ; literal pool for: "%d\n"
100000510: 1f 20 03 d5 nop
100000514: 07 00 00 94 bl 0x100000530 ; symbol stub for: _printf
100000518: 00 00 80 52 mov w0, #0x0
10000051c: fd 7b 41 a9 ldp x29, x30, [sp, #0x10]
100000520: ff 83 00 91 add sp, sp, #0x20
100000524: c0 03 5f d6 ret
_add:
100000528: 20 00 00 0b add w0, w1, w0
10000052c: c0 03 5f d6 ret
Contents of (__TEXT,__stubs) section
100000530: 30 00 00 90 adrp x16, 4 ; 0x100004000
100000534: 10 02 40 f9 ldr x16, [x16] ; literal pool symbol address: _printf
100000538: 00 02 1f d6 br x16

A call reaches __stubs, which loads a bound pointer from __got and branches indirectly. This resembles a PLT6/GOT route. The target ABI reserves registers such as arm64 x16 for these veneers.

Mach-O distinguishes rebase, adjusting an image-internal pointer for its slide, from bind, resolving an imported symbol:

// ptrs.c: inspect fixups for stored pointers
int printf(const char *fmt, ...);
void *malloc(unsigned long n);
int counter = 40;
int *p_counter = &counter; // image-local target: rebase
void *p_printf = (void *)&printf; // libSystem target: bind
void *p_malloc = (void *)&malloc; // likewise
int main(void) { return *p_counter + (p_printf != 0) + (p_malloc != 0); }
$ llvm-objdump --macho --chained-fixups ptrs
ptrs:
chained fixups header (LC_DYLD_CHAINED_FIXUPS)
fixups_version = 0
starts_offset = 32
imports_offset = 80
symbols_offset = 88
imports_count = 2
imports_format = 1 (DYLD_CHAINED_IMPORT)
symbols_format = 0
chained starts in image
seg_count = 4
seg_offset[0] = 0 (__PAGEZERO)
seg_offset[1] = 0 (__TEXT)
seg_offset[2] = 24 (__DATA)
seg_offset[3] = 0 (__LINKEDIT)
chained starts in segment 2 (__DATA)
size = 24
page_size = 0x4000
pointer_format = 2 (DYLD_CHAINED_PTR_64)
segment_offset = 0x4000
max_valid_pointer = 0
page_count = 1
page_start[0] = 8
dyld chained import[0]
lib_ordinal = 1 (libSystem)
weak_import = 0
name_offset = 0 (_printf)
dyld chained import[1]
lib_ordinal = 1 (libSystem)
weak_import = 0
name_offset = 8 (_malloc)

The older encoding stores state-machine bytecode referenced by LC_DYLD_INFO_ONLY:

$ llvm-objdump --macho --rebase --bind ptrs-old
ptrs-old:
Rebase table:
segment section address type
__DATA __data 0x100004008 pointer
Bind table:
segment section address type addend dylib symbol
__DATA __data 0x100004010 pointer 0 libSystem _printf
__DATA __data 0x100004018 pointer 0 libSystem _malloc

Chained fixups instead encode much of the operation in the pointer slots that will later be overwritten. Page metadata identifies chain starts, and each slot gives the next slot's distance. An import table supplies names/providers for bind entries.

$ llvm-objdump -s -j __data ptrs
ptrs: file format mach-o arm64
Contents of section __DATA,__data:
100004000 28000000 00000000 00400000 01001000 (........@......
100004010 00000000 00001080 01000000 00000080 ................
struct dyld_chained_ptr_64_rebase {
uint64_t target : 36, high8 : 8, reserved : 7, next : 12, bind : 1; // bind == 0
};
struct dyld_chained_ptr_64_bind {
uint64_t ordinal : 24, addend : 8, reserved : 19, next : 12, bind : 1; // bind == 1
};

This output uses DYLD_CHAINED_PTR_64, format 2. Its first rebase word contains preferred virtual address 0x100004000 and next = 2, meaning an eight-byte step because the unit is four bytes. The next two words bind _printf and _malloc using import indexes 0 and 1; the last has next = 0.

Format 6, DYLD_CHAINED_PTR_64_OFFSET, interprets the target relative to image base instead. Reading the same bitfield with the wrong pointer-format rule loses 0x100000000. Authenticated arm64e variants have further layouts. Always select the encoding before interpreting bits.

The scripts explicitly choose chained versus old fixups, avoiding version-dependent defaults. The x86-64 prog-lazy variant also exposes the older lazy route through __la_symbol_ptr, __stub_helper, and dyld_stub_binder. Chained imports in these outputs are processed during loading. Chains still need page headers and import metadata; they do not make all auxiliary tables disappear.

An interface stub is not a runtime library

--- !tapi-tbd-v3
archs: [ arm64, x86_64 ]
platform: macosx
install-name: '/usr/lib/libSystem.B.dylib'
exports:
- archs: [ arm64, x86_64 ]
symbols: [ _printf, _puts, _malloc, _atoi, dyld_stub_binder ]
...

This complete educational .tbd input only advertises names, targets, and install identity. It has no implementation of printf. A real SDK contains much richer interfaces, while a Darwin runtime may obtain system implementations from its shared cache rather than standalone files at their install names.

The Apple shared-cache release notes explain that separation. Linux-side linking can validate recorded dependencies without proving target-system search, permissions, or shared-cache behavior.

Verify the signature bytes without pretending to run the program

LC_CODE_SIGNATURE points to a signature region. LLD's ad-hoc CodeDirectory records covered bytes, hash algorithm, block size, and per-block hashes; it contains no developer certificate. Content consistency and permission to execute are separate questions.

The Python checker parses the load commands, SuperBlob, and CodeDirectory, then computes every SHA-256 slot using the recorded signature block size, not the CPU's page size:

prog: flags=0x20002, codeLimit=0xc160, pageSize=4096, slots=13, mismatches=[]
prog-nosig: no LC_CODE_SIGNATURE

codeLimit = 0xc160 covers 49,504 bytes. With 4,096-byte blocks, that needs 13 hashes. The unsigned variant lacks the signature command.

Flip the first byte of __text, preserving the old signature:

python3 "the supplied example" prog --mutate bad
python3 "the supplied example" bad
mutated file offset 0x4e8; signed page 0
bad: flags=0x20002, codeLimit=0xc160, pageSize=4096, slots=13, mismatches=[0]

The checker identifies slot zero and exits 1. That negative case demonstrates that the signature covers the modified code. Changing load commands or code after signing likewise requires regenerating the signature from the final bytes.

Darwin decides when and how to enforce signatures through its kernel and execution policy; Apple's platform notes and XNU's page-fault implementation describe that machinery. This chapter does not report a Darwin signal, page-fault timing, or successful resign-and-run test. The measured result is an offline hash mismatch.

Compact unwind, with a DWARF escape hatch

ELF frame information can express rich instruction-by-instruction state. Many functions need only a small set of common patterns, so Mach-O compact unwind encodes those patterns in a 32-bit value.

$ llvm-objdump --macho -s --section=__LD,__compact_unwind main-macho.o
main-macho.o:
Contents of (__LD,__compact_unwind) section
0000000000000048 00000000 00000000 00000040 04000000
0000000000000058 00000000 00000000 00000000 00000000

The input record is 32 bytes: function address, length, encoding, personality pointer, and LSDA7 pointer. Relocations supply the addresses. For the arm64 sample, 0x04000000 denotes a frame-based pattern whose saved frame pointer and return address can be recovered directly.

The linker sorts records and builds a two-level lookup structure:

$ llvm-objdump --macho --unwind-info prog
Contents of __unwind_info section:
Version: 0x1
Common encodings array section offset: 0x1c
Number of common encodings in array: 0x2
Personality function array section offset: 0x24
Number of personality functions in array: 0x0
Index array section offset: 0x24
Number of indices in array: 0x2
Common encodings: (count = 2)
encoding[0]: 0x04000000
encoding[1]: 0x02000000
Personality functions: (count = 0)
Top level indices: (count = 2)
[0]: function offset=0x000004e8, 2nd level page offset=0x0000003c, LSDA offset=0x0000003c
[1]: function offset=0x00000530, 2nd level page offset=0x00000000, LSDA offset=0x0000003c
LSDA descriptors:
Second level indices:
Second level index[0]: offset in section=0x0000003c, base function offset=0x000004e8
[0]: function offset=0x000004e8, encoding[0]=0x04000000
[1]: function offset=0x00000528, encoding[1]=0x02000000

main uses frame mode; the simple add uses frameless mode with no stack allocation. Common encodings are shared through indexes, reducing repeated records and interpretation work.

Now write a function whose frame is based on x19, outside those compact patterns:

// odd.s: x19-based frame requires a DWARF fallback
.text
.globl _odd
.p2align 2
_odd:
.cfi_startproc
stp x19, x30, [sp, #-16]!
.cfi_def_cfa_offset 16
.cfi_offset w30, -8
.cfi_offset w19, -16
mov x19, sp
.cfi_def_cfa w19, 16
mov w0, #7
ldp x19, x30, [sp], #16
.cfi_def_cfa wsp, 0
.cfi_restore w19
.cfi_restore w30
ret
.cfi_endproc

After ldp, SP is back at the caller's stack position and x19/x30 have been restored. The following ret therefore needs CFA=SP, not the body's x19+16 rule. The three CFI directives describe that state change without emitting machine instructions. restore reinstates the CIE's register rules; it does not perform the register loads already done by ldp. An unwind description must cover the epilogue as well as the prologue and body.

Add a header-free C++ catch fixture for its metadata:

// throw.cpp: try/catch needs personality and LSDA
extern "C" int printf(const char *, ...);
extern "C" int odd(void);
static void thrower(int x) { if (x) throw x; }
int main(int argc, char **) {
try { thrower(argc); } catch (int v) { printf("caught %d, odd=%d\n", v, odd()); }
return 0;
}
$ llvm-objdump -h odd.o throw.o throw
odd.o: file format mach-o arm64
Sections:
Idx Name Size VMA Type
0 __text 00000014 0000000000000000 TEXT
1 __compact_unwind 00000020 0000000000000018 DATA
2 __eh_frame 00000040 0000000000000038 DATA
throw.o: file format mach-o arm64
Sections:
Idx Name Size VMA Type
0 __text 00000090 0000000000000000 TEXT
1 __gcc_except_tab 00000020 0000000000000090 DATA
2 __cstring 00000013 00000000000000b0 DATA
3 __compact_unwind 00000020 00000000000000c8 DATA
throw: file format mach-o arm64
Sections:
Idx Name Size VMA Type
0 __text 000000a4 00000001000004f0 TEXT
1 __stubs 00000048 0000000100000594 TEXT
2 __gcc_except_tab 00000020 00000001000005dc DATA
3 __cstring 00000013 00000001000005fc DATA
4 __unwind_info 00001048 0000000100000610 DATA
5 __eh_frame 00000040 0000000100001658 DATA
6 __got 00000040 0000000100004000 DATA
$ llvm-objdump --macho --unwind-info throw
Contents of __unwind_info section:
Version: 0x1
Common encodings array section offset: 0x1c
Number of common encodings in array: 0x2
Personality function array section offset: 0x24
Number of personality functions in array: 0x1
Index array section offset: 0x28
Number of indices in array: 0x2
Common encodings: (count = 2)
encoding[0]: 0x54000001
encoding[1]: 0x03000014
Personality functions: (count = 1)
personality[1]: 0x00004038
Top level indices: (count = 2)
[0]: function offset=0x000004f0, 2nd level page offset=0x00000048, LSDA offset=0x00000040
[1]: function offset=0x00000594, 2nd level page offset=0x00000000, LSDA offset=0x00000048
LSDA descriptors:
[0]: function offset=0x000004f0, LSDA offset=0x000005dc
Second level indices:
Second level index[0]: offset in section=0x00000048, base function offset=0x000004f0
[0]: function offset=0x000004f0, encoding[0]=0x54000001
[1]: function offset=0x00000580, encoding[1]=0x03000014
$ llvm-dwarfdump --eh-frame throw
throw: file format Mach-O arm64
.debug_frame contents:
.eh_frame contents:
00000000 00000010 00000000 CIE
Format: DWARF32
Version: 1
Augmentation: "zR"
Code alignment factor: 1
Data alignment factor: -8
Return address column: 30
Augmentation data: 10
DW_CFA_def_cfa: WSP +0
CFA=WSP
00000014 00000028 00000018 FDE cie=00000000 pc=100000580...100000594
Format: DWARF32
DW_CFA_advance_loc: 4 to 0x100000584
DW_CFA_def_cfa_offset: +16
DW_CFA_offset: W30 -8
DW_CFA_offset: W19 -16
DW_CFA_advance_loc: 4 to 0x100000588
DW_CFA_def_cfa: W19 +16
DW_CFA_advance_loc: 8 to 0x100000590
DW_CFA_def_cfa: WSP +0
DW_CFA_restore: W19
DW_CFA_restore: W30
DW_CFA_nop:
DW_CFA_nop:
0x100000580: CFA=WSP
0x100000584: CFA=WSP+16: W19=[CFA-16], W30=[CFA-8]
0x100000588: CFA=W19+16: W19=[CFA-16], W30=[CFA-8]
0x100000590: CFA=WSP

The linked compact record for odd contains DWARF mode and FDE8 offset 0x14 into __eh_frame. Its CFI9 supplies the unusual rule. main's 0x54000001 combines an LSDA flag, personality index 1, frame mode, and the saved x19/x20 pair. Those references connect the compact table to exception cleanup information.

The link uses -undefined dynamic_lookup for unavailable C++ runtime implementations, so this is a structural sample. Its metadata does not establish an executable exception path on Darwin. Nor must every Mach-O use exactly this indexing arrangement: missing compact records and other link options can require direct DWARF searching.

PE/COFF: section headers also describe loading

COFF10 is the ordinary object format. PE11 extends it with image-loading information for executables and DLLs. Microsoft's PE specification defines the fields used here.

Build an image without a C runtime

The sample only needs imported Windows functions and exits explicitly:

// hello.c: a no-CRT Windows image importing kernel32 functions
__declspec(dllimport) void __stdcall ExitProcess(unsigned int code);
void __stdcall Sleep(unsigned int ms); // deliberately omit dllimport
static int value = 40;
int *p_value = &value; // stored absolute address: needs a base relocation
int main(void) {
Sleep(0);
ExitProcess(*p_value + 2);
return 0;
}

Generate an import library from a definition file:

LIBRARY kernel32.dll
EXPORTS
ExitProcess
Sleep
$ llvm-nm kernel32.lib
kernel32.dll:
00000000 i .idata$2
00000000 ? .idata$4
00000000 ? .idata$5
00000000 i .idata$6
00000000 I __IMPORT_DESCRIPTOR_kernel32
U __NULL_IMPORT_DESCRIPTOR
U kernel32_NULL_THUNK_DATA
kernel32.dll:
00000000 I __NULL_IMPORT_DESCRIPTOR
kernel32.dll:
00000000 I kernel32_NULL_THUNK_DATA
kernel32.dll:
00000000 T ExitProcess
00000000 T __imp_ExitProcess
kernel32.dll:
00000000 T Sleep
00000000 T __imp_Sleep

An import library is an archive whose members describe exported names and DLL ownership, with the necessary descriptors and terminators. The generated symbols include both a callable name and its __imp_ pointer slot.

The script compiles the object and links with /NODEFAULTLIB /ENTRY:main /SUBSYSTEM:CONSOLE:

$ file hello.exe
hello.exe: PE32+ executable for MS Windows 6.00 (console), x86-64, 5 sections
$ wc -c hello.exe
3584 hello.exe

The resulting 3,584-byte image is inspected on Linux, not executed. It does not require an installed Windows SDK because the import interface was constructed explicitly.

DOS stub, PE header, optional header

This is the linked PE image hello.exe, not an input COFF .obj. The input carries sections, symbols, and relocations for the linker; the image adds the loading contract. The 0x3c lookup below therefore does not apply to an ordinary .obj.

Keep its coordinates separate: a file offset counts from the disk file's start, an RVA from the loaded image's base, and a VA equals the actual load base plus RVA. An RVA into section contents needs section-based translation before indexing file bytes. A zero-filled region has no payload to read from disk. Microsoft's PE specification defines these distinctions.

The same image has different file and memory layouts. e_lfanew at file offset 0x3c contains 0x78. From there, a four-byte signature, 20-byte COFF header and 240-byte optional header lead to the section table at 0x180. Five 40-byte entries end at 0x248. Their contents are elsewhere, in raw-data regions beginning at 0x400, 0x600, and subsequent offsets.

PE file headers, section contents and RVA/VA coordinates

Trace import-lookup-table RVA 0x2028. It lies in .rdata, whose RVA starts at 0x2000, so its section-relative distance is 0x28. The raw data starts at file offset 0x600, yielding file offset 0x628. Only after adding preferred base 0x140000000 do we get VA 0x140002028. Loading the image at a different base changes the VA while preserving the RVA and file offset.

Reading a field of width w additionally requires δ + w ≤ SizeOfRawData, where δ = RVA − VirtualAddress, with checked arithmetic and a valid final file range. A memory-only zero-fill region has no corresponding file bytes. Header RVAs are a separate case: within SizeOfHeaders they can correspond to equal file offsets, still subject to file bounds. The certificate directory stores a file offset from the outset and does not use RVA conversion.

$ xxd -l 0x90 hello.exe
00000000: 4d5a 7800 0100 0000 0400 0000 0000 0000 MZx.............
00000010: 0000 0000 0000 0000 4000 0000 0000 0000 ........@.......
00000020: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000030: 0000 0000 0000 0000 0000 0000 7800 0000 ............x...
00000040: 0e1f ba0e 00b4 09cd 21b8 014c cd21 5468 ........!..L.!Th
00000050: 6973 2070 726f 6772 616d 2063 616e 6e6f is program canno
00000060: 7420 6265 2072 756e 2069 6e20 444f 5320 t be run in DOS
00000070: 6d6f 6465 2e24 0000 5045 0000 6486 0500 mode.$..PE..d...
00000080: 0f06 c56a 0000 0000 0000 0000 f000 2200 ...j..........".

MZ begins the DOS stub. At offset 0x3c, e_lfanew locates the PE\0\0 signature and COFF header. The historical stub prints a message if treated as a DOS program.

$ llvm-readobj --file-headers hello.exe | grep -E 'Machine:|SectionCount:|OptionalHeaderSize:|Magic:|AddressOfEntryPoint:|ImageBase:|SectionAlignment:|FileAlignment:|SizeOfImage:|Subsystem:|IMAGE_DLL_CHARACTERISTICS|TableRVA:|TableSize:'
Machine: IMAGE_FILE_MACHINE_AMD64 (0x8664)
SectionCount: 5
StringTableSize: 0
OptionalHeaderSize: 240
Magic: 0x20B
AddressOfEntryPoint: 0x1000
ImageBase: 0x140000000
SectionAlignment: 4096
FileAlignment: 512
SizeOfImage: 24576
Subsystem: IMAGE_SUBSYSTEM_WINDOWS_CUI (0x3)
IMAGE_DLL_CHARACTERISTICS_DYNAMIC_BASE (0x40)
IMAGE_DLL_CHARACTERISTICS_HIGH_ENTROPY_VA (0x20)
IMAGE_DLL_CHARACTERISTICS_NX_COMPAT (0x100)
IMAGE_DLL_CHARACTERISTICS_TERMINAL_SERVER_AWARE (0x8000)
ExportTableRVA: 0x0
ExportTableSize: 0x0
ImportTableRVA: 0x2000
ImportTableSize: 0x28
ResourceTableRVA: 0x0
ResourceTableSize: 0x0
ExceptionTableRVA: 0x4000
ExceptionTableSize: 0xC
CertificateTableRVA: 0x0
CertificateTableSize: 0x0
BaseRelocationTableRVA: 0x5000
BaseRelocationTableSize: 0xC
TLSTableRVA: 0x0
TLSTableSize: 0x0
LoadConfigTableRVA: 0x0
LoadConfigTableSize: 0x0
Magic: MZ

The “optional” header is optional for ordinary objects, but necessary for this image. Magic 0x20b selects PE32+. An RVA is an address relative to the image base: the entry RVA 0x1000 plus preferred base 0x140000000 gives 0x140001000.

DYNAMIC_BASE permits rebasing for ASLR12; NX_COMPAT declares compatibility with data-execution protection. It does not promise that no data-containing page can ever receive executable permissions. Data directories locate imports, unwind tables, base relocations, TLS, and other structures. Most use RVA plus size; the certificate-table directory uses a file offset, since its data is not mapped as image content.

$ llvm-readobj --sections hello.exe | grep -E 'Name:|VirtualSize:|VirtualAddress:|RawDataSize:|PointerToRawData:|IMAGE_SCN_MEM'
Name: .text (2E 74 65 78 74 00 00 00)
VirtualSize: 0x36
VirtualAddress: 0x1000
RawDataSize: 512
PointerToRawData: 0x400
IMAGE_SCN_MEM_EXECUTE (0x20000000)
IMAGE_SCN_MEM_READ (0x40000000)
Name: .rdata (2E 72 64 61 74 61 00 00)
VirtualSize: 0x84
VirtualAddress: 0x2000
RawDataSize: 512
PointerToRawData: 0x600
IMAGE_SCN_MEM_READ (0x40000000)
Name: .data (2E 64 61 74 61 00 00 00)
VirtualSize: 0x10
VirtualAddress: 0x3000
RawDataSize: 512
PointerToRawData: 0x800
IMAGE_SCN_MEM_READ (0x40000000)
IMAGE_SCN_MEM_WRITE (0x80000000)
Name: .pdata (2E 70 64 61 74 61 00 00)
VirtualSize: 0xC
VirtualAddress: 0x4000
RawDataSize: 512
PointerToRawData: 0xA00
IMAGE_SCN_MEM_READ (0x40000000)
Name: .reloc (2E 72 65 6C 6F 63 00 00)
VirtualSize: 0xC
VirtualAddress: 0x5000
RawDataSize: 512
PointerToRawData: 0xC00
IMAGE_SCN_MEM_DISCARDABLE (0x2000000)
IMAGE_SCN_MEM_READ (0x40000000)

There is no separate ELF-like segment table. Section records contain virtual placement, file placement, and permissions. This image aligns sections to 4 KiB in memory and 512 bytes in the file, so .text's RVA 0x1000 differs from its file offset 0x400.

The linker can merge import content into .rdata; directory fields, not a required .idata spelling, locate it. COFF input names such as .idata$2, $4, $5, and $6 are grouped by the prefix and ordered by suffix. This assembles descriptors, lookup/address tables, and names into the required order before output suffixes disappear.

ILT names the functions; IAT receives their addresses

$ llvm-objdump -s -j .rdata hello.exe
hello.exe: file format coff-x86-64
Contents of section .rdata:
140002000 28200000 00000000 00000000 6e200000 ( ..........n ..
140002010 40200000 00000000 00000000 00000000 @ ..............
140002020 00000000 00000000 58200000 00000000 ........X ......
140002030 66200000 00000000 00000000 00000000 f ..............
140002040 58200000 00000000 66200000 00000000 X ......f ......
140002050 00000000 00000000 00004578 69745072 ..........ExitPr
140002060 6f636573 73000000 536c6565 70006b65 ocess...Sleep.ke
140002070 726e656c 33322e64 6c6c0000 01040100 rnel32.dll......
140002080 04420000 .B..
$ llvm-readobj --coff-imports hello.exe
File: hello.exe
Format: COFF-x86-64
Arch: x86_64
AddressSize: 64bit
Import {
Name: kernel32.dll
ImportLookupTableRVA: 0x2028
ImportAddressTableRVA: 0x2040
Symbol: ExitProcess (0)
Symbol: Sleep (0)
}

The import descriptor at RVA 0x2000 identifies the lookup table, DLL name, and address table. The ILT at 0x2028 contains pointers to hint/name records for ExitProcess and Sleep. The IAT at 0x2040 initially contains the same references; the loader overwrites its slots with resolved addresses.

$ llvm-objdump -d hello.exe
hello.exe: file format coff-x86-64
Disassembly of section .text:
0000000140001000 <.text>:
140001000: 48 83 ec 28 subq $0x28, %rsp
140001004: 31 c9 xorl %ecx, %ecx
140001006: e8 25 00 00 00 callq 0x140001030 <.text+0x30>
14000100b: 48 8b 05 f6 1f 00 00 movq 0x1ff6(%rip), %rax # 0x140003008
140001012: 8b 08 movl (%rax), %ecx
140001014: 83 c1 02 addl $0x2, %ecx
140001017: ff 15 23 10 00 00 callq *0x1023(%rip) # 0x140002040
14000101d: 31 c0 xorl %eax, %eax
14000101f: 48 83 c4 28 addq $0x28, %rsp
140001023: c3 retq
140001024: cc int3
140001025: cc int3
140001026: cc int3
140001027: cc int3
140001028: cc int3
140001029: cc int3
14000102a: cc int3
14000102b: cc int3
14000102c: cc int3
14000102d: cc int3
14000102e: cc int3
14000102f: cc int3
140001030: ff 25 12 10 00 00 jmpq *0x1012(%rip) # 0x140002048

PE32+ import descriptor, ILT and IAT on disk and in memory

Each descriptor field is four bytes, whereas each PE32+ ILT/IAT entry is eight bytes. Width follows the structure definition rather than a general rule that every address occupies eight bytes. A name-import entry identifies a Hint/Name record by RVA. An ordinal import instead sets the high bit and stores the ordinal in the low 16 bits; interpreting it as a string RVA is wrong. An eight-byte zero entry terminates one DLL’s lookup table; a separate all-zero 20-byte descriptor terminates the DLL directory.

__imp_ExitProcess identifies the IAT slot. Before import resolution, this input’s slot contains the lookup RVA 0x2058; afterward it contains the function’s actual VA. The slot itself remains at image RVA 0x2040. The indirect call locates the slot through a RIP-relative displacement, reads its address value, and branches there. It does not call the Hint/Name string.

Because ExitProcess was declared dllimport, its object reference names __imp_ExitProcess and code calls directly through the IAT slot. Sleep was deliberately declared without it, so an ordinary direct call reaches a synthesized import thunk that jumps through the IAT. This resembles the difference between GOT-direct and PLT-mediated calls.

Normal PE imports are resolved at load time. Delayed DLL loading needs the explicit delay-import machinery, commonly requested with /DELAYLOAD. Omitting dllimport for a function can be repaired with a thunk; imported data needs an additional dereference in the generated instruction sequence, so the MSVC ABI requires the appropriate declaration. Other ecosystems, such as MinGW, may add different runtime accommodations.

Without the import library, the unresolved names reveal both paths:

$ lld-link /NODEFAULTLIB /ENTRY:main /SUBSYSTEM:CONSOLE /OUT:x.exe hello.obj
lld-link: error: undefined symbol: Sleep
>>> referenced by hello.obj:(main)
lld-link: error: undefined symbol: __declspec(dllimport) ExitProcess
>>> referenced by hello.obj:(main)

The import also names its DLL. Ordinary lookup is provider-specific, unlike ELF's common global search-scope model. Forwarded exports and explicit interposition techniques are additional mechanisms, not evidence that all three default contracts are identical.

Rebasing an already written pointer

$ llvm-objdump -s -j .data -j .reloc hello.exe
hello.exe: file format coff-x86-64
Contents of section .data:
140003000 28000000 00000000 00300040 01000000 (........0.@....
Contents of section .reloc:
140005000 00300000 0c000000 08a00000 .0..........
$ llvm-readobj --coff-basereloc hello.exe
File: hello.exe
Format: COFF-x86-64
Arch: x86_64
AddressSize: 64bit
BaseReloc [
Entry {
Type: DIR64
Address: 0x3008
}
Entry {
Type: ABSOLUTE
Address: 0x3000
}
]

p_value initially contains preferred address 0x140003000. A base-relocation block names page RVA 0x3000; entry 0xa008 means a 64-bit address at page offset 8. The zero entry is padding/no-op. At a different load base, the loader adds the base delta to the existing pointer.

This differs from a conventional ELF RELA relative relocation, which carries an explicit addend beside the destination. PE records the position and keeps the initial value in place. RELR also exploits in-place values and compact position descriptions; Mach-O chains encode their own links in the slots. Similar arithmetic does not imply interchangeable encodings.

Selecting repeated entities in two more formats

// common.h: the inline function from Theory 14
inline int twice(int x) { return 2 * x; }
$ llvm-readobj --sections --symbols a.obj | grep -E 'Name: |LNK_COMDAT|Selection:|AssocSection:'
Name: .text (2E 74 65 78 74 00 00 00)
Name: .data (2E 64 61 74 61 00 00 00)
Name: .bss (2E 62 73 73 00 00 00 00)
Name: .xdata (2E 78 64 61 74 61 00 00)
Name: .text (2E 74 65 78 74 00 00 00)
IMAGE_SCN_LNK_COMDAT (0x1000)
Name: .debug$S (2E 64 65 62 75 67 24 53)
Name: .pdata (2E 70 64 61 74 61 00 00)
Name: .llvm_addrsig (2F 34 00 00 00 00 00 00)
Name: .xdata (2E 78 64 61 74 61 00 00)
IMAGE_SCN_LNK_COMDAT (0x1000)
Name: .pdata (2E 70 64 61 74 61 00 00)
IMAGE_SCN_LNK_COMDAT (0x1000)
Name: .text
Selection: 0x0
Name: .data
Selection: 0x0
Name: .bss
Selection: 0x0
Name: .xdata
Selection: 0x0
Name: .text
Selection: Any (0x2)
Name: ?twice@@YAHH@Z
Name: .xdata
Selection: Associative (0x5)
AssocSection: .text (5)
Name: .debug$S
Selection: 0x0
Name: .pdata
Selection: 0x0
Name: .pdata
Selection: Associative (0x5)
AssocSection: .text (5)
Name: .llvm_addrsig
Selection: 0x0
Name: @feat.00
Name: ?fa@@YAHH@Z
Name: .file
FileName: a.cpp

COFF COMDAT belongs to a section. Auxiliary records describe its selection policy; associative .xdata and .pdata sections follow the fate of the code section they reference. ELF groups express related all-or-nothing selection differently.

The documented policies used here are:

1 IMAGE_COMDAT_SELECT_NODUPLICATES Reject duplicates
2 IMAGE_COMDAT_SELECT_ANY Select one, discard the rest
3 IMAGE_COMDAT_SELECT_SAME_SIZE Select one; sizes must match
4 IMAGE_COMDAT_SELECT_EXACT_MATCH Select one; contents must match exactly
5 IMAGE_COMDAT_SELECT_ASSOCIATIVE Follow another COMDAT section
6 IMAGE_COMDAT_SELECT_LARGEST Select the largest

This list is not an exhaustive catalog of every implementation extension. For example, other COFF constants can include NEWEST. The experiment covers ANY versus exact-content matching:

# exact1.s: EXACT_MATCH COMDAT containing 1
.section .rdata,"dr",same_contents,cval
.globl cval
cval:
.long 1
$ llvm-objdump -s -j .rdata any.exe
any.exe: file format coff-x86-64
Contents of section .rdata:
140002000 01000000 01000000 ........
$ lld-link /NODEFAULTLIB /ENTRY:main /SUBSYSTEM:CONSOLE /OUT:ex.exe use.obj anyc1.obj exact1.obj exact2.obj
lld-link: error: duplicate symbol: cval
>>> defined at exact1.obj
>>> defined at exact2.obj

ANY permits the first value even when another differs. EXACT_MATCH rejects the mismatch. Compilers commonly choose ANY for inline functions and templates, so Windows linking does not automatically diagnose source-level ODR13 violations either.

Mach-O needs no separate COMDAT section for the same function:

$ llvm-nm -m ca.o
0000000000000000 (__TEXT,__text) external __Z2fai
0000000000000028 (__TEXT,__text) weak external __Z5twicei
0000000000000000 (__TEXT,__text) non-external ltmp0
0000000000000048 (__LD,__compact_unwind) non-external ltmp1

The weak definition occupies an atom within shared __text. With valid subsection boundaries, the linker can discard the duplicate atom and associate its compact unwind record with that decision.

$ llvm-nm -m cprog | grep twice
0000000100000428 (__TEXT,__text) weak external __Z5twicei
$ llvm-otool -hv cprog
Mach header
magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
MH_MAGIC_64 ARM64 ALL 0x00 EXECUTE 16 960 NOUNDEFS DYLDLINK TWOLEVEL WEAK_DEFINES BINDS_TO_WEAK PIE

The surviving symbol remains weak. The image records weak-definition participation, and its import information uses special ordinal -3 for weak coalescing:

$ llvm-objdump --macho --chained-fixups cprog
cprog:
chained fixups header (LC_DYLD_CHAINED_FIXUPS)
fixups_version = 0
starts_offset = 32
imports_offset = 80
symbols_offset = 84
imports_count = 1
imports_format = 1 (DYLD_CHAINED_IMPORT)
symbols_format = 0
chained starts in image
seg_count = 4
seg_offset[0] = 0 (__PAGEZERO)
seg_offset[1] = 0 (__TEXT)
seg_offset[2] = 24 (__DATA_CONST)
seg_offset[3] = 0 (__LINKEDIT)
chained starts in segment 2 (__DATA_CONST)
size = 24
page_size = 0x4000
pointer_format = 2 (DYLD_CHAINED_PTR_64)
segment_offset = 0x4000
max_valid_pointer = 0
page_count = 1
page_start[0] = 0
dyld chained import[0]
lib_ordinal = -3 (weak)
weak_import = 0
name_offset = 0 (__Z5twicei)

That channel lets runtime weak definitions share an identity across images despite ordinary two-level binding. ELF section groups likewise do not eliminate all need for record-level handling of shared metadata such as .eh_frame. The unit of selection and the unit of metadata repair can differ.

TLS still needs two coordinates

A thread-local variable needs both a module-specific location and a current-thread instance:

_Thread_local int t = 5;
int get(void) { return t; }

Mach-O's arm64 sequence reaches a descriptor first:

$ llvm-objdump -h -dr tls.o
tls.o: file format mach-o arm64
Sections:
Idx Name Size VMA Type
0 __text 00000024 0000000000000000 TEXT
1 __thread_data 00000004 0000000000000024 DATA
2 __thread_vars 00000018 0000000000000028 DATA
3 __compact_unwind 00000020 0000000000000040 DATA
Disassembly of section __TEXT,__text:
0000000000000000 <ltmp0>:
0: a9bf7bfd stp x29, x30, [sp, #-0x10]!
4: 910003fd mov x29, sp
8: 90000000 adrp x0, 0x0 <ltmp0>
0000000000000008: ARM64_RELOC_TLVP_LOAD_PAGE21 _t
c: f9400000 ldr x0, [x0]
000000000000000c: ARM64_RELOC_TLVP_LOAD_PAGEOFF12 _t
10: f9400008 ldr x8, [x0]
14: d63f0100 blr x8
18: b9400000 ldr w0, [x0]
1c: a8c17bfd ldp x29, x30, [sp], #0x10
20: d65f03c0 ret

__thread_data contains the four-byte initial value. The 24-byte __thread_vars record contains a thunk, key, and offset. Code gets the descriptor, invokes its function pointer with that descriptor, and only then receives the current thread's variable address. Relocations to __tlv_bootstrap and the initializer symbol connect this protocol. It resembles ELF TLSDESC conceptually but has distinct fields and calling rules.

Windows x64 uses a module index and a per-thread pointer array:

$ llvm-objdump -h -dr tls.obj
tls.obj: file format coff-x86-64
Sections:
Idx Name Size VMA Type
0 .text 0000001a 0000000000000000 TEXT
1 .data 00000000 0000000000000000 DATA
2 .bss 00000000 0000000000000000 BSS
3 .tls$ 00000004 0000000000000000 DATA
4 .debug$S 0000005c 0000000000000000 DATA, DEBUG
5 .llvm_addrsig 00000000 0000000000000000
Disassembly of section .text:
0000000000000000 <get>:
0: 8b 05 00 00 00 00 movl (%rip), %eax # 0x6 <get+0x6>
0000000000000002: IMAGE_REL_AMD64_REL32 _tls_index
6: 65 48 8b 0c 25 58 00 00 00 movq %gs:0x58, %rcx
f: 48 8b 04 c1 movq (%rcx,%rax,8), %rax
13: 8b 80 00 00 00 00 movl (%rax), %eax
0000000000000015: IMAGE_REL_AMD64_SECREL t
19: c3 retq

Code loads _tls_index, obtains the TLS array through the TEB at the illustrated %gs:0x58, selects this module's block, and uses a SECREL relocation for the variable's offset. The PE TLS directory describes the template and where the loader writes the module index. Several directory fields are VAs rather than RVAs and therefore need base adjustment.

This verifies the generated COFF access sequence. The earlier no-CRT executable had no TLS and cannot certify the runtime side of this protocol.

A comparison of contracts

ConcernELF in this chapterMach-O in this chapterPE/COFF in this chapter
Link/load viewsSeparate section and program headersSegment commands contain sectionsImage sections contain load placement and permissions
Ordinary relocationx86-64 RELA, explicit addendEight-byte records, implicit or paired addendsTen-byte records, in-place addends
Duplicate selectionCOMDAT section groupsWeak atoms with associated metadataCOMDAT section policies and association
Dynamic identityCommonly a name within a search scopeDefault provider plus name, with flat/weak exceptionsDLL plus imported name/ordinal
Runtime repairRELA/REL/RELR and GOT/PLTRebase/bind opcodes or pointer chainsBase relocations and IAT filling
TLSTemplate plus architecture-specific access modelsTemplate, TLV descriptor, resolverTemplate, module index, thread array
UnwindingFrame records, often an indexCompact records with DWARF fallbackFunction table and unwind-code data
SigningNo universal generic-ABI code-signature structureCodeDirectory reached by a load commandOptional Authenticode certificate directory

These columns describe the selected target combinations, not every format variant. Authenticode's file-offset directory is described here but not exercised. Mach-O flat lookup and ELF local binding are reminders that a default is not the only supported policy.

WebAssembly adds another useful comparison: a relocatable .o can itself be a Wasm module, with linking and reloc.CODE custom sections carrying toolchain information. Its linking conventions define those additions separately from ordinary module execution.

Supporting another format is therefore more than swapping a file writer. Input granularity, symbol identity, relocation decoding, synthesized runtime structures, and final-byte signing all affect the pipeline. Theory 16 turns those differences into engineering choices and verification strategies.

Exercises

Use the files generated by the Linux run-all.sh; no host /bin/ls, Darwin command, or Windows runtime is part of these exercises.

  1. Inspect lookup contracts. Compare main and main-flat with llvm-nm -m, llvm-otool -L, and chained-fixup inspection. How is provider-specific versus flat lookup represented? For x86-64 prog-lazy, inspect the initial lazy pointer, helper, and lazy-bind bytecode. What does the pushed integer identify?

  2. Calculate before decoding. For rapp, use P = 0x1000003ec, the following add at 0x1000003f0, S = 0x100004008, and A = 12. The branch is at 0x100000408 and its target at 0x10000040c. Initial instruction words are 0x90000000, 0x91000000, and 0x14000000. Compute relocation results, then account for any LOH rewrite. Separately, predict format-2 chained slots at 0x100004008, ...4010, and ...4018 pointing to counter, counter + 1, and imported printf. How would OFFSET format change the first two?

  3. Break the metadata. Remove the local rpath, or move the main image into sub/ while leaving libraries above it. What relinked rpath fits that layout? Flip prog's first code byte and predict the signature mismatch slot. Finally truncate the thin Mach-O to 64 bytes: which boundary must a parser reject before reading individual commands?

The example checks fat-file byte order, slice bounds, and thin-image load commands on Linux.

Answers

1. Ordinals and lazy records

main identifies both _hello and _only_a as supplied by library A. Flat output instead uses ordinal -2. That changes the requested lookup semantics, not merely the display.

Lazy bind table:
__DATA __la_symbol_ptr 0x100003000 libSystem _printf
Contents of section __DATA,__la_symbol_ptr:
100003000 34060000 01000000
100000634: 68 00 00 00 00 pushq $0x0
100000639: e9 e6 ff ff ff jmp 0x100000624 <__stub_helper>

The lazy pointer initially contains 0x100000634, the helper's pushq $0. Zero is the offset into the lazy-bind bytecode for this sole import. The decoded operation identifies destination 0x100003000 and libSystem's _printf. This checks the protocol statically; no first-call execution was measured.

2. Relocation is not always the final transformation

S + A = 0x100004014
ADRP page delta = (0x100004000 - 0x100000000) / 4096 = 4
immlo = 0, immhi = 1
ADRP = 0x90000000 | (1 << 5) = 0x90000020
ADD page-offset immediate = 0x14
ADD = 0x91000000 | (0x14 << 10) = 0x91005000
B displacement = (0x10000040c - 0x100000408) / 4 = 1
B = 0x14000001
$ llvm-nm -m rapp
0000000100000000 (__TEXT,__text) [referenced dynamically] external __mh_execute_header
00000001000003ec (__TEXT,__text) external _addr_of_elem
0000000100000408 (__TEXT,__text) external _call_it
000000010000040c (__TEXT,__text) external _callee
0000000100004048 (__DATA,__data) external _ext_var
00000001000003b0 (__TEXT,__text) external _main
0000000100004000 (__DATA,__data) external _p_elem
00000001000003f8 (__TEXT,__text) external _read_ext
0000000100004008 (__DATA,__data) external _table
(undefined) external dyld_stub_binder (from libSystem)
$ llvm-objdump -d rapp | sed -n '/<_addr_of_elem>:/,/<_read_ext>:/p;/<_call_it>:/,/<_callee>:/p'
00000001000003ec <_addr_of_elem>:
1000003ec: 1001e140 adr x0, 0x100004014 <_table+0xc>
1000003f0: d503201f nop
1000003f4: d65f03c0 ret
00000001000003f8 <_read_ext>:
0000000100000408 <_call_it>:
100000408: 14000001 b 0x10000040c <_callee>
000000010000040c <_callee>:

The direct branch matches the calculated word. The page-address pair subsequently becomes adr/nop: S + A - P = 0x3c28, so the ADR encoding is 0x1001e140; the following instruction is NOP 0xd503201f. The ordinary relocation calculation was a correct intermediate result.

The chained slots are:

0x4008: 0x0010000100004000
0x4010: 0x0010000100004004
0x4018: 0x8000000000000000
100004000 28000000 00000000 00400000 01001000
100004010 04400000 01001000 00000000 00000080

The first two use next = 2 because eight bytes equal two four-byte units. The second target already includes the four-byte element offset. The last binds import zero and terminates the chain. Format 6 would store targets 0x4000 and 0x4004, omitting the preferred image base. The slot's import index and the import record's library ordinal are two distinct indexing layers.

3. Validate the right contract at the right layer

For the moved-image arrangement, relink with -rpath @loader_path/... An install name alone does not provide the search directories. Changing signed load commands also requires rebuilding the signature, while target-system load permission remains a separate test.

The first code byte is at file offset 0x4e8; with signature blocks of 0x1000, its slot is zero. The checker reports mismatches=[0] and exits 1. That status belongs to the Python checker, not to a Darwin process or signal.

The truncated image fails before command internals are visited:

machoinfo: sizeofcmds 736 runs past end of file (64 bytes)

A correct parser checks the declared command region against file bounds, then checks each command and embedded structure within that region. A fat container additionally bounds each slice before invoking the thin-image parser. A valid magic value is only the beginning of validation.

Appendix: terms and tools

  1. 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. ↩

  2. 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. ↩

  3. ABI, Application Binary Interface, specifies how compiled components cooperate, including calling conventions, data layout, and object-format rules. It governs the machine-level boundary rather than only the source API. System V ABI. ↩

  4. 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. ↩

  5. RELRO, RELocation Read-Only, protects memory that needs relocation-time writes but should become read-only afterward. PT_GNU_RELRO describes the range; the startup path must enforce the protection. GNU ld options. ↩

  6. 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. ↩

  7. LSDA, Language-Specific Data Area, carries exception-handling information such as protected regions and actions. The generic unwinder and language personality cooperate to use it. Exception-handling ABI. ↩

  8. FDE, Frame Description Entry, associates a code-address range with unwind instructions. Moving code or rebuilding .eh_frame requires updating addresses and inter-record references. Exception-frame format. ↩

  9. CFI here means Call Frame Information: rules for recovering a caller from the current frame. The security term Control-Flow Integrity shares the acronym but describes a different mechanism. DWARF 5. ↩

  10. COFF — Common Object File Format provides the object-file structures used by the Windows targets here; PE adds image-loading information. Microsoft PE/COFF specification. ↩

  11. PE — Portable Executable is the Windows image format for executables and DLLs. Its headers and directories describe loading, imports, relocations, and related runtime data. Microsoft specification. ↩

  12. 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. ↩

  13. ODR — The C++ One Definition Rule constrains consistency of repeated language entities. A format's duplicate-selection mechanism does not itself establish that consistency. C++ draft. ↩