在一个简单的hello,world程序中,我们调用了libc库中的printf
函数。libc是动态加载库(shared object),即libc的代码在内存中只有一份,即使有多个程序会调用到它。为了使不同的进程共享同一份代码,libc的代码是位置无关(Position Independent)的,即无论代码被加载到内存的何处,都可以正常运行。 但是这也给程序的运行带来了困扰,正由于位置无关代码(Position Independent Code,PIC)被动态地加载到内存,因此在编译期我们无法得知符号(全局变量或函数)的具体地址。那么程序是怎么在运行期得知这些符号的地址的呢?我们将结合下面的代码进行分析。
//main.c
#include <stdio.h>
int main() {
printf("hello, world\n");
return 0;
}
将main.c
通过gcc -m32 main.c
编译成a.out
,并使用objdump -D a.out
命令反汇编,首先看一下main函数部分:
00001060 <main>:
1060: 8d 4c 24 04 lea 0x4(%esp),%ecx
1064: 83 e4 f0 and $0xfffffff0,%esp
1067: ff 71 fc push -0x4(%ecx)
106a: 55 push %ebp
106b: 89 e5 mov %esp,%ebp
106d: 53 push %ebx
106e: e8 6d 00 00 00 call 10e0 <__x86.get_pc_thunk.bx>
1073: 81 c3 8d 2f 00 00 add $0x2f8d,%ebx
1079: 51 push %ecx
107a: 83 ec 0c sub $0xc,%esp
107d: 8d 83 08 e0 ff ff lea -0x1ff8(%ebx),%eax
1083: 50 push %eax
1084: e8 b7 ff ff ff call 1040 <puts@plt>
1089: 8d 65 f8 lea -0x8(%ebp),%esp
108c: 31 c0 xor %eax,%eax
108e: 59 pop %ecx
108f: 5b pop %ebx
1090: 5d pop %ebp
1091: 8d 61 fc lea -0x4(%ecx),%esp
1094: c3 ret
1095: 66 90 xchg %ax,%ax
1097: 66 90 xchg %ax,%ax
1099: 66 90 xchg %ax,%ax
109b: 66 90 xchg %ax,%ax
109d: 66 90 xchg %ax,%ax
109f: 90 nop
因为a.out
会被加载到内存中执行,这样在描述一条指令时就会有在内存中的地址和在文件中的地址这两套地址,为简单起见,我们假设程序被加载到0x0开始的区域,从而两套地址相同。
0x1060-0x106b是对堆栈进行一些初始化设置;
0x106d-0x1073是将GOT(Global Offset Table,全局偏移表)的地址保存到%ebx
寄存器中。由于这里我们在编译时使用了-m32
选项,我们得到的程序是使用32位机器码的,不支持64位机器的相对%rip
寻址,因此这里需要借助__x86.get_pc_thunk.bx
函数实现。看一下0x10e0地址处的__x86.get_pc_thunk.bx
实现:
000010e0 <__x86.get_pc_thunk.bx>:
10e0: 8b 1c 24 mov (%esp),%ebx
10e3: c3 ret
非常简单,就两行汇编代码。首先取出%esp
寄存器中的值,将其存放到%ebx
寄存器中。%esp
寄存器中的值是什么呢?其实就是__x86.get_pc_thunk.bx
的调用者的下一条指令在内存中的地址,也就是main
函数中0x1073处add $0x2f8d,%ebx
这条指令的地址。为什么呢?因为call
指令会首先把返回地址(也就是上述指令的地址)压入栈中,然后再跳转到目标函数(__x86.get_pc_thunk.bx
)处。在__x86.get_pc_thunk.bx
中,%esp
寄存器中的值就是add $0x2f8d,%ebx
这条指令的地址!__x86.get_pc_thunk.bx
返回后,再执行add $0x2f8d,%ebx
,其中$0x2f8d
是该指令与GOT之间的偏移值,可以在编译期确定。0x1073+0x2f8d=0x4000。果然,0x4000处的内容正是GOT!这样,%ebx
寄存器中的值就是GOT的地址了。
00004000 <_GLOBAL_OFFSET_TABLE_>:
4000: fc cld
4001: 3e 00 00 add %al,%ds:(%eax)
...
400c: 46 inc %esi
400d: 10 00 adc %al,(%eax)
400f: 00 56 10 add %dl,0x10(%esi)
接着是地址0x1084处的指令call 1040 <puts@plt>
,这里调用了地址0x1040处的puts(如果printf的参数是纯字符串,gcc会将其优化成puts)。来看下0x1040处的指令:
00001040 <puts@plt>:
1040: ff a3 0c 00 00 00 jmp *0xc(%ebx)
1046: 68 00 00 00 00 push $0x0
104b: e9 e0 ff ff ff jmp 1030 <_init+0x30>
0x1040处的指令jmp *0xc(%ebx)
首先读取(%ebx)+0xc
处的值,然后跳转到该值处。%ebx
寄存器中的值是0x4000,加上0xc是0x400c,即GOT[3],此处的值正好是0x1046。于是跳转到下一条的push $0x0
指令处继续执行。
400c: 46 inc %esi
400d: 10 00 adc %al,(%eax)
400f: 00 56 10 add %dl,0x10(%esi)
1046处的指令将0压栈,然后跳转到0x1030处。
00001030 <puts@plt-0x10>:
1030: ff b3 04 00 00 00 push 0x4(%ebx)
1036: ff a3 08 00 00 00 jmp *0x8(%ebx)
GOT表中第0项是保存给动态链接器使用的,第1项是指向link map结构的指针,动态链接器将在这些结构中查找符号,第2项是指向_dl_runtime_resolver_
的指针,即动态链接器。因为%ebx
寄存器中的值是GOT的地址0x4000,push 0x4(%ebx)
首先将GOT[1]压栈,然后调用GOT[2],即_dl_runtime_resolver_
,动态链接器查找得到puts
的真实地址,并回填到GOT[3]。这样,下次调用puts
就可以直接从GOT[3]调用,不需要动态链接器再进行查找了。
由于学识所限,文章中必然存在问题。还请大家不吝赐教。