万书网 > 文学作品 > 深度探索Linux操作系统 > 5.4 进程加载

5.4 进程加载



根据POSIX标准的规定,操作系统创建一个新进程的方式是进程调用操作系统的fork服务,复制当前进程作为一个新的子进程,然后子进程使用操作系统的服务exec运行新的程序。前面,我们看到内核已经静态地创建了一个原始进程,进程1复制这个原始进程,然后加载了用户空间的可执行文件。这一节,我们就来探讨用户进程的加载过程,大致上整个加载过程包括如下几个步骤:

1)内核从磁盘加载可执行程序,建立进程地址空间;

2)如果可执行程序是动态链接的,那么加载动态链接器,并将控制权转交到动态链接器;

3)动态链接器重定位自身;

4)动态链接器加载动态库到进程地址空间;

5)动态链接器重定位动态库、可执行程序,然后跳转到可执行程序的入口处继续执行。

在本节中,我们使用下面的例子探讨用户进程的加载。



我们分别将foo1.c和foo2.c编译为动态库libf1.so和libf2.so,将hello.c编译为一个可执行程序,命令如下:



因为hello要链接当前目录下的动态库libf1.so和libf2.so,所以这里将当前目录添加到了环境变量LD_LIBRARY_PATH中,告诉链接器寻找动态库时,也包括当前工作目录。当然读者也可将这个定义添加到文件.bashrc中,每次登录shell时将自动定义这个变量,避免每次都需要手工进行定义,实现代码如下:



5.4.1 加载可执行程序


一个进程的所有指令和数据并不一定全部要用到,比如某些处理错误的代码。某些错误可能根本不会发生,如果也将这些错误代码加载进内存,就是白白占据内存资源。而且对于某些特别大的程序,如果启动时全部加载进内存,也会使启动时间延长,让用户难以忍受。因此,内核初始加载可执行程序(包括动态库)时,并不将指令和数据真正的加载进内存,而仅仅将指令和数据的“地址”加载进内存,通常我们也将这个过程形象地称为映射。

对于一个程序来说,虽然其可以寻址的空间是整个地址空间,但是这只是个范围而已,就比如某个楼层的房间编号可能是4位的,但是并不意味着这个楼层0000~9999号房间都可用。对于某个进程而言,一般也仅仅使用了地址空间的一部分。那么一个进程如何知道自己使用了哪些虚拟地址呢?这个问题就转化为是谁为进程分配的运行时地址呢?没错,是链接器分配的,那么当然从ELF程序中获取了。所以内核首先将磁盘上ELF文件的地址映射进来。

除了代码段和数据段外,进程运行时还需要创建保存局部变量的栈段(Stack  Segment)以及动态分配的内存的堆段(Heap  Segment),这些段不对应任何具体的文件,所以也被称为匿名映射段(anonymous  map)。对于一个动态链接的程序,还会依赖其他动态库,在进程空间中也需要为这些动态库预留空间。

通过上述的讨论可见,进程的地址空间并不是铁板一块,而是根据不同的功能、权限划分为不同的段。某些地址根本没有对应任何有意义的指令或者数据,所以从程序实现的角度看,内核并没有设计一个数据结构来代表整个地址空间,而是抽象了一个结构体vm_area_struct。进程空间中每个段对应一个vm_area_struct的对象(或者叫实例),这些对象组成了“有效”的进程地址空间。进程运行时,首先需要将这个有效地址空间建立起来。

内核支持多种不同的文件格式,每种不同格式的加载都实现为一个模块。比如,加载ELF格式的模块是binfmt_elf,加载脚本的模块是binfmt_script,它们都在内核的fs目录下。对于每个要加载的文件,内核都读入其文件头部的一部分信息,然后依次调用这些模块提供的函数load_binary根据文件头的信息判断其是否可以加载。前面,initramfs中的init程序是使用shell脚本写的,显然,它是由内核中负责加载脚本的模块binfmt_script加载。模块binfmt_script中的函数指针load_binary指向的具体函数是load_script,代码如下:



linux_binprm是内核设计的一个在加载程序时,临时用来保存一些信息的结构体。其中,buf中保存的就是内核读入的要加载程序的头部。函数load_script首先判断buf,也就是文件的前两个字符是否是“#!”。这就是脚本必须以“#!”开头的原因。

如果要加载的程序是一个脚本,则load_script从字符“#!”后的字符串中解析出解释程序的名字,然后重新组织bprm,以解释程序为目标再次调用函数search_binary_handler,开始寻找加载解释程序的加载器。而脚本文件的名字将被当作解释程序的参数压入栈中。

对于initramfs中的init程序,其是使用shell脚本编写的,所以加载init的过程转变为加载解释程序"/bin/bash"的过程,而init脚本则作为bash程序的一个参数。

可见,脚本的加载,归根结底还是ELF可执行程序的加载。

ELF文件“一人分饰二角”,既作为链接过程的输出,也作为装载过程的输入。在第2章中,我们从链接的角度讨论了ELF文件格式,当时我们看到ELF文件是由若干Section组成的。而为了配合进程的加载,ELF文件中又引入了Segment的概念,每个Segment包含一个或者多个Section。相应于Section有一个Section  Header  Table,ELF文件中也有一个Program  Header  Table描述Segment的信息,如图5-21所示。

图 5-21 ELF文件中的Segment与Section

Program  Header  Table中有多个不同类型的Segment,但是如果仔细观察图5-21,我们会发现,两个类型为LOAD的Segment基本涵盖了整个ELF文件,而一些Section,如"ment"、".symtab"等,包括Section  Header  Table,只是链接时需要,加载时并不需要,所以没有包含到任何Segment中。基本上,这两个类型为LOAD的Segment,在映射到进程地址空间时,一个映射为代码段,一个映射为数据段:

◆  代码段(code  segment)具有读和可执行权限,但是除了保存指令的Section外,一些仅具有只读属性的Section,比如记录解释器名字的".interp",动态符号表".dynsym",以及重定位表".rel.dyn"、".rel.plt",甚至是ELF  Header、Program  Header  Table,也包含到了这个段中。这些是程序加载和重定位时需要的信息,随着讨论的深入,我们慢慢就会理解它们的作用。

◆  数据段(data  segment)具有读写权限,除了典型保存数据的Section外,一些具有读写权限的Section,如GOT表,也包含到这个段中。

除了这两个LOAD类型的Segment外,ELF规范还规定了几个其他的Segment,它们都是辅助加载的。仔细观察Program  Header  Table,我们会发现,其他类型的Segment都包括在LOAD类型的段中。所以,在加载时,内核只需要加载LOAD类型的Segment。

内核中加载ELF可执行文件的代码如下:



1)函数load_elf_binary首先检测文件头部信息,判断是否是ELF类型的文件,包括进一步检测是否是ELF的可执行文件或者动态库等。

2)经过一致性检查,如果确认是ELF可执行文件,load_elf_binary读入Program  Header  Table。

3)load_elf_binary遍历Program  Header  Table,调用函数elf_map映射类型为PT_LOAD的段到进程地址空间。elf_map为每个段创建一个vm_area_struct对象,其第二个参数就是段在进程地址空间中映射的地址,这个地址在编译时链接器就已经分配好了。加载偏移load_bias是用于动态库的,对于可执行文件来说,load_bias值是0。

事实上,除了映射ELF文件中的段到进程地址空间外,内核还创建了其他几个进程运行时必不可少的段,包括BSS、栈和堆三个匿名段,以及为动态库及文件映射预留的内存映射区域,这个区域中一般包含多个段。

(1)栈段

起初,内核将栈安排在用户空间的最顶端,即栈底在0xc0000000。后来为了安全起见,Linux使用了ASLR(Address  Space  Layout  Randomization)技术。ASLR是一种针对缓冲区溢出的安全保护技术,在进程的地址空间中,堆、栈、内存映射等段不再分配固定的地址,而是在每次进程启动时,在原来的位置上加上一个随机的偏移,增加攻击者确定这些段的位置的难度,从而达到阻止溢出攻击的目的。

创建栈段的vm_area_struct对象的代码如下:



函数__bprm_mm_init为栈创建了一个vm_area_struct对象,栈的初始大小是一个页面(PAGE_SIZE),栈底在STACK_TOP_MAX。宏STACK_TOP_MAX的值如下:



其中PAGE_OFFSET的值就是内核在进程空间中的偏移,即0xc0000000,也就是用户空间的最顶端。但是接下来在将参数、环境变量所在的页面映射到新进程的栈空间时,内核对栈段的位置进行了随机化处理,代码如下:



x86架构的函数arch_align_stack的代码如下:



根据其中使用黑体标识的部分可见,栈段的地址被进行了随机处理。另外,注意if条件中的变量randomize_va_space,用户可以通过proc文件系统中的接口改变这个变量,从而可以动态控制内核的这个特性。

在程序运行时,当进行压栈操作时,如果栈空间不足,将引起缺页中断。缺页中断处理函数调用函数expand_stack扩展栈段,代码如下:



(2)BSS段

BSS段保存的是未初始化的数据,所以BSS段并不需要从文件中读取数据,BSS也并不需要映射到文件,故BBS段也是一个匿名映射段。但是注意一点,并不是每个进程都需要创建BSS段。如果程序中根本就没有未初始化数据,那么自然就不需要创建BSS段。或者程序中未初始化数据占据的空间被数据段的对齐部分覆盖,也不需要创建数据段。假设可执行文件中数据段的结束地址为:



按照数据段的页对齐要求,在进程地址空间中对齐后,数据段的结束地址为:



如果从0x804a028到0x804b000之间的这段空间已经覆盖了全部的未初始化数据,那么就不必再创建BSS段了。

函数load_elf_binary中创建BSS段的相关代码如下:



代码第9~20行遍历ELF文件的Program  Header  Table,其中elf_phdr是指向表Program  Header  Table中的Program  Header的指针,elf_ppnt->p_vaddr是段在进程地址空间中的起始地址,elf_ppnt->p_filesz是段在ELF文件中占据的尺寸,elf_ppnt->p_memsz记录的是段在内存中占据的尺寸。

对于ELF可执行程序而言,这个for循环将循环两次,第一次映射代码段,第二次映射数据段。因此,在第二次循环后,第12行代码中的变量k的值是数据段的起始地址(VirtAddr)与数据段(不包含BSS)的大小(FileSiz)的和,并在第15行代码将这个值记录在变量elf_bss中。第17行代码中变量k的值是数据段的起始地址(VirtAddr)与数据段(包含BSS)的大小(MemSiz)的和,并在第19行记录在变量elf_brk中。显然,elf_bss指向不包含BSS数据的数据段的结束位置,而elf_brk指向包含BSS数据的数据段的结束位置。然后,load_elf_binary调用函数set_brk比较elf_bss和elf_brk。看到brk这个词是不是有点似曾相识?没错,brk就是program  break,即代表程序动态申请地址的上限。那么BSS段和brk有关系吗?为什么在映射BSS段时出现了brk?当然有,因为BSS段的末尾就是brk的起始地址。函数set_brk的代码如下:



