链接器的世界/ 原理篇/ 17 篇
155 分钟阅读公开阅读

[链接器的世界-原理篇15] 走出 ELF:Mach-O 与 PE/COFF 的另一套规则

这是文件格式的对照专题。前提是原理篇 02的 ELF 文件、节表与内容之间的关系,以及原理篇 04的地址修正;动态加载和展开记录分别沿用第 7、8 章的概念。Mach-O、COFF 和 PE 会重新编码这些关系,不能把 ELF 的字段偏移或位值直接搬过去。

首次阅读可以沿“一份源码,三个目标文件 → 各自怎样找到内容和符号 → 三种格式对照”建立整体模型。dyld 的 chained fixups、compact unwind、TLS 与 Wasm 是后续专题,按需要阅读。每段数值都属于该段注明的格式和架构;文件偏移、RVA 与 VA 不因名称相近就成为同一个坐标。

把同一个 C 函数编译成 Linux、macOS 和 Windows 的目标文件,函数的计算可以完全相同,文件却不能互换。链接器要先读懂它的组织方式,才能找到代码、符号和待修补的位置;生成程序以后,操作系统的加载器也要按同一套格式约定解释它。

此前使用的 ELF1 是其中一套约定。Apple 平台主要使用 Mach-O;Windows 的普通目标文件使用 COFF2,可执行文件和 DLL 则使用在 COFF 基础上扩展的 PE3。三者都要表达代码、数据、符号和地址修正,但未必把它们装进相同的结构。例如,ELF 可以把函数及其附属数据组成 COMDAT4 节组,Mach-O 却能在一个节内部划出独立单元,完成类似的去重。文件格式的区别因此会一直影响到链接算法,而不只是文件开头的几个字节。

一份源码,三个目标文件

用一个跨文件调用观察这些差别。为了让三种目标都能直接编译,main.c 不包含任何头文件,自己声明 printf:

// main.c:不包含头文件,三种目标都能直接编译
int printf(const char *fmt, ...);
extern int counter; // 定义在 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; }

把 main.c 交给三条命令:

$ 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

第一条对应本机 Linux ABI5;第二、三条显式选择 Mach-O arm64 和 COFF x86-64。目标三元组同时包含 CPU 与平台约定,msvc 表示微软 ABI。这里有两个变化轴:输出格式不同,处理器也不同。比较节表和符号组织可以观察格式约定,比较指令与重定位则必须同时考虑 CPU;Arm 的两条页寻址指令与 x86 的一条 PC 相对指令不同,不能全部归因于文件格式。

先看头部。llvm-readobj --file-headers 能读三种格式,下面只留关键几行:

$ 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 的魔数 7F 45 4C 46 第 2 章拆过。Mach-O 的魔数是 0xFEEDFACF,按小端存放,文件里的前四个字节是 CF FA ED FE;它的头里没有"节头表在哪里"这种字段,取而代之的是"后面有 5 条 load command,共 456 字节"。COFF 目标文件没有魔数,第一个字段就是机器类型 0x8664,然后是节数和符号表的位置;本次头里的 TimeDateStamp 记录编译时刻,跨秒重新编译就可能改变产物;它也可以为零或采用可复现构建所需的约定,不能一律解释为真实时间。

后面的 Mach-O 主样本采用 arm64 目标;需要隔离 CPU 差异时,脚本还生成 main-x86macho.o,让 ELF/COFF/Mach-O 都有 x86-64 对照。它们的编译器进程始终在本机 Linux 执行。

再看节(ELF 和 COFF 两段省略了全为零的 VMA 列):

$ 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 的符号表、字符串表、重定位表都是节(第 2 章)。这份 Mach-O 目标文件只有三个节。符号表由 LC_SYMTAB 命令定位;各节的重定位记录则由段命令所含的节结构中的偏移和数量定位,它们不必各自成为一个节。节名用双下划线开头,字符串常量进 __cstring,这份文件的展开信息放在 __compact_unwind 中;其他函数还可能需要 __eh_frame(见栈展开一节)。COFF 的节名和 ELF 很像,因为 ELF 本来就借鉴了 COFF 的节(第 1 章):.text、.data、.bss,只读数据叫 .rdata;.xdata 和 .pdata 是 Windows x64 的展开表;.debug$S 是一小段 CodeView 格式(微软的调试信息格式)的编译器版本信息,没加 -g 也有。名字里的 $ 是 COFF 的分组记号,下面讲 PE 的时候会用上。

然后是符号:

$ 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 给每个 C 符号都加了一个前导下划线,main 变成 _main。这是早期 Unix C 编译器的老约定,ELF 把它去掉了,Mach-O 一直保留着。ltmp0、l_.str 是汇编器生成的局部符号,原因在重定位一节说。COFF 的 x86-64 不加下划线(32 位 x86 的 COFF 加),C 函数名原样出现;可是字符串常量 "%d\n" 有了一个以 ??_C@ 开头的名字,这是 MSVC 修饰规则给字符串常量起的名字。它放在一个 COMDAT 节里,相同的字符串常量由链接器合并成一份,ELF 靠带 SHF_MERGE 标志的 .rodata.str1.1 做这件事(第 5 章)。@feat.00 是一个绝对符号,值是一组特性标志位。

把 add.c 当作 C++ 编译,三种修饰的差别更明显:

$ 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 用的就是第 14 章的 Itanium 修饰,只是再加一个下划线,所以 _Z3addii 成了 __Z3addii。MSVC 是另一套规则:以 ? 开头,名字在前,@@ 结束作用域,Y 表示非成员函数,A 是调用约定 __cdecl,接着 H 是返回类型 int,再两个 H 是参数,@ 结束参数表,Z 表示没有异常规格。和 Itanium 比有两处不同:普通函数也编码返回类型(Itanium 的普通函数名不编码返回类型,第 14 章),全局变量也要修饰(?counter@@3HA 里的 3 表示全局变量,H 是类型)。llvm-undname 把它们还原成 int __cdecl add(int, int) 和 int counter。

最后是重定位,节选 objdump -d -r 的输出:

$ 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

ELF 的两条第 4 章都见过:本次 Linux Clang6 面向 x86-64 Linux 目标的代码生成使用位置无关访问,外部变量 counter 可能来自共享库,于是经 GOT 读取,类型是 GOTPCRELX,调用用 PLT32,加数都是 −4。Mach-O 的 arm64 代码同样经 GOT 读 counter,用的是 adrp 加 ldr 的页寻址(第 4 章的 AArch64 一节),两条重定位的名字带着 GOT_LOAD。COFF 的两处都是 IMAGE_REL_AMD64_REL32,既没有 GOT 也没有 PLT7,加数一栏也空着。原因有两个。其一,COFF 的 REL32 定义为相对"这个 32 位字段结束处"的距离,也就是 P + 4,第 4 章里那个补偿用的 −4 已经写进了类型的定义。其二,本例 MSVC ABI 目标对没有标 dllimport 的符号生成直接引用,不为它准备间接访问;要从 DLL(dynamic-link library,Windows 的动态库,相当于 ELF 的共享库)里引用数据,必须在声明上写 __declspec(dllimport),PE/COFF 一节会看到它把代码变成什么样。

Mach-O 的骨架:头、load command、segment 与 section

先区分“描述记录”和“被描述的内容”。load command 是文件头后面的元数据记录,不是 CPU 要执行的指令。LC_SEGMENT_64 记录描述一个 segment,其记录内部紧接着该 segment 的 section 描述;section 的机器码或数据则位于描述中指定的文件偏移。读取一个 section 描述,不等于已经读到了这个 section 的内容。

要找到的东西从哪里获得位置或范围
load command 列表文件头后的区域;头中给出条数和总长度
下一条 command当前 command 的起点加 cmdsize
segment 内的 section 描述LC_SEGMENT_64 内的 section 记录数组,数量由 nsects 给出
section 内容与重定位记录对应 section 描述中的 offset、size、reloff、nreloc

ELF 把节头表放在 e_shoff 指定的位置;Mach-O 把这些描述组织进 load command。两者都需要从元数据走到内容,只是目录的组织方式不同。

下面的图把同一个 main-macho.o 分成四层看:完整文件、load command 数组、第一个命令内的节描述、真正的节内容。32 字节文件头之后的 456 字节都是命令记录;其中第一个命令占 72 + 3 × 80 = 312 字节。三个 80 字节记录分别描述三个节,它们的内容从文件偏移 488、552、560 开始,位于命令区域之外。__text 的六条重定位又占另一段 6 × 8 = 48 字节,从偏移 592 开始。目录项、节内容、重定位表各有自己的范围。

Mach-O 整个目标文件、load command 与节内容的字节关系

这些具体偏移属于图中的 ARM64 示例,定位规则则来自格式本身:检查头部范围后遍历恰好 ncmds 条命令,每条至少能容纳 cmd/cmdsize;读取 LC_SEGMENT_64 时再检查 72 字节固定部分及 nsects 个 80 字节记录是否都位于该命令内;读取内容时独立检查 offset + size 是否在文件内。非文件存储节(例如零填充节)不能按普通内容节切片。不能因为目录记录可读,就跳过它所指内容的边界检查。Apple 的 loader.h定义了这些结构。

Mach-O 的名字来自 Mach 微内核:NeXTSTEP 基于 Mach 和 BSD,它的目标文件格式后来随 NeXT 一起进入 Mac OS X。格式的定义就是一个 C 头文件 mach-o/loader.h,SDK 里有一份,Apple 开源的 xnu 和 cctools 仓库里也有(xnu loader.h)。下面提到的结构体和常量都出自这个文件。

