共享库“位置无关”(PIC)实现原理

PIC 即 position independent code(位置无关代码),对应编译选项 -fpic:生成适合放入共享库的位置无关代码。

共享库有一个重要特征——可以被多个可执行文件共享,以达到节省磁盘和内存空间的目标。这意味着:

  • 磁盘上只有一份拷贝,加载到内存后也只有一份拷贝,因此代码部分在运行时不能被修改,否则就需要多份拷贝存在;
  • 需要能够灵活地映射到不同的虚拟地址空间,以适应不同的程序、避免地址冲突。

这两点要求共享库的代码和数据都是位置无关的。接下来先看什么是”位置无关”。

什么是位置无关

同样以 hello.c 为例:

#include <stdio.h>

int main(void)
{
    printf("hello\n");
    return 0;
}

以普通方式编译并反汇编一个可执行文件看看:

$ gcc -m32 -o hello hello.c
$ objdump -d hello | grep -B1 "call.*puts@plt>"
8048416: 68 b0 84 04 08 push $0x80484b0
804841b: e8 c0 fe ff ff call 80482e0 <puts@plt>

可以看到,传递给 puts(printf)的字符串地址是”写死”的,在编译时就已经确定。这意味着它的 Load Address 也必须是固定的:

$ readelf -l hello | grep LOAD | head -1
LOAD 0x000000 0x08048000 0x08048000 0x005b0 0x005b0 R E 0x1000

Load Address 为 0x8048000。如果 Load Address 变了,数据地址就会指向别的内容,这就是”位置有关”。共享库必须摒弃这种”写死的”地址,做到”位置无关”(注:prelink 是特殊需求,暂且不表)。

位置无关的实现(上):运行时计算地址

位置无关,意味着运行时可以灵活调整 Load Address,即使 Load Address 改变了,代码仍能被执行到、数据仍能被正确访问。因此代码和数据都不能再用绝对地址,而要采用某个相对 Load Address 的地址

动态链接器负责找到并装载共享库,所以它是知道 Load Address 的,函数符号也因此很容易确定。先看不带 -fpic 编译生成的共享库:

$ gcc -m32 -shared -o libhello.so hello.c
$ objdump -d libhello.so | grep -A2 "main>:"
000004a9 <main>:
  4a9: 8d 4c 24 04 lea 0x4(%esp),%ecx
  4ad: 83 e4 f0 and $0xfffffff0,%esp

查看装载地址——编译后初始化为 0:

$ readelf -l libhello.so | grep LOAD | head -1
LOAD 0x000000 0x00000000 0x00000000 0x0057c 0x0057c R E 0x1000
$ readelf --dyn-syms libhello.so | grep m
Symbol table '.dynsym' contains 12 entries:
Num: Value Size Type Bind Vis Ndx Name
4: 00000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
9: 000004a9 46 FUNC GLOBAL DEFAULT 11 main
$ hexdump -C -s $((0x4a9)) -n 10 libhello.so
000004a9  8d 4c 24 04 83 e4 f0 ff  71 fc          |.L$.....q.|
000004b3

可以看到,对 main 而言,无论把共享库装载到哪里,动态链接器总能根据 Load Address 以及 .dynsym 中的偏移算出 main 的运行时地址(见 glibc 的 _dl_fixup)。但此时(不用 -fpic 的话)数据地址仍然是”写死”的

$ objdump -d libhello.so | grep -B1 "call.*main"
4bd: 68 ec 04 00 00 push $0x4ec
4c2: e8 fc ff ff ff call 4c3

作为对比,看看加上 -fpic 的效果:

$ gcc -m32 -shared -fpic -o libhello.so hello.c
$ objdump -dr libhello.so | grep -B6 "call.*puts@plt>"
4c8: e8 28 00 00 00 call 4f5 <__x86.get_pc_thunk.ax>
4cd: 05 33 1b 00 00 add $0x1b33,%eax
4d2: 83 ec 0c sub $0xc,%esp
4d5: 8d 90 10 e5 ff ff lea -0x1af0(%eax),%edx
4db: 52 push %edx
4dc: 89 c3 mov %eax,%ebx
4de: e8 bd fe ff ff call 3a0 <puts@plt>

可以看到,用上 -fpic 以后,传递给 puts 的数据地址(push %edx)已经是动态计算出来的了。那是怎么算的呢?上面有个内联进来的函数很关键:

$ objdump -dr libhello.so | grep -A3 "__x86.get_pc_thunk.ax>:"
000004f5 <__x86.get_pc_thunk.ax>:
4f5: 8b 04 24 mov (%esp),%eax
4f8: c3 ret

这个函数非常简单——从栈顶取了一个数据就跳回去了。取的是什么数据呢?这就要了解调用它的 call 指令:call 会把下一条指令的 EIP 压栈,然后跳转到目标地址:

call backward ==> push eip;
jmp backward

所以数据地址是运行时计算的,跟运行时的 EIP 关联上了。不难猜测:如果知道当前指令的位置,又提前保存了数据离当前位置的偏移,那么数据地址就可以直接算出。只是上面那段代码略显复杂,因为有一堆 “magic number”。

不管怎样,先模拟计算一下。假设装载到的地址就是 0x0,那么执行到 add 指令时存入 %eax 的 EIP,恰好是 call 返回后下一条指令的地址,即 0x4cd

4c8: e8 28 00 00 00 call 4f5 <__x86.get_pc_thunk.ax>
4cd: 05 33 1b 00 00 add $0x1b33,%eax
4d5: 8d 90 10 e5 ff ff lea -0x1af0(%eax),%edx

根据上述指令,%edx 算出来就是 0x510

$ echo "obase=16;$((0x4cd+0x1b33-0x1af0))" | bc
510