set_brk对比经过页对齐后的elf_bss和elf_brk。如果对齐后前者不能涵盖后者,则调用函数vm_brk创建单独的BSS段,为BSS段创建一个vm_struct_area对象。

结构体mm_struct中的start_brk用来记录堆段的起始位置,变量brk记录堆段的结束位置。根据函数set_brk的代码可见,初始化时,堆的起始位置和结束位置都是BSS段的结束位置。在程序动态申请内存时,内核再按需扩展堆的大小。

(3)堆段

堆段映射的内存是进程运行时动态分配的,所以在建立进程的地址空间时,只需确定堆段的起始位置即可。根据前面讨论的函数set_brk,初始时,堆的起始位置和结束位置都指向BSS段的结束位置。在进程运行时,根据程序动态申请内存情况动态的调整堆的大小。比如程序调用C库的malloc/free函数动态分配和释放内存时,事实上就是通过内核的系统调用brk/sbrk动态改变堆的大小。

出于安全的原因,堆段也使用了ASLR技术,所以这个位置一般并不紧接在BSS的后面,而是又加了一个随机的偏移,代码如下:



根据上面的代码可见,如果变量randomize_va_space的值大于1,则调用体系结构相关的函数arch_randomize_brk将start_brk和brk调整到一个随机的值。IA32架构中的函数arch_randomize_brk实现如下:



内核在proc中为用户提供了一个接口,允许用户修改变量randomize_va_space,从而可以动态控制内核的这个特性。

(4)内存映射区域

进程空间中还专门留有一个区域用于内存映射,比如文件映射、共享内存等,动态库就映射在这个区域。内存映射区域一般包含多个vm_struct_area对象。比如一个程序依赖多个动态库,那么就会有多个动态库映射到这里。而且即使是同一个动态库,也存在着如代码段、数据段等多个段。

对于x86架构,在2.4版本时,内存映射区域的起始地址是固定的,在内核用户空间的1/3处,即0xc0000000/3=0x40000000。从2.6版本以后,内核将这个区域安排在了栈段的下方。x86架构下确定内存映射区域的基址的函数如下:



在函数arch_pick_mmap_layout中,if代码块中对应的就是内核传统的(2.4版本)确定内存映射区域起始位置的方法,而else代码块对应的则是从2.6版本开始使用的方法。根据代码可见,在2.6版本下,确定这个位置的函数是mmap_base,其代码如下:



根据第3行代码可见,内核取出进程的栈的上限,然后将内存映射区域安排在栈的下方。内核默认进程栈的大小是8MB,但是每个进程都可以通过系统调用ulimit设置进程的各项资源,包括栈的大小。所以在分配内存映射的基址时,内核首先尊重进程的意愿,调用rlimit读取了进程设置的栈的上限。但是,内核可不能由着用户的性子来,毕竟资源有限,内核还要判断用户设置的栈空间是否合理,这就是代码第5~8行的目的。我们看到,内核要求进程空间中,栈的最小尺寸是MIN_GAP,而最大尺寸是MAX_GAP。这两个宏的定义如下:



可见,内核给栈预留的空间最小是128MB,最大是TASK_SIZE/6*5=3GB/6*5=2.5GB。

最后,内核调用函数mmap_rnd计算了一个随机的偏移,加在了内存映射的基址上,见第10行代码。也就是说,内存映射区域,内核也使用了ASLR技术。

综上,进程的地址空间大致如图5-22所示。

图 5-22 进程的地址空间示意图

在图5-22中,进程地址空间中有效的部分使用实线标出,虚线部分是尚未映射的部分。因为数据段可能涵盖了BSS,所以映射BSS的vm_area_struct对象也使用虚线标出,表示在程序映射时,可能并不会建立BSS段。另外,在内存映射区域,图中只示意性地列出了C库和动态链接器中的部分段的映射,其他段的映射并没有列出,所以也使用了一个虚线标出的vm_area_struct对象代表其他的映射。

最后,我们以可执行程序hello为例,具体观察一下进程的地址空间。

首先来看一下hello的Program  Header  Table:



虽然hello的Program  Header  Table中包含了多达9个段,但是正如我们前面谈到的,其中只有类型为LOAD的段才会被映射进内存。hello中包含两个类型为LOAD的段,根据"Flg"一列可见,第一个LOAD类型的段具有读和可执行权限(RE),映射为进程中的代码段;第二个LOAD类型的段具有读写权限(RW),映射为进程中的数据段。事实上,LOAD类型的段已经涵盖了全部ELF文件中需要加载进内存的部分。其他几个段完全是为了辅助加载用的,要么包含在代码段中,要么包含在数据段中。

代码段的起始地址是0x08048000,结束地址是0x08048000+0x0079c=0x804879c。根据列"Align"可见代码段要求4KB对齐,起始地址已经是4KB对齐的,无须调整,而结束地址则需要从0x804879c调整为0x8049000。

数据段的起始地址是0x08049f00,结束地址是0x08049f00+0x0012c=0x0804a02c。根据列"Align"可见数据段也要求4KB对齐,所以数据段的起始地址需要调整为0x08049000,结束地址需要调整为0x0804b000。hello的BSS段为8244字节(0x02160~0x0012c),显然数据段对齐的那部分已经不能涵盖未初始化数据了,因此需要创建一个单独的BSS段。

下面我们将hello程序运行起来,结合前面的理论分析,实际观察一下其进程地址空间的映射。



根据输出可见:

1)地址范围0x08048000~0x08049000具有读和可执行权限,显然就是进程的代码段。

2)地址范围0x0804a000~0x0804b000具有读写权限,是进程的数据段。

3)在代码段和数据段之间映射了一个只读的段:0x08049000~0x0804a000。虽然这个段是读的,但是广义上其属于数据段,这个只不过是从数据段中划分的一个子段,称为段RELRO,读者可暂不关心,我们在5.4.9节会讨论进程空间映射这个段的缘由。

4)地址范围0x0804b000~0x0804d000具有读写权限,而且是个匿名映射段,紧接在数据段后面,而且正好占据8KB大小的空间,我想读者已经猜到了,其就是保存程序hello中未初始化的全局数组a[2048]的BSS段。读者可以做个实验,去掉程序hello.c中的这个数组,然后重新编译运行,就可发现hello进程将无需再映射单独的BSS段。

5)地址范围0x0985e000~0x0987f000具有读写权限,显然也是保存数据的,根据后面的字串"heap",读者应该可以猜到,这个段是hello进程的堆段。读者也可作个实验,去掉程序hello.c中使用mallo动态分配的1024个字节,然后重新编译运行,就可发现堆段也将不再被映射。前面我们谈到过,堆是程序运行时动态分配的,所以如果程序中尚未在堆中申请变量,内核将不会主动为进程映射堆段,只是首先确定好堆的基址,在需要时按需动态映射。

6)接下来,就是一个大的内存映射区域了:0xb75a4000~0xb7790000,在这个区域中,映射了C库、动态链接器以及动态库libf1和libf2。对于每个动态库来说,其映射过程与可执行程序并无本质差别,仔细观察,可以发现,每个动态库也有自己的代码段、数据段等,其具体映射过程我们在加载动态库一节再讨论。

7)进程空间中最后映射的一个段:0xbf92a000~0xbf94b000,具有读写权限,也是保存数据的,根据后面输出字串"stack"可知,这个段就是栈段。

最后,我们留意栈段的起始地址:0xbf94b000。理论上,栈底应该在用户空间的最顶端,即0xc0000000,但是为什么不是这个地址呢?请读者回想一下我们前面谈到的ASLR技术,栈底在0xc0000000的基础上减去了一个随机的偏移。

与此相仿的还有堆段和内存映射部分。读者可以做个实验,多次启动hello程序,你会发现,每次这几个段的起始地址全都不同。每次进程启动时,都会在原来的理论地址上,再加了一个随机的偏移。

内核在proc文件系统中为用户提供了一个接口,允许用户动态控制是否使用ASLR技术。这个接口可以接收3个参数:0代表关闭ASLR技术;1代表内存映射区域、栈和vdso段的起始地址是随机的;2表示堆段起始地址也是随机的。以笔者的机器为例,其默认值为2:



读者可以采用下面的方法,关闭ASLR:



然后,即使多次启动同一个程序,但是这几个段的地址也不再随机变动了。

内核还保留了2.4版本的分配内存映射区域的方法,用户也可以通过下面的方法使用传统的方法:



使用下面的命令关闭传统的内存映射机制:



限于篇幅,我们就不再一一列出开启和关闭这些参数后,进程空间的映射情况,读者可自行进行这些非常有趣的实验。



5.4.2 进程的投入运行

丑媳妇总是要见公婆的,进程最终一定是要切换到用户空间的,进程1也躲不过去。在内核创建进程1时,进程0是当前进程,因此,进程1要“回到”用户空间,需要经过两个步骤:

1)要将进程0赶出CPU。也就是在内核空间,进程1要“恢复”为当前进程,这是进程1“返回”用户空间的前提条件。

2)进程1从内核空间“回到”用户空间。

显然,从进程0“恢复”到进程1需要进程1在内核空间的现场;进程1从内核空间“回到”用户空间需要进程1在用户空间的现场。但是,事实上,不仅是进程1,对于所有刚刚创建的进程,它们并没有经历过从用户空间切到内核空间,然后在内核空间被其他进程抢占的过程,哪里来的保护现场?所以,就需要操作系统助它们一臂之力,人为地为新创建的进程伪造现场。

这一节,我们首先来看看在这两次转换过程中,保护现场的原理。然后,我们再来讨论内核是如何在原理的指引下伪造这两个现场的。事实上,不仅进程1如此,其他进程也如此,这里的讨论适用于所有进程。

1.用户现场的保护

我们通过讨论一个进程从用户空间切换到内核空间来观察用户现场是如何保护的。

(1)从用户栈切换到内核栈

当一个进程正在用户空间运行时,一旦发生中断,那么进程将从用户空间切换到内核空间运行。进程在内核空间运行时,CPU各个寄存器同样将被使用,因此,为了在处理完中断后,程序可以在用户空间的中断处得以继续执行,需要在穿越的一刻保护这些寄存器的值,以免被覆盖,即所谓的保护现场。Linux使用进程的内核栈保存进程的用户现场。因此,在中断时,CPU做的第一件事就是将栈从用户栈切换到内核栈,如图5-23所示。

图 5-23 从用户栈切换到内核栈

Intel从硬件层面设计了TSS段(task-state  segment)支持任务的管理,其中记录了任务的状态信息。既然是一个段,每个TSS也在GDT中占据一个表项。如同代码段将段选择子保存在寄存器CS中,CPU要求将TSS段选择子保存在专用寄存器TR(Task  Register)中。

