首页 > 代码库 > 实例分析ELF文件动态链接
实例分析ELF文件动态链接
参考文献:
《ELF V1.2》
《程序员的自我修养---链接、装载与库》第6章 可执行文件的装载与进程 第7章 动态链接
《Linux GOT与PLT》
开发平台:
[root@tanghuimin dynamic_link]# uname -a
Linux tanghuimin 2.6.32-358.el6.x86_64 #1 SMP Fri Feb 22 00:31:26 UTC 2013 x86_64 x86_64 x86_64 GNU/Linux
<style></style>
实例讲解之前先来一段理论铺垫,文字很繁琐但很必要事先了解。
1. ELF文件的装载
《程序员的自我修养》6.5节对Linux内核装载ELF的过程有描述。
首先在用户界面,bash进程会调用fork()系统调用创建一个新的进程,然后新的进程调用execve()系统调用执行指定的ELF文件,原先的bash进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。
在进入execve()系统调用之后,Linux内核就开始真正的装载工作。在内核中,execve()系统调用相应的入口是sys_execve(),sys_execve()进行一些参数的检查复制之后,调用do_execve()。do_execve()检查被执行的文件的格式,调用search_binary_handle()查找合适的可执行文件装载处理过程。elf可执行文件的装载处理过程叫做load_elf_binary(),load_elf_binary()主要的工作是:
(1) 检查ELF可执行文件格式的有效性,比如魔数,程序头表中段(segment)的数量;
(2) 查找动态链接的“.interp”段,设置动态连接器路径;
(3) 根据ELF可执行文件的程序头表的描述,对ELF文件进行映射,比如代码,数据,只读数据;
(4) 初始化ELF进程环境,在进程堆栈中保存命令行参数,环境变量,及辅助信息数组(Auxiliary Vector),辅助信息数组为动态连接器提供可执行文件各个段的信息和程序的入口地址,关于辅助信息数组的详细描述在《程序员的自我修养》的7.5.5节;
(5) 将系统调用的返回地址改成ELF可执行文件的入口点,这个入口点取决于程序的链接方式。对于静态链接的ELF可执行文件,这个程序入口就是ELF文件的文件头中的e_entry所指的地址,对于动态链接的ELF可执行文件,程序的入口点是动态链接器,动态链接器会被加载。
(6) 当load_elf_binary()执行完毕,返回至do_execve()再返回至sys_execve()时,上面第5步中已经把系统调用的返回地址改成了被装载的ELF程序的入口地址了,所以当sys_execve()系统调用从内核态返回用户态时,EIP寄存器直接跳转到ELF程序的入口地址,于是新的程序开始执行,ELF可执行文件装载完成。
2. 动态链接的步骤
(1) 动态链接器的自举
动态链接器是一个特殊的共享对象。首先,它是静态链接的,不依赖其他任何共享对象;其次,动态链接器本身所需要的静态和全局变量的重定位工作都由它本身完成。对于第二个条件,动态链接器在启动时,有一段非常精巧的代码可以完成这项艰巨的工作,同时又不用到静态和全局变量,这样一段启动代码叫做自举。动态连接器的入口即为自举代码的入口。
(2) 装载共享对象
完成自举后,动态链接器将可执行文件和其本身的符号表都合并到全局符号表中,然后开始寻找可执行文件所依赖的共享对象。可执行文件的.dynamic段中的DT_NEEDED入口下,记录了该可执行文件所依赖的共享对象。动态链接器将所有依赖的共享对象装载到内存,并将符号表合并到全局符号表中,所以当所有的共享对象都被装载进来的时候,全局符号表中包含了进程中所有动态链接所需要的符号。
(3) 重定位和初始化
连接器遍历可执行文件和共享对象的重定位表,将它们的GOT/PLT中的每个需要重定位的位置进行修正。因为此时动态链接器已经拥有了进程的全局符号表,所以这个修正过程比较容易。重定位完成之后,如果共享对象有.init段,那么动态链接器会执行.init段中的代码,用以实现共享对象特有的初始化过程。
当重定位和初始化都完成之后,所需要的共享对象都已经装载并链接完成,动态链接器将进程的控制权转交给程序入口并开始执行。
3. 关于.got和.plt
got:
由于共享库的装载地址是不固定的,为了保持代码段的地址无关性,代码段中对静态和全局变量的访问都通过got段来周转,当要对变量进行操作时,从got段相应入口下读取该变量的地址。每个变量的地址对应got段的一个入口,共享库被装载前,got为全0,当动态链接器将该共享库装载进内存时,会将每个变量的绝对地址写入got段。
对于共享库中定义的全局变量,链接时,连接器会在可执行文件的bss段中创建该变量的副本,当共享模块被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器会把got中的地址指向该副本,这样该变量在运行时的实例只有一个。如果变量在共享模块中被初始化,那么动态链接器还要将该初始化的值复制到可执行文件的副本中,如果该全局变量在可执行文件中没有副本,那么got中相应地址就指向模块内部的该变量的副本。因为共享库中的全局变量在每个进程中都拥有一个副本,所以多个进程对该全局变量的操作不会相互干扰。
plt:
动态链接相比静态链接,虽然节约了内存空间,但是使用动态链接的程序却没有使用静态链接的程序速度快,这是因为动态链接下,全局变量,静态变量,函数调用都是通过got来间接跳转,运行速度必定会变慢;其次动态链接是运行时链接,即程序运行时,连接器会加载共享库,进行重定位工作,而在程序的运行过程中,很多函数根本不会用到,如果一开是就把所有的函数链接好显然是一种是一种浪费,所以便催生了延迟绑定技术(Lazy Binding),基本思想是,函数第一次使用时才进行重定位,否则不为其重定位。
plt(procedure linkage table)是实现延迟绑定技术的一段精巧指令序列,当函数第一次执行时,会调用_dl_runtime_resolve()函数来为函数符号进行重定位,每个函数的地址在.got.plt下对应一个入口。当函数第二次执行时,只需从.got.plt相应的入口获取该函数的地址即可。
.got.plt段专门用来存放函数的入口地址,.got.plt的前三个入口是有特殊含义的:
1) 第一项保留的是“.dynamic”段的地址,这个段描述的是本模块动态链接相关的信息;
2) 第二项为本模块的ID,调用_dl_runtime_resolve()对函数重定位时,需要将该id作为参数传入;
3) 第三项保留的是_dl_runtime_resolve()的地址。
第二项和第三项都是在动态链接器装载共享模块时负责将它们初始化。.got.plt的其余入口都用来存放外部函数的地址。
关于.plt段中的精巧指令,下面的实例中会讲到。
4. 实例讲解
创建共享库源文件common.c
int val = 1;
int func(void)
{
return (val+10);
}
<style></style>
创建test.c
extern int val;
extern int func(void);
int main()
{
val = 10;
func();
return 0;
}
<style></style>
生成共享库
gcc -shared -fPIC -o common.so common.c
生成可执行文件
gcc -o test test.c ./common.so
将可执行文件test反汇编
objdump -S test > test.S
来分析一下test.S的main函数,看看代码是怎么去寻找val和func的入口的
main函数实现如下
00000000004005f4 <main>:
119 4005f4: 55 push %rbp
120 4005f5: 48 89 e5 mov %rsp,%rbp
121 4005f8: c7 05 ae 03 20 00 0a movl $0xa,0x2003ae(%rip) # 6009b0 <val>
122 4005ff: 00 00 00
123 400602: e8 f9 fe ff ff callq 400500 <func@plt>
124 400607: b8 00 00 00 00 mov $0x0,%eax
125 40060c: c9 leaveq
126 40060d: c3 retq
127 40060e: 90 nop
128 40060f: 90 nop
val = 10;对应的汇编代码为
121 4005f8: c7 05 ae 03 20 00 0a movl $0xa,0x2003ae(%rip) # 6009b0 <val>
到0x6009b0处获取val。
readelf -S test查看section header table,
......
[25] .bss NOBITS 00000000006009b0 000009ac
57 0000000000000018 0000000000000000 WA 0 0 8
......
<style></style>
0x6009b0是bss段在内存中的虚拟地址,看来共享库中的全局变量果真在bss段有副本。
再来看func函数的调用
123 400602: e8 f9 fe ff ff callq 400500 <func@plt>
跳转到func@plt处了
来看看.plt段到底都干了些什么
15 Disassembly of section .plt:
16
17 00000000004004e0 <__libc_start_main@plt-0x10>:
18 4004e0: ff 35 a2 04 20 00 pushq 0x2004a2(%rip) # 600988 <_GLOBAL_OFFSET_TABLE_+0x8>
19 4004e6: ff 25 a4 04 20 00 jmpq *0x2004a4(%rip) # 600990 <_GLOBAL_OFFSET_TABLE_+0x10>
20 4004ec: 0f 1f 40 00 nopl 0x0(%rax)
21
22 00000000004004f0 <__libc_start_main@plt>:
23 4004f0: ff 25 a2 04 20 00 jmpq *0x2004a2(%rip) # 600998 <_GLOBAL_OFFSET_TABLE_+0x18>
24 4004f6: 68 00 00 00 00 pushq $0x0
25 4004fb: e9 e0 ff ff ff jmpq 4004e0 <_init+0x18>
26
27 0000000000400500 <func@plt>:
28 400500: ff 25 9a 04 20 00 jmpq *0x20049a(%rip) # 6009a0 <_GLOBAL_OFFSET_TABLE_+0x20>
29 400506: 68 01 00 00 00 pushq $0x1
30 40050b: e9 d0 ff ff ff jmpq 4004e0 <_init+0x18>
<style></style>
每个需要动态链接的函数在.plt段下都对应一个入口,如上入口都以“函数名@plt”来命名。
当调用func()时,代码跳转到了0x400500处,第一条指令 jmpq *0x20049a(%rip),跳转到了0x6009a0处,0x60090在.got.plt中,readelf -r test可以知道该地址正式func函数在.got.plt中对应的入口的地址,readelf -S test查到.got.plt的虚拟地址为0x600980,段大小为0x28,每个入口8个字节,一共有五个入口。hexdump -C test来看看.got.plt段的几个入口在程序运行前都存了些什么。
<style></style>
由上图看出,第一个入口,0x6007d8存的正式.dynamic段的地址,第二个和第三个入口应该是module id和_dl_runtime_resolve()函数的入口,这两个现在都是全0,共享库被加载并初始化的时候会为其赋值,第四个入口是__libc_start_main函数的入口地址,第五个是func函数的入口地址。
jmpq *0x20049a(%rip)到底跳转到哪里去了呢?我们发现0x9a0处存的地址是0x400506,是jumpq的下一条指令的地址,好吧,饶了半天直接跳跳下一条指令而已,继续往下走,将0x1压栈,然后跳转到0x4004e0处,即.plt的第一个入口,pushq 0x2004a2(%rip) ,将0x600988处的值压栈,0x600988是.plt的第二个入口,存的是module id,然后 jmpq *0x2004a4(%rip) ,跳转到0x600990处,0x600990是.plt的第三个入口,即_dl_runtime_resolve()函数的入口,_dl_runtime_resolve()就是用来重定位函数的,忽然领悟,原来这就是传说中的“延迟绑定”,即第一次调用函数的时候进行重定位。
重新看一下上面plt部分的代码,理一下思路。当在可执行程序中第一次调用共享库中的函数func时,跳转到func@plt处,这时func在.got.plt中的地址是jmpq的下一条指令的地址,进而引导代码去调用_dl_runtime_resolve()链接函数func,当然这之前会将func在.rel.plt中的index(0x01)和module id压栈作为参数传给_dl_runtime_resolve()。_dl_runtime_resolve()执行完后,.got.plt相应入口下便写入了func的真实地址,当程序第二次调用func函数时,就能跳转到func函数的入口了。
5. GDB调试
用gdb来调试test,看看不同时刻.got.plt中的值是怎样变化的,还有.bss中的val值。
先把要查看的各项的地址交代一下
val:0x6009b0
module id: 0x600988
_dl_runtime_resolve()地址:0x600990
func地址:0x6009a0
$ gcc -o test -g test.c ./common.so
加上-g选项生成带gdb调试信息的可执行文件
在main函数处设断点,查看运行到main处时,.bss中的val值和.got.plt中的各项值
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /tmp/test/dynamic_link/test...done.
(gdb) l
1 extern int val;
2 extern int func(void);
3
4 int main()
5 {
6 val = 10;
7 func();
8 return 0;
9 }
(gdb) b main
Breakpoint 1 at 0x4005f8: file test.c, line 6.
(gdb) r
Starting program: /tmp/test/dynamic_link/test
Breakpoint 1, main () at test.c:6
6 val = 10;
Missing separate debuginfos, use: debuginfo-install glibc-2.12-1.132.el6.x86_64
(gdb)
<style></style>
在查看内存中各项值之前我们先预测一下:
可执行程序运行到main函数时,动态库已经被加载了,动态库的初始化已经完成,所以val的值应该是1,即共享库中初始化的值,module id和_dl_runtime_resolve()的地址也应该有了。func()函数的地址应该还没有,因为func函数还没有被执行
好,看看内存中的情况是不是如我所测
Breakpoint 1, main () at test.c:6
6 val = 10;
Missing separate debuginfos, use: debuginfo-install glibc-2.12-1.132.el6.x86_64
(gdb) x 0x6009b0
0x6009b0 <val>: 0x00000001
(gdb) x 0x600988
0x600988 <_GLOBAL_OFFSET_TABLE_+8>: 0x41021188
(gdb) x 0x600990
0x600990 <_GLOBAL_OFFSET_TABLE_+16>: 0x40e146f0
(gdb) x 0x6009a0
0x6009a0 <func@got.plt>: 0x00400506
(gdb)
<style></style>
很好,和预测的一样
下面单步执行val =10
(gdb) n
7 func();
(gdb) x 0x6009b0
0x6009b0 <val>: 0x0000000a
(gdb) x 0x6009a0
0x6009a0 <func@got.plt>: 0x00400506
(gdb)
<style></style>
可以看到val的值已经变成10了,func没有被执行,地址依然没有变
再次单步执行,func()函数被执行过了
查看func在.got.plt中的地址
(gdb) n
8 return 0;
(gdb) x 0x6009a0
0x6009a0 <func@got.plt>: 0xf7dfc55c
(gdb)
<style></style>
可以看出func执行过一次之后,其在.got.plt中的地址已被重定向到正确的地址0xf7dfc55c。