进程虚拟地址空间
每个程序被运行起来以后,它将拥有自己独立虚拟空间地址,这个虚拟地址空间的大学由计算机的硬件平台决定,具体地说是由CPU的位数决定。硬件决定了地址空间的最大理论上限,即硬件的寻址空间大小,比如32位的硬件平台决定了虚拟地址空间的地址为0到2的32次方-1,即0x00000000到0xFFFFFFFF,也就是我们常说的4GB虚拟空间大小,64位硬件平台的虚拟空间地址为0x0到0xFFFFFFFFFFFFFFFF,下面按照32位硬件平台讨论。
那么到底4GB的进程虚拟地址空间是怎样的分配状态?首先以Linux操作系统为例子,默认情况下,Linux操作系统将进程的虚拟地址空间做了如图1所示分配。
图 图1 Linux进程虚拟空间分布
整个4GB被划分成两部门,其中操作系统本身用去了一部分:从地址0xC0000000到0xFFFFFFFF,共1GB.剩下的从0x00000000地址开始到0xBFFFFFFF共3GB的空间都是留给进程使用的。那么从原则上来说,我们的进程最多可以使用3GB的虚拟空间,也就是说整个进程在执行的时候,所有的代码,数据包括通过C语言malloc等方法申请的虚拟空间之和不可以超过3GB。
装载的方式
程序执行时所需要的指令和数据必须在内存中才能正常运行,最简单的办法就是将程序运行所需要的指令和数据全部都装入内存,这样程序就可以顺利运行,但是很多情况下程序所需要的内存数量大于物理内存的数量,当内存的数量不够时,根本的解决办法就是添加内存。相对于磁盘来说,内存是昂贵且稀有的,这种情况自计算机磁盘诞生以来一直如此,人们希望能够在不添加内存的情况下让更多的程序运行起来,尽可能有效的利用内存。后来研究发现,程序运行时是有局部性原理的,所以我们可以将程序最常用的部门驻留在内存中,而将一些不太常用的数据存放在磁盘里面,这就是动态装入的基本原理。
动态装入的思想是程序用到那个模块,就将那个模块装入内存,如果不用就暂时不装入,存放在磁盘中。
页映射
页映射是虚拟存储进制的一部分,这里我们结合可执行文件的装载来阐述一下页映射是如何被应用到动态装载中去的。页映射也不是一下子就把程序的所有数据和指令都装入内存,而是将内存和所有磁盘中的数据和指令按照"页"为单位划分成若干个页,以后所有的装载和操作的单位就是页。硬件规定的页的大小有4096字节,8192字节,2MB,4MB等,最常用的InteliA32处理器一般都使用4096字节的页,那么512M的物理内存就拥有了512 * 1024 * 1024 / 4096=131072个页。
为了演示页映射的基本机制,假设我们的32位机器有16KB的内存,每个页大小为4096字节,则公有4个页,如图2所示。
页编号 | 地址 |
F0 | 0x00000000-0x00000FFF |
F1 | 0x00001000-0x00001FFF |
F2 | 0x00002000-0x00002FFF |
F3 | 0x00003000-0x00003FFF |
图2
假设程序所有的指令和数据总和为32KB,那么程序总共被分为8个页面。我们将它们编号为P0-P7。很明显,16KB的内存无法同时将32KB的程序装入,那么我们将按照动态装入的原理来进行整个装入过程。如果程序刚开始执行的入口地址为P0,这是装载管理器(我们假设装载过程由一个叫做装载管理器的家伙来控制)发现程序的P0不在内存中,于是将内存F0分配给P0,并且将P0的内容装入F0;运行一段时间以后,程序需要用到F5,于是装载管理器将F5装入F1;就这样,当程序用到P3和P6的时候,它们分别被装入到了F2和F3,图3是映射关系图
图三 页映射与页装载
很明显,如果这个时候程序只需要P0,P3,P5和P6这4个页,那么程序就能一直运行下去。但是问题很明显,如果这时候程序需要访问P4,那么装载管理器必须做出抉择,它必须放弃目前正在使用的4个内存页中的其中一个来装载P4。至于选择哪个页,我们有很多算法可以选择,比如可以选择F0,因为它是第一个被分配掉的内存页(FIFO,先进先出算法);假设装载器发现F2很少被访问,那么我们可以选择F2(LUR,最少使用算法)。假设我们放弃了F0,那么这个时候F0就装入F4。程序接着按照这样的方式运行。
这个所谓的装载管理器就是现代的操作系统,更加准确的来讲就是操作系统的存储管理器。目前几乎所有主流操作系统都是按照这种方式装载可执行文件。
从操作系统角度看可执行文件的装载
从上面页映射的动态装入的方式可以看到,可执行文件中的页可能被装入内存的任何页。比如程序需要P4的时候,它可能被装入F0-F3这4个页中的任意一个。很明显,如果程序使用了物理地址直接进行操作,那么每次页被装入时都需要进行重定位。但是在虚拟存储中,现代的硬件MMU都提供了地址转换的功能。有了硬件的地址转换和页映射机制,操作系统动态加载可执行文件的方式跟静态加载有了很大的区别。
本节我们将站在操作系统的角度来阐述一个可执行文件如何被装载,并且同时在进程中执行。
进程的建立
事实上,从操作系统的角度来看,一个进程最关键的特征是它拥有独立的虚拟地址空间。这使得它有别于其他进程。很多时候一个程序被执行同时都伴随着一个新的进程的创建,那么我们就来看看这种最通常的情形:创建一个进程,然后装载相应的可执行文件并且执行。在有虚拟存储的情况下,上述过程最开始只需要做三件事情:
1、创建一个独立的虚拟地址空
2、读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系
3、将CPU的指令寄存器设置成可执行文件的入口地址,启动运行
首先是创建虚拟地址空间,我们知道一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,那么创建一个虚拟空间实际上并不是创建空间而是创建映射函数所需要的相应的数据结构,在i386的Linux下面,创建虚拟地址空间实际上只是分配一个页目录就可以,甚至不设置页映射关系,这映射关系等到后面程序发生页错误的时候再进行设置。
读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。上面那一步的页映射关系 函数是虚拟空间到物理内存的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。我们知道,当程序执行发生页错误时,操作系统将从物理内存中分配一个物理页,然后将该“缺页”从磁盘中读取到内存中,再设置页的虚拟页和物理页的映射关系,它应该知道程序当前所需要的页在可执行文件中的那个位置。这就是虚拟空间与可执行文件之间的映射关系。从某种角度来看,这一步是整个装载过程中最重要的一步,也是传统意义上的"装载"的过程。
由于可执行文件在装载时实际上是被 映射到虚拟空间,所以可执行文件很多时候又被叫做映射文件。
让我们考虑最简单的情况,假设我们的ELF可执行文件只有一个代码段“.text”,它的虚拟地址为0x08048000,它在文件中的大小为0x000e1,对齐方式为0x1000。由于虚拟存储的页映射都是以页为单位的,在32位的IntelIA32下一般为4096字节,所以32位ELF的对齐粒度为0x1000。由于该.text段大小不到一个页,考虑到对齐该段占用一个段。所以一旦该可执行文件被装载,可执行文件与可执行文件进程的虚拟空间的映射关系所图4所示
图四 可执行文件与进程虚拟空间
很明显,这种映射关系只是保存在操作系统内部的一个数据结构。Linux中将进程虚拟空间中的一个段叫做虚拟内存区域(VMA,Virtual Memory Area) 。操作系统创建进程后,会在进程相应的数据结构中设置一个.text段的VMA:它在虚拟空间中的地址为0x08048000-0x08049000,它对应ELF文件中偏移为0的.text,它的属性为只读(一般代码段都是只读),还有一些其他属性。
将CPU指令寄存器设置成可执行文件入口,启动运行。
页错误
上面的步骤执行完以后,其实可执行文件的真正指令和数据都没有被装入内存中。操作系统只是通过可执行文件头部的信息建立可执行文件和进程虚拟之间的映射关系而已。假设在上面的例子中,程序的入口地址为0x08048000,即刚好是.text段的起始地址。当CPU开始打算执行这个地址的指令时,发现页面0x08048000-0x08049000是个空页面,于是它就认为这个一个页错误。CPU将控制权交给操作系统,操作系统专门的页错误处理例程来处处理这种情况。这时候我们前面提到的装载过程的第二步建立的数据结构体起到了关键作用,操作系统将查询这个数据结构,然后找到空页面所在的VMA,计算出相应的页面在可执行文件中的偏移,然后再物理内存中分配一个物理页面,将进程中该虚拟页与分配到的物理页之间建立映射关系,然后把控制权再还给进程,进程从刚才页错误的位置重新开始执行。
随着进程的执行,页错误也会不断产生,操作系统也会为进程分配相应的物理页面来满足进程执行的需求,如图5所示。当然有可能进程所需要的内存会超过可用的内存数量,特别是在有多个进程同时执行的时候,这时候操作系统就需要精心组织和分配物理内存,甚至有时候应将分配给进程的物理内存暂时收回等。
图五 页错误