Intel建议为每一个进程准备一个独立的TSS段,每当任务切换时,更换CPU中TR寄存器指向当前任务的TSS段。天下没有免费的午餐,方便的代价就是效率的低下。而事实上,任务切换时,本不必保存如TSS中包含的如此复杂的上下文,而且TR寄存器的格式比较特殊,远非直接装入一个地址那么简单,还需要进行一些计算,因此切换TR的代价也比较大。因此,Linux并没有使用TSS段保存任务状态信息。

但是当中断发生时,CPU自动从任务寄存器TR中找到TSS段,然后从该段中装载ss和esp。“我的地盘听我的”,所以Linux还要遵从Intel的“霸王”条款,必须得使用TSS。但是Linux处理得比较巧妙,其并没有为每个任务设计一个TSS段,而是为每个CPU准备一个TSS段。内核只是在初始化阶段设置TR,使之指向一个TSS段,从此以后永不改变TR的内容。TSS段的初始内容如下:



内核初始化时,在函数cpu_init中初始化了TR寄存器,代码如下:



其中,函数set_tss_desc设置GDT中的TSS段的描述符,函数load_TR_desc设置TR寄存器。

虽然TSS不需要切换了,但是TSS中的ss和sp0却需要随着任务的切换,走马灯式地更换,记录下一个投入运行的任务的内核栈的ss和sp0。这样,就保证了在中断发生时,CPU可以正确地从TSS中加载当前进程内核栈的ss和esp。在宏INIT_TSS中,TSS段中记录的内核栈的ss0被设置为__KERNEL_DS,所以这里ss0永远不需要改变,只需切换sp0即可。可见,TSS是“铁打的营盘,流水的兵”。

但是不知道读者思考过没有,当中断发生时,当CPU从TSS段中加载ss0和sp0分别到ss和esp时,尚未保存用户现场,那么此时保存在ss和esp中的用户栈的信息岂不是被覆盖了?Intel的工程师们当然清楚这一点,事实上,CPU在加载内核栈信息前,会将寄存器ss和esp中的值首先临时保存到CPU内部,除了保存寄存器ss和esp的值外,CPU临时保存的还包括寄存器eflags、cs、eip中的值。

经过这一步后,进程已经完成了栈的切换,进程在向内核空间前进的道路上迈出了第一步。

(2)保存用户空间的现场

切换完栈后,CPU在进程的内核栈中保存了进程在用户空间执行时的现场信息,包括eflags、cs、eip、ss和esp,如图5-24所示。

图 5-24 保存用户空间的现场

在进程退出内核空间时,中段处理函数最后会调用x86的指令iret将CPU压入的这几个值恢复到对应的寄存器中。

(3)穿越中断门

接下来,进程就将进行最后的穿越了,当然,内核在初始化时就已经为CPU初始化了中断相关的部分,代码如下:



代码第6~26行初始化中断描述符表idt_table,其包含256项,每一项均是一个64位的描述符。CPU运行过程中,可能有多种情况需要中断正在执行的指令,转而先去处理中断。包括外部设备来的信号,或者是执行指令时发生了异常,如发生了除数是0的情况。因此,中断描述符表中包括几种不同的描述符,但是大同小异。这些描述符也被称为门描述符(gate  descriptor),以中断门为例,其格式如图5-25所示。

图 5-25 中断门格式

对于图5-25,重点关注其中两个字段,一个是"Segment  Selector",这个是段选择子,也就是对应这个中断的处理函数所在的段;另外一个是"Offset",其表示的是中断处理函数在段内的偏移。因为Linux使用的是平坦内存模式,段基址为0,所以实际上这个段内偏移就是中断处理函数的地址。

上面代码中包含两个loop循环,填充了256项中断描述符。每个门的段选择子都是__KERNEL_CS,只有中断处理函数不同。前NUM_EXCEPTION_VECTORS项对应的中断处理函数是early_idt_handlers,其余项的中断处理函数是ignore_int。这两个函数都是内核初始化早期的临时中断处理函数,在内核建立好基本环境后,会使用真正的中断处理函数替换这些临时的,代码如下:



中断描述符表构建完成后,内核还需要将其地址告诉CPU,CPU中为此设计了一个专用寄存器idtr。除了中断描述表的地址外,当然还需要将这个表长度也载入这个寄存器。x86设计了指令lidt来加载idtr寄存器,见函数startup_32代码中的第3行。

了解了中断门的数据结构后,我们就很容易理解在穿越中断门的一刹那,CPU的所作所为了。CPU首先将根据寄存器IDTR,找到中断描述符表。然后以中断向量作为下标,在中断描述符表中找到对应的门,CPU将其中的段选择子加载到寄存器cs,将其中的偏移地址加载到寄存器eip,如图5-26所示。

图 5-26 穿越中断门

经过这一步后,进程彻底的穿越到了内核空间。

因为Linux没有使用TSS,所以除了CPU自动将cs、eip等几个寄存器压入栈外,中断处理函数需要将其他的寄存器也压入内核栈,保存进程完整的用户空间的现场。在进程退出内核空间时,中段处理函数负责将其压入栈的这些值再恢复到相应的寄存器中。

2.内核现场的保护

当进程在内核空间运行时,在发生进程切换时,依然需要保护切换走的进程的现场,这是其下次运行的起点。那么进程的内核现场保存在哪里合适呢?前面我们看到进程的用户现场保存在进程的内核栈,那么进程的内核现场当然也可以保存在进程的内核栈。

但是,当调度函数准备切换到下一个进程时,下一个进程的内核栈的栈指针从何而来?在前面讨论进程从用户空间切换到内核空间时,我们看到,CPU从进程的TSS段中获取内核栈的栈指针。那么当在内核空间发生切换时,调度函数如何找到准备切入进程的内核栈的栈指针?

除了进程的内核栈外,进程在内核中始终存在另外一个数据结构——进程的户口,即任务结构。因此,进程的内核栈的栈指针可以保存在进程的任务结构中。在任务结构中,特意抽象了一个结构体thread_struct来保存进程的内核栈的栈指针、返回地址等关键信息。

调度函数使用宏switch_to切换进程,我们来仔细观察以下这段代码,为了看起来更清晰,删除了代码中的注释:



在每次进程切换时,调度函数将准备切出的进程的寄存器esp中的值保存在其任务结构中,见第5行代码。然后从下一个投入运行的进程的任务结构中恢复esp,见第6行代码。除了栈指针外,程序下一次恢复运行时的地址也有一点点复杂,不仅仅是简单的保存eip中的值,有一些复杂情况需要考虑,比如稍后我们会看到对于新创建的进程,其恢复运行的地址的设置。所以调度函数也将eip保存到了任务结构中,第7行代码就是保存被切出进程下次恢复时的运行地址。第8行代码和第10行的jmp,以及函数__switch_to最后的ret指令联手将投入运行的进程的地址,即next->thread.ip,恢复到寄存器eip中。

除了eip、esp外,宏switch_to将其他寄存器如eflags、ebp等保存到了进程内核栈中。

每次中断时,CPU会从TSS段中取出当前进程的内核栈的栈指针,因此,当发生任务切换时,TSS段中的esp0的值也要更新为下一个投入运行任务的内核栈的栈指针。在宏switch_to中,即上面第10行代码处,调用函数__switch_to的目的就是设置TSS段中的esp0:



综上,进程在内核中的切换过程如图5-27所示,其中next表示即将投入运行的任务,prev表示当前任务,但是马上将被切出。被切出进程下一次恢复运行时的地址并不一定是就是当前指令指针中的地址,所以图中eip使用了虚线,其表达的意图就是进程恢复运行时的地址也保存在了进程的任务结构中。

图 5-27 内核中的进程切换

3.伪造现场

我们先来看看内核是如何伪造内核现场的。其实伪造内核现场只需要伪造三个关键的地方:进程恢复运行时的地址,即eip;进程的内核栈的栈指针,这个无需解释了,进程当然需要知道目前的栈顶在哪里;最后还要准备内核栈的栈底,为了日后在进程返回到用户空间后,发生中断时,CPU可以找到内核栈,从内核栈的栈底开始保存用户现场。

根据前面的讨论,如图5-27,在调度器调度下一个进程投入运行时,即宏switch_to中,将从下一个投入运行的进程的任务结构的thread_struct中加载sp到寄存器esp;加载ip到寄存器eip;并在这个宏调用的函数__switch_to中,将TSS段中的sp0指向thread_struct中的sp0。因此,要伪造这三个数据,只需设置任务结构中的结构体thread_struct中下面几个值即可:



进程的任务结构在复制进程时创建,因此,这几个数据在复制进程时伪造是再合适不过了。复制进程时,与结构体thread_struct相关的复制函数为copy_thread,其代码如下:



先来看一下结构体pt_regs,这个结构体就是为了解释内核栈底部保存的进程的用户现场而设计的,其中的字段完全按照压栈的各个寄存器的顺序设计。第3行代码中的宏task_pt_regs就是获取内核栈中pt_regs的,并使用childregs指向这个区域。

显然,第5行代码是在伪造栈指针。第6行代码是在为TSS段伪造内核栈的栈底。但是这两个变量的值可能让人有些困惑,我们通过图5-28来直观展示一下。

图 5-28 进程内核栈

根据图5-28可见,childregs是进程在内核态运行时使用的内核栈的栈底。childregs+1是在childregs的基础上,向地址增大方向偏移一个pt_regs的大小。也就是说,从childregs到childregs+1正是给伪造用户现场预留的空间。

函数copy_thread中的第10行和第14行都是在为新复制的进程伪造返回时的运行地址。只不过第10行是针对内核线程的,从进程0复制进程就属于这种情况。函数ret_from_kernel_thread与ret_from_fork唯一的不同是,ret_from_kernel_thread在返回到用户空间前,其将执行一个函数,然后才恢复进程的用户现场,具体如下:



PT_EBX(%esp)就是pt_regs中寄存器ebx处的值,显然,这个值是一个函数地址。那么,这个新复制的进程,在返回用户空间之前到底执行了一个什么函数呢?在函数copy_thread伪造eip时,其实已经设置了寄存器ebx的值:



寄存器ebx中保存的是函数copy_thread的第2个参数sp,我们再来看看这个参数是什么:



我们来回顾一下进程1的创建过程:



可见,寄存器ebx中保存的是函数kernel_init的地址。而恰恰是kernel_init,开启了创建进程的第二阶段,即exec的过程。也就是说,在复制进程后,在返回用户空间之前,内核开启了exec过程,加载可执行程序。与我们编写普通程序时,在复制之后,使用exec执行新的程序异曲同工。

显然,在加载可执行程序之后,是一个伪造用户现场的合理时机。因为,只有这个时候,才知道新执行的程序的入口地址,也就是进程切换到用户空间后的执行地址。而且,也只有在进程的地址空间建立好之后,才能知道进程的用户栈的位置。相关代码如下:



在加载了可执行文件后,函数load_elf_binary确定了进程在用户空间的入口elf_entry,也确定了进程的用户栈所在的位置bprm->p,然后调用函数start_thread伪造进程的用户空间的现场,代码如下:



