12、重定位基础:静态重定位与动态重定位,重定位表

说到重定位,很多朋友第一反应就是「链接器干的那点事」。嗯,没错,但又不全对。我做了这么多年底层开发,踩过最深的坑之一,就是重定位没搞明白,导致程序在某个特定地址跑得好好的,换个环境就崩了。

说白了,重定位就是「修正地址」。你写代码时用的变量名、函数名,编译后都变成了相对地址或符号。等到链接或加载时,得把这些地址改成真正的内存地址。这个过程,就是重定位。

为什么需要重定位?

你想想看,编译器在编译单个源文件时,它根本不知道其他模块长什么样。比如你调用了 printf,编译器只知道这是个外部符号,具体地址是多少?不知道。它只能先留个空位,打个标记:「这里需要填地址,等链接时再说」。

这个「留空位、打标记」的过程,就是重定位的起点。链接器拿到所有目标文件后,统一分配地址,再把那些空位填上。这就是静态重定位。

核心概念:重定位表(Relocation Table)就是记录这些「空位」的清单。每个条目告诉你:在哪个位置、需要修正成什么值、怎么修正。

静态重定位:链接时搞定一切

静态重定位是最传统的方式。链接器在生成可执行文件时,就把所有地址都算好了。程序加载到内存后,直接运行,不需要再做任何地址修正。

我早期做嵌入式开发时,用的就是静态重定位。那时候内存小,CPU 慢,能省一点算一点。静态重定位的好处就是运行时零开销,加载即运行。

但缺点也很明显:

  • 可执行文件体积大(每个程序都包含完整的库代码)
  • 无法共享代码(比如多个进程用同一个库,内存里得各存一份)
  • 更新库得重新链接整个程序

来看个简单的例子。假设我们有这么个 C 代码:

// main.c
extern int global_var;
void func(void);

int main() {
    func();
    return global_var;
}

编译成目标文件后,用 objdump -r 看看重定位表:

$ gcc -c main.c -o main.o
$ objdump -r main.o

main.o:     file format elf64-x86-64

RELOCATION RECORDS FOR [.text]:
OFFSET           TYPE              VALUE
0000000000000015  R_X86_64_PLT32    func-0x0000000000000004
0000000000000022  R_X86_64_PC32     global_var-0x0000000000000004

看到了吗?funcglobal_var 都还没确定地址。链接器拿到这个表,就知道在偏移 0x15 和 0x22 处需要填地址。填什么?根据最终分配的内存地址算出来。

我的经验:调试链接问题时,objdump -rreadelf -r 是我最常用的两个命令。遇到莫名其妙的段错误,先看看重定位表有没有异常条目。

动态重定位:加载时再修正

动态重定位就灵活多了。程序编译成共享库(.so 或 .dll)时,链接器不把地址写死,而是生成位置无关代码(PIC)。程序加载时,动态链接器再根据实际加载地址修正。

为什么会需要这个?你想想看,如果系统里有 100 个程序都用 libc.so,静态链接的话每个程序都包含一份 libc 的副本,内存里得存 100 份。用动态链接,内存里只存一份,所有程序共享。

但代价就是加载时多了个重定位步骤。动态链接器要遍历重定位表,逐个修正地址。这个过程叫「重定位」或「绑定」。

动态重定位有两种策略:

  • 加载时重定位:程序启动时,一次性把所有符号地址都算好。启动慢,但运行时快。
  • 延迟绑定(Lazy Binding):用到哪个符号才去解析哪个。启动快,但第一次调用某个函数时会有点延迟。

我个人习惯用延迟绑定。尤其是大型程序,启动时如果把所有符号都解析一遍,那体验太差了。延迟绑定让启动飞快,后面调用时稍微卡一下,但用户感知不到。

注意:延迟绑定有个坑——如果符号解析失败,程序会在运行时才崩溃,而不是启动时。我曾经排查过一个 bug,程序跑了几分钟才崩,最后发现是某个动态库的符号版本不匹配。如果用加载时重定位,启动时就能报错。

重定位表的结构

重定位表本质上就是个数组。每个条目包含几个关键字段:

字段 含义
Offset 需要修正的位置(相对于段起始的偏移)
Info 包含符号索引和重定位类型
Addend 修正时的附加常量(有些格式没有)

在 ELF 格式里,重定位表是独立的节(Section),通常叫 .rel.text.rel.data 等。每个节对应一个需要重定位的目标节。

重定位类型决定了怎么算地址。常见的类型有:

  • 绝对重定位:直接把符号的地址填进去
  • 相对重定位:计算符号地址与当前位置的差值
  • PLT/GOT 重定位:用于动态链接的间接跳转

嗯,这里要特别注意:相对重定位在 PIC 代码里用得很多。因为代码段是只读的,动态链接器不能修改它,所以用相对寻址来绕过这个问题。

SVG:重定位流程全景图

下面这张图展示了从编译到加载的完整重定位流程。我建议你仔细看看,尤其是「静态」和「动态」两条路径的分叉点。

重定位流程全景图 编译阶段 生成目标文件 + 重定位表 链接方式? 静态链接 链接器计算所有地址 生成可执行文件 地址已固定,无需再修正 加载运行 零重定位开销 动态链接 生成 PIC 代码 + GOT/PLT 加载时重定位 动态链接器修正 GOT 表 运行 延迟绑定 / 已绑定 编译 静态 动态 运行

实战:查看重定位表

光说不练假把式。我们来实际看看一个共享库的重定位表长什么样。

$ gcc -shared -fPIC -o libtest.so test.c
$ readelf -r libtest.so

Relocation section '.rela.dyn' at offset 0x4a8 contains 8 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000003dc8  000200000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize + 0
000000003dd0  000300000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTable + 0
...

Relocation section '.rela.plt' at offset 0x508 contains 5 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000003de0  000400000007 R_X86_64_JUMP_SLOT 0000000000000000 printf + 0
000000003de8  000500000007 R_X86_64_JUMP_SLOT 0000000000000000 malloc + 0
...

看到 R_X86_64_GLOB_DATR_X86_64_JUMP_SLOT 了吗?前者是全局数据重定位,后者是函数跳转重定位。动态链接器加载时,会遍历这些条目,把 GOT(全局偏移表)里的值填上正确的地址。

避坑指南:我曾经遇到一个诡异的问题——程序在 Ubuntu 18.04 上跑得好好的,换到 CentOS 7 就崩。查了半天,发现是 glibc 版本不同,某个符号的版本号变了,动态链接器找不到对应的重定位目标。解决方案是编译时加上 -Wl,--no-undefined,强制链接器检查所有符号是否都有定义。

静态 vs 动态:怎么选?

这个问题没有标准答案,得看场景。我个人的经验是:

  • 嵌入式系统、内核模块:用静态重定位。运行时环境简单,不需要动态加载,静态链接更可靠。
  • 桌面应用、服务器程序:用动态重定位。节省内存,方便更新库。
  • 性能敏感的场景:可以考虑静态链接,避免动态链接器的运行时开销。但要注意,静态链接会让可执行文件变大。

嗯,其实现在很多大型项目是混合使用的。核心模块静态链接,插件或扩展模块动态加载。这样既保证了核心性能,又保留了灵活性。

最后说一句:重定位看似是链接器的内部细节,但理解它,能帮你解决很多运行时问题。下次遇到「符号找不到」或「段错误」,不妨先看看重定位表。


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