每一个 C 程序员都遇到过这样的错误:undefined reference to 'foo' 或 multiple definition of 'bar'。编译通过了,但链接器拒绝了你的代码。那一刻,你可能只会机械地检查头文件、库路径或声明顺序,然后继续工作。但你是否想过,链接器究竟在做什么?为什么它能容忍某些重复定义,却对另一些报错?为什么同一个程序在静态链接和动态链接下行为不同?
链接器是编译工具链中最被低估的组件。它没有编译器那么"智能",没有操作系统那么"底层",但它是连接代码与执行的桥梁。理解链接器,不仅能让你更快地诊断那些诡异的各种链接错误,更能让你理解程序的真正结构。
链接器解决的核心问题
回到 1970 年代的 Bell Labs,Ken Thompson 和 Dennis Ritchie 正在开发 Unix。他们发现了一个问题:当一个程序变得越来越大,不可能把所有代码都塞进一个源文件里。于是他们发明了模块化编译的概念——每个源文件独立编译成目标文件,然后通过一个程序把它们"链接"在一起。
这个程序就是链接器。它解决的核心问题是:如何在不知道其他模块具体实现的情况下,正确地调用它们的函数和访问它们的数据?
编译器在编译每个源文件时,并不知道其他源文件中定义的函数和变量最终会被放在内存的哪个位置。它只能在目标文件中留下"占位符"——告诉链接器"这里需要一个地址,请帮我填上"。
这个过程看似简单,实则涉及两个核心机制:符号解析(Symbol Resolution)和重定位(Relocation)。
静态链接的两阶段过程
静态链接通常分为两个阶段,这个过程被称为"两遍链接"(Two-Pass Linking)。
第一阶段:符号解析
链接器首先扫描所有的目标文件和库文件,构建一个全局符号表。每个目标文件都包含自己的符号表,记录了三种类型的符号:
- 全局符号:在本模块中定义,可被其他模块引用的函数和全局变量
- 外部符号:在本模块中引用,但在其他模块中定义的符号
- 本地符号:仅在本模块内部可见的静态函数和静态变量
链接器维护一个"未解析符号集合",遍历所有目标文件,将定义的符号加入符号表,同时尝试解析引用的符号。如果某个符号被引用但从未定义,就会产生 undefined reference 错误。
这个过程类似于解方程组:每个目标文件提供一些"已知量"(定义的符号)和一些"未知量"(引用的外部符号),链接器的任务是找到一组满足所有约束的解。
第二阶段:重定位
符号解析完成后,链接器知道了每个符号的最终位置。现在它需要回到目标文件中,把那些"占位符"替换成真正的地址。
这个过程就是重定位。每个目标文件中都包含一个重定位表(Relocation Table),记录了代码中哪些位置需要被修正,以及如何修正。
重定位的数学公式可以表示为:
$$\text{relocated\_value} = S + A - P$$其中:
- $S$ = 符号的最终地址(Symbol address)
- $A$ = 重定位项中的加数(Addend),存储在重定位表中
- $P$ = 需要被重定位的位置的地址(Place of relocation)
不同的重定位类型使用不同的计算公式。例如,x86-64 上的 R_X86_64_64 类型执行的是绝对地址重定位:
而 R_X86_64_PC32 类型执行的是相对地址重定位:
这种区分的原因是现代 CPU 支持两种寻址方式:绝对寻址和相对寻址。相对寻址不需要知道绝对地址,只需要知道"距离当前位置有多远",这对位置无关代码(PIC)至关重要。
ELF 文件的结构:链接器的"数据库"
要理解链接器如何工作,必须理解目标文件的格式。在 Linux 和大多数 Unix 系统上,标准格式是 ELF(Executable and Linkable Format)。
一个 ELF 文件由多个节(Section)组成。每个节是一块连续的数据,具有特定的属性和用途。常见的节包括:
| 节名 | 内容 | 属性 |
|---|---|---|
.text |
程序代码 | 可执行、只读 |
.data |
已初始化的全局变量 | 可读写 |
.rodata |
只读数据(字符串常量等) | 只读 |
.bss |
未初始化的全局变量 | 可读写、不占文件空间 |
.symtab |
符号表 | 链接时使用 |
.rel.text |
.text 节的重定位表 |
链接时使用 |
.strtab |
字符串表 | 存储符号名等字符串 |
ELF 符号表中的每个条目是一个 Elf64_Sym 结构:
typedef struct {
Elf64_Word st_name; // 符号名在字符串表中的偏移
unsigned char st_info; // 符号类型和绑定属性
unsigned char st_other; // 可见性
Elf64_Section st_shndx; // 符号所在的节索引
Elf64_Addr st_value; // 符号的值(地址)
Elf64_Xword st_size; // 符号的大小
} Elf64_Sym;
其中 st_info 字段编码了符号的绑定属性(Binding),这决定了链接器如何处理多个同名符号:
STB_LOCAL:本地符号,只在当前目标文件内可见STB_GLOBAL:全局符号,对整个链接过程可见,只允许一个定义STB_WEAK:弱符号,类似于全局符号,但优先级更低
强符号与弱符号:链接器的裁决规则
弱符号是理解链接器行为的关键概念。当一个符号被声明为弱符号时,链接器对它的处理方式会发生变化。
GCC 和 Clang 通过 __attribute__((weak)) 将一个符号声明为弱符号。C++ 中的内联函数和模板实例化默认就是弱符号。
链接器处理符号的规则如下:
规则一:不允许有多个同名强符号。如果存在,报错 multiple definition。
规则二:如果有一个强符号和多个弱符号同名,选择强符号,忽略所有弱符号。
规则三:如果只有多个弱符号同名,选择任意一个(通常是第一个遇到的)。
这些规则解释了为什么模板可以在多个编译单元中实例化而不报错——编译器将每个实例化都标记为弱符号,链接器会自动选择其中一个,丢弃其他的。
// file1.c
__attribute__((weak)) int config_value = 100;
// file2.c
int config_value = 200; // 强定义覆盖弱定义
// main.c
extern int config_value;
printf("%d\n", config_value); // 输出 200
弱引用是另一个相关的概念。一个未定义的弱引用在链接时如果找不到定义,不会报错,而是被赋值为 0。这允许程序可选地依赖某个功能:
__attribute__((weak)) void optional_hook(void);
void run_hook(void) {
if (optional_hook) {
optional_hook();
}
// 如果 optional_hook 没有定义,条件为假,安全跳过
}
COMDAT:模板实例化的优雅解决方案
虽然弱符号可以处理模板实例化的重复问题,但它有一个缺点:所有重复定义都会被完整编译,浪费时间和空间。
COMDAT(Common Data)机制提供了更优雅的解决方案。编译器可以将一个节标记为 COMDAT,链接器在遇到多个同名 COMDAT 节时,只保留其中一个,丢弃其他。
在 ELF 中,这是通过节组(Section Group)实现的。每个节组有一个签名(通常是符号名),链接器保证每个签名最多只保留一个节组。
.section .text._ZNK5thingIjE2idEv,"axG",@progbits,_ZNK5thingIjE2idEv,comdat
.weak _ZNK5thingIjE2idEv
_ZNK5thingIjE2idEv:
// 函数代码
这段汇编代码展示了一个模板成员函数的定义。"axG" 表示这是一个代码节(allocatable、executable、Group),后面的 _ZNK5thingIjE2idEv,comdat 指定了节组的签名和类型。
动态链接:延迟绑定的艺术
静态链接把所有代码打包成一个可执行文件,简单直接,但有问题:如果每个程序都包含一份 libc 的代码,磁盘和内存会被大量重复的代码占满。动态链接解决了这个问题——多个程序可以共享同一个动态库的代码。
但动态链接带来了新的挑战:动态库的加载地址在编译时是未知的。程序怎么调用一个地址未知的函数?
解决方案是 PLT(Procedure Linkage Table) 和 GOT(Global Offset Table) 的组合。
PLT 是一小段跳板代码,每个外部函数都有一项。GOT 是一个地址表,存储外部函数的真实地址。调用一个动态库函数的流程如下:
第一次调用:
- 程序调用
foo@plt - PLT 代码跳转到 GOT 中存储的地址
- GOT 中的地址指向 PLT 的下一条指令(第一次调用时)
- PLT 调用动态链接器的
_dl_runtime_resolve - 动态链接器找到
foo的真实地址,写入 GOT - 跳转到
foo执行
后续调用:
- 程序调用
foo@plt - PLT 代码跳转到 GOT 中存储的地址
- GOT 中已经是
foo的真实地址,直接执行
这种机制称为延迟绑定(Lazy Binding)。它带来的好处是:只有实际被调用的函数才会被解析,节省了程序启动时间。
PLT entry for puts:
puts@plt:
jmp *GOT[puts] # 跳转到 GOT 中的地址
push $offset # 压入重定位信息
jmp resolver # 跳转到解析器
图片来源: maezyn.com - PLT and GOT
位置无关代码:ASLR 的基石
现代操作系统都启用了 ASLR(Address Space Layout Randomization),这是一种安全机制,每次程序运行时,其代码段、数据段、栈、堆的地址都会随机化。这使得攻击者难以预测关键地址,大大提高了漏洞利用的难度。
但要支持 ASLR,代码必须是位置无关的。这意味着代码不能包含任何硬编码的绝对地址,必须能够在任意地址正确执行。
位置无关代码的关键技术是全局偏移表(GOT)。所有全局变量和外部函数的访问都通过 GOT 进行,而 GOT 的地址在运行时由动态链接器根据实际加载地址修正。
在 x86-64 上,访问全局变量的典型代码如下:
mov foo@GOTPCREL(%rip), %rax # 从 GOT 获取 foo 的地址
mov (%rax), %eax # 读取 foo 的值
这里使用了 RIP 相对寻址:%rip 是当前指令指针,foo@GOTPCREL(%rip) 表示"GOT 中 foo 条目的地址,相对于当前指令的位置"。这种寻址方式不依赖任何绝对地址,完全满足位置无关的要求。
链接器脚本:控制内存布局的利器
默认情况下,链接器会按照约定俗成的规则安排各个节的位置。但在嵌入式开发、操作系统内核等场景中,开发者需要精确控制代码和数据在内存中的布局。这就是链接器脚本(Linker Script)的用武之地。
链接器脚本使用一种特殊的命令语言,告诉链接器:
- 输入节的输出位置
- 各节的内存地址(VMA)和加载地址(LMA)
- 需要保留或丢弃的符号
一个简单的链接器脚本示例:
ENTRY(_start)
MEMORY {
RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 128M
ROM (rx) : ORIGIN = 0x00000000, LENGTH = 1M
}
SECTIONS {
.text : {
*(.text)
*(.text.*)
} > ROM
.data : {
*(.data)
*(.data.*)
} > RAM AT > ROM
.bss : {
*(.bss)
*(.bss.*)
} > RAM
}
这个脚本定义了两个内存区域:RAM 和 ROM。代码段 .text 放在 ROM 中,数据段 .data 和 .bss 放在 RAM 中。AT > ROM 表示 .data 的初始值存储在 ROM 中,程序启动时需要复制到 RAM。
链接器脚本还允许定义符号,供程序使用:
SECTIONS {
.text : {
__text_start = .;
*(.text)
__text_end = .;
}
}
程序可以通过这些符号获取代码段的起止地址:
extern char __text_start[], __text_end[];
size_t text_size = __text_end - __text_start;
现代链接器的性能优化
链接大型项目可能非常耗时。Chromium 项目的链接时间曾经超过两分钟。现代链接器引入了多种优化技术来加速这个过程。
并行链接
传统的 GNU ld 是单线程的。LLVM 的 LLD 和 rui314 开发的 Mold 采用了并行架构,利用多核 CPU 加速链接。
Mold 的设计哲学是:链接是一个"令人尴尬的并行"(Embarrassingly Parallel)问题。符号解析需要全局视图,但重定位可以并行进行。Mold 将文件读取、符号解析、重定位等阶段都并行化,在现代 CPU 上可以实现 5-10 倍的加速。
ICF(Identical Code Folding)
很多程序中存在功能相同但来自不同编译单元的函数。ICF 技术通过计算函数的哈希值,识别出完全相同的函数,将它们合并为一个,只保留一份代码。
ICF 的效果非常显著。Chrome 浏览器通过 ICF 减少了约 10% 的代码大小。但 ICF 也有争议:它破坏了"一个函数一个地址"的假设,可能影响某些依赖函数地址唯一性的代码。
增量链接
开发过程中,每次修改代码都重新进行完整链接太慢。增量链接只重新链接修改过的部分,大幅减少链接时间。Visual Studio 的 link.exe 和 Clang 的 -Wl,--incremental 都支持这种模式。
ELF、PE 和 Mach-O:三大平台的设计哲学
不同操作系统使用不同的可执行文件格式,这反映了它们的历史和设计哲学。
ELF(Linux/Unix):设计简洁、灵活,同时支持静态和动态链接。一个文件可以是可重定位目标文件、可执行文件或共享库。节(Section)和段(Program Header)分离,分别服务于链接和执行。
PE(Windows):源自 COFF 格式,设计更偏向执行效率。Windows 的动态链接使用导入表(Import Table)和导出表(Export Table),机制与 ELF 的 GOT/PLT 类似但细节不同。
Mach-O(macOS/iOS):支持"胖二进制"(Fat Binary),一个文件可以包含多个架构的代码。使用"加载命令"(Load Command)描述文件结构,设计更加面向对象。
一个有趣的设计差异:在 Mach-O 中,弱引用可以触发静态库的提取;而在 ELF 中,弱引用不会触发静态库提取。这可能导致相同的代码在 macOS 和 Linux 上有不同的链接行为。
安全与链接器:RELRO 和 BIND_NOW
链接器在程序安全中扮演重要角色。RELRO(Relocation Read-Only)是一种重要的安全特性,分为两种级别:
Partial RELRO:.dynamic 段和 .got 段(但不包括 .got.plt)被标记为只读。这保护了动态链接器写入的元数据,但不保护延迟绑定的函数地址。
Full RELRO:在程序启动时立即解析所有符号(禁用延迟绑定),然后将整个 GOT 标记为只读。这防止了 GOT 覆盖攻击,但会增加启动时间。
启用 Full RELRO 的编译选项:
gcc -Wl,-z,relro,-z,now source.c
-z,now 告诉动态链接器立即绑定所有符号,等价于设置 LD_BIND_NOW=1。
另一个重要的安全特性是栈执行保护(NX bit),链接器在生成可执行文件时会设置适当的段权限,防止代码在栈上执行。
常见链接错误及解决方案
理解链接器原理后,很多"神秘"的链接错误就变得清晰了。
undefined reference
最常见的原因是忘记链接库。解决方法是检查链接命令,确保所有依赖库都被包含。注意库的顺序:在 GCC 中,链接器从左到右处理库,所以依赖库要放在使用它的代码之后。
# 错误:库在使用它的代码之前
gcc -lm main.c
# 正确:库在使用它的代码之后
gcc main.c -lm
multiple definition
通常是某个函数或变量在头文件中定义(而不是声明),然后被多个源文件包含。解决方案是使用 static、inline 或将定义移到源文件中。
静态库中的弱引用问题
如果一个弱引用的符号定义在静态库中,链接器可能不会提取那个库成员。这是因为弱引用不触发静态库提取。解决方案是将弱引用改为强引用,或者使用链接器选项强制提取。
链接时优化(LTO):打破编译单元边界
传统编译模型中,编译器只能看到一个编译单元内的代码,无法进行跨单元优化。LTO(Link Time Optimization)改变了这一点:编译器在编译时生成中间表示(IR),链接器在链接时合并所有 IR,然后作为一个整体进行优化。
LTO 可以实现传统编译无法做到的优化:
- 跨单元内联:即使函数定义在其他源文件中,也可以内联
- 全局死代码消除:删除整个程序中未被使用的函数
- 全局常量传播:传播跨文件常量值
- 虚函数去虚拟化:在知道具体类型时,将虚函数调用转换为直接调用
启用 LTO 的代价是链接时间增加和内存消耗增加。但对于大型项目,性能提升往往是值得的。
gcc -flto -O2 source1.c source2.c -o program
链接器的未来
链接器作为一个"古老"的工具,仍在不断演进。Mold 的出现证明了即使在这个看似成熟的领域,仍有巨大的优化空间。静态链接的复兴(Go 语言默认静态链接、Alpine Linux 使用 musl)重新引发了关于链接策略的讨论。WebAssembly 的模块系统则提出了全新的链接语义。
无论如何,理解链接器的工作原理是每个系统程序员的必修课。下次遇到链接错误时,不要只是搜索解决方案——花点时间理解错误背后的机制,你会发现链接器是一个精密而优雅的设计。
参考资料
- John R. Levine. Linkers and Loaders. Morgan Kaufmann, 1999.
- ELF Format Specification. https://refspecs.linuxbase.org/elf/gabi4+/contents.html
- Ian Lance Taylor. Linkers series. https://lwn.net/Articles/276782/
- Rui Ueyama. Mold: A Modern Linker. https://github.com/rui314/mold
- MaskRay (Fangrui Song). Blog posts on linking. https://maskray.me/blog/
- Procedure Linkage Table and Global Offset Table. https://maezyn.com/articles/plt_and_got/
- Everything You Never Wanted To Know About Linker Script. https://mcyoung.xyz/2021/06/01/linker-script/
- Oracle. Linker and Libraries Guide. https://docs.oracle.com/cd/E19683-01/816-1386/
- Thread-Local Storage. https://chao-tic.github.io/blog/2018/12/25/tls
- RELRO: Relocation Read-Only. https://www.redhat.com/en/blog/hardening-elf-binaries-using-relocation-read-only-relro