在函数start_thread中,各个段寄存器均被设置为了用户空间的段,进程的栈也指向了用户空间的栈。于是,在ret_from_kernel_thread最后,从进程的内核栈恢复用户现场时,进程被彻底打回原形,从天上掉到了人间,进程又作回了凡人,在用户空间从入口地址elf_entry处开始执行。



5.4.3 按需载入指令和数据

在建立进程的地址空间时,我们看到,内核仅仅是将地址映射进来,没有加载任何实际指令和数据到内存中。这主要还是出于效率的考虑,一个进程的所有指令和数据并不一定全部要用到,比如某些处理错误的代码。某些错误可能根本不会发生,如果也将这些错误代码加载进内存,就是白白占据内存资源。而且特别对于某些大型程序,如果启动时全部加载进内存,也会使启动时间延长,让用户难以忍受。所以,在实际需要这些指令和数据时,内核才会通过缺页中断处理函数将指令和数据从文件按需加载进内存。这一节,我们就来具体讨论这一过程。

1.获取引起缺页异常的地址

IA32架构的缺页中断的处理函数do_page_fault调用函数__do_page_fault处理缺页中断,相关代码如下:



在发生缺页中断时,寄存器CR2中保存的是引起缺页中断的线性地址。因此,第4行代码首先到寄存器CR2中读取线性地址。然后,函数__do_page_fault检查这个地址的合法性,判断条件就是这个地址是否在某个vma的范围内。注意这里的函数find_vma,它从映射进程地址空间底部的vm_area_struct对象开始遍历,当找到第一个结束地址恰好要大于这个异常地址、但是却是最接近这个异常地址的vm_area_struct对象时,就将这个对象返回。如果找不到这样一个vm_area_struct对象,那么就说明这个地址是非法的,内核将向进程发送SIGSEGV信号,这是程序运行时出现segment  fault的原因之一,见代码第6~10行。

如果找到了一个vm_area_struct对象,并且引起异常的这个地址也大于这个vm_area_struct对象的起始地址,那么就说明这个地址恰好在其涵盖的范围内,直接跳转到标号good_area处,见代码第11~12行。

但是,假如引起异常的这个地址小于vm_area_struct对象的起始地址,那也不能一棍子打死,我们还要给它一次机会。在前面讨论栈段的创建时,我们已经看到,内核初始只为进程分配了一个页面大小的空间,因此,其完全有可能是一次压栈操作,但是栈空间尚未映射具体的物理地址,如图5-29所示。

图 5-29 栈异常

那么如何判断这个vm_area_struct对象是否是代码的栈段呢?那就要看其成员vm_flags中是否设置了VM_GROWSDOWN这个标志。如果这个vm_area_struct对象是栈段,则首先对栈进行扩展。代码第13~21行就是处理这种特殊情况的。

2.更新页表

在复制子进程时,子进程也需要复制或者共享父进程的页表。如果没有页表,子进程寸步难行,指令或者数据的地址根本没有办法映射到物理地址,更不用提从物理内存读取指令了。当子进程替换(exec)为一个新的程序时,无论子进程是共享或者复制了父进程的页表,子进程都需要创建新的页表。创建页表的函数如下:



函数pgd_alloc申请了一个物理内存页面,然后调用函数pgd_ctor将存储在swapper_pg_dir处的页目录中的映射内核空间的页目录项复制过来。我们看到,这里没有复制映射用户空间的页目录项,而且也不需要复制,因为用户空间需要映射到一个新的程序。于是,页目录中映射用户空间的这些页目录项的自然为空,更不用提那些还没有影儿的页表了。当访问地址落在这些空的页目录项映射范围内时,自然就引发了缺页异常。那么在缺页异常处理函数中,自然就需要分配页面、分配页表、更新页目录、更新页表项等。

为了可以映射更大的地址空间,Linux中使用多个级别的页面映射。因此,我们先来理解一下内核中页表的管理。比如缺页异常处理函数调用的函数handle_mm_fault,代码如下:



从上述代码中我们看到了pgd、pud、pmd和pte。读者应该可以猜出来,内核使用了4级页表机制。但是这4级页表是如何与物理上的页表结合的呢?我们以没有开启PAE的IA32架构为例,来探讨这一过程。

对于没有启用PAE的IA32,其映射的地址空间为4GB,所以理论上内核使用与IA32物理上相同的2级页表就足够了。但是为了代码的一致性,内核依然保留了pud和pmd等概念。只不过通过一些巧妙的定义,绕过了中间的pud和pmd。当配置内核时,如果未开启支持IA32的PAE特性,内核实质上将使用2级页表。内核使用了文件pgtable-nopud.h和pgtable-nopmd.h中有关pud和pmd的一些定义:



从文件名字我们就已经看出了内核的意图,就是要屏蔽pud和pmd层。下面,我们就结合这两个文件中的定义,来看看内核是如何绕过pud和pmd的。以函数handle_mm_fault中使用的函数pud_alloc为例:



在文件pgtable-nopud.h中函数pgd_none的定义为:



也就是说,函数pgd_none永远返回0,那么pud_alloc中的函数__pud_alloc就不需要执行了,而且pud_alloc返回的值就是函数pud_offset的返回值,这个函数当然也对应的是文件pgtable-nopud.h中的实现:



看到函数pud_offset的实现,我想读者一定会恍然大悟。显然,pud就是pgd。pmd_alloc亦是如此处理的,读者可以仿照上面的方法自行查看。也就是说在使用了2级页表的情况下,pud和pmd就像透明的空气,根本不存在,内核绕了一个圈后又回到原点。虽然代码中还有所谓的pud、pmd等,但是所谓的pud和pmd都是pgd。

读者亦不要被诸如pgd_t、pud_t、pmd_t等这些封装的数据类型所迷惑,说白了,它们就是一些表项中的值。再直白一点,就是一个整数,或者至多是个整数的数组而已。这里之所以使用了一个结构体将这些整数封装起来,就是为了屏蔽这些表项的细节,避免日后的改动影响更多的代码。

了解了内核的页表管理机制后,下面我们就来具体看一下函数handle_mm_fault。



函数handle_mm_fault首先确定引起异常的地址是由哪个页目录项映射的,见第9行代码。

在确定了页目录项pgd后,如前面讨论,我们可以无视第10~13行关于pud和pmd的部分。后面的pud和pmd都是pgd。

确定了页目录项后,第15行代码就要判断页目录项是否为空。如果页目录项为空,显然还要分配页表。前面我们已经看到,对于一个新加载的程序,页目录表中映射用户空间的页目录项是空的。所以pmd_none的值一定是True。进而继续执行函数__pte_alloc分配页表,代码如下:



函数__pte_alloc首先调用pte_alloc_one分配了一个物理页面承载页表,然后调用函数pmd_populate,将这个页表的地址填充进页目录表中对应的表项。这里,对于使用2级页表的情况,pmd表就是页目录表,所以函数pmd_populate也不是在填充什么pmd表,而是填充页目录表。

3.从文件载入指令和数据

页表准备就绪后,handle_mm_fault最后准备载入指令和数据了:



handle_mm_fault首先调用函数pte_offset_map取得引起异常的地址在页表中的页表项。毫无疑问,这个页表项pte也是空的。然后handle_mm_fault调用函数handle_pte_fault从文件载入指令和数据:



handle_pte_fault首先调用pte_present检查这个pte是否存在,见第9行代码。

如果不存在,那么可能有两种情况:第一种情况是页面是首次访问,也就是这个页表项是彻底的空的,而不仅仅是没有置位present,在这种情况下,需要建立页面映射,见代码第10~17行;第二种情况是页面映射已经建立了,只不过是目前交换出内存了,即代码第18~20行处理的情况。我们只讨论第一种情况。
对于第一种情况,也存在两种情况:一种是如果vm_area_struct对象中的成员vm_ops存在,并且vm_ops中提供了函数fault,那么说明段是映射到文件的,见代码第11~15行;否则是匿名映射。这里我们只讨论典型的从文件加载的情况,代码如下:



do_linear_fault这个函数非看似简单,仅一条计算指令,计算了变量pgoff的值,然后就将后续处理丢给了函数__do_fault。但是小计算大智慧,不要小看这条计算指令,它计算出的pgoff是从文件载入指令和数据的关键。

我们知道,每次从文件加载指令或者数据时,都是以页面为单位的,所以我们可以将文件想象为多个连续的页面。那么如何确定引起异常的这个地址对应于文件中的哪个页面呢?

事实上,当从文件中将段映射到进程地址空间时,创建的段的vm_area_struct对象中的成员vm_pgoff已经记录了段在文件中的偏移,而且是以页为单位的。一个段可以占据一个或者多个页面。

当发生缺页异常时,虽然不能确定引起异常的地址是在文件中的哪一个页面,但是可以计算出这个地址相对于段的起始地址的差值。将这个差值转换为以页为单位,再加上段在文件中的偏移,即可确定这个地址在文件中的哪个页面上。

我们用图5-30来更直观地表示一下这个过程。图5-30表示数据段及其相应的vm_area_struct对象,其中使用虚线框起来的页是数据段所映射的范围。

图 5-30 异常地址到页面的计算

我们以下面程序中变量g_a为例,具体体验一下这个偏移的计算过程。



为了更具有代表性,我们使用静态链接,这样编译出的可执行文件尺寸大一点,页面偏移可以多一点。



(1)段在文件的偏移(vm_pgoff)

因为变量g_a在数据段,所以我们看看数据段在可执行文件中的偏移:



我们看到数据段在文件中的偏移是0x0a5fa4,按照页面对齐后,数据段应该从文件中偏移0x0a5000处开始映射。而0x0a5000/0x1000=165,也就是说,数据段映射的文件的起始位置是第165个页。

(2)引起异常的地址在段内的偏移

一个段可能会映射到文件中的多个页,所以我们还要计算具体的地址在段内的偏移(以页为单位)。



变量g_a的地址为0x080ef068。因为映射是以页为单位的,所以这个地址应该包含在从0x080ef000到0x080f0000一个页面中。因此,使用地址0x080ef000与段的起始地址0x080ee000做差,从而得出这个地址所在页在段内的偏移:



即偏移一个页。也就是说,在段在文件中的偏移的基础上,再偏移一个页就可以了,即载入文件第166(165+1)页的数据到内存。

根据上面的讨论可见,do_linear_fault这个函数的主要目的正如同其名字一样,是处理这个线性的缺页异常地址,将其从线性地址转换为相应的页单元。偏移这个参数准备好了,我们继续往下看__do_fault。



函数__do_fault调用具体文件系统中的fault函数,将所需的文件数据读入到内存。至于读入哪个页面,当然要使用刚刚计算的pgoff了。以ext4文件系统为例,其为vma提供的vm_operations_struct如下:



ext4文件系统中的filemap_fault将指定偏移处的页面读入内存,其中参数vmf中的page就是指向从文件载入的页面。

