☰
静态链接核心机制:符号解析与重定位原理全解
2026/10/9 6:19:13 网站建设 项目流程

如果你问一个写过三年 C/C++ 的程序员:链接器到底在干什么?最可能的回答是"把目标文件合成可执行文件"。这个说法没错,但太粗糙了,以至于一遇到 undefined reference 或者 multiple definition 就只能靠搜索引擎。我在啃《深入理解计算机系统》的链接章节时,最大的收获是意识到——链接器的全部工作可以压缩成两件事:符号解析和重定位。符号解析回答"代码里写到的 foo 到底是哪个 foo",重定位回答"每个符号都被安排在内存的哪个地址,所有引用它的地方要怎么改"。这篇文章会把这两件事的原理、规则和坑一次讲透,结合我自己的实验输出,适合刚学到编译链接原理的本科生和想补基础的程序员。这篇是系列第一篇,先把静态链接的机制讲完整。

1. 链接为什么值得单独花一章去啃

1.1 可执行文件不是编译出来的,是"拼"出来的

回到最基本的编译流程:gcc -c 把 .c 编译成 .o,多个 .o 再由链接器拼成可执行文件。很多人默认"编译"包含了链接,实际上编译器在生成 .o 时,对每个源文件是独立工作的,它根本不知道另一个源文件里某个函数会被安排到哪个地址。

比如 main.c 里调用 sum.c 中定义的 sum(),当 main.c 被编译成 main.o 时,汇编器只知道"这里要 call 一个外部符号 sum",但 sum 最终在内存中的地址是多少,此时完全未知。于是汇编器先填一个占位值 0,同时在一个专门的重定位表里记一笔:这里有个引用,等着链接器来改。链接器拿到所有 .o 后,才统一决定每个节放在哪里、每个符号被分配到哪个虚拟地址,然后把占位值一个个替换成真实地址。

这就是为什么我用"拼"这个字:编辑器里写下的每个源文件都只是一个碎片,只有链接器把它们对齐、粘合、修正之后,一个完整的可执行文件才真正成型。

1.2 链接器全程解决两个问题

链接器从开始到结束,其实只干两件事。

第一件叫符号解析。每个 .o 都带着一张符号表,里面记录了两类信息:自己定义的符号,以及引用了但没定义的符号。链接器要把每一处"引用"和某一个"定义"对应起来。对应不上,就是 undefined reference;有多个同名定义抢一个引用,就是 multiple definition。

第二件叫重定位。确定好每个符号对应哪个定义之后,下一步是给这些符号分配真实的内存地址。代码段放哪里,数据段放哪里,这个函数在第几个字节处,那个全局变量又在哪里,全部排定之后,链接器再回头把之前占位的那些引用全部修正成最终地址。这个过程是个纯机械操作,但涉及的细节非常多,比如 PC 相对引用和绝对引用的计算方式就不一样。

我后来用一个不太文雅的类比给朋友讲链接:它有点像出版社排版一本书。作者交稿时写"详见第 7 章的图",排版工要把"第 7 章"换成真实的页码。符号解析是确认"图"到底指哪张图,重定位就是把页码填对。没有链接器,一堆孤立的 .o 文件就像散装的书稿,哪一页在哪根本说不清。

2. 符号解析:一切"找不到"报错的根源

符号解析阶段,链接器面对的核心数据结构是符号表。

2.1 从ELF符号表说起

每个可重定位目标文件(.o)里,都有一个 .symtab 节。别被名字吓到,它就是一张表,一行一个符号。用 readelf -s 可以看得很清楚。以最简单的 main.o 为例,假设它有 main 函数和对外部函数 sum 的引用:

$ readelf -s main.o Symbol table '.symtab' contains 9 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 SECTION LOCAL DEFAULT 1 2: 0000000000000000 0 SECTION LOCAL DEFAULT 3 3: 0000000000000000 0 SECTION LOCAL DEFAULT 4 4: 0000000000000000 0 SECTION LOCAL DEFAULT 5 5: 0000000000000000 0 SECTION LOCAL DEFAULT 7 6: 0000000000000000 0 FILE LOCAL DEFAULT ABS main.c 7: 0000000000000000 21 FUNC GLOBAL DEFAULT 1 main 8: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND sum

别急着学每个字段。先看 Bind 这一列:LOCAL 表示这个符号只在当前目标文件里可见,GLOBAL 表示可以跨文件链接。Ndx 列告诉你符号定义在哪个节,UND 表示这个符号在这个文件里只有引用、没有定义——sum 就是这样,它在 main.o 里是未定义的,需要链接器到别的目标文件里找。

