介绍
分段的内存管理方式存在固有的问题:将空间切成不同长度的分片以后,空间本身会碎片化(fragmented),随着时间推移,分配内存会变得比较困难。
因此,值得考虑另一种方法:将空间分割成固定长度的分片。在虚拟内存中,我们称这种思想为分页。分页不是将一个进程的地址空间分割成几个不同长度的逻辑段,而是分割成固定大小的单元,每个单元称为一页。相应地,我们把物理内存看成是定长槽块的阵列,叫作页帧(page frame)。每个这样的页帧包含一个虚拟内存页。
示例
下图展示了一个只有64字节的小地址空间,有4个16字节的页(虚拟页0、1、2、3)。
物理内存则如图所示,也由一组固定大小的槽块组成。在这个例子中,有8个页帧(由128字节物理内存构成,也是极小的)。从图中可以看出,虚拟地址空间的页放在物理内存的不同位置。
与我们以前的方法相比,分页有许多优点。可能最大的改进就是灵活性:通过完善的分页方法,操作系统能够高效地提供地址空间的抽象,不管进程如何使用地址空间。
另一个优点是分页提供的空闲空间管理的简单性。例如,如果操作系统希望将64字节的小地址空间放到8页的物理地址空间中,它只要找到4个空闲页。也许操作系统保存了一个所有空闲页的空闲列表(free list),只需要从这个列表中拿出4个空闲页。
为了记录地址空间的每个虚拟页放在物理内存中的位置,操作系统通常为每个进程保存一个数据结构,称为页表(page table)。页表的主要作用是为地址空间的每个虚拟页面保存地址转换(address translation),从而让我们知道每个页在物理内存中的位置。
重要的是要记住,这个页表是每一个进程都有的数据结构。
为了转换(translate)该过程生成的虚拟地址,我们必须首先将它分成两个组件:虚拟页面号(virtual page number,VPN)和页内的偏移量(offset)。对于这个例子,因为进程的虚拟地址空间是64字节,我们的虚拟地址总共需要6位。因为我们知道页的大小(16字节),所以可以进一步划分虚拟地址,如下所示:
当进程生成虚拟地址时,操作系统和硬件必须协作,将它转换为有意义的物理地址。通过虚拟页号,我们可以检索页表,找到虚拟页所在的物理页面。然后我们可以通过用PFN替换VPN来转换此虚拟地址,因为偏移量只是告诉我们页面中的哪个字节是我们想要的,所以偏移量保持不变,最后将结果发送给物理内存。
页表的结构
页表就是一种数据结构,用于将虚拟地址(或者实际上,是虚拟页号)映射到物理地址(物理帧号)。因此,任何数据结构都可以采用。最简单的形式称为线性页表(linearpage table),就是一个数组。操作系统通过虚拟页号(VPN)检索该数组,并在该索引处查找页表项(PTE),以便找到期望的物理帧号(PFN)。现在,我们将假设采用这个简单的线性结构。
至于每个PTE的内容,我们在其中有许多不同的位,值得有所了解。有效位(valid bit)通常用于指示特定地址转换是否有效。例如,当一个程序开始运行时,它的代码和堆在其地址空间的一端,栈在另一端。所有未使用的中间空间都将被标记为无效(invalid),如果进程尝试访问这种内存,就会陷入操作系统,可能会导致该进程终止。因此,有效位对于支持稀疏地址空间至关重要。通过简单地将地址空间中所有未使用的页面标记为无效,我们不再需要为这些页面分配物理帧,从而节省大量内存。
PTE还可能有保护位(protection bit),表明页是否可以读取、写入或执行。同样,以这些位不允许的方式访问页,会陷入操作系统。
存在位(present bit)表示该页是在物理存储器还是在磁盘上(即它已被换出,swapped out)。交换允许操作系统将很少使用的页面移到磁盘,从而释放物理内存。脏位(dirty bit)也很常见,表明页面被带入内存后是否被修改过。
参考位(reference bit,也被称为访问位,accessed bit)有时用于追踪页是否被访问,也用于确定哪些页很受欢迎,因此应该保留在内存中。这些知识在页面替换(page replacement)时非常重要。
还有其他一些重要的部分,但现在我们不会过多讨论。
性能
内存中的页表,我们已经知道它们可能太大了。事实证明,它们也会让速度变慢。
为了实现地址转换的功能,硬件必须知道当前正在运行的进程的页表的位置。现在让我们假设一个页表基址寄存器(page-table base register)包含页表的起始位置的物理地址。然后,我们使用VPN作为页表基址寄存器指向的PTE数组的索引,从内存中获取PTE,提取PFN,并将它与来自虚拟地址的偏移量连接起来,形成所需的物理地址。
对于每个内存引用(无论是取指令还是显式加载或存储),分页都需要我们执行一个额外的内存引用,以便首先从页表中获取地址转换。额外的内存引用开销很大,在这种情况下,可能会使进程减慢两倍或更多。
快速地址转换(TLB)
使用分页作为核心机制来实现虚拟内存,可能会带来较高的性能开销。因为要使用分页,就要将内存地址空间切分成大量固定大小的单元(页),并且需要记录这些单元的地址映射信息。因为这些映射信息一般存储在物理内存中,所以在转换虚拟地址时,分页逻辑上需要一次额外的内存访问。每次指令获取、显式加载或保存,都要额外读一次内存以得到转换信息,这慢得无法接受。
为此,我们要增加所谓的地址转换旁路缓冲存储器(translation-lookaside buffer,TLB),它就是频繁发生的虚拟到物理地址转换的硬件缓存。对每次内存访问,硬件先检查TLB,看看其中是否有期望的转换映射,如果有,就完成转换,不用访问页表。
TLB的基本算法
硬件处理虚拟地址转换的大致算法如下:首先从虚拟地址中提取页号,然后检查TLB是否有该VPN的转换映射。如果有,我们有了TLB命中(TLB hit),这意味着TLB有该页的转换映射。接下来我们就可以从相关的TLB项中取出页帧号,与原来虚拟地址中的偏移量组合形成期望的物理地址,并访问内存(假定保护检查没有失败)。
如果CPU没有在TLB中找到转换映射(TLB miss),硬件访问页表来寻找转换映射,假设该虚拟地址有效,而且我们有相关的访问权限,就用该转换映射更新TLB。上述系列操作开销较大,主要是因为访问页表需要额外的内存引用。最后,当TLB更新成功后,系统会重新尝试该指令,这时TLB中有了这个转换映射,内存引用得到很快处理。
由于局部性原理,TLB的访问命中率一般都会比较高。
TLB的内容
典型的TLB有32项、64项或128项,并且是全相联的(fullyassociative)。这意味着一条地址映射可能存在TLB中的任意位置,硬件会并行地查找TLB,找到期望的转换映射。一条TLB项内容可能像下面这样:
VPN | PFN | 其他位
在TLB的其他位中,通常会有一个有效(valid)位,用来标识该项是不是有效地转换映射。通常还有一些保护(protection)位,用来标识该页是否有访问权限。例如,代码页被标识为可读和可执行,而堆的页被标识为可读和可写。还有其他一些位,包括地址空间标识符(address-space identifier)、脏位(dirty bit)等。
上下文切换时对TLB的处理
有了TLB,在进程间切换时,会面临一些新问题。具体来说,TLB中包含的虚拟到物理的地址映射只对当前进程有效,对其他进程是没有意义的。所以在发生进程切换时,硬件或操作系统必须注意确保即将运行的进程不要误读了之前进程的地址映射。
这个问题有一些可能的解决方案。一种方法是在上下文切换时,简单地清空(flush)TLB,这样在新进程运行前TLB就变成了空的。如果是软件管理TLB的系统,可以在发生上下文切换时,通过一条显式(特权)指令来完成。如果是硬件管理TLB,则可以在页表基址寄存器内容发生变化时清空TLB(在上下文切换时,操作系统必须改变页表基址寄存器的值)。不论哪种情况,清空操作都是把全部有效位(valid)置为0,本质上清空了TLB。
上下文切换的时候清空TLB,这是一个可行的解决方案,进程不会再读到错误的地址映射。但是,有一定开销:每次进程运行,当它访问数据和代码页时,都会触发TLB未命中。如果操作系统频繁地切换进程,这种开销会很高。
为了减少这种开销,一些系统增加了硬件支持,实现跨上下文切换的TLB共享。比如有的系统在TLB中添加了一个地址空间标识符(Address Space Identifier,ASID)。可以把ASID看作是进程标识符(Process Identifier,PID),但通常比PID位数少(PID一般32位,ASID一般是8位)。当然,硬件也需要知道当前是哪个进程正在运行,以便进行地址转换,因此操作系统在上下文切换时,必须将某个特权寄存器设置为当前进程的ASID。
较小的页表
我们现在来解决分页引入的第二个问题:页表太大,因此消耗的内存太多。让我们从线性页表开始。假设一个32位地址空间,4KB的页和一个4字节的页表项。一个地址空间中大约有一百万个虚拟页面,乘以页表项的大小,你会发现页表大小为4MB。有一百个活动进程的话,就要为页表分配数百兆的内存!因此,要寻找一些技术来减轻这种沉重的负担。
简单的解决方案:更大的页
可以用一种简单的方法减小页表大小:使用更大的页。假设使用16KB的页,每个页表的总大小变为1MB,页表缩到四分之一。
然而,这种方法的主要问题在于,大内存页会导致每页内的浪费,这被称为内部碎片(internal fragmentation)问题。因此,结果是应用程序会分配页,但只用每页的一小部分,而内存很快就会充满这些过大的页。因此,大多数系统在常见的情况下使用相对较小的页大小。
多级页表
多级页表的基本思想很简单。首先,将页表分成页大小的单元。然后,如果整页的页表项无效,就完全不分配该页的页表。为了追踪页表的页是否有效(以及如果有效,它在内存中的位置),使用了名为页目录(page directory)的新结构。页目录因此可以告诉你页表的页在哪里,或者页表的整个页不包含有效页。
下图展示了一个例子。图的左边是经典的线性页表。即使地址空间的大部分中间区域无效,我们仍然需要为这些区域分配页表空间。右侧是一个多级页表。页目录仅将页表的两页标记为有效;因此,页表的这两页就驻留在内存中。
在一个简单的两级页表中,每页页表包含了一项页目录。它由多个页目录项(Page DirectoryEntries,PDE)组成。PDE拥有有效位(valid bit)和页帧号(page frame number,PFN),类似于PTE。但是,这个有效位的含义稍有不同:如果PDE项是有效的,则意味着该项指向的页表(通过PFN)中至少有一页是有效的,即在该PDE所指向的页中,至少一个PTE,其有效位被设置为1。如果PDE项无效,则PDE的其余部分没有定义。
与我们至今为止看到的方法相比,多级页表有一些明显的优势。首先,多级页表分配的页表空间,与你正在使用的地址空间内存量成比例。因此它通常很紧凑,并且支持稀疏的地址空间。
其次,如果仔细构建,页表的每个部分都可以整齐地放入一页中,从而更容易管理内存。操作系统可以在需要分配或增长页表时简单地获取下一个空闲页。有了多级结构,我们增加了一个间接层(level of indirection),使用了页目录,它指向页表的各个部分。这种间接方式,让我们能够将页表页放在物理内存的任何地方。
不过,多级页表是有成本的。在TLB未命中时,需要从内存加载两次,才能从页表中获取正确的地址转换信息(一次用于页目录,另一次用于PTE本身),而用线性页表只需要一次加载。因此,多级页表是一个时间—空间折中(time-space trade-off)的典型示例。
另一个明显的缺点是复杂性。无论是硬件还是操作系统来处理页表查找(在TLB未命中时),这样做无疑都比简单的线性页表查找更复杂。通常我们愿意增加复杂性以提高性能或降低管理费用。
当使用多级页表,做虚拟地址转换的时候,需要将VPN划分为页目录索引(PDIndex)和页表索引(PTIndex)。一旦从VPN中提取了页目录索引,我们就可以通过简单的计算来找到页目录项的地址:PDEAddr = PageDirBase +(PDIndex * sizeof(PDE))。如果页目录项标记为无效,则我们知道访问无效,从而引发异常。如果PDE有效,我们必须从页目录项指向的页表的页中获取页表项。要找到这个PTE,我们必须使用VPN的剩余位索引到页表的部分。查询到PTE后,取出PPN与偏移量组合后,就得到了真正的物理地址。
在我们的例子中,假定多级页表只有两个级别:一个页目录和几页页表。在某些情况下,更深的树是可能的(并且确实需要)。
还要记住,在任何复杂的多级页表访问发生之前,硬件都会首先检查TLB。在命中时,物理地址直接形成,而不像之前一样访问页表。只有在TLB未命中时,硬件才需要执行完整的多级查找。在这种情况下,系统需要进行两次额外的内存访问来查找有效的转换映射。