两个 .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:四步走

预处理器 cpp hello.c → hello.i 编译器 cc1 hello.i → hello.s 汇编器 as hello.s → hello.o 链接器 ld *.o + *.so → hello 编译系统四步:只有最后一步才涉及「多个文件」 前三步每个 .c 各干各的,彼此互不知情 —— 这就是「跨文件引用」必须留给链接器解决的原因。 hello.i 是 ASCII 中间文件,hello.s 是汇编,hello.o 是可重定位目标文件,hello 才是可执行目标文件。

直接观察每一步的产物:

1
2
3
4
5
6
7
$ gcc -E hello.c -o hello.i    # 展开 #include / #define,仍是 C 文本
$ gcc -S hello.i -o hello.s # 生成 x86-64 汇编
$ gcc -c hello.s -o hello.o # 生成 ELF 可重定位目标文件
$ gcc hello.o -o hello # 链接:补上 printf、_start 等
$ file hello.o hello
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
hello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, ...

注意 file 给的两个词:relocatable 和 executable。这是链接器出场前后最本质的区别——前者还带着"我不知道别人在哪"的欠条,后者所有欠条都兑付了。

2. ELF 可重定位目标文件长什么样

ELF 可重定位目标文件(.o)的内部结构 ELF 头(魔数、类型、节头表位置) .text 已编译的机器代码 .rodata 只读数据(字符串、跳转表) .data 已初始化的全局 / 静态变量 .bss 未初始化的全局 / 静态变量 .symtab 符号表(定义 / 引用的符号) .rel.text .text 中待重定位的位置 .rel.data .data 中待重定位的指针 .debug / .line / .strtab / 节头表 代码 占磁盘 不占磁盘,只占位 链接器的账本 欠条清单 .bss 在文件中只记录「要留多大地方」,真正占内存是加载时的事 —— 全是零,没必要存。 局部变量不进 .symtab:它们在运行时栈上,链接器根本不需要知道。

3. 符号、强符号与弱符号

链接器的账本上记三类符号:

  • 全局符号:本模块定义的、非 static 的函数和全局变量。
  • 外部符号:本模块引用、但由别的模块定义的(extern)。
  • 局部符号:带 static 的函数和变量——本模块内部可见,链接器不会拿它去跟别处匹配。

强弱之分是 GNU 工具链最反直觉的地方:

符号 强弱
函数名 强
已初始化的全局变量 强
未初始化的全局变量 弱

三条裁决规则:

  1. 不允许多个同名强符号,否则 multiple definition。
  2. 一个强符号 + 若干弱符号 → 选强符号(此时弱定义被静默丢弃)。
  3. 全是弱符号 → 任选一个(链接器挑它先看到的那个)。

规则 2 是真正吃人的那条:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* main.c */
#include <stdio.h>
int y = 15212;
int x = 15213; /* 强符号:已初始化的 int */
void f(void);
int main(void) {
f();
printf("x = 0x%x y = 0x%x\n", x, y);
return 0;
}

/* f.c */
double x; /* 弱符号:未初始化,被当成 8 字节 */
void f(void) { x = -0.0; } /* 写 8 个字节,直接踩到 y */
1
2
3
$ gcc -fcommon -o demo main.c f.c
$ ./demo
x = 0x0 y = 0x80000000

f() 只想写一个 double,但因为规则 2 选中了 main.c 里那个 4 字节的 int x,这 8 字节的写入有一半落到了相邻的 y 上——类型不匹配的定义,在没有报错的情况下改写了另一个变量。 所以 GCC 从 10 起把默认改成 -fno-common,让弱符号也不再静默合并:

