链接(Linking)
两个 .c 文件,一个定义 int sum(int),另一个 extern 声明它,各自编译都过,最后 gcc main.o sum.o 才变成一个能跑的进程。可如果你把这两个 .o 的顺序换成 gcc sum.o main.o 之外再加个静态库,就可能蹦出一句 undefined reference to 'addvec'——而那个符号明明就在你传给链接器的 .a 里。链接器不是"把一堆字节拼起来",它是一道有状态的扫描过程:先收集符号,再决定谁被留下,最后才把地址填进指令里。
这一篇把 CSAPP 第 7 章讲透:编译系统四步、ELF 目标文件的内部结构、强/弱符号的三条规则、静态库为什么对命令行顺序敏感、重定位那条 PC 相对公式怎么手算、以及动态链接里 GOT/PLT 是怎么做到"第一次慢、以后直接跳"的。
1. 从 hello.c 到 a.out:四步走
直接观察每一步的产物:
1 | $ gcc -E hello.c -o hello.i # 展开 #include / #define,仍是 C 文本 |
注意 file 给的两个词:relocatable 和 executable。这是链接器出场前后最本质的区别——前者还带着"我不知道别人在哪"的欠条,后者所有欠条都兑付了。
2. ELF 可重定位目标文件长什么样
3. 符号、强符号与弱符号
链接器的账本上记三类符号:
- 全局符号:本模块定义的、非
static的函数和全局变量。 - 外部符号:本模块引用、但由别的模块定义的(
extern)。 - 局部符号:带
static的函数和变量——本模块内部可见,链接器不会拿它去跟别处匹配。
强弱之分是 GNU 工具链最反直觉的地方:
| 符号 | 强弱 |
|---|---|
| 函数名 | 强 |
| 已初始化的全局变量 | 强 |
| 未初始化的全局变量 | 弱 |
三条裁决规则:
- 不允许多个同名强符号,否则
multiple definition。 - 一个强符号 + 若干弱符号 → 选强符号(此时弱定义被静默丢弃)。
- 全是弱符号 → 任选一个(链接器挑它先看到的那个)。
规则 2 是真正吃人的那条:
1 | /* main.c */ |
1 | $ gcc -fcommon -o demo main.c f.c |
f() 只想写一个 double,但因为规则 2 选中了 main.c 里那个 4 字节的 int x,这 8 字节的写入有一半落到了相邻的 y 上——类型不匹配的定义,在没有报错的情况下改写了另一个变量。 所以 GCC 从 10 起把默认改成 -fno-common,让弱符号也不再静默合并:
1 | $ gcc -o demo main.c f.c # GCC 10+ 默认 -fno-common |
一句话:别在头文件里放 int foo; 这种试探性定义,要么 extern 声明,要么老老实实在一个 .c 里定义一次。
4. 静态库:为什么链接器对命令行顺序敏感
链接器维护三个集合,按顺序扫命令行:
- E:已经决定要合并进去的可重定位目标文件。
- U:引用了但还没找到定义的符号。
- D:已经找到定义的符号。
算法是这样的:碰到 .o 就无条件塞进 E;碰到 .a(静态库,其实就是一包 .o)只把能消掉当前 U 中符号的成员拽进 E,其余成员直接丢弃;扫完一遍若 U 还有残留,就报错。
1 | $ gcc -c main2.c |
5. 重定位:那条 PC 相对公式到底在算什么
符号解析只解决"谁对上谁",真正把地址写进指令是第二步——重定位。分两小步:先把所有同名节合并、给每个节和每个符号指派运行时地址;再遍历 .rel.text,按条目把引用处改写。
x86-64 的重定位条目(RELA)长这样:(offset, symbol, type, addend)。看一个真实例子:
1 | $ objdump -dx main.o |
e8 是 call 的操作码,后面 4 字节全是 0——汇编器不知道 sum 在哪,只能先填 0 并记一张欠条:"在 .text 偏移 0x1b 处,请填入 sum 的 PC 相对地址,addend 为 -4"。
R_X86_64_PC32(以及现代 GCC 产出的 R_X86_64_PLT32)的换算公式是:
1 | refaddr = ADDR(s) + r.offset |
对照 R_X86_64_PLT32:现代 GCC 即使调用最终会落在可执行文件内部,也倾向于走 PLT,这样在链接期用 -shared 或介入 LD_PRELOAD 时还能改写去向——多一层间接,换一份灵活。
6. 动态链接与延迟绑定:GOT / PLT
静态链接有两个大毛病:每个进程都各自带一份 libc 代码,磁盘和内存都浪费;库一升级就得重新链接。共享库(.so)的解法是:一份代码在内存里只留一份,被所有进程共享——前提是这段代码必须是位置无关代码(PIC),能加载到任意地址。
PIC 的两个关键技巧:
- 数据引用:不管代码被加载在哪,代码与它自己的
.data之间的距离是链接时已知的,所以用 PC 相对寻址先找到 GOT(全局偏移表,位于 .data 区),再从 GOT 里取出真正的绝对地址。 - 函数调用:PLT(过程链接表,位于 .text 区) + 延迟绑定。第一次调用才解析,之后直接跳。
1 | $ ldd hello |
7. 运行时打桩:用动态链接器"拦截"库函数
上面那套机制能被反向利用——不改动目标程序,就替换掉它对某个库函数的调用:
1 | /* mymalloc.c */ |
1 | $ gcc -shared -fPIC -o mymalloc.so mymalloc.c -ldl |
Python 走的是同一条路子,只是把"链接"推迟到运行时显式调用:
1 | import ctypes, os |
C 的链接期把符号翻译成地址,Python 的 ctypes 在运行时做同一件事——区别只在于:前者失败时是 undefined reference(链接都过不了),后者失败时是 AttributeError: undefined symbol(程序已经跑起来了才炸)。
8. C++ 与 Java:名字改编,和另一种"链接"
C++ 多了名字改编(name mangling)。 重载让"函数名"不足以定位符号,编译器把名字空间、类名、参数类型都编进去:
1 | $ cat f.cc |
要让 C++ 给 C 用,就得 extern "C" 关掉改编:
1 | extern "C" int f(int); // 符号就是朴素的 f,C 代码能链接上 |
nm 输出的字母也值得记:T = 在本节定义(.text)、U = 未定义(外部引用)、D/B = 在 .data/.bss 定义、t/d 小写 = 局部(static)。
Java 把链接搬进了 JVM。 类加载的五个阶段正是链接器的翻版:
| JVM 阶段 | 做的事 | C 链接器对应物 |
|---|---|---|
| 加载 Loading | 读字节码,生成 Class 对象 | 读 .o / .so |
| 验证 Verification | 校验字节码安全性 | (C 没有,这是漏洞的根源) |
| 准备 Preparation | 为静态字段分配内存并置零 | .bss / .data 分派 |
| 解析 Resolution | 常量池符号引用 → 直接引用 | 符号解析 + 重定位 |
| 初始化 Initialization | 执行 <clinit> |
(C 无对应,靠 __attribute__((constructor))) |
而且 JVM 的解析默认是懒的——直到第一次真正用到某个类/方法才解析,跟 PLT 延迟绑定是同一个思路。失败形态也一一对应:NoClassDefFoundError ≈ undefined reference,NoSuchMethodError ≈ "库版本不对,符号签名对不上了"(也就是 C 世界里那个经典的 symbol lookup error: version 'GLIBC_2.34' not found)。
9. 对照表:三种链接形态
| 维度 | 静态链接 | 动态链接(共享库) | 运行时加载(dlopen) |
|---|---|---|---|
| 发生时机 | 构建期 | 程序启动 / 首次调用 | 程序自己决定 |
| 产物 | 单个自包含可执行文件 | 可执行文件 + 若干 .so | 可执行文件 + 按需 .so |
| 磁盘占用 | 大,每个程序一份 libc | 小,全系统一份 | 最小,用不上的不加载 |
| 内存占用 | 每进程一份代码副本 | 代码页跨进程共享 | 同动态链接 |
| 启动速度 | 快(无解析开销) | 略慢(符号解析) | 首次 dlopen 慢 |
| 库升级 | 必须重新链接 | 替换 .so 即刻生效 | 替换 .so 即刻生效 |
| 典型失败 | undefined reference |
error while loading shared libraries |
dlopen: cannot open shared object file |
| 可移植性 | 强(拷过去就能跑) | 弱(依赖目标机有对应 .so) | 弱,且路径要自己管 |
小结
- 链接器的工作是"收集符号 → 决定保留谁 → 把地址填进指令",这三步对应符号解析、静态库扫描、重定位。
.o是可重定位的欠条,.so/可执行文件是兑付后的成品;.bss不占磁盘只占内存,.rel.text记录着所有待填地址的位置。- 强/弱符号规则里,规则 2 最危险:一个强定义 + 一个类型不同的弱定义,链接器一声不吭地选了强的,于是 8 字节的写入踩坏了 4 字节的变量。别在头文件里放试探性定义。
- 静态库对命令行顺序敏感,因为链接器只扫一遍、不回头;
.o放前面,库放后面,互相依赖时用--start-group。 - PC 相对重定位的公式是
ADDR(sym) + addend - refaddr,那个-4是在抵消%rip已经自增的 4 字节。 - 动态链接靠 GOT(可写数据)+ PLT(共享代码)实现延迟绑定:第一次兜一圈回填,之后一步到位;代价是 GOT 可写带来的攻击面,用 RELRO 可以关掉。
LD_PRELOAD/--wrap/ 自定义头文件是三层打桩手段,不改源码就能拦截库函数——调试内存问题的利器。
下一篇进入处理器架构与流水线,看看一条指令从取指到写回,硬件内部到底走了几步。