文件开头是 32 字节的 mach_header_64,八个 32 位字段:魔数、CPU 类型、CPU 子类型、文件类型、load command 的条数和总字节数、标志位,最后一个保留字段。文件类型相当于 ELF 的 e_type(第 2 章):MH_OBJECT 是目标文件,MH_EXECUTE 是可执行文件,MH_DYLIB 是动态库(dylib,相当于 ELF 的共享库 .so),MH_BUNDLE 是只能由 dlopen 加载的插件。llvm-otool -hv 把这些字段解码出来:

$ 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 command(加载命令)。每条命令以两个 32 位字段开头:cmd 说明这是哪一种命令,cmdsize 是整条命令的字节数,工具可以在检查长度与边界之后按 cmdsize 移动到下一条命令,但加载器不能无条件忽略不认识的命令:带 LC_REQ_DYLD 位的命令是运行所必需的,不支持它就不能装载。SDK 的 loader.h 在这个标志定义前明确说明了这一区别。这是 Mach-O 和 ELF 在结构上最大的不同。ELF 有两张定长表,节头表给链接器看,程序头表给加载器看(第 2 章的"两种视图"),动态链接要用的信息再单独放进 .dynamic 节(第 7 章)。Mach-O 把这些都写成一种可扩展的命令列表,新功能就是新命令。

这份目标文件有 5 条命令:

$ 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

(输出有删节,run-all.sh 里是完整的。)

LC_SEGMENT_64 描述一个段(segment),段的名字在目标文件里是空的,段里包含 3 个节(section)。每个节的记录是 80 字节的 section_64,写着它属于哪个段、叫什么、地址、大小、在文件里的偏移和对齐。一个节的完整名字是"段名,节名",比如 __TEXT,__text,这是 Mach-O 的两级结构。在目标文件里段只是一个容器;到了可执行文件里,段才有了加载的含义。

重定位表不是一个节,它的位置直接写在节记录的 reloff、nreloc 两个字段里:__text 有 6 条重定位,从文件偏移 592 开始。ELF 要用一个 .rela.text 节,再用节头里的 sh_info 指回被修改的节(第 2 章),Mach-O 省掉了这一层。

节的种类写在 flags 的低 8 位里。__cstring 的 flags 是 2,即 S_CSTRING_LITERALS,表示"节里是以零结尾的字符串,链接器可以去重",作用和 ELF 的 SHF_MERGE | SHF_STRINGS 一样(第 5 章);__text 的高位标志说明它只含指令。__compact_unwind 所在的段叫 __LD,这个段名的意思是"只给链接器看",它不会原样出现在输出里,链接器会把它转换成可执行文件里的 __unwind_info。

LC_SYMTAB 指向符号表和字符串表。符号表的每一项是 16 字节的 nlist_64:名字在字符串表里的偏移、类型字节、所在节的编号(从 1 开始)、描述字段、值,比 ELF 的 24 字节 Elf64_Sym(第 2 章)少了 st_size,Mach-O 的符号没有大小。LC_DYSYMTAB 把符号表切成三段:局部符号、本文件定义的外部符号、未定义符号,各给出起始下标和个数。ELF 只要求局部符号排在前面(sh_info 记分界),Mach-O 要求三类各自连续,不用扫描整张表就能找到全部未定义符号。

头部标志里的 MH_SUBSECTIONS_VIA_SYMBOLS 是一个承诺:这个文件里的节可以沿着符号的边界切开,每两个相邻符号之间的字节是一块独立的内容。Apple 的链接器 ld64 把这样的一块叫作 atom(原子),链接时以 atom 为单位做符号解析、去重和死代码删除(-dead_strip)。效果相当于 ELF 的 -ffunction-sections -fdata-sections(第 5 章),但不用改编译选项。这也解释了前面 nm8 里那几个怪名字:节要能沿符号切开,节里的每一块就都得从某个符号开始,汇编器于是给每个节的起点补上 ltmp0、ltmp1 这样的局部符号,字符串常量也得有个名字 l_.str,才能成为一个独立的 atom。

LC_LINKER_OPTIMIZATION_HINT(简称 LOH,链接器优化提示)是编译器另给链接器的一张表,列出几组相互配合的指令,告诉链接器在条件满足时可以把整组改写得更短。llvm-objdump --macho --link-opt-hints main-macho.o 列出两组:一组 AdrpAdd(偏移 0x24、0x28,取字符串地址),一组 AdrpLdrGotLdr(0xc、0x10、0x14,经 GOT 读 counter)。

可执行文件

把两个目标文件链接起来:

$ 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

本次 17 条 load command。先看段:

$ 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

这五个段共同描述加载布局,作用可类比 ELF 程序头;其中 __PAGEZERO 只保留无访问权限的地址范围,不能机械地当成五个普通文件映射(第 5 章),initprot 是初始权限,5 是读加执行,3 是读写,1 是只读。和 ELF 对照有几处不同。

第一,节嵌在段的命令里。ELF 的节头表和程序头表是两张互相独立的表,strip 掉整张节头表,程序照样能运行;Mach-O 的节记录就附在 LC_SEGMENT_64 后面,一个节必须属于某个段。

第二,__PAGEZERO。它从地址 0 开始,长 4GB,文件里不占一个字节,权限是 0,什么都不能做。它的作用是让空指针和任何被截断成 32 位的指针一访问就出错,64 位代码里把指针误存进 int 的错误会立刻暴露。ELF 没有对应的东西:Linux 靠内核参数 vm.mmap_min_addr 禁止映射最低的一段地址,非 PIE9 的 x86-64 程序默认从 0x400000 开始(第 5 章)。

第三,段在文件里也按页对齐。__TEXT 从文件偏移 0 开始,连 Mach-O 头和 load command 一起映射进内存;每个段的 fileoff 和 vmaddr 相对起点的偏移完全相同,都是 16KB 的整数倍(arm64 macOS 的页大小是 16KB)。第 5 章的"p_offset 与 p_vaddr 模页大小同余"在这里退化成了相等,代价是文件里有大量填充:prog 只有 72 字节代码,文件却有 50048 字节。

第四,__DATA_CONST。里面是 GOT 这类只需在启动时写一次的数据,dyld(macOS 的动态链接器,相当于 ld.so)处理完它们之后把这个段改成只读,和 ELF 的 RELRO10 是同一个想法(第 7 章)。

第五,__LINKEDIT。它不含任何节,装的是符号表、字符串表、给 dyld 的修正信息、导出符号表、代码签名,这些在 ELF 里分散在 .dynsym、.dynstr、.rela.dyn、.gnu.hash 等节中。Mach-O 把它们都放进这个只读段,由各条 load command 用"文件偏移加长度"指进来。

再看剩下的命令:

$ 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 指定动态链接器 /usr/lib/dyld,相当于 ELF 的 PT_INTERP(第 7 章)。LC_LOAD_DYLIB 是一个依赖库,相当于 DT_NEEDED;macOS 上 C 库、数学库、线程库等都合在 libSystem.B.dylib 里,所以只有这一条。LC_UUID 是一个 128 位的唯一标识,作用和 build-id 一样(第 11 章)。LC_BUILD_VERSION 记着平台、最低系统版本和 SDK 版本,ELF 里没有对应物。LC_DYLD_EXPORTS_TRIE 是本文件导出符号的前缀树,承担 ELF 的 .dynsym 加 .gnu.hash 的查找工作。LC_DYLD_CHAINED_FIXUPS 和 LC_CODE_SIGNATURE 留到动态链接一节。

LC_MAIN 的 entryoff 是 1256,即 0x4e8。__TEXT 从文件偏移 0 映射到 0x100000000,所以入口地址是 0x1000004e8,llvm-otool -tV 显示这正是 _main。入口直接是 main,没有 _start:dyld 完成加载和修正后自己调用 main,除了 argc、argv、envp,还传入第四个参数 apple,一组由内核准备的 key=value 字符串,例如 executable_path= 和 executable_cdhash=;main 返回后,dyld 再把返回值交给 exit(dyld-1378 的 dyld/dyldMain.cpp 第 1462–1471 行)。第 6 章那条 _start → __libc_start_main → main 的路在 macOS 上由 dyld 包办,链接时不需要 crt1.o。更老的可执行文件用 LC_UNIXTHREAD,直接写出入口处各个寄存器的初值,那时候入口还是 start。

nm -m 能显示每个符号所在的段和节:

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

__mh_execute_header 是链接器合成的符号,指向文件头本身,用途类似 ELF 的 __ehdr_start(第 3 章的合成符号)。最后一行的 (from libSystem) 是 ELF 的 nm 永远不会输出的:一个未定义符号,后面写着它来自哪个库。它就是动态链接一节要讲的 two-level namespace。

Mach-O 的重定位

第 4 章说过,一条重定位要回答四个问题:在哪儿改、按什么规则算、参考谁的地址、再加多少。ELF 的 Elf64_Rela 用 24 字节装下这四项。Mach-O 的 relocation_info 只有 8 字节,定义在 mach-o/reloc.h:

struct relocation_info {
int32_t r_address; /* 节内偏移 */
uint32_t r_symbolnum:24, /* r_extern=1 时是符号下标,=0 时是节的序号 */
r_pcrel:1, /* 是否 PC 相对 */
r_length:2, /* 0=1 字节, 1=2 字节, 2=4 字节, 3=8 字节 */
r_extern:1,
r_type:4; /* 与架构相关的类型 */
};

前三个问题都有字段,第四个没有:Mach-O 的重定位记录里没有加数。类型只有 4 位,每种架构最多 16 种重定位,ELF AArch64 的类型编号已经排到了一千多。32 位时代的 Mach-O 还有一种 scattered relocation(离散重定位),记录里写目标地址而不写符号,x86-64 和 arm64 都不再使用。

用一个文件把 arm64 上几种常见的访问方式都凑齐:

