5.4.4 加载动态链接器

在现代操作系统中,绝大部分程序都是动态链接的。对于动态链接的程序,除了加载可执行程序外,其依赖的动态库也要加载。对于动态链接的程序和库,编译时并不能确定引用的外部符号的地址,因此在加载后,还要进行符号重定位。

为了降低内核的复杂度,上述工作并没有包含在内核中,而是转移到了用户空间,由用户空间的程序来完成这个过程。这个程序被称为动态加载/链接器(dynamic linker/loader),一般也将其简称为动态链接器。后续行文中,凡是没有使用“动态”二字修饰的链接器,均指编译时的链接器。内核只负责将动态链接器加载到内存,其他的都交由动态链接器去处理。

为了更大的灵活性,内核不会假定系统中使用动态链接器,而是由可执行程序主动告诉内核谁是动态链接器。当编译一个可执行程序时,链接器将创建一个类型为"INTERP"的段,这个段非常简单,就是包含一个字符串,这个字符串就是动态链接器的名字,以可执行程序hello为例:

5.4.4 加载动态链接器 - 图1

由上可见,类型为"INTERP"的段就是一个19(0x13)个字符长的字串"/lib/ld-linux.so.2",正是动态链接器。

当内核加载可执行程序时,其将检查可执行程序的Program Header Table中是否包含有类型为"INTERP"的段,代码如下:

5.4.4 加载动态链接器 - 图2

5.4.4 加载动态链接器 - 图3

如果有INTERP的段,那么说明这个ELF文件是一个动态链接的可执行程序。Linux中动态链接器以动态库的方式实现,于是内核需要将动态链接器这个动态库加载到进程的地址空间,代码如下:

5.4.4 加载动态链接器 - 图4

加载动态链接器与加载可执行程序的过程基本完全相同,函数load_elf_interp就是一个简化版的load_elf_binary,这里我们不再赘述。完成动态链接器加载后,需要跳转到动态链接器的入口继续执行。那么,如何确定动态链接器的入口地址呢?动态链接器的ELF头中将记录一个入口地址:

5.4.4 加载动态链接器 - 图5

难道编译时链接器计算错了?0x1050不太像进程地址空间的虚拟地址。没错,0x1050是虚拟地址,只不过是因为在编译时不能确定动态库的加载地址,所以动态库中地址分配从0开始,见下面动态库的Program Header Table:

5.4.4 加载动态链接器 - 图6

函数load_elf_interp返回的是动态链接器在进程地址空间中的映射的基址,所以在这个基址加上入口地址0x1050后才是动态链接器的入口的真正的运行时地址。计算好动态链接器的入口地址后,内核调用函数start_thread,伪造了用户现场。在进程切换到用户空间时,将跳转到动态链接器的入口处开始执行。

我们看看动态链接器入口地址对应的符号:

5.4.4 加载动态链接器 - 图7

可见,动态链接器的入口是符号_start:

5.4.4 加载动态链接器 - 图8

函数_start调用_dl_start在进行一些自身的必要的准备工作。其中最重要的一点是动态链接器也是一个动态库,其在进程地址空间中的地址也是加载时才确定的,因此动态链接器也需要重定位,我们将在5.4.8节讨论这一过程。

然后,_dl_start调用函数dl_main加载动态库以及重定位工作。其中,加载动态库的过程在5.4.5节讨论,重定位动态库的过程在5.4.6节讨论,有关重定位可执行程序的部分将在5.4.7节讨论。

在完成加载及重定位后,函数_dl_start将返回可执行程序的入口地址。因此,汇编指令从寄存器eax中取出可执行程序的入口地址,并临时保存到寄存器edi。在这段程序的最后,通过指令"jmp*%edi"跳转到可执行程序的入口处开始执行可执行程序。

另外,我们再留意一下上面代码中的标号_dl_start_user。从这个标号处开始,到最后跳转到可执行程序的入口前,动态链接器将调用动态库相关的一些初始化函数。前面在第2章中最后在动态库的初始化部分添加的那个函数,就是在这里执行的。

我们以一个具体的例子看看动态链接器在进程地址空间中映射的情况:

5.4.4 加载动态链接器 - 图9

可见,对于这个进程,动态链接器被映射到进程地址空间从0xb7736000开始的地方,这个就是我们前面提到的动态库在进程地址空间中映射的基址。其中0xb7736000~0xb7756000这个段的权限是"rx",显然这个段应该是代码段和一些只读的数据;0xb7756000~0xb7757000和0xb7757000~0xb7758000都对应的是数据段。但是为什么数据段被划分为两个段?其实不只是动态链接器如此,包括其他动态库和动态链接的可执行程序都是如此,具体原因我们将在5.4.9节讨论。