关于动态链接
为什么要使用动态链接呢?因为静态链接对计算机的内存和磁盘的空间浪费非常严重,特别是多进程操作系统情况下,程序内部除了printf,scanf,strlen这样的公共库函数,还有其他的库函数和辅助数据结构。在linux系统中,一个普通c语言静态库至少会在1MB以上,如果机器中运行多个这样的程序,浪费的磁盘空间是非常大的。比如:程序program1和program2分别包含program1.o和progarm2.o,并且他们公用lib.o这个公共模块。在静态链接的情况下,因为progarm1和program2都用到了lib.o这个模块,所以在链接在输出的可执行文件Progarm1和Progarm2时,lib.o会存在两个复本。当系统中类似lib.o被多个程序共享的目标文件时,其中很大一部分的空间就被浪费了。以swap函数为例,静态链接反汇编的文件有20万行。
除了空间浪费,静态链接另一个重要的问题是对程序的更新,部署和发布也会带来很多麻烦。一旦程序中得任何模块更新,整个程序就要重新链接,发布,非常不便。
动态链接将程序的模块互相分割开形成独立的文件,等到程序要运行时才进行链接。以program1和program2为例,假设保留program1.o,progarm2.o和lib.o三个目标文件,当要加载progarm1.o时,当系统发现progarm1.o用到了lib.o,那么系统接着加载Lib.o,如果progarm1.o或者lib.o还依赖其他目标文件,系统会按照这个方法将他们全部加载至内存。当所有需要的目标文件加载完毕之后,如果依赖关系满足,即所有依赖的目标都存在磁盘,系统开始链接工作,链接包括了符号解析,地址重定位等。在完成这些步骤之后,系统把控制权交给了progarm1.o的程序入口,并且开始运行。这个时候如果需要运行progarm2, 系统只需要加载progarm2.o而不再需要加载lib.o,因为内存中已经存在一份lib.o的副本, 系统要做的只是把progarm2.o和lib.o链接起来。静态链接与动态链接的示意图如图2.1所示:
装载时重定位之GOT
动态链接需要考虑的第一个问题:共享对象在被装载时,如何确定其在虚拟内存中的位置?当多个模块被多个程序使用时,如何确定共享对象的在虚拟内存中的位置?
比如模块A占用了0x1000~0x2000,但当B模块在装载时,假设B与A时独立的,那么B就不会考虑A是否占有了0x1000-0x2000,于是占用了这块地址。但是别的模块比如C这个时候需要调用到模块A这个地址,但是此时的地址又被B占用了。也就是说如果所有的模块都是相互独立的,就不会存在内存分配的问题。但是在实际开发过程中,经常会出现调用的情况,比如图2.1中的lib.o,如果有成千上万个共享对象的系统,如何分配共享对象的在虚拟内存中的位置,是很重要的。
其解决思路是,在编译,链接时并不指定共享对象的地址,而是在装载的时候把所有需要绝对地址的引用进行重定位,然后把这些需要重定位的对象存放在一个表中,叫做重定位表。在静态链接是,采用的是方式是链接时重定位,而动态链接采用的时装载时重定位。这个重定位表就是我们所说的GOT。
GOT时Global Offset Table的缩写,为全局偏移量表,其格式如图2.2:
回顾图1.1,got段为可读可写,那么GOT其余表项存储的地址是怎么来的呢?首先got段为可读可写,GOT段的内容是会被修改的,而且这些绝对地址是由放在GOT[2]中的Resolver计算出来的,至于是怎么计算出来的,在何时计算的,在下文中详细讲述。Got的相关信息可以在readelf中查看segment段,也可以通过readelf -x .got main来具体查看got表中初始值。
装载时重定位无法共享动态库的指令
装载时重定位可以解决动态模块中绝对地址引用的办法之一,但是还有一个很大的问题就是共享库的指令部分无法在多个进程之间共享。(更加详细的描述可见:https://eli.thegreenplace.net/2011/11/03/position-independent-code-pic-in-shared-libraries/)
静态链接与动态链接一个明显的区分点就是重定位的时间点。对于静态链接来说,使用的是编译时重定位,常见的比如main中调用了test,通过手动添加头文件的方式,gcc main.c,test.c,就可以生成一个可执行文件,编译器在编译的时候就会知道test的地址在哪。但是这个可执行文件是一个整体,所以的代码objdump出来会把main中的test直接调用之后塞进去,其实和在main中写一个test没有什么区别。
动态链接是通过模块的方式进行链接,会把test生成一个.so的共享库文件,main调用test的时候,不再是直接在main函数中展开,而是会通过got重定位表寻找到test函数的绝对地址,再去test中去执行,执行完成之后再回到main,与静态链接所有的执行还是放在main中执行不同,main跳转到test会把权利交给test去执行。一个比较形象的例子是对于动态链接来说,对于我们国家的火箭发动机,整流罩,各种零件是在全国各地生产的,最后组装一下形成了一个真正的火箭,而不是在一个地方完成所有零件的生产。这种装载时重定位的方法可以实现各个模块的分离,在未来各个模块之间的升级,更新上比静态库方便许多。
但还存着这一个问题,装载的过程是把可执行文件从磁盘装载到虚拟内存中,虚拟内存映射到物理内存之后才可以真的执行该文件。装载时重定位需要在装载时把所有的动态库指令部分的绝对地址算出来然后映射到物理内存中。如果一个共享库同时被多个进程同时使用,装载时重定位是需要对共享库的指令进行修改的,那么问题来了,如图2.3对于共享库来说是不是需要被映射进内存很多次?
理想的情况是,lib.so只需要被加载至内存中一次,但是可以被多个进程中使用。解决这个办法的方法是:在装载阶段,动态库中的代码部分并不展开,而是在运行时再计算。这就需要我们对动态库在编译的时候加上-fPIC参数来指定我们的生成的动态库是位置无关的。接下来要讲述的就是位置无关码与plt,got是如何实现延时绑定,最终实现了我们最常见的动态链接。
我们先来看一下什么是位置有关码。
示例代码如下,仅实现了一个printf:
#include <stdio.h>
int main(){
printf("fno_pic\n");
return 0;
}
在x86中,如果直接gcc一个文件的话,生成的代码是位置无关码,我们先用如下命令生成位置有关码:
gcc -g -m32 -no-pie -fno-pic main.c -o main
-g表示带调试信息,-m32表示在64位的机器中用32位的方式编译,-no-pie -fno-pic表示生成的代码是位置无关码,-o将生成的可执行文件重命名为main。接着使用objdump命令和readelf生成反汇编文件和ELF文件格式的文件查看代码。首先声明,位置有关码也是动态链接的方式,如果是静态链接的方式,生成的可执行文件反汇编之后会有几十万行,原因是文件会把需要加载的内容全部在链接阶段放在一起。位置有关码的方式依然是使用动态链接调用的,比如上述代码中需要调用printf,方式是调到重定位表中,在got通过Resolver(就是寻找printf真正地址的工具),只是位置相关码直接会给出绝对地址,调用printf的方式如图2.4下:
位置无关码,依然是实现一个printf函数:
int main(){
printf("fPIC\n");
return 0;
}
编译:gcc -m32 -g main.c -o main
Objdump和readelf查看文件反汇编和elf文件格式,可以查看main函数调用printf的方法:
int main(){
51d: 8d 4c 24 04 lea 0x4(%esp),%ecx
521: 83 e4 f0 and $0xfffffff0,%esp
524: ff 71 fc pushl -0x4(%ecx)
527: 55 push %ebp
528: 89 e5 mov %esp,%ebp
52a: 53 push %ebx
52b: 51 push %ecx
52c: e8 28 00 00 00 call 559 <__x86.get_pc_thunk.ax>
531: 05 a7 1a 00 00 add $0x1aa7,%eax
printf("fPIC\n");
536: 83 ec 0c sub $0xc,%esp
539: 8d 90 08 e6 ff ff lea -0x19f8(%eax),%edx
53f: 52 push %edx
540: 89 c3 mov %eax,%ebx
542: e8 69 fe ff ff call 3b0 <puts@plt>
547: 83 c4 10 add $0x10,%esp
return 0;
可以看出,相比于位置有关码,位置无关的地址都相当简洁。其使用大概思路是:实际的模块装载地址+各种偏移量。可以看到程序52c行调用了<__x86.get_pc_thunk.ax>,其代码如下:
00000559 <__x86.get_pc_thunk.ax>:
559: 8b 04 24 mov (%esp),%eax
55c: c3 ret
因为在x86中,调用call函数会自动把下一条指令的地址存放在栈顶,而esp寄存器始终指向的是站定,所以此时的eax寄存器就保存了0x531这个地址。然后在0x531处,eax又加上了0x1aa7,此时eax的地址为0x1df8。这个eax给了接下来我们对got的操作提供了地址,请继续看关于plt的分析。
举个例子来描述一下位置有关码与位置无关码:位置有关码是指定A是第一个,B是第二,C是第三个;位置无关码是指定B在A的后面,C在B的后面,那么对于动态链接来说,可以随时随地使用动态无关码改变ABC组成的模块的位置而不用担心内存冲突,但是位置有关码需要考虑的因素更多,因为需要同时修改所有元素的位置。当然位置无关码由于需要依赖寄存器进行计算,可能会导致寄存器使用紧张,也可能由于计算让速度变慢。