// kinds.c:arm64 上几种常见的访问方式
extern int table[16] __attribute__((visibility("hidden"))); // 链接时一定在本模块里
extern int ext_var; // 可能来自 dylib:走 GOT
void callee(void);
int *p_elem = &table[5]; // 数据里的指针:ARM64_RELOC_UNSIGNED
int *addr_of_elem(void) { return &table[3]; } // 地址加常量:要带加数
int read_ext(void) { return ext_var; }
void call_it(void) { callee(); } // 尾调用:BRANCH26

table 声明成 hidden(第 3 章的可见性),编译器就知道它不可能来自动态库,可以用 adrp 加 add 直接取地址,不必经过 GOT。同一个文件分别编成 Mach-O 和 ELF:

$ 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

两边生成的指令完全一样,只是重定位的名字和记法不同。按功能对照(Mach-O 的定义见 mach-o/arm64/reloc.h,cctools 仓库;ELF 的见第 4 章引过的 aaelf64):

功能 Mach-O arm64 ELF AArch64
数据里的 64 位指针 ARM64_RELOC_UNSIGNED (0) R_AARCH64_ABS64
b / bl 的 26 位跳转 ARM64_RELOC_BRANCH26 (2) R_AARCH64_JUMP26 / CALL26
adrp 的页号 ARM64_RELOC_PAGE21 (3) R_AARCH64_ADR_PREL_PG_HI21
页内偏移 ARM64_RELOC_PAGEOFF12 (4) R_AARCH64_ADD_ABS_LO12_NC、LDST{8,16,32,64,128}_ABS_LO12_NC
GOT 槽位的页号 ARM64_RELOC_GOT_LOAD_PAGE21 (5) R_AARCH64_ADR_GOT_PAGE
GOT 槽位的页内偏移 ARM64_RELOC_GOT_LOAD_PAGEOFF12 (6) R_AARCH64_LD64_GOT_LO12_NC
TLV 描述符 ARM64_RELOC_TLVP_LOAD_PAGE21 (8) (TLS 另有一套,第 9 章)
给下一条补加数 ARM64_RELOC_ADDEND (10) (不需要,r_addend 字段)

计算公式和第 4 章一样:PAGE21 是 Page(S + A) - Page(P) 右移 12 位,PAGEOFF12 是 (S + A) 的低 12 位,BRANCH26 是 (S + A - P) 右移 2 位。差别在于类型分得粗。ELF 为 add、32 位 ldr、64 位 ldr 各定义了一种 LO12,因为不同的访存宽度要把立即数右移不同的位数(第 4 章的 LDST32 只取 [11:2] 位)。Mach-O 只有一种 PAGEOFF12,链接器要读出被修改的那条指令,自己判断它是 add 还是几字节宽的 ldr/str。ELF 也区分 b 的 JUMP26 和 bl 的 CALL26,Mach-O 都叫 BRANCH26。

加数放在哪里

&table[3] 的加数是 12。ELF 把它写在 RELA 记录的 r_addend 里,objdump11 显示为 table+0xc。Mach-O 的记录没有这个字段,于是在 PAGE21 前面插一条 ARM64_RELOC_ADDEND,它不修改任何东西,只给紧跟着的下一条重定位提供加数。llvm-otool -r 显示原始字段:

$ 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

记录按地址从大到小排列。类型 10 的两条 extern 为 0,symbolnum 一栏是 12,也就是加数 0xc:这条记录借用了 24 位的符号下标字段来存一个有符号的加数,所以 ADDEND 能表示的范围只有 ±8M。紧跟其后的类型 3(PAGE21)和类型 4(PAGEOFF12)extern 为 1,symbolnum 是 9,指向符号表里的 _table。

数据里的指针走另一条路。p_elem = &table[5] 的加数是 20,看 __data 的内容:

$ 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

0x14 就写在要被修改的那 8 个字节里,链接器读出原值当作加数,加上 _table 的地址再写回。这就是第 4 章讲过的 REL 风格:加数藏在被修改的位置上。ELF 的 AArch64 规定用 RELA,那 8 个字节是零,加数在记录里。指令里的立即数字段太窄,放不下任意的加数(adrp 的页号字段里根本没有低 12 位),所以 arm64 的 Mach-O 才需要 ADDEND 这种补丁式的记录。本例 x86-64 Mach-O 的这条重定位用 REL 风格:clang --target=x86_64-apple-macos 编出的 leaq 0xd(%rip), %rdi 在指令里留着 0d 00 00 00,重定位是 X86_64_RELOC_SIGNED __cstring,extern 为 0,指向整个 __cstring 节。0xd 是 0x2b − 0x1e:字符串在目标文件地址空间里的地址(__cstring 从 0x2b 开始)减去下一条指令的地址,汇编器按目标文件里的布局先把距离算好,链接器挪动节之后再按位移差修正。

链接器改写了指令

目标文件的 LC_LINKER_OPTIMIZATION_HINT 把相互配合的指令位置告诉链接器。本例有读取 counter 的 AdrpLdrGotLdr,以及取得字符串地址的 AdrpAdd。链接时已知 counter 在本映像中,LLD12 可以把三条 GOT 访问改成一条 literal load,原位置补 nop;字符串足够近时,adrp/add 可改成 adr/nop。这些是固定宽度原位改写,不缩短后面代码的地址。

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

重新编译时加 -mllvm -aarch64-enable-collect-loh=false,再用相同的 ld64.lld 链接:

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

不带 LOH 时,LLD 仍可依据 GOT 重定位做普通松弛,但失去了安全合并整组指令的额外关系。这正是“地址已知”与“允许怎样改写”之间的区别。这里用 Linux 上同一个 LLD 比较两种输入,可以把变化归因于是否提供 LOH。

动态链接:dyld 怎样找到符号

内核按 LC_LOAD_DYLINKER 把 dyld 映射进来,由它加载依赖的 dylib、修正指针、绑定符号,再调用 main。它的源码在 Apple 的开源仓库里(dyld)。第 7 章讲 ld.so 时有三个核心问题:依赖库去哪里找,一个未定义符号在哪个库里找,要修改的地址怎样告诉动态链接器。先看一个引用如何指定提供符号的库,再看库的位置和地址修正记录。

two-level namespace

ELF 通常把待查名字交给运行时搜索范围;Mach-O 默认再记住提供符号的库,即 two-level namespace。twolevel/a.c 与 b.c 都定义 hello,只有 a 定义 only_a。在 Linux 用 Clang 编成 arm64 目标,再用 ld64.lld -dylib -install_name @rpath/liba.dylib 生成库;主程序按 -la -lb 链接。

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

_hello 和 _only_a 都标为 from liba,这是文件里可直接核对的事实。若只换库、不重链接主程序,a 不再导出 hello,two-level 的普通绑定仍指向 a,不会仅因为 b 有同名符号就自动改选 b。这个结论来自记录的库序号及 dyld 查找契约;这里仅检查文件结构;dyld 的运行行为需要在 Darwin 上验证。

再加 -flat_namespace 链接 main-flat,检查 chained imports:

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

库序号变成特殊值 −2(flat namespace),不再是某个 LC_LOAD_DYLIB 的下标。由此可在 Linux 直接验证两种链接模式写出了不同的查找约定。旧 dyld 环境变量是否仍被某个系统版本接受,是运行时版本问题,不作为本实验的操作步骤。

install name 与 @rpath

llvm-otool -L 只读文件,列出主程序的 LC_LOAD_DYLIB;-D 则读取库自身的 LC_ID_DYLIB。链接器把库的 install name 复制到使用者的依赖命令中。它可以是绝对路径,也可以使用路径前缀:@executable_path 相对主程序目录,@loader_path 相对包含依赖命令的映像,@rpath 按加载链的运行路径搜索。

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

main 的 LC_RPATH 是 @loader_path,因而对应查找它旁边的 liba.dylib。脚本还链接了没有 -rpath 的 main-norpath:它依然有 @rpath/liba.dylib 依赖,但没有相应搜索路径命令。这是在文件层面构造出的不完整部署配置,不需要真的启动 Darwin 才能指出问题。

如果把主程序放进 sub/、库仍放父目录,路径展开应改用 @loader_path/..。可以重新链接时指定该 rpath,然后检查命令;至于目标机器的权限、共享缓存与 dyld 策略是否允许加载,需要在目标运行环境另行验证。ELF 的 DT_RUNPATH 同样表达搜索目录,但它与 dyld 的 rpath 加载链继承规则并不相同,不能逐项替换。dyld Loader.cpp。

stubs、GOT 与 chained fixups

第三个问题:要修改的地址怎样告诉 dyld。ELF 的答案是动态重定位表 .rela.dyn 和 .rela.plt,外加 GOT 和 PLT(第 7 章)。Mach-O 有对应的结构,只是名字不同。先看 prog 怎样调用 printf:

$ 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

bl 跳到 __stubs 里的一个桩(stub),桩从 __got 的槽位里取出地址,再间接跳过去。__stubs 对应 ELF 的 .plt,__got 对应 .got,x16 是 AArch64 调用约定专门留给这种跳板的临时寄存器(ELF 的 PLT 也用它)。__got 里那 8 个字节由 dyld 在启动时填上 printf 的地址,chained import 列出了要绑定的 _printf。

Mach-O 把动态修正分成两类:rebase(重定基)是"这里存着一个本模块内的地址,按实际加载地址加上偏移量",对应 ELF 的 R_X86_64_RELATIVE(第 7 章);bind 是"这里要填某个库里某个符号的地址",对应 GLOB_DAT 和 JUMP_SLOT。写几个指针把两类都凑出来:

