程序的链接和装入及Linux下动态链接的实现--Linux下动态链接的实现
- UID
- 1066743
|
程序的链接和装入及Linux下动态链接的实现--Linux下动态链接的实现
如今我们在Linux下编程用到的库(像libc、QT等等)大多都同时提供了动态链接库和静态链接库两个版本的库,而gcc在编译链接时如果不加-static选项则默认使用系统中的动态链接库。对于动态链接库的原理大多数的书本上只是进行了泛泛的介绍,在此笔者将通过在实际系统中反汇编出的代码向读者展示这一技术在Linux下的实现。
下面是个最简单的C程序hello.c:
1
2
3
4
5
6
| #include <stdio.h>
int main()
{
printf("Hello, world\n");
return 0;
}
|
在Linux下我们可以使用gcc将其编译成可执行文件a.out:
程序里用到了printf,它位于标准C库中,如果在用gcc编译时不加-static的话,默认是使用libc.so,也就是动态链接的标准C库。在gdb中可以看到编译后printf对应如下代码 :
1
2
3
4
5
6
| $ gdb -q a.out
(gdb) disassemble printf
Dump of assembler code for function printf:
0x8048310 <printf>: jmp *0x80495a4
0x8048316 <printf+6>: push $0x18
0x804831b <printf+11>: jmp 0x80482d0 <_init+48>
|
这也就是通常在书本上以及前面提到的打桩(stub)过程,显然这并不是真正的printf函数。这段stub代码的作用在于到libc.so中去查找真正的printf。
1
2
| (gdb) x /w 0x80495a4
0x80495a4 <_GLOBAL_OFFSET_TABLE_+24>: 0x08048316
|
可以看到0x80495a4处存放的0x08048316正是pushl $0x18这条指令的地址,所以第一条jmp指令没有起到任何作用,其作用就像空操作指令nop一样。当然这是在我们第一次调用printf时,其真正的作用是在今后再次调用printf时体现出来的。第二条jmp指令的目的地址是plt,也就是procedure linkage table,其内容可以通过objdump命令查看,我们感兴趣的就是下面这两条对程序的控制流有影响的指令:
1
2
3
4
5
6
| $ objdump -dx a.out
……
080482d0 >.plt>:
80482d0: ff 35 90 95 04 08 pushl 0x8049590
80482d6: ff 25 94 95 04 08 jmp *0x8049594
……
|
第一条push指令将got(global offset table)中与printf相关的表项地址压入堆栈,之后jmp到内存单元0x8049594中所存放的地址0x4000a960处。这里需要注意的一点是,在查看got之前必须先将程序a.out启动运行,否则通过gdb中的x命令在0x8049594处看到的结果是不正确的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| (gdb) b main
Breakpoint 1 at 0x8048406
(gdb) r
Starting program: a.out
Breakpoint 1, 0x08048406 in main ()
(gdb) x /w 0x8049594
0x8049594 <_GLOBAL_OFFSET_TABLE_+8>: 0x4000a960
(gdb) disassemble 0x4000a960
Dump of assembler code for function _dl_runtime_resolve:
0x4000a960 <_dl_runtime_resolve>: pushl %eax
0x4000a961 <_dl_runtime_resolve+1>: pushl %ecx
0x4000a962 <_dl_runtime_resolve+2>: pushl %edx
0x4000a963 <_dl_runtime_resolve+3>: movl 0x10(%esp,1),%edx
0x4000a967 <_dl_runtime_resolve+7>: movl 0xc(%esp,1),%eax
0x4000a96b <_dl_runtime_resolve+11>: call 0x4000a740 <fixup>
0x4000a970 <_dl_runtime_resolve+16>: popl %edx
0x4000a971 <_dl_runtime_resolve+17>: popl %ecx
0x4000a972 <_dl_runtime_resolve+18>: xchgl %eax,(%esp,1)
0x4000a975 <_dl_runtime_resolve+21>: ret $0x8
0x4000a978 <_dl_runtime_resolve+24>: nop
0x4000a979 <_dl_runtime_resolve+25>: leal 0x0(%esi,1),%esi
End of assembler dump.
|
前面三条push指令执行之后堆栈里面的内容如下:
下面将0x18存入edx,0x8049590存入eax,有了这两个参数,fixup就可以找到printf在libc.so中的地址。当fixup返回时,该地址已经保存在了eax中。xchg指令执行完之后堆栈中的内容如下:
最妙的要数接下来的ret指令的用法,这里ret实际上被当成了call来使用。ret $0x8之后控制便转移到了真正的printf函数那里,并且清掉了堆栈上的0x18和0x8049584这两个已经没用的参数,这时堆栈便成了下面的样子:
而这正是我们所期望的结果。应该说这里ret的用法与Linux内核启动后通过iret指令实现由内核态切换到用户态的做法有着异曲同工之妙。很多人都听说过中断指令int可以实现用户态到内核态这种优先级由低到高的切换,在接受完系统服务后iret指令负责将优先级重新降至用户态的优先级。然而系统启动时首先是处于内核态高优先级的,Intel i386并没有单独提供一条特殊的指令用于在系统启动完成后降低优先级以运行用户程序。其实这个问题很简单,只要反用iret就可以了,就像这里将ret当作call使用一样。另外,fixup函数执行完还有一个副作用,就是在got中与printf相关的表项(也就是地址为0x80495a4的内存单元)中填上查找到的printf函数在动态链接库中的地址。这样当我们再次调用printf函数时,其地址就可以直接从got中得到,从而省去了通过fixup查找的过程。也就是说got在这里起到了cache的作用。 |
|
|
|
|
|