让我们以此开始——在elf格式中,重定位记录是一些允许我们稍后填写的二进制信息——链接阶段由编译工具填充或者在运行时刻由动态连接器填写。一个二进制的重定位记录从本质上说就是“确定符号X的值,然后把这个值放入二进制文件中的偏移量为Y的地方”——每一个重定向记录都有个特定的类型,这个类型在ABI文档中定义,用来准确的描述在实际中是如何确定X的值。
下面是一个简单的例子:
$ cat a.c
extern int foo;
int function(void) {
return foo;
}
$ gcc -c a.c
$ readelf --relocs ./a.o
Relocation section '.rel.text' at offset 0x2dc contains 1 entries:
Offset Info Type Sym.Value Sym. Name
00000004 00000801 R_386_32 00000000 foo
在编译生成a.o文件的时候,编译器并不知道符号foo的值,所以预留一个重定位记录(类型为R_386_32),表示“在最终的二进制文件中,把这个目标文件中符号foo的地址填入偏移量为4的地方(相对于text 区而言)”。如果你观察下a.o的汇编结果,你就会发现在text区偏移量为4的地方,有4个字节为0,这四个字节最终将会填入真实的地址。
$ objdump --disassemble ./a.o
./a.o: file format elf32-i386
Disassembly of section .text:
00000000 <function>:
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
3: a1 00 00 00 00 mov 0x0,%eax
8: 5d pop %ebp
9: c3 ret
在链接的时候,如果你编译的另外一个目标文件含有foo的地址,并且把这个目标文件与a.o一起编译为一个最终的可执行文件,那么重定位记录就会消失。但是仍然有很多的东西直到运行的时候才能确定,当编译一个可执行文件或者动态库的时候。正如我马上要解释的,PIC,与地址无关的代码是一个很重要的原因。当你观察一个可执行文件,你会注意到它有一个固定的加载地址:
$ readelf --headers /bin/ls
[...]
ELF Header:
[...]
Entry point address: 0x8049bb0
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
[...]
LOAD 0x000000 0x08048000 0x08048000 0x16f88 0x16f88 R E 0x1000
LOAD 0x016f88 0x0805ff88 0x0805ff88 0x01543 0x01543 RW 0x1000
这并不是地址无关。代码段(权限为RE,可读可执行)必须被加载到虚拟地址
0x08048000,数据段(RW)必须被加载到
0x0805ff88。
这对于可执行文件来说很不错,因为每一次你创建一个新的进程(fork,然后exec),都会有一个全新的地址空间。考虑到时间的消耗提前计算好地址并把它们固定到最终的输出文件中,这种方式是值得考虑的。(当然也可以采取 与地址无关的可执行文件 的方式来实现,但这是另外的一个话题了)
这对于共享库来说就不是那么好了。关键点是,你可以为了达到你的目标而对共享库随意的组合。如果你的共享库必须要在固定的地址上运行,32位的系统的地址空间很快就不够用了。因此当你查看一个共享库,它们并不指定一个固定的加载地址:
$ readelf --headers /lib/libc.so.6
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
[...]
LOAD 0x000000 0x00000000 0x00000000 0x236ac 0x236ac R E 0x1000
LOAD 0x023edc 0x00024edc 0x00024edc 0x0015c 0x001a4 RW 0x1000
共享库还有第二个目的,代码分享。如果有一百个进程使用一个共享库,就没有必要在内存中产生100分代码拷贝。如果代码是完全只读,并且永远不会修改,那么每一个进程就可以分享相同的代码。然而,对于共享库有一个约束:对于每一个进程都必须有一份自己的数据实例。然而,在运行时刻,将库数据放到任何我们想要的地址上也是可行的,这就要我们预留重定义记录为代码段打上“补丁”,告知代码段到哪里找到实际的数据——这种方法实际上是不行的,因为破环了动态库的只读属性和共享性。就如同你从头文件信息中看到的一样,解决方案为:可读可写的数据段相对于代码段有一个固定的偏移量。通过这种方式,利用虚拟内存的魔力,每个进程都有属于自己的数据段,而共享不可修改的代码段。所以访问数据段的算法是很简单的:我想访问的数据的地址 = 当前地址+ 固定偏移。
但是,当前的地址有可能不是那么简单的知道:
$ cat test.c
static int foo = 100;
int function(void) {
return foo;
}
$ gcc -fPIC -shared -o libtest.so test.c
foo位于数据段,与函数function中的指令有一个固定的偏移量。我们要做的就是找到它。在amd64上,这很简单:
000000000000056c <function>:
56c: 55 push %rbp
56d: 48 89 e5 mov %rsp,%rbp
570: 8b 05 b2 02 20 00 mov 0x2002b2(%rip),%eax # 200828 <foo>
576: 5d pop %rbp
上面的代码的意思是说“把与当前指令地址偏移0x2002b2处的值放入eax”。另一方面,i386并没有提供访问当前指令偏移的能力。所以有一些限制:
0000040c <function>:
40c: 55 push %ebp
40d: 89 e5 mov %esp,%ebp
40f: e8 0e 00 00 00 call 422 <__i686.get_pc_thunk.cx>
414: 81 c1 5c 11 00 00 add $0x115c,%ecx
41a: 8b 81 18 00 00 00 mov 0x18(%ecx),%eax
420: 5d pop %ebp
421: c3 ret
00000422 <__i686.get_pc_thunk.cx>:
422: 8b 0c 24 mov (%esp),%ecx
425: c3 ret
这里的魔数是
__i686.get_pc_thunk.cx。i386不允许我们得到当前指令的地址,但是我们可以得到一个已知的固定地址——
__i686.get_pc_thunk.cx的值,cx中的值是call的返回地址,这里是0x414.我们做一个简单的算术:0x115c+0x414 = 0x1570.最终的数据和0x1588偏移了0x18个字节,查看汇编代码:
00001588 <global>:
1588: 64 00 00 add %al,%fs:(%eax)
正是100所处的地址。
现在我们越来越接近了,但是还是有很多的问题要处理。如果一个共享库可以被加载到任意的地址,那么,一个可执行文件或者其他的共享库,如何知道怎么访问它的数据或者调用它的函数呢?从理论上,我们是可以的,加载库,然后把数据的地址或者函数的地址填入到库相应的地方。然后这正如之前所讲的,违反了代码共享性。就如同我们所了解的,所有的问题都可以通过增加一个中间层来解决,在这种情形下,称之为全局偏移表或者got。
考虑下面的库:
$ cat test.c
extern int foo;
int function(void) {
return foo;
}
$ gcc -shared -fPIC -o libtest.so test.c
这和之前的文件很像,但是foo是extern的。假设是由其他的库提供。让我们看一下在amd64上它是如何工作的:
$ objdump --disassemble libtest.so
[...]
00000000000005ac <function>:
5ac: 55 push %rbp
5ad: 48 89 e5 mov %rsp,%rbp
5b0: 48 8b 05 71 02 20 00 mov 0x200271(%rip),%rax # 200828 <_DYNAMIC+0x1a0>
5b7: 8b 00 mov (%rax),%eax
5b9: 5d pop %rbp
5ba: c3 retq
$ readelf --sections libtest.so
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[...]
[20] .got PROGBITS 0000000000200818 00000818
0000000000000020 0000000000000008 WA 0 0 8
$ readelf --relocs libtest.so
Relocation section '.rela.dyn' at offset 0x418 contains 5 entries:
Offset Info Type Sym. Value Sym. Name + Addend
[...]
000000200828 000400000006 R_X86_64_GLOB_DAT 0000000000000000 foo + 0
反汇编的结果显示返回值位于当前指令偏移0x200271处:0x0200828。查看section header,这个地址位于.got区。接着我们查看重定位记录,可以发现有一个类型为R_X86_64_GLOB_DAT的重定位的意思是“找到foo的值,然后把它放在地址0x200828处”。
所以,当这个动态库被加载,动态加载器将会检查重定位记录,找到foo的值,并按照要求为.got中的条目打上“补丁”。当动态库中的代码运行并访问foo的时候,访内指针将会指向正确的地址,一切都会正常工作,而不用去修改指令的值,以避免代码的共享性。
以上是数据的处理,那么函数调用呢?函数调用的中间层称之为procedure linkage table 或者PLT.代码不会直接调用外部的函数,而是通过一个plt stub。
$ cat test.c
int foo(void);
int function(void) {
return foo();
}
$ gcc -shared -fPIC -o libtest.so test.c
$ objdump --disassemble libtest.so
[...]
00000000000005bc <function>:
5bc: 55 push %rbp
5bd: 48 89 e5 mov %rsp,%rbp
5c0: e8 0b ff ff ff callq 4d0 <foo@plt>
5c5: 5d pop %rbp
$ objdump --disassemble-all libtest.so
00000000000004d0 <foo@plt>:
4d0: ff 25 82 03 20 00 jmpq *0x200382(%rip) # 200858 <_GLOBAL_OFFSET_TABLE_+0x18>
4d6: 68 00 00 00 00 pushq $0x0
4db: e9 e0 ff ff ff jmpq 4c0 <_init+0x18>
$ readelf --relocs libtest.so
Relocation section '.rela.plt' at offset 0x478 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000200858 000400000007 R_X86_64_JUMP_SLO 0000000000000000 foo + 0
现在,我们function跳转到0x4d0.反汇编,我们看到这是一个有趣的调用,我们跳转到当前rip指针偏移
0x200382,也就是
0x200858处。可以发现,这个地址存放着符号foo的重定位的记录。
I让我们来看一下0x200858的初始值:
$ objdump --disassemble-all libtest.so
Disassembly of section .got.plt:
0000000000200840 <.got.plt>:
200840: 98 cwtl
200841: 06 (bad)
200842: 20 00 and %al,(%rax)
...
200858: d6 (bad)
200859: 04 00 add $0x0,%al
20085b: 00 00 add %al,(%rax)
20085d: 00 00 add %al,(%rax)
20085f: 00 e6 add %ah,%dh
200861: 04 00 add $0x0,%al
200863: 00 00 add %al,(%rax)
200865: 00 00 add %al,(%rax)
...
0x200858的初始值是
0x4d6,居然是下一条指令的地址!这条指令把0要入栈中,然后跳转到0x4c0.通过查看代码我们可以发现,把GOT一个值压入栈中,然后跳到GOT中的第二个值。
00000000000004c0 <foo@plt-0x10>:
4c0: ff 35 82 03 20 00 pushq 0x200382(%rip) # 200848 <_GLOBAL_OFFSET_TABLE_+0x8>
4c6: ff 25 84 03 20 00 jmpq *0x200384(%rip) # 200850 <_GLOBAL_OFFSET_TABLE_+0x10>
4cc: 0f 1f 40 00 nopl 0x0(%rax)
这里究竟是在做什么呢?这就是 lazy binding(延迟绑定)——按照约定,动态连接器加载一个动态库,首先应该在got中的已知地址存放能够解析符号的默认函数。因此,上面的处理流程大体是这样子的:当第一次调用一个函数的时候,因为此时got中还没有它的地址,所以调用失败,从而进入默认的stub处理流程,这个stub用来解决符号解析。当找到foo的地址之后,就会把这个值填入到got,这样下次调用的时候,就直接调用到foo的实际地址。
https://www.technovelty.org/linux/plt-and-got-the-key-to-code-sharing-and-dynamic-libraries.html