1
2
$ gcc -o demo main.c f.c          # GCC 10+ 默认 -fno-common
/usr/bin/ld: f.o:(.bss+0x0): multiple definition of `x'; main.o:(.data+0x0): first defined here

一句话:别在头文件里放 int foo; 这种试探性定义,要么 extern 声明,要么老老实实在一个 .c 里定义一次。

4. 静态库:为什么链接器对命令行顺序敏感

链接器维护三个集合,按顺序扫命令行:

  • E:已经决定要合并进去的可重定位目标文件。
  • U:引用了但还没找到定义的符号。
  • D:已经找到定义的符号。

算法是这样的:碰到 .o 就无条件塞进 E;碰到 .a(静态库,其实就是一包 .o)只把能消掉当前 U 中符号的成员拽进 E,其余成员直接丢弃;扫完一遍若 U 还有残留,就报错。

静态库解析:同一个库,放在前面还是后面,结果完全不同 正确顺序:gcc -static -o p main.o libvector.a main.o 进 E U = {addvec} 扫 libvector.a addvec.o 命中 → 进 E U 清空 → 链接成功 multvec.o 被丢弃(没用到) 错误顺序:gcc -static -o p libvector.a main.o 扫 libvector.a U 是空的 → 全部丢弃 main.o 进 E U = {addvec} U 非空,后面没人了 undefined reference to addvec 链接器只扫一遍、不回头 —— 库被掠过时 U 里还没那个符号,这个成员就永远失去机会了。 口诀:把 .o 放前面,库放后面;库之间若互相依赖,要么重复列出,要么用 --start-group / --end-group 包起来。
1
2
3
4
5
$ gcc -c main2.c
$ gcc -static -o p2 main2.o libvector.a # 库在后面:OK
$ gcc -static -o p2 libvector.a main2.o # 库在前面:
/usr/bin/ld: main2.o: in function `main':
main2.c:(.text+0x1a): undefined reference to `addvec'

5. 重定位:那条 PC 相对公式到底在算什么

符号解析只解决"谁对上谁",真正把地址写进指令是第二步——重定位。分两小步:先把所有同名节合并、给每个节和每个符号指派运行时地址;再遍历 .rel.text,按条目把引用处改写。

x86-64 的重定位条目(RELA)长这样:(offset, symbol, type, addend)。看一个真实例子:

1
2
3
4
$ objdump -dx main.o
...
1a: e8 00 00 00 00 call 1f <main+0x1f>
1b: R_X86_64_PLT32 sum-0x4

e8 是 call 的操作码,后面 4 字节全是 0——汇编器不知道 sum 在哪,只能先填 0 并记一张欠条:"在 .text 偏移 0x1b 处,请填入 sum 的 PC 相对地址,addend 为 -4"。

R_X86_64_PC32(以及现代 GCC 产出的 R_X86_64_PLT32)的换算公式是:

1
2
refaddr = ADDR(s) + r.offset
*refptr = (unsigned)(ADDR(r.symbol) + r.addend - refaddr)
PC 相对重定位:call 指令里的 4 字节是怎么填出来的 链接后 .text 起始地址 ADDR(.text) = 0x4004d0 0x4004ea: e8 [ ? ? ? ? ] call sum ↑ 待填:偏移 0x1a 处的 4 字节 下一条指令地址(%rip 在取指后的值)refaddr + 4 = 0x4004ee ADDR(sum) = 0x4004f8 addend = -4 refaddr = 0x4004ea 填入值 = 0x4004f8 + (-4) - 0x4004ea = 0x0000000a CPU 执行时:目标 = %rip(0x4004ee) + 0xa = 0x4004f8 ✓ —— addend 的 -4 正是在抵消 %rip 已经走过了 4 字节。 绝对寻址 R_X86_64_32 就简单得多:直接把 ADDR(symbol) + addend 写进去,常用于 .data 里的指针初值。

对照 R_X86_64_PLT32:现代 GCC 即使调用最终会落在可执行文件内部,也倾向于走 PLT,这样在链接期用 -shared 或介入 LD_PRELOAD 时还能改写去向——多一层间接,换一份灵活。

6. 动态链接与延迟绑定:GOT / PLT

静态链接有两个大毛病:每个进程都各自带一份 libc 代码,磁盘和内存都浪费;库一升级就得重新链接。共享库(.so)的解法是:一份代码在内存里只留一份,被所有进程共享——前提是这段代码必须是位置无关代码(PIC),能加载到任意地址。

PIC 的两个关键技巧:

  • 数据引用:不管代码被加载在哪,代码与它自己的 .data 之间的距离是链接时已知的,所以用 PC 相对寻址先找到 GOT(全局偏移表,位于 .data 区),再从 GOT 里取出真正的绝对地址。
  • 函数调用:PLT(过程链接表,位于 .text 区) + 延迟绑定。第一次调用才解析,之后直接跳。
延迟绑定:第一次走 PLT 兜一圈,之后 GOT 直接命中 调用点(.text,PIC) call addvec@PLT PLT[n](.text) jmp *GOT[n+3] push $id; jmp PLT[0] GOT[n+3](.data,可写) 初值 = PLT[n] 下一行 解析后 = 真实地址 首次:跳回 PLT 下一条 PLT[0] → _dl_runtime_resolve 查符号表,回填 第二次调用:jmp *GOT[n+3] 一步跳到真实函数,PLT 之后的部分再也不会执行。 GOT 在 .data 里(可写),PLT 在 .text 里(可执行、可共享)—— 代码不变,只改数据,进程间才能共享同一份 .so。 安全代价:GOT 可写 = 攻击面。RELRO(-Wl,-z,relro,-z,now)在启动后把 GOT 设为只读,代价是放弃延迟绑定。 这也正是 LD_PRELOAD 能生效的位置:预加载的库先被搜索,符号解析时抢先命中。
1
2
3
4
$ ldd hello
linux-vdso.so.1 (0x00007ffd3a1f0000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2a5c200000)
/lib64/ld-linux-x86-64.so.2 (0x00007f2a5c400000)

7. 运行时打桩:用动态链接器"拦截"库函数

上面那套机制能被反向利用——不改动目标程序,就替换掉它对某个库函数的调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* mymalloc.c */
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
#include <stdlib.h>

void *malloc(size_t size) {
static void *(*real_malloc)(size_t) = NULL;
if (!real_malloc)
real_malloc = dlsym(RTLD_NEXT, "malloc"); /* 找到"下一个"真正的 malloc */
void *p = real_malloc(size);
fprintf(stderr, "malloc(%zu) = %p\n", size, p);
return p;
}
1
2
3
4
$ gcc -shared -fPIC -o mymalloc.so mymalloc.c -ldl
$ LD_PRELOAD=./mymalloc.so ./hello
malloc(1024) = 0x55f1a3c2b2a0
Hello, world

Python 走的是同一条路子,只是把"链接"推迟到运行时显式调用:

1
2
3
4
5
6
import ctypes, os
libc = ctypes.CDLL("libc.so.6")
libc.atoi.restype = ctypes.c_int
libc.atoi.argtypes = [ctypes.c_char_p]
print(libc.atoi(b"15213")) # 15213
print(libc.getpid() == os.getpid()) # True

C 的链接期把符号翻译成地址,Python 的 ctypes 在运行时做同一件事——区别只在于:前者失败时是 undefined reference(链接都过不了),后者失败时是 AttributeError: undefined symbol(程序已经跑起来了才炸)。

8. C++ 与 Java:名字改编,和另一种"链接"

C++ 多了名字改编(name mangling)。 重载让"函数名"不足以定位符号,编译器把名字空间、类名、参数类型都编进去:

1
2
3
4
5
$ cat f.cc
int f(int); int f(double); // 两个同名函数
$ g++ -c f.cc && nm f.o
0000000000000000 T _Z1fi # f(int)
0000000000000010 T _Z1fd # f(double)

要让 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) 弱,且路径要自己管

小结

  1. 链接器的工作是"收集符号 → 决定保留谁 → 把地址填进指令",这三步对应符号解析、静态库扫描、重定位。
  2. .o 是可重定位的欠条,.so/可执行文件是兑付后的成品;.bss 不占磁盘只占内存,.rel.text 记录着所有待填地址的位置。
  3. 强/弱符号规则里,规则 2 最危险:一个强定义 + 一个类型不同的弱定义,链接器一声不吭地选了强的,于是 8 字节的写入踩坏了 4 字节的变量。别在头文件里放试探性定义。
  4. 静态库对命令行顺序敏感,因为链接器只扫一遍、不回头;.o 放前面,库放后面,互相依赖时用 --start-group。
  5. PC 相对重定位的公式是 ADDR(sym) + addend - refaddr,那个 -4 是在抵消 %rip 已经自增的 4 字节。
  6. 动态链接靠 GOT(可写数据)+ PLT(共享代码)实现延迟绑定:第一次兜一圈回填,之后一步到位;代价是 GOT 可写带来的攻击面,用 RELRO 可以关掉。
  7. LD_PRELOAD / --wrap / 自定义头文件是三层打桩手段,不改源码就能拦截库函数——调试内存问题的利器。

下一篇进入处理器架构与流水线,看看一条指令从取指到写回,硬件内部到底走了几步。