深入二进制系列:链接器发展简史
你写下
gcc a.c b.c -lpthread,回车,几十毫秒后多出一个能跑的可执行文件。这中间发生的事,叫链接。七十多年里,链接器要解决的核心问题——「把分散编译的代码片段,解析符号、重定位、拼成一个能跑的整体」——其实一个字都没变,变的只是规模和约束。本文按这条主线,从「为什么需要链接器」一路讲到 mold、wild 这些新一代链接器,以及它们身上背的那些优化。
这是「深入二进制」系列的又一篇。前面几篇——《符号导出三国志》、《在 Wine 里打开并读一个文件》、《线程局部存储演化简史》——拆的都是「动态库 / 运行时」那一侧。这次往前挪一步,回到把这些二进制生产出来的那个环节:链接器。它是整条工具链里最沉默的一个——平时根本想不起它,直到某天报一句 undefined reference to ...,或者大型项目的链接慢到让你想砸键盘。
一、为什么需要链接器:从「分离编译」说起
要理解链接器,得先理解一个更基础的决定:分离编译(separate compilation)。
设想没有它的世界:整个程序只能写在一个文件里,一次编译。改一行注释,全量重编;想复用别人写好的排序函数,只能把源码拷过来。这在程序只有几百行时还行,规模一上来就崩了。
于是很早人们就达成共识:把程序拆成多个源文件,各自独立编译成「目标文件(object file)」,最后再拼到一起。 这一拼,就拼出了两个绕不开的麻烦:
- 符号解析(symbol resolution):
a.c里调用了b.c定义的foo()。编译a.c时,编译器根本不知道foo将来在内存哪个地址,只能先留个「这里要调用一个叫foo的东西」的未决引用(undefined reference)。得有人在最后把所有这种引用,和它们真正的定义对上号。 - 重定位(relocation):每个目标文件都是「假设自己从地址 0 开始」编出来的。拼到一起时,
b.o可能被放到 0x4000 处,那它内部所有「写死的地址」(跳转目标、全局变量地址)都得整体平移、逐个修正。
做这两件事的程序,就是链接器。 一句话概括它的天职:
收集所有目标文件和库 → 把每个未决符号引用绑定到唯一的定义(解析)→ 给所有段分配最终地址、修正所有写死的地址(重定位)→ 按目标格式输出一个可执行文件或共享库。
这四步,是 1950 年代到今天每一个链接器都在做的事。后面你会看到的所有花样——BFD、单遍多遍、COMDAT、并行化、--gc-sections——没有一个跳出这个框,它们要么是让这四步更快,要么是处理这四步上冒出来的新约束。
顺带厘清两个常被混淆的角色:链接器(linker) 在「构建时」把目标文件拼成镜像;加载器(loader) 在「运行时」把镜像搬进内存、做最后的动态重定位。静态链接里活全是链接器干的;动态链接则是链接器和加载器分工——链接器留下「待运行时解析」的元信息,加载器接手。本系列前几篇讲的 PLT/GOT、符号插入、TLS,大半是加载器侧的故事,这篇专讲链接器侧。
二、最早的链接器:从手工到 linkage editor
链接的需求比「链接器」这个词还早。1940 年代末,冯·诺依曼和 Goldstine 就在讨论子程序库——把常用例程攒成一个库,用的时候「接」进主程序。最初这个「接」是人肉的:程序员手工把子程序的机器码抄到主程序后面,再手工把所有跳转地址改对。这就是最原始的「重定位」,由人脑完成。
很快这件事被自动化。1950 年代的汇编器开始能输出「可重定位」的代码,配套出现了链接装入器(linking loader)——装入程序时顺便完成符号解析和重定位。再往后,IBM 在 OS/360(1960 年代)上把这件事独立成一个工具:linkage editor(链接编辑器,IEWL)。它正式确立了「目标文件 + 库 → 可执行模块」的流水线,连「link」这个动词都是从这儿固定下来的。
Unix 把这套继承下来并简化成了那个沿用至今的名字——ld(它到底是 link editor 还是 loader 的缩写,至今两种说法都有人引,并无定论)。早期 Unix 的目标格式是 a.out,结构极简:就是代码段、数据段、符号表、重定位表几块拼起来。ld 干的就是本文第一节那四步,朴素而直接。
那个年代链接器的特征是:单一格式、单线程、跑得也不慢——因为程序本来就小。所有后续的复杂度,都是「格式变多」和「程序变大」逼出来的。
三、格式爆炸与 BFD:链接器开始背上「通用性」包袱
a.out 很快就不够用了。它太死板——段的种类写死、不支持调试信息的灵活扩展、共享库支持蹩脚。于是各家平台各自演化出自己的目标格式:
| 格式 | 出身 | 今天的地盘 |
|---|---|---|
| a.out | 早期 Unix | 已淘汰 |
| COFF | System V Unix | 演化成下面两个 |
| ELF | System V Release 4(约 1989–1990,ABI 1990 定稿) | Linux / *BSD / 几乎所有类 Unix |
| PE/COFF | Windows NT(基于 COFF) | Windows |
| Mach-O | CMU Mach 内核 → NeXTSTEP → macOS | macOS / iOS |
格式一多,链接器面前就出现一个工程抉择:是为每种格式写一个专用链接器,还是写一个能吃所有格式的通用链接器?
GNU 工具链选了后者,于是诞生了 BFD。BFD 是 Cygnus 的几位工程师(Steve Chamberlain、John Gilmore、K. Richard Pixley、David Henkel-Wallace 等)在 1990 年代初搞出来的一层抽象——名字本是句玩笑「Big F***ing Deal」,后来才被「正经化」成官方解释 Binary File Descriptor。它把「目标文件」抽象成一组统一的概念(节、符号、重定位项……),底下用不同的「后端」适配 ELF、COFF、a.out、Mach-O 等等几十种格式。GNU ld、as、objdump、gdb 全都站在 BFD 之上——写一次工具,吃所有格式。
这是一笔典型的工程交易:用「通用性」换「性能」。BFD 的统一抽象意味着每次访问一个符号、一个重定位项,都要穿过一层格式无关的间接;它的数据结构是为「什么格式都能表达」设计的,而非为「某种格式的链接最快」设计的。这个决定让 GNU ld 极其能打(什么稀奇古怪的格式和交叉编译目标都支持),但也给它日后的「慢」埋下了根——这是后面 gold、lld 要造反的直接原因。
与此同时,别家走的是「专用」路线:
- 微软 LINK(
link.exe):血脉可上溯到 MS-DOS 的 LINK,一路演化到今天 Visual C++ 的链接器,专吃 COFF/PE。它很早就支持增量链接(/INCREMENTAL)——只重链改动的部分,这在 GNU 世界长期是块短板。后来又长出/LTCG(链接时代码生成,即 MSVC 的 LTO)。 - Borland TLINK(Turbo Linker):Turbo Pascal / Turbo C 的链接器,在 DOS 时代以快著称——它是「专用换性能」的早期典范,专注单一平台、数据结构紧凑,链接速度甩开同期对手一大截。
- Apple
ld64:macOS 的 Mach-O 专用链接器(名字里的 64 指 64 位),下面会再提到它的重写。
一个有意思的对照:通用 vs 专用,是链接器世界反复上演的主题。 GNU
ld用 BFD 把通用性拉满,代价是慢;后来者 gold/lld/mold 几乎都是反方向——砍掉通用性、专攻主流格式、换取速度。七十年来格式在变、约束在变,但「通用还是专用」这道选择题一直摆在每个链接器作者面前。
四、单遍还是多遍:GNU ld 为什么对库顺序那么挑剔
几乎每个写过 C/C++ 的人都踩过这个坑:
1
2
gcc -lpthread a.o # 链接失败:undefined reference to pthread_create
gcc a.o -lpthread # 成功
明明库都给了,仅仅是顺序不同,结果天差地别。这不是 bug,而是经典链接器单遍扫描 + 库的「按需提取」语义的直接后果,根子在 ld 处理静态库(archive,.a) 的方式上。
要点是:目标文件(.o)和静态库(.a),链接器的待遇完全不同。
- 命令行上显式给的
.o,无条件全部链入。 - 而
.a只是一袋.o的打包。链接器从左到右扫描命令行,维护一张「当前还没解析的未决符号」表;扫到一个.a时,它只从袋里掏出恰好能解析当前未决符号的那些成员,其余的看都不看就跳过。
于是顺序就要命了:
-lpthread a.o:扫到pthread库时,未决符号表还是空的(a.o还没处理),链接器觉得「这库里没我需要的」,整个跳过;等扫到a.o,发现它要pthread_create,但库已经过去了,没人回头——报未定义。a.o -lpthread:先处理a.o,pthread_create进了未决表;再扫到库,正好掏出对应成员,解析成功。
所以铁律是:库要放在「用到它的目标文件」的后面。 而当两个库互相依赖(A 需要 B 的符号,B 又需要 A 的)时,无论怎么排都有一方落空,于是又有了 --start-group ... --end-group——让链接器对组内的库反复扫描直到没有新符号被解析,本质上是局部退化成了「多遍」。
严格说,现代
ld/ gold / lld 内部的符号解析早就不是字面意义的「单遍」了,但为了兼容几十年来无数 Makefile 依赖的这套 archive 语义,它们默认都保留了「库按出现位置、按需提取」的行为。所以这个坑至今还在。lld 提供了--start-lib/--end-lib让你把一堆.o当 archive 语义对待,mold 也沿用了这些;但默认行为谁都不敢改——这是「向后兼容」如何把一个上古设计冻进现代工具的活标本。
顺带一提,「单遍 vs 多遍」在另一个意义上也真实存在过:早期内存紧张的链接器确实要分多遍读盘(一遍建符号表、一遍做重定位),因为塞不下。今天内存便宜了,现代链接器恨不得把所有输入一次性 mmap 进来并行处理——约束(内存)变了,遍数的取舍也跟着变了,但它要解析的符号、要修的重定位,一个没少。
五、C++ 给链接器出的三道难题
C 程序对链接器的要求相对朴素。真正把链接器逼复杂的是 C++——它的几个语言特性,每一个都给链接器派了新活。
1. 全局对象的构造:谁来调用这些构造函数?
C++ 允许你写带构造函数的全局/静态对象:
1
2
std::string g_name = "hello"; // 程序启动时要调用 string 的构造函数
static Logger g_logger; // 同上
问题是:main 还没开始跑,这些构造函数得有人替你调。编译器的处理是:为每个 TU 生成一段「初始化代码」,把它的地址登记到一个特殊节里——ELF 上是 .init_array(老式叫 .ctors),Mach-O 上是 __mod_init_func。
链接器的活,就是把所有 TU 的这些节按序拼接成一整张「启动时要调用的函数指针表」,运行时的启动代码(crt)再遍历这张表挨个调用。析构则没这么整齐:现代 C++(Itanium ABI)其实是在构造时用 __cxa_atexit 运行时登记析构函数,ELF 上的 .fini_array 只是 -fno-use-cxa-atexit 时的退路,而 Mach-O 上对应的节叫 __mod_term_func——并没有统一的 .fini_array。
这也顺带解释了 C++ 著名的「静态初始化顺序灾难」:跨 TU 的全局对象,构造顺序取决于链接器拼接 .init_array 的顺序(基本就是目标文件在命令行上的顺序),而这个顺序语言标准不保证。你在 a.cpp 的全局对象构造里用了 b.cpp 的全局对象,而后者恰好排在后面还没构造——就踩雷了。根子在链接,不在编译。
2. inline 函数与模板:同一个东西被定义了 100 遍
C++ 的 inline 函数和模板,定义通常放在头文件里。于是每个包含该头文件的 TU 都会各自生成一份这个函数/模板实例的代码。链接 50 个 TU,可能就有 50 份一模一样的 std::vector<int>::push_back。
按 C 的规矩,「符号重复定义」是要报错的(multiple definition)。但 C++ 这里是合法的(单一定义规则 ODR 下的「vague linkage」),还得避免最终二进制里塞 50 份重复代码。
解法是编译器和链接器配合的 COMDAT:编译器把这种「可能在多个 TU 重复、但应当只保留一份」的符号,放进一个带名字的 COMDAT 组(ELF 里是 section group .group,PE 里是 COMDAT,Mach-O 用 coalesced symbols)。链接器看到同名的 COMDAT 组,只保留一份、丢弃其余的(COMDAT folding)。 这样既不报重复定义,也不会代码膨胀。
3. 弱符号:允许「重复」,但要会挑
和 COMDAT 相关但更通用的机制是弱符号(weak symbol)。普通符号是「强」的,重复定义即报错;弱符号允许多个定义,链接器自行挑一个,而且强定义永远压过弱定义。
C++ 的很多东西底层都落到弱符号上:模板实例、inline 函数、虚函数表、operator new 的默认实现……都标成 weak,这样:用户没自己定义就用库里的默认(弱)实现,用户一旦提供自己的(强)定义就自动覆盖。__attribute__((weak)) 把这个能力也开放给了 C。
这三道题的共同点是:它们都不改变「解析符号、重定位」这两件核心事,而是在「一个符号到底对应哪个定义」上加了新规则——可以重复、要去重、要按强弱挑选、要把分散的初始化收集起来。链接器的核心没变,是 C++ 给「符号解析」这一步追加了约束。
附带的红利:标识相同的代码可以合并
既然模板会产出大量「长得不一样但编译出来机器码一模一样」的函数(比如 vector<int*> 和 vector<char*> 的很多成员函数,指针操作的机器码完全相同),链接器干脆更进一步:ICF(Identical Code Folding,相同代码折叠)——扫描所有函数,把机器码逐字节相同的合并成一份。这个手法最早出现在微软的 link.exe(那边叫 identical COMDAT folding);gold 第一个把它带进 ELF 链接器(--icf,并贡献了 safe 模式),lld、mold 随后都支持。它能给 C++ 大项目省下可观的体积,代价是要小心:合并后两个函数地址相同,会破坏「函数指针相等性」,所以有 safe(只折叠取地址不影响正确性的)和 all 两档。
六、性能危机:当链接慢成了瓶颈
进入 2000 年代,C++ 项目的规模开始失控——Chrome、LLVM、大型游戏引擎,目标文件成千上万、符号数以百万计。链接,这个长期没人在意的「最后一步」,慢成了开发迭代的瓶颈。 改一行代码,编译只要一秒,链接却要等三十秒——而且链接是全量、串行的,增量编译省下的时间全被它吃掉。
GNU ld 在这个规模下尤其吃力:单线程,加上 BFD 那层为通用性付出的间接开销。于是一连串「为速度而生」的新链接器接连登场,几乎每一个的设计哲学都是同一句话——砍通用性、上并行、换速度。
gold(2008):第一次认真的提速尝试
gold 是 Ian Lance Taylor 在 Google 写的(他也是前述那套经典《Linkers》博客的作者)。它的核心决断是彻底抛弃 BFD,只支持 ELF——放弃了 GNU ld 引以为傲的全格式通用性,换来为 ELF 量身定做的紧凑数据结构和(部分)并行。在当年它确实快了一截,更关键的是它引入了链接器插件(plugin)接口——早期 GCC 和 LLVM 的 LTO 都得靠 gold 这套插件机制,才能在链接时回调编译器后端做全程序优化(这条接口后来 lld 也实现了)。
但 gold 今天已经正式退场。它没能跟上后来 lld/mold 的并行化程度,Google 自己也转投了 LLVM 工具链;binutils 2.44(2025 年 2 月)起 gold 被正式弃用,从默认源码包里拆出来单独打包,随时可能彻底移除。它更像一座承上启下的桥:证明了「专用化能换来速度」这条路走得通,然后把接力棒交给了 LLVM 阵营。
lld(~2017 成熟):LLVM 的全能选手
lld 是 LLVM 项目的链接器,主力作者是 Rui Ueyama。它的野心比 gold 大得多——不只 ELF,还吃 COFF/PE、Mach-O、WebAssembly,目标是「一个链接器通吃所有平台」,而且要快。它做了认真的多线程并行(符号解析、重定位并行跑),实测常比 GNU ld 快好几倍。
今天 lld 已是事实标准之一:Chrome、Android、FreeBSD、不少 Linux 发行版、几乎整个 LLVM/Rust 生态都用它——Rust 自 1.90(2025 年 9 月)起,已在 x86_64 Linux 上把 lld 设为默认链接器。它证明了「专用化」和「通用」不是非此即彼——可以为每种格式分别写高度优化的实现,再共用一套框架。
ld64:Apple 的重写
macOS 这边长期用自家的 ld64(Mach-O 专用)。老 ld64 在大项目上也慢。Apple 在 Xcode 15(2023) 端出了重写的新链接器(内部代号 ld_prime,可用 -ld_new / -ld_classic 切换新旧),官方称其显著更快——又是一次「老链接器扛不住规模,索性推倒重写」的经典剧情。
mold(2021):把并行做到极致
还是 Rui Ueyama,写完 lld 之后又写了 mold。如果说 lld 是「认真并行」,mold 就是「为并行重新设计一切」:几乎每个阶段都尽量并行,激进地预分配输出文件、用更适合并发的数据结构,能把现代多核机器吃满。在大型项目上 mold 常比 lld 再快好几倍,链接 LLVM 这种庞然大物从分钟级压到秒级。它现在主攻 Linux/ELF,是「想链接快」的人的首选之一。
wild(进行中):押注增量
最新的探索是 David Lattimore 用 Rust 写的 wild。它的赌注不在「全量更快」,而在增量链接(incremental linking)——改一个文件,只重链受影响的部分,其余原样复用,目标是把增量链接压到毫秒级,让链接快到「感觉不到」。这正好补上了 GNU 世界长期缺失、而 MSVC 早就有的 /INCREMENTAL 那一块。wild 还在开发中,但方向很有意思:当全量链接已经被 mold 压榨到接近内存带宽极限,下一个数量级的提速只能来自「根本不做全量」。
把这几代横着摆一起,那条主线再清楚不过——要解决的还是符号解析 + 重定位,但「规模」从几百个符号涨到上千万,逼着实现从「单线程通用」一路走到「极致并行专用」乃至「增量」:
| 链接器 | 出身 / 年代 | 格式 | 关键词 |
|---|---|---|---|
| GNU ld | binutils,元老 | 全格式(BFD) | 通用、单线程、慢 |
| gold | Google,2008 | 仅 ELF | 弃 BFD、提速首秀、引入插件接口、2025 已弃用 |
| lld | LLVM,~2017 | ELF/COFF/Mach-O/WASM | 跨平台、并行、新标准 |
ld64(新 / ld_prime) | Apple,Xcode 15(2023) | Mach-O | 重写、显著提速 |
| mold | Rui Ueyama,2021 | ELF(主) | 极致并行、最快之一 |
| wild | David Lattimore,进行中 | ELF | Rust、押注增量链接 |
七、链接器背上的优化:不止是「拼起来」
链接器站在「能看到整个程序」的独特位置上——所有目标文件、所有符号都汇聚于此。这个全局视角让它不只能「拼」,还能「优化」。这些年压在链接器身上的优化越来越多:
–gc-sections:丢掉用不到的代码
默认情况下链接器会把目标文件里的节原样搬进输出,哪怕某个函数压根没被调用。配合编译器的 -ffunction-sections -fdata-sections(把每个函数、每个变量单独放一个节),链接器就能做节级别的可达性分析:从入口点、导出符号等「根」出发遍历引用图,把没人引用到的节整个丢掉(--gc-sections)。这是嵌入式和追求体积的项目的标配,能显著瘦身。它本质上是链接期的「死代码消除」——又一个只有在「能看到全程序」时才做得了的优化。
重定位格式的瘦身:RELR 与 CREL
随着 PIE(位置无关可执行文件)成为默认,二进制里的重定位项多到惊人——一个大型 PIE 的相对重定位可以占掉可观的体积,也拖慢链接和加载。于是出现了两个「把重定位压扁」的格式:
- RELR(
DT_RELR):专门压缩相对重定位(R_*_RELATIVE,PIE 里的绝大多数)。它不再为每个重定位存一条完整记录,而是用位图编码连续的相对重定位,体积砍掉一大截。Android 先行,glibc 等随后跟进。 - CREL(Compact RELocations):MaskRay(Fangrui Song)2024 年提出的目标文件重定位新格式,用来取代 ELF 里那套冗长的
RELA。它用变长编码和增量编码把.rela节压到极小,既缩小.o体积,又加快链接(链接器要读的重定位数据少了)。目前 LLVM 已实现(仍标记为实验性,需--crel),binutils 侧还在推进中——是个很新、正在落地的格式。
这两个是「约束变了倒逼格式演化」的最新例子:PIE/ASLR 成为安全默认 → 重定位暴涨 → 链接器和格式一起想办法把它压回去。 要做的事(修地址)没变,但要修的数量变了,于是连「怎么表示一条重定位」都得重新设计。
冷热布局:让链接器摆放代码
链接器决定每个函数在最终二进制里的物理位置。既然如此,它就能按「冷热」重新摆放——把热函数聚到一起、冷函数(错误处理等)踢到远端,提升指令缓存和 iTLB 命中。lld 的 --symbol-ordering-file、ld64 的 -order_file 就是干这个的:喂一份函数顺序,链接器照排。
这件事和我上一篇《C++ 全局优化技术简史》里讲的 Propeller 直接接上了——Propeller 正是让编译器开启 basic-block sections,再由链接器按 profile 重排基本块来做 post-link 布局优化。也就是说,链接器从「被动拼接」变成了「按 profile 主动摆位」的优化执行者。
LTO:链接器召唤编译器后端
LTO(链接时优化) 严格说是编译器的活,但它寄生在链接这一步:编译期目标文件里塞的不是机器码而是 IR,链接器通过插件(LLVMgold.so / liblto)在链接时把 IR 交还给编译器后端,做全程序优化再生成机器码。所以现代链接器都得支持 LTO 插件协议。这块在上一篇里展开过,这里只点出它和链接器的接口关系。
Post-link:在链接之后再来一遍
最后,还有一类工具承认「链接器摆得还不够好」,干脆在链接之后重写二进制——BOLT、Propeller。它们拿着 profile 把整个二进制的代码布局再优化一遍,常能在 LTO + PGO 之上再榨出 5%~15%。严格说它们已经不算链接器,而是「链接后优化器(PLO)」——但它们的存在恰恰说明,「代码最终怎么摆」这件链接器开的头,重要到值得专门再来一遍。详见上一篇。
八、一条没变的主线
把七十年捋下来,会发现一件很反直觉的事:链接器的核心,几乎没变过。
1950 年代的 linkage editor 和 2024 年的 mold,干的是同一件事——收集分散编译的片段,解析符号,重定位,拼成一个能跑的整体。 第一节那四步,一步没多,一步没少。
变的全是规模和约束:
- 规模:符号从几百个涨到上千万 → 逼出并行(gold→lld→mold)、增量(wild)。
- 格式约束:a.out 一种 → ELF/PE/Mach-O/WASM 多种 → 逼出 BFD 的「通用」与后来者的「专用」之争。
- 语言约束:C 的朴素 → C++ 的构造函数 / 模板 / 弱符号 → 逼出
.init_array收集、COMDAT 去重、ICF。 - 安全约束:PIE/ASLR 成默认 → 重定位暴涨 → 逼出 RELR、CREL。
- 性能约束:cache / iTLB 成瓶颈 → 逼出
--gc-sections、冷热布局、LTO、post-link。
每一代链接器作者面对的,都是「在新的规模和新的约束下,把那同样的四步做对、做快」。理解了这一点,再去看 ld、gold、lld、mold、wild 之间那一堆眼花缭乱的差异,就都对得上号了——它们不是在解决不同的问题,是在不同的约束下解决同一个问题。
下次你敲下回车、链接器默默吐出一个可执行文件时,不妨想想:这个沉默的一步,背后是七十年没变的核心,和七十年没停的演化。
推荐阅读
- John R. Levine, Linkers and Loaders(《链接器与加载器》)—— 链接器领域的经典专著,符号解析、重定位、库、动态链接的原理讲得最透彻的一本,本文的骨架很大程度上来自它。
- 俞甲子 / 石凡 / 潘爱民,《程序员的自我修养——链接、装载与库》—— 中文世界讲链接装载最系统的一本,Windows(PE)和 Linux(ELF)两条线都讲,适合配合上一本对照着读。
- Ian Lance Taylor, Linkers 系列(2007,20 篇)—— gold 链接器作者写的链接器全景,从符号解析到共享库语义的来龙去脉,第一手的设计视角。
- MaskRay(Fangrui Song)的博客 —— LLVM 链接器/二进制方向最活跃的一线作者,Explain GNU style linker options、关于 RELR/CREL、
--gc-sections、archive 语义等都有深度长文,想追现代链接器细节必读。 - mold / lld / wild 的官方文档与设计笔记 —— 想看现代链接器「为什么这么快」的实现哲学,直接读源码作者的自述最实在。
- 本系列前几篇:《符号导出三国志》、《在 Wine 里打开并读一个文件》、《线程局部存储演化简史》,以及《C++ 全局优化技术简史》——和本文在「二进制 / 链接 / 加载」这条线上互为补充。