6.3 从操作系统角度看可执行文件的装载
从上面页映射的动态装入的方式可以看到,可执行文件中的页可能被装入内存中的任意页。比如程序需要P4的时候,它可能会被装入F0~F3这4个页中的任意一个。很明显,如果程序使用物理地址直接进行操作,那么每次页被装入时都需要进行重定位。正如我们在第1章中所提到的,在虚拟存储中,现代的硬件MMU都提供地址转换的功能。有了硬件的地址转换和页映射机制,操作系统动态加载可执行文件的方式跟静态加载有了很大的区别。
我们经常看到各种可执行文件的装载过程的描述,虽然大致能够明白这个过程,但是总觉得似乎还有那么一层迷雾阻隔着,一旦涉及细节总是有一些模糊。本节我们将站在操作系统的角度来阐述一个可执行文件如何被装载,并且同时在进程中执行。
6.3.1 进程的建立
事实上,从操作系统的角度来看,一个进程最关键的特征是它拥有独立的虚拟地址空间,这使得它有别于其他进程。很多时候一个程序被执行同时都伴随着一个新的进程的创建,那么我们就来看看这种最通常的情形:创建一个进程,然后装载相应的可执行文件并且执行。在有虚拟存储的情况下,上述过程最开始只需要做三件事情:
- 创建一个独立的虚拟地址空间。
- 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。
- 将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。
**首先是创建虚拟地址空间。**回忆第1章的页映射机制,我们知道一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,那么创建一个虚拟空间实际上并不是创建空间而是创建映射函数所需要的相应的数据结构,在i386 的Linux下,创建虚拟地址空间实际上只是分配一个页目录(Page Directory)就可以了,甚至不设置页映射关系,这些映射关系等到后面程序发生页错误的时候再进行设置。
**读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。**上面那一步的页映射关系函数是虚拟空间到物理内存的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。我们知道,当程序执行发生页错误时,操作系统将从物理内存中分配一个物理页,然后将该"缺页"从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才得以正常运行。但是很明显的一点是,当操作系统捕获到缺页错误时,它应知道程序当前所需要的页在可执行文件中的哪一个位置。这就是虚拟空间与可执行文件之间的映射关系。从某种角度来看,这一步是整个装载过程中最重要的一步,也是传统意义上"装载"的过程。
由于可执行文件在装载时实际上是被映射的虚拟空间,所以可执行文件很多时候又被叫做映像文件(Image)。
让我们考虑最简单的情况,假设我们的ELF可执行文件只有一个代码段".text",它的虚拟地址为0x08048000,它在文件中的大小为0x000e1,对齐为0x1000。由于虚拟存储的页映射都是以页为单位的,在32位的Intel IA32下一般为4 096字节,所以32位ELF的对齐粒度为0x1000。由于该.text段大小不到一个页,考虑到对齐该段占用一个段。所以一旦该可执行文件被装载,可执行文件与执行该可执行文件进程的虚拟空间的映射关系如图6-5所示。

图6-5 可执行文件与进程虚拟空间
很明显,这种映射关系只是保存在操作系统内部的一个数据结构。Linux中将进程虚拟空间中的一个段叫做虚拟内存区域(VMA, Virtual Memory Area);在Windows中将这个叫做虚拟段(Virtual Section),其实它们都是同一个概念。比如上例中,操作系统创建进程后,会在进程相应的数据结构中设置有一个.text 段的VMA:它在虚拟空间中的地址为0x08048000~0x08049000,它对应ELF文件中偏移为0的.text,它的属性为只读(一般代码段都是只读的),还有一些其他的属性。
VMA是一个很重要的概念,它对于我们理解程序的装载执行和操作系统如何管理进程的虚拟空间有非常重要的帮助。
上面的例子中,我们描述的是最简单的只有一个段的可执行文件映射的情况。操作系统在内部保存这种结构,很明显是因为当程序执行发生段错误时,它可以通过查找这样的一个数据结构来定位错误页在可执行文件中的位置,此内容后面会详细介绍。
**将CPU指令寄存器设置成可执行文件入口,启动运行。**第三步其实也是最简单的一步,操作系统通过设置CPU的指令寄存器将控制权转交给进程,由此进程开始执行。这一步看似简单,实际上在操作系统层面上比较复杂,它涉及内核堆栈和用户堆栈的切换、CPU运行权限的切换。不过从进程的角度看这一步可以简单地认为操作系统执行了一条跳转指令,直接跳转到可执行文件的入口地址。还记得ELF文件头中保存有入口地址吗?没错,就是这个地址。
6.3.2 页错误
上面的步骤执行完以后,其实可执行文件的真正指令和数据都没有被装入到内存中。操作系统只是通过可执行文件头部的信息建立起可执行文件和进程虚存之间的映射关系而已。假设在上面的例子中,程序的入口地址为0x08048000,即刚好是.text段的起始地址。当CPU开始打算执行这个地址的指令时,发现页面0x08048000~0x08049000是个空页面,于是它就认为这是一个页错误(Page Fault)。CPU将控制权交给操作系统,操作系统有专门的页错误处理例程来处理这种情况。这时候我们前面提到的装载过程的第二步建立的数据结构起到了很关键的作用,操作系统将查询这个数据结构,然后找到空页面所在的VMA,计算出相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系,然后把控制权再还回给进程,进程从刚才页错误的位置重新开始执行。
随着进程的执行,页错误也会不断地产生,操作系统也会为进程分配相应的物理页面来满足进程执行的需求,如图6-6所示。当然有可能进程所需要的内存会超过可用的内存数量,特别是在有多个进程同时执行的时候,这时候操作系统就需要精心组织和分配物理内存,甚至有时候应将分配给进程的物理内存暂时收回等,这就涉及了操作系统的虚拟存储管理。这里不再展开,有兴趣的读者可以参考相应的操作系统方面的资料。

图6-6 页错误