载入页面后,还有最后一步要做:更新页表项。函数__do_fault锁定页表中映射这个页面的页表项,然后调用函数mk_pte创建页表项的值,最后调用set_pte_at将页表项的值填充到页表中对应的页表项。



5.4.4 加载动态链接器

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

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

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



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

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



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



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



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



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

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



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



函数_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章中最后在动态库的初始化部分添加的那个函数,就是在这里执行的。

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



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



5.4.5 加载动态库

加载动态库前,首先需要知道这个可执行程序依赖的动态库,当然也包括这些动态库依赖的动态库,因此,这是一个递归的过程。那么动态链接器是如何知道这些依赖的动态库呢?动态链接器不是一个人在战斗,在编译时,链接器已经为动态链接做了很多铺垫,其中之一就是在ELF文件中创建了一个段".dynamic",保存的全部是与动态链接相关的信息。

我们观察一下可执行程序hello中的段".dynamic":



段".dynamic"中记录了多组与动态库有关的信息,每一组信息都使用如下格式保存:



可见,每组信息使用的是tag/value的形式保存,只不过value有的是个整数值,有的是地址而已。

其中类型(Type)为"NEEDED"的项记录的就是可执行程序依赖的动态库。可以看到,hello依赖动态库libc.so.6和libf1.so。

动态链接器设计了一个数据结构来代表每个加载到内存的动态库(包括可执行程序),定义如下:



这个数据结构中记录了动态库重定位需要的关键两项信息:l_addr和l_ld。l_addr记录的是动态库在进程地址空间中映射的基址,有了这个参照,动态链接器才可以修订符号的运行时地址;l_ld指向动态库的段".dynamic",通过这个参数,动态链接器可以知道一切与动态重定位相关的信息。为了方便,结构体link_map中定义了一个数组l_info,将段".dynamic"中的信息记录在这个数组中,就不必每次使用时再去重新解析".dynamic"了。

当内核将控制权转交给动态链接器时,链接器首先为即将处理的可执行程序创建一个link_map对象,在动态链接器代码中将其命名为main_map。然后,动态链接器找到这个可执行程序依赖的动态库,当然也包括其依赖的动态库也依赖的动态库,依次链接在main_map的后面,形成一个link_map对象链表。动态链接器作为动态库依赖的一个动态库,自然也包含在这个链表中。沿着这个链表,动态链接器将动态库映射进进程地址空间,并进行重定位。

函数dl_main调用_dl_map_object_deps加载可执行程序依赖的所有动态库,代码如下:



函数dl_main给函数_dl_map_object_deps传递了一个参数main_map,前面提到过这个参数,就是可执行程序的link_map对象。函数_dl_map_object_deps遍历可执行程序依赖的所有动态库,对每一个动态库调用函数_dl_map_object将这些动态库全部映射到进程的地址空间,代码如下:



因为动态库可能已经被加载到内存了,所以_dl_map_object首先从已经映射的动态库中寻找。如果没有找到,则调用函数_dl_map_object_from_fd从文件系统加载,代码如下:



_dl_map_object_from_fd调用函数__mmap映射文件中的段到进程地址空间,并将映射基址记录到link_map中的l_addr。至于函数__mmap,读者应该已经隐隐猜出来了,没错,这个函数就是我们编写应用程序时使用的C库的函数mmap,只不过这是在C库内部调用,所以函数名称略有差别,其实现如下:



_mmap首先将系统调用号__NR_mmap2装入寄存器eax,然后就向内核请求服务。这里请求内核服务时之所以没有使用0x80中断,原因是为了在支持快速系统调用(Fast  System  Call)指令sysenter/sysexit的CPU上使用这些比0x80中断更优化的系统调用指令。

在映射了程序段后,函数_dl_map_object_from_fd调用了函数__mprotect设置段的读、写以及可执行权限,__mprotect使用的是内核调用号为__NR_mprotect的服务。

我们看到,动态链接器并没有发明什么新的魔法,它只是使用内核提供的系统调用将动态库映射到进程的地址空间。也就是说,虽然动态库是由动态链接器在用户空间进程映射的,但是本质上的映射动作还是由内核完成的。

最后,_dl_map_object_from_fd将link_map中的成员l_ld指向了段".dynamic"所在的位置:



函数_dl_map_object_from_fd从Program  Header  Table中取出类型为"DYNAMIC"的段的地址,然后再加上动态库的映射基址。

最后,我们结合图5-31来直观地看一下多个进程是如何共享一个动态库的。

图 5-31 动态库的映射

最初,当共享库映射进内存后,代码段和数据段在物理内存中分别都只有一份副本,并且都是只读的,进程A和进程B共享只读的代码段和数据段。在进程运行过程中,当任一个进程试图修改数据段时,则内核将为这个进程复制一份私有的数据段的副本,而且这个数据段的权限被设置为可读写的。这里使用的策略就是所谓的写时复制(COW,Copy  On  Write)。但是这个复制动作不会影响进程的地址空间,对进程是透明的,只是同一段地址通过页面表映射到不同的物理页面而已。



5.4.6 重定位动态库

动态库在编译时,链接器并不知道最后被加载的位置,所以在编译时,共享库的地址是相对于0分配的。以动态库libf1.so为例:



根据动态库libf1.so的Program  Header  Table,注意列VirtAddr,显然地址是从0开始分配的。因此,在映射到具体进程的地址空间后,需要修订其中那些通过绝对方式引用的符号的地址,代码如下:



函数dl_main从main_map开始,调用函数_dl_relocate_object重定位link_map链表中的所有动态库和可执行程序,顺序是从后向前。如果有符号重定义了,那么后面发现的符号的地址将覆盖掉前面的符号地址。换句话说,链接时排在前面的动态库中的符号将被优先使用。另外,还有一点要注意,这个列表中的动态链接器将不再需要重定位,因为其已经在前面自己重定位好了。

常用的重定位方式有两种:加载时重定位(Load-time  relocation)和PIC方式。

加载时重定位与编译时的重定位非常相似。动态链接器在加载动态库后,遍历动态库的重定位表,对于重定位表中的每一项记录,解析这个记录中指明的符号的地址,然后使用解析到的地址修订这个记录中指定的偏移处,当然这个偏移需要加上动态库映射的基址。

但是动态库是多个进程共享的,不同的进程映射的动态库的地址不同,因此,如果某个进程按照动态库在自己进程空间中映射的基址修改了动态库的代码段,那么这个动态库的显然就不能被其他进程所共享了,除非所有的进程映射动态库的位置相同,但是这又带来太多的限制和问题。

基于以上原因,开发者们又设计了另外一种方式——PIC(Position-Independent  Code)。PIC基于两个关键的事实:

◆  数据段是可写的。既然代码段是不能更改的,但是数据段总是可以更改的。于是PIC把重定位战场从代码段转移到数据段,在数据段中增加了一个GOT(GLOBAL  OFFSET  TABLE)表。在编译时,链接器将所有需要重定位的符号在这个表中分配一项,其中记录了符号以及其实际所在的地址。重定位时,动态链接器只修改GOT表中的值。

◆  代码和数据的相对位置不变。在代码中凡是引用GOT表中的符号,只需要找到GOT表的地址,再加上变量在GOT表中的偏移即可。但是,如此还是没有避开代码段被修改的命运,因为动态库在进程地址空间中的位置只有在加载时才能确定,所以,GOT表的地址在加载时也需要重定位。但是,我们也注意到这样一个事实:对动态库来说,虽然其映射的地址在编译时不确定,但是在映射到进程的地址空间时,代码段和数据段依然按照编译时分配好的地址映射,也就是说,指令和数据的相对位置却是固定的。因此,GOT表作为数据段中的一员,代码段中的任一指令与GOT表基址之间的偏移是固定的,在编译时就可以确定。PIC恰恰是基于这个事实,在代码中凡是访问GOT表的地方,都是使用这个固定的相对偏移来引用GOT表以及其中的变量,因此,代码中引用GOT表的地址不再需要重定位,从而避开了代码段被修改的问题。接下来我们结合具体的实例进一步解释这个过程。

1.GOT表

显然,PIC技术中,GOT表是一个非常重要的数据结构,在继续深入探讨前,我们先来认识一下这个数据结构,如图5-32所示。

图 5-32 GOT表

由图5-32可见,这么大名鼎鼎的GOT表却如此简单,其就是一个一维数组。对于32位CPU来说,每个数组元素就是32位的地址。GOT表分成两个部分:.got和.got.plt。.got中存储的是变量的地址。.got.plt中存储的是函数的地址。在5.4.9节中我们将讨论GOT表一分为二的原因。

在编译时,链接器将定义一个符号_GLOBAL_OFFSET_TABLE_,指向.got和.got.plt的连接处,凡是访问GOT表中的地址时,都使用基于这个符号的偏移。比如,访问变量var  1,那么使用:



访问函数func1则使用:



GOT表中除了记录变量和函数的地址外,还有另外三个特殊的表项,我们在图5-32中也已经标出,它们就是.got.plt的前三项。其中第1项记录的是动态库或者可执行文件的.dynamic段的地址;第2项记录的是代表动态库或者可执行文件的link_map对象;第3项记录的是动态链接器提供的解析符号地址的函数_dl_runtime_resolve的地址。我们以动态库libf1.so为例,看看在一个已经编译好的动态库中,这三项的值:



从地址0x2000处起,就是.got.plt开始的地方。其中使用黑体标识的3个32位地址就分别是这三项的值。可见,除了第1项被赋予了具体的值外,其余两项全部是0。原因是段.dynamic的地址是编译时就确定的。我们查看动态库libf1.so的段.dynamic的值:



上面,使用"-x"显示段.got.plt的内容时,是以little-endian表示的,所以.dynamic段的地址"00001ef8"被显示为"f81e0000"。

记录动态库信息的link_map是在加载后创建的,编译时当然不知道这个运行时创建的对象的地址。同理,因为动态链接器也是以动态库的形式加载到进程地址空间的,其映射地址也是加载时才确定的,所以动态链接器中的函数_dl_runtime_resolve的地址也是在动态链接器加载后才能确定。因此,与段.dynamic的地址在编译时就可确定不同,这两项是由动态链接器动态填充的,代码如下:



其中第6行语句将相关宏进行替换后,展开如下:



前面,讨论结构体link_map时,我们提到过,这个结构体中的数组l_info就是为了方便存储段.dynamic的信息的。因此,这条语句的目的就是从段.dynamic中取得GOT表的基地址,也就是got.plt的基址。

接下来的第8行和第10行语句的目的是在获得了.got.plt的基址之后,分别设置其中第2项和第3项的值。很明显,一个是代表动态库的link_map对象,另外一个就是函数_dl_runtime_resolve的地址。

读者这里了解GOT表中这特殊的三项就可以了,更具体的我们后面会讨论。其中第1项主要是动态链接器重定位自己时使用,我们将在5.4.8节讨论;第2项和第3项主要是用在函数的延迟绑定中使用,我们在5.4.6节中讨论。