// ptrs.c:数据段里放几个指针,看链接器为它们留下什么
int printf(const char *fmt, ...);
void *malloc(unsigned long n);
int counter = 40;
int *p_counter = &counter; // 指向本模块:需要 rebase
void *p_printf = (void *)&printf; // 指向 libSystem:需要 bind
void *p_malloc = (void *)&malloc; // 同上
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)

这些信息存在哪里?传统的做法是 LC_DYLD_INFO_ONLY 命令指向的几段字节码:rebase opcodes 和 bind opcodes,dyld 像执行一个小解释器那样逐条执行,每条指令设置"当前段、当前偏移、当前符号"之类的状态,再"在这里做一次修正,偏移前进若干字节"。链接器的 -no_fixup_chains 选项还能生成这种格式:

$ 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(链式修正),由 LC_DYLD_CHAINED_FIXUPS 描述。它的想法是:反正每个要修正的位置都要被 dyld 写一遍,那就把"怎样修正"直接编码进这个位置原来的 8 个字节里,并且让每个位置记住下一个待修正位置的距离,把同一页里的所有修正串成一条链。__LINKEDIT 里只需要保存每一页链头的偏移,外加一张导入符号表。看 ptrs 的 __data 里实际存了什么:

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

0x100004000 是 counter,值 0x28 即 40。后面三个 8 字节按 mach-o/fixup-chains.h 里的位域解读(dyld 仓库),本机 LLD 生成的格式是 DYLD_CHAINED_PTR_64(编号 2),不是 DYLD_CHAINED_PTR_64_OFFSET(编号 6):

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
};

第一个指针实际是 0x0010000100004000:bind=0,36 位 target 是首选虚拟地址 0x100004000,next=2,以下一个位置距此 8 字节表示链的下一项。对格式 2,dyld 给恢复的首选地址加 slide;只有格式 6 才把 target 解释为相对映像起点的偏移。第二项 0x8010000000000000 是 bind,导入序号 0 指向 _printf,next=2;第三项 0x8000000000000001 的序号 1 指向 _malloc,next=0 结束。页头显示 page_start[0]=8。先读 pointer_format,再读位域,才能避免把同样的数字套进错误公式。

链式修正把位置关系编码进待修正的槽位,让加载器沿页内链逐项处理;它仍需页头和导入表,不能理解成完全没有附加元数据。arm64e 等目标还有带指针认证的格式,其位域不同;这里只验证编号 2 的普通 64 位指针,不能用它解码所有 Mach-O。

这里显式用 -fixup_chains 与 -no_fixup_chains 选择两种编码。部署目标和链接器版本会影响默认值,所以应检查实际 load command,而不是靠文件扩展名或“arm64 程序”推断。旧格式还可包含惰性绑定:__la_symbol_ptr 初值指向 __stub_helper,首次调用由 dyld_stub_binder 解析;脚本生成的 x86-64 prog-lazy 可用来直接检查这条结构链。chained fixups 的导入表则在装载修正阶段处理。

dyld shared cache:链接接口不等于磁盘运行库

prog 的依赖写着 /usr/lib/libSystem.B.dylib,并不要求构建机上存在这个运行库。链接器只需知道库的 install name 和导出符号,就可以通过 .tbd(text-based dylib stub)建立绑定。本实验使用的接口样本完整地写在脚本中:

--- !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 ]
...

这个文件是我们构造的教学输入,绝不提供 printf 实现。真实 Apple SDK 的接口文件会有更多导出、平台信息和重导出关系;运行时实现则可能位于 dyld shared cache,而不是同名的独立文件。共享缓存可以预先组织系统库、处理部分绑定和元数据,减少逐个映像的装载工作。因此“文件系统上找不到某条 install name 对应的文件”与“运行时绝对找不到库”不是一回事。Apple Big Sur Release Notes说明了系统库转入共享缓存后的变化。

Linux 上的 ELF .so 常同时充当链接输入和运行库;Mach-O .tbd 把这两个角色明显分开。理解这个区别后,Linux 上的格式检查就能说明 install name 如何记录依赖,而实际装载和共享缓存查找仍属于 Darwin 运行时的工作。

代码签名:在 Linux 核对每一页的散列

LC_CODE_SIGNATURE 给出签名区域的文件偏移和长度。LLD 的 ad-hoc 签名包含 CodeDirectory:声明覆盖范围 codeLimit、散列算法、块大小,以及依次对应文件页块的散列值。它没有开发者证书,只能为后续完整性检查提供内容摘要;“散列一致”与“目标系统允许执行”是两件事。

示例 只用 Python 标准库读取 Mach-O load command、签名 SuperBlob 与 CodeDirectory,检查每个 SHA-256 槽。它按文件字段给出的 pageSize 分块,不把 CPU 页大小硬编码成签名块大小。上述 Linux 环境的检查结果:

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

codeLimit=0xc160 即 49504 字节,除以 4096 向上取整为 13。flags=0x20002 表示 ad-hoc 和 linker-signed。prog-nosig 用相同链接命令加 -no_adhoc_codesign 生成,没有签名 load command。LLD 写出这些字段与页面散列的代码位于 LLVM 21.1.8 SyntheticSections.cpp。

再从段内节表找出 __text 的文件偏移,翻转第一个字节,保持原签名:

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]

检查器退出 1,准确指出受损槽,而不是启动一个不能在 Linux 运行的文件去等待信号。这项负例证明链接器产物中的签名覆盖了代码页,也说明在签名之后修改 load command 或代码会使旧散列失效。修复办法是根据最终字节重新生成签名;仅改程序里的一个校验和变量并不能修复 CodeDirectory。

在 Darwin,是否验证页面、遇到无效页面如何处理,还取决于内核和签名政策。Apple silicon 对原生可执行代码的签名要求见 Apple Universal Apps Release Notes,XNU 的缺页与代码签名路径见 vm_fault.c。这解释了为什么文件内容损坏可能影响运行,但本章没有实测 Darwin 的 SIGKILL、缺页触发时机或重签执行结果,不能从离线散列比较推断所有系统的执行政策。

栈展开:__unwind_info 与 __eh_frame

第 8 章的 .eh_frame 用 CFI13 指令为每个函数描述"在每个 PC 处,CFA 怎样算、各个寄存器保存在哪里",再用 .eh_frame_hdr 里的一张有序表按 PC 找到对应的 FDE14。表达能力很强,代价是体积大,查找时还要解释执行一串指令。Apple 观察到绝大多数函数的序言和尾声只有寥寥几种固定形状,于是为它们设计了一种压缩格式,叫 compact unwind(紧凑展开信息):每个函数用一个 32 位的编码描述它的展开方式。

编译器先把编码放进目标文件的 __LD,__compact_unwind,每个函数一条 32 字节的记录:

$ 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

记录依次是:函数起始地址(8 字节,留给一条重定位去填)、函数长度(4 字节)0x40、编码(4 字节)0x04000000、personality 函数指针(8 字节)、LSDA15 指针(8 字节)。编码的含义在 SDK 的 mach-o/compact_unwind_encoding.h 里:高位的 0x0F000000 是模式,0x04000000 是 UNWIND_ARM64_MODE_FRAME,表示函数用一段建立了帧指针链的序言:main 的序言是 sub sp, sp, #0x20、stp x29, x30, [sp, #0x10]、add x29, sp, #0x10,x29 指向保存着调用者 x29 和返回地址的那两个槽位,展开时从那里取回就行;低位的若干比特说明还额外保存了哪几对被调用者保存的寄存器(x19/x20、x21/x22 等)。main 只保存了帧指针和返回地址,低位全是 0。

链接器把所有记录收集起来,按地址排好,压成可执行文件里的 __TEXT,__unwind_info:

$ 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 在 0x4e8,是 FRAME 模式;_add 在 0x528,编码 0x02000000 是 UNWIND_ARM64_MODE_FRAMELESS,没有建立帧,栈大小为 0,返回地址还在 x30 里。这张表同时承担了 ELF 的 .eh_frame_hdr 和大部分 .eh_frame 的工作,而且查找时不用解释任何指令。

编码描述不了的函数怎么办?写一个用 x19 当帧基址的汇编函数,这种形状不在 compact unwind 的几种模式里:

// odd.s:用 x19 当帧基址,compact unwind 的编码表达不了
.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

ldp 执行后,SP 已回到调用者的栈位置,x19 与 x30 也已恢复。下一条 ret 因此必须使用 CFA=SP,而不能继续按 x19+16 计算。三条 CFI 指令描述这次状态变化,不产生机器指令;restore 恢复的是 CIE 的寄存器规则,与前面的 ldp 实际恢复寄存器值是两个层面。这说明展开描述必须覆盖尾声,不能只描述序言与函数体。

再编译一个带 C++ try/catch 的格式样本,在 catch 中引用它;不依赖系统头文件,只声明 printf。链接时的 -undefined dynamic_lookup 保留异常运行库的外部引用,我们只检查 LSDA、personality 与 DWARF16 回退,不在 Linux 执行这份 Mach-O:

// throw.cpp:带 try/catch 的函数需要 personality 和 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

汇编器为 odd 同时生成了 compact unwind 记录和 __eh_frame。最终的 __unwind_info 里,odd(0x580)的编码是 0x03000014:模式 0x03000000 是 UNWIND_ARM64_MODE_DWARF,意思是"去查 __eh_frame",低 24 位 0x14 是它的 FDE 在 __eh_frame 里的偏移,llvm-dwarfdump 显示偏移 0x14 处正是 pc=100000580 的那个 FDE,里面就是第 8 章讲过的 CFI 指令。

