位置无关代码(PIC)
- 位置无关代码(
PIC
):程序可以在内存中的任意位置运行,不需要修改代码中的绝对地址。 - 节省空间:相比使用 64 位绝对地址,RIP 相对寻址只需要一个 32 位偏移量。
- 更安全:支持地址随机化(ASLR),提高程序的安全性。
在 x86-64 架构中,传统的绝对地址寻址方式不再适用于位置无关代码。于是引入了 RIP(指令指针)相对寻址:
假设你有一个全局变量 int x = 42;,在汇编中访问它可能会变成:
1 | asm |
这里的 offset_to_x 是编译器计算出来的 x 相对于当前指令的偏移量。
寻址方式 | 描述 | 是否位置无关 |
---|---|---|
绝对地址寻址 | 使用固定地址,如 [0x400123] | ❌ 否 |
寄存器间接寻址 | 如 [rax],地址由寄存器决定 | ✅ 是 |
RIP 相对寻址 | 如 [rip + offset],相对当前指令位置 | ✅ 是 |
但并不是所有 PIC 都用 RIP 相对寻址,PIC 的实现方式取决于:
- 架构:在 x86(32 位)中没有 RIP 寄存器,PIC 通常通过 call 指令获取当前地址,再加偏移量。
- 编译器策略:有些编译器会使用全局偏移表(GOT)或过程链接表(PLT)来实现位置无关性。
- 访问目标:访问函数地址时可能通过 PLT;访问外部变量时可能通过 GOT;访问静态数据时可能用 RIP 相对寻址。
架构 | 是否使用 RIP 相对寻址 | 是否支持位置无关代码 |
---|---|---|
x86-64 | ✅ 常用,尤其访问数据段 | ✅ 强力支持(默认启用) |
x86 (32位) | ❌ 无 RIP,用其他方式实现 | ✅ 但需要特殊技巧 |
举个 gdb 调试的例子:
1 | (gdb) x/i $rip |
cmpl $0x0, 0x2d939d(%rip)
是一条比较指令(cmp
),用于将某个内存地址中的值与立即数0
进行比较。(%rip)
表示使用 RIP 相对寻址,这是 x86-64 架构中常见的一种寻址方式。- 实际比较的是地址
0x2ac084e37fb4
处的值,也就是__libc_multiple_threads
这个变量。
__libc_multiple_threads
是什么?
- 这是 GNU C 库(glibc)中的一个内部变量,用来标记当前进程是否启用了多线程。
- 如果这个值是 0,说明当前进程是单线程。
- 如果是非零,说明进程中有多个线程。
所以这条指令的作用是:判断当前进程是否是多线程环境,可能用于决定是否启用线程安全的行为。
为什么使用 RIP 相对寻址?
- RIP 是唯一始终已知的寄存器
- 在执行指令时,CPU总是知道当前指令的地址(即 RIP)。
- 所以可以在编译时计算出目标数据与当前指令之间的偏移量,而不需要知道数据的绝对地址。
这就允许编译器生成位置无关代码,即使程序被加载到不同的内存地址,偏移量仍然有效。
- 其他寄存器值是动态的,不可预测
- 比如 RBX、RAX、RDI 等寄存器,它们的值在运行时可能被程序修改。
- 如果用这些寄存器做基址寻址,编译器就无法提前知道它们的值,也就无法生成稳定的偏移量。
- 支持共享库和地址空间布局随机化(ASLR)
- RIP 相对寻址让代码段不依赖固定地址,可以被多个进程共享。
- 也支持操作系统在运行时随机加载地址,提高安全性(ASLR)。
- 节省指令空间
- 使用 RIP 相对寻址只需要一个 32 位偏移量。
- 如果使用绝对地址,需要嵌入完整的 64 位地址,指令长度更长,效率更低。
为什么使用 RIP 相对寻址只需要一个 32 位偏移量
在 x86-64 架构中,RIP 相对寻址的偏移量被设计为一个有符号的 32 位整数,也就是一个 displacement(位移)字段,它在机器码中只占用 4 个字节。
RIP 是 64 位的指令指针,表示当前指令的地址。
RIP 相对寻址的目标地址是通过:
目标地址 = 下一条指令地址(RIP) + 32 位偏移量
这个偏移量是一个 有符号整数,所以它的范围是:
从 −2³¹ 到 +2³¹−1,即 ±2GB 的寻址范围。
这意味着,当前指令附近 ±2GB 范围内的任何数据都可以通过 RIP 相对寻址访问。
优点 | 说明 |
---|---|
✅ 节省空间 | 只用 4 字节表示偏移,比使用完整 64 位地址节省指令长度 |
✅ 支持位置无关代码 | 编译器只需计算偏移,不依赖绝对地址 |
✅ 高效 | CPU 执行时只需加法运算,无需查表或重定位 |
✅ 安全 | 支持地址空间布局随机化(ASLR),提高安全性 |
为什么可以被多个进程共享?
因为代码中不再硬编码具体地址,多个进程可以:
- 使用同一份物理内存中的代码段。
- 每个进程有自己的数据段,但共享同一份只读代码。
这大大节省了内存,提高了系统效率。
举个例子:
进程 | 加载地址 | 使用的代码段 |
---|---|---|
A | 0x400000 | 使用共享代码段 |
B | 0x500000 | 使用共享代码段 |
两者的代码段内容完全一样,因为里面的寻址是相对 RIP 的,不依赖于加载地址。
为什么绝对寻址不可以被多进程共享?
- 每个进程的虚拟地址空间是独立的
- 操作系统为每个进程分配独立的虚拟地址空间。
- 即使两个进程都加载了同一个程序,它们的地址空间可能完全不同。
- 如果代码中使用绝对地址,加载到不同地址空间后,这些地址就不再有效。
所以,绝对地址在一个进程中是有效的,在另一个进程中可能就指向错误的地方或根本不存在。
需要重定位,无法直接共享物理页
- 如果使用绝对地址,操作系统必须在每个进程加载时对代码进行“重定位”,修改指令中的地址。
- 一旦修改,代码段就变成了进程私有,不能共享同一份物理内存。
- 而位置无关代码(如使用 RIP 相对寻址)不需要修改,可以直接映射到多个进程的地址空间。
违反共享库的设计原则
- 动态链接库(如
.so
或.dll
)的核心优势就是可以被多个进程共享。 - 如果库中使用绝对地址,每个进程都要有自己的副本,失去了共享的意义。
- 正确做法是使用位置无关代码(PIC),让库在任意地址都能运行。
- 动态链接库(如
区域 | 是否可共享 | 原因说明 |
---|---|---|
代码段 | ✅ 是 | 只读 + 位置无关,多个进程可映射同一物理页 |
数据段 | ❌ 否 | 每个进程的数据不同,需独立副本 |
堆 | ❌ 否 | 动态分配,地址空间不同 |
栈 | ❌ 否 | 私有调用栈,不能混用 |
共享内存段 | ✅ 是 | 显式创建,专门用于共享 |
如果你想深入了解某个进程的内存布局,可以分析 /proc/[pid]/maps
或用工具如 pmap
、vmmap
。