链接器工作原理
# 50.链接器工作原理
# 目录介绍
# 1. 案例引入
# 1.1 链接顺序灾——libB 在 libA 前面→undefined reference
某金融系统的模块编译单独通过——但最终链接时报了一堆 undefined reference:
$ g++ main.o -lA -lB -lC -o app
/usr/bin/ld: main.o: undefined reference to `ModuleA::init()'
/usr/bin/ld: libB.a(logger.o): undefined reference to `ModuleA::flush()'
2
3
排查——nm 确认 ModuleA::init 在 libA.a 中有定义:
$ nm libA.a | grep init
0000000000000000 T _ZN7ModuleA4initEv # ✅ 这里有定义!
2
根因:链接器从左到右扫描命令行。扫描到 libB.a 时:
libB.a(logger.o)引用了ModuleA::flush()→ 添加到「未解析列表」- 此时
libA.a还没被扫描——ModuleA::flush不在当前已知符号中 - 链接器向前找——没有更多的库了 →
undefined reference
修复——把被依赖的库放在后面:
$ g++ main.o -lB -lA -lC -o app # ✅ libA 在 libB 之后——链接器找到符号
# 1.2 强弱符号重叠
// config.cpp
int max_connections = 100; // 强符号——已初始化
// defaults.cpp
int max_connections; // 弱符号——未初始化(C 语言中的「暂定定义」)
// 链接器:选择 config.cpp 的强符号 max_connections = 100 ✅
// 但如果是两个已初始化的强符号:
// config.cpp
int max_connections = 100;
// main.cpp
int max_connections = 200; // ❌ 链接错误——重复符号
2
3
4
5
6
7
8
9
10
11
12
13
更隐蔽的陷阱——两个 .o 中的弱符号类型不同但大小相同:
// a.cpp
int counter = 0; // 4 字节
// b.cpp
float counter; // 也是 4 字节——弱符号
// 链接器选择 a.cpp 的强符号——b.cpp 中所有对 counter 的 float 操作
// 实际上读的是 int 的内存——数据错误但无链接错误!
2
3
4
5
6
7
# 1.3 七个待解疑问
① 链接器的输入是什么?.o 文件里有什么信息让链接器干活? → 第 2 / 第 3 章
② 链接器怎么找「未定义符号」的定义?什么时候报告 undefined reference? → 第 3 章
③ 重定位是什么?call 指令怎么变成「跳到具体地址」的? → 第 4 章
④ 强符号和弱符号的区别?链接器在两个同名符号间怎么选? → 第 5 章
⑤ 静态库 .a 和一堆 .o 有什么区别?链接器怎么从 .a 里「取出」需要的? → 第 6 章
⑥ --gc-sections 怎么工作?为什么有时要加 -ffunction-sections? → 第 7 章
⑦ 链接顺序为什么重要?循环依赖怎么办? → 第 8 章
2
3
4
5
6
7
# 2. 架构概览
# 2.1 输入与输出
输入:
main.o (来自 main.cpp 编译)
libfoo.a (libfoo 的静态库)
libbar.so (libbar 的动态库)
crt1.o (C 运行时的启动代码——_start → __libc_start_main → main)
...
│
▼
┌──────────────────────────────────────────────┐
│ 链接器 (ld / gold / lld) │
│ │
│ 阶段 1:符号解析 (Symbol Resolution) │
│ - 收集所有 .o 和 .a 中的符号定义 │
│ - 为所有未定义符号查找定义 │
│ - 报告 undefined reference 错误 │
│ │
│ 阶段 2:段合并 (Section Merging) │
│ - 把所有 .o 的 .text 合并为一个 .text │
│ - 把所有 .o 的 .data 合并为一个 .data │
│ - 重定位——修正所有地址引用 │
│ │
│ 阶段 3:输出布局 (Layout) │
│ - 确定各段在最终文件中的偏移 │
│ - 写入 ELF 头、程序头、段表 │
│ │
│ 阶段 4:动态链接信息 (Dynamic Linking) │
│ - 写入 PLT/GOT 条目(用于 .so 的函数调用) │
│ - 写入 .dynamic 段和动态符号表 │
└──────────────────────┬───────────────────────┘
│
▼
输出:app (可执行文件 ELF)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 2.2 为何这么切
符号解析(第一阶段):回答「这个符号在哪个 .o / .a 里?」
需要扫描所有输入文件——收集定义、匹配引用
输出:符号→地址的映射表
重定位(第二阶段):回答「这个 call 指令调用地址应该填什么?」
第一阶段完成后——每个符号都有地址了
扫描所有重定位表——把占位符 (0x00000000) 替换为真实地址
两者分开——因为第二阶段只能在「所有符号都已知」之后进行。
2
3
4
5
6
7
8
9
# 3. 符号解析
# 3.1 符号表的结构
$ nm main.o
0000000000000000 T main # T = .text 中的全局符号
0000000000000000 D global_counter # D = .data 中的全局符号
U _ZN6Logger3logEi # U = 未定义符号(需要链接器找)
0000000000000000 t static_helper # t = .text 中的本地符号(static)
0000000000000000 W _Z4maxIiET_S0_S0_ # W = 弱符号(模板实例化)
2
3
4
5
6
符号类型标记速查:
| 字母 | 含义 | 链接器行为 |
|---|---|---|
T | 代码段中的强符号 | 唯一定义——重复报错 |
t | 代码段中的本地符号 | 不跨 .o 可见 |
D | 数据段中的强符号 | 唯一定义 |
B | BSS 段中的未初始化全局变量 | 不会出现在文件中——运行时加载器分配 |
U | 未定义引用 | 必须被解析——否则 undefined reference |
W | 弱符号 | 可被同名的 T 或 D 覆盖 |
w | 未初始化的弱符号 | 同上 |
# 3.2 未定义符号的搜索路径
链接器在命令行输入中扫描——顺序从左到右:
① 先处理所有显式给出的 .o 文件(从左到右)
→ 收集所有定义的符号(T/D/B/W/v)
→ 记录所有未定义的符号(U)
② 然后处理 -l 选项指定的库(从左到右)
对每个 .a 库:
→ 检查库中是否有「当前未解析的 U 符号」
→ 如果有——从库中提取「包含该符号的 .o 文件」
→ 加入该 .o 的符号定义——可能引入新的 U 符号
→ 继续下一个 .o——不回头
③ 如果扫描完仍有 U 符号——报 undefined reference
2
3
4
5
6
7
8
9
10
11
12
13
14
关键: 链接器是单遍的、从左到右的。如果一个库的前面需要后面库中的符号——链接器看不到——必须调整顺序。
# 3.2.5 链接器内部——符号表的数据结构
链接器内部维护三个核心表:
① 全局符号表 (symbol hash table):
key = 符号名 (mangled name)
value = {地址, 类型 (T/D/U/W), 所在的 .o 文件}
→ 用于查找、检查重复定义
→ 哈希冲突通过链地址法解决
② 未解析列表 (undefined list):
当前所有 U 符号的列表
→ 每扫描一个 .o 或库 → 检查这个列表中的条目能否被满足
③ 库符号索引 (archive symbol index):
从 libfoo.a 的 __.SYMDEF 读到的映射
→ {符号名 → 所在 .o 文件名}
符号表的典型大小:
中等 C++ 项目 (200 个 .cpp):~20000 个已定义符号 + ~15000 个未定义引用
链接器用哈希表实现——O(1) 平均查找
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 3.3 增量提取
libfoo.a 的内部结构:
libfoo.a = foo1.o + foo2.o + foo3.o (通过 ar 打包)
附加:符号索引 (__.SYMDEF)——记录「哪个符号在哪个 .o 中」
链接器处理 libfoo.a 的流程:
① 读取索引 → 建立「符号→.o 文件」的映射
② 遍历当前未解析符号列表 → 查找索引
③ 如果 foo1.o 提供了某个未解析符号 → 从 .a 中提取 foo1.o
→ 将 foo1.o 的代码和数据合并到最终二进制
→ 同时——foo1.o 可能引入新的未解析符号(foo1.o 自己的 U 符号)
④ 继续检查——新引入的 U 符号是否在库的其他 .o 中
⑤ 库扫描完——不再回头
2
3
4
5
6
7
8
9
10
11
12
不是整个 .a 被链接——只提取需要的 .o。 这是静态链接的核心优势——未使用的 .o 不进入最终二进制。
# 3.4 为什么链接顺序至关重要
案例 1.1 的根因在这里完整展开:
命令行:g++ main.o -lB -lA
链接器视角:
① 扫描 main.o:
定义: main
未解析: ModuleB::process
② 扫描 -lB (libB.a):
检查索引→ ModuleB::process 在 b.o → 提取 b.o
b.o 中引入新的未解析符号: ModuleA::init, ModuleA::flush
检查索引→ libB.a 中没有 ModuleA 相关符号 → 跳过
③ 扫描 -lA (libA.a):
检查索引→ ModuleA::init, ModuleA::flush 都在 a.o → 提取 a.o
✅ 所有未解析符号找到
但如果是:
g++ main.o -lA -lB (A 在前,B 在后)
① 扫描 main.o → U: ModuleB::process
② 扫描 -lA → libA.a 提供 ModuleA::init, ModuleA::flush
但 main.o 不需要这些——libA 没有被提取!
③ 扫描 -lB → libB 提供 ModuleB::process → 提取 b.o
b.o 需要 ModuleA::init → 但 -lA 已经扫过了——不回头!
→ undefined reference: ModuleA::init ❌
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 4. 重定位
# 4.1 重定位表
每个 .o 文件中都有一个重定位表(.rela.text / .rela.data)
重定位条目的结构:
offset: 在段中的偏移 (需要修改哪个字节)
type: 重定位类型 (怎么算地址)
symbol: 引用哪个符号
addend: 加数 (在基址上加多少)
objdump -r 查看:
$ objdump -r main.o
0000000000000022 R_X86_64_PC32 _ZN6Logger3logEi-0x00000004
↑ offset ↑ type ↑ symbol ↑ addend
2
3
4
5
6
7
8
9
10
11
12
# 4.2 两种最常见重定位类型
R_X86_64_PC32 (PC 相对寻址——32 位偏移):
用于:同一 .o 内的函数调用、静态链接的外部函数调用
计算:target_addr + addend - (current_addr + offset)
限制:偏移必须在 ±2GB 内
R_X86_64_PLT32 (PLT 相对寻址——32 位偏移):
用于:动态库中的函数调用(通过 PLT 跳板)
和 PC32 相同——但链接器知道目标在 PLT 中
R_X86_64_64 (64 位绝对地址):
用于:64 位全局变量的地址引用
计算:target_addr + addend
链接器在 .got / .data 段中写入完整 64 位地址
真实重定位过程:
.o 中 call 指令的机器码:e8 00 00 00 00
后 4 字节是 0——占位符
链接器计算 PC32 偏移 → 0x00001234
修改后:e8 34 12 00 00
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 4.3 数据段的重定位
extern int global_counter; // 在另一个 .o 中定义
int* ptr = &global_counter; // 需要链接器填地址
2
; ptr 在 .data 段中:
; 未重定位:.quad 0x0000000000000000 ← 占位
; 重定位条目:R_X86_64_64 global_counter
; 链接后:.quad 0x0000000000404020 ← global_counter 的真实地址
2
3
4
和函数调用的关键差异:
- 函数调用用 PC32(相对偏移)——省空间、支持位置无关代码
- 全局变量地址用 64(绝对地址)——因为是数据段不是代码段,不能相对寻址
# 4.4 汇编层视角
# 编译但不链接
$ g++ -c main.cpp -o main.o
# 查看汇编 + 重定位
$ objdump -dr main.o
0000000000000000 <main>:
0: push %rbp
1: mov %rsp,%rbp
4: call 9 <main+0x9>
5: R_X86_64_PLT32 _ZN6Logger3logEi-0x4 ← 重定位条目
# 汇编中是 call 9(占位符=0)
# 重定位表说:在偏移 5 处填 PLT32(_ZN6Logger3logEi)
2
3
4
5
6
7
8
9
10
11
12
13
# 5. 强符号与弱符号
# 5.1 强弱符号规则——初始化 vs 未初始化 vs attribute((weak))
强符号(编译器默认):
int x = 42; // 已初始化的全局变量
void func() { ... } // 函数定义
弱符号:
int x; // C 中的「暂定定义」(tentative definition)
__attribute__((weak)) void func() { ... }
template <typename T> void max(T a, T b) { ... } // 模板实例化
链接器仲裁——同名符号:
① 一个强 + 零/多弱 → 选强 ✅
② 零强 + 一弱 → 选弱 ✅
③ 零强 + 多弱 → 任选其一 ✅(任意——但内容应该相同)
④ 多强 → ❌ 链接错误——重复符号
2
3
4
5
6
7
8
9
10
11
12
13
14
# 5.2 弱符号的典型用例
// 库的默认实现——弱符号
__attribute__((weak)) void error_handler(const char* msg) {
fprintf(stderr, "Error: %s\n", msg);
exit(1);
}
// 用户可以在自己的 .c/.cpp 中提供强符号版本——覆盖默认
void error_handler(const char* msg) {
// 用户自定义的错误处理——发送到远程日志服务
send_to_remote_logger(msg);
exit(1);
}
2
3
4
5
6
7
8
9
10
11
12
链接器看到两个 error_handler 符号——一个强的(用户版)、一个弱的(库版)→ 选强。
# 5.3 COMDAT与模板机制
第 49 篇已提到——这里从链接器视角展开:
模板函数在每个使用它的 TU 中实例化一次:
a.o: _Z4maxIiET_S0_S0_ (W - 弱符号)
b.o: _Z4maxIiET_S0_S0_ (W - 弱符号)
c.o: _Z4maxIiET_S0_S0_ (W - 弱符号)
链接器处理:
① 三个 .o 都定义了同名的弱符号
② 弱符号互不冲突——链接器任选一个(通常选第一个扫描到的)
③ 丢弃其他两个副本——只保留一份代码 + COMDAT 段标记
④ 最终二进制中:一份 max<int> 的代码
GCC 的实现:把每个 inline/template 函数放在独立的 .gnu.linkonce 段
链接器看到同名的 .gnu.linkonce 段 → 只保留第一份
2
3
4
5
6
7
8
9
10
11
12
13
# 6. 静态库 .a——归档文件的内在机制
# 6.1 .a 文件的结构
# 创建静态库
$ ar rcs libfoo.a foo1.o foo2.o foo3.o
# 查看内容
$ ar t libfoo.a
foo1.o
foo2.o
foo3.o
__.SYMDEF # ← 符号索引——加速链接器查找
# libfoo.a 的结构:
┌─────────────────────────────────────┐
│ ar magic ("!<arch>\n") │
├─────────────────────────────────────┤
│ __.SYMDEF │ ← 映射:符号名 → 所在 .o 文件
│ 符号1 → foo1.o │
│ 符号2 → foo1.o │
│ 符号3 → foo2.o │
├─────────────────────────────────────┤
│ foo1.o(完整 ELF .o 文件) │
├─────────────────────────────────────┤
│ foo2.o │
├─────────────────────────────────────┤
│ foo3.o │
└─────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
ranlib(ar s 中的 s)生成 __.SYMDEF 索引——没有这个索引链接器需要线性扫描库中所有 .o 文件。
# 6.2 对 .a 的处理方式
已在 3.3 和 3.4 详细展开。核心规则重复:
- 单遍扫描——处理过的 .a 不回头
- 按需提取——只取需要的 .o
- 提取后可能引入新未解析符号——在同一个库内继续找——但库处理完后放弃
# 6.3 循环依赖的解决方案
libA 依赖 libB 的符号、libB 依赖 libA 的符号——循环依赖
传统方案——命令行重复:
g++ main.o -lA -lB -lA -lB
→ libA 被扫描两遍——第二遍能找到 libB 需要的但之前遗漏的符号
GNU ld 方案——组循环扫描:
g++ main.o -Wl,--start-group -lA -lB -Wl,--end-group
→ --start-group 和 --end-group 之间的库被反复扫描
→ 直到不再有新符号被解析——或达到最大迭代次数
gold / lld 更智能——自动处理循环依赖——不需要 --start-group
2
3
4
5
6
7
8
9
10
11
12
# 7. --gc-sections——垃圾回收未使用的段
# 7.1 段级可达性分析
原理:
① 编译时:-ffunction-sections -fdata-sections
→ 每个函数 → 独立的 .text.<funcname> 段
→ 每个变量 → 独立的 .data.<varname> 段
② 链接时:--gc-sections
→ 从入口点 (_start) 开始标记所有引用的段
→ 递归遍历——标记传递闭包中所有可达的段
→ 丢弃不可达的段——省空间 + 省加载时间
这和 GC 的「标记-清除」算法是同构的——只是作用在「段」层而非「对象」层。
2
3
4
5
6
7
8
9
10
11
# 7.2 前置条件
# 没有 -ffunction-sections——所有函数在同一个 .text 段
$ g++ -c widget.cpp -o widget.o
$ objdump -h widget.o | grep .text
.text 000001a0 全在一个段——gc-sections 无法分开
# 有 -ffunction-sections——每个函数独立一段
$ g++ -c -ffunction-sections widget.cpp -o widget.o
$ objdump -h widget.o | grep .text
.text._ZN6WidgetC1Ei 00000020 Widget::Widget(int)
.text._ZN6WidgetD1Ev 00000018 Widget::~Widget()
.text._ZN6Widget7processEv 00000030 Widget::process()
.text._ZN6Widget5resetEv 00000028 Widget::reset()
# 每个函数独立——链接器可以分别保留/丢弃
2
3
4
5
6
7
8
9
10
11
12
13
# 7.3 实际收益
# 不使用 --gc-sections
$ g++ main.o -lfoo -o app
$ size app
text data bss dec hex
245678 8456 1234 255368 3e588
# 使用 --gc-sections
$ g++ main.o -lfoo -o app -Wl,--gc-sections -ffunction-sections -fdata-sections
$ size app
text data bss dec hex
182340 7200 1234 190774 2e936
# text 段减小 ~26%——主要来自未调用的模板实例化和小工具函数
2
3
4
5
6
7
8
9
10
11
12
--gc-sections 的边界情况:
- 构造函数注册表:如果用了
__attribute__((constructor))——链接器可能把「只被构造函数引用的函数」误判为不可达→丢弃 - 虚函数表:如果整个类都没有被实例化——但 vtable 被保留(因为 .rodata 的引用链)→浪费
- 显式模板实例化:
template class Foo<int>;强制生成所有成员函数——即使没有调用——gc-sections 不能丢弃(因为是显式请求的强符号)
和动态库的对比:
- 静态链接:gc-sections 只保留需要的段——效果好
- 动态库(.so):整个 .so 被加载——即使
--gc-sections在编译 .so 时工作——运行时整个 .so 仍在内存中 → 动态库的「未使用代码消除」需要更激进的手段——如把库拆成多个小的 .so
# 8. 常见陷阱与反模式
# 8.1 静态库顺序陷阱
案例 1.1 的完整解决方案:
# ❌ 被依赖方在前
g++ main.o -lA -lB # libB 需要 libA 的符号——但 libA 被扫时 libB 还没被扫
# ✅ 依赖方在前
g++ main.o -lB -lA # libB 的未解析符号在后续扫描 libA 时被满足
# ✅ 组循环(GNU ld)
g++ main.o -Wl,--start-group -lA -lB -Wl,--end-group
# ✅ CMake 自动处理——不需要手动排顺序
target_link_libraries(myapp libA libB) # CMake 自动生成正确的顺序
2
3
4
5
6
7
8
9
10
11
# 8.2 同名的全局变量
案例 1.2 的变种——static 在 TU 内隔离 vs 全局变量的跨 TU 冲突:
// a.cpp
int counter = 100; // 强符号
// b.cpp
int counter; // C 弱符号——链接器选择 a.cpp 的 100
// b.cpp 以为 counter 是 0——实际是 100!
// 解决方法:避免使用 C 风格的「暂定定义」
// b.cpp:int counter = 0; // 强符号→链接器报重复定义错误——及早发现
2
3
4
5
6
7
8
# 8.3 inline 函数地址不唯一
// common.h
inline int id() { return 42; }
// a.cpp
auto ptr_a = &id; // ptr_a = 0x401000
// b.cpp
auto ptr_b = &id; // ptr_b = 0x402000 ← 不同的地址!
// 虽然链接器通过 COMDAT 只保留了一份函数体——
// 但取函数地址的操作在编译期就决定了——每个 .cpp 编译时指向不同的偏移
2
3
4
5
6
7
8
9
10
11
C++17 inline 变量保证了跨 TU 唯一地址——函数不是变量——在 inline 函数中 &function 仍不保证唯一。
# 8.4 静态初始化顺序
// a.cpp
std::string global_name = "hello"; // 静态初始化——生成 __cxx_global_var_init
// b.cpp
extern std::string global_name;
int name_len = global_name.size(); // ⚠️ 如果 b.cpp 的初始化先于 a.cpp → UB
// 链接器把 __cxx_global_var_init 放在 .init_array 段——但不保证顺序!
2
3
4
5
6
7
# 9. 综合案例串讲
# 9.1 案例真相揭晓
| # | 疑问 | 答案 |
|---|---|---|
| ① | 链接器输入? | 第 3.1:每个 .o 有符号表(T/D/U/W)和重定位表 |
| ② | 怎么找符号? | 第 3.2-3.4:单遍从左到右——.o 全部收纳、.a 按需提取——不回头 |
| ③ | 重定位做什么? | 第 4 章:把 call 指令的占位符 0x00000000 替换为计算好的 PC32/PLT32 偏移 |
| ④ | 强弱符号怎么选? | 第 5.1:遇到强 + 弱→选强、多强→报错、多弱→选其一 |
| ⑤ | .a 和 .o 的区别? | 第 6 章:.a = 归档的 .o + 符号索引——链接器只提取需要的 .o |
| ⑥ | gc-sections 怎么工作? | 第 7 章:把每个函数/变量独立分段——从入口点标记可达段——丢弃不可达 |
| ⑦ | 链接顺序重要? | 第 8.1:单遍扫描——依赖方必须在前、被依赖方在后 |
案例①修复——链接顺序:g++ main.o -lB -lA ——依赖 libA 的 libB 放在前面。
案例②修复——强弱符号:用 -Wl,--warn-common 在链接时打印强弱符号冲突——把未初始化的全局变量改成显式初始化。
# 9.2 一次完整的链接过程
三个 .o 文件:main.o, logger.o, widget.o
═══════ 阶段 1:符号解析 ═══════
① 扫描 main.o:
符号表:U _Z5startv, T main
已知定义:main
未解析:_Z5startv
② 扫描 widget.o:
符号表:T _ZN6WidgetC1Ei, U _ZN6Logger3logEi
已知定义:main, Widget::Widget(int)
未解析:_Z5startv, Logger::log(int)
③ 扫描 logger.o:
符号表:T _ZN6Logger3logEi, T _Z5startv
已知定义:+ Logger::log(int), + start()
未解析:(清空——全部找到了!)
═══════ 阶段 2:段合并 + 排序 ═══════
所有 .text 段合并 → 一个 .text 段 (0x401000 起)
所有 .data 段合并 → 一个 .data 段 (0x404000 起)
所有 .rodata 段合并 → 一个 .rodata 段
→ 确定每个符号的最终地址
═══════ 阶段 3:重定位 ═══════
main.o 中的 call _Z5startv:
重定位前:e8 00 00 00 00
重定位后:e8 xx xx xx xx (start 的真实 PC32 偏移)
widget.o 中的 call _ZN6Logger3logEi:
同上——替换占位符为真实偏移
═══════ 阶段 4:输出 ═══════
写入 ELF 头 (file format + entry point)
写入段表 (各段在文件中的位置 + 大小)
写入符号表 (.symtab)
写入最终二进制 → app
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# 9.3 设计哲学回扣
哲学 1:链接器是「补洞工」——不是编译器、不是魔法师
编译器的每个 .o 文件里满是可以优化的代码片段和未填充的地址洞。链接器的工作不是生成新代码——是填补这些洞(重定位)和合并代码片段(段合并)。它的强大来自它的简单——一个单遍的、确定性的缝合机。
哲学 2:链接顺序的敏感性不是 bug——是单遍扫描的效率代价
如果链接器是双遍的——可以解决所有顺序问题。但单遍 = 不回头意味着不需要缓存所有符号、不需要磁盘 IO 回溯。1970 年代的链接器设计在内存不足的约束下做出了这个决定——今天的链接器(gold/lld)虽然内存充裕,但仍沿用了这个模型以保证兼容性和可预测性。
哲学 3:强弱符号不是魔法——是链接器在「定义冲突」时的自动裁决规则
C 语言的「暂定定义」(int x; 不初始化 = 弱符号)是为了让多个 .c 文件可以声明同一个全局变量而设计的。C++ 放弃了暂定定义——但 inline、template 继承了弱符号的语义。弱符号的本质:让「多份代码」在链接器中变成「一份代码」——不报错、静默削重。
哲学 4:静态库是最简单的代码分发机制——一个 .a 就是一个「按需加载的 .o 超市」
链接器走进 .a 这个超市——拿着「未解析符号」的购物清单——只拿需要的那几件 .o——然后继续赶路。这个模型在 40 年来没有本质变化——因为它简单、可预测、不需要额外的运行时支持。
# 9.4 速查表合集
nm 符号字母速查:
| 字母 | 含义 | 冲突行为 |
|---|---|---|
T | 代码段强符号 | 重复→错误 |
D | 数据段强符号 | 重复→错误 |
B | BSS 未初始化全局 | 弱→被强覆盖 |
U | 未定义引用 | 必须被解析 |
W | 弱符号(模板/内联) | 多弱→选其一 |
t / d | 本地符号 (static) | 不跨 .o 可见 |
链接顺序规则:
正确顺序:依赖方 → 被依赖方
命令行中——需要符号的 .o/.a 放在提供符号的 .o/.a 之前
循环依赖:--start-group ... --end-group
两组之间的库被反复扫描——直到不再有新符号被解析
2
3
4
5
重定位类型速查:
| 类型 | 适用场景 | 公式 |
|---|---|---|
R_X86_64_PC32 | 同一 .o 内函数调用、静态链接外部函数 | target + addend - PC |
R_X86_64_PLT32 | 动态库函数调用(通过 PLT) | 同上 |
R_X86_64_64 | 64 位全局变量地址 | target + addend |
--gc-sections 配置:
# 编译时——每个函数/变量独立分段
CFLAGS += -ffunction-sections -fdata-sections
# 链接时——丢弃不可达的段
LDFLAGS += -Wl,--gc-sections
2
3
4
5
本篇小结:链接器把一堆 .o 文件缝合成一个可执行文件——符号解析(找定义)+ 重定位(填充地址)+ 段合并(布局)。静态库 .a 是按需提取的 .o 超市——链接器被单遍扫描的约束决定了链接顺序至关重要。强弱符号是模板和内联函数的基石——让「多份代码」在链接器中自动归为一份。
--gc-sections把死代码消除从函数级推到段级——模板密集型项目可省 10-30% 的 .text 段。
下一篇:链接器把符号和段缝合好了——但 ODR 规则可能在缝合过程中埋下定时炸弹。下一篇进入 51.ODR规则与陷阱——一次定义规则、inline 变量 C++17、模板与 ODR、跨 TU 的 static 与匿名命名空间——链接器缝合时的 UB 典型场景。