共享动态库是现代系统的一个重要组成部分,大家肯定都不陌生,但是通常对背后的一些细节上的实现机制了解得不够深入。当然,网上有很多关于这方面的文章。希望这篇文章能够以一种与其他文章不同的角度呈现,可以对你产生一点启发。
关于动态链接与静态链接,看到过这么与一个比方:如果我的文章引用了别人的一部分文字,在我发布文章的时候把别人的段落复制到我的文章里面就属于静态连接,而做一个超链接让你们自己去看就属于动态链接。
一、重定位
首先是重定位,先看一个例子:
extern int foo;
int function(void) {
return foo;
}
保存为 a.cpp 然后编译并查看 a.o 中的重定位表信息:
$ gcc -c a.c
$ readelf --relocs ./a.o
输出如下:
Relocation section '.rela.text' at offset 0x1c8 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
000000000006 000900000002 R_X86_64_PC32 0000000000000000 foo - 4
在创建a.o时,foo的值是未知的,因此编译器会留下一个(类型为R_X86_64_PC32的)重定位,它表示“在最终的二进制文件中,在这个目标文件的偏移0x4处,用符号foo的地址来替换值”。如果查看输出,我们能看到在偏移0x4处有4字节的零,表示亟待一个真实的地址:
$ objdump --disassemble ./a.o
./test.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <_Z8functionv>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # a <_Z8functionv+0xa>
a: 5d pop %rbp
b: c3 retq
二、动态库访问数据
对于动态库 .so 来说,它可以被任意多个进程使用,这样在每个进程中都保留一份代码副本就没有意义。如果代码是只读的话,永远不会被修改,那么每个进程都可以共享相同的代码。
但是实际情况是,动态库在每个进程中仍然必须有一个唯一的数据实例。虽然在运行时可以将库数据放在任何地方,但这将需要重定位来告诉它实际的数据在哪个地址,从而破坏代码的始终只读属性,更是破坏了代码的可共享性。
所以现在的解决方案是将读写数据部分始终放置在已知偏移处。通过虚拟内存,每个进程都能看到自己的数据部分 .data,但可以共享未修改的指令代码也就是 .text 。访问数据需要的只是一些简单的加减运算;我想要访问变量的地址=我的当前地址+已知的固定偏移。
再看下以下示例:
$ cat test.c
static int foo = 100;
int function(void) {
return foo;
}
$ gcc -fPIC -shared -o libtest.so test.c
看一下它的反汇编:
0000000000000589 <_Z8functionv>:
589: 55 push rbp
58a: 48 89 e5 mov rbp,rsp
58d: 8b 05 8d 0a 20 00 mov eax,DWORD PTR [rip+0x200a8d] # 201020 <_ZL3foo>
593: 5d pop rbp
594: c3 ret
这表示“将当前指令指针(rip)偏移 0x200a8d 的位置的值放入 eax 中”。也就是我们知道数据在那个固定的偏移量上,所以我们可以找到它。
再看下下面的代码,我们引用外部的变量 foo :
$ cat test.c
extern int foo;
int function(void) {
return foo;
}
$ gcc -shared -fPIC -o libtest.so test.c
请注意,foo是 extern 的;假定由其他库提供。
继续看下反汇编和段还有重定位信息:
$ objdump --disassemble libtest.so
[...]
00000000000005b9 <_Z8functionv>:
5b9: 55 push rbp
5ba: 48 89 e5 mov rbp,rsp
5bd: 48 8b 05 24 0a 20 00 mov rax,QWORD PTR [rip+0x200a24] # 200fe8 <foo@Base>
5c4: 8b 00 mov eax,DWORD PTR [rax]
5c6: 5d pop rbp
5c7: c3 ret
$ readelf --sections libtest.so
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[19] .got PROGBITS 0000000000200fd8 00000fd8
0000000000000028 0000000000000008 WA 0 0 8
[20] .got.plt PROGBITS 0000000000201000 00001000
0000000000000020 0000000000000008 WA 0 0 8
$ readelf --relocs libtest.so
Relocation section '.rela.dyn' at offset 0x3e0 contains 8 entries:
Offset Info Type Sym. Value Sym. Name + Addend
[...]
000000200fe8 000300000006 R_X86_64_GLOB_DAT 0000000000000000 foo + 0
能够从反汇编看出要返回的值是从当前 rip 偏移 0x200a24 的位置加载的,即0x0200fe8。查看部分 Section Headers 信息,我们看到 .got 表的大小是 0x28,位于偏移 0x200fd8 处,所以0x0200fe8 是 .got 的一部分,它位于GOT表的偏移量为16的地方。继续看重定位时,我们看到了一个R_X86_64_GLOB_DAT重定位,它表示“查找符号 foo 的值并将其放入偏移 0x2008fe8 处。
因此,当加载此库时,动态加载器将检查重定位,查找 foo 的值,并根据需要修补 .got 入口。当代码加载该值时,它将找到正确的位置,一切都正常,而无需修改任何代码值,从而不破坏代码的可共享性。
总结一下,用一张图来表示:
这只展示了对数据变量的调用,但函数调用呢?
三、动态库调用函数
对于函数的调用与上面类似,不同的是GOT中相应的项保存的是目标函数的地址,当模块需要调用目标函数时,使用了称为过程链接表(PLT, Procedure Linkage Table)。代码不直接调用外部函数,而只通过 PLT 调用:
$ cat test.c
int foo(void);
int function(void) {
return foo();
}
$ gcc -shared -fPIC -o libtest.so test.c
$ objdump --disassemble libtest.so
[...]
00000000000005c9 <_Z8functionv>:
5c9: 55 push %rbp
5ca: 48 89 e5 mov %rsp,%rbp
5cd: e8 1e ff ff ff callq 4f0 <_Z3foov@plt>
5d2: 5d pop %rbp
5d3: c3 retq
因此,我们看到 function 调用了地址 0x4f0 的代码。反汇编此代码:
$ objdump --disassemble-all libtest.so
Disassembly of section .plt:
00000000000004e0 <.plt>:
4e0: ff 35 22 0b 20 00 pushq 0x200b22(%rip) # 201008 <_GLOBAL_OFFSET_TABLE_+0x8>
4e6: ff 25 24 0b 20 00 jmpq *0x200b24(%rip) # 201010 <_GLOBAL_OFFSET_TABLE_+0x10>
4ec: 0f 1f 40 00 nopl 0x0(%rax)
00000000000004f0 <_Z3foov@plt>:
4f0: ff 25 22 0b 20 00 jmpq *0x200b22(%rip) # 201018 <_Z3foov@Base>
4f6: 68 00 00 00 00 pushq $0x0
4fb: e9 e0 ff ff ff jmpq 4e0 <.plt>
我们可以看到这里跳转到 0x200b22(%rip) 也就是 201018,我们可以看到其重定位为符号 foo。
$ readelf --relocs libtest.so
Relocation section '.rela.plt' at offset 0x490 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000201018 000300000007 R_X86_64_JUMP_SLO 0000000000000000 _Z3foov + 0
再继续看,可以看到 201018 处是 04f6:
$ objdump --disassemble-all libtest.so
Disassembly of section .got.plt:
0000000000201000 <_GLOBAL_OFFSET_TABLE_>:
201000: 20 0e and %cl,(%rsi)
201002: 20 00 and %al,(%rax)
...
201018: f6 04 00 00 testb $0x0,(%rax,%rax,1)
20101c: 00 00 add %al,(%rax)
20101e: 00 00 add %al,(%rax)
201020: 06 (bad)
201021: 05 00 00 00 00 add $0x0,%eax
这就又回到了foo@plt的第二项,这里把 0 push到了栈上.
实际上,这里就是延迟绑定的实现,第一次访问这个函数的时候,链接器并没有把函数的真实地址填到 GOT 中,而是将 foo@plt 的第二条指令 pushq $0x0 的地址填到了 GOT 中,也就是说第一次访问函数的时候,foo@plt 中 第一条指令的效果就是跳转到 foo@plt 的第二条指令,push 的 0 就是 foo 这个符号引用在重定位表 .rel.plt 中的下标,接着又是一条跳转指令到 4e0。
00000000000004f0 <_Z3foov@plt>:
4f0: ff 25 22 0b 20 00 jmpq *0x200b22(%rip) # 201018 <_Z3foov@Base>
4f6: 68 00 00 00 00 pushq $0x0
4fb: e9 e0 ff ff ff jmpq 4e0 <.plt>
4e0是啥呢?这里又到了 plt 上,又把 0x200b22(%rip) 入栈,然后继续跳转到*0x200b24(%rip)处存储的地址上:
$ objdump --disassemble-all libtest.so
Disassembly of section .plt:
00000000000004e0 <.plt>:
4e0: ff 35 22 0b 20 00 pushq 0x200b22(%rip) # 201008 <_GLOBAL_OFFSET_TABLE_+0x8>
4e6: ff 25 24 0b 20 00 jmpq *0x200b24(%rip) # 201010 <_GLOBAL_OFFSET_TABLE_+0x10>
4ec: 0f 1f 40 00 nopl 0x0(%rax)
从上面不难看出,其实
0x200b22(%rip) # 201008 <_GLOBAL_OFFSET_TABLE_+0x8> 和
*0x200b24(%rip) # 201010 <_GLOBAL_OFFSET_TABLE_+0x10>
都是 GOT 里面的偏移为 8 和 16 的条目。
第一条,pushq 0x200b22,是将地址压到栈上,也即向最终调用的函数传递参数。
第二条,jmp *0x200b24,这是跳到最终的函数去执行,不过猜猜就能想到,这是跳到能解析动态库函数地址的代码里面执行。
*0x200b24里面放着的到底是什么呢?其实就是函数 _dl_runtime_resolve,那么_dl_runtime_resolve 是怎么干活的呢?
还记得前面的 pushq $0x0 吗,我们刚才说 0 就是 foo 这个符号引用在重定位表 .rel.plt 中的下标,也就是相当于函数 foo 的 id,有了它 _dl_runtime_resolve 就能够知道要去解析哪个函数了,然后它在进行一系列的解析工作之后将 foo 的真正地址填入到 GOT 中去。
一旦 foo 的地址被解析完之后,当再次调用 foo@plt 的时候,第一条 jmp 指令就能直接跳转到真正的 foo() 函数中。
最后用一张图再来总结一下: