5.4 进程加载

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

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

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

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

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

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

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

5.4 进程加载 - 图1

5.4 进程加载 - 图2

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

5.4 进程加载 - 图3

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

5.4 进程加载 - 图4

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,代码如下:

5.4 进程加载 - 图5

5.4 进程加载 - 图6

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.4 进程加载 - 图7

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

Program Header Table中有多个不同类型的Segment,但是如果仔细观察图5-21,我们会发现,两个类型为LOAD的Segment基本涵盖了整个ELF文件,而一些Section,如".comment"、".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可执行文件的代码如下:

5.4 进程加载 - 图8

5.4 进程加载 - 图9

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对象的代码如下:

5.4 进程加载 - 图10

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

5.4 进程加载 - 图11

5.4 进程加载 - 图12

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

5.4 进程加载 - 图13

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

5.4 进程加载 - 图14

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

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

5.4 进程加载 - 图15

(2)BSS段

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

5.4 进程加载 - 图16

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

5.4 进程加载 - 图17

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

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

5.4 进程加载 - 图18

代码第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的代码如下:

5.4 进程加载 - 图19

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的后面,而是又加了一个随机的偏移,代码如下:

5.4 进程加载 - 图20

5.4 进程加载 - 图21

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

5.4 进程加载 - 图22

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

(4)内存映射区域

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

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

5.4 进程加载 - 图23

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

5.4 进程加载 - 图24

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

5.4 进程加载 - 图25

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

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

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

5.4 进程加载 - 图26

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

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

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

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

5.4 进程加载 - 图27

5.4 进程加载 - 图28

虽然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程序运行起来,结合前面的理论分析,实际观察一下其进程地址空间的映射。

5.4 进程加载 - 图29

根据输出可见:

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:

5.4 进程加载 - 图30

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

5.4 进程加载 - 图31

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

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

5.4 进程加载 - 图32

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

5.4 进程加载 - 图33

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