main 的编码 0x54000001 拆开是:0x40000000 是 UNWIND_HAS_LSDA,有 LSDA(第 8 章的调用点表,在 __gcc_except_tab 里,对应 ELF 的 .gcc_except_table);0x10000000 是 personality 下标 1,指向 personality 表里的第 1 项(C++ 的 __gxx_personality_v0,通过一个 GOT 槽位引用);0x04000000 是 FRAME 模式;最低位 0x1 是 UNWIND_ARM64_FRAME_X19_X20_PAIR,main 还保存了 x19 和 x20。personality 和 LSDA 在 ELF 里写在 CIE17 的增广数据和 FDE 里(第 8 章的 zPLR),这里它们成了编码里的两个字段,加上一张 LSDA 描述表。

所以 Mach-O 上的展开器先查 __unwind_info,多数函数到此为止,少数函数被转给 __eh_frame。本例最终保留的 __eh_frame 用于这种回退,__unwind_info 提供对应索引,没有使用 ELF 的 .eh_frame_hdr。但缺少 compact unwind 或使用其他链接选项的文件仍可能需要直接搜索 DWARF 信息,不能把本例布局说成所有 Mach-O 的唯一展开路径。第 8 章讲过展开库的 libgcc 实现;macOS 用 LLVM18 的 libunwind,它的 Mach-O 路径就是这样实现的。

PE/COFF

Windows NT 在 1993 年发布时采用了一种新的可执行文件格式,叫 PE(Portable Executable,可移植可执行文件)。它在 COFF 的基础上加了一个面向加载器的头。所以在 Windows 上,目标文件 .obj 是 COFF,可执行文件 .exe 和动态库 .dll 是 PE,两者共用同一套节表、同一个文件头。《程序员的自我修养》第 5 章专门讲了 Windows 的 PE/COFF,第 9 章讲 Windows 下的 DLL;格式的权威定义是微软的 PE Format 文档,下面引用的常量和字段名都出自那里。

不靠 CRT 链接一个 exe

平时用 MSVC 链接一个程序,会默认带上 C 运行时库(CRT),它提供 mainCRTStartup 这样的真实入口和一大堆初始化代码。为了看清格式本身,这里写一个不依赖 CRT 的程序,只调用 kernel32.dll 里的两个函数:

// hello.c:不依赖 CRT 的 Windows 程序,只调用 kernel32 的 ExitProcess
__declspec(dllimport) void __stdcall ExitProcess(unsigned int code);
void __stdcall Sleep(unsigned int ms); // 故意不写 dllimport
static int value = 40;
int *p_value = &value; // 数据里的绝对地址:需要基址重定位
int main(void) {
Sleep(0);
ExitProcess(*p_value + 2);
return 0;
}

链接器要知道 ExitProcess 来自哪个 DLL,靠的是导入库(import library):一个 .lib 文件,格式就是静态库的 ar 归档(第 3 章),但成员不是普通的目标文件,而是一条条"某个符号由某个 DLL 导出"的声明。手头没有 Windows SDK,就用一个模块定义文件(.def)自己生成:

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

lld-link /lib /def:kernel32.def /machine:x64 /out:kernel32.lib 能生成同样的东西。五个成员里,前三个构成导入目录项和两个结尾空项,后两个各对应一个导出符号,各提供 ExitProcess 和 __imp_ExitProcess 两个名字。

编译、链接:

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

/NODEFAULTLIB 不链接任何默认库,/ENTRY:main 把入口设为 main,/SUBSYSTEM:CONSOLE 声明这是控制台程序。3584 字节,比 Mach-O 的 prog 小得多,因为 PE 在文件里只按 512 字节对齐。

这个 exe 仅作为 Linux 上生成的格式样本,不在 Linux 原生执行。本章没有引入 Wine 或 Windows 虚拟机;结构检查、链接器的预期失败与导入目录核对足以覆盖这里提出的文件格式问题。

从 DOS stub 到节表

这一节检查的是链接后的 PE 映像 hello.exe,不是前面的 COFF 输入 .obj。.obj 用节表、符号表和重定位让链接器修补引用;PE 映像还需要入口、映像基址、导入表和加载对齐等信息供加载器使用。因此不能在普通 .obj 的偏移 0x3c 处套用下面的查找方法。

读 PE 时还要分清坐标:file offset 从磁盘文件开头计数;RVA 从内存中映像的起点计数;VA 是实际加载基址加 RVA。节内容中的 RVA 不能直接用作文件切片下标,必须通过覆盖该 RVA 的节描述换算;没有文件内容的零填充部分,也不能换算出可读取的内容字节。Microsoft PE 格式说明分别定义了这些坐标。

把同一个 PE 映像的文件与内存布局放在一起看。文件偏移 0x3c 存放 e_lfanew=0x78;从 0x78 依次经过签名 4 字节、COFF 头 20 字节和可选头 240 字节,到达节表 0x180。节表有五条 40 字节记录,结束于 0x248;它们描述的内容并不紧跟各自的记录,而是分别位于 0x400、0x600 等原始数据区域。

PE 文件头、节内容与 RVA/VA 的对应布局

以导入查找表的 RVA 0x2028 为例:它属于起点为 0x2000 的 .rdata,节内距离是 0x28。该节文件起点为 0x600,所以查找表位于文件偏移 0x628;首选基址为 0x140000000 时,VA 才是 0x140002028。映像换到另一个加载基址后,前两个位置不变,VA 随基址变化。

对一个宽度为 w 的字段,文件读取还必须满足 δ + w ≤ SizeOfRawData,其中 δ = RVA − VirtualAddress;加法溢出和最终文件范围也必须检查。落在节的内存零填充部分,只能得到内存坐标,不能读出相应的文件字节。映像头部中的 RVA 是另一种情况:在 SizeOfHeaders 范围内可对应同值文件偏移,仍需检查文件边界。证书目录则从一开始就使用文件偏移,不参与这套 RVA 换算。

文件开头:

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

最前面是一个 MS-DOS 可执行文件:MZ 是 DOS 程序的魔数,偏移 0x40 开始的十几字节是一段 16 位的 DOS 程序,在 DOS 下运行时用 int 21h 打印 "This program cannot be run in DOS mode." 然后退出。这段叫 DOS stub(DOS 存根)。偏移 0x3c 处的 4 字节 0x78 告诉 Windows 加载器 PE 头在哪里;0x78 处是签名 PE\0\0,紧跟着的是和 .obj 一模一样的 COFF 文件头:机器类型 0x8664、5 个节、时间戳。

llvm-readobj 把剩下的部分解码出来(节选):

$ 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

COFF 头之后是 Optional Header(可选头)。名字是历史遗留:对 .obj 它确实可有可无(OptionalHeaderSize 是 0),对映像文件则必不可少。Magic 为 0x20B 表示 PE32+,即 64 位格式。这里出现了一个贯穿整个 PE 的概念 RVA(relative virtual address,相对虚拟地址):一个地址减去映像的基址。ImageBase 是链接器选定的首选基址 0x140000000,入口在 RVA 0x1000,也就是 0x140001000。DYNAMIC_BASE 允许加载器把映像放到别的基址(Windows 的 ASLR19),对应 ELF 的 PIE(第 5 章);NX_COMPAT 声明映像兼容 DEP(数据执行保护);DEP 依据页面是否具有执行权限阻止取指,并不意味着任何存放数据的页面都绝不可能获得执行权限,ELF 没有整映像的开关,PT_GNU_STACK 只管栈(第 7 章)。

本例可选头最后有 16 项 Data Directory(数据目录),实际项数由头字段指定。多数项是一个“RVA 加大小”,证书表项则使用文件偏移,指向加载器要用的一张表:导入表、异常处理表、基址重定位表、TLS20 目录、IAT 等。ELF 里做这件事的是 .dynamic 的各个 DT_* 项(第 7 章)和 PT_GNU_EH_FRAME 这样的特殊程序头,Mach-O 里是各种 load command。

然后是节表:

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

(从 --sections 的输出整理。)PE 没有"段"这一层:节表同时扮演 ELF 的节头表和程序头表,每个节在内存里按 SectionAlignment(4096)对齐、在文件里按 FileAlignment(512)对齐,权限直接写在节的 Characteristics 里,加载器按节逐个映射。两种对齐不同,文件偏移和 RVA 就不相等(.text 在文件偏移 0x400、RVA 0x1000),这一点和 本例 Mach-O 的段文件对齐方式正好相反。.reloc 带着 MEM_DISCARDABLE,加载器用完就可以丢弃。

节表里没有 .idata。.idata 是导入数据的常见节名,加载器通过数据目录的 RVA 定位导入表,而不依赖这个名字;本例 lld-link 将导入内容并入 .rdata。导入库里的成员节叫 .idata$2、.idata$4、.idata$5、.idata$6,这就是开头提到的 $ 分组规则:链接器把 $ 前面相同的节合成一个输出节,按 $ 后面的字符串排序后依次拼接,$ 及其后缀在输出里消失(PE Format 文档的 Grouped Sections 一节)。目录项在 $2,查找表在 $4,地址表在 $5,名字在 $6,几个 DLL 的导入库各贡献一份,排序后所有目录项自然排在一起,最后由 __NULL_IMPORT_DESCRIPTOR 补上全零的结尾项。ELF 的 .init_array.N(第 14 章)也按后缀排序,但那是链接器对这类节名的专门处理,PE 把规则写进了格式。

导入表:IAT、ILT 与 _imp

导入表的内容都在 .rdata 里:

$ 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)
}

RVA 0x2000 开始是一个 20 字节的导入目录项 IMAGE_IMPORT_DESCRIPTOR,五个 32 位字段:查找表的 RVA 0x2028、时间戳 0、转发链 0、DLL 名字的 RVA 0x206e(指向字符串 kernel32.dll)、地址表的 RVA 0x2040。之后是 20 个零字节,表示目录结束。