再去取这个地址的数据:

$ hexdump -C -s $((0x510)) -n 10 libhello.so
00000510  68 65 6c 6c 6f 00 00 00  01 1b  |hello.....|
0000051a

果然是字符串的地址。所以相对偏移其实被拆分成了两部分——0x1b33-0x1af0,两个 “magic number” 一加就出来了。

小结一下:”位置无关”是通过运行时动态获取 EIP,并加上一个编译时记录好的偏移计算出来的。这样无论加载到什么位置,都能访问到数据。

位置无关的实现(下):GOT 与 GOTOFF

这两个 “magic number” 既然是编译时确定的,那就看看汇编层面是怎么回事:

$ gcc -m32 -shared -fpic -S hello.c
$ cat hello.s | grep -v .cfi
...
.LC0:
    .string "hello"
    .text
    .globl main
    .type main,@function
main:
.LFB0:
    leal 4(%esp),%ecx
    andl $-16,%esp
    pushl -4(%ecx)
    pushl %ebp
    movl %esp,%ebp
    pushl %ebx
    pushl %ecx
    call __x86.get_pc_thunk.ax
    addl $_GLOBAL_OFFSET_TABLE_,%eax
    subl $12,%esp
    leal .LC0@GOTOFF(%eax),%edx
    pushl %edx
    movl %eax,%ebx
    call puts@PLT
    ...

从 i386 的 archABI(P61~P62)不难找到这块的定义:name@GOTOFF(%eax) 直接表示 name 符号相对 %eax 保存的 GOT 的偏移地址。

编译时先要计算 $_GLOBAL_OFFSET_TABLE_.LC0@GOTOFF$_GLOBAL_OFFSET_TABLE_ 是 GOT 相对 EIP 的偏移,可计算为:

$_GLOBAL_OFFSET_TABLE_ = .got.plt - eip

计算过程如下:

$ readelf -S libhello.so | grep .got.plt
[21] .got.plt PROGBITS 00002000 001000 000010 04 WA 0 0 4
$ echo "obase=16;$((0x2000-0x4cd))" | bc
1B33

接着计算 .LC0@GOTOFF

.LC0 - eip = _GLOBAL_OFFSET_TABLE_ + .LC0@GOTOFF
.LC0@GOTOFF = .LC0 - eip - _GLOBAL_OFFSET_TABLE_

计算过程如下:

$ echo "obase=16;$((0x510-0x4cd-0x1B33))" | bc
-1AF0

反过来,运行时的计算公式为:

.LC0 = _GLOBAL_OFFSET_TABLE_ + .LC0@GOTOFF + eip
.LC0 = 0x1B33 + (-1AF0) + eip

.got.plt = _GLOBAL_OFFSET_TABLE_ + eip
.got.plt = 0x1B33 + eip

实际上,只有 .got.plt 的地址(即 %ebx)需要 $_GLOBAL_OFFSET_TABLE_ 来计算,这是用来做动态地址重定位的,暂且不表。而 .LC0 的地址完全可以换一种方式——直接用 .LC0 到 EIP 的偏移即可。改造后的汇编代码如下:

call __x86.get_pc_thunk.ax
.eip:
#计算 eip+(.LC0-.eip) 刚好指向内存中的数据 "hello" 所在位置
movl %eax,%ebx
leal (.LC0-.eip)(%eax),%edx

#计算 .got.plt 地址,_GLOBAL_OFFSET_TABLE_ 是相对 eip 的偏移,所以必须加上这个 offset:. - .eip
addl $_GLOBAL_OFFSET_TABLE_+(.-.eip),%ebx
subl $12,%esp
pushl %edx
call puts@PLT

验证结果:

$ gcc -m32 -g -shared -fpic -o libhello.so hello.s
$ gcc -m32 -g -o hello.noc -L./ -lhello
$ LD_LIBRARY_PATH=$LD_LIBRARY_PATH:./ ./hello.noc
hello

小结

本文介绍了 Linux 下 C 语言共享库”位置无关”(PIC)的核心实现原理:用 EIP 相对地址取代绝对地址

“位置无关”代码带来了很大的内存使用灵活性,也带来一定的安全性——因为”位置无关”意味着加载地址可以随机化,给代码注入增加了难度。正因为有这些好处,各大平台的 gcc 都开始默认打开可执行文件的 -pie -fpie(因为 gcc 编译时开启了 --enable-default-pie)。这也可能导致一些”性能衰退”,有需要时可以用 -no-pie-fno-pie 关闭。

当然,共享库的实现精髓不止于此,最核心的还是函数符号地址的动态解析过程,而它们与上面的 .got.plt 地址密切相关。受限于篇幅,这里暂不展开。


   转载规则


《共享库“位置无关”(PIC)实现原理》 吴杭沉 采用 知识共享署名 4.0 国际许可协议 进行许可。
 上一篇
C++ 双缓冲:读写分离 C++ 双缓冲:读写分离
1. 引言:为什么我们需要双缓冲?在现代软件开发中,尤其是在高并发、实时性要求极高的系统中,数据的读写分离是一个永恒的话题。最近在处理一个实时数据系统时,我遇到了数据库(MySQL)在高并发读写下的性能瓶颈。传统的加锁机制虽然能保证数据一致
2020-07-21
下一篇 
C++ 无锁编程原理 C++ 无锁编程原理
无锁编程(Lock-Free Programming)是现代并发编程中的一种重要技术,它避免了传统的锁机制,如互斥锁(mutex),并通过原子操作确保线程安全。无锁编程能够在多线程环境中显著提高程序的并发性和可伸缩性,减少线程之间的竞争、阻
2020-07-09
  目录