这里有个值得专门提的细节:static 修饰的函数和全局变量会以 LOCAL 身份出现在符号表里。也就是说,两个不同的 .c 文件各自写一个 static int count,链接器完全不会把它们当成同一个符号。很多人把这个理解成"static 只是隐藏了名字",不完全准确,在链接层面上它直接决定了符号不会参与跨模块绑定。

2.2 强符号与弱符号:C语言里"谁说了算"的潜规则

继续看符号解析,最有趣也最容易出事的规则是关于多重定义的。

C 语言在实践中把全局符号分成强符号和弱符号两类:

  • 强符号:带初始化值的全局变量,以及函数定义。
  • 弱符号:没带初始化值的全局变量(也就是 C 标准里说的暂定定义,tentative definition)。

链接器面对多个目标文件里有同名符号时,按三条规则裁决:

规则行为
规则1不允许出现多个同名的强符号
规则2一个强符号 + 多个弱符号时,选强符号
规则3多个弱符号同名时,任选其中一个,并不保证是哪一个

这三条规则看起来简单,但坑很深。规则1触发时,链接器直接报 multiple definition 错误,这个后面章节专门讲。规则2和规则3最坑人的地方在于:链接器不报错。假如两个弱符号同名,一个大小是 4 字节一个大小是 8 字节,链接器任选一个定义,引用它的代码却可能按另一个大小来访问内存,程序就会在毫无征兆的情况下踩到错误的内存,这类 bug 排查起来极其痛苦。

判断一个全局变量到底是强还是弱,有个简单的口头判断法:凡是"就当作它已经被初始化为 0"的变量,在链接器眼里都是弱符号。比如经典写法int x;放在全局作用域,C 标准里说它的值会被初始化为 0,但链接器看它是弱符号,如果另一个文件里有int x = 2;,则 x 最终绑定到后者。

2.3 静态库是按需"拉取"的:E、U、D 三个集合

符号解析的另一半是静态库。许多人以为静态库就是把一堆 .o 简单打包,链接时全量塞进可执行文件。不是的。

链接器在处理命令行参数时,从左到右扫描每个 .o 和 .a,内部维护了三个集合:

  • E:被加入链接的模块集合
  • U:尚未解析的符号引用集合
  • D:已经定义的符号集合

具体行为是:遇到一个 .o,无条件加入 E,同时更新 U 和 D;遇到一个 .a,则逐个检查里面的成员模块,只有当这个成员能解析 U 中的某个符号时,才把该成员拉进 E,否则跳过。扫描完所有输入,如果 U 还有未解析的符号,就报 undefined reference。

这个模型直接解释了为什么 gcc 的命令行里库的位置有讲究。假设有 main.o 引用了 libfoo.a 里的 foo(),写成gcc main.o -lfoo没问题;如果写成gcc -lfoo main.o,链接器扫描 -lfoo 时 U 里还没有 foo,于是整个 libfoo.a 被跳过,等扫描到 main.o 时 U 里才出现 foo,但库已经不会再被回头查看了,于是报错。稍后我会在第 4 章用一个真实排查案例把这个过程再走一遍。

3. 重定位:把占位符一件件替换成真实地址

符号解析完成之后,链接器进入更机械的一步:重定位。这一步做两件事:一是合并输入模块的相同节,给它们分配运行时地址;二是修改所有需要修正的引用。

3.1 重定位条目:链接器拿到的一张"待办清单"

链接器怎么知道哪些地方要改?靠的是每个目标文件里的 .rela.text、.rela.data 这类节。这些节里是一张重定位条目表,每条记录四个关键字段:

  • Offset:需要修改的位置在节内的偏移;
  • Type:怎么改,最常见的是 PC 相对引用和绝对引用;
  • Symbol:要绑定的符号是谁;
  • Addend:一个修正用的常数,通常由汇编器算好。

用 readelf -r 看一个最简单的例子。main.o 里有一处对 sum 的调用:

$ readelf -r main.o Relocation section '.rela.text' at offset 0x80 contains 1 entry: Offset Info Type Sym. Value Sym. Name + Addend 000000000000000f 000800000004 R_X86_64_PC32 0000000000000000 sum - 4

Offset 是 0xf,意思是待修改的位置在 .text 节内偏移 0xf 处,也就是 call 指令的偏移字段。Sym. Name 是 sum,Addend 是 -4。

3.2 PC相对引用与绝对引用:两种最常见的修正类型

R_X86_64_PC32 是 x86-64 下最常见的重定位类型,代表 PC 相对引用。它的计算公式是:

