0%

位置无关代码

位置无关代码(PIC)

  • 位置无关代码(PIC):程序可以在内存中的任意位置运行,不需要修改代码中的绝对地址。
  • 节省空间:相比使用 64 位绝对地址,RIP 相对寻址只需要一个 32 位偏移量。
  • 更安全:支持地址随机化(ASLR),提高程序的安全性。

在 x86-64 架构中,传统的绝对地址寻址方式不再适用于位置无关代码。于是引入了 RIP(指令指针)相对寻址:

假设你有一个全局变量 int x = 42;,在汇编中访问它可能会变成:

1
2
asm
mov eax, DWORD PTR [rip + offset_to_x]

这里的 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
2
3
4
(gdb) x/i $rip
=> 0x2ac084b5ec10 <poll>: cmpl $0x0,0x2d939d(%rip) # 0x2ac084e37fb4 <__libc_multiple_threads>
(gdb) p (bool)$__libc_multiple_threads
true
  • cmpl $0x0, 0x2d939d(%rip) 是一条比较指令(cmp),用于将某个内存地址中的值与立即数 0 进行比较。
  • (%rip) 表示使用 RIP 相对寻址,这是 x86-64 架构中常见的一种寻址方式。
  • 实际比较的是地址 0x2ac084e37fb4 处的值,也就是 __libc_multiple_threads 这个变量。

__libc_multiple_threads 是什么?

  • 这是 GNU C 库(glibc)中的一个内部变量,用来标记当前进程是否启用了多线程。
  • 如果这个值是 0,说明当前进程是单线程。
  • 如果是非零,说明进程中有多个线程。

所以这条指令的作用是:判断当前进程是否是多线程环境,可能用于决定是否启用线程安全的行为。

为什么使用 RIP 相对寻址?

  1. RIP 是唯一始终已知的寄存器
  • 在执行指令时,CPU总是知道当前指令的地址(即 RIP)。
  • 所以可以在编译时计算出目标数据与当前指令之间的偏移量,而不需要知道数据的绝对地址。

这就允许编译器生成位置无关代码,即使程序被加载到不同的内存地址,偏移量仍然有效。

  1. 其他寄存器值是动态的,不可预测
  • 比如 RBX、RAX、RDI 等寄存器,它们的值在运行时可能被程序修改。
  • 如果用这些寄存器做基址寻址,编译器就无法提前知道它们的值,也就无法生成稳定的偏移量。
  1. 支持共享库和地址空间布局随机化(ASLR)
  • RIP 相对寻址让代码段不依赖固定地址,可以被多个进程共享。
  • 也支持操作系统在运行时随机加载地址,提高安全性(ASLR)。
  1. 节省指令空间
  • 使用 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 或用工具如 pmapvmmap