2.重定位变量

变量的重定位在动态库加载时进行,注意不要将这里的加载时与前面特指的“加载时重定位”混淆,这里指的是使用PIC技术在加载时进行的变量重定位的过程。我们分别从代码中引用变量以及动态链接器修订GOT表两个角度来讨论PIC中的变量重定位。

(1)代码中引用变量

我们以库libf1中的函数foo1_func引用库libf2中的符号foo2为例,具体看一下PIC中的变量重定位。我们反汇编动态库libf1.so,其中引用全局变量foo2的反汇编代码片段如下:



1)获取下一条指令的运行时地址。注意偏移0x587处的指令,其调用了偏移0x57b处的函数__x86.get_pc_thunk.cx。在调用这个函数时,call指令会将下一条指令的地址0x58c压入到栈中。而在进入函数__x86.get_pc_thunk.cx后,其将栈顶的值取出到寄存器ebx中,然后返回。显然,调用这个函数的目的就是取得下一条指令的运行时地址。这里之所以这么做,是因为x86指令集中没有提供获取指令指针值的指令,不得以才采用的一个小技巧。

2)计算GOT表的运行时地址。现在,下一条指令的绝对地址保存在寄存器ebx中,而下一条指令与GOT之间的偏移又是固定的,因此寄存器ebx加上这个固定的偏移后,就确定了GOT表在运行时所在的地址。

编译时,链接器定义了一个变量_GLOBAL_OFFSET_TABLE_代表GOT表的基址,库libf1中该符号地址如下:



因此,库libf1中偏移0x58c处的指令到GOT表所在位置的差为:0x2000-0x58c=0x1a74,这就是地址0x58c处的值0x1a74的由来。也就是说,这个0x1a74就是指令与GOT表之间的那个固定偏移。

3)计算符号foo2在GOT表中的偏移。取得了GOT表的绝对地址后,如要访问变量foo2,还要加上变量foo2在GOT表中的偏移。那这个偏移是多少呢?我们看看动态库libf1的重定位表:



根据重定位表可见,符号foo2在偏移0x00001fe8处。而GOT表基址在0x2000处,因此,根据这两个值之差就可以确定符号foo2在GOT表中的偏移:0x1fe8-0x2000=-0x18,也就是说,变量foo2相对GOT表的偏移是-0x18。根据ELF文件中段的布局:



可见,GOT表的基址是介于.got和.got.plt之间的。对于.got部分来说,GOT表的基址位于.got部分的底部,这就是偏移为负的原因。之所以将GOT表的基址设置在.got和.got.plt之间,并无特别的目的,这样访问.got.plt就是正值了。所以,我们看到在库libf1的地址0x592处在ebx的基础上又加了偏移-0x18。

(2)动态链接器修订GOT表

我们还是以库libf1中引用的库libf2中的符号foo2为例,来看看在加载时,动态链接器是如何解析这个符号并修订GOT表的。

1)获取动态库libf1的重定位表。重定位信息保存在重定位表中,因此,动态链接器首先要找到重定位表。段.dynamic中类型为REL的条目记录的就是重定位表的位置,动态库libf1段.dynamic中记录的重定位表如下:



可见,保存重定位变量的表位于0x38c处。因此,动态链接器按照如下公式计算重定位表的地址:



2)根据重定位表,确定需要修订的位置。确定重定位表后,动态链接器就遍历重定位表中的每一条记录。以libf1.so中的引用的全局变量dummy、foo2和foo1的重定位记录为例:



其中第一条重定位记录表示需要使用符号dummy的值修订下面位置处的值:



第二条重定位记录表示需要使用符号foo2的值修订下面位置处的值:



第三条重定位记录表示需要使用符号foo1的值修订下面位置处的值:



3)寻找动态符号表。需要修订的位置确定后,那么接下来就需要解析符号的值。动态链接器从link_map这个链表的表头,即代表可执行程序的main_map开始,依次在它们的动态符号表中查找符号。所以,要解析符号的地址,首先要确定动态符号表的地址。以动态库libf2为例,动态链接器确定其动态符号表的过程如下。

动态链接器根据代表库libf2的link_map中的字段l_ld找到段.dynamic,然后在该段中取出动态符号表的地址:



段.dynamic中类型为SYMTAB的项记录的是动态符号表的地址。可见,libf2的动态符号表的地址是0x178,因此,其在运行时的绝对地址使用如下公式计算:



4)解析符号地址。动态链接器找到了动态符号表后,进一步在动态符号表中查找符号的地址。以全局变量foo2为例,动态链接器将在库libf2的动态符号表中找到这个符号的信息:



上述动态符号表中符号的地址是相对于0的,因此需要加上libf2在进程地址空间中映射的基址,所以符号foo2的运行时地址是:



然后,动态链接器使用上述这个地址,修订前面确定的需要修订的位置。

前面是静态的分析,下面我们将这个例子运行起来,动态地观察一下全局变量foo2的重定位过程。



我们在另外一个终端中查看动态库libf2在进程hello的地址空间中映射的基址:



可见,库libf1和libf2在hello进程的地址空间中映射的基址分别是0xb7fd8000和0xb7e15000。那么libf1中需要修订的地址是:



符号foo2的地址是:



下面我们使用gdb查看内存0xb7fd9fe8处的值,如果计算正确,那么该内存处的值应该已经被动态链接器修订为0xb7e17018:



根据输出结果可见,内存0xb7fd9fe8处输出的值与我们理论上计算的符号foo2的地址完全吻合。

综上可知,变量foo2的重定位过程如图5-33所示。

图 5-33 变量foo2的重定位过程

不知道读者注意到没有,在例子中,我们在可执行文件hello和动态库libf1中分别定义了全局变量dummy。这不是我们的笔误,而是故意为之。不知读者想过没有,对于变量foo2,其定义在动态库libf2中,编译时动态库libf1对其一无所知,所以在加载时进行重定位,我们没有任何疑义。但是,对于变量dummy,其在动态库libf1中已经定义了,既然指令和数据的相对位置是固定的,那么为什么不采用与寻址GOT表一样的方法,编译时就直接定义好位置,而还是通过GOT表,在加载时进行重定位呢?

我们先反过来问读者一个问题:动态库libf1中函数foo1_func中引用的变量dummy是动态库libf1中定义的,还是可执行程序hello中定义的?答案是后者。对于一个全局符号,包括函数,其可能在本地定义,但在其他库中、甚至包括使用动态库的可执行程序中也可能有定义。在动态链接器解析符号时,将沿着以可执行程序的link_map对象main_map开头的这个链表依次查找动态符号表,使用最先找到的符号值。如我们的例子中,可执行程序hello的动态符号表将先于动态库libf1的动态符号表被查找,所以,库libf1中的函数foo1_func将使用可执行程序hello中dummy的定义。

除此之外,还有一种所谓的Copy  Relocation,也要求即使引用同一个动态库中定义的全局变量,也要使用重定位的方式,我们在5.4.7节讨论这种重定位情况。

3.重定位函数

前面我们讨论了变量的重定位,本小节我们讨论函数的重定位。理论上,函数的重定位使用与变量相同的方法即可。但是,因为相对比较少的全局变量的引用,函数引用的数量可能要大得多,因此函数重定位的时间不得不考虑。

事实上,读者回想一下我们日常开发的程序,其实很多代码不一定能全部执行,比如有些分支、错误处理等。而且,即使可执行程序本身使用的函数数量并不大,但是可执行程序依赖的动态库可能还会引用其他动态库中的函数,这些动态库再依赖其他的动态库,如此,需要重定位的函数的数量不容小觑。更重要的是,可执行程序可能根本就用不到这些动态库中的函数,因此,加载时重定位函数只会延长程序启动的时间,但是重定位的某些函数却可能根本就用不到。出于以上考虑,PIC对于函数的重定位引入了延迟绑定技术(lazy  binding)。

也就是说,在加载时,动态链接器不解析任何一个需要重定位的函数的地址,而是在运行时真正调用时,再去重定位。为此,开发者们引入了PLT(Procedure  Linkage  Table)机制。在GOT表的巧妙配合下,PIC将函数地址的解析推迟到了运行时。

在编译时,链接器在代码段中插入了一个PLT代码片段,每个外部函数在PLT中都占据着一小段代码。我们可以将这些片段看作外部函数在本地代码中的代理。代码段中所有引用外部函数的地方,全部指向其相应的本地代理。其他具体的事情就交由本地代理去处理。

PLT的代码片段的逻辑如图5-34所示。

图 5-34 PLT代码片段

由图5-34可见:

1)代码中所有引用函数如func1、func2的地方全部替换为指向PLT中的代码片段。因为这里使用的是相对寻址,所以运行时代码段无须再进行任何修订,也就是说,代码段不需要重定位了。保证了代码段的可读属性,从而在多个进程间可以共享。

2)PLT中每个函数的代码片段除了两处数据外,基本完全相同。以调用函数func1为例,它的基本逻辑是:如果不是第一次调用func1,就说明函数func1的地址已经被解析,并且GOT表中对应的func1的地址的项也已经被正确修订了,那么直接跳转到GOT表中对应的项即可,也就是说,这样就直接跳转到了函数foo2的开头。这里,因为GOT表的前3项有特殊的用途,所以func1的地址占据GOT表的第4项。ELF标准规定,在调用PLT中的代码片段前,主调函数需要将GOT表的基址装载进寄存器ebx,所以,PLT中凡是访问got的地方,都使用ebx,*0xc(%ebx)就是GOT表中第4项的值,即函数func1的地址。读者可以回顾一下前面讨论的重定位变量一节,那里讨论确定GOT的基地址时,正是将GOT表的地址装入了寄存器ebx。

3)如果是第一次调用,那么将调用动态链接器提供的函数_dl_runtime_resolve解析函数foo1的地址。这里显然不能将函数_dl_runtime_resolve的地址直接写在PLT代码中,如果这样的话,那么PLT也需要重定位这个函数,除非使用前面提到的加载时重定位,但前面已经提到了其种种弊端。因此,动态链接器在加载库时,将函数_dl_runtime_resolve的地址填充到动态库的GOT表的第3项,而在PLT表中,则直接跳转到GOT表中第3项保存的地址,即*0x8(%ebx)。

4)在跳转到函数_dl_runtime_resolve的地址前,有两条push指令,它们就是为函数_dl_runtime_resolve准备参数的。在具体看这两条直指令前,我们先来看一下修订GOT表中的函数地址时需要的信息:

◆  第一个需要的信息是当前重定位的函数在重定位表中的偏移。根据这个偏移,_dl_runtime_resolve找到相应的重定位条目,从而确定需要解析的符号的名字,以及需要修订的位置。对于函数在重定位表中的偏移,这个在编译时就可以确定,所以我们看到PLT中直接使用了确定的数字。如函数func1在重定位表中占据第1个条目,那么偏移就是0x0,这就是汇编指令"push$0x0"的作用。而对于函数foo2,因为其在重定位表中占据第2个条目,所以偏移就是0x8。

