8、重定位机制:重定位表,绝对地址重定位与相对地址重定位,重定位类型

好,咱们今天聊重定位。这名字听着挺唬人,其实说白了就是一件事:把代码里的“占位符”填成真正的地址

我记得刚入行那会儿,第一次看链接器输出的 map 文件,满屏的地址和偏移量,看得我头皮发麻。后来自己动手写了一个小型的静态链接器,才真正搞明白——重定位就是链接器的“最后一公里”,也是最容易出幺蛾子的地方。

8.1 为什么需要重定位?

你想想看,编译器在编译每个 .c 文件时,它根本不知道其他文件长什么样。比如你在 a.c 里调用了 b.c 里的函数 foo(),编译器只能先留个“坑”,等链接器来填。

这个“坑”就是重定位条目。每个坑都记录了:

  • 在哪个节(.text、.data 等)
  • 在节内的什么位置
  • 需要填什么类型的地址

链接器拿到所有目标文件后,先合并节,再遍历每个重定位条目,把地址填进去。嗯,流程就是这么简单,但细节里全是坑。

8.2 重定位表长什么样?

在 ELF 文件里,重定位表是一个独立的节,通常叫 .rel.text.rela.text。区别在于:.rel 只存偏移量和符号索引,.rela 还多存一个“加数”。

我个人的习惯是优先用 .rela,因为加数可以让你更灵活地处理地址计算。来看一个实际的例子:

// a.c
extern int global_var;
void func(void) {
    global_var = 42;
}

编译后用 readelf -r a.o 查看重定位表:

Relocation section '.rel.text' at offset 0x2c8 contains 1 entry:
 Offset     Info    Type            Sym.Value  Sym. Name
00000005  00000201 R_386_32          00000000   global_var

这里 Offset 是 0x05,表示在 .text 节偏移 5 字节的位置需要修改。TypeR_386_32,表示这是一个 32 位绝对地址重定位。

8.3 绝对地址重定位 vs 相对地址重定位

这是重定位里最核心的两个概念。我分开讲。

8.3.1 绝对地址重定位

说白了,就是把符号的最终地址直接写进去。比如上面的 R_386_32,链接器算出 global_var 在内存中的地址是 0x804a000,就直接把这个值写到指令里。

这种方式的优点是简单直接,缺点也很明显——代码不能随便挪位置。一旦加载地址变了,所有绝对地址都得重新算。

我曾经踩过的坑: 在写一个 bootloader 时,用了绝对地址重定位的全局变量。结果代码从 Flash 拷贝到 RAM 后,所有指针都指向了 Flash 里的旧地址,程序直接跑飞。排查了整整一个下午才发现是重定位类型选错了。

8.3.2 相对地址重定位

相对地址不写死地址,而是写一个“偏移量”。最典型的就是 x86 上的 R_386_PC32,用于函数调用。

它的计算方式是:

最终指令值 = 目标地址 - (当前指令地址 + 指令长度)

举个例子,假设 call 指令在 0x100,目标函数在 0x200,指令长度 5 字节:

值 = 0x200 - (0x100 + 5) = 0xFB

这样,无论代码段被加载到哪个基地址,只要指令和目标的相对位置不变,call 就能正确跳转。这也是为什么共享库里大部分都是相对地址重定位——位置无关嘛。

8.4 重定位类型详解

不同的 CPU 架构有不同的重定位类型。我以最常用的 x86-64 为例,列出几个你一定会遇到的:

类型 说明 适用场景
R_X86_64_32 1 32位绝对地址 全局变量、静态变量
R_X86_64_PC32 2 32位相对地址 函数调用、跳转
R_X86_64_PLT32 4 通过PLT的相对地址 共享库函数调用
R_X86_64_GOTPCREL 9 GOT表项的相对地址 共享库数据访问
R_X86_64_64 1 64位绝对地址 大地址空间的数据访问

这里我特别想提一下 R_X86_64_PLT32。你在编译共享库时,如果调用了另一个共享库的函数,链接器会生成一个 PLT(过程链接表)条目。这个重定位类型就是告诉链接器:帮我算一下从当前指令到 PLT 条目的偏移。

为什么要绕一圈?因为动态链接时,函数的最终地址要到运行时才能确定。PLT 相当于一个“跳板”,先跳到 PLT,PLT 再通过 GOT(全局偏移表)跳到真正的函数地址。

一个小技巧: 如果你用 gcc -static 编译,所有重定位都会在链接时完成,不会生成 PLT/GOT。这样程序启动快,但体积大。我个人在嵌入式开发中偏好静态链接,省去动态链接器加载的麻烦。

8.5 重定位的完整流程

咱们用一张图把整个流程串起来:

重定位完整流程 编译器编译 生成 .o 目标文件 生成重定位表 .rel.text / .rela.text 链接器处理 合并节 + 符号解析 遍历重定位条目 绝对地址重定位 直接写入符号地址 相对地址重定位 计算偏移量后写入 生成可执行文件

从图上你能看到,链接器拿到重定位表后,会逐个处理每个条目。根据类型不同,要么直接填绝对地址,要么算相对偏移。最后所有“坑”填完,一个完整的可执行文件就诞生了。

8.6 实战中的注意事项

最后分享几个我这些年积累的经验:

  • 检查重定位溢出:32 位绝对地址只能寻址 4GB 空间。如果你在 64 位系统上不小心用了 R_X86_64_32,而符号地址超过了 4GB,链接器会报错。我建议在链接脚本里加上 --no-allow-shlib-undefined 来提前发现这类问题。
  • 小心节对齐:重定位偏移量是基于节起始位置的。如果链接器调整了节的对齐方式,所有偏移量都得重新算。嗯,这也是为什么 ld--section-alignment 参数不能乱设。
  • 动态库的重定位:共享库的重定位更复杂,因为加载地址不确定。这时候要用 -fPIC 编译,生成位置无关代码。我见过有人忘了加这个选项,结果动态库加载时符号解析失败,程序直接 segfault。

核心要点回顾:

  • 重定位表记录了所有需要修改地址的位置
  • 绝对地址重定位直接写入符号地址,简单但不可重定位
  • 相对地址重定位写入偏移量,支持位置无关
  • 不同架构有不同重定位类型,x86-64 上最常用的是 R_X86_64_32 和 R_X86_64_PC32
  • 动态链接依赖 PLT/GOT 机制,需要额外的重定位类型支持

重定位这块内容,说白了就是链接器在帮我们“擦屁股”——把编译器留下的烂摊子收拾干净。理解了它,你就能真正看懂链接器在背后干了什么活。


公众号:蓝海资料掘金营,微信deep3321