如果你在 Linux 下写过 C 程序八成见过undefined reference或者relocation truncated to fit这类链接错误。过去我遇到这些报错时第一反应是去查函数名、检查库有没有链接对直到有一次被一个莫名其妙的地址越界折腾了大半天才真正老老实实打开 ELF 的文档把 relocation 从头到尾捋了一遍。回头再看之前那些玄学报错全部都有清晰的电路图只是我当时把 relocation 当成了链接器内部的黑话没当回事。实际上ELF 格式里的 relocation重定位就是一份补丁清单。目标文件编译出来后编译器并不知道最终这些代码会被安排在什么内存地址也不知道外部函数和全局变量到底在哪于是先把所有悬空的引用记下来交给链接器在后期统一填充。这篇文章就是把我自己从头理解 relocation 的过程整理出来它会告诉你为什么需要这个东西、底层数据结构怎么设计的、x86-64 下那些常见类型背后的数学公式是什么以及怎么用 readelf 和 objdump 把一个 .o 文件的重定位表翻个底朝天。如果你在做二进制分析、嵌入式交叉编译或者只是好奇链接器到底干了什么活这篇文章可以直接拿来做入门地图。1. 为什么编译到一半的代码需要重定位1.1 每个目标文件都是一张只能自己看的局部地图先想一个最简单的问题编译器处理一个.c文件时它知道另一个文件里函数长什么样吗答案是只知道声明不知道地址。比如main.c里call external_func()编译器能确定external_func的符号名字却不知道这个函数最终会落在虚拟地址的哪一段——因为所有.o文件最后合并成什么布局是链接器说了算不是编译器说了算。于是编译器留了一手它把call指令的跳转偏移先空着然后在重定位表里记一条这里需要一个补丁内容是某个符号的地址。这也是为什么.o文件在 ELF 里叫relocatable file直译过来就是可被重定位的文件它天生就不是一份完整地图而是等着拼接的局部地图。我习惯打一个比方每个.o文件都像一张只在小区内部有效的指示图写的全是从当前位置往后走 30 米到食堂绝不会有距离城市中心 3 公里这种外部坐标。链接器把所有局部图拼到一张完整地图上时必须把所有相对当前位置的说法换算成最终内存地址的说法这个换算过程就是 relocation。1.2 链接器在干什么把局部地图拼成完整地图链接器的工作分两步走。第一步是把输入的所有.o文件按 section 归类main.o的.text和extern.o的.text会排进输出文件的同一个.text段里然后根据链接脚本为每个输出段分配最终的虚拟地址。第二步才轮到重定位遍历每个目标文件里记录下来的补丁清单对照最终的地址布局把机器码里空缺的位置填上。所以 relocation 的核心可以浓缩成三件事改哪里地址、参考谁符号、用什么规则类型。第一条重定位记录实际上就是这三元组的文字化表示。汇编器负责在生成.o时发现这个引用需要以后补链接器负责在拿到所有目标文件后真正执行补丁。两者缺一不可。这里有个容易忽略的点并不是所有跨文件引用都需要重定位。如果同一个编译单元内两个函数互相调用汇编器能直接算出函数间的相对位移E8 rel32指令当场就填好了不会产生重定位需求。只有那些链接期才能确定的引用才会出现在.rela.text、.rela.data这些节里。1.3 重定位的两个典型场景静态与动态搞清楚链接一次就完事是不够的现代系统里 relocation 要出现不止一次。静态链接的场景比较简单ld在生成可执行文件时把所有外部符号的地址算出来直接填进最终文件之后运行时不需要再改。动态链接就是另一个故事了。共享库的加载地址是运行时才定的受地址随机化影响每次加载的基地址都可能不同。如果库内部有一条指令要访问一个绝对地址那加载器必须在把库映射进内存之后临时修改对应位置。所以动态可执行文件里还保留了专门的动态重定位节.rela.dyn、.rela.plt交给动态链接器在进程启动时处理。这也是理解 relocation 的关键心态它不是链接器私有的内部细节而是一个从目标文件到可执行文件再到最终进程内存一路延续的机制。2. 深入重定位的数据结构REL、RELA 和符号表2.1 一条重定位记录到底长什么样在 64 位 ELF 文件里最常见的重定位记录结构体是Elf64_Rela一共 24 个字节三个字段字段字节数含义r_offset8目标位置在目标文件里是节内偏移在可执行文件/共享库里是虚拟地址r_info8低 32 位是重定位类型高 32 位是符号表索引r_addend8加数参与最终地址计算的常量为什么要有r_offset它是改哪里的坐标。在.o文件里这个值通常是针对所属 section 的偏移你只有把它跟 section 的最终基地址相加才能得到真正的指令位置。在链接完毕的可执行文件里重定位表里的r_offset就已经是绝对的虚拟地址了因为此时 section 布局已定。r_info是这条记录的灵魂它同时编码了两样东西。用标准宏来看ELF64_R_SYM(info)提取出符号表索引指向.symtab里某个符号ELF64_R_TYPE(info)提取出类型编号决定地址计算采用什么公式。readelf -r输出中的Info列显示的就是这个原始值所以经常看到0000000800000002这种一长串十六进制前一半是符号索引后一半才是类型号。2.2 符号表是重定位的参考答案重定位记录里的符号索引并不是直接存一个函数名或变量名而是指向符号表项。这一点很重要链接器真正读取的是Elf64_Sym结构里的st_value、st_shndx所在 section等字段名字反而只是给人看的。readelf -r很贴心它会在解析符号表之后把符号名字直接列在每一行的末尾所以我们通常看到的是一条条可读的记录比如0000000000000014 0000000800000002 R_X86_64_PC32 0000000000000000 global_counter - 4如果符号索引是 0也就是SHN_UNDEF说明这条重定位并不依赖任何具名符号——最典型的例子就是R_X86_64_RELATIVE下面第 5 章会专门讲。2.3 REL 与 RELA为什么 x86-64 偏爱 RELAELF 规范里其实有两种重定位记录Elf64_Rel和Elf64_Rela。区别只有一个Rel没有r_addend字段所有加数必须存放于被修正位置原有的机器码或数据里Rela则把加数单独放在结构体里被修正位置可以初始化为 0。听起来差别不大实际影响不小。REL的问题在于链接器必须先去目标位置读一个初始值再把这个初始值当作加数参与计算。如果目标位置在只读代码段而某个重定位类型又需要修改地址偏移量这套读出来再改回去的流程就非常别扭而且容易在调试时让人困惑——这个地址值一开始就存在指令里到底是谁写的x86-64 选择的是RELA好处是重定位记录自带完整的加数诊断信息一目了然链接器不需要去逆向读目标位置的旧值。类似地ARM64 也用RELA而传统的 i386、ARM32 则大量使用REL。当你看到某个架构的readelf -r输出里多了一个Addend列基本就能判断它用的是RELA系列。3. 咬文嚼字x86-64 常用重定位类型的语义与公式3.1 公式里的几个字母分别代表什么x86-64 System V ABI 文档里重定位类型的语义大多写成公式比如S A - P。第一次看会懵其实字母固定就那几个S符号的最终地址也就是符号表里那个符号在链接完成后的虚拟地址。A加数来自r_addend。P被修正位置本身的内存地址由r_offset换算而来。G符号在 GOT全局偏移表里的条目偏移不是符号的最终地址。GOTGOT 表的基地址。B共享对象被加载到内存后的基地址。抓住这套字母几乎所有重定位类型都能自己推导出来。3.2 R_X86_64_64、R_X86_64_PC32绝对地址和相对地址的分水岭R_X86_64_64最简单公式是S A意思是往目标位置写入一个 64 位的绝对地址。这个类型最常见于数据段引用和某些调试信息里比如一个指针变量的初始值需要指向某个外部符号。R_X86_64_PC32就要绕一下公式是S A - P。它实现的是PC 相对寻址也就是写入一个相对当前指令位置的 32 位偏移。为什么公式里有一个- P因为 CPU 在执行call rel32这类指令时实际跳转地址 下一条指令地址 偏移量而重定位位置P往往不是那条指令的末尾。以 x86-64 的call指令为例E8 rel32一共 5 个字节P指向 rel32 字段但 CPU 真正做加法时用的是P 4整个指令之后的位置。所以r_addend会被设置成-4让S A - P最终等于S - (P 4)正好和 CPU 的计算方式对齐。这个-4是新手最容易忽略的细节。看到readelf输出里.text的重定位 Addend 写着- 4不是错误而是为了让公式满足指令编码的偏移基准。R_X86_64_PC32的致命限制是结果只能装进 32 位有符号整数也就是正负 2GB 范围。一旦代码段和数据段距离过远链接器就会报relocation truncated to fit这就是第 6 章要讲的核心错误之一。3.3 位置无关的关键跳板R_X86_64_GOTPCREL 与 R_X86_64_PLT32默认开启 PIE位置无关可执行文件的主流 Linux 发行版里你会发现访问外部全局变量时几乎看不到R_X86_64_PC32取而代之的是R_X86_64_GOTPCREL。原因很简单PIE 文件被加载到的基地址是不固定的代码段里不能出现写死的绝对地址否则地址随机化就白做了。于是所有外部数据符号的统一处理方式是符号的地址放进 GOT 的一个条目代码里通过一个相对 GOT 的偏移去取条目内容。R_X86_64_GOTPCREL的公式是G GOT A - P。它先把符号在 GOT 中的条目偏移G算出来再加上 GOT 基地址GOT得到一个指向该 GOT 项的地址最后减掉P变成相对偏移。这条重定位的结果会嵌在类似mov fooGOTPCREL(%rip), %rax的指令里代码执行时从那个 GOT 项中读出符号的真实地址。函数调用走的是另一条路。外部函数通常用R_X86_64_PLT32公式是PLT(S) A - P其中PLT(S)是符号对应 PLT过程链接表桩函数的地址。现代链接器在不需要 PLT 的场合会把这种类型退化成PC32但概念上还是要知道调用外部函数跳的往往不是函数本身而是 PLT 里的一段转发代码。3.4 R_X86_64_RELATIVE共享库加载时的平移补丁共享库被动态链接器加载到某个基地址B之后库里所有原本按静态基准地址计算出来的绝对地址全都需要整体平移B。R_X86_64_RELATIVE就是干这个的公式为B A。它不需要任何符号索引因为本质上不是某个符号的地址而是整个对象基地址加上固定偏移。GNU 编译工具在生成共享库时会针对很多内部指针生成大量R_X86_64_RELATIVE记录。这类重定位数量巨大但计算简单是加载期最常见的重定位类型。安全性方面现代系统又把 GOT 等关键区域通过RELRO机制设为只读所以这些记录必须在进程启动早期就被处理完。4. 实操拆开一个 .o亲手看重定位长什么样子4.1 准备一个跨界引用的小例子纸上谈兵到此为止现在拿真实工具把重定位表翻出来。准备两个文件/* extern.c */ int global_counter 1; void external_func(void) { global_counter 1; }/* main.c */ extern int global_counter; extern void external_func(void); int local_counter 100; void local_func(void) { global_counter 42; external_func(); } void local_func2(void) { local_counter 1; }先用一种强制关闭 PIE 的方式编译目标文件方便先看最基础的PC32重定位gcc -fno-pie -fno-pic -c main.c -o main_nopie.o再用默认 PIE 方式生成一个后面对比用gcc -c main.c -o main_pie.o4.2 readelf -r 的逐字段解读查看main_nopie.o的重定位readelf -r main_nopie.o输出大致如下Relocation section .rela.text at offset 0x2c0 contains 2 entries: Offset Info Type Sym. Value Sym. Name Addend 0000000000000014 0000000800000002 R_X86_64_PC32 0000000000000000 global_counter - 4 000000000000001d 0000000900000004 R_X86_64_PLT32 0000000000000000 external_func - 4第一眼要关注Type列。R_X86_64_PC32出现在global_counter那条说明编译器用 PC 相对寻址去访问外部全局变量R_X86_64_PLT32出现在external_func那条说明调用外部函数走 PLT。第二列Info的十六进制拆开来看前四位00000008是符号表索引后四位00000002是类型编号跟Type列一一对应。Offset是相对.text节头的偏移不是虚拟地址。再对比main_pie.oreadelf -r main_pie.o输出会有明显区别Relocation section .rela.text at offset 0x300 contains 2 entries: Offset Info Type Sym. Value Sym. Name Addend 0000000000000014 0000000800000009 R_X86_64_GOTPCREL 0000000000000000 global_counter - 4 000000000000001d 0000000900000004 R_X86_64_PLT32 0000000000000000 external_func - 4同样访问global_counterPIE 模式下变成了R_X86_64_GOTPCREL。这就是第 3 章讲的默认 PIE 强制代码位置无关外部数据必须经过 GOT 中转。函数调用那条还是PLT32因为 PLT 本身就是位置无关的设计。如果你还想看.data段的重定位把extern.c里那个全局变量的声明搬到另一个模块并在数据区引用readelf -r会出现.rela.data节。实际项目里.rela.data经常藏着一堆绝对地址引用别只看代码段。4.3 objdump -dr 把重定位放回指令上下文readelf -r给出的是列表视角但它不告诉你这条重定位对应哪条指令。这时候就得用objdump -drobjdump -dr main_pie.o输出片段能看到反汇编和重定位标注混排0000000000000000 local_func: 0: f3 0f 1e fa endbr64 4: 55 push %rbp 5: 48 89 e5 mov %rsp,%rbp 8: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax b: R_X86_64_GOTPCREL global_counter-0x4 f: c7 00 2a 00 00 00 movl $0x2a,(%rax) 15: e8 00 00 00 00 call 0x1a 16: R_X86_64_PLT32 external_func-0x4 1a: 90 nop看到mov 0x0(%rip),%rax下面标着R_X86_64_GOTPCREL你就能直观理解这条指令的偏移量字段当前全是 0链接器之后会把G GOT - 4的数值填进那 4 个字节。call 0x1a下面标的R_X86_64_PLT32同理0x1a是占位值真正跳转目标链接时才会补上。objdump -drx加一个x可以顺带显示符号表配合定位符号定义位置非常方便。4.4 链接之后的动态重定位把两个目标文件链接成可执行文件再回头看重定位表gcc -fno-pie -fno-pic main_nopie.o extern.c -o prog_nonpie gcc main_pie.o extern.c -o prog_pie readelf -r prog_nonpie readelf -r prog_pie非 PIE 的prog_nonpie里.rela.text已经不存在了——因为静态重定位在链接期全部完成程序加载后不需要再改。.rela.dyn里通常会有些与libc的全局符号相关的记录比如R_X86_64_GLOB_DAT。PIE 的prog_pie就耐看了它会有.rela.dyn和.rela.plt两个动态重定位节里面的R_X86_64_RELATIVE、R_X86_64_JUMP_SLOT等项目就是动态链接器在进程启动时需要处理的待办清单。这也是非常重要的认知一个可执行文件就算是最终产物也不代表它没有重定位需求只要它选择动态链接、选择 PIE加载期补丁永远跑不掉。5. 动态链接的分工GOT、PLT 和看似多余的重定位5.1 为什么共享库不能像静态链接一样一次算完前面静态链接的例子展示了最理想的情形最终地址链接时全定了运行时零修改。共享库做不到因为同一个.so文件在不同进程里的加载基地址是变化的代码和全局数据都必须经受整体搬移。如果一个库里有一百个绝对地址引用那加载器就得在进程启动时做一百次修正。有人可能会问那为什么不把所有共享库代码也全部做成 PC 相对寻址连重定位都省掉架构上确实可以x86-64 的长跳转用call rel32够用但数据访问做不到完全无成本。全局变量、函数指针、虚表这些需要绝对地址的场景必须有一个地方存放真实地址同时又得让指令以某种基址 偏移的方式来够到这个储存处。GOT 就是为这个需求生的。5.2 GOT 与 PLT 的分工数据走 GOT函数走 PLTGOT 的本质是一张地址表。代码不直接引用外部符号而是引用 GOT 里的一个槽位再从槽位里读取真实地址。动态重定位里最常见的就是请把符号X的地址写到 GOT 第 N 项这个类型在 readelf 里显示为R_X86_64_GLOB_DAT或R_X86_64_JUMP_SLOT。PLT 则是函数调用的跳板。每个外部函数在.plt段有一个桩桩的典型逻辑是先jmpq *GOT[n]如果 GOT 项还没被填充就跳到一段解析代码解析完成后会改写对应的 GOT 项。下一次再调用同一个函数jmpq直接就跳到真实地址了。这个机制被称为惰性绑定。默认情况下外部函数第一次被调用前并不解析地址省去了大量启动成本。你可以在编译时用-z now强制全部提前绑定代价是启动时多干活好处是 GOT 可以变成只读安全属性和 GOT 被改风险都会改善。很多发行版为了提高安全性默认已经开启这个选项。5.3 用 readelf 观察可执行文件里的动态重定位回到prog_pie执行readelf -r prog_pie你会看到类似这样的结构Relocation section .rela.dyn at offset 0x4c0 contains 4 entries: Offset Info Type Sym. Value Sym. Name Addend 0000000000003df0 0000000000000008 R_X86_64_RELATIVE 1000 0000000000003df8 0000000200000008 R_X86_64_RELATIVE 1100 0000000000003fe0 0000000200000006 R_X86_64_GLOB_DAT 0000000000000000 global_counter Relocation section .rela.plt at offset 0x500 contains 1 entries: Offset Info Type Sym. Value Sym. Name Addend 0000000000003ff8 0000000300000007 R_X86_64_JUMP_SLOT 0000000000000000 external_funcR_X86_64_RELATIVE不依赖任何具名符号它就是从基地址加一个偏移专门给那些内部绝对地址做整体平移。R_X86_64_GLOB_DAT表示往 GOT 里写外部变量的真实地址R_X86_64_JUMP_SLOT是给 PLT 桩填函数地址用的。要快速查看一个程序依赖哪些共享库、以及动态段里有哪些元数据配套使用readelf -d prog_pie重点看NEEDED、DT_RELA、DT_RELASZ、DT_JMPREL这些条目。动态链接器正是通过这些元信息定位到重定位表的位置和大小然后逐条处理。6. 在实际项目中遇到的重定位问题与排查思路6.1 undefined reference链接期符号缺失严格说undefined reference出现在链接器查符号表阶段比执行重定位更早但它是大家最常见的relocation 相关报错。根因是重定位清单里有一条记录了符号X的索引但所有输入文件加库的符号表里都找不到X的定义。排查套路很固定先确认是工程里漏了源文件还是外部库没链接对。nm是不错的工具对某个文件执行nm file.oU表示未定义符号大写字母表示已定义。如果你看到某个库文件里根本没有你需要的那个符号那大概率是库版本不对或者函数名拼错。还有一个静态库顺序问题值得一提静态库只在命令行的位置从右往左解析未定义符号liba.a依赖libb.a命令行里b必须出现在a后面否则链接器先处理完a时还没看到b的定义就会报未定义。6.2 relocation truncated to fit地址放不下的经典报错这个错误的字面意思是重定位结果放不进目标字段。最常见的水下场景是R_X86_64_PC32算出的偏移超出了 32 位有符号整数范围。链接器很实在它会直接告诉你哪一个.o文件里的哪条重定位类型溢出了。排查时先戴上布局眼镜。执行gcc -Wl,-Mapoutput.map -o app *.o然后打开output.map看.text和.data的起始地址和大小。很多嵌入式项目的链接脚本会刻意把只读区放在低地址、把可写区放在高地址如果中间隔着巨大空洞就可能出现某个跨 2GB 距离的 PC 相对引用。解决思路一般是调整链接脚本的MEMORY区域划分或者对数组、缓冲区的放置位置做规划实在无法避免超大跨度时可以换用支持更大范围的重定位类型或指令——比如改成绝对寻址、使用-mcmodellarge之类的代码模型选项。还有一类截断发生在目标文件本身。32 位架构上如果代码段超过 2GBR_386_PC32也会截断。现在纯 32 位的大程序不多倒是交叉编译场景经常踩因为交叉工具链默认的链接脚本把地址空间拉得很开。6.3 动态链接器运行时报错符号查找失败动态重定位如果没找到符号程序启动时动态链接器会直接终止报symbol lookup error: undefined symbol: xxx。这种错误几乎不会出现在链接期因为它发生在程序已经生成、但运行时加载依赖时。常见原因程序链接时用的libA.so版本和运行时实际加载的libA.so版本不一致导致某个符号在新版本里被移除或改名。排查时先看readelf -d app里NEEDED列出的库名再用readelf -s查看库里的符号表确认符号是否存在。环境变量LD_DEBUGlibs可以输出动态链接器的搜索过程LD_DEBUGsymbols则能追踪每个符号的解析结果这是动态链接问题里最有效的调试手段。6.4 交叉验证三张表联合定位摸爬滚打下来我发现自己遇到 relocation 问题时单纯看报错会走很多弯路但把三张表摆在一起基本必中readelf -S看 section 布局、readelf -r看重定位清单、objdump -dr看指令上下文。先确认重定位类型是什么再确认符号在哪定义最后确认指令字段的大小能不能装下结果绝大多数所谓的玄学都会当场露出真面目。之前在某项目排查一个共享库崩溃问题时我一度怀疑是代码逻辑错误后来偶然用readelf -r看库本身发现库里居然残留了一堆未处理的R_X86_64_64重定位根因是编译时没有加-fPIC库根本无法被重定位到其他基地址。这种错误链接期不一定会立刻暴露但运行到第一次访问那几条指令时就崩。单看代码永远找不到规律回到二进制视角才一击命中。我个人在看过这么多二进制细节后的体会是relocation 这个概念一点都不高深你只需要一直追问三件事——这条引用要修哪里、修的时候参考哪个符号、以及用什么样的数学公式去修。只要这三件事能回答上来再冷门的重定位类型你都能自己推导回答不上来就去readelf -r、objdump -dr里找答案。工具永远比文档更诚实。如果你还想往深走一层建议自己动手写一个最小的链接器脚本或者写几行汇编故意制造一个relocation truncated to fit把报错、Map 文件、重定位表三者对照着看一遍那比你读十篇概念文章都管用。对付二进制世界的问题老办法永远是把抽象概念变成可观察的输出然后一点点拆开看。
阅读完成 · 觉得有帮助?