◆  第二个是需要个代表当前动态库的link_map对象。要获得重定位表,当然需要知道动态库映射的基址以及段.dynamic所在的地址,而这些信息记录在库的link_map对象中。在查找符号时,其需要遍历可执行程序的link_map链表,因此,函数_dl_runtime_resolve要根据动态库的link_map对象找到link_map链表。而link_map也是在动态链接器加载库时填充到GOT表中的,它占据GOT表的第2项,这就是PLT代码中汇编语句"push  0x4(%ebx)"的作用。

5)准备好参数后,_dl_runtime_resolve将开始寻找符号,最后修订GOT表中的地址。相关代码如下:



_dl_runtime_resolve中核心的是调用函数_dl_fixup进行符号解析,并修订GOT表。这里使用的是寄存器传参,所以_dl_runtime_resolve在调用_dl_fixup前,将动态库的link_map存储在寄存器eax中,作为传给_dl_fixup的第1个参数;将重定位函数在重定位表中的偏移存储在寄存器edx,作为传给_dl_fixup的第2个参数。

然后,在_dl_fixup执行完毕后,会将解析的函数的地址返回。这个返回值会放在寄存器eax中,所以我们看到_dl_runtime_resolve在_dl_fixup执行完毕后,会将保存在寄存器eax中的值放到栈顶,然后调用ret指令,将这个返回地址弹出到指令指针之中,从而跳转到解析后的地址运行。

下面我们再简要看一下解析函数地址的函数_dl_fixup:



先看函数_dl_fixup的两个参数,第一个参数l就是传递进来的动态库的link_map;第2个参数reloc_arg就是重定位表的偏移,根据第2行代码的宏定义可见,函数体中使用的变量reloc_offset就是reloc_arg。

代码第9~12行根据传递来的link_map,首先取得动态库的动态符号表,包括SYMTAB和STRTAB。

代码第14~15行根据传进来的函数在重定位表中的偏移,从重定位表中获取对应的重定位记录reloc。

第16行代码根据重定位记录reloc中符号在动态符号表中的索引,从动态符号表symtab中取出符号的名字。

第17行代码根据重定位记录reloc中的记录的偏移,加上库映射的基址,计算出需要修订的位置。当然这个位置对应的是GOT表中的某一项。

代码第21~22行调用_dl_lookup_symbol_x遍历link_map链表,查找符号的地址。

代码第26~27行调用elf_machine_fixup_plt修订GOT表中对应的项,函数elf_machine_fixup_plt中就一条代码,如代码第37行,就是给GOT表的某一项赋个符号地址而已。

理论上,函数的重定位过程可以就此完成了。但是,上述方法还有些瑕疵:

◆  在PLT代码片段中,需要设计标志来表示函数是否是第一次调用。

◆  在PLT代码片段中,编译器的实现者们不想做那个多余的if判断,即函数是否是第一次调用的判断。尽管这可能只是一次跳转和一次访存,但是编译器的实现者们还是想把它们节省下来。

于是,编译器的设计者们在上述基础上,做出了更进一步的改进,如图5-35所示。

图 5-35 PLT代码片段

我们看到,PLT中的代码片段不再进行任何判断,而是直接跳转到GOT表中用来保存解析的函数的地址的表项。这里面最关键的一个技巧就是图5-35中用黑体标识的GOT表中的两项。编译时,编译器将函数对应的项的地址初始化为PLT代码片段中jmp语句的下一条地址。在动态库加载时,动态器会在此基础上,再加上动态库的映射的基址。如此,当第一次执行这个函数时,jmp语句并没有跳转到真正的函数的地址处,而是直接相当于执行PLT代码片段中的下一条语句,即压栈参数,然后调用_dl_runtime_resolve解析函数地址,使用解析的符号的地址修订GOT表中的项,然后跳转到解析的函数的地址,执行函数。

这里不知是否有读者有过这样的设想:程序加载时,将函数的GOT表项直接填写为函数_dl_runtime_resolve的地址,是不是更合理?非也,GOT表一项只有4字节,只能保存一个地址,而调用_dl_runtime_resolve之前,还需要其他指令准备参数。

经过第一次调用后,GOT表中的函数对应的项已经变为真正的函数的地址,下次再次调用时,将直接跳转到函数的地址继续执行,如图5-36所示。

图 5-36 PLT代码片段

观察图5-36会发现,PLT中func1@plt中的地址为0x7和0x8处两行的代码,以及func2@plt中地址0xe和0xf处的代码完全一样。事实上,所有函数的PLT片段的最后两行都完全相同。于是,PLT将这两行代码独立为一个“子函数”plt0。进一步改进后PLT的代码如图5-37所示。

图 5-37 PLT代码片段
下面我们以库libf1中的函数foo1_func调用库libf2中的函数foo2_func为例,来具体体会一下前面的理论分析。反汇编库libfoo2,并截取引用函数foo2_func的有关部分:



先来看地址0x5b3处的指令。汇编指令call的操作数0xfffffe98(补码)对应的原码是-0x168,call指令的操作数是一个相对寻址,因此-0x168是目标地址和下一条指令的差值。因为下一条指令的地址是0x5b8,所以跳转的目的地址是:



地址0x450处正是PLT中对应函数foo2_func的片段。我们看到地址0x450处的汇编指令跳转到GOT表中偏移为0x14处中的值表示的地址处。那么GOT表中这个位置处保存的是什么呢?我们需要到记录函数重定位的表——.rel.plt中寻找答案:



动态库libf1的GOT表的基址为0x2000,所以偏移0x14处的地址即为0x2014,也就是重定位表中的第3条记录。可见,这条重定位记录要求动态链接器使用符号foo2_func的值填充地址为0x2014处的GOT表项。根据前面的理论分析,初始时,这个地址指向下一条push指令,即地址0x456处的指令。所以,当首次调用foo2_func时,地址0x450处的指令跳转到了地址0x456处。

地址0x456处的指令压栈了一个立即数0x10。根据前面的理论分析,这是为符号解析函数_dl_runtime_resolve压栈的一个参数,即需要重定位的函数在重定位表中的偏移。根据重定位表中的信息,函数_dl_runtime_resolve就可以找到与重定位函数相关的信息,如重定位函数的符号名称、需要修订的位置等。0x10用十进制表示是16,也就是从重定位表.rel.plt开始偏移16字节,重定位表中每个条目占据8字节,因此偏移16字节处的第3条重定位记录正是记录函数f002-func的重定位信息。

继续看下一条指令,即地址0x45b处的指令。也是一条相对跳转指令,补码0xffffffc0的原码是-0x40,所以跳转的目的地址是:



objdump工具虽然显示地址0x420处的函数的名字是"__cxa_finalize@plt-0x10",实际上与函数"__cxa_finalize"没有任何关系,这里解析的有一点bug,忽略即可。地址0x420处就是PLT表的第0项。我们看到plt0首先将GOT表中偏移0x4处,即GOT表第2项的值(库libf1的link_map)压栈,显然是给解析函数传参。然后跳转到GOT表的偏移0x8处,即第3项,也就是解析函数_dl_runtime_resolve的地址处执行,该函数解析符号foo2_func,然后使用解析得到的符号f002-func的运行时地址修订GOT表中偏移0x14处,即第6项,然后跳转到函数foo2_func执行。

首次调用函数foo2_func后,GOT表中第6项保存的就是foo2_func的地址了。以后再次调用该函数时,PLT中的foo2_func@plt将不再跳转到函数_dl_runtime_resolve处解析函数了,而是直接跳转到函数foo2_func处。

在静态分析后,下面我们再动态观察一下函数foo2_func的重定位过程。

我们首先来看一下编译时库libf1的GOT表中第6项,即偏移0x2014处,保存的内容是什么,前面我们已经讨论过了,理论上这里应该是foo2_func@plt中push指令的地址:



注意上面使用黑体标识的部分,编译时偏移0x2014处的4字节初始化为0x0456,正是foo2_func@plt中push指令的地址。

我们将hello运行起来,观察一下GOT表中第6项的变化情况:



我们在另外一个终端中查看库libf1在进程hello的地址空间中映射的基址:



根据输出可见库libf1在进程hello的地址空间中映射基址是0xb7fd8000。虽然说函数foo2_func的地址是在使用时再去重定位,但是加载时动态链接器还是要做一个重定位。读者不禁要问,重定位什么呢?我们以GOT表的第6项,即偏移0x2014处的值为例。在编译时,我们看到链接器将此处的地址填充为0x0456,即jmp后的push指令的地址。但是不知读者是否注意到,这个地址是相对于0的地址,在加载后,当动态库libf1的映射基址确定为0xb7fd8000后,显然需要修订这个地址为:



我们通过gdb看一下实际的输出:



可见,GOT表中的这一项在加载时确实修订了。

在foo2_func第一次执行后,这个GOT表中的地址就应该修订为foo2_func的地址,我们看一下库libf2中为foo2_func分配的地址:



而动态库libf2在进程hello的地址空间中映射的基址是:



所以,符号foo2_func的运行时地址是:



我们通过gdb来查看一下foo2_func执行一次后,GOT表中的保存这个函数的地址被修订成了什么:



可见,在首次调用后,GOT表中的值已经修订为符号foo2_func的运行时地址。



5.4.7 重定位可执行程序

可执行程序如果引用的是自身定义的函数和变量,这些符号在编译时就已经确定,不需要任何重定位。即使其他动态库中也定义了与可执行程序中相同的符号,链接器也优先使用可执行程序自身定义的函数和变量。

如果引用了动态库中的函数和全局变量,那么编译时可执行程序根本不知道这些符号最终的地址,在重定位了动态库之后,可执行程序也需要重定位这些符号。可执行程序的重定位与共享库原理基本一致,只有一点差别,我们这里简单讨论一下它们之间的差别。

(1)重定位引用的动态库中的函数

我们以hello中引用动态库libf1中的函数foo1_func为例,来看关于函数的重定位。可执行程序hello中调用foo1_func的反汇编代码如下:



可见,可执行程序也使用了延迟绑定的技术。再来看看PLT部分的代码:



与动态库不同,可执行程序的地址在编译时就已经分配好了,所以,GOT的地址在编译时就确定了,不必再如动态那样在运行时动态获取GOT表的基址。我们来看看hello的GOT表的基址:



GOT表的基址为0x0804a000,所以任何以GOT表基址为参照的偏移,直接使用这个地址即可。比如访问GOT表中的第3项,即函数_dl_runtime_resolve时,直接在此地址上加两个4字节偏移即可(因为_dl_runtime_resolve占据GOT表的第3项,所以偏移8字节):



观察hello中plt0部分,即地址0x8048486处,我们看到,指令中也确实是这么做的,jmp的目标地址在编译时就计算好了,就是*0x804a008。