0x2028 处是 ILT(Import Lookup Table,导入查找表),每项 8 字节:0x2058、0x2066、0。它们指向 Hint/Name 表项:2 字节的提示(建议的导出表下标,这里是 0)加以零结尾的函数名,0x2058 处是 ExitProcess,0x2066 处是 Sleep。0x2040 处是 IAT(Import Address Table,导入地址表),在文件里和 ILT 内容完全一样。加载器处理这个 DLL 时,按 ILT 里的名字逐个在 kernel32.dll 的导出表里查到地址,覆盖写进 IAT 的对应位置;ILT 保持原样,留着以后再用。IAT 就是 PE 的 GOT。

PE32+ 导入目录、ILT 与 IAT 的文件和内存视图

图中每个目录字段占 4 字节,但 PE32+ 的 ILT/IAT 项各占 8 字节。字段宽度由结构定义,不能因为两者都存地址就统一成 8 字节。按名字导入时,表项低位给出 Hint/Name 的 RVA;按序号导入则置最高位,并在低 16 位保存序号,不能把这种表项当作字符串 RVA。零项结束的是当前 DLL 的查找表,另一个全零的 20 字节目录项结束的是 DLL 目录,两个层级不能混淆。

__imp_ExitProcess 标识的是 IAT 槽的位置。加载前,槽中用于查找的 0x2058 是 RVA;解析导入后,槽中变成实际函数 VA,而槽自身仍位于映像 RVA 0x2040。一次间接调用先按 RIP-relative 位移找到槽,再读取槽中的地址跳转;它不是直接调用 Hint/Name 字符串所在的位置。

再看代码怎样用它:

$ 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

两个函数走了两条路。ExitProcess 声明了 dllimport,编译器在目标文件里引用的是 __imp_ExitProcess(hello.obj 的重定位里看得到),这个名字就是 IAT 里那一项的地址,于是代码直接 call *IAT[0],一次间接调用到达目标,相当于 ELF 用 -fno-plt 编译时经 GOT 调用。Sleep 没有声明 dllimport,编译器以为它就在本模块里,生成一条普通的 call Sleep;导入库里的 Sleep 成员定义了一个同名的 import thunk(导入桩),链接器把它放在 .text 的末尾 0x140001030,内容是 jmp *IAT[1]。MaskRay 的 Linker notes on PE/COFF 说它 "is like a PLT entry in ELF"。区别在于 PE 的导入桩默认不做惰性绑定,IAT 在加载时全部填好;需要推迟加载的 DLL 要用 /DELAYLOAD 显式声明,链接器为它另外生成一套延迟导入表,由数据目录里的 DelayImportDescriptor 指向。

对函数来说,少写 dllimport 只是多跳一次。对数据就不行了:导入库不会为一个变量生成"桩",代码里一条读 var 的 mov 指令必须改成先从 __imp_var 读出地址再访问,这只有编译器能做。所以 MSVC 要求引用 DLL 里的变量时必须在声明上写 dllimport,否则链接报错;MinGW 另有一套运行时伪重定位的办法补救(同见 MaskRay 的文章)。ELF 用第 7 章的 copy relocation 或经 GOT 访问解决同一个问题,编译器和链接器都不需要使用者在源码里标注。

把导入库拿掉,报错直接点出了这两种名字:

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

导入表里写的是 kernel32.dll 这个名字,加载器只在 kernel32.dll 的导出表里找 ExitProcess。导入符号在链接时就绑定到了具体的 DLL,和 Mach-O 的 two-level namespace 一样,和 ELF 那种"记下名字,运行时在全局范围里找"的做法不同。Windows 上同样没有现成的符号插入,要替换另一个 DLL 里的函数,得改写 IAT 或者给目标函数打补丁。

基址重定位

p_value 是一个指向 value 的指针,看 .data:

$ 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
}
]

value(40,即 0x28)在 0x140003000,p_value 在 0x140003008,链接器已经把按首选基址算出的绝对地址 0x140003000 写了进去。如果加载器把映像放在了别的基址,就要修正这 8 个字节,.reloc 节记着所有这样的位置。它的格式是一组块,每块覆盖一个 4KB 页:块头是页的 RVA(0x3000)和块的总字节数(0xc),后面是若干个 16 位的项,高 4 位是类型,低 12 位是页内偏移。0xa008 拆开是类型 0xA(IMAGE_REL_BASED_DIR64,修正一个 64 位地址)和偏移 0x008;最后的 0x0000 是类型 0(ABSOLUTE),什么都不做,只是把块补齐到 4 字节对齐。

对照第 7 章的 R_X86_64_RELATIVE:在本系列的 ELF64 RELA 示例中,位置与显式加数分别记在记录中,加载器把 B + A 写到目标字段。目标槽原来为零还是预填加数,都不改变这条计算规则;记录宽度为 24 字节,也不是所有 ELF 重定位编码的共同宽度。PE 按首选基址链接,值已经写在原处,基址重定位只记位置,每个位置 2 字节;加载器算出"实际基址减首选基址"的差,加到每个位置上,如果映像恰好落在首选基址,什么都不用做。第 7 章提到的 DT_RELR 用位图压缩一串相邻的 RELATIVE 位置,前提也是值放在原处、表里只记位置。Mach-O 的 chained rebase 连位置表也省了。

COMDAT 的选择规则,以及 Mach-O 怎样去重

现在回到第 14 章留下的问题,用的是第 14 章同一个 inline 函数:

// common.h:和第 14 章同一个 inline 函数
inline int twice(int x) { return 2 * x; }

a.cpp 和 b.cpp 都包含它并调用 twice。先看 COFF:

$ 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

twice 被放进 5 号节,一个带 IMAGE_SCN_LNK_COMDAT 标志的 .text。COFF 的 COMDAT 是以节为单位的:节的符号带一条辅助记录,写着节的长度、内容的校验和,以及选择规则 Selection。twice 的展开信息 .xdata 和 .pdata 也各是一个 COMDAT 节,选择规则是 Associative,Number: 5 表示"5 号节留下我就留下,5 号节被丢弃我也跟着丢弃"。ELF 用一个节组把函数和它的附属节捆在一起(第 3 章),COFF 用"关联到另一个节"来表达。

PE Format 文档的 COMDAT Sections 一节列出下面六种选择规则;不能把它当作所有工具链扩展的穷尽列表,例如 LLVM 的 COFF 常量还定义了 NEWEST。这里的实验只覆盖文档列出的规则:

1 IMAGE_COMDAT_SELECT_NODUPLICATES 出现多份就报重复定义
2 IMAGE_COMDAT_SELECT_ANY 任取一份,其余丢弃
3 IMAGE_COMDAT_SELECT_SAME_SIZE 任取一份,但各份大小必须相同
4 IMAGE_COMDAT_SELECT_EXACT_MATCH 任取一份,但各份内容必须逐字节相同
5 IMAGE_COMDAT_SELECT_ASSOCIATIVE 跟随另一个 COMDAT 节的去留
6 IMAGE_COMDAT_SELECT_LARGEST 取最大的一份

EXACT_MATCH 的原文是 "If all definitions do not match exactly, a "multiply defined symbol" error is issued.",lld-link 的实现(lld/COFF/InputFiles.cpp 第 780–787 行)直接比较两节的内容,注释说 link.exe 也只比较内容、不管对齐等属性。ELF 的 GRP_COMDAT 只有一种行为,相当于这里的 ANY:保留第一份,后面的整组丢弃,内容不同也不检查(第 3 章、第 14 章的 ODR21 违规就是这样悄悄发生的)。COFF 让编译器为每个 COMDAT 选择检查的严格程度。用汇编写两对内容不同的 COMDAT 试一下,一对用 ANY(汇编语法里叫 discard),一对用 EXACT_MATCH(same_contents)。exact2.s 和下面的 exact1.s 只差在 .long 2,anyc1.s、anyc2.s 把 same_contents,cval 换成 discard,aval,内容同样是 1 和 2,use.c 返回 cval + aval:

# exact1.s:一个 EXACT_MATCH 的 COMDAT,内容是 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 的两份内容不同,链接器默默留下了第一份(aval 是 1);EXACT_MATCH 的两份不同,链接器报重复定义。不过编译器为 inline 函数和模板实例生成的都是 ANY,twice 就是一例,C++ 的 ODR 违规在 Windows 上通常也一样不会被发现。

Mach-O 没有节组,也没有 COMDAT 节。它的办法要从 atom 说起:

$ 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

twice 和 fa 挤在同一个 __text 节里,没有单独的节。它的符号是一个 weak definition(弱定义),汇编里写作 .weak_definition,在 nlist_64 的描述字段里是 N_WEAK_DEF 标志。因为文件声明了 MH_SUBSECTIONS_VIA_SYMBOLS,链接器可以把 __Z5twicei 到下一个符号之间的字节切出来,当作一个独立的 atom。几个目标文件各有一个同名的弱定义 atom,链接器只留一个,其余的 atom 连同它们的 compact unwind 记录(记录通过重定位指向那个 atom)一起丢掉。ELF 的 COMDAT 选择以节组为单位,可以捆住多个节;不过 .eh_frame 等共享元数据还有专门的记录级处理,不能说所有展开信息都必须入组。本例 Mach-O 以符号边界划分的 atom 为单位选择代码,再关联处理 compact unwind 记录;并不是任何名字都天然代表一个可独立删除的单元。

链接后:

$ 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

只剩一份 twice,并且它仍然是一个导出的弱定义。头部多了两个标志:WEAK_DEFINES 表示这个映像导出了弱定义,BINDS_TO_WEAK 表示它引用了弱定义。dyld 看到这两个标志,会在启动时把所有映像里同名的弱定义统一到一份上。cprog 自己对 twice 的调用也经过 GOT,chained import 的库序号为 −3(weak),表明这是弱定义合并查找:

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

