本篇会重点介绍静态链接,动态链接,延迟绑定机制
问:两个或者多个不同的目标文件是如何组成一个可执行文件的呢?
答:这就需要进行链接( linking )。链接由链接器( linker )完成,根据发生的时间不同,可分为编译时链接( compile time)、加载时链接( load time )和运行时链接(runtime )。
测试代码
main.c
extern int shared;
extern void func(int * a, int * b);
int main()
{
int a = 100;
func(&a, &shared);
return 0;
}
func.c
int shared = 1;
int tmp = 0;
void func(int * a, int * b)
{
tmp = *a;
*a = *b;
*b = tmp;
}
把两个目标文件链接成一个可执行文件
gcc -static -fno-stack-protector main.c func.c -save-temps --verbose -o func.ELF
在将main.o和func.o这两个目标文件链接成一个可执行文件时,最简单的方法是按序叠加这种方案的弊端是,如果参与链接的目标文件过多,那么输出的可执行文件会非常零散。而段的装载地址和空间以页为单位对齐,不足一页的代码节或数据节也要占用一页,这样就造成了内存空间的浪费。
另一种方案是相似节合并,将不同目标文件相同属性的节合并为一个节,如将main.o与func.o的.text节合并为新的.text 节,将main.o与 func.o中的.data节合并为新的.data节,这种方案被当前的链接器所采用,首先对各个节的长度、属性和偏移进行分析,然后将输入目标文件中符号表的符号定义与符号引用统一生成全局符号表,最后读取输入文件的各类信息对符号进行解析、重定位等操作。相似节的合并就发生在重定位时。完成后,程序中的每条指令和全局变量就都有唯一的运行时内存地址了。
为了构造可执行文件,链接器必须完成两个重要工作:符号解析( symbol resolution )和重定位( relocation )。
对比一下静态链接可执行文件 func.ELF和中间产物main.o的区别。使用objdump可以查看文件各个节的详细信息,这里我们重点关注.text、.data和.bss节。
objdump -h main.o
objdump -h func.ELF
5 .text 0008f480 00000000004004d0 00000000004004d0 000004d0 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
20 .data 00001af0 00000000006b90e0 00000000006b90e0 000b90e0 2**5
CONTENTS, ALLOC, LOAD, DATA
25 .bss 000016f8 00000000006bb2e0 00000000006bb2e0 000bb2d8 2**5
ALLOC
尚未进行链接的目标文件 main.o的VMA都是0。而在链接完成后的 func.ELF中,相似节被合并,且完成了虚拟地址的分配。
使用objdump查看main.o的反汇编代码,参数“-mi386:intel”表示以intel格式输出。
objdump -d -M intel --section=.text main.o
main()函数的地址从0开始。其中,对func()函数的调用在偏移0x20处,0xe8是CALL指令的操作码,后四个字节是被调用函数相对于调用指令的下一条指令的偏移量。
由于还没有重定位,编译器并不知道func函数的位置以及变量shared的位置,所以其地址用0x0代替,之后的地址替换工作是交给链接器完成
查看func.ELF的符号地址
objdump -d -M intel --section=.text func.ELF | grep -A 16 "<main>"
调用func()函数的指令CALL位于0x4009c9,其下一条指令MOV位于0x4009ce,因此相对于MOV指令偏移量为0x07的地址为0x4009ce+0x07=0x4009d5,刚好就是func()函数的地址。同时,0x4009c1处也已经改成了shared 的地址0x6ca090。
查看main.o里的可重定位表
objdump -r main.o
可重定位文件中最重要的就是要包含重定位表,用于告诉链接器如何修改节的内容。每一个重定位表对应一个需要被重定位的节。
例如名为.rel.text的节用于保存.text节的重定位表。.rel.text包含两个重定位入口:
后缀名为.a的文件是静态链接库文件,如常见的libc.a。一个静态链接库可以视为一组目标文件
经过压缩打包后形成的集合。执行各种编译任务时,需要许多个同的目标文件,比如输入输出有printf.o、scanf.o,内存管理有malloc.o等。为了方便管理,人们使用ar 工具将这些目标文件进行了压缩、编号和索引,就形成了libc.a。
静态链接产生问题:
动态链接:系统库和自己写的代码先不链接在一起,都是独立的模块,等到程序执行时在内存中完成链接。而且内存一个系统库可以被多个程序一起使用。这些被共享的库被称作共享库,或者共享对象,这个过程由动态链接器完成。
GCC默认使用动态链接编译,通过下面的命令我们将func.c编译为共享库,然后使用这个库编译main.c。
gcc -shared -fpic -o func.so func.c
gcc -fno-stack-protector -o func.ELF2 main.c ./func.so
参数-shared表示生成共享库, -fpic 表示生成与位置无关的代码。这样可执行文件 func.ELF2就会在加载时与func.so进行动态链接。另外动态加载器ld-linux.so本身就是一个共享库,因此加载器会加载并运行动态加载器,并由动态加载器来完成其他共享库以及符号的重定位。
可以加载而不需要重定位的代码被称为位置无关代码(PIC),这个共享库的基本属性,通过给gcc传递 -fpic 参数可以生成 PIC 。这样一个共享库就可以被所有进程使用。
一个程序(或共享库)的数据段和代码段的相对距离不变,与绝对内存地址无关。于是就由了全局偏移量表(GOT),位于数据段的开头,用于保存全局变量和库函数的引用,每个条目占8个字节,加载时会进行重定位并填入符号的绝对地址。
因为引入了RELRO保护机制,GOT被拆分为 .got 和 .got.plt节两个部分:
看一下 func.so
objdump -h func.so
readelf -r func.so | grep tmp
objdump -d -M intel --section=.text func.so | grep -A 20 "<func>"
全局变量 tmp 位于GOT上,R_X86_64_GLOB_DAT 表示需要动态链接器找到 tmp 的值并填充到0x200fd8。在func()函数需要取出 tmp时,计算符号相对PC的偏移 rip+0x2009e5,也就是0x6c9+0x2009e5=0x200fd8。
由于动态链接是由动态链接器在程序加载时进行的,如果有很多个程序需要加载,会影响到动态链接器的性能。延迟绑定,其思想是当函数第一次被调用时,动态链接器才进行符号查找,重定位等操作,没被调用就不进行绑定。
ELF文件通过过程链接表(Procedure Linkage Table,PLT )和GOT的配合来实现延迟绑定,每个被调用的库函数都有一组对应的PLT和GOT。
位于代码段.plt节的PLT是一个数组,每个条目占16个字节。其中 PLT[0]用于跳转到动态链接器,PLT[1]用于调用系统启动函数_libc_start_main(),我们熟悉的main()函数就是在这里面调用的,从PLT[2]开始就是被调用的各个函数条目。
位于数据段.got.plt节的GOT也是一个数组,每个条目占8个字节。其中 GOT[0]和 GOT[1]包含动态链接器在解析函数地址时所需要的两个地址(.dynamic和 relor条目),GOT[2]是动态链接器ld-linux.so 的人口点,从GOT[3]开始就是被调用的各个函数条目,这些条目默认指向对应PLT条目的第二条指令,完成绑定后才会被修改为函数的实际地址。
当func.ELF2调用库函数 func()为例