
如果你问一个写过三年 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 - 4Offset 是 0xf意思是待修改的位置在 .text 节内偏移 0xf 处也就是 call 指令的偏移字段。Sym. Name 是 sumAddend 是 -4。3.2 PC相对引用与绝对引用两种最常见的修正类型R_X86_64_PC32 是 x86-64 下最常见的重定位类型代表 PC 相对引用。它的计算公式是*refptr S A - PS 是目标符号最终被分配到的地址A 是 AddendP 是重定位字段所在的位置。最终写入的值是目标地址相对当前位置的距离。为什么需要 -4 这种看起来奇怪的加数这和 x86-64 指令的编码方式有关。拿 call 指令来说指令长度为 5 字节重定位要改的偏移字段位于 E8 后面的 4 个字节而 CPU 执行到 call 时RIP 已经指向下一条指令比字段所在位置恰好多了 4 字节。要让公式算出的偏移量正确匹配 S - RIPAddend 就必然要为 -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 main0x13 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 0x401126sum 被分配到的地址A -4P 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:(.text0xe): 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.alibB.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。