编译与链接 - 位置无关代码
背景
动态链接库在程序启动后由链接器载入,动态链接库每次被载入后,其各个段在内存中的位置是不确定的。而在运行时,程序指令需要具体的虚拟地址方能正常运行,但动态库中指令与数据的地址均是不确定的。为了解决这个问题,一种直观的思路是动态库载入后,对其进行重定位,就像静态链接中的重定位一样。程序启动后,载入动态库,而后修改动态库的 .text 部分,将指令中访问的地址修改为真实的地址,另外对当前进程的 .text 中引用动态库中变量与函数的地址做修改。但这种思路会引发几个问题:
- 启动时做大量的重定位工作,这会导致程序启动速度变慢。
- 这种重定位策略会导致动态库本身的
.text被修改,这就无法实现动态库的代码共享。 - 当前进程的代码段一般会被操作系统设置为只读,动态修改其中的内容难以实现,且不安全。
为此,需要一种不需要对 .text 部分做修改的策略,而这种方法就是本文要描述的位置无关代码。
位置无关代码(Position Independent Code, PIC) 是一种可以让动态库中的代码不依赖于动态库加载地址的策略,采用这种策略后,无论动态库被加载到内存中的什么位置,动态库的 .text 部分均不需要做任何改动即可正常运行。
在计算机网络中,一个网站的 IP 地址可能常常发生改变,为此人们引入了一个中间层,即域名。用户只需要记住网站的域名,而后通过域名间接地从 DNS 中获取到 IP 地址。看了后文你就会发现,位置无关代码中也采用了类似的思想,通过引入一个中间层解决了动态库加载地址不确定的问题。
思想与原理
我不打算一开始就深入到细枝末节中,这里先从宏观上了解大致位置无关代码的原理。
位置无关代码引入了一个前提,即动态库的 .data 和 .text 这两个节之间的相对位置是固定的,无论动态库被加载到内存中的什么地方,.text 中的指令均可以通过相对寻址来定位到 .data 中的内容。
动态库加载后需要做重定位,而重定位就是要让动态库中的指令能得到符号的实际地址,而这些地址在构建动态库的时候自然是不知道的。但利用 .data 和 .text 两个节之间的相对位置固定这一前提,则可以在 .data 中创建一个表格,表格用于记录各个符号的地址。动态库加载后,运行时链接器可以修改 .data 中的这个表格,将符号的真实地址填入其中。因为 .text 和 .data 之间相对位置固定,.text 中的指令可使用相对寻址得到对应表格单元的地址,并读取其中符号的实际地址,再通过此地址访问符号本身。
下图更具体地说明了上述思路:

