[链接器的世界-原理篇16] 链接器工程:从能链接,到值得信赖
节、符号和重定位已经足以描述一个小链接器的主要工作。把输入换成几万个目标文件,要求多线程执行、反复构建得到同样的结果,再把产物交给不同版本的内核和运行时,问题就变了:正确的局部算法,怎样组成一个可验证、可维护的完整工具?
格式差异首先会影响实现的边界。ELF1、Mach-O 与 PE/COFF 都要组织代码和修补引用,但各自对符号、布局和加载的约定并不相同。把它们塞进同一套抽象,也可能把本来直接的操作变复杂。
lld 对这项取舍给出了一个具体答案:它的各个端口都是对应格式的原生链接器,"设计相同,但几乎不共用代码";几种格式差别够大,抽象层的复杂度和运行时开销都不值得,去掉抽象层让实现大大简化了(lld: The ELF, COFF and Wasm Linkers)。GNU2 ld 背后的 BFD 则保留了支持多种目标格式的通用层。两种组织方式面对的维护成本不同,也影响性能;是否值得共享代码,要落实到各阶段实际操作的数据上。
以 ELF 链接为例,忽略需要反复调整布局的情形,一条简化的执行路径是:
- 读入所有输入:目标文件、静态库、共享库(第 2、3、7 章),还有可能出现的链接器脚本(第 5、10 章)。
- 符号解析,必要时从静态库里按需拉出成员(第 3 章)。
- 从入口等根出发做标记,删掉没人引用的节(第 5 章)。
- 将输入节组织成输出节,合并允许共享的字符串;扫描保留内容的重定位,收集 GOT3、PLT4 与 TLS5 的需求,并确定合成节的大小(第 4、7、9 章)。
- 给每个输出节分配地址和文件偏移(第 5 章)。
- 在已分配的范围内拷贝内容,用
S + A − P这类公式填写重定位,并填充.dynamic、.eh_frame_hdr等合成节中依赖最终地址的字段(第 4、7、8 章)。 - 计算 build-id(第 11 章),写盘。
这种分阶段处理的结构称为阶段流水线(pass pipeline)。阶段边界规定哪些信息已经稳定、哪些工作可以并行;真实实现并不总是单向经过一次。RISC-V6 松弛可能改变节大小,跳转桩可能增加布局内容,LTO7 又会生成新的目标文件,相关阶段必须允许重新计算。
这里的“合成节”指没有完整输入副本、由链接器生成的输出内容。它也占用文件或内存空间,必须参与布局。确定其大小与填写其字节是两个步骤:GOT 项数决定预留多少个地址槽,最终符号地址决定每个槽写什么;.eh_frame_hdr 的索引项数决定表的范围,函数与 FDE 的最终地址决定索引项的值。只有大小已确定,后面的地址才有稳定的依据。
假设一个 ELF64 输出中,GOT 占据 [0x402000, 0x402010),后面紧接一块只读常量。这两个 GOT 槽各占 8 字节,常量起点为 0x402010。如果地址确定后又增加一个槽,GOT 就要占据到 0x402018,常量也必须移动:
| 布局输入 | GOT 范围 | 常量起点 | 已填写的常量引用 |
|---|---|---|---|
| 两个槽,每槽 8 字节 | [0x402000, 0x402010) | 0x402010 | 可按该地址填写 |
| 增加第三个槽 | [0x402000, 0x402018) | 0x402018 | 必须重新计算 |
这里忽略常量的额外对齐要求,专门展示大小变化如何传播。真实布局还必须重新检查对齐、范围和段边界。后续阶段若发现新的需求,可以要求重新布局;它不能继续把旧地址称为最终地址。
可以用 main.o 调用 add.o 中的 add 来检查这些依赖。符号解析完成后,链接器知道调用指向哪份定义,但还不知道该定义的最终地址。布局完成后,才同时知道 add 的地址 S 和调用字段的位置 P,能够填写 S + A − P。如果另一条重定位要求增加 GOT 项,相关节的大小又会影响布局;必须先收集这类需求,再把地址当成最终结果。
| 阶段完成后稳定的信息 | 下一步允许做什么 | 还不能据此做什么 |
|---|---|---|
| 解析输入元数据 | 验证记录边界,建立输入节与符号关系 | 把输入偏移当成输出地址 |
| 选择定义、确定保留内容 | 扫描有效引用,统计合成表的需求 | 填写依赖最终地址的重定位 |
| 确定大小并完成布局 | 在指定输出范围中拷贝、修补字节 | 在不重新布局的情况下增删内容 |
| 所有会影响散列的字节已确定 | 按选定规则计算 build-id | 把早先算出的散列用于已经改变的内容 |
并行化的是阶段内相互独立的工作,不是取消这些先后依赖。两个线程各处理一个输入节,可以同时读输入;只有输出范围已经确定且互不重叠时,它们才适合同时写出。后面讨论的原子标记、固定输出顺序和阶段边界,分别处理共享状态、确定性和依赖问题。
这些阶段把两类约束连在一起:数据量要求减少遍历和复制,格式与运行时契约却要求每次省略都有依据。局部公式的正确性、阶段之间的不变式以及消费者的解释规则,共同决定完整工具是否可靠。
正确性:错误总在远处爆炸
链接器输出的文件要交给内核、动态加载器、展开库和调试器解释。正确性不只取决于自己的算法有没有算完,还取决于这些消费者是否得到约定的结构和行为。
第 1 章讲过 glibc8 2.41 让一批 Steam 游戏和 Discord 打不开的事,第 7 章看过它在 ld.so 里的机制。那个"需要可执行栈"的 PT_GNU_STACK 是链接器多年前写下的,依据只是某个输入文件缺了 .note.GNU-stack。链接器当年没有报错,程序当年也运行正常,直到下游的 ld.so 改了规则。
另一个例子在第 8 章的地盘上。mold 2.42.1(2026 年 9 月)列在首位的修复是这样一个 bug:编译器把一个函数的冷热部分拆开存放,拆出去的冷段后来被完全优化掉,却留下一条覆盖长度为零的 FDE9,它的起始地址正好和下一个函数的 FDE 相同。展开库在 .eh_frame_hdr 这张按地址排序的表里二分查找时,可能命中那条空记录,误以为这一层没有异常处理器,一个本该被 catch 住的异常就让程序直接终止了(release notes、修复提交)。
两个故障有共同的形状:链接成功,主路径也能跑,出错的是某个平时不走的路径,而读到错误字节的组件和链接器毫无关系。"链接没报错""跑一下 hello world 正常"这类测试,几乎抓不到链接器最危险的那一类 bug。
bug 对 bug 兼容
链接器也没有一份能逐条对照的完整规范。gABI10 和各架构的 psABI11(第 2 章)规定了格式和重定位公式,但大量行为只存在于现有实现里,而现有实现本身也不完美。缺少 .note.GNU-stack 时,历史版本的 GNU ld 在 x86 上曾推断输入需要可执行栈,而 AArch64、RISC-V、LoongArch 后端不采用同一默认值(见 elfnn-aarch64.c 的 elf_backend_default_execstack)。默认值还会随链接器版本改变,不能把旧版行为当作格式规范。当前实测的 GNU ld 2.46 与 LLD12 21 对下面的混合输入都给出不可执行栈;确实需要可执行栈时应显式声明。GNU ld 也提供 --error-execstack,让相应诊断成为构建失败。
哪种行为更安全,答案很清楚。但一个想替换 GNU ld 的链接器必须回答另一个问题:有没有软件依赖了 GNU ld 的那种行为?Rust 官方博客在切换默认链接器时直说,lld 和 GNU ld 并不是 bug 对 bug 兼容的(Rust Blog)。前面讲的 lld 静态库语义就是有意不兼容的一处,需要在兼容性测试中识别依赖传统扫描顺序的构建,而不是假定这种依赖不存在。gold 当年也吃过苦头,Taylor 写道,用 gold 构建 Linux 内核"经常坏掉",因为内核依赖链接器脚本里没有文档的特性(LFCS 2010)。
这条差异可以直接复现。下面这个共享库由两个文件组成:lib.c 由 clang 编译,自带 .note.GNU-stack;fast_add.s 是手写汇编,没有写那行声明:
// lib.cint fast_add(int, int);int api(int x) { return fast_add(x, 1); }# fast_add.s .text .globl fast_add .type fast_add, @functionfast_add: leal (%rdi,%rsi), %eax ret .size fast_add, .-fast_add$ clang -c fast_add.s -o fast_add.o$ clang -O2 -fPIC -c lib.c -o lib.o这和第 7 章用 fast.s 做的是同一个实验。本次 GNU ld 2.46 与 LLD 21 的输出都是 GNU_STACK ... RW;GNU ld 的对齐值为 0x10,LLD 为 0。只给 GNU ld 一个完全不含 .note.GNU-stack 的 fast_add.o,产物则没有 GNU_STACK 程序头。比较必须包含工具版本和输入集合。旧版 GNU ld 曾给混合输入写入 RWE 并发出弃用警告,这种已经发布的旧产物仍可能遇到 glibc 2.41 起更严格的加载检查。新的默认行为不会自动修复磁盘上已有的旧文件。
因此至少要区分三类判断依据:规范规定的行为、参照实现实际采用的行为、现有软件已经依赖的兼容行为。它们有时互相冲突;兼容目标也必须明确范围,不能用“另一个链接器这样做”代替对结果的解释。
这种区分直接决定测试怎么写。规范约束需要结构断言,执行语义需要运行测试,实现差异需要比较并解释;没有哪一种检查能独自承担全部责任。
链接慢在哪里
编译是局部的,一个源文件可以单独编译、并行编译、缓存复用。链接是全局的,它必须看到所有输入才能决定谁引用了谁、哪些节能删、每一块放在哪里。改一行代码,最终程序也得从头链接一遍,所以编译越快、缓存命中率越高,链接的占比就越大,第 1 章引过的 ripgrep 数据(调试构建约一半时间在链接)就是这么来的。
一次链接到底处理多少东西?lld 的设计文档给过一组 Chrome 的数据:带调试信息链接一次,输出约 2 GB,输入是 17,000 个文件、180 万个节、630 万个符号、1300 万条重定位,全程 15 秒;光是符号名字符串就有 450 MB,把它们插进哈希表要 1.5 秒(NewLLD)。mold 的设计文档在 2020 年统计的另一个 Chrome 配置更大,约 6200 万条重定位(mold design.md)。
再对照第 4 章:绝大多数重定位就是读出符号地址、套一个固定公式、写回 4 或 8 个字节。第 3 章的符号解析,主体就是一次哈希查找。相对于数据量,链接器要做的判断少得出奇。lld 文档说,随手给每个符号多加一次哈希查找,整个链接器就会慢 10%。在这类常见工作负载中,数据搬运、内存布局和并行度是主要成本;但这不意味着复杂度已经无关紧要。归档迭代、尾部字符串合并、ICF13 与松弛一旦重复遍历过多,仍可能成为瓶颈。四代链接器基本就是在这几件事上做了不同的选择。
四代链接器,四种取舍
GNU ld 建立在 BFD 之上,第 1 章讲过,BFD 想用一套代码处理几十种二进制格式。gold 的作者 Ian Lance Taylor 在 2010 年的演讲稿里列过 GNU ld 慢的具体原因:一次典型链接要把符号表遍历 13 遍,gold 只遍历 3 遍;为了兼顾 BFD 的通用抽象,x86-64 上一个符号表项占 156 字节,gold 只要 68 字节。他的结论是,要改掉这种设计就等于重写(Taylor, Gold, LFCS 2010)。
gold 于是只支持 ELF,用 C++ 模板为每种目标的字长和字节序生成专门的代码。Google 发布时说它比 GNU ld 快约五倍(Google Open Source Blog, 2008)。gold 也支持多线程,但 --threads 默认关闭(gold/options.h),并行在它那里是一项可选的优化。
lld 的设计文档把性能原则写成一句话:"做得少,比做得高效更重要。"节的内容和重定位不到必须时不读;查哈希表这种昂贵操作只做一次,查到后拿回一个指针当句柄;每个符号名在全局只对应一个 Symbol 对象,解析结果原地替换进去。lld 还有意偏离了一处传统语义:第 3 章讲过传统链接器按命令行顺序扫描静态库,lld 则记住所有见过的符号,后面的文件引用了前面某个静态库里的符号,也能把那个成员拉进来。这是有意选择的兼容边界;第 3 章的 --warn-backrefs 可以帮助发现与传统归档顺序语义不同的依赖,不能据此断言所有构建都等价。
mold 在 2020 年开工时,作者植山类(也是 lld ELF 重写的主导者)手上有一台 64 核 128 线程的机器,现有链接器没法利用这么多核。mold 的设计文档的基本判断是:想接近 cp 拷贝文件的速度,最要紧的是尽快把输出布局定下来,好尽早开始搬数据。并发策略刻意保持朴素:绝大部分地方用数据并行;线程间偶尔要共享状态,比如给符号打上"需要 GOT"的标记,就把标记做成原子变量(读、改、写一步完成,不会被别的线程从中间打断)。文档的原话是,从高处看,mold 只是串行地一个接一个执行各个阶段,每个阶段内部用并行 for 循环展开(mold design.md)。
少搬数据:mmap 与按需读取
最省的拷贝是不拷贝。第 5、6 章讲过,mmap 把文件映射进进程的地址空间,访问那段内存就等于读文件,内核在第一次访问某一页时才把它从磁盘或页缓存(内核缓存在内存里的文件内容)调进来。链接器把每个输入文件 mmap 进来以后,后续阶段直接拿指针指向输入里的字节,符号名、节内容、重定位表都不必复制一份。
这和 lld 的"不到必须时不读"配合得很好:没有被抽取的静态库成员、被 GC14 丢弃的节,通常可以避免复制和逐字节处理其内容;但元数据解析、同页数据、文件系统预读和其他分析仍可能触及相关页面,不能保证物理上一页都没读。带大量调试信息的链接里,需要逐字节处理的数据只占输入的一部分。
切开工作:按文件并行与按节并行
链接器的并行主要沿两种粒度切。
一种是按文件。解析每个目标文件的 ELF 头、节头表、符号表,文件之间互不相干。扫描重定位以决定 GOT 和 PLT 的大小,可以按重定位表并行,只在需要时把符号上的标记原子地置位。符号解析也按文件并行:每个线程负责一批文件,把它们定义的符号插进一张全局的并发哈希表,也就是允许多个线程同时插入、同时查找的哈希表,常见实现是把表分成许多段各自加锁,或者用原子操作做无锁插入。
另一种是按节。文件大小差别很大,一个巨大的目标文件会让负责它的线程成为瓶颈;节的粒度更细,负载更容易摊平。拷贝输入节并当场填重定位,每个节可以交给不同线程;--gc-sections 的标记可以从多个根同时出发;第 12 章的 ICF 给每个节算哈希,每轮细分也按等价类并行。
字符串合并:一个"简单"的去重
字符串合并看起来最简单,实际要多想一步。下面两个文件都用到了同一个字符串字面量。
// a.cint puts(const char*);void a(void){puts("connection reset");puts("ok");}// b.cint puts(const char*);void b(void){puts("connection reset");puts("retry");}$ clang -O2 -c a.c -o a.o$ clang -O2 -c b.c -o b.o$ objdump -s -j .rodata.str1.1 a.oContents of section .rodata.str1.1: 0000 636f6e6e 65637469 6f6e2072 65736574 connection reset 0010 006f6b00 .ok.$ objdump -s -j .rodata.str1.1 b.oContents of section .rodata.str1.1: 0000 636f6e6e 65637469 6f6e2072 65736574 connection reset 0010 00726574 727900 .retry.$ objdump -r a.o # 节选,省略了 .eh_frame 的重定位RELOCATION RECORDS FOR [.text]:OFFSET TYPE VALUE0000000000000004 R_X86_64_PC32 .L.str-0x40000000000000009 R_X86_64_PLT32 puts-0x40000000000000010 R_X86_64_PC32 .L.str.1-0x40000000000000016 R_X86_64_PLT32 puts-0x4.rodata.str1.1 带着第 2 章讲过的 SHF_MERGE 和 SHF_STRINGS 标志,名字里的 1.1 表示元素宽 1 字节、对齐 1 字节。链接器把它切成一个个以零结尾的片段,相同的片段只保留一份,输出里只会有一个 connection reset。
去重本身只是插哈希表。多想的那一步在重定位:a.o 里第一条 leaq 引用的是"本节偏移 0 处的那个字符串",合并之后它在输出节里的偏移变了,b.o 里同一个字符串也要指向同一个位置。所以链接器要为每个输入片段记下它在输出里的新偏移,填重定位时把"输入节 + 偏移"翻译成"输出节 + 新偏移"。调试信息里的 .debug_str 也是这种节,动辄几百 MB。
并行化之后还冒出一个新问题:多个线程同时往哈希表里插字符串,谁先插进去取决于线程调度,如果按插入顺序排布输出,每次链接的输出就可能不同。lld 的做法(SyntheticSections.cpp, MergeNoTailSection::finalizeContents)是按字符串的哈希值把它们分到固定数目的分片里,哈希值不同的两个字符串一定不相同,分片之间不会漏掉该合并的字符串;每个线程负责若干分片,都按输入文件的顺序遍历所有片段,只处理落在自己分片里的那些;最后按分片编号依次拼接。每个分片内部的顺序只取决于输入顺序,分片怎么分给线程都不影响结果。这个思路后面还会反复出现。
先算大小,再写出
为什么要先把所有大小算出来,才开始写?因为重定位需要最终地址。.text 里一条 call 要填的是目标地址减去当前位置,两个地址都取决于前面每个节有多大;而有些节的大小要等扫描完全部重定位才知道:.got 有多少项,取决于有多少符号被 GOTPCREL 引用;.eh_frame_hdr 有多少项,取决于 GC 之后还剩多少 FDE。因此实现通常把大小计算与最终写出分开。这里的“两遍”是职责划分,不是固定扫描次数:跳转桩和松弛可能让布局迭代,多次计算后才能进入最终写出。
mold 的设计文档考虑过一种更激进的做法:先把大小固定的普通节放在文件开头,一边拷贝它们,一边计算 .got、.plt 这类变长节,放到文件末尾。最后否决了:算变长节不到 100 毫秒,提前开工省不了多少;先拷贝后填重定位,等于把每个节读两遍,而布局定下来之后边拷贝边填,数据刚读进 CPU 缓存就被修改,几乎不花额外成本。
两遍结构还有一个好处:布局定下来以后,每个输入节在输出文件里的偏移都已知,写出阶段每个线程只写自己那一段,写入区间互不重叠,不需要锁。输出文件通常也 mmap 进来,线程直接往映射的内存里写。
到了这个层面,瓶颈开始出现在操作系统身上。mold 的文档记了两处:在 Linux 上给新建的大文件分配磁盘块很慢,覆盖写入一个已在页缓存里的旧文件,创建 2 GiB 的输出能省约 300 毫秒;mmap 了大量文件的进程退出时,内核清理资源可能要几百毫秒,mold 于是 fork 出一个子进程干活,写完输出就让父进程先退出。
build-id:并行地算一个指纹
流水线的最后一步是 build-id。第 11 章讲过,它是写在 .note.gnu.build-id 里的标识;采用内容派生算法时,由链接产物的选定内容计算,调试器和符号服务器靠它把去掉了调试信息的二进制和单独存放的调试信息配对;那一章也看过 lld 的几种算法,名为 md5、sha1 的两种实际都用 BLAKE3 计算,uuid 则是每次链接都不同的随机值。
这里关心的是它怎样并行。采用传统串行哈希接口时,对完整文件求值需要依次输入所有字节;树形哈希则可以把独立块分开计算,再确定性地汇总。内容派生的 build-id 还必须约定其自身字段怎样处理,不能递归地把尚未求出的标识作为输入。mold 和 lld 都把输出按 1 MiB 切块,每块并行算哈希,再对拼起来的所有块哈希算一次,也就是一棵高度为 2 的 Merkle 树(一种用哈希逐层汇总数据的树)。mold 文档给的数字是:用 16 个核,2 GiB 的可执行文件算 build-id 只要 60 到 70 毫秒。这样得到的值和对整个文件直接算哈希不同,对于这种内容派生标识,目标是同一内容产生同一结果,并把碰撞概率降到足够低;GNU build-id 也允许随机 UUID 或显式指定值,所以这两条不是所有 build-id 模式的通用定义,更不能把它当作防篡改证明。
前一条要求,引出了并行链接器绕不开的一个问题。
并行之后:确定性
可复现构建(reproducible build)指的是:给定同样的源码、构建环境和工具,任何人都能构建出逐字节相同的产物(reproducible-builds.org)。它是软件供应链安全的基础,只有这样,第三方才能独立验证发行版里的二进制确实来自公开的源码。
链接器在这件事上处境特殊。内容派生的 build-id 会把其覆盖范围内的不确定性带到标识中。单线程实现也要控制输入遍历顺序、哈希表随机种子、环境依赖、时间戳等来源;不用随机数和指针排序只是其中两项。并行链接器则处处是陷阱:哈希表插入的先后、字符串片段的排布、.dynsym 里符号的顺序、GOT 项的编号,凡是"谁先谁后"的决定,只要交给线程调度,就会在某一次链接里换个结果。
最典型的例子是第 3 章的弱定义。两个目标文件都给出 log_level 的弱定义,规则是按输入顺序,先出现的胜出。如果并行符号解析写成"谁先插进哈希表谁赢",结果就取决于哪个线程先跑到这一行。mold 的办法是给每个候选定义算一个排名(mold src/input-files.cc):
auto get_sym_rank = [&] { if (esym.is_common()) { ... return is_in_archive ? 6 : 5; } if (file->is_dso || is_in_archive) return (esym.st_bind == STB_WEAK) ? 4 : 3; if (esym.st_bind == STB_WEAK) return 2; return 1;};return (get_sym_rank() << 24) + file->priority;排名的高位是定义的种类(目标文件里的强定义最优先,其次是弱定义,再次是共享库和静态库成员里的定义,最后是 common),低位是文件在命令行上的优先级。每个线程在符号自带的锁里比较排名,更小才替换。在这些可竞争候选具有明确优先级的前提下,线程到达顺序不再决定胜者;同优先级和重复强定义仍要按相应规则检查。排名编码实现的是该链接器自己的选择语义,也不能自动证明它在所有归档情形下都与另一种串行链接器一致。
最基本的确定性检查是:同样的输入,换不同的线程数链接,输出必须逐字节相同。
$ ld.lld -shared --threads=1 --build-id=sha1 a.o b.o -o t1.so$ ld.lld -shared --threads=8 --build-id=sha1 a.o b.o -o t8.so$ shasum t1.so t8.sod1b2d47f3d60b104fbaec863e41216d47c99e74a t1.sod1b2d47f3d60b104fbaec863e41216d47c99e74a t8.so这么小的输入证明不了什么,检查要在大项目上反复跑。
lld 的字符串分片和 mold 的符号排名是同一个思路:计算并行地做,决策的结果只由输入决定。build-id 的分块哈希则是另一种形态:并行地压缩,再由单个线程按固定顺序汇总。确定性也服务调试,十次链接只出现一次的 bug 几乎无法定位。
测试方法论:怎样知道链接器是对的
链接器的输出没有唯一正确的答案。同一组输入,GNU ld 把 main 放在 0x401000,lld 放在 0x2011b0(第 4 章),两份都对;节的顺序、空隙里填的字节、GOT 项的编号,规范都留了余地。逐字节比较标准答案这条路走不通,测试只好分几层,每层回答一个不同的问题。
用一个错误串起不同证据
考虑一条 x86-64 的 call rel32:指令从 0x401000 开始,四字节位移字段从 P = 0x401001 开始,目标函数 answer 位于 S = 0x401020。输入重定位的 addend 为 A = −4,因此应写入:
S + A − P = 0x401020 − 4 − 0x401001 = 0x1b指令字节:e8 1b 00 00 00CPU 的下一条指令地址:0x401005实际调用目标:0x401005 + 0x1b = 0x401020如果错误地把下一条指令地址当成 P,写入的值就变为 0x17,调用目标落在 0x40101c,即函数前四字节。ELF 头、程序头和段大小完全不需要变化;错误藏在合法代码段里的一个字段中。
示例 将 _start 和 answer 放在两个目标文件中,answer 返回 42。它在函数前的空隙填入 0xcc(int3),再对正确输出的调用字段做受控扰动,生成上述错误版本。这样,错误调用不会偶然越过填充后继续进入函数,而会触发陷阱。这个例子检验测试能否区分两种输出,不等于已经检验所有链接器缺陷。
| 检查层次 | 本例的观察 | 能据此证明什么 |
|---|---|---|
| 输入记录 | start.o 的调用引用 answer,重定位为 PLT32,addend 为 −4 | 调用需求及其编码参数已明确;输出地址尚未确定 |
| 输出结构 | 两份输出都满足本例检查的文件范围、段大小、同余和入口权限条件 | 这些结构条件不能识别错误调用位移 |
| 字段语义 | 从输出指令解码出目标 0x401020 或 0x40101c | 直接识别调用是否到达选中定义 |
| 原生执行 | 正确程序退出码为 42;错误版本在 Linux 上因 SIGTRAP 终止 | 这条实际执行路径与字段计算相互印证 |
| 重复生成 | 相同输入反复得到相同字节 | 只能证明受检样本的确定性;错误输出也可以稳定重现 |
在原生 x86-64 Linux 的仓库根目录按前述命令运行 可以重建两份输出并核对上述结果。脚本通过子进程状态区分正常退出和信号终止,不把一个非零退出码直接解释为崩溃。
这些观察各自有清晰的责任。结构检查守住文件与映射边界;字段语义检查验证引用关系;运行测试验证消费者实际走过的路径;确定性检查验证重复构建。若差分测试让参考实现沿用待测实现给出的错误目标地址,两者甚至可能一致,因此还需要一个独立的预期:这里就是符号选择得到的 answer,而不是待测输出自己声称的调用目标。
结构不变式
结构测试要先区分格式约束和当前实现的设计选择。对于 p_align > 1 的 PT_LOAD,p_offset 与 p_vaddr 需要按它同余,p_filesz 不能大于 p_memsz,文件范围还必须有效;这些可以直接按格式要求检查。p_align 为 0 或 1 的情形不能拿来做取模运算。
一个选择 _start 作为入口、将不同权限分隔到不同页面的实现,可以把这些选择写成结构断言;提供 __stop_x 的实现还可以检查它位于节 x 末尾。这些有助于验证声明的布局设计,却不是所有 ELF 的通用公理。ELF 可以显式选择其他入口;一个节也可能同时由 PT_LOAD 和 PT_TLS 等不同类型的程序头描述;NOBITS 不占文件字节,不意味着其 sh_offset 数值必须落在所有文件段范围之外。
每条断言都要写清适用对象和理由。它们的局限同样明确:即使段的大小、对齐全部合法,一条 call 的位移仍可能算错,所以还需要运行与重定位语义检查。
运行
第二层是把程序跑起来。运行能抓住最后那几个字节,可它测的只是这一次执行走过的路径。前面说过,链接器最危险的 bug 在平时不走的路径上,所以用例要专门去走它们:抛异常并在几层之外接住,多个线程读写 TLS 变量,dlopen 一个库再调用其中的函数。
运行环境也是测试的一部分。把代码段的 p_flags 从 R+X 改成只有 R;在本系列的 x86-64 Linux 上直接执行,内核递送 SIGSEGV,shell 得到退出码 139。文件结构能被工具读出来,并不代表它满足运行时的权限约束。测试应同时检查 readelf -lW 的段权限和实际退出状态。用户态模拟器包含自己的 ELF 加载器,结果受其版本和实现影响,因此本系列的 ELF 主线统一交给 Linux 内核。第 15 章的非 ELF 格式采用静态解析实验,不能用这种结果代替目标操作系统的加载与签名验证。
差分:和参考实现比
第三层是差分测试:同一组输入分别交给待测链接器和参照链接器,比较两份输出。地址不同就不能逐字节比,要比语义:每条重定位最终指向哪个符号加多少偏移,动态符号表和 .dynamic 标签,每个段的权限,每个函数是否仍被 FDE 覆盖。把前面字符串合并的例子交给两个链接器:
$ ld.lld -shared a.o b.o -o ab.so$ readelf -p .rodata ab.so [ 0] connection reset [ 11] retry [ 17] ok$ ld.bfd -shared a.o b.o -o ab_bfd.so$ readelf -p .rodata ab_bfd.so [ 0] connection reset [ 11] ok [ 14] retry两边都只留下一份 connection reset,ok 和 retry 的顺序和偏移却不同,这是 lld 按哈希分片再拼接的结果。逐字节比较会判定两份输出不一致,按"每条引用指向哪个字符串"比较,两者完全等价。
差分测试还要明确地址由谁决定。一种方案是读取被测链接器的输出布局,生成脚本,让参考链接器采用相同的 S 和 P,再比较重定位字段。这能隔离字段计算,却不能独立验证布局:两边使用同一份错误坐标,结果仍可能一致。入口少加了符号的节内偏移,或把局部符号值误当成节起点,都需要额外的结构断言与运行用例。另一种方案是让参考链接器自行布局,比较程序头、符号与节内容的语义关系。它提供了独立选择,但参考布局也不是唯一正确答案;有意差异必须逐条解释,并让测试验证这些差异仍满足约束。
错误行为也是规格
报错有人会拿 grep 去匹配,所以它本身也要测。纽约大学(NYU)操作系统课的链接器作业做得很彻底:规格书列出 11 条错误和警告规则:第 1 条遇语法错误即停止,其余 10 条规定报什么、接下来怎么做,比如重复定义时保留第一个、未定义符号的地址用 0,评分脚本用 diff -b -B -E 比较输出(规格书)。中国人民大学的 LinkLab 用 TOML 描述每个用例的期望,强符号冲突的用例要求返回码为 1、stderr 匹配正则 (M|m)ultiple definition of strong symbol: global_var(LinkLab-2025)。诊断也是可观察输出。测试可以明确规定返回码、stderr 文本以及正常输入上的静默要求;多出一行警告也可能是回归。
测试本身要被测试
测试全部通过,只说明实现和测试写下的预期一致,预期漏了什么看不出来。密歇根大学 EECS 370 的链接器作业要求学生交一套自己的测试,助教准备了一批各带一个 bug 的链接器,学生的测试要能让足够多的有 bug 版本失败才算数(EECS 370 Project 2)。这在软件测试里叫变异测试(mutation testing),埋进去的每个 bug 叫一个变异。可以逐个植入代表性的源码错误,运行测试并记录哪一层发现了错误;存活的变异需要分析:它可能揭示盲区,也可能没有改变程序的可观察行为。
有效的输入必须让正确规则与错误规则给出不同结果。若二者在所有现有用例上恰好一致,添加更多同类断言也无法区分它们。可以从候选缺陷反推一个最小的区分输入:
| 候选缺陷 | 为什么常见用例可能漏掉 | 能区分它的输入与预期 |
|---|---|---|
| COMMON 大小取后来的值,而不是最大值 | 小定义在前、大定义在后时,两种规则都得到大值 | 大小 16、4 的两个定义按两种顺序链接;合并结果都必须为 16 |
| 64 位重定位只写低 32 位 | 原槽高位为零且结果小于 2³² 时,漏写高位不可见 | 令计算结果为 0x0000000100000042;8 字节字段必须为 42 00 00 00 01 00 00 00 |
| 页同余补齐错误地只保留低四位 | 所需补齐小于 16 时,与正确公式相同 | 取页大小 4096,当前文件偏移 0x100、虚拟地址低位 0x200;应补 0x100,不能补 0 |
| 检查只要求输出不崩溃,却不验证保留代码 | 把所有节都删掉也可能生成可解析的文件 | 用运行结果和入口可执行范围断言验证语义,同时检查应删除的节确实消失 |
第二行只验证地址计算与编码,不要求真的把程序加载到那个高地址。第三行来自同余条件:需要 f + d ≡ v (mod page),故最小非负补齐为 d = (v − f) mod page。采用二的幂掩码写法时,仍应先用安全算术处理减法,再按对应算法表达模运算。真实布局还必须满足段和节的其他对齐约束。
这些例子分别检验交换输入顺序的不变量、记录宽度、布局同余和执行语义。源码变异测试要一次只改一个有明确含义的规则,使失败可以归因;故意使程序无法编译的“变异”没有检验链接正确性。一个缺陷被测试发现,通常称为该变异被杀死;未被发现也可能是等价变异,而不一定是漏测。应逐个分析存活者。无论杀死多少个预设变异,结论始终限于这些缺陷模型,不能推成任意实现错误都已排除。
真实项目怎么做
成熟链接器的测试形式各不相同。lld 用 LLVM15 的 lit(LLVM 自己的测试运行器,按文件里的指令执行测试):测试文件开头的 # RUN: 行写明怎样用 llvm-mc(LLVM 的汇编器)汇编输入、怎样调用 ld.lld,再把 llvm-readobj 或 llvm-readelf 的输出交给 FileCheck,由文件里带检查前缀的注释行(默认前缀是 CHECK:)逐行匹配(例如 gc-sections.s,工具说明见 FileCheck),基本不运行产物。mold 的测试是一个个 shell 脚本,用编译器现场生成输入,链接后既用 readelf 加 grep 检查结构,也用 $QEMU 运行程序(例如 test/gc-sections.sh)。GNU ld 的 testsuite 基于 DejaGnu(GNU 的一个用 Tcl 写的测试框架),大量用例是一个 .d 文件:开头几行声明源文件、链接选项和用哪个工具转储,后面是匹配转储输出的正则,由 run_dump_test 驱动(例如 ld-elf/orphan.d,驱动函数在 binutils-common.exp)。这些例子把预期表达为可检查的结构或行为,文本转储和程序标准输出都可以成为检查对象;关键是每条预期对应什么契约,而不是使用了哪一种测试框架。
fuzz
这里的输入变异与上一节的源码变异是两种不同实验:源码变异主动破坏实现,检验测试的辨别力;输入变异保留实现,破坏字节或组合,检验它面对异常输入是否守住边界。二者都需要重放,但记录的对象不同。
一次损坏输入的检查至少有三种结果:接受、正常拒绝、实现失败。正常拒绝应当返回约定的错误,它是健壮性的成功路径;panic、进程崩溃、超时和超出资源预算则需要分别记录。不能把所有非成功退出都当作相同结果,也不能把消除错误消息当作修复。
输入经过变异,不保证变成非法文件。改动可能命中无语义的填充、修改仍然合法的数值,甚至写回原值。因此鲁棒性调查不能要求每个变异都被拒绝。应把两个问题分开:实现是否正常终止并守住内存边界;若接受,输出是否满足所支持输入的语义。前一个问题可以用接受/拒绝/失败计数调查,后一个需要解析结果、结构不变式和可运行样本的行为预言。
计数还应闭合。执行 N 次调查时,在每次调用都能返回或被捕获的条件下,应有 接受数 + 拒绝数 + 捕获的失败数 = N。这个等式能发现漏计、重复调用或把 panic 误算成拒绝,却不能证明接受结果正确。类似地,“至少有一次拒绝”只证明调查触及一条错误路径;把所有输入直接拒绝也会满足它,所以必须先验证支持范围内的原始有效样本。
要重放由伪随机生成器产生的失败,必须保存初始种子、样本顺序、生成器及变异规则的版本、失败迭代和被修改的输入。每次变异消耗的随机数数量可能不同,因而“重新设种子然后直接变异失败样本”未必重现同一字节;可以完整重放前面的抽取,也可以直接保存失败时的输入文件。后者便于最小化:逐次删除无关内容,仍然复现同一个失败,得到较小的反例。
catch_unwind 只能拦截 Rust 的 unwind panic,不会拦截 panic=abort、进程信号、无限循环或内存分配耗尽。要调查这些故障,运行器需要子进程隔离、超时与资源限制,并保留退出信号等信息。panic hook 又是进程全局状态,临时替换它的调查器需要恢复原 hook,并避免与同时更改 hook 的工作并发运行。Rust catch_unwind明确区分了可捕获的展开与 abort。
还有测试作者没有想到的畸形输入。Fuzzing 自动生成随机或半随机字节,观察解析、符号选择和链接是否 panic、卡住或申请异常规模的内存。可先把一个有效目标文件截成所有较短前缀,检查拒绝与边界;再对多个有效样本按固定种子变异。只统计“不崩溃”还不够:若所有变异都在魔数检查处被拒绝,后续阶段根本没有得到检验。应记录接受、拒绝和到达各阶段的情况,结合覆盖率改进样本。
另一种思路来自编译器测试。Csmith 随机生成不含UB16的 C 程序,交给多个编译器编译运行,输出不一致就说明至少有一个编译器错了,作者用它向编译器开发者报告了 325 个以上此前未知的 bug(Yang 等, PLDI 2011、csmith)。搬到链接器上,就是随机生成一组互相引用的目标文件,交给两个链接器,运行并比较结果,差分测试和 fuzz 合在了一起。
能链接、运行却不对:诊断方法
前面讲的是链接器作者怎样确认自己是对的。用链接器的人碰到的是另一面:链接成功了,程序却跑错。这个系列几乎每一章都留下过这样的例子。"先看什么"一列是第一步该看的地方,不是全部。
| 症状 | 可能的原因 | 先看什么 | 见 |
|---|---|---|---|
| 写一个变量,旁边的变量跟着变 | 两个文件对同一个名字的类型和大小看法不同,链接器不检查类型 | nm -S 看定义的大小;GCC 开 -flto 时的 -Wlto-type-mismatch | 第 0、3 章 |
| 默认配置没生效,读到另一个值 | 弱定义被静态库成员里的强定义顶替,成员是因为别的符号被拉进来的 | -y 名字、--why-extract | 第 3 章 |
| 地址的高位丢了,或跳到一个不相干的位置 | 该检查溢出却没检查,或者用了 32 位寻址(R_X86_64_32、32S) | readelf -r 看类型,objdump -d 对照 S + A − P 手算 | 第 4 章 |
| 调用落到另一个模块的同名函数 | 符号插入,GOT 或 PLT 绑到了意料之外的定义 | LD_DEBUG=bindings、readelf --dyn-syms | 第 7 章 |
| 库升级后,旧程序结果错了 | 接口变了,soname 和符号版本却没变;或者绑到了错误的版本 | readelf -V、LD_DEBUG=versions、abidiff | 第 13 章 |
| 未初始化的全局变量一开始不是 0 | 自己写的加载器或启动代码没把 p_filesz 之后的部分清零 | readelf -lW 的 FileSiz、MemSiz | 第 6 章 |
| 进程一条指令都没执行就被 SIGSEGV 杀掉 | 段的偏移与地址不同余,内核在 execve 里映射失败 | readelf -lW 的 Offset、VirtAddr、Align | 第 6 章 |
取第一条指令就 SIGSEGV(SEGV_ACCERR) | 代码段和不可执行的段共用一页,或代码段没有 X 权限 | readelf -lW 的 Flg 和各段的页边界 | 第 5 章 |
| 裸机程序里全局变量的初值不对 | .data 的 LMA 和 VMA 分开,启动代码没把初值从 LMA 拷到 VMA | readelf -lW 的 PhysAddr 和 VirtAddr,objdump -h 的 LMA 列 | 第 10 章 |
| gdb 断点落错函数,或行号对不上 | 被 GC 删掉的函数留下墓碑值、代码又恰好从地址 0 开始;或者调试文件取错了 | llvm-dwarfdump --debug-line,readelf -n 对比两边的 build-id | 第 11 章 |
main 之前崩溃,或全局对象读到另一个还没构造的对象 | 跨翻译单元的静态初始化顺序随链接顺序变化 | readelf -x .init_array,gdb 在构造函数下断点 | 第 14 章 |
| 同一个 inline 函数在不同调用处行为不同 | ODR17 违规,链接器只保留其中一份 COMDAT18 | nm -C -S 比较各份大小,objdump -d 对比代码;-Wodr 只报类型和虚表定义的不一致 | 第 3、14 章 |
dlopen 报 cannot allocate memory in static TLS block | 被动态加载的共享库用了 IE 模型 | readelf -r 找 R_X86_64_TPOFF64 或 R_AARCH64_TLS_TPREL64;readelf -d 的 STATIC_TLS 只作参考,aarch64 的 BFD 不设它 | 第 9 章 |
异常没被 catch 住就 terminate,backtrace 断在某一层 | 那一层没有 FDE:手写汇编缺 CFI19、零长度 FDE、.eh_frame_hdr 缺失 | readelf --debug-dump=frames,readelf -lW 里的 GNU_EH_FRAME | 第 8 章、本章 |
| Mach-O 的 CodeDirectory 页散列不匹配 | 签名生成后,受保护的文件字节发生变化 | 在 Linux 上运行第 15 章的散列核验脚本,定位不匹配的页 | 第 15 章 |
这些症状有一个共同点:现象和原因隔得很远,而链接器在其中大多只是照规则办事,出错的是喂给规则的输入。所以诊断时先要回答两个问题:链接器或 ld.so 做出的哪一个决定导致了这个结果,那个决定又是根据哪一个输入做出的。
一套通用的流程
先分清是链接时的决定还是运行时的决定。链接时的决定问链接器:-Map 写出每个输入节和符号放在哪里,--why-extract 说出每个静态库成员是被谁的哪个符号拉进来的,-y 名字 打出一个名字经过的引用和定义,lld 的 --why-live 说出一个符号为什么没被 GC 删掉。运行时的决定问 ld.so:glibc 的 LD_DEBUG=libs、bindings、versions 分别打出库的搜索过程、每个符号绑到了哪个库、用了哪个版本。这是 glibc 的功能,musl20 的 ld.so 没有,调查 musl 程序时,可以用 gdb 检查实际 GOT 值、符号定义与加载器状态。换到 glibc 只能作为有明确目的的对照:运行库和加载器也发生了变化,原故障不保证仍然存在。
再看字节。readelf -r 看输入里每条重定位要填什么,objdump -d 看输出里实际填了什么,对不上就照第 4 章手算一遍。gdb 里 info symbol 地址 说出一个地址落在哪个符号里,x/i 看那里是什么指令。
还看不出来,就二分。换一个链接器:GNU ld、lld、mold 的结果不同,可以帮助缩小到兼容规则、布局或优化选择;差异本身不能判定哪一份实现有错。前面的栈权限例子还说明,默认规则会随版本改变,比较时必须记录链接器版本和实际程序头。去掉一项优化:关掉 LTO、--gc-sections、--icf,看问题是否消失,第 12 章那个只在 LTO 下才找不到的 helper2 就是这样定位的。缩小输入:把目标文件分成两半,一半换成另一种编译选项或另一个编译器的产物,逐步逼近出问题的那一个。为了让每一次二分都在同样的输入上进行,可以先用 lld 的 --reproduce 把整次链接冻结成一个 tar 包。
两个小实验
第一个问题是"这个函数为什么还在"。handlers.c 有一张函数指针表,第二项是调试用的 debug_dump,main 只用第一项:
// main.c:只用了 handlers[0]typedef void (*handler)(void);extern handler handlers[];int main(void) { handlers[0](); return 0; }// handlers.c:一张函数指针表,第二项是调试用的 debug_dumpvoid on_read(void) {}void debug_dump(void) {}void (*handlers[])(void) = { on_read, debug_dump };$ clang -O2 -ffunction-sections -fdata-sections -c main.c handlers.c$ ld.lld -e main --gc-sections --why-live='debug_*' main.o handlers.o -o livelive symbol: handlers.o:(debug_dump)>>> referenced by: handlers.o:(handlers)>>> referenced by: main.o:(main) (entry point)这里的 live 用于检查 GC 决策,没有链接 C 启动代码;不能将 main 当作正常进程入口直接运行。
开了 --gc-sections,debug_dump 仍然留在输出里。第 5 章的 --print-gc-sections 只列出删掉了什么,留下的理由要问 --why-live。--why-live 给出的链条从下往上读:入口 main 引用了 handlers,handlers 里有一条指向 debug_dump 的重定位。第 5 章讲过,GC 以节为单位、以重定位为边,表里存了它的地址,它就是活的,运行时有没有人调用它,链接器无从知道。想删掉它,得把这张表拆开,或者只在调试构建里让它进表。
第二个问题是"为什么用的是这个定义"。app.c 给 log_level 一个弱的默认值 1,静态库 libnet.a 的成员 net.o 里有一个强定义 3:
// app.c:给 log_level 一个弱的默认值#include <stdio.h>int log_level __attribute__((weak)) = 1;void net_init(void);int main(void) { net_init(); printf("log_level = %d\n", log_level); return 0;}// net.c:libnet.a 的成员,自己带了一个强定义int log_level = 3;void net_init(void) {}$ musl-gcc -O2 -c app.c net.c$ ar rcs libnet.a net.o$ nm -A app.o net.o | grep log_levelapp.o:0000000000000000 V log_levelnet.o:0000000000000000 D log_level$ musl-gcc -static -fuse-ld=lld -Wl,--no-dynamic-linker app.o -L. -lnet -o app \ -Wl,-y,log_level -Wl,--why-extract=why.txtapp.o: definition of log_level./libnet.a(net.o): definition of log_level$ grep -v libc.a why.txtreference extracted symbolapp.o ./libnet.a(net.o) net_init$ ./applog_level = 3本例要求一个不通过动态解释器启动的静态程序,因此显式传入 --no-dynamic-linker,并用 readelf -lW app 检查产物不含 PT_INTERP。驱动选项与输出契约是两层不同证据:包装器可能同时添加静态链接选项和解释器路径,不同链接器对这种组合的处理也可能不同。musl-gcc -### 用于检查最终传给链接器的参数,程序头则说明内核实际将采用哪条启动路径。musl 项目讨论过这种包装配置与链接器行为差异。诊断时应检查产物和启动约定,不能仅凭命令行出现 -static 推断全部运行条件已经满足。
why.txt 里 libc.a 成员的几十行被过滤掉了。nm 显示 app.o 里的 log_level 是 V(弱对象),net.o 里是 D。-y 说明两个文件都定义了它,--why-extract 说明 net.o 是因为 app.o 引用了 net_init 才被拉进来的。拉进来的是整个成员,它带着的强定义按第 3 章的规则顶替了弱定义。把对 net_init 的调用去掉,成员不再被拉进来,程序就打印 1。触发这个变化的改动和 log_level 的定义毫无关系,却改变了程序行为;引用追踪把这条间接的因果关系显露出来了。
职责在扩张
这些检查覆盖了一次链接从输入到运行的主要路径,新的优化和平台特性还会扩大需要验证的范围。链接阶段能汇集参与此次构建的多个模块,因此常被用来协调跨模块的决策;LTO 与硬件安全属性正是两种不同的例子。
LTO:链接器开始驱动编译器
第 12 章讲过链接期优化(LTO):-flto 编出来的 .o 装的是 LLVM bitcode 或 GCC 的中间表示,链接器自己读不懂,只能通过插件接口或内嵌的 LLVM 问出其中的符号,和普通目标文件一起解析,再告诉编译器哪些符号必须保留,等它生成真正的目标文件后接着走流水线。对链接器工程而言,后果是边界模糊了:一个只在 LTO 下出现的错误,可能出在符号解析,可能出在"哪些符号必须保留"的判断,也可能出在优化器里,第 12 章那个只在内联汇编里被调用、结果被删掉的 helper2 就是一例。
第 12 章的 ICF(相同代码折叠)是另一项落到链接器身上的优化,它要守住"不同函数地址不同"的语义,还要顾及展开信息和调试信息。
安全属性:所有输入的按位与
新一代 CPU 提供了几种防止控制流被劫持的硬件机制。AArch64 的 BTI(Branch Target Identification)在启用的保护条件下,要求受检查的间接跳转落到兼容的落脚指令;它不只涉及字面上的 bti,部分 PAC 指令也可承担相应作用;x86 的 IBT(Indirect Branch Tracking)对应的落脚指令是 endbr64。x86 的影子栈(SHSTK)让 CPU 另存一份返回地址,返回时比对两份是否一致,它和 IBT 是 CET 的组成部分;-fcf-protection 影响代码生成与属性声明,运行时启用还取决于 CPU、内核和加载器。AArch64 上与影子栈类似的机制叫 GCS(Guarded Control Stack),PAC(Pointer Authentication)则给返回地址等指针加上密码学签名。
这些机制的启用条件并不完全相同。下面关注的是 GNU property 中按位与合并的一类兼容性声明:参与链接的输入声明自己支持哪些特性,链接器保守合并,再交给加载器按平台策略处理。属性缺失、显式强制启用和运行时是否支持都要分别考虑。拿一个只有一行的 p.c 来看:
// p.cint f(void){return 1;}$ clang -O2 -fcf-protection=full -c p.c -o p_cet.o$ objdump -s -j .note.gnu.property p_cet.oContents of section .note.gnu.property: 0000 04000000 10000000 05000000 474e5500 ............GNU. 0010 020000c0 04000000 03000000 00000000 ................$ objdump -d p_cet.o0000000000000000 <f>: 0: f3 0f 1e fa endbr64 ...$ clang --target=aarch64-unknown-linux-gnu -O2 -mbranch-protection=standard -c p.c -o p_bti.o$ objdump -s -j .note.gnu.property p_bti.oContents of section .note.gnu.property: 0000 04000000 10000000 05000000 474e5500 ............GNU. 0010 000000c0 04000000 07000000 00000000 ................按小端序读:名字长 4、描述长 0x10、类型 5(NT_GNU_PROPERTY_TYPE_0),名字 GNU\0。描述里是一条属性:x86 上类型 0xc0000002(GNU_PROPERTY_X86_FEATURE_1_AND),数据长 4,值 3 即 IBT(位 0)加 SHSTK(位 1);AArch64 上类型 0xc0000000(GNU_PROPERTY_AARCH64_FEATURE_1_AND),值 7 即 BTI、PAC、GCS 三位(常量见 llvm/BinaryFormat/ELF.h)。
类型名里的 _AND 说明了链接器的职责:对所有输入的这个值做按位与,写进输出,再由内核和 ld.so 逐个模块决定是否开启。只要有一个输入缺了 IBT 位,比如一个手写的汇编文件,默认合并结果就不再声明完整的 IBT 兼容性;最终是否启用还要看平台及加载策略。把带属性的 p_cet.o 和前面那个手写的 fast_add.o(它没有 .note.gnu.property)链接在一起看看:
$ ld.lld -shared p_cet.o -o cet_ok.so$ readelf -nW cet_ok.so | grep Properties GNU 0x00000010 NT_GNU_PROPERTY_TYPE_0 Properties: x86 feature: IBT, SHSTK$ ld.lld -shared p_cet.o fast_add.o -o cet_mix.so$ readelf -nW cet_mix.so | grep Properties$ ld.lld -shared -z cet-report=warning p_cet.o fast_add.o -o cet_mix.sold.lld: warning: fast_add.o: -z cet-report: file does not have GNU_PROPERTY_X86_FEATURE_1_IBT propertyld.lld: warning: fast_add.o: -z cet-report: file does not have GNU_PROPERTY_X86_FEATURE_1_SHSTK property第二次链接没有任何提示,输出里的属性却整个消失了:一个四行的汇编文件,使最终库不再携带这两项兼容性声明,和缺了 .note.GNU-stack 的那个输入是同一种情形。
链接器自己生成的代码也要守规矩。PLT 项是链接器合成的跳板,如果模块开启了 IBT,每个 PLT 项开头也得有 endbr64。lld 的 -z force-ibt 和 -z force-bti 让链接器在有输入缺属性时也照样给 PLT 加上落脚指令、在输出里写上这一位,同时对那个输入发出警告;-z cet-report=warning|error 则用来找出是哪个输入拖了后腿(ld.lld(1))。
新格式
格式本身也在变。第 8 章的 SFrame、第 4 章的 CREL 都是近几年的新东西。每多一种节类型,链接器就要回答一串问题:GC 时怎么处理,能不能合并,链接器脚本没提到它时放在哪里,空节算不算合法。答案一旦和别的实现不同,就会在某个发行版上爆出来:在 Ubuntu 25.10 上,汇编器给 glibc 的启动文件产生了空的 .sframe 节,mold 原先把它当作损坏的输入而报错,直到 2.42.1 才修好(release notes)。
格式在增加,要解析的输入在变复杂,这些输入又越来越多地来自构建者控制不了的地方。于是有人开始重新考虑链接器本身该用什么语言来写。
为什么新的链接器开始用 Rust 写
mold 在 2026 年宣布从 C++ 改用 Rust 重写,作为 3.0 发布。植山类在 2.42.1 的发布说明里给的理由是:这样的工具预期会被使用几十年,从这个尺度看它仍处在生命早期,现在重写正是时候;到 2026 年,Rust 已经是系统软件的实用选择,性能和 C++ 相当,同时有内存安全保证。他也说风险没有消失:"People trust mold because it just works, and we cannot afford to lose that trust."(人们信任 mold,是因为它拿来就能用,失去这份信任的代价承担不起。)(release notes)
从链接器自身的特点看,还有两条理由。第一,输入并不总是可信的。GNU 工具链的传统立场是假设输入可信(binutils SECURITY.txt),Ubuntu 安全团队也直说 "binutils21 isn't safe for untrusted inputs"(Ubuntu CVE-2025-11495)。针对畸形输入的漏洞一直有,2026 年还有一例:GNU ld 处理特制的 XCOFF(IBM AIX 的目标文件格式)文件时,把一个未经校验的字段当作数组下标(Red Hat CVE-2026-15003),而构建集群里链接第三方发布的预编译库是日常操作。第二,链接器的结构和 Rust 的借用规则(编译器跟踪每个引用指向什么、能活多久、能否修改)比较契合:流水线前一阶段的数据在后面基本只读,可以放心地分给多个线程,类型系统会在编译期拒绝可能造成数据竞争的写法。
但这一章前面讲的几类问题,Rust 帮不上忙。文件映射常通过带 unsafe 契约的接口创建(调用者负责证明额外条件,其他 Rust 检查仍然生效),因为文件可能在外部被修改。类型系统挡得住数据竞争,挡不住调度带来的不确定性:那个"谁先插进哈希表谁赢"的弱定义解析,用带锁的哈希表写出来是完全合法的安全 Rust,输出照样每次可能不同。写出一个错误的字节,比如那条零长度 FDE,和内存安全与否根本无关。
成为默认值的门槛
一些大型工作负载的全量链接已明显加快,但能否成为系统默认值,还取决于兼容性范围。mold 2.42.1 的发布说明把这一差距列为后续目标:多数 Linux 发行版的系统链接器仍使用 GNU ld。gold 在 2008 年就比 GNU ld 快好几倍,却始终没成为主流发行版的系统链接器,Taylor 在 2010 年已经写明差距在哪:除了内核构建里那些没有文档的链接器脚本特性,还得解决 glibc 的构建过程(LFCS 2010)。binutils 2.44 弃用 gold 后,Fedora、Swift、Apache Arrow 相继提出跟进(Fedora、Swift、Arrow)。
快链接器真正成为默认值的地方,除了第 1 章提过的苹果 ld-prime 和 Rust 1.90,还有 FreeBSD 12.0(2018 年起把 lld 作为 /usr/bin/ld,FreeBSD Foundation)和 Android NDK r22(2020 年底起默认使用 lld,NDK Changelog)。这四个都是一个组织掌控整条工具链的生态,发现不兼容可以自己改。Rust 的新默认值只对官方分发的工具链生效(PR #140525)。主流 Linux 发行版则由成千上万个独立维护的项目拼成,把 /usr/bin/ld 换掉,就等于要为所有这些项目的构建脚本负责。
链接器脚本是这道门槛最具体的形态,而且比想象中常见:mold 的设计文档提到,Linux 上的 /usr/lib/x86_64-linux-gnu/libc.so 名字像共享库,实际是一个文本文件,里面是一段链接器脚本。mold 从一开始就得支持这一小部分语法,其余部分文档里说"真的不想实现";到了 3.x 想成为 /usr/bin/ld,第一步要补的恰恰是第 10 章那些内核和固件依赖的语法。植山类在 2.42.1 的发布说明里也承认,他花在兼容性上的精力不如花在速度上的多。
增量链接:老想法,新难点
在某些工作负载上,全量链接已经接近复制同等大小文件的耗时。继续缩短重复构建时间的一条路线是增量链接:只处理改动的部分,复用上一次的结果。
这个想法很老。MSVC 链接器的 /INCREMENTAL 在 /DEBUG 下默认开启,它在代码和数据之间留出填充,函数挪了位置就通过跳转 thunk(只负责跳到真正目标的一小段代码)间接调用,状态存在 .ilk 文件里;开启 /OPT:REF(相当于 --gc-sections)或 /OPT:ICF、增删目标文件时,它都退回全量链接(Microsoft Learn)。gold 在 2010 年代初做过 --incremental,Cary Coutant 2012 年的演讲列过代价:每个节预留 10% 的补丁空间;SHF_MERGE 节不合并,调试字符串不去重,体积涨到约 10 倍;不兼容 --gc-sections 和 LTO 插件(Coutant, LFCS 2012)。
mold 的设计文档解释过当初为什么不做增量链接,理由很具体。程序可以自己定义 malloc 覆盖 libc 的;某次改动删掉了自己的 malloc,链接器就要从 libc 拉进它的版本,而那又可能牵出更多目标文件。源码层面一处局部的改动,在二进制层面可能是全局的连锁反应(mold design.md)。到了 2.42.1,植山类说如果能找到简单的设计,mold 愿意加上。
增量链接除了减少工作量,还要证明复用旧结果没有漏掉依赖变化。为了给后续修改留余地,它的布局本来就和全量链接不同,不可能要求两者逐字节相同。MSVC 文档说增量链接的程序和非增量链接的程序"功能上等价",这句话本身需要证明:在什么意义上等价,每次改动之后怎样检验它仍然成立。一个能被信任的增量链接器,大概需要对每一次增量结果都和同一组输入的全量结果自动做语义比较。前面的差分与运行测试因此需要加入“连续修改”的维度:不仅检查一个静态输入集,还要检查从旧状态更新到新状态的路径。
一个链接器工程师需要什么
这个领域要的能力大致是五样。读规范,并且知道规范在哪里停止,孤儿节的放置这类行为只存在于 GNU ld 的源码和既成事实里。读二进制,能用 readelf、objdump22 说出一个文件的节、段、符号和每条重定位。手算,S + A − P、FDE 里的 CIE23 pointer、.note.gnu.property 的每一位,链接器的 bug 往往就是某个数差了几个字节。诊断在远处爆炸的故障,从"异常没被接住"倒推到"两条 FDE 的起始地址相同"。设计可验证的系统,每一处并行、每一项优化都要能持续证明结果和朴素做法一致。
回到 cc main.c add.c
第 0 章从两个文件间的一次调用开始:声明告诉编译器怎样传参,目标文件留下尚未确定的地址,链接器把引用接到定义。随后那个 int x 与 extern double x 不一致的程序揭示了边界:符号名相同,并不证明两份源码对对象的类型有相同理解。传统符号表缺少完整的 C 类型信息;LTO 能补充诊断,也不能把UB变成合法程序。
同样的边界一路延伸到了这里。布局必须满足加载契约,展开表必须支持真实的异常路径,线程局部访问必须接上运行时,并行算法还必须保留既定的符号选择。一个能够处理更多输入的链接器,需要增加与这些能力相匹配的证据:结构断言、执行用例、可解释的差分、错误规格、变异测试与覆盖率反馈 fuzz。生成随机输入时,要把目标文件视图、链接和运行分别接入,而不只检查解析器是否崩溃。
到这里,可以开始读一个真实链接器的源码了。lld 的 lld/ELF/Driver.cpp 里,LinkerDriver::link 从上到下依次调用 markLive、doIcf、writeResult 这些阶段(Driver.cpp);mold 的 src/main.cc 里,mold_main 依次调用 resolve_symbols、gc_sections、scan_relocations、compute_section_sizes、copy_chunks(main.cc),几乎就是本章开头那张七步清单。从自己手算过的一样东西读起,比如第 4 章的 PC32 或第 8 章的 .eh_frame_hdr,找到它被计算的那几行,再翻那个文件的提交历史,看这几行是因为哪一份 bug 报告才变成今天的样子。读到那个修复时,可以问自己一个问题:如果当初由你给这几行写测试,结构断言、运行、差分、错误规格、fuzz,哪一层能在它出错之前抓住它?
练习
- 观察。用诊断一节的
main.c和handlers.c,加--why-live=on_read链接,回答on_read是被谁留下来的;再加-Map=-看它在输出里的位置。映射文件能不能回答"为什么留下"? - 预测。把
app.c里对net_init()的调用删掉,其余不变,重新编译、打包、链接。先写下程序会打印什么、--why-extract的输出里还有没有net.o那一行,再运行验证。 - 改坏。把本章的
p_cet.o和fast_add.o链接成共享库,加上-z cet-report=error,让链接失败。然后修改fast_add.s,照"安全属性"一节读出的格式手写一个.note.gnu.property节,让同一条命令通过,输出的属性是 IBT 和 SHSTK。只加属性、不改代码,够不够?
进一步阅读
- John R. Levine,《Linkers and Loaders》(Morgan Kaufmann,1999)。至今最完整的一本链接器专著,部分内容已显陈旧,概念框架仍然适用(作者页面,含未校对的原稿章节)。
- Ian Lance Taylor 的 Linkers 系列博客(2007 年 8 月起,共二十篇),gold 作者从链接器做什么讲起,逐篇讲到重定位、共享库、符号版本、TLS 等内部细节(第 1 篇)。
- MaskRay(宋方睿)的博客,lld 维护者之一,长期写 ELF、重定位、栈展开、TLS、CREL、SFrame 的细节和各实现之间的差异(maskray.me)。
- 规范本身:System V gABI、x86-64 psABI、AArch64 ABI、DWARF 5 标准、Itanium C++ ABI 异常处理。
- mold 的 design.md(2020 年写成,作者注明部分内容已过时)和 lld 的设计文档,两份都很短。
- Cary Coutant 关于 gold 增量链接的演讲(LFCS 2012),和 mold design.md 里拒绝增量链接的那一段放在一起读。
- Csmith 的论文 Finding and Understanding Bugs in C Compilers(PLDI 2011),讲随机生成程序做差分测试时怎样避开UB、怎样把出错的程序缩小。
答案
练习一
$ clang -O2 -ffunction-sections -fdata-sections -c main.c handlers.c$ ld.lld -e main --gc-sections --why-live=on_read main.o handlers.o -o livelive symbol: handlers.o:(on_read)>>> referenced by: handlers.o:(handlers)>>> referenced by: main.o:(main) (entry point)$ ld.lld -e main --gc-sections -Map=- main.o handlers.o -o live | grep -E 'on_read|handlers|main' 2001c8 2001c8 30 1 main.o:(.eh_frame+0x0) 2001f8 2001f8 28 1 handlers.o:(.eh_frame+0x18) 201230 201230 e 16 main.o:(.text.main) 201230 201230 e 1 main 201240 201240 1 16 handlers.o:(.text.on_read) 201240 201240 1 1 on_read 201250 201250 1 16 handlers.o:(.text.debug_dump) 203260 203260 10 16 handlers.o:(.data.handlers) 203260 203260 10 1 handlers链条和 debug_dump 的一样:入口 main 引用了 handlers,handlers 里有一条指向 on_read 的重定位。main 只用了 handlers[0],可 GC 看的是节,.data.handlers 整个活着,它引用的两个函数也就都活着。映射文件只说明 .text.on_read 在 0x201240、占 1 字节,看不出是谁把它留下的;GNU ld 的映射文件开头有静态库成员为什么被拉进来的一段,同样不回答 GC 的问题。
练习二
打印 log_level = 1,--why-extract 里不再有 net.o:
$ musl-gcc -static -fuse-ld=lld -Wl,--no-dynamic-linker app.o -L. -lnet -o app2 \ -Wl,-y,log_level -Wl,--why-extract=why.txtapp.o: definition of log_level$ grep -v libc.a why.txtreference extracted symbol$ ./app2log_level = 1app.o 里已经没有未定义的 net_init,libnet.a 的符号索引里 net.o 只提供 log_level 和 net_init,而 log_level 在表里已经有一个弱定义。第 3 章讲过,已有的弱定义不会触发拉取,只有未定义的引用才会,所以 net.o 不进链接,-y 也只报 app.o 一处定义。
练习三
$ ld.lld -shared -z cet-report=error p_cet.o fast_add.o -o mix.sold.lld: error: fast_add.o: -z cet-report: file does not have GNU_PROPERTY_X86_FEATURE_1_IBT propertyld.lld: error: fast_add.o: -z cet-report: file does not have GNU_PROPERTY_X86_FEATURE_1_SHSTK property修好的版本如下:
fast_add: endbr64 leal (%rdi,%rsi), %eax ret .size fast_add, .-fast_add
.section .note.gnu.property,"a",@note .p2align 3 .long 4 # n_namesz:名字 "GNU\0" 长 4 .long 16 # n_descsz:一条属性,8 字节对齐后 16 字节 .long 5 # n_type:NT_GNU_PROPERTY_TYPE_0 .asciz "GNU" .long 0xc0000002 # GNU_PROPERTY_X86_FEATURE_1_AND .long 4 # pr_datasz .long 3 # IBT | SHSTK .p2align 3 # pr_data 补齐到 8 字节
.section .note.GNU-stack,"",@progbits$ clang -c fast_add_cet.s -o fast_add_cet.o$ objdump -s -j .note.gnu.property fast_add_cet.o | tail -2 0000 04000000 10000000 05000000 474e5500 ............GNU. 0010 020000c0 04000000 03000000 00000000 ................$ ld.lld -shared -z cet-report=error p_cet.o fast_add_cet.o -o fixed.so$ readelf -nW fixed.so | grep Properties GNU 0x00000010 NT_GNU_PROPERTY_TYPE_0 Properties: x86 feature: IBT, SHSTK字节和 clang 为 p_cet.o 生成的完全一样。只加属性不改代码,链接器不会发现,它只看声明;可那就是一句假话:fast_add 被函数指针间接调用时,开了 IBT 的 CPU 发现目标处不是 endbr64,直接产生异常。影子栈这边,fast_add 只有一条 ret,不改写返回地址,SHSTK 的声明是真的。所以 endbr64 必须加,顺手补上 .note.GNU-stack。对照一下 -z force-ibt:用原来的 fast_add.o,它只警告,输出照样写上 IBT 位,那句假话就由链接器替你说了。
$ ld.lld -shared -z force-ibt p_cet.o fast_add.o -o force.sold.lld: warning: fast_add.o: -z force-ibt: file does not have GNU_PROPERTY_X86_FEATURE_1_IBT property$ readelf -nW force.so | grep Properties GNU 0x00000010 NT_GNU_PROPERTY_TYPE_0 Properties: x86 feature: IBT参考
附录:术语与工具
-
ELF — ELF(Executable and Linkable Format)规定目标文件、可执行文件与共享对象的结构。通用规则见 gABI,架构相关的调用约定和重定位规则见对应 psABI。 官方文档。 ↩
-
GNU — GNU 是 “GNU’s Not Unix” 的递归缩写,指自由软件操作系统项目。GCC、binutils 和 glibc 都属于 GNU 项目,但分别承担编译、二进制处理和 C 运行库职责。 官方文档。 ↩
-
GOT — GOT(Global Offset Table)保存供代码间接访问的地址或相关偏移。它让部分地址修补集中到数据表中;表项的具体用途由重定位类型与 ABI 决定。 官方文档。 ↩
-
PLT — PLT(Procedure Linkage Table)是一组函数调用跳板,常与 GOT 和动态符号绑定配合。PLT 存放指令,不能简单理解成另一张纯地址表。 官方文档。 ↩
-
TLS — TLS(Thread-Local Storage)让不同线程拥有同一变量的独立实例。链接器描述初始化模板并处理寻址模型,运行时负责为线程建立实例。这里不是网络协议 Transport Layer Security。 官方文档。 ↩
-
RISC-V — RISC-V 是开放的指令集架构。本系列主线是在原生 x86-64 Linux 上构建链接器;RV64 用于架构对照与内核案例。相关例子的编码、寄存器约定和重定位规则见 RISC-V psABI,不能直接套用 x86-64 的规则。 ↩
-
LTO — LTO(link-time optimization)在链接阶段协调编译器优化。它利用保留下来的中间表示跨文件分析,能力不同于仅处理本机目标文件的普通链接。 官方文档。 ↩
-
glibc — glibc(GNU C Library)是许多 Linux 发行版默认使用的 C 库。库的启动文件、共享库和动态链接器共同参与程序构建与运行。 官方文档。 ↩
-
FDE — FDE(Frame Description Entry)关联一段代码的地址范围与栈展开指令。移动代码或重新组织
.eh_frame时,链接器必须同步更新相关地址和记录间的引用。 官方文档。 ↩ -
gABI — gABI 是通用 System V ABI,规定 ELF 等跨架构规则;psABI 是处理器相关补充,进一步规定寄存器约定、重定位编号与 TLS 等细节。 官方文档。 ↩
-
psABI — psABI(processor-specific ABI)是特定处理器架构的二进制接口约定。不同架构可以共用 ELF 文件结构,同时拥有不同的指令、调用约定和重定位公式。 官方文档。 ↩
-
LLD — LLD 是 LLVM 项目的链接器。ELF 平台通常通过
ld.lld调用;lld-link则提供兼容 Windows 工具链的接口。它与负责处理源码的 Clang 是不同组件。 官方文档。 ↩ -
ICF — ICF(Identical Code Folding)合并被判定为等价的代码。字节相同不一定足以证明可合并,还需要考虑重定位目标、函数地址是否可观察以及关联的运行时元数据。 官方文档。 ↩
-
GC — 本文 GC 指 section garbage collection,即链接器从入口和其他根出发保留可达节、删除无用节。它发生在构建阶段,与运行时堆内存的垃圾回收不同。 官方文档。 ↩
-
LLVM — LLVM 是一组编译器与工具链项目的名称,包括优化基础设施、目标代码生成和相关工具。Clang、LLD 与 LLVM IR 各有职责,不能互作同义词。 官方文档。 ↩
-
UB — UB(undefined behavior,未定义行为)表示语言标准不再对该次执行提出行为要求。观察到某次输出可以解释具体产物,却不能把它当作可移植的程序结果;链接错误
undefined reference是另一类问题。 官方文档。 ↩ -
ODR — ODR(One Definition Rule)是 C++ 对定义一致性的要求。链接器选择了一个同名实例,并不能证明不同翻译单元中的定义满足语言规则。 官方文档。 ↩
-
COMDAT — COMDAT 让工具链表示可供择一保留的重复定义组。ELF 通过 section group 与签名表达相关关系;选择副本时,组内关联内容需要一致处理。 官方文档。 ↩
-
CFI — 在栈展开语境中,CFI 指 Call Frame Information,描述怎样从当前帧恢复调用者状态。安全加固语境中的 Control-Flow Integrity 也缩写为 CFI,两者需要按上下文区分。 官方文档。 ↩
-
musl — musl 是 Linux 的一种 C 标准库实现,提供
printf等库函数及运行时支持。本系列在需要分析或链接较小的静态运行库时使用它;普通 Linux 服务器不一定预装 musl。 官方文档。 ↩ -
binutils — GNU binutils 是一组处理目标文件的工具,包含汇编器
as、链接器ld,以及readelf、nm、objdump、ar等检查与归档工具。 官方文档。 ↩ -
objdump —
objdump可反汇编机器码,也能显示节和重定位信息。GNU 与 LLVM 版本的排版、指令写法和默认选项并不完全相同,本文命令保留具体工具名。 官方文档。 ↩ -
CIE — CIE(Common Information Entry)保存一组栈展开记录共用的规则和编码信息。FDE 引用它,再描述特定函数地址范围内的规则变化。 官方文档。 ↩