*refptr = S + A - P

S 是目标符号最终被分配到的地址,A 是 Addend,P 是重定位字段所在的位置。最终写入的值是"目标地址相对当前位置的距离"。

为什么需要 -4 这种看起来奇怪的加数?这和 x86-64 指令的编码方式有关。拿 call 指令来说,指令长度为 5 字节,重定位要改的偏移字段位于 E8 后面的 4 个字节,而 CPU 执行到 call 时,RIP 已经指向下一条指令,比字段所在位置恰好多了 4 字节。要让公式算出的偏移量正确匹配 S - RIP,Addend 就必然要为 -4。理解这一步,对后面手工验证帮助很大。

另一种类型是 R_X86_64_32,绝对引用。公式简单得多:

*refptr = S + A

直接把目标符号地址写进去,不需要参考当前位置。早年 32 位代码里很常见,比如全局指针在 .data 节初始化时指向另一个全局变量,就需要这种绝对地址。64 位下绝对引用仍然存在,只是更多用 R_X86_64_64 这种 8 字节的形式。

3.3 手工算一遍重定位:从那个 0xf 的位置开始

理论说多了容易发虚,还是实际算一个。这是我在本机用gcc -fno-plt -c main.c sum.c编译得到的片段,只有一个外部调用:

0000000000000000 <main>: 0: 55 push %rbp 1: 48 89 e5 mov %rsp,%rbp 4: be 02 00 00 00 mov $0x2,%esi 9: bf 01 00 00 00 mov $0x1,%edi e: e8 00 00 00 00 call 13 <main+0x13> f: R_X86_64_PC32 sum-0x4 13: 5d pop %rbp 14: c3 ret

然后我用gcc -no-pie main.o sum.o -o a.out做静态链接,生成的可执行文件布局如下(地址基于本机环境,不同机器会有差异,但形式一致):

0000000000401120 <main>: 401120: 55 push %rbp 401121: 48 89 e5 mov %rsp,%rbp 401124: be 02 00 00 00 mov $0x2,%esi 401129: bf 01 00 00 00 mov $0x1,%edi 40112e: e8 f3 ff ff ff call 401126 <sum> 401133: 5d pop %rbp 401134: c3 ret

关键变化就在 call 那条指令。重定位前偏移是四个字节的 00 00 00 00,重定位后变成了 f3 ff ff ff,也就是 -13。来验证公式:

  • S = 0x401126(sum 被分配到的地址)
  • A = -4
  • P = 0x40112f(重定位字段地址,偏移字段在 call 指令起始地址 0x40112e 之后的一个字节)
  • S + A - P = 0x401126 - 4 - 0x40112f = 0xfffffff3 = -13

写入 -13 后,CPU 执行到 0x40112e 这条 call 时,RIP 已经是下一条指令地址 0x401133。0x401133 加上 -13,正好得到 0x401126,也就是 sum 的地址。一旦在反汇编里看到 call 后面跟的十六进制能以这种方式圆回来,你就算真正看懂重定位了。

4. 链接器报错背后的"潜规则":一段排查实录

理论学完,看实际工作里天天打交道的三张报错面孔。

4.1 undefined reference:最像"假象"的真报错

这个错误大家一定见过,形式是:

/tmp/ccxxxxxx.o: In function `main': main.c:(.text+0xe): undefined reference to `sum' collect2: error: ld returned 1 exit status

它的本质很单纯:链接器扫完所有输入后,U 集合里还有 sum 没被任何 D 集合中的符号顶掉。但背后常见原因至少有四种:

  • 函数名拼写不一致。声明里写的是 sum,实现里写的是 summ,这在编译阶段完全不报错,直到链接才暴露。
  • 声明了 extern 符号,但没在链接命令里给出定义它的目标文件或库。
  • 给出库了,但库文件名顺序错了,前面提到的情况。
  • 依赖了动态库却漏掉了 -l 参数。

排查这类问题我通常三步走。先用nm 目标文件.o | grep 符号看定义是否存在、是否在预期模块里;再用nm -u 目标文件.o看哪些符号还没被解析;最后扫一遍链接命令行,确认库顺序。大多数 undefined reference 在这三步内都能找到答案。

4.2 multiple definition:当强符号撞上强符号

第二个高频报错:

