分页式内存管理
以前的段式内存管理在分配内存时,随着系统长时间运行,内存会变得碎片化,空闲的区域可能会小于需要的大小,或者空闲区域又远大于需要的大小。为了解决这个问题,英特尔从80386开始,引入了分页式内存管理。
分页式内存管理的主要思路就是把内存分解成固定大小的页,以前需要多少字节的需求现在转化成请求 “字节数/页面大小(当然,有小数时会向上取整)” 个页面。页面的最小单位是4KB,也就是4096字节,用十六进制表示就是0x1000,因此第1个页面的物理地址是0x00000000,第2个是0x00001000,第3个是0x00002000,以此类推。最终可以把4GB的内存划分成1048576个页,很显然,所有物理页面的地址最低12位始终是0。
在分页模式下,操作系统可以创建一个为所有任务共用的4GB虚拟内存空间,也可以为每一个任务创建独立的4GB虚拟内存空间。当一个程序加载时,操作系统既在要左边的虚拟内存中分配段空间,又要在右边的物理内存中分配相应的页面。因此,第一个步骤是寻找空闲的段空间,该段空间既没有被其他程序使用,也没有被同一程序内的其他段使用。
如图所示,假设己经成功找到并分配了一个段空间,基地址为0x00200000,长度为8200字节。 页的最小尺寸是4KB,也就是4096字节。因此,8200字节的段,需要占用3个页面,其中最后一个页面只用了8个字节,其余都浪费着,但这无关紧要,如果允许页共享,多个段或多个程序可以用同一个页来存放各自的数据。
在分段之后,操作系统的任务是把段拆开,并分别映射到物理页。注意,段必须是连续的,但不要求所分配的页都是连续的。在实际中,内存页会频繁分配和回收,空闲页会零零散散地分布在物理内存中,一般不会是连续的。分配页面时,操作系统会搜索那些空闲的页,并分配给程序使用,所分配页面的总长度要大于等于段长度。
作为一个具体的例子,操作系统为程序分配了一个段,段是在虚拟内存中分配的,起始地址为0x00200000。该段有8200字节,需要分配3个页面。为此,操作系统在物理内存中搜索可用的空闲页,这二个页面的物理地址分别是0x00002000、0x00004000 和 0x00007000。接下来,要建立线性地址和页之间的对应关系,在图中,0x00200000~0x00200FFF 对应着物理地址为 0x00002000 的页,0x00201000~0x00201FFF 对应着物理地址为 0x00004000 的页, 0x00202000~0x00202007 对应着物理地址为 0x00007000 的页。当然,这里只是示例,线性地址区间和页的对应关系可以随意。
4GB虚拟内存空间不可能用来保存任何数据,因为它是虚拟的,它只是用来指示内存的使用情况。当操作系统加载一个程序并创建为任务时,操作系统在虚拟内存空间寻找空闲的段,并映射到空闲的页。然后,到真正开始加载程序时,再把原本属于段的数据按页的尺寸拆开,分开写入对应的页中。 从段部件输出的是线性地址,或者叫虚拟地址。为了根据线性地址找到页的物理地址,操作系统必须维护一张表,把线性地址转换成物理地址,这是一个反过程。 如图16-4所示,因为有 1048576 个页,所以转换表也有 1048576 项。这是个一维表格,每个表项占4字节,内容为页的物理地址。这个表格的用法是这样的:因为页的尺寸是4KB,故线性地址的低12位可用于访问页内偏移,高20位可用于指定一个物理页。因此,把线性地址的高 20位当成索引,乘以4,作为表内偏移量,从表中取出一个双字,那就是该线性地址所对应的页的物理地址。
如图所示,如果执行指令
1 | mov edx,[0x2002] |
那么,段部件用段地址 0x00200000 加上指令中给出的偏移量 0x2002,得到线性地址 0x00202002。线性地址的高20位是表格索引,即0x00202。将索引乘以4,得到0x00808,这就是表内偏移。看图,从该单元可以取出一个双字0x00007000,这就是页物理地址。
线性地址的低12位是页内偏移量,用页物理地址加上页内偏移量,就是最终的物理内存地址。0x00007000加上0x002,得到0x00007002,这就是实际要访问的物理内存地址。
当程序加载时,操作系统会首先在虚拟内存中分配段。然后,根据段需要分成多少页,来搜索空闲页面。当段较大时,要按页的尺寸分成好几个地址区段,操作系统用每个区段的首地址,取高20位,乘以4,作为偏移量访问表格,并将分配给该区段的页的物理地址写入该表项。最后,把原本需要写入每个区段的程序数据,写到对应的页中。
在页式内存管理中很重要的一点是,页面的管理和分配是独立的,和分段以及段地址没有关系。操作系统所要做的,就是寻找空闲页面,把它分配给需要的段,并将页的物理地址填写到映射表内。很显然,线性地址,包括线性地址空间,和页面分配机制也没有关系。
基于以上特点,一般来说,每个任务都可以拥有 4GB的虚拟内存空间;同时,每个任务都有自己的页映射表,如图所示。
尽管有很多任务,而且每个任务都有自己的4GB虚拟内存空间,但在整个系统中,物理页面是统一调配的。考虑这样一种情景:任务A有一个段,段基地址为 0x00050000,段长度为3000字节,操作系统为它分配了一个物理地址为0x08001000的页。过了 一会儿,另一个任务B加载了,它也有个段,段基地址也是0x00050000,段长度为4096字节。此时,操作系统则分配另个不同的、物理地址为0x00700000的页。在这种情况下,在任务 A内访问线性地址0x00050006,访问的其实是物理地址0x08001006;在任务B内访问同样的线性 地址时,访问的其实是物理地址0x00700006。
页目录和页表
为了完成从虚拟地址(线性地址)到物理地址的转换,操作系统应当为每个任务准备一张页映射表。因为任务的虚拟地址空间为4GB,可以分出1048576个页,所以,映射表需 要1048576个表项,用于存放页的物理地址。又因为每个表项占4字节,所以,映射表的总大小为4MB。 不过如果每个任务都要有一个4MB的映射表,那很浪费空间,而且也不是所有的任务都需要访问所有的内存。所以这个设计在实际中有变通,就是层次化的分页结构。
分页结构层次化的主要手段是不采用单一的映射表,取而代之的是页目录表和页表。如图16-6 所示,首先,因为4GB的虚拟内存空间对应着1048576个4KB的页,可以随机地抽取这些页,将 它们组织在1024个页表内,每个页表可以容纳1024个页。页表内的每个项目叫做页表项,占4字节,存放的是页的物理地址,故每个页表的大小是4KB,正好是一个标准页的长度。
注意,页在页表内的分布是随机的,哪个页位于哪个页表中,这是没有规律的。在一个真实的系统中,老任务不断被关闭,新任务不断被创建并投入运行,页面的回收和再分配没有什么规律可言。
由于页表中存放的是页的物理地址,故每个页表项占4字节,这样,每个页表占4096字节, 正好是个物理页的大小,可以很方便地用个物理页来定义每个页表。
如图所示,在将1048576个页归拢到1024个页表之后,接着,再用一个表来指向1024 个页表,这就是页目录表(Page Directory Table, PDT),和页表一样,页目录项的长度为4字节,填写的是页表的物理地址,共指向1024个表页,所以页目录表的大小是4KB,正好是一个标准页的长度。
这样的层次化分页结构是每个任务都拥有的,或者说,每个任务都有自己的页目录和页表。 如图16-7所示,在处理器内部,有一个控制寄存器CR3,存放着当前任务页目录的物理地址,故又叫做页目录基址寄存器(Page Directory Base Register, PDBR)。
每个任务都有自己的任务状态段(TSS),它是任务的标志性结构,存放了和任务相关的各种数据,其中就包括了CR3寄存器域,存放了任务自己的页目录物理地址。当任务切换时,处理器切换到新任务开始执行,而CR3寄存器的内容也被更新,以指向新任务的页目录位置。相应地, 页目录又指向一个个的页表,这就使得每个任务都只在自己的地址空间内运行。 从图16-7中还可以看出,页目录和页表也是普通的页,混迹于全部的物理页中。它们和普通 页的不同之处仅仅在于功能不一样。当任务撤销之后,它们和任务所占用的普通页一样会被回收,并分配给其他任务。
逻辑地址到物理地址
对于英特尔处理器来说,有关分页,最简单和最基本的机制就是这些:CR3寄存器给出了页目录的物理基地址;页目录给出了所有页表的物理地址而每个页表给出了它所包含的页的物理地址。那如何用这种层次性的分页结构把线性地址转换成物理地址? 这里举个例子。
假如某个任务加载后,操作系统根据它的实际情况,在其4GB虚拟地址空间里创建了一个 段,段的起始地址为0x00800000,段界限值为0x5000,字节粒度。当该任务执行时,段寄存器 DS指向该段。又假设执行了下面一条指令:
1 | mov edx [0x1050] |
此时,段部件会输出线性地址0x00801050。在没有开启分页机制时,这就是要访问的物理内存地址,但现在开启了分页机制,所以,这是一个虚拟地址,要经过页部件的转换,才能得到物理地址。
如图所示,处理器的页部件专门负责线性地址到物理地址的转换工作。它首先将段部件送来的32位线性地址截成3段,分别是高10位、中间的10位和低12位。高10位是页目录的索引,中间10位是页表的索引,低12位则作为页内偏移来用。
当前任务页目录的物理地址在处理器的CR3寄存器中,假设它的内容为0x00005000。段管理部件输出的线性地址是0x00801050,其二进制的形式为0000 0000 1000 0000 0001 0000 0101 0000。高10位为 0000000010,也就是十六进制的0x002,它是页目录表内的索引,处理器将它乘以4(因为每个目录项为4字节),作为偏移量访问页目录。最终,处理器从物理地址00005008 处取得页表的物理地址 0x08001000。
线性地址的中间10位为二进制的0000000001,即0x001,处理器要用它作为页表内的索引来取得页的物理地址。处理器将该索引值乘以4,作为偏移量访问页表。最终,处理器又从物理地址 08001004处取得页的物理地址,这就是我们一直努力寻找的那个页。
页的物理地址是0x0000C000,而线性地址的低12位是数据所在的页内偏移量。故处理器将它们相加,得到物理地址0x0000C050,这就是线性地址0x00801050所对应的物理地址,要访问的数据就在这里。
当任务加载时,操作系统先创建虚拟的段,并根据段地址的高20位决定它要用到哪些页目录项和页表项。然后,寻找空闲的页,将原本应该写入段中的数据写到一个或者多个页中,并将页的物理地址填写到相应的页表项中。只有这样做了,当程序运行的时候,才能以相反的顺序进行地址变换,并找到正确的数据。