在 main 程序内部,维护了一个表格,这个表格中存放的是 info、warn、error、printf 这几个函数的地址。同样地在 liblog.so 也有一个表格,用于存放 printf 的地址。
程序启动后,首先载入动态库 liblog.so,因为 liblog.so 依赖于 libc.so,于是再载入 libc.so。载入 libc.so 后,就能得到 printf 函数的地址,链接器将其地址填入到 liblog.so 的表格中。而后将 info、warn、error、printf 这四个函数的地址填入到 main 的表格中。
简单来说,通过引入一个中间层,使用类似 C 语言中指针的指针的思路,运行时在 .data 中簿记函数或变量地址,并利用 .text 和 .data 之间相对位置固定这一前提,在 .text 中使用相对寻址实现 .text 中的指令与自身位置无关,并避免了在链接阶段修改 .text 中的指令内容。
位置无关代码的思想大致如此,但在实现上略有一些差异,请继续看后文介绍。
位置无关代码
在链接生成动态库的时候链接器会构造一个叫做全局偏移量表(Global Offset Table, GOT) 的结构,全局偏移量表用于存储需要被重定位的变量的地址。
GOT 存储在 .got 节中,.got 节和 .text 节之间的偏移量在链接生成动态库的时候就确定了,动态库载入内存后,这个偏移量也不会改变。当动态库被载入到内存中后,代码段和 GOT 的相对偏移量不变,链接器将符号的实际的地址填入到全局偏移量表中。对于未对外暴露的变量和函数,可以通过相对寻址来访问。对于外部变量和函数,可以通过 GOT 表来间接访问。
全局偏移量表(Global Offset Table, GOT)
全局偏移量表中的每一项存储一个符号的地址,动态库加载后,会处理动态库的重定位信息,此时就会给 GOT 中填充上真实的地址。
GOT 存储在 .got 节,运行时这个节对应的 segemnt 是读写的,程序载入后,动态链接器会在 GOT 表中填充入符号的实际地址。下面是一个使用 GOT 访问数据的例子:
mov 0x2e78(%rip),%rax # 使用指令寄存器加偏移量得到 GOT 表中条目的地址,存入 rax 寄存器
mov (%rax),%eax # 通过 GOT 条目中存储的地址,访问实际数据,将其存入 eax 寄存器
数据访问过程
下面结合例子来分析位置无关代码对外部变量的访问过程:
// print.c
#include <stdio.h>
extern int a;
void print() {
printf("a = %d\n", a);
}
构建动态库:
$ gcc print.c -fPIC -shared -o libprint.so
查看动态库中 print 的指令:
$ objdump -d libprint.so
0000000000001165 <print>:
1165: 55 push %rbp
1166: 48 89 e5 mov %rsp,%rbp
1169: 48 8b 05 78 2e 00 00 mov 0x2e78(%rip),%rax # 3fe8 <a>
1170: 8b 00 mov (%rax),%eax
1172: 89 c6 mov %eax,%esi
1174: 48 8d 3d 85 0e 00 00 lea 0xe85(%rip),%rdi # 2000 <_fini+0xe2c>
117b: b8 00 00 00 00 mov $0x0,%eax
1180: e8 bb fe ff ff callq 1040 <printf@plt>
1185: 90 nop
1186: 5d pop %rbp
1187: c3 retq
1188: 0f 1f 84 00 00 00 00 nopl 0x0(%rax,%rax,1)
118f: 00
上面地址为 1169 的这行指令通过指令寄存器 (rip) 加一个偏移量 0x2e78 得到变量 a 在 GOT 表中对应条目的地址,并将其存储在寄存器 rax 中。下一行指令则取 rax 所指地址中的值。
查看 .got 的内容你会发现,a 对应的应该是 .got 中的第 5 个条目,目前 .got 中的内容都是 0,当动态库载入后,其中的内容才会被改写。
$ objdump -s libprint.so
...
Contents of section .got:
3fd8 00000000 00000000 00000000 00000000 ................
3fe8 00000000 00000000 00000000 00000000 ................
3ff8 00000000 00000000
查看重定位表,会看到有对该位置重定位的记录:
$ readelf -r libswap.so
Relocation section '.rela.dyn' at offset 0x420 contains 6 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000004020 000000000008 R_X86_64_RELATIVE 4020
000000003fd8 000100000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0
000000003fe0 000300000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
000000003fe8 000400000006 R_X86_64_GLOB_DAT 0000000000000000 a + 0
000000003ff0 000500000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTa + 0
000000003ff8 000600000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize@GLIBC_2.2.5 + 0
函数调用过程
使用 GOT 可以实现位置无关代码中数据与函数的访问,但在实际实现中,GOT 仅用于实现数据的访问,对函数的访问采用了更复杂(但原理大致相同)的方法,后文接着介绍。
观察汇编指令中对函数的调用:
$ objdump -d libprint.so
0000000000001165 <print>:
1165: 55 push %rbp
1166: 48 89 e5 mov %rsp,%rbp
1169: 48 8b 05 78 2e 00 00 mov 0x2e78(%rip),%rax # 3fe8 <a>
1170: 8b 00 mov (%rax),%eax
1172: 89 c6 mov %eax,%esi
1174: 48 8d 3d 85 0e 00 00 lea 0xe85(%rip),%rdi # 2000 <_fini+0xe2c>
117b: b8 00 00 00 00 mov $0x0,%eax
1180: e8 bb fe ff ff callq 1040 <printf@plt>
1185: 90 nop
1186: 5d pop %rbp
1187: c3 retq
1188: 0f 1f 84 00 00 00 00 nopl 0x0(%rax,%rax,1)
118f: 00
观察地址为 1180 的这行指针,这条指令前并没有从 GOT 表拿 printf 函数地址的操作,call 指令是直接调用了一个叫做 printf@plt 的函数。位置无关代码中,对外部函数的调用实际上是先调用一个包装函数,在这里函数内部会去动态查询实际函数的地址,然后执行调用。在我们深入实现细节之前,先来看看为什么要这样设计。
延迟绑定
动态链接中,动态库中的函数只有在动态库加载完成后才能确定。当一个动态库加载后,需要对其执行重定位,这就涉及到函数地址的查找。查找函数地址的过程就要叫做绑定,通俗来说就是将函数的实际地址与符号绑定起来。函数地址的查找,需要使用函数名称去当前程序以及当前程序加载的各个动态库中查找,这个查找过程需要时间。
默认情况下,在动态库加载后,动态链接器不会立刻对 GOT 中的内容做改写,这是因为一个动态库可能会使用到很多函数,而查找这些函数的位置需要查找当前程序的符号表,如果没有找到还要查找其他已经加载的动态库的符号表,这是比较耗时的操作。而且有很多函数也不一定会被调用,因此对函数的地址的查找会被延迟到函数被调用时。
基于以上考虑,函数的绑定并未在动态库载入后就立刻执行,而是被推迟到函数运行时,这就是延迟绑定。
为了支持延迟绑定,一个新的间接层被引入,它就是过程链接表(Procedure Linkage Table, PLT)。
过程链接表(Procedure Linkage Table, PLT)
对于对外可见的函数(未使用 static 定义的函数)或者外部定义的函数(使用 extern 修饰),动态库的会为其生成包装函数,这些包装函数内部会去调用正在需要被调用的函数。真正需要被调用的函数的地址存放在 GOT 表中,包装函数内部会从 GOT 中获取真正要被调用的函数的地址,并完成调用。这些包装函数被称为 Procedure Linkage Table,PLT。
PLT 存储在 .plt 节中,里面包含一组指令入口,每一个对应一个外部函数,PLT 中每一项都是一段指令,下面是一个例子:
Disassembly of section .plt:
0000000000001030 <.plt>:
1030: ff 35 d2 2f 00 00 pushq 0x2fd2(%rip) # 4008 <_GLOBAL_OFFSET_TABLE_+0x8>
1036: ff 25 d4 2f 00 00 jmpq *0x2fd4(%rip) # 4010 <_GLOBAL_OFFSET_TABLE_+0x10>
103c: 0f 1f 40 00 nopl 0x0(%rax)
0000000000001040 <printf@plt>:
1040: ff 25 d2 2f 00 00 jmpq *0x2fd2(%rip) # 4018 <printf@GLIBC_2.2.5>
1046: 68 00 00 00 00 pushq $0x0
104b: e9 e0 ff ff ff jmpq 1030 <.plt>
代码中对 printf 函数的调用,被替换为对函数 printf@plt 调用,printf@plt 会去查找 printf 的地址,并执行对 printf 的调用。
先来看 printf@plt 中的指令:
第一条指令是一个跳转指令,它会跳转到 GOT 表中 printf 对应的条目中指定的地址处。默认情况下,GOT 表中这个位置存放的是 printf@plt 中第二条指令的地址。因此,printf@plt 中第一条跳转指令实际上只是跳到了下一行指令。
第二条指令是一个 push 指令,它实际上是把 printf 在重定位表中的下标压入了栈中。
$ readelf -r libprint.so
Relocation section '.rela.dyn' at offset 0x420 contains 6 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000004020 000000000008 R_X86_64_RELATIVE 4020
000000003fd8 000100000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0
000000003fe0 000300000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
000000003fe8 000400000006 R_X86_64_GLOB_DAT 0000000000000000 a + 0
000000003ff0 000500000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTa + 0
000000003ff8 000600000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize@GLIBC_2.2.5 + 0
Relocation section '.rela.plt' at offset 0x4b0 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
000000004018 000200000007 R_X86_64_JUMP_SLO 0000000000000000 printf@GLIBC_2.2.5 + 0
第三条指令是跳转指令,它会跳转到 .plt 中,.plt 中会基于前面压入的符号表的下标,查找该符号的地址,然后更新 GOT 表。
当 GOT 表更新后,下次再调用时,GOT 表中存储的就是 printf 的地址了,printf@plt 中的第一条指令就跳转到了 printf 处。
动态链接引入 PLT 是为了实习函数的惰性加载,当程序首次调用时会动态查询此函数的地址,并将其填入 GOT 表中。之后就可以使用 GOT 表中之前已经查询到的函数来执行调用了。
与动态链接有关的节
ELF 文件中有的 section 与动态链接有关,这里做个总结。先查看一个动态库中所有的节:
$ readelf -S libprint.so
There are 34 section headers, starting at offset 0x3b50:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .hash HASH 0000000000000200 00000200
000000000000003c 0000000000000004 A 3 0 8
[ 2] .gnu.hash GNU_HASH 0000000000000240 00000240
0000000000000030 0000000000000000 A 3 0 8
[ 3] .dynsym DYNSYM 0000000000000270 00000270
00000000000000f0 0000000000000018 A 4 1 8
[ 4] .dynstr STRTAB 0000000000000360 00000360
0000000000000086 0000000000000000 A 0 0 1
[ 5] .gnu.version VERSYM 00000000000003e6 000003e6
0000000000000014 0000000000000002 A 3 0 2
[ 6] .gnu.version_r VERNEED 0000000000000400 00000400
0000000000000020 0000000000000000 A 4 1 8
[ 7] .rela.dyn RELA 0000000000000420 00000420
0000000000000090 0000000000000018 A 3 0 8
[ 8] .rela.plt RELA 00000000000004b0 000004b0
0000000000000018 0000000000000018 AI 3 21 8
[ 9] .init PROGBITS 0000000000001000 00001000
0000000000000024 0000000000000000 AX 0 0 4
[10] .plt PROGBITS 0000000000001030 00001030
0000000000000020 0000000000000010 AX 0 0 16
[11] .plt.got PROGBITS 0000000000001050 00001050
0000000000000010 0000000000000008 AX 0 0 8
[12] .text PROGBITS 0000000000001060 00001060
0000000000000171 0000000000000000 AX 0 0 16
[13] .fini PROGBITS 00000000000011d4 000011d4
000000000000000e 0000000000000000 AX 0 0 4
[14] .rodata PROGBITS 0000000000002000 00002000
0000000000000008 0000000000000000 A 0 0 1
[15] .eh_frame_hdr PROGBITS 0000000000002008 00002008
0000000000000024 0000000000000000 A 0 0 4
[16] .eh_frame PROGBITS 0000000000002030 00002030
000000000000007c 0000000000000000 A 0 0 8
[17] .ctors PROGBITS 0000000000003e28 00002e28
0000000000000010 0000000000000000 WA 0 0 8
[18] .dtors PROGBITS 0000000000003e38 00002e38
0000000000000010 0000000000000000 WA 0 0 8
[19] .dynamic DYNAMIC 0000000000003e48 00002e48
0000000000000190 0000000000000010 WA 4 0 8
[20] .got PROGBITS 0000000000003fd8 00002fd8
0000000000000028 0000000000000008 WA 0 0 8
[21] .got.plt PROGBITS 0000000000004000 00003000
0000000000000020 0000000000000008 WA 0 0 8
[22] .data PROGBITS 0000000000004020 00003020
0000000000000008 0000000000000000 WA 0 0 8
[23] .bss NOBITS 0000000000004028 00003028
0000000000000010 0000000000000000 WA 0 0 8
[24] .comment PROGBITS 0000000000000000 00003028
0000000000000011 0000000000000001 MS 0 0 1
[25] .debug_aranges PROGBITS 0000000000000000 00003040
0000000000000080 0000000000000000 0 0 16
[26] .debug_info PROGBITS 0000000000000000 000030c0
0000000000000044 0000000000000000 0 0 1
[27] .debug_abbrev PROGBITS 0000000000000000 00003104
0000000000000024 0000000000000000 0 0 1
[28] .debug_line PROGBITS 0000000000000000 00003128
00000000000000c5 0000000000000000 0 0 1
[29] .debug_str PROGBITS 0000000000000000 000031ed
0000000000000066 0000000000000001 MS 0 0 1
[30] .debug_ranges PROGBITS 0000000000000000 00003260
0000000000000080 0000000000000000 0 0 16
[31] .symtab SYMTAB 0000000000000000 000032e0
00000000000005b8 0000000000000018 32 52 8
[32] .strtab STRTAB 0000000000000000 00003898
0000000000000196 0000000000000000 0 0 1
[33] .shstrtab STRTAB 0000000000000000 00003a2e
0000000000000122 0000000000000000 0 0 1
.dynsym
这个是需要动态链接的符号的符号表。.symtab 也是符号表,但是 .symtab 仅在调试的时候有用,将 .symtab 中的内容剔除也没有关系。而 .dynsym 在动态链接时意义重大,不可或缺。比如它在重定位的时候会被重定位条目引用,在符号查找的时候需要使用符号的名称。
.dynstr
与前面的符号表管理的字符串表。
rela.dyn
重定位表,记录需要重定位的变量。
.rela.plt
重定位表,记录需要重定位的函数。
.plt
Procedure Linkage Table
.plt.got
.plt 中包含的是需要惰性加载的函数的包装函数。而 .plt.got 包含的动态库载入后立刻会被重定位的函数,这里面的指令如下:
Disassembly of section .plt.got:
0000000000001050 <__gmon_start__@plt>:
1050: ff 25 8a 2f 00 00 jmpq *0x2f8a(%rip) # 3fe0 <__gmon_start__>
1056: 66 90 xchg %ax,%ax
0000000000001058 <__cxa_finalize@plt>:
1058: ff 25 9a 2f 00 00 jmpq *0x2f9a(%rip) # 3ff8 <__cxa_finalize@GLIBC_2.2.5>
105e: 66 90 xchg %ax,%ax
.dynamic
记录的是依赖的动态库信息。
.got
保存变量实际地址的全局偏移量表(GOT)。
.got.plt
保存函数实际地址的全局偏移量表(GOT)。
一些问题
-fPIC 编译选项有什么用?
-fPIC 指示 GCC 在生成目标文件时是否产生位置无关代码,本质就是对引用的其他目标文件中的函数的数据是否产生 GOT 和 PLT。如果采用静态链接,位置重定位会在链接阶段完成,因此这 GOT 和 PLT 是无用的,但若采用动态链接则 -fPIC 选项必不可少。
因此,如果目标文件是用于构造动态链接库的,那么在编译的时候需要加上 -fPIC 选项。