C++ 要求同一个具有外部链接的 inline 函数在各翻译单元中具有同一地址,跨共享库时 ELF 靠符号插入做到这一点(第 7 章)。默认的 two-level namespace 限制了普通符号的扁平查找,Mach-O 只好为弱定义单独留出这条扁平查找的通道。

TLS:同一个名字怎样指向当前线程的副本

普通全局变量的访问只需找到一个进程地址;第 9 章的线程局部变量还要选择当前线程。文件格式必须表达初始化模板,编译器与运行时则共同完成“模块中的哪个变量、哪个线程的副本”这两次定位。换成 Mach-O 或 PE,这个问题仍在,只是协议不同。

示例 只保留一次读取,让地址计算过程直接出现在反汇编里:

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

Mach-O:先取得描述符,再取得变量地址

在本机 Linux 上选择 arm64 Mach-O 目标,编译并查看:

$ 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 中的四字节 5 是初值模板;__thread_vars 的 24 字节却不是变量本身,而是一个线程局部变量描述符。Apple 的 mach-o/loader.h 将它定义为三个字段:函数指针 thunk、key、offset,本例各占八字节。结构定义。

前两条指令经 TLV 指针取得描述符地址,放进 x0。随后从描述符首项取出函数指针,在 blr x8 处调用,把描述符作为参数。返回的 x0 才是当前线程中 t 的地址;最后的 ldr w0, [x0] 读取整数。不能把第一次 ldr 得到的描述符地址当成 TLS 数据地址。

目标文件的重定位进一步说明了谁来接上运行时:__thread_vars 偏移 0 引用 __tlv_bootstrap,偏移 16 引用初值符号 _t$tlv$init。链接器和 dyld 处理这些记录,把文件中的模板、描述符及运行时访问协议连接起来。它与 ELF TLSDESC 都使用描述符参与求地址,但结构、重定位和调用约定各有定义,不能互相替换。

PE/COFF:模块索引加线程数组

同一个文件按 x64 Windows 的 MSVC ABI 编译:

$ 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

变量的四字节初值进入 .tls$ 输入节。指令先读取 _tls_index,取得本模块的 TLS 索引,再从当前线程的线程环境块(TEB)取得 TLS 指针数组。这里 %gs:0x58 是本次 x64 代码的访问位置;数组项是八字节指针,所以第三条指令按索引乘 8 取出本模块在线程中的数据块。最后一条重定位 SECREL 填入 t 在所属节中的偏移,读取对应整数。

索引不是编译器预先分配的固定编号。PE 的 TLS 目录告诉加载器初值模板的位置、补零大小,以及把模块索引写到哪里;运行时为线程准备数据,代码再通过索引访问。TLS 目录中的几个地址字段是 VA,并非普通数据目录常用的 RVA,移动映像基址时还要相应修正。Microsoft PE Format:TLS。

这里验证的是 COFF 目标文件及访问序列,没有把它与 Windows 运行时链接并执行。前面那个省略 CRT 的 hello.exe 也没有 TLS 变量,不能拿它的成功链接证明 TLS 支持已经完整。

三种格式对照

下表对应本章实际使用的 x86-64 ELF、arm64 Mach-O 和 x64 MSVC ABI 的 PE/COFF 组合,数值大小与默认行为不能推广到所有架构或链接选项。ELF 的 REL/RELA/RELR、musl22 的立即绑定以及 Mach-O 的扁平查找模式,都是前文已经出现的例外。

约定ELF(本章 x86-64)Mach-O(本章 arm64)PE/COFF(本章 x64 MSVC ABI)
目标文件ET_REL,节头表MH_OBJECT,本例无名 LC_SEGMENT_64 包含各节;符号边界可参与细分COFF .obj,普通头部以机器类型开始,后接节表
加载布局节头表与程序头表分别表达链接、加载视图段命令包含节;本例 __PAGEZERO 保留低 4 GB节表包含 RVA 和权限;本例文件/内存对齐为 512/4096 字节,实际以头字段为准
符号名C 原名;本例 C++ _Z3addiiC 前加 _;本例 C++ __Z3addiix64 C 原名;MSVC C++ ?add@@YAHHH@Z,全局变量也修饰
重定位本例 Elf64_Rela 24 字节;另有 REL、RELR 等编码普通记录 8 字节;加数可在原处,arm64 指令也可配 ADDEND 记录普通记录 10 字节;加数在原处,REL32 相对字段结束处 P+4
重复实体GRP_COMDAT 以节组选择;GNU ld/LLD 常保留先遇到的一组弱定义与 atom 选择,展开记录另作关联处理COMDAT 节按 ANY、EXACT_MATCH、ASSOCIATIVE 等规则选择
动态查找常见全局搜索范围允许符号插入,受可见性、版本和加载方式约束默认 two-level,另有 flat namespace 和弱定义合并导入按 DLL 组织;转发导出还可将解析引到另一 DLL
库定位DT_NEEDED 常保存 soname,结合 RUNPATH 等搜索规则install name,结合 @rpath、@loader_path 等展开DLL 名及 Windows 的加载搜索规则
动态修正.rela.dyn 等记录含 RELATIVE、GLOB_DAT、JUMP_SLOT;也可用 RELR本例 chained fixups 将 rebase/bind 信息编码进指针链;也有旧编码.reloc 记录基址修正位置;加载器解析导入并填写 IAT
惰性绑定常见 glibc23 PLT 支持惰性绑定,-z now 可关闭;musl 采用立即绑定旧机制有 lazy 指针与 helper;本例 chained fixups 在启动阶段绑定普通导入启动时绑定,延迟导入由 /DELAYLOAD 等显式启用
TLSPT_TLS,GD/LD/IE/LE、TLSDESC 等访问模型初值模板、__thread_vars 描述符及运行时求地址函数TLS 目录、模块索引与线程中的指针数组;本例经 %gs:0x58 访问
展开.eh_frame,可配 .eh_frame_hdr 索引compact unwind 与 DWARF 回退;本例由 __unwind_info 索引本例 .pdata 函数表与 .xdata 展开码
签名通用 ELF ABI 没有统一要求的代码签名结构,系统可另加完整性机制本例 LC_CODE_SIGNATURE,Apple silicon macOS 原生可执行文件要求签名可选 Authenticode,Certificate Table 以文件偏移定位

Authenticode 是 Windows 的代码签名,由 signtool 在链接之后加上,放在文件末尾;数据目录的 CertificateTable 项存的是文件偏移而不是 RVA,因为签名不会被加载进内存。本文没有实测它。

动态查找的默认范围是三者的重要分歧:ELF 的常见模型允许在搜索范围中插入符号,Mach-O 默认记录提供符号的库,PE 的导入按 DLL 组织。不过 Mach-O 也有扁平查找与弱定义合并通道,ELF 同样有局部绑定和隔离命名空间。比较的是默认契约及其例外,不能据此断言只有某一种格式支持全局查找。

WebAssembly

WebAssembly24 也有链接器 wasm-ld:用本机 LLVM 21 的 clang --target=wasm32 -c 生成的 .o 本身就是一个 wasm 模块,符号表和重定位放在 linking、reloc.CODE 这样的 custom section(标准允许的、带名字的自定义数据节)里,约定见 tool-conventions/Linking.md。

文件格式怎样影响链接器的边界

这一章用到的 ld64.lld、lld-link 和一直在用的 ld.lld 都出自 lld。MaskRay 在 lld 22 ELF changes 开头说,这几种格式差别很大,每个端口都必须遵循所在平台系统链接器的约定:去重的单位是节还是 atom,加数在记录里还是在原处,要合成的是 .dynamic、chained fixups 还是导入表,签名要不要等全部内容写完再散列一遍。

一个链接器要同时支持几种格式,共用的那一层抽象该画在哪里,节、符号、重定位这三个概念够不够用?每种格式的产物又拿什么来验证,和平台的参考链接器逐字节比较,还是放到真实系统上运行?这是第 16 章要讲的链接器工程。

练习

以下练习使用 run-all.sh 在 Linux 生成的文件,不读取宿主机的 /bin/ls,也不需要 dyld、codesign 或 Windows 运行时。先运行脚本,再进入它打印的目录。

练习一,观察。(1) 对 main 和 main-flat 分别检查 llvm-nm -m、llvm-otool -L 与 llvm-objdump --macho --chained-fixups。hello 来自哪个库?flat 模式怎样表示“不指定某一个库”?(2) 检查 x86-64 prog-lazy 的 __la_symbol_ptr、__stub_helper 和 --lazy-bind 输出:指针初值指到哪里,helper 压入的整数代表什么?

练习二,手算。(1) 本例 rapp 的 _addr_of_elem 在 P=0x1000003ec,下一条 add 在 0x1000003f0;_table 的地址是 S=0x100004008,加数 A=12。_call_it 的 b 在 0x100000408,_callee 在 0x10000040c。目标文件的原指令字依次为 0x90000000、0x91000000、0x14000000。先算出重定位写入后的指令,再用 llvm-objdump -d rapp 检查 LOH 是否进一步松弛了它们。(2) ptrs2 使用 DYLD_CHAINED_PTR_64,三个指针位于 0x100004008、0x100004010、0x100004018,分别指向 counter、counter+1 和 printf,导入表只有 printf。预测三个槽的原始 64 位整数,再检查 __data 与 chained-fixups。为什么换成编号 6 的 OFFSET 格式时第一、第二项会不同?

