
如果你写过多文件 C/C 项目大概率被这两行东西折磨过undefined reference to swap multiple definition of buf搜索一圈答案无非是“把-lm放到后面”“加个extern”“你两个文件重复定义了”。你照着改了程序跑通了但链接器到底在背后干了什么很多人始终没搞明白。这篇文章想做的就是把《理解计算机系统》第 7 章里最核心的两个概念——符号解析和重定位——彻底掰开揉碎讲清楚。这个系列适合三类人正在啃教材的学生、被链接报错反复折磨的 C/C 开发者以及想理解“一个main是怎么从一堆.o文件里长出来的”的好奇心患者。第一篇我们先不碰动态库、不碰dlopen只聊静态链接里的符号解析和重定位。这两块一旦通了后面看动态链接、看装载基本就是顺水推舟的事。1. 链接到底解决什么问题先找准坐标1.1 编译四阶段与链接器的角色一个 C 程序从源代码到可执行文件通常被描述成四步预处理、编译、汇编、链接。前三步其实很好理解预处理器干的是“把#include和宏展开”编译器干的是“把 C 翻译成汇编”汇编器干的是“把汇编写成机器码”。但到了链接这一步很多人就开始含糊。你想想看汇编器把main.c翻译成main.o时它眼里只有main.c这一个文件。它知道swap这个函数要去调用但不知道swap函数的机器码在哪里它知道buf这个数组要被访问但不知道buf最终会被放到内存的哪个地址。汇编器能做到的只是把这些“悬空的引用”在目标文件里留个记号。真正把这些记号变成真实地址的就是链接器。链接器的活儿用大白话说就是三件把各个.o文件里的代码段、数据段合并到一起把符号引用和符号定义一一对上号把指令里的占位地址改成真实的运行时地址。1.2 静态链接的三件事静态链接器在合并.o文件时核心动作可以拆成三步第一合并节。假设你写了main.c和swap.c编译后得到main.o和swap.o。每个.o都有自己的.text代码、.data已初始化数据、.bss未初始化数据。链接器会把所有.text拼成一个大的.text所有.data拼成一个大的.data最后组成可执行文件里的段。第二符号解析。链接器会维护一个符号表逐个检查每个目标文件引用了哪些符号、定义了哪些符号。如果一个引用始终找不到定义就报undefined reference。如果同一个符号出现多个强定义就报multiple definition。第三重定位。符号解析完成之后所有符号都有了确定的最终地址。链接器再拿着重定位条目表回去把每个.o文件里那些“留了空位”的指令和数据位置真正填上算好的内存地址。1.3 本篇的边界先啃硬骨头合并节这一步说实话没有太多玄学。符号解析和重定位才是链接器报错的重灾区也是理解动态链接的基础。所以这个系列第一篇我打算集中火力把符号解析和重定位讲透。你会看到链接器报错背后真正的规则而不是靠搜索猜答案。2. 看懂ELF可重定位目标文件链接器的工作底稿2.1 一个 .o 文件里到底有什么我建议你去手边随便找个项目编译一下然后用readelf -S看看目标文件的节section结构。一定会看到类似这样的东西节头 [号] 名称 类型 地址 偏移量 大小 全体大小 旗标 链接 信息 [ 0] NULL 00000000 00000000 0000000000000000 0000000000000000 0 0 0 [ 1] .text PROGBITS 00000000 00000040 0000000000000017 0000000000000000 AX 0 0 1 [ 2] .data PROGBITS 00000000 00000057 0000000000000010 0000000000000000 WA 0 0 8 [ 3] .bss NOBITS 00000000 00000067 0000000000000004 0000000000000000 WA 0 0 4 [ 4] .comment PROGBITS 00000000 00000067 000000000000001c 0000000000000001 MS 0 0 1 [ 5] .symtab SYMTAB 00000000 00000100 ... [ 6] .rela.text RELA 00000000 00000250表格看着复杂但你只需要抓住几个关键.text编译出的机器码只读。.data已初始化的全局变量和静态变量。.bss未初始化的全局变量。注意它不占磁盘空间只有大小记录加载进内存时再预留。.symtab符号表。.rela.text、.rela.data重定位条目表记录.text和.data里哪些位置需要被填地址。重点记住rela开头的节只有可重定位目标文件.o才有链接完成之后这些节会被丢弃因为已经没有“待填的坑”了。这一点是很多人看readelf时会不知所措的地方——可执行文件里没有.rela.text所以.o和可执行文件的节布局天然不同。2.2 符号表链接器的通讯录我习惯把符号表当成一本通讯录。每个符号条目记录一个名字、它属于哪个节、在节内的偏移、它是全局可见还是局部可见。符号大致分三类全局符号非static的全局变量和函数。外部符号在其他编译单元里定义、本文件里通过extern引用的符号。局部符号static修饰的全局变量、函数以及函数内部的static局部变量。这里最容易误解的是“局部变量”。你在函数里写的int x是栈上的局部变量它不会出现在符号表里。链接器不管栈上的东西那是编译器在函数体内用栈偏移解决的问题。符号表里只有那些跨编译单元可见的、或者生命周期在函数之外的变量。static函数和static全局变量会进入符号表但它们的Bind属性是LOCAL只能在当前编译单元内使用。链接器在做跨文件解析时会直接忽略LOCAL符号。这就是为什么你可以在两个文件里各自定义一个static int helper而不会报 multiple definition——它们在各自的编译单元里是独立的链接器根本不让它们互相对话。2.3 实操把符号表和重定位表翻出来看一看自己动手看一次比读十篇文章都管用。假设你有两个文件/* main.c */ extern int buf[]; extern void swap(void); int main() { swap(); return 0; }/* swap.c */ extern int buf[]; int *bufp0 buf[0]; int *bufp1; void swap() { int temp; bufp1 buf[1]; temp *bufp0; *bufp0 *bufp1; *bufp1 temp; }编译gcc -c main.c swap.c然后看符号表nm main.o swap.o输出大致长这样main.o: U buf U swap 0000000000000000 T main swap.o: 0000000000000000 D bufp0 U buf 0000000000000004 C bufp1 0000000000000000 T swap大写字母之间很有讲究T表示在.text里的全局函数定义D表示在.data里的已初始化全局变量U表示未定义引用C表示 COMMON 弱符号。buf在两个文件里都是U说明它俩都引用了buf但还没人定义——正常情况下它应该在另一个文件里被定义。再看重定位表readelf -r main.o swap.oreadelf -r输出里会列出Rela条目每条都写着“在节内偏移多少字节的位置需要把某个符号的地址填进来”。这就是链接器手里的施工清单。2.4 为什么局部变量不在符号表里这个问题我问过不少刚开始看链接原理的朋友。道理其实简单局部变量是编译器在栈帧上用rbp/rsp加偏移量来定位的整个过程在编译期就完全确定了运行时根本不需要“查符号表找地址”。符号表存在的意义是解决编译单元之间互相引用的问题。既然局部变量对别的文件不可见自然没资格进通讯录。如果哪天你的nm输出里出现了一大串你看不懂的符号名比如_ZZ4mainE7my_static不用慌那通常是 C 里的static局部变量、lambda 或者 name mangling 的产物。3. 符号解析的完整规则与实战陷阱3.1 规则本身强符号、弱符号与多重定义符号解析的核心规则在链接器里非常朴素强符号函数、已初始化的全局变量。弱符号未初始化的全局变量。三条处理规则规则一同名的强符号出现两次链接器直接报multiple definition。规则二一个强符号和多个弱符号同名选强符号。规则三多个弱符号同名任选一个实际往往选占用空间最大的那个。规则二和规则三听起来很宽松但正是这种宽松埋下了不少隐蔽 bug。很多编译器包括 GCC对未初始化的全局变量并不直接放进.bss而是放进一个叫 COMMON 的区域按弱符号处理。这样做的历史原因是为了兼容 Fortran 和老式 C 里“两个文件分别声明同一个变量”的习惯。但代价是链接器会对类型完全不知道同名就硬选一个。3.2 一个经典翻车现场COMMON块与类型不匹配我见过一个很冤的线上问题。代码大概是/* a.c */ int x; /* b.c */ double x 0.0;按规则二double x 0.0是强符号int x是弱符号链接会成功。但a.c里用int的视角去读写这 4 字节b.c里用double的视角去读写 8 字节数据错位跑起来全是“玄学 bug”。这种问题在单文件里绝无可能发生只有在跨编译单元链接时才会出现。排查手段也简单编译时加上-fno-common把所有未初始化全局变量从弱符号返回普通强符号链接器发现x有两个强定义就会直接报错让 bug 在构建期暴露而不是运行时爆炸。这也是为什么很多大项目比如内核、Android会把-fno-common加进默认编译参数里。我自己现在写 CMake 项目也会顺手在CMAKE_C_FLAGS里加上它属于花一行代码买一份安心。3.3 静态库的扫描顺序单遍解析的脾气静态库本质上是一个.o文件的集合链接器在处理静态库时有个极其重要的行为从左到右单遍扫描不会回头。链接器维护三个集合已加入的未解析符号集合 U 和已定义符号集合 D。遇到普通.o文件无条件加入已解析区遇到静态库.a只有展开后发现里面有符号能解决当前 U 里的某个未解析引用才会把这个成员.o提取出来。所以下面的命令可能链接成功gcc main.o libfoo.a libbar.a但同样一个项目改成gcc main.o libbar.a libfoo.a就有可能在最后一步报undefined reference。原因很简单单遍扫描在第一次扫到libbar.a时前面的main.o还没把libfoo.a里的符号加入 U所以libbar.a的成员没有被提取等后面扫到libfoo.a时它被提取了却引用了libbar.a里的符号——可是libbar.a已经扫完了。链接器不会为了你去回头重扫一遍。这大概是所有链接报错里最“常识之外”的一种。我见过不少人把库顺序从左调到右、从右调到左最后一顿乱试后才通过。3.4 库依赖循环与 --start-group如果你的库之间有循环依赖比如libsql.a引用librecord.alibrecord.a也引用libsql.a单纯调顺序是永远解决不了的。这时用--start-group和--end-group圈住一组库链接器会在这一组库里反复扫描直到 U 不再变化gcc main.o -Wl,--start-group libsql.a librecord.a -Wl,--end-group理解了这个机制你还会明白另一个反直觉的实践尽量让静态库在命令行里排在靠后的位置。GNU ld 的手册也反复强调这一点因为只有前面的目标文件先把需要的符号“欠账”记到 U 里后面的库才有机会还账。4. 重定位把占位符替换成真实地址4.1 为什么编译期填不了地址你写call swap()时汇编器生成的机器码里call指令后面是一个 4 字节的偏移量。问题是在编译main.c时编译器根本不知道swap会被放在最终可执行文件的什么位置。它只能临时填 0然后在.rela.text里记一条重定位条目告诉链接器“喂这个位置的偏移要改成swap的地址”。这就像装修时你先在水电图上标了“此处装插座”留好空洞等到施工时再按实际布线把插座装进去。重定位就是链接阶段的“实际施工”。4.2 重定位条目的四个关键字段用readelf -r看一条重定位记录重点看四样东西Relocation section .rela.text at offset 0x2e0 contains 2 entries: Offset Info Type Sym. Value Sym. Name Addend 00000000000d 000a00000002 R_X86_64_PC32 0000000000000000 swap - 4Offset需要修改的位置在节内的偏移量。Type重定位类型决定计算公式。Sym. Name被引用的符号名。Addend额外的修正值。Type是重定位的灵魂不同架构有一大堆取值。但绝大部分场景下你只需要关心两种核心模型PC 相对寻址和绝对寻址。4.3 两种核心寻址方式与计算模型第一种是PC 相对寻址。典型对应call、jmp、RIP 相对引用。CPU 真正拿到的地址是通过“当前指令的下一条地址 一个偏移量”算出来的。第二种是绝对寻址。典型对应“直接把这个符号的地址放到指令里”的用法CPU 读到一个完整的 32 位或 64 位地址值直接用。在 x86-64 的 ELF 里绝对寻址通常对应R_X86_64_32。教材用的经典例子是movl $buf, %eax这条指令把buf的地址塞进%eax属于绝对寻址。现代 64 位编译器默认更喜欢用 RIP 相对寻址比如movq buf(%rip), %rax因为这样能生成位置无关代码也更容易优化。这两种方式并不是二选一的互斥选项一个模块里往往两者混着用函数调用走 PC 相对偶尔取某个全局符号地址走绝对。4.4 完整推演一个 call 指令的重定位这里我们做一个可以手算验证的完整推演地址我采用教材里的简化例子方便你对着算。假设链接之后main函数被放到地址0x4004d1其中call swap这条指令占 5 个字节0x4004d1是操作码e8后面 4 个字节是立即数偏移位于0x4004d2。而swap函数最终被放到0x4004e0。CPU 执行call时rip指向下一条指令的地址也就是0x4004d6。为了让 CPU 跳转到0x4004e0需要的偏移是0x4004e0 - 0x4004d6 0x0a所以在最终可执行文件里call指令后应该填入0a 00 00 00。如果用教材的公式来算也是一样的S 0x4004e0符号最终地址P 0x4004d2被修改的立即数字段所在地址A -4加数即操作码长度用来把 rip 从“当前字段”修正到“下一条指令”S A - P 0x4004e0 - 4 - 0x4004d2 0x0a注意这个A -4在readelf -r输出的Addend列中会直接看到。很多人不理解为什么调 4 个字节的函数调用偏移会带一个 -4这里的原因正在于此——PC 相对的本质是拿 rip 做基准而 rip 永远指向下一条指令。4.5 重定位截断错误一个真实现场绝对寻址有个天然限制32 位绝对地址最多表达 2GB 范围严格说是无符号 4GB但有符号限制。如果链接器算出的最终地址超过了这个范围就会报relocation truncated to fit: R_X86_64_32 against symbol data这个错误在普通小项目里很少见但在大型项目里偶尔会冒出来。典型场景是某些老代码或者内嵌汇编使用了绝对寻址而程序的数据段或者代码段太大链接器没法把符号地址压进一个 32 位字段。实战里我有次做嵌入式 Linux 上的大型二进制时就遇到过。排查思路是先确认是不是某个特殊 section 被放到了异常地址再尝试改用大代码模型gcc -mcmodellarge或者把相关代码改成 RIP 相对寻址 / 位置无关代码-fPIC或者检查链接脚本里有没有把某个段放得太远这类错误看表面是“寻址距离不够”本质上是“代码模型和实际布局不匹配”。明白了计算模型遇到报错就不会瞎猜。5. 链接报错的排查思路与工具清单5.1 undefined reference 为什么会发生undefined reference是最常见的链接错误原因总结起来就几类第一链接时漏了库或者目标文件。你调用了sqrt但没加-lm你调用了自定义函数但那个.o没出现在命令行里。第二库顺序错了上一节已经详细说了。第三符号名不匹配。C 会对符号做 name mangling如果你的函数声明是 C 的定义放在 C 文件里且没有加extern C两边对应的符号名就对不上。第四声明和定义的类型不一致。你在a.c里声明int foo();在b.c里定义了void foo() {}链接器只看符号名不看返回值大概率能链上但 C 下因为 mangling 规则不同可能直接 undefined reference。这里值得记住一行命令nm -C libfoo.a | grep foo-C会把 C 修饰后的符号还原成可读形式一眼就能看出名字对不对得上。5.2 multiple definition 的排查套路遇到 multiple definition很多人第一反应是“删定义”。但更高效的思路是先看报错里提到的符号名和文件名nm -A *.o | grep my_symbol-A会让输出带上文件名直接列出哪些文件定义了同一个符号。然后反思这几个问题这个全局变量真的需要跨文件共享吗如果只有本文件用加static。是不是头文件里定义了变量在 C 里int var;写在头文件等于在每个包含它的.c文件里各定义一份。正解是在头文件里写extern int var;在一个.c文件里写int var ...;。是不是没有-fno-common时弱符号冲突被掩盖了加上编译选项让它炸在明面上。排查 multiple definition 的时候readelf -s和nm是最好用的组合因为报错信息只会告诉你“哪个符号重复了”但不会告诉你为什么重复。用工具把符号定义位置全列出来问题一目了然。5.3 静态库顺序问题速查报错/现象可能原因解决方案链接最后一步报 undefined reference且符号明明在某个库里该库在命令行中出现在引用它的目标文件之前把库放到引用它的.o之后两个库互相引用调顺序无法解决循环依赖用-Wl,--start-group ... -Wl,--end-group包住同一个库出现多次仍有 undefined reference库内成员符号被 strip 或条件编译裁掉检查生成.a时的编译选项、检查nm确认符号存在用-Wl,--as-needed后符号丢失链接器自动裁剪了未直接使用的库在命令行中组织好顺序或显式关闭--as-needed这里我想特别说一句我以前很排斥--start-group觉得它是“用蛮力掩盖问题”。但后来项目里引入第三方依赖多了发现真正干净的依赖层级设计才是少数很多大型静态库之间就是会存在合理的互相依赖。--start-group不是错误关键是你得知道它让链接器做了什么否则某天性能问题或者符号选错时你会觉得莫名其妙。5.4 一套可以复制到任何项目的排查路径我自己的习惯遇到链接错误按以下顺序走基本没有落空的第一步完整复读报错信息。链接器报错一般会给出符号名和文件名甚至给出偏移量。先把符号名记下来不要上来就改代码。第二步用 nm 找符号的定义。决定这是“未定义”还是“多重定义”nm -A *.o *.a | grep symbol第三步用 readelf 看重定位表。如果怀疑是地址计算、代码模型、类型不匹配问题看目标文件的重定位条目readelf -r file.o第四步用链接器自己的诊断选项。GCC 里-Wl,--trace可以列出链接器处理了哪些文件-Wl,-M或-Wl,--print-map能输出完整的链接映射。这些信息量很大但排查“库没被提取”这种问题比任何猜测都直接。第五步最小化复现。把报错涉及的文件抽出来写一个 10 行以内的最小例子手动跑gcc ...命令复现。很多时候在 CMake 或 Makefile 的复杂变量里看不清的问题在命令行里一摆就全通了。5.5 常见工具速查表工具常用场景nm快速看目标文件和库里的符号定义/引用objdump -d反汇编看代码前后的机器码差异objdump -r查看目标文件里的重定位条目readelf -S查看节表确认各节的位置和属性readelf -s查看完整符号表含 Bind 类型readelf -r查看重定位表重点看 Type 和 Addendgcc -v查看实际调用的链接命令行gcc -Wl,--trace打印链接器处理过的每个输入文件gcc -Wl,--print-map输出完整链接映射定位节和符号最终地址这些工具不需要全部背下来。我的建议是nm、readelf -r、readelf -s三件套熟练掌握其余用到再查。6. 写在后面下一篇的引子链接这个主题我每读一遍都有新收获。这篇文章里讲的符号解析和重定位看起来只是“把.o合成可执行文件”的机械过程但同一个逻辑放到动态链接里会变得更精彩动态库的符号解析被推迟到装载时重定位也不再是简单填地址而是要处理和位置无关代码、PLT、GOT 相关的一整套复杂机制。如果你手头有《理解计算机系统》这本书建议按这个顺序读先看 ELF 那一节把.o的结构搞清楚再把我这篇文章里的例子动手跑一遍最后带着问题去翻动态链接的部分。我自己当年是被“某个链接错误在网上搜了三个小时没解决”这种经历逼着去读原理的结果发现原来答案一直就在书里。链接器看起来是个“幕后角色”但恰恰是这种幕后角色决定了一个大型项目能不能在半小时内构建完也决定了哪些 bug 会在编译期被当场活捉、哪些会隐藏到你上线之后才发作。理解它是每一个想真正掌控自己程序的人的必修课。下一篇我会接着写动态链接里的符号解析和重定位。先把静态链接这一步踩实后面的路就顺了。