ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

GCC静态链接重定位原理:从ELF到undefined reference

GCC静态链接重定位原理:从ELF到undefined reference 只要跟着 GCC 走完一次 C 程序的编译链接你大概率遇到过这样的报错undefined reference to xxx或者更劝退的relocation truncated to fit: R_X86_64_PC32 against symbol yyy前者还能靠补链接库解决后者往往让人一头雾水——什么叫 relocation什么叫 truncated to fit搜了半天找到的解法多半是“试试加 -no-pie”或者“把数组改小一点”但很少有人解释清楚背后的原理。我最初接触重定位是在排查一个嵌入式项目的启动代码时那段汇编里手工填了好几个地址一改代码布局就全部失效后来才彻底搞明白编译器编译每个 .c 文件时根本不知道其他文件里的函数、全局变量最终会被安排在哪个地址所以遇到跨文件引用就只能先“占个坑”等链接阶段再由链接器统一计算并回填真实地址。这个“占坑 回填”的过程就是重定位relocation。这篇文章我就把 GCC 静态链接中的重定位过程从头到尾拆一遍。内容适合三类人被 undefined reference、relocation truncated 这类报错折磨过的人写裸机启动代码、内核、驱动、自研 bootloader 的人以及单纯想把“链接”这件事搞明白、不再停留在会用 gcc 层面的开发者。我会从编译链接的完整流程讲起深入到 ELF 文件的重定位条目结构然后再用实际编译产物演示重定位计算过程最后整理常见报错和排查工具。整个过程不涉及平台独有内容纯工程视角跟着走一遍你就能建立完整的心智模型。1. 重定位到底是什么先把它放在地图里1.1 一次编译链接背后的“隐藏步骤”平时我们写的gcc a.c b.c -o app在 GCC 眼里不是一步完成的。它内部会拆成预处理、编译、汇编、链接四个阶段。前三个阶段负责把每个 .c 文件变成独立的 .o 目标文件第四个阶段才把一堆 .o 和静态库拼成可执行文件。很多人误以为链接就是“把文件拼在一起”其实没那么简单。拼在一起只是节区合并真正的难点是a.o 里引用了一个func()但func()真正实现是放在 b.o 里的a.o 在汇编阶段根本不知道 b.o 里那个函数最终会被放到虚拟地址的哪个位置。所以 a.o 里那条call指令的跳转目标只能暂时留空留下一个“重定位条目”作为待办事项。链接器拿到所有 .o 之后第一件事是符号解析也就是把所有 undefined symbol 和定义符号对上第二件事是节区合并把所有 .o 的 .text 段、.data 段、.bss 段分别按类别合到一起并为每个段确定最终的虚拟地址第三件事才是重定位——遍历每个目标文件的重定位表把之前留下的“坑”按照生成的目标地址逐一填上。1.2 一个类比装修图纸预留线盒我经常用装修来类比这个过程。每个 .c 文件编译出来的 .o相当于一张局部的电工图纸。图纸上标注了“这里以后要装一个开关”但具体这个开关连到哪个配电箱回路、线怎么走图纸阶段定不了因为整栋楼的回路还没规划。链接器就是那个负责全场统筹的电工头。它先决定每一路线的起点和走向分配虚拟地址再回头把每张图纸上预留的“待接线盒”全部按规划接好填入地址。如果某个线盒对应的设备图找不到了就是 undefined reference如果规划的线路距离太远超出预埋管长度就是 relocation truncated to fit。这么一对照报错就好理解多了。1.3 重定位的核心矛盾编译期地址未知重定位存在的根本原因是编译器和链接器分工不同。编译器只负责生成机器指令它知道每条指令在当前 .o 里的偏移但不知道其他编译单元的布局。链接器知道全局布局但它的信息来源是目标文件的符号表和重定位表。所以重定位的本质就是“编译期留下缺口链接期统一修补”的一个信息传递机制。理解了这一点后面看重定位条目的结构就非常顺畅了——它本质上就是一张“修补说明表”写着某个偏移处有一个引用引用的符号是哪个需要用什么方式计算地址。2. ELF 文件里重定位信息藏在哪2.1 目标文件的三类核心节区先抛开复杂的 ELF 规范只记三个东西.text存代码.data存已初始化的全局变量.bss存未初始化的全局变量。代码里引用外部符号的位置只出现在.text而符号的本体可能来自.data、.bss也可能来自另一个.text。用readelf -S看一个 .o 文件你会发现除了这几个常见节区还有一类以.rela开头的节区比如.rela.text、.rela.data。这些就是重定位表。.rela.text的意思是.text段里有几处位置需要修补每一处对应一个重定位条目。2.2 重定位条目的四个关键字段Linux x86-64 环境下重定位条目的结构是Elf64_Rela有四个字段我记得非常牢字段含义通俗解释r_offset需要修补的位置在目标文件中的偏移哪面墙上有预埋盒r_info低 32 位是符号表索引高 32 位是重定位类型这个盒子要接哪个设备、按什么规矩接r_addend加数参与地址计算的常量修正接线时额外预留的长度隐含目标符号来自符号表被引用的符号本体注意 x86-64 用的是Rela加数单独存在字段里而 32 位 x86 用的是Rel加数是直接写死在待修补位置里的读取时需要“把旧值取出来再加”。这个差异很多人栽过跟头看到 x86 的R_386_32和 x86-64 的R_X86_64_64长得像就以为计算方式一样实际差了一个加数来源。2.3 重定位类型决定“地址怎么算”重定位类型决定了链接器拿到符号地址后怎么计算最终要写进指令里的值。x86-64 下最常见的几个类型是R_X86_64_64、R_X86_64_PC32、R_X86_64_PLT32、R_X86_64_GOTPCREL。它们的差异可以压缩成一句话用绝对地址计算还是用相对当前指令位置计算。这一点在下一节会结合具体例子展开。这里先记住一个底层规则重定位类型的背后是处理器指令集对“寻址方式”的表达能力限制。x86-64 的指令里有的地方只能塞 32 位有符号立即数有的地方能塞 64 位绝对值编译器汇编器只能按指令的约束生成重定位请求链接器则按请求去算。不是链接器想怎么算就怎么算是“指令本身就决定了地址只能怎么给出”。3. 重定位的计算公式拆到最细3.1 核心公式S A 或 S A - P重定位计算虽然最终还是落到“把数值填入指令的某个字段”但计算规则因类型而异。熟悉 ELF 规范的人都知道计算时会出现四个符号S目标符号在最终链接产物中的地址symbol valueA加数addendP被修补位置在最终链接产物中的地址place即 r_offset 对应到链接产物的地址于是有两族公式。绝对寻址用的是S A意思是“把符号的最终地址加上加数直接写进去”PC 相对寻址用的是S A - P意思是“写到指令里的数值是目标地址和当前修补位置的差值”。为什么需要 A因为指令编码往往不是从引用的起点开始算而是从指令的某个字段之后才开始算。一个典型的例子x86-64 里call指令的 E8 跳转目标是从下一条指令开始计算的但重定位位置 P 指向的是call指令的操作码地址中间差了指令长度。A 就是用来补偿这个差的。3.2 手算一个 R_X86_64_PC32我拿一个最简单的场景做演示。假设有一个 a.o里面的 main 要调用外部函数func()而func()最终被链接器安排在地址0x400090。a.o 里那条call指令的反汇编样子如下400075: e8 00 00 00 00 call func重定位条目记录的是r_offset 0x400076也就是.text里那条call指令的立即数字段位置r_type R_X86_64_PC32r_addend -4。下一条指令地址是0x400075 5 0x40007a。代入公式S 0x400090 A -4 P 0x400076 结果 S A - P 0x400090 - 4 - 0x400076 0x16也就是说链接器往call指令的立即数字段写入0x16即可。CPU 执行时取0x40007a 0x16 0x400090正好跳到func()。A 的-4就是用来把 P 从立即数字段位置折算到下一条指令位置的。这就是 PC 相对寻址的精髓执行时地址是浮动的只要符号最终地址和引用位置之间的距离小于 32 位有符号数能表达的范围约 ±2GB就没问题。3.3 绝对寻址 R_X86_64_64 和它引发的问题绝对寻址R_X86_64_64的写法是S A直接写死 64 位地址。听起来最简单但有副作用一旦程序里出现这种重定位就会在最终产物里留下一个“绝对地址”。如果可执行文件启用了 PIEPosition Independent Executable地址随机化加载操作系统加载时随机调整基址这个绝对地址就得由动态链接器重新修复导致性能开销和加载复杂度上升。默认情况下发行版 GCC 编译出的程序都是 PIE所以你会发现编译普通代码时哪怕是访问一个全局变量反汇编出来的也多半是 RIP 相对寻址。这就是为什么我们日常很少看到R_X86_64_64反而看到大量R_X86_64_PC32和R_X86_64_PLT32。不是链接器偏爱相对寻址而是 PIE 模式下必须尽量保持地址无关才能让整个可执行文件在不同基址下都能正确运行。3.4 关于 PLT 和 GOT静态链接为什么也有它们你在静态链接产物里同样会看到.plt和.got。现代 GCC 默认生成 PIE 后编译器对函数调用会生成call funcPLT对全局变量访问会生成mov func(%rip), ...这些都会对应R_X86_64_PLT32、R_X86_64_GOTPCREL类型的重定位。静态链接时PLT 和 GOT 并不一定走动态解析而是仍会被填充成直接地址。具体来说-no-pie时链接器能把 PLT 条目优化掉直接把call变成call func-pie时则保留 PLT/GOT 结构但静态链接下这些槽位依然会在加载时被填好不会像动态链接那样走_dl_runtime_resolve。这个细节很多人没注意搞嵌入式交叉编译时尤其容易踩——有些工具链默认-pie你链接出来的裸机程序里居然还有 PLT 跳板一进反汇编就懵。4. 实操演示从编译产物里把重定位挖出来4.1 准备一个最小的两个文件工程理论说了半天不如直接看真家伙。我建一个简单工程a.cextern int global_var; extern void func(void); int main(void) { global_var 42; func(); return 0; }b.cint global_var 10; void func(void) { global_var; }编译命令gcc -c a.c b.c这会在当前目录生成a.o和b.o。注意加上-Wall不会有任何警告因为编译器允许引用尚未定义的外部符号这是重定位机制的前提。4.2 readelf 查看重定位表和符号表先看 a.o 的重定位表readelf -r a.o输出大致是Relocation section .rela.text at offset 0x... contains 2 entries: Offset Info Type Sym. Value Sym. Name Addend 000000000000000f 0000000200000002 R_X86_64_PC32 0000000000000000 global_var - 4 0000000000000016 0000000300000002 R_X86_64_PC32 0000000000000000 func - 4这两行的意思是a.o 的.text段里偏移0x0f处有一处引用global_var偏移0x16处有一处引用func重定位类型都是R_X86_64_PC32加数为-4。再看符号表readelf -s a.oSymbol table .symtab contains 3 entries: Num: Value Size Type Bind Vis Ndx Name 1: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND main 2: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND global_var 3: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND funcglobal_var和func都是 UND未定义它们就是待链接器解析的“欠账”。链接器拿到 b.o 后能在 b.o 的符号表里找到对应的定义债务才被偿清。4.3 反汇编并联动重定位信息单独反汇编 a.o 看到的是占坑状态objdump -dr a.o0000000000000000 main: 0: f3 0f 1e fa endbr64 4: 55 push %rbp 5: 48 89 e5 mov %rsp,%rbp 8: c7 05 00 00 00 00 movl $0x2a,0x0(%rip) # f main0xf e: 2a 00 ... f: R_X86_64_PC32 global_var-0x4 10: b8 00 00 00 00 mov $0x0,%eax 15: e8 00 00 00 00 call 1a main0x1a 16: R_X86_64_PC32 func-0x4 1a: b8 00 00 00 00 mov $0x0,%eax 1f: 5d pop %rbp 20: c3 ret注意objdump -r会把重定位信息以注释形式贴在对应指令旁边。R_X86_64_PC32 global_var-0x4表示这个位置的立即数等链接完成后填写。而global_var-0x4里的-0x4正是加数 A它让公式从指令的立即数字段位置折算到下一条指令的起始位置。链接完成后再看gcc a.o b.o -o app objdump -d app此时call指令的立即数不再是 0而是填上了真正的偏移值。全局变量访问指令同理RIP 相对偏移会指向.data段里global_var最终所在的位置。同一个地址在不同阶段呈现出来的状态完全不同这就是“占坑”和“回填”的直观体现。4.4 链接映射文件里也能看到重定位痕迹如果还想再深入一点用gcc a.o b.o -o app -Wl,-Mapapp.map生成的app.map里记录了每个节区的虚拟地址范围以及每个符号落在哪个地址。比如你会看到global_var被放在0x4020附近、func被放在某个.text偏移处。把这些地址拿回去套公式验证就和第 3 节手算的结果对上了。这个 map 文件在排查布局问题时比 readelf 更直观推荐养成生成 map 的习惯。5. 重定位的三大经典坑和排查姿势5.1 坑一undefined reference 不只是“没链接库”undefined reference to func是重定位阶段最常见的报错。表面原因是符号表中找不到func的定义但实际原因可以很复杂。我自己踩过的就有忘记链接对应的.o链接静态库时把-lfoo放在了引用它的.o前面C 代码和 C 代码混用时没有用extern C包裹 C 头文件导致符号名被 name mangling 改掉用static修饰了本该跨文件使用的函数。排查思路一定要按顺序走先nm看目标文件里有没有这个符号再readelf -s看符号是 GLOBAL 还是 LOCAL再检查链接命令的库顺序。-lfoo有个规则静态库只处理“当前已积累的未定义符号”所以库必须放在引用它的目标文件之后。这个规则我屡次栽跟头后来干脆统一用gcc a.o b.o -Wl,--start-group -lfoo -Wl,--end-group偷懒或者直接改用 pkg-config 生成的顺序。5.2 坑二relocation truncated to fit 真的是“距离太远”relocation truncated to fit: R_X86_64_PC32的含义非常直接R_X86_64_PC32最终要往指令里塞一个 32 位有符号数但计算结果超过了[-2^31, 2^31-1]的表示范围。也就是说引用位置和符号位置之间的实际距离超过了约 2GB。常见触发场景是程序里放了一个巨大的全局数组导致.data或.bss段跨过 2GB 边界或者用链接脚本把某些数据段和代码段安排得过于分散或者强开了-fno-pic同时又把可执行文件映射地址定得很高。解决办法分两层如果是真超过 2GB 的布局问题要调整链接脚本或数据规划如果只是某些特定访问方式触发的可以改访问方式为绝对寻址或加中转比如用-fno-pie编译局部文件或用#pragma pack减小数组。切忌无脑加-mcmodellarge——那个选项会让所有访问都变慢属于重型武器。判断到底差了多少可以这样把报错里的符号地址和引用位置打印出来。用readelf -s拿到符号地址用objdump -d找到引用指令所在地址手工算一下距离超过 2GB 就实锤。5.3 坑三多重定义和强弱符号规则冲突multiple definition of xxx; first defined here是另一类高频重定位报错。这里涉及 GCC 的强符号、弱符号规则多个目标文件里出现同名全局符号时如果都是强符号链接器直接报错如果一强一弱链接器选强符号如果全是弱符号选其中一个。解决多重定义不能只靠删变量。常见场景是头文件里定义了全局变量而多个 .c 文件包含这个头文件造成每个 .o 里都有一份定义。正解是头文件里只写extern int x;在某个 .c 文件里写int x 1;。另一种场景是第三方静态库里和你的代码符号撞车且库是弱符号行为就会很诡异先用-Wl,--allow-multiple-definition临时绕过再改代码不要一上来就用这个选项它会掩盖真正的逻辑错误。5.4 排查重定位问题必备工具清单工具这块我整理了一张实际操作时最常用的命令表工具/命令用途readelf -r/readelf -R查看目标文件和可执行文件的重定位表readelf -s查看符号表确认符号定义状态readelf -S查看节区布局确认段地址readelf -l查看程序头表确认段加载地址objdump -dr反汇编并显示重定位注释最直观objdump -d -MintelIntel 语法的反汇编配合上一条使用nm快速查看符号判断 undefined/definedld -M或gcc -Wl,-Map生成链接映射文件查看最终布局gcc -v打印完整的链接命令行排查链接参数我自己的习惯是遇到任何链接问题先跑一条组合命令gcc a.o b.o -o app -Wl,-Mapapp.map objdump -d -Mintel appmap 文件看布局反汇编看代码形态两个一对照重定位问题十有八九能看出端倪。5.5 静态库链接顺序的补充经验重定位报错里静态库顺序是我见过最容易反复踩的。规则只有一条GNU ld 处理目标文件时是单趟扫描从左到右维护一个“未解决符号集合”。遇到.o文件把它的未定义符号加进集合遇到静态库.a只从里面抽取能解决当前集合中未定义符号的目标成员抽取到的成员又会加入新的未定义符号所以库之间的顺序也有讲究。写成经验就是被依赖的库放右边依赖别人的库放左边。比如gcc main.o libX.a libY.a如果 libX 依赖 libY这个顺序没问题如果反过来就会报 libX 里的符号未定义。多个库互相依赖时用-Wl,--start-group ... -Wl,--end-group让链接器反复扫描直到集合不再变化为止。这个参数我用过很多次代价是链接时间变长但能解决 99% 的顺序问题。6. 静态重定位之后的延伸为什么这层知识值得沉淀6.1 从重定位看编译器和链接器的分工边界理解重定位之后你会发现很多“编译器报错”本质是链接器报错很多“代码莫名其妙”的问题其实是符号可见性与布局问题。比如性能分析时看到某次访问变慢了可能是编译器被迫生成了 GOT 间接访问而不是直接 RIP 相对访问比如可执行文件体积异常可能是每个 .o 都带回了多余节区而你没有用-ffunction-sections配合--gc-sections。这套知识还能帮你读懂交叉编译工具链的行为差异。不同架构的重定位类型完全不同ARM、AArch64、RISC-V 都有各自的修饰符号和计算规则但底层思路一脉相承符号表 重定位表 链接器回填。只要把 x86-64 这套公式想透了换架构时只需查对应 ABI 文档里的公式不慌。6.2 自研链接脚本和启动代码时重定位就是底线如果你做过裸机开发、写过链接脚本对重定位会更敏感。链接脚本里每个段的地址都是手定的启动代码里往往要手工引用一些符号地址。早期我写过一个 bootloader把.data段放在 0x8000 之后但代码段在 0x0000结果因为 PC 相对跳转的距离超过限制链接器疯狂报relocation truncated。后来我用objdump -d确认跳转指令类型手动在跳转链上增加中转跳板问题才解决。对类似场景我的建议是不要在链接脚本里把段地址排列得过于“跳跃”尽量保持代码段和数据段的连续性如果必须跨远处访问要么显式使用绝对地址跳转如ldr伪指令要么增加一层 trampoline。重定位公式里那个 P 和 S 的关系就是你布局设计的地图。6.3 静态链接重定位与动态链接重定位的关系这篇文章讲的是静态链接但动态链接的重定位原理是同源的。动态链接时可执行文件里保存的是R_X86_64_GLOB_DAT、R_X86_64_JUMP_SLOT这类类型由动态链接器在加载时填写。理解静态重定位后再看-fPIC和-fPIE的区别就很轻松前者是生成位置无关代码让共享库任何地址都能加载后者是生成位置无关可执行文件本质上也是为了让加载地址随机化。二者对重定位表的影响就是条目类型和是否可被优化的差异。6.4 一个平时很少被注意的细节字节序和立即数宽度重定位回填的是指令里立即数字段而立即数字段在不同架构和指令上的宽度不同这决定了重定位类型不能乱写。x86-64 的movl $42, addr(%rip)只支持 32 位偏移所以编译器汇编器生成重定位时就限定了只能选R_X86_64_PC32而不是什么 64 位相对。如果试图往一个 32 位字段填一个超过 32 位的结果就必然报 truncated。实际代码里如果遇到这类问题往往不是链接器故障而是代码本身跨越了寻址范围。结尾的几句实在话这些年我每次排查链接问题最后都会回到一个朴素认知上连接器不是神仙它只是照着重定位表按公式填值。它能填多宽、往哪填都是编译器和汇编器提前定好的。所以遇到任何和地址相关的报错别急着搜“终极解法”先readelf -r看这张表再objdump -dr看指令形态基本就能定位到根因。我个人实际操作中最受益的一个习惯是每次链接都顺手生成 map 文件并且把关键变量的地址记下来。一开始觉得多余后来发现无论是重定位报错、启动代码调试还是做性能优化时查内存布局map 文件都成了第一手资料。如果你今天只记住一个操作希望是这一条链接时加-Wl,-Mapapp.map然后把这个文件当成你认识链接器的敲门砖。后面遇到再复杂的重定位问题你至少知道该去哪里找真相。
返回列表