练习三,改坏。(1) 比较 main 与 main-norpath 的依赖命令和 rpath 命令。若主程序在 sub/ 而库在父目录,应在重链接时使用哪个 rpath?为什么仅检查 LC_LOAD_DYLIB 不够?(2) 用 sign/check-signature.py 找到并翻转 prog 的代码字节,预测哪个散列槽不匹配并验证。为什么不能把检查器的退出 1 等同于 Darwin 程序的某个信号退出码?(3) 把 machoinfo/check-linux.sh 生成的薄 Mach-O 截断为 64 字节,解析器应拒绝哪一层结构?

解析器需要完成三层检查:fat 文件目录使用大端字段,薄映像的 load command 使用小端字段,命令中的字符串偏移必须落在文件边界内。把这三层校验分开,才能区分目录损坏、命令损坏和字符串越界。

参考

答案

练习一答案

(1) main 中 _hello 与 _only_a 的普通符号表标明 from liba;LC_LOAD_DYLIB 列出实际依赖顺序,chained import 记录相应库序号。main-flat 改用特殊序号 −2(flat namespace),其符号表也不再为这两个引用标 from liba。这证明两者请求不同的运行时查找规则,而非只改变一个显示选项。

(2) 原生工具对 prog-lazy 的输出节选:

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>

小端指针是 0x100000634,正好指向 helper 的 pushq $0。0 是这个唯一符号的 lazy-bind 字节码起始偏移,供公共 helper 和 dyld_stub_binder 找到相应修正操作;lazy-bind 表显示操作要写入 0x100003000 并查找 libSystem 的 _printf。ELF PLT 常携带重定位索引,这里携带字节码偏移,都是把首次调用与待解析记录关联起来。这是静态协议检查,不包含首次调用的实际执行。

练习二答案

(1) 先分清页差与页内偏移:

S + A = 0x100004014
ADRP 页差 = (0x100004000 - 0x100000000) / 4096 = 4
immlo = 0, immhi = 1
ADRP = 0x90000000 | (1 << 5) = 0x90000020
ADD 页内立即数 = 0x14
ADD = 0x91000000 | (0x14 << 10) = 0x91005000
B 位移 = (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>:

直接分支与手算一致;前两条却变成了 adr 和 nop。这是本章前面介绍的 LOH 松弛:S+A-P = 0x3c28,落在 ADR 的范围内,immlo=0、immhi=0xf0a,故 0x10000000 | (0xf0a << 5) = 0x1001e140。第二条替换为 NOP 0xd503201f。手算重定位值只是中间结果,最终机器码还必须计入链接器允许的松弛;这份输出正好把两个阶段区分开了。

(2) next 的单位为 4 字节,相邻槽差 8 字节,所以前两项为 2,末项为 0。格式 2 的 rebase target 保存首选虚拟地址,而非映像内偏移:

0x4008: 0x0010000100004000
0x4010: 0x0010000100004004
0x4018: 0x8000000000000000

第一、第二项是 rebase;第二项的加数 4 已经加进 target。第三项是 bind,ordinal=0、addend=0、next=0。实际字节:

100004000 28000000 00000000 00400000 01001000
100004010 04400000 01001000 00000000 00000080

如果选择 DYLD_CHAINED_PTR_64_OFFSET,target 应是 0x4000 与 0x4004,前两项就少了首选映像基址对应的 0x100000000。因此解码必须先检查 pointer_format。bind 的导入序号与导入记录里的库序号也是两层不同的索引,不能混为一谈。

练习三答案

(1) main-norpath 仍然依赖 @rpath/liba.dylib,但缺少 LC_RPATH。主程序移到 sub/、库留在父目录时,重链接应指定 -rpath @loader_path/..。这只修复文件表达的搜索路径,不能代替目标系统上的权限和加载验证。修改签名覆盖范围内的 load command 后,还必须更新对应签名。

(2) 本例 __text 文件偏移为 0x4e8,CodeDirectory 块大小为 0x1000,因此受影响槽为 floor(0x4e8/0x1000)=0。检查器准确返回 mismatches=[0] 并退出 1;原文件是空列表,关闭 ad-hoc 签名生成的文件则没有 LC_CODE_SIGNATURE。退出 1 是这个 Python 检查器的约定,不是执行 Mach-O 的结果。Darwin 页验证和进程处置需要其内核、映射与签名政策参与。

(3) parser 在读取声明的 load command 区域时发现长度超过文件尾,退出 1,输出:

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

它应在进入命令内部的段节、路径、入口字段之前拒绝文件,不能因为头部魔数正确就继续无界读取。fat 文件还要先检查每个 slice 的偏移与大小,再把薄文件解析器限制在该 slice 的边界内。

附录:术语与工具

  1. ELF — ELF(Executable and Linkable Format)规定目标文件、可执行文件与共享对象的结构。通用规则见 gABI,架构相关的调用约定和重定位规则见对应 psABI。 官方文档。 ↩

  2. COFF — COFF(Common Object File Format)是一族目标文件格式的名称。Windows 的 COFF 对象与 PE 映像有各自的头和表,不能直接套用 ELF 的节与段规则。 官方文档。 ↩

  3. PE — PE(Portable Executable)是 Windows 使用的映像格式,与 COFF 目标文件格式相关。它与 ELF 在导入导出、加载及重定位组织上各有约定。 官方文档。 ↩

  4. COMDAT — COMDAT 让工具链表示可供择一保留的重复定义组。ELF 通过 section group 与签名表达相关关系;选择副本时,组内关联内容需要一致处理。 官方文档。 ↩

  5. ABI — ABI(Application Binary Interface)规定二进制组件如何协作,包括调用约定、数据布局和文件格式等。它约束编译结果之间的交接,比源码层面的 API 更靠近机器。 官方文档。 ↩

  6. Clang — Clang 是 LLVM 项目中的 C、C++ 等语言前端及驱动程序。它通常使用集成汇编器,但仍需调用链接器;最终使用哪个链接器取决于目标平台和配置。 官方文档。 ↩

  7. PLT — PLT(Procedure Linkage Table)是一组函数调用跳板,常与 GOT 和动态符号绑定配合。PLT 存放指令,不能简单理解成另一张纯地址表。 官方文档。 ↩

  8. nm — nm 列出目标文件的符号。字母标记概括符号所在节或绑定等属性;需要判断准确语义时,应继续对照 ELF 符号表字段。 官方文档。 ↩

  9. PIE — PIE(position-independent executable)是可以在不同加载基址运行的可执行文件。生成 PIE 需要编译与链接选项配合;static-PIE 还需要自身的启动路径完成必要重定位。 官方文档。 ↩

  10. RELRO — RELRO(RELocation Read-Only)将需要重定位、但之后不应继续写入的区域转为只读。ELF 的 PT_GNU_RELRO 描述该范围,实际保护由启动路径实施。 官方文档。 ↩

  11. objdump — objdump 可反汇编机器码,也能显示节和重定位信息。GNU 与 LLVM 版本的排版、指令写法和默认选项并不完全相同,本文命令保留具体工具名。 官方文档。 ↩

  12. LLD — LLD 是 LLVM 项目的链接器。ELF 平台通常通过 ld.lld 调用;lld-link 则提供兼容 Windows 工具链的接口。它与负责处理源码的 Clang 是不同组件。 官方文档。 ↩

  13. CFI — 在栈展开语境中,CFI 指 Call Frame Information,描述怎样从当前帧恢复调用者状态。安全加固语境中的 Control-Flow Integrity 也缩写为 CFI,两者需要按上下文区分。 官方文档。 ↩

  14. FDE — FDE(Frame Description Entry)关联一段代码的地址范围与栈展开指令。移动代码或重新组织 .eh_frame 时,链接器必须同步更新相关地址和记录间的引用。 官方文档。 ↩

  15. LSDA — LSDA(Language-Specific Data Area)保存语言异常处理所需的额外信息,例如异常区域和处理动作。展开器与语言 personality 函数分工使用这些数据。 官方文档。 ↩

  16. DWARF — DWARF 是调试信息格式,描述源码行、类型、变量与机器位置的关系。它可以随 ELF 保存,但不是 ELF 符号表的别名。 官方文档。 ↩

  17. CIE — CIE(Common Information Entry)保存一组栈展开记录共用的规则和编码信息。FDE 引用它,再描述特定函数地址范围内的规则变化。 官方文档。 ↩

  18. LLVM — LLVM 是一组编译器与工具链项目的名称,包括优化基础设施、目标代码生成和相关工具。Clang、LLD 与 LLVM IR 各有职责,不能互作同义词。 官方文档。 ↩

  19. ASLR — ASLR(Address Space Layout Randomization)随机化进程中某些区域的位置。它是操作系统的加载策略,PIE 等产物形式为相应地址变化提供条件。 官方文档。 ↩

  20. TLS — TLS(Thread-Local Storage)让不同线程拥有同一变量的独立实例。链接器描述初始化模板并处理寻址模型,运行时负责为线程建立实例。这里不是网络协议 Transport Layer Security。 官方文档。 ↩

  21. ODR — ODR(One Definition Rule)是 C++ 对定义一致性的要求。链接器选择了一个同名实例,并不能证明不同翻译单元中的定义满足语言规则。 官方文档。 ↩

  22. musl — musl 是 Linux 的一种 C 标准库实现,提供 printf 等库函数及运行时支持。本系列在需要分析或链接较小的静态运行库时使用它;普通 Linux 服务器不一定预装 musl。 官方文档。 ↩

  23. glibc — glibc(GNU C Library)是许多 Linux 发行版默认使用的 C 库。库的启动文件、共享库和动态链接器共同参与程序构建与运行。 官方文档。 ↩

  24. WebAssembly — WebAssembly 定义一种可验证的二进制指令格式及执行语义。其链接对象与原生 ELF 的地址、重定位和运行环境不同,需要专门的工具链约定。 官方文档。 ↩