资讯动态

GCC静态链接中的重定位原理:ELF目标文件如何被链接成可执行文件

发布时间:2026/10/9 18:53:03 来源:尧图企业网站定制
如果你日常用GCC编译C程序可能很少会停下来想链接器在可执行文件里到底改了什么我最初学编译原理时一直以为编译就是把.c变成可执行文件直到第一次在嵌入式项目里看到“relocation truncated to fit: R_X86_64_32”这样的报错才意识到静态链接过程中的重定位relocation才是那个最容易被忽略、却又直接影响程序能不能跑起来的关键步骤。简单说重定位就是把目标文件.o里那些“地址还没定”的占位符替换成链接器计算出来的真实内存地址。这篇文章想把这个过程彻底讲透重定位的由来、ELF里对应的数据结构、重定位类型的计算公式、用readelf和objdump观察的实际流程以及我踩过的那些链接坑。适合手头有C/C项目、正在学Linux应用开发或者刚开始碰嵌入式移植的读者。1. 为什么编译完的.o文件不能直接运行重定位的由来1.1 一条编译命令背后隐藏的四个阶段GCC把hello.c变成hello并不是一个魔法过程。它内部经历了预处理、编译、汇编、链接四个阶段。前三个阶段产生的hello.o严格来说只是一个“可重定位目标文件”。你用file命令看它会得到ELF 64-bit LSB relocatable, x86-64这样的描述。这里的relocatable意思是这个文件内部所有需要用到外部符号的位置都先空着等着链接器来填。很多初学者以为.o和可执行文件差不多其实差得远.o文件里没有任何一个绝对地址是确定的它的.text、.data节地址都是从0开始排的。如果你在编译阶段用gcc -cGCC就停在生成.o这一步不做链接。这也是我们观察重定位的最好切入点。因为重定位表就在.o里但最终的结果要等链接完成才出现在可执行文件里。很多教程喜欢一上来就讲动态链接和GOT、PLT反而容易把初学者绕晕。我建议你从静态链接入手先把“.o里留空、链接时回填”这套逻辑吃透后面看动态链接会轻松很多。1.2 地址未知所以需要“占位”假设main.c里调用foo.c里的foo()函数。编译main.c时编译器并不知道foo()将来会挂在哪个地址上甚至连foo.o会不会参与链接都不一定。这时候它只能先给call指令的目标字段填上0再额外生成一条重定位记录在.text段的偏移0x14处有一个4字节的字段需要填上符号foo的地址。这条记录就是重定位项。链接器拿到所有.o文件后会先给每个节分配虚拟地址再回头拿符号foo的最终地址把这个4字节字段填上。这段“先留0、后回填”的过程放到不同架构上只是字段宽度和编码方式不同本质都一样。理解了这一点就理解了重定位的最核心动机地址未知所以用占位符先把位置占住。你平时可能还听过“地址无关代码”“位置无关可执行文件”这些说法它们其实都是围绕同一个核心问题展开的怎样才能让一个指令流在内存地址不确定的情况下仍然准确访问到它想访问的数据和函数。1.3 静态链接的三板斧符号解析、节合并、重定位链接器干活可以拆成三步。第一步是符号解析把所有.o和库文件放在一张大表里检查所有未定义符号外部引用是否都有唯一对应的定义符号如果找不到或找到多个就会报undefined reference或multiple definition。第二步是节合并与地址分配把多个.o的.text合并到一个.text节把.data合并到.data节然后根据链接脚本为每个节和每个符号确定最终虚拟地址。第三步才是重定位根据符号表和节地址逐条处理重定位表里的记录把占位符改写成实际的地址。静态链接的重定位和动态链接有个关键区别静态链接里所有上一步的结果在链接结束时就已经定死生成的可执行文件自包含运行时不再需要外部解析。这也是为什么静态链接出的可执行文件通常更大但部署环境不依赖目标机器上有没有对应的.so。也正因如此静态链接在执行效率上通常有一点点优势因为所有指令在链接期已经被修正成“最直接”的形式运行时不需要再跳一次GOT表或PLT表。2. ELF重定位的底层结构节、符号与重定位项2.1 从readelf -S开始哪些节在配合演出对main.o执行readelf -S你会看到一长串节区但和重定位相关的就那么几个。.text是代码本身.data是已初始化的全局数据.bss是未初始化数据.symtab是符号表.strtab是字符串表而.rela.text这一类节专门用来存放重定位信息。在x86_64的ELF64格式里带addend的重定位节叫.rela.text、.rela.data旧的32位格式有时叫.rel.text区别在于加数addend是放在节里还是藏在被重定位的字段中。我这里给你看一个典型的main.o节表片段Section Headers: [Nr] Name Type Address Offset [ 1] .text PROGBITS 0000000000000000 00000040 [ 4] .data PROGBITS 0000000000000000 000000c0 [ 5] .bss NOBITS 0000000000000000 000000c0 [ 8] .symtab SYMTAB 0000000000000000 00000160 [ 9] .rela.text RELA 0000000000000000 00000260注意观察这些节的Address列在.o里基本全是0。为什么因为此时节的最终内存地址还没分配只有链接器才会把它们放到具体的虚拟地址上。重定位表rela.text自身也还没有地址它的作用纯粹是描述“待办事项”而不是存放可执行数据。2.2 符号表里不只是名字UND、GLOBAL、LOCALreadelf -s main.o显示的符号表除了你认识的函数名还会出现很多辅助符号。最值得关注的是每个符号的Bind和Ndx两列。Bind告诉你这个符号是GLOBAL还是LOCALstatic修饰的局部函数通常是LOCAL只在本目标文件内可见普通函数和全局变量是GLOBAL允许被其他目标文件引用。Ndx告诉你该符号定义在哪个节里如果是UND就说明它是这个文件引用了、但还没定义的外部符号。举个例子下面是一段readelf -s main.o输出中与foo相关的行Num: Value Size Type Bind Vis Ndx Name 9: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND foo可以看到foo的Ndx是UND这说明它在main.o里是一个未定义的外部引用。链接器做符号解析时本质就是检查所有UND符号能否在某个GLOBAL符号里找到匹配项。如果到了链接结束还有UND没被解决那么涉及的每个重定位项也就没法计算这就是undefined reference错误的直接原因。符号表当中还有一类LOCAL符号比如用static修饰的辅助函数它们不会参与跨模块解析却有可能出现在同一重定位节里因为即使在一个目标文件内部局部符号的位置也可能要随节地址一起被修正。2.3 重定位项的三元组offset、type、symbol、addend一个标准的重定位项包含四个关键信息readelf -r会直接以表格形式打印出来。第一个是Offset即r_offset表示要修改的位置在这个目标文件对应节内的偏移量第二个是Info即r_info内部拆成低32位的重定位类型和高32位的符号表索引第三个是符号名和Symbol Value第四个是Addend即r_addend一个参与最终计算的有符号常数。以x86_64为例readelf -r main.o输出可能长这样Relocation section .rela.text at offset 0x260 contains 1 entry: Offset Info Type Sym. Value Sym. Name Addend 0000000000000014 000900000004 R_X86_64_PLT32 0000000000000000 foo - 4前面的0x14告诉链接器改哪个位置R_X86_64_PLT32告诉链接器按什么规则改foo是符号最后的-4是加数。这三个要素缺一不可。理解了这条记录再看后面的计算公式就不会晕。这里需要特别提醒r_info的拆分方式在不同架构上略有差异但ELF的基本思路一致用一个字段同时编码“类型”和“符号索引”节省空间。实际动手时你可以直接用readelf看解析好的结果不需要自己手算比特位。3. 重定位类型与地址计算链接器是怎么改地址的3.1 先认识两个最常见的x86_64重定位公式x86_64上最常碰到的重定位类型一个是R_X86_64_PC32一个是R_X86_64_32。前者用于函数调用后者用于绝对地址写入。它们的通用计算式是结果 S A - PPC32或结果 S A32。其中S是符号在最终可执行文件里的虚拟地址A是重定位项里的addendP是被重定位位置在最终可执行文件里的虚拟地址。这里有个容易踩迷糊的点为什么PC32公式里A经常是-4因为x86的call指令用的是相对下一条指令的偏移CPU在计算跳转目标时会用下一条指令的地址加上指令里存的32位偏移量。链接器重定位的是偏移字段本身它的地址P后面还有4字节的指令长度所以CPU实际计算时自动多了4。为了抵消编译器在生成重定位项时把addend设成-4。你看到readelf输出里foo - 4不是随便写的这正是x86指令长度带来的修正。实际算一次就清楚了。假设main中call指令的偏移字段位于最终地址0x400014foo最终被链接到0x401000addend为-4链接器算出的结果就是0x401000 - 4 - 0x400014 -24写成补码就是0xffffffe8。CPU走到这条call时下一条指令地址是0x400018加上0xffffffe8正好落在0x401000。整个过程严丝合缝不差一字节。如果你用objdump反汇编链接后的可执行文件看到的机器码就是e8 e8 ff ff ff前面的e8是call操作码后面的e8 ff ff ff就是刚刚算出来的偏移。3.2 绝对地址重定位R_X86_64_32的适用范围和限制R_X86_64_32则简单粗暴直接把SA写进32位字段不会做相对位置的减算。这种重定位常见于对全局变量的直接访问或者把某个函数地址加载到寄存器。它的问题也很明显如果链接脚本把目标节放到离得太远的位置导致最终地址超过32位无符号范围链接器就会报“relocation truncated to fit: R_X86_64_32”。在64位系统上做嵌入式或自定义链接脚本时这个错出镜率很高。为什么会有这种限制因为指令的编码字段本身只有32位写不进超过4GB的地址。你可能会想64位系统地址空间那么大怎么可能超过4GB但这不是绝对地址大小的问题而是某些指令天生只能用32位立即数。尤其是当你用自定义链接脚本把代码段放到0x100000000以上时R_X86_64_32就会立刻炸掉。解决办法要么换用支持64位绝对地址的重定位要么改成PC相对访问要么调整链接脚本布局。对多数开发者来说最现实的方案是检查那个变量或函数为什么被编译成了32位绝对访问然后考虑加-fPIC或调整代码逻辑。这也是为什么现代Linux发行版默认编译成PIE位置无关可执行文件用PC相对访问的地方更多绝对地址重定位尽量少用。可执行文件装载到哪个地址都不影响内部跳转安全性和灵活性都好一些。当然PIE带来的代价是性能上偶尔多一次间接跳转但这点开销在现代CPU上小到可以忽略。3.3 -fPIC和PIE下重定位类型的变化以及链接器的“松弛”优化当你用默认选项编译一个C程序时GCC常常会生成PLT/GOT相关的重定位比如R_X86_64_PLT32。这是因为默认的PIE编译模式下所有对外部函数的调用都要走PLT以保证整个文件在任意地址都能运行。你把foo.o和main.o放在一起静态链接时链接器会发现foo就在同一个可执行文件内跳转范围完全够用于是会把原本要经过PLT的调用“拉直”成直接调用把PLT32修正成PC32。这个优化在编译资料里通常叫relaxation或链接器松弛。我在刚接触这个术语时很困惑为什么.o里看到的是PLT32链接完用readelf看可执行文件却变成了R_X86_64_PC32原因就是链接器在重定位阶段做了这种等价替换。它不改语义只减少一次不必要的间接跳转。类似的事情在ARM和RISC-V上更常见比如ARM的BL指令经过链接器松弛后可以直接变成短跳转减少一条指令。理解这一点回头再看readelf -r上出现的各种类型名就不会再望而生畏。4. 实操观察用工具一步步查看重定位过程4.1 准备样本并编译出.o文件光看书不如自己动手我建议你新建两个文件。main.c内容如下extern int foo(int); int main(void) { return foo(3); }foo.c内容如下int foo(int x) { return x * 2; }然后执行以下命令生成目标文件并查看重定位信息gcc -c main.c -o main.o gcc -c foo.c -o foo.o readelf -r main.o readelf -s main.o objdump -dr main.o这套命令在任何Linux发行版上都通用。如果手里是macOS对应的工具链不同但原理相近最方便的办法还是装一个Linux虚拟机或容器。另外记得编译时不要加-O0以外的优化选项-O2会把foo直接内联进main.o那样就看不到外部函数引用的重定位了。4.2 观察重定位条目和指令中的零占位readelf -r main.o的输出你应该能看到.rela.text段里有一条针对foo的重定位记录。readelf -s里foo这一行的Ndx是UND和前面分析完全对得上。objdump -dr更有意思它会把重定位注释直接标在反汇编的指令后面比如0000000000000000 main: 0: f3 0f 1e fa endbr64 4: 48 83 ec 08 sub $0x8,%rsp 8: bf 03 00 00 00 mov $0x3,%edi d: e8 00 00 00 00 call 12 main0x12 e: R_X86_64_PLT32 foo-0x4 12: 48 83 c4 08 add $0x8,%rsp 16: c3 ret前面的机器码e8是call指令后面4字节00 00 00 00就是那串待填充的占位符。你可以用hexdump验证链接之前这里确实是零。记住这个状态它不是一个可用地址而是一个等待重定位的槽位。最关键的是objdump会把foo-0x4这个重定位注释直接标在指令边上看着非常直观。4.3 链接后对比地址变化从零到有接下来做真正的静态链接gcc main.o foo.o -o a.out然后分别运行readelf -s a.out | grep foo和objdump -d a.out。你会发现foo符号有了具体地址比如0x401126main里的call指令也变成了e8加一串非零数值。这个数值就是第3节说的PC相对偏移而不是foo的绝对地址。很多人第一次看可执行文件反汇编会以为call后面跟的就是绝对地址于是对不上号。记住x86的call目标要靠“下一条指令地址偏移”才算出来。如果想要更干净地观察可以加-Wl,-Ttext,0x10000和-no-pie参数把代码段手动铺到固定地址重定位前后地址对比会一目了然。不过要提醒这两个参数会影响整个链接布局正式项目里不要随便改。你也可以用ld -M a.out查看链接地图里面会详细列出每个符号最终落在哪个虚拟地址这对理解重定位结果非常有帮助。4.4 默认PIE与-no-pie的一个容易混淆点我遇到过不少读者问为什么自己的readelf -r main.o里显示的是R_X86_64_PLT32而不是R_X86_64_PC32。这往往不是代码问题而是发行版默认开了PIE。不同GCC版本和发行版默认的选项不同同一个.o文件在不同环境下看到的类型可能略有差异。只要理解了第3节的规则类型名称不同并不会妨碍你判断哪个符号、哪个位置、哪种计算方式。顺带说一句网上总有人问“gcc升级后为啥还是旧版本”这种问题通常和PATH路径或者软链接有关和重定位本身无关。但如果你在交叉编译环境里混用了新旧工具链倒真的会出现“unknown relocation type”这类报错说明当前ld无法识别目标文件里用到的重定位类型这时优先检查工具链版本是否匹配比检查代码更有效。5. 静态链接中重定位的实战坑链接错误与符号解析5.1 undefined reference症状出现在重定位之前undefined reference几乎每个C/C开发都见过但它其实不是重定位计算错误而是符号解析阶段就失败了。链接器在整个链接范围内找不到一个全局符号来匹配重定位项里的UND符号自然没法继续填地址。排查思路很有套路先用nm或readelf看报错符号到底以什么状态出现确认它在哪个库里再检查头文件声明、实现文件是否真的参与了编译、C里有没有因为name mangling导致符号不匹配。一个非常经典的坑是C调用C函数库如果C头文件没包extern CC编译器会把foo搞成带修饰名的符号而C库导出的是普通foo重定位项里的符号和库里的符号对不上最后就是undefined reference。这个和算法无关纯粹是符号名没对齐。遇到这种情况第一件事是用nm看库导出符号的实际名字再对一下重定位表里的符号名往往一眼就能找到原因。5.2 静态库顺序导致的重定位失败一个容易被忽视的“玄学”静态库本身是.o文件的打包集合链接器处理库时有一个从左到右、只按需提取的规则。我举个典型例子main.o调用libb.a里的bar()bar()内部又调用liba.a里的foo()。如果你写成gcc main.o -la -lb链接器先处理liba.a时当前未定义符号只有bar而bar不在liba里所以liba整个被跳过等处理libb.a时提取了bar.o此时未定义符号才出现foo可是liba已经处理完了结果就是foo undefined reference。解决办法有两个一是把被依赖的库放在右边命令改成gcc main.o -lb -la二是用-Wl,--start-group -la -lb -Wl,--end-group让链接器在库之间反复搜索直到未定义符号全部解决。这不是重定位公式的问题但顺序错了会让人误以为是重定位出了毛病所以值得记一下。很多大型项目里这种问题常常出现在CI构建换了一台机器后突然冒出来原因就是环境里的库版本或原有缓存顺序变了。5.3 多重定义和弱符号重定位时到底听谁的如果两个目标文件都定义了同名GLOBAL符号链接器会直接报multiple definition。有的老资料说链接器会“取第一个定义”那是某些动态场景下的规则GNU ld在静态链接时遇到两个强符号默认是报错而不是帮你选。想让它放行可以把其中一个改成弱符号比如用__attribute__((weak))。弱符号的好处是重定位时如果存在强符号S取强符号的地址不存在强符号S取弱符号自己的地址。但在一组全是弱符号的情况下地址则取决于链接顺序和符号优先级实际项目中我会尽量避免依赖这种默认行为宁可显式改函数名或设计模块隔离。从地址计算角度看多重定义和弱符号都是在影响公式里的S本质上是帮链接器决定“该用哪个符号的地址”。如果你在做嵌入式会经常碰到弱符号用来实现弱中断处理函数或默认回调这时要尤其小心同名冲突。一旦两个弱符号真的都定义了链接器不一定报错但链接顺序一变程序行为可能就变这是最难排查的一种。5.4 架构相关ARM的veneer和RISC-V的中断向量重定位重定位不是x86专属嵌入式里问题更明显。以ARM为例BL/B指令使用PC相对跳转但立即数位数有限跳转范围大概在正负32MB内。当代码和函数布局刚好卡在范围边缘时链接器不能只靠简单回填它会自动插入一段veneer也就是一小段辅助跳转代码然后把原来的调用重定位到veneer上。这个过程肉眼看不到但反汇编时能发现多出来一些跳板函数。RISC-V场景也类似最近常有人在MCU工程里问“为什么我的中断函数没被正确调用”只要中断函数是普通C函数它本质上就是个符号。链接器把向量表里的函数指针项写进中断函数地址时靠的仍然是重定位表项。如果链接脚本把向量表和函数放到了不同地址空间类型和加数不对就会静默连错。所以做嵌入式移植第一步永远是用readelf确认每个重定位落到哪个地址空间再怀疑代码逻辑。我个人的习惯是每接触一个新的链接脚本或工具链都会先编译一个只包含main和foo的最小例子分别看链接前和链接后的重定位条目。这个习惯救了我很多次。重定位并不神秘它只是链接器“按表填地址”而已关键是你得看得懂表里那几个字段。下一次再遇到链接错误先别急着改代码拿出readelf和objdump找到那条重定位记录错误往往就清清楚楚了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