multiple definition of `init_cache'; /tmp/xxx.o:init_cache first defined here

这正是规则 1 被触发的信号:两个目标文件里出现了两个同名强符号。常见于两个源文件各自定义了一个默认的全局函数,或者你引入了某个第三方库,而库里的符号和你的一个函数重名。

这里要特别注意一种隐蔽情形:弱符号。前面规则 2 说过,一个强符号可以盖过任意多个弱符号而不报错。很多开源项目会用弱符号来做"默认实现替换",比如编译器内置的某些函数用__attribute__((weak))声明,你可以在另一个文件里提供强符号版本来覆盖它。可如果你在某处把强符号写成了弱符号,原本该被发现的重复定义就悄无声息地被"覆盖"掉了,真正的实现根本没参与链接,运行时的行为自然就错了。所以我在工程里对全局命名有个土规定:能加 static 的一律加 static,必须导出的符号用项目前缀统一命名,把撞名的概率从源头压下去。

4.3 库顺序问题:链接器真的"只看一眼"

第 2 章讲过链接器从左到右扫描命令行,而且不会回头重复扫描。常规情况下,这要求库出现在所有引用它的目标文件之后。循环引用则更麻烦,假设 libA.a 依赖 libB.a,libB.a 也依赖 libA.a,单纯-lA -lB仍然可能失败,因为链接器扫到 libA.a 时解析了部分符号,扫到 libB.a 时又产生了新的未解析符号,而这些符号在已经扫完的 libA.a 里就有。解决方法是重复放库:

gcc main.o -lA -lB -lA -o app

第一次 -lA 拉进 A 中需要的模块,-lB 拉进需要的 B 模块,第二次 -lA 时 U 集合里剩下的一些符号就能从 A 的剩余模块里满足了。从 E/U/D 模型的角度看,这个过程完全符合预期:链接器对预处理完的输入只扫描一遍,把同一份库放两次,等价于给每个符号两次被捡起来的机会。实际维护大型工程时,我见过不少奇葩链接错误就是命令里的库顺序被重构工具打乱导致的,理清这个模型之后,这类问题基本一眼破案。

5. 把链接过程"看"明白:一个最小实验的完整复盘

说再多不如自己动手观察一遍。下面这个实验我已经在文章里分步走过,这里串成完整命令,方便你照做。

5.1 编译目标文件,观察它"残缺"的样子

准备两个文件。main.c 引用外部函数 sum:

// main.c extern int sum(int, int); int main() { return sum(1, 2); }

sum.c 提供定义:

// sum.c int sum(int x, int y) { return x + y; }

执行:

gcc -fno-plt -c main.c sum.c

然后分别观察:

objdump -dr main.o readelf -r main.o

你会发现 main.o 里对 sum 的 call 指令偏移字段全是 00 00 00 00,旁边挂着一条重定位记录告诉你"这里必须改"。这就是目标文件"残缺"的直接证据。

5.2 链接后对比:地址从占位符变成真实地址

再执行:

gcc -no-pie main.o sum.o -o a.out objdump -d a.out

此时 call 的偏移字段从 00 00 00 00 变成了 f3 ff ff ff。按第 3 章的公式验一遍 S + A - P,正好等于 -13。如果你机器上的地址和我上面示意的不完全相同,没关系,重点不是地址数值,而是这个换算关系永远成立。

顺手可以再看一个东西:readelf -s a.out | grep sum。链接后 sum 的 Value 列已经不再是 0,而是它在可执行文件里的虚拟地址;它和 main 一样出现在同一个 .text 节里。两个碎片在这里被真正拼成了一块。

5.3 这套原理能帮你排查什么

链接原理最实用的地方,不在编译期,而在运行期。

举几个我实际遇到的例子:某个函数明明有定义,但被另一个文件里的强符号抢占,导致行为诡异;一个全局变量在多个文件里声明了不同类型,链接器按弱符号规则任选了一个,程序跑起来数据错乱;还有共享库覆盖导致的符号版本错位。这些问题的共同特征是编译期一切正常,运行期时好时坏,原因全部藏在符号解析的边界规则里。理解强、弱符号和多模块的绑定规则,遇到这类诡异崩溃时,你至少知道往哪个方向开第一枪。

实验做完后,强烈建议自己再加一步:用 objdump 或者 xxd 看看那条 E8 指令的十六进制编码,确认偏移字段的起始位置和指令长度的关系,再解释为什么偏移字段地址和 RIP 基准差 4。这一步做完,PC 相对寻址在你这儿就再也不会忘。

我个人在这块学习上最大的体会是:链接这个主题,光看书上公式没用,一定要亲手编译一个两个文件的小项目,观察重定位前后的反汇编差异,然后拿着计算器把地址验一遍。验过一轮,那些规则就不再是背诵项,而是顺理成章的事。下一篇我会接着写动态链接里更让人头疼的 GOT 和 PLT。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询