除GOT表的基址固定外,可执行程序函数的重定位与动态库中函数的重定位完全一致。

(2)重定位引用的动态库中的变量

可执行程序与动态库不同,一般而言,其地址是编译时分配好的,是固定的(这里我们不考虑为了安全而使用PIE技术)。如果编译时没有传给编译器参数"-fPIC",那么对于引用的外部的全局变量,可执行程序不使用GOT表的方式寻址。换句话说,可执行程序引用的变量,在编译链接时就需要在编译链接时确定好地址,不能在加载时再进行重定位。

但是,编译时动态库都不能确定自己的变量的最终加载地址,更别提可执行程序了。那怎么办呢?于是ELF标准定义了一种新的重定位类型——R_386_COPY。对于这种重定位类型,编译器、链接器和动态链接器是这样协作的:编译时,编译器将偷偷地在可执行程序的BSS段创建了一个变量,这样就解决了编译时,变量地址不确定的问题。在程序加载时,动态链接器将动态库中的变量的初值复制到可执行程序的BSS段中来。然后,动态库(包括其他动态库)在引用这个变量时,因为可执行程序在link_map的最前面,所以解析符号都将使用可执行程序中的这个偷偷创建的变量。

下面我们结合hello引用动态库libf1中的变量foo1来具体的讨论一下。先来看一下hello的动态符号表:



虽然我们没有在可执行程序中定义变量foo1,但是根据动态符号表可见,可执行程序hello中却定义了变量foo1,其所在地址是0x0804a028,而且在第25个段中。我们来看看第25个段是什么:



可见,第25个段是.bss。也就是说,编译时,链接器为可执行程序hello定义了一个未初始化的全局变量foo1。而hello中,使用的恰恰是hello自己的foo1,而不是库libf1中的foo1。观察下面中引用的符号foo1的地址,正是hello中定义的符号foo1的地址:



链接器将hello的重定位表中foo1的重定位类型设置为R_386_COPY,当处理这个类型的重定位时,动态链接器将在加载时,将库libf1中变量foo1的值复制到hello中的foo1:



下面我们将程序运行起来,动态观察一下R_386_COPY类型的重定位过程。



理论上,动态链接器应该将库libf1中的foo1的初值10复制到hello中定义的foo1处。我们将hello中定义变量foo1所在地址实际的值打印出来:



可见,hello中的foo1已经被赋值为库libf1中的foo1的初值10了。

另外,库libf1中GOT表中保存的foo1的地址,也应该指向hello中定义的foo1的地址,而不是库libf1中的变量foo1的地址。原因是链接时,可执行程序排在链表link_map的表头,所以hello中的符号foo1当然要优先于库libf1中的foo1。我们来实际验证一下这一点,首先找到库libf1中变量foo1所在位置:



在另外一个终端中查看库libf1在进程hello的地址空间中映射的基址:



库libf1的GOT表中记录符号foo1的地址是:



我们打印一下GOT表中的值:



根据输出可见,地址0x0804a040正是hello中定义的符号foo1的地址。可见,动态库libf1中使用的foo1变量是可执行程序中创建的这个副本。显然,虽然这个副本仅仅是编译器为其偷偷分配的,但是实际已经取代了库libf1中的foo1,已经转正了。

当然,在编译可执行程序时也可以给其传递参数"-fPIC",如此,可执行程序中对外部变量的应用也将采用GOT表的方式,但是这对可执行程序没有任何意义。



5.4.8 重定位动态链接器

在Linux中,动态链接器被实现为一个动态库的形式,而且这个动态库是自包含的(self-contained),没有引用其他库的符号,但是与普通动态库一样的道理,它在编译时也不知道自己的确切位置,所以它也难逃重定位的命运。事实上,当C库加载后,动态链接使用了C库中的内存管理相关函数替换了自身的实现。

查看一下动态链接器的重定位表就可见其需要重定位的符号:



但是,与动态库和可执行程序不同,它们有动态链接器负责为它们重定位,而动态链接器则没有这么好的命。在内核跳转到动态链接器时,它是非常残酷的,并没有给动态链接器如link_map信息。好在动态链接器不依赖其他的动态库,只需要确定自己被加载的基地址,然后找到动态链接需要的段.dynamic就可以解决问题,后续的重定位过程与动态库的过程基本完全相同。因此,动态链接器重定位自己的关键是:

◆  确定自己被加载的基地址;

◆  找到段.dynamic。

动态链接器被加载的地址就相当于link_map中的l_addr了。运行后,动态链接器可以获取到某个符号的地址,但是这并不足以计算出动态链接器在进程地址空间中映射的基址,只有对比,才能求出基址。因此,动态链接器还是需要编译时的链接器作一点小小的配合。在编译时,链接器定义了一个符号"_DYNAMIC":



定义这个符号的目的就是为了标识段.dynamic所在的地址,看下面动态链接器的Section  Header  Table:



由上可见,符号_DYNAMIC的地址正是段.dynamic的地址。在运行时,动态链接器使用如x86指令lea读取符号_DYNAMIC的运行时地址,实际就是读取运行时段.dynamic的地址。

除了定义了这个符号外,在编译时,段.dynamic的地址也被装载到了GOT表中的第1项。读者回忆一下在5.4.6节讨论GOT表时的内容。其中,第2项的link_map和第3项的解析函数我们都已经看到其作用了,但是尚未看到第1项的意义。在重定位动态链接器时,这一项发挥了关键作用。前面我们就已经看到过,编译时定义了另外一个符号_GLOBAL_OFFSET_TABLE_,目的与_DYNAMIC相似,是为了标识GOT表的地址。因此,动态链接器就可以使用符号_GLOBAL_OFFSET_TABLE_找到GOT表,从而取出GOT表中第1项的值。

然后,使用取得的符号_DYNAMIC,也就是段.dynamic的运行时地址,与GOT表第一项在编译时保存的段.dynamic的地址(其是相对于0的)做差,得出的就是动态链接器在进程地址空间映射的基址了。相关代码如下:



注意变量bootstrap_map,相信从名字读者已经猜出来了,相当于代表普通动态库和执行程序的link_map。而且根据这个变量的名字,我们也可以揣摩到开发者的用意是在表达这是动态链接器的自举过程。变量bootstrap_map中的关键两项读者应该非常熟悉了,l_addr是代表动态链接器自己被映射的地址,l_ld代表动态链接器的段.dynamic所在的地址。找到段.dynamic后,动态链接器调用elf_get_dynamic_info读取了这个段的信息。

我们来看看获取l_addr和l_ld这两个地址的函数:



函数elf_machine_dynamic利用在编译时定义的符号_GLOBAL_OFFSET_TABLE_读取GOT表中第0项的值。

函数elf_machine_load_address计算动态链接器加载的地址。其首先取得符号_DYNAMIC的运行时地址,对于x86来说,可以使用指令lea,然后与GOT表中保存的编译时的地址做差,从而得出动态库在进程地址空间中映射的基址。

事实上,动态连接器重定位表中的那些动态内存管理的函数,如malloc、free等,最初动态链接器使用的是自己内部的实现:



但是一旦C库加载后,动态链接器将再次重定位这几个函数,使用C库中的相应实现。



5.4.9 段RELRO

最初,编译时链接器并没有过多考虑ELF文件中各个段的布局,一个ELF文件各个段的大致布局如图5-38所示。

图 5-38 早期ELF文件段的布局

可见,动态链接器重定位涉及的GOT表、段.dynamic都位于数据段的后面,一旦数据段发生溢出,动态链接器使用的GOT表、段.dynamic都可能受到破坏,尤其是作为函数跳转表的GOT表,更容易被攻击者利用。而事实上,除了函数被延迟到运行时重定位外,变量等的重定位在加载时就已经完成了,后续动态链接器不再会对这些段进行写操作,也就是说完全可以在完成加载时重定位后,把这部分数据修改为只读。

因此,如今的链接器重新安排了各个段的布局,将动态链接器涉及到的段提到了数据段的前面,并将GOT拆分为两个部分:.got和.got.plt。.got部分用于记录需要重定位的变量,.got.plt部分用于记录需要重定位的函数。在加载时完成重定位后,除了.got.plt仍然保留可写属性,允许在运行时进行重定位外,包括.got在内的其余部分全部更改为只读,减少被攻击的可能。

这些在重定位后更改为只读的段被称为RELRO段。从Program  Header  Table的角度看,段RELRO仍然包含于数据段中,只不过是数据段开头部分一块只读的数据而已。经过上述调整后,一个ELF文件的大致布局演化为如图5-39所示的形式。

图 5-39 使用RELRO后ELF文件的布局

在加载时完成重定位后,动态链接器将检查ELF文件的Program  Header  Table中是否存在段RELRO。如果这个段存在,则将这个段更改为只读,从而达到保护更多数据的目的。相关代码如下:



其中_dl_relocate_object就是动态链接中负责加载时重定位的函数。在这个函数的最后,也就是加载时重定位完成后,这个函数调用_dl_protect_relro修改段RELRO的权限为只读。函数_dl_protect_relro逻辑非常简单,就是通过函数__mprotect请求内核更改段RELRO的属性为PROT_READ。

编译时链接器并没有强制使用RELRO这个特性,如果需要使用这个特性,在链接时需要向链接器传递参数"-z  relro"。以笔者使用的Ubuntu12.10为例,可以看到在编译时编译器确实给链接器传递了这个参数,注意下面使用黑体标识的部分:



在我们构建的工具链中,为简单起见,并没有默认启用RELRO特性。

理解了RELRO的设计动机以及理论背景后,我们结合一个实例来具体体验一下这个特性。以下面程序为例:



我们使用如下命令分别编译不支持RELRO特性和支持RELRO特性的两个可执行程序:



其中,hello是不支持RELRO特性的,hello_relro是支持RELRO特性的。我们首先对比一下这两个程序的Program  Header  Table,hello的Program  Header  Table如下:


hello_relro的Program  Header  Table如下:



留意hello_relro的Program  Header  Table中使用黑体标识的部分。显然,相比于程序hello,hello_relro中多了段"GNU_RELRO"。

读者可能会有个疑问,前面不是提到内核只加载ELF文件中类型为LOAD的段吗,那么这个类型为GNU_RELRO的段会被加载吗?请仔细观察段RELRO与第2个类型为LOAD的段(即数据段)的Offset一列,可见,RELRO段在hello_relro中偏移与数据段在hello_relro文件中的偏移相同。换句话说,RELRO段正是数据段的开头部分,所以在加载数据段时,已经隐含着将段RELRO加载了。

接下来,我们再来动态的观察一下特性RELRO。这里偷个懒,因为目标系统也是x86的,所以笔者直接在宿主系统上运行了,读者当然可以将hello_relro复制到目标系统做这个试验。hello的进程地址空间的映射情况如下:



hello_relro的进程地址空间的映射情况如下:



对比hello和hello_relro的进程空间的映射情况,注意hello_relro映射中使用黑体标识的部分,可见,hello_relro在0x0804