12 KiB
页表
页表是操作系统用来为每个进程提供专有地址空间和内存的一种机制。页表不仅决定了虚拟地址的真实含义(物理地址),还决定了一个进程能够访问内存的哪些地方。页表机制的存在使得xv6能够隔离不同进程的地址空间以及将一块物理内存复用为多个虚拟地址空间。同时,页表所提供的这种映射机制也使得xv6的内存管理十分巧妙。比如,将同一片物理内存映射到多个虚拟地址空间、不映射内核和用户进程的堆栈等等。本章接下来的部分将解释RISC-V硬件所提供的页表机制以及xv6是如何实现页表机制的。
分页硬件
需要提到的是,RISC-V提供了操纵虚拟地址的指令(内核态和用户态都有)。机器的RAM,或者说物理内存,是通过物理地址来索引的。RISC-V的与页表相关的硬件通过将虚拟地址映射回物理地址起到了虚拟地址和物理地址的桥梁作用。
xv6跑在Sv39 RISC-V上,这意味着64位的虚拟地址实际上只有低39位被用到了,而高25位并没有被使用。根据Sv39规范,一个RISC-V页表在逻辑上拥有 2^{27}=134217728
个页表项(page table entries, PTEs)。每个PTE包含一个44位的物理页号(physical page number, PPN)和一些标志位。分页硬件通过被用到的那39位的高27位在页表中索引PTE,而剩下的那低12位则为页内偏移(offset)。由于Sv39的物理地址为56位,且这56位中的低12直接由虚拟地址中的低12位(页内偏移)得到,则物理页号有 56-12=44
位,这44位物理页号(PPN)由被索引的PTE所指向的物理页号(PPN)得到。一个页表可以简单理解为一个存放了很多页表项(PTEs)的数组,下图描述了这种逻辑关系。页表为操作系统提供了虚实地址转换的控制,这种控制以 4096(2^{12})
字节的对齐块为粒度。这样的块就是一个页面。
在Sv39标准的RISC-V中,不使用虚拟地址的高25位进行转换。将来,RISC-V可能会使用这些位来定义更多级别的转换。物理地址也有增长的空间。在PTE格式中,物理页号(PPN)字段可以再增长10位。
如图2所示,实际的虚实地址转化分为三个步骤。页表以高度为3的树的形式存储在物理内存中。根节点是一个4096字节的页表页,其中包含512个页表项(PTE),这些页表项(PTE)包含树的下一级页表页的物理地址。这些页面中的每一页面都包含512个页表项(PTE),这些页表项(PTE)是树的叶子节点。分页硬件使用这27位中的前9位在根页面表页面中选择一个页表项(PTE),中间9位在树的下一级的页表页中选择一个页表项(PTE),底部9位选择最后一个页表项(PTE)。综上所述,RISC-V采取三级页表机制。
如果不存在转换地址所需的三个PTE中的任何一个,则分页硬件会引发页面错误异常(page-fault exception),并将这个异常交给内核来处理(请参见第4章)。一种比较常见的情况是,在页表页中大量虚拟地址没有映射,这种三层树状页表机制将忽略整个页表页。
每个页表项(PTE)都包含标志位,这些标志位的存在使得分页硬件知道如何使用这些被关联的虚拟地址。标志位PTE_V(VALID)指示该页表项(PTE)是否有效:如果该位被置0,则对该页面的引用会引发异常(即不允许引用)。PTE_R(READ)控制是否允许指令读取该页面。PTE_W(WRITE)控制是否允许指令写入该页面。PTE_X(EXECUTE)控制CPU是否可以将该页面的内容解释为指令并执行它们。PTE_U(USER)控制是否允许用户模式下的指令访问页面:如果该位被置0 ,则只能在超级用户模式下使用该页面项所指向的页面。下图展示了它们如何工作。标志和所有其他与页面硬件相关的结构都在(kernel / riscv.h)中定义。
为了告诉硬件使用页表映射机制,内核必须将根页表页的物理地址写入satp寄存器。每个CPU都有自己的satp 。CPU将使用其自己的satp指向的页表来转换后续指令生成的所有地址。由于每个CPU都有自己的satp,因而不同的CPU可以运行不同的进程,每个进程都有一个由其自己的页表描述的专用地址空间。
关于术语的一些注释:物理内存是指DRAM中的存储单元。物理内存中每一个字节都有一个地址(通常情况下按字节编址),称为物理地址。指令仅使用虚拟地址,分页硬件负责将其转换为物理地址,然后将其发送至DRAM硬件以读取或写入额你存。与物理内存和虚拟地址不同,虚拟内存不是物理对象,而是内核提供的抽象和机制的集合,用于管理物理内存和虚拟地址。
内核地址空间
xv6为每个进程维护一个页表,用于描述每个进程的用户地址空间。除此之外,还有单独一个页表用于描述内核的地址空间。内核配置其地址空间的布局,以使其能够以可预测的虚拟地址访问物理内存和各种硬件资源。下图显示了此布局如何将内核虚拟地址映射到物理地址。文件(kernel / memlayout.h)声明了xv6内核内存布局的常量。
QEMU可以模拟一个包含RAM(物理内存)的机器,该RAM从物理地址0x80000000开始,一直延伸到至少0x86400000,xv6将其称为PHYSTOP。QEMU模拟还包括I/O设备,例如磁盘接口。QEMU以物理地址空间中0x80000000以下的内存映射控制寄存器的形式为软件提供设备接口。内核可以通过读/写这些特殊的物理地址与设备进行交互;这样的读写实际上是在与设备硬件进行通信,而不是RAM。第4章介绍了xv6如何与设备交互。
内核使用“直接映射”来获取RAM和内存映射的设备寄存器。也就是说,将资源映射到与物理地址相等的虚拟地址。例如,内核本身在虚拟地址空间和物理内存中都位于KERNBASE = 0x80000000 。直接映射简化了读取或写入物理内存的内核代码。例如,当fork为子进程分配用户内存时,分配器返回该内存的物理地址;fork将父进程的用户内存复制到子进程时,fork直接将该地址用作虚拟地址。
有几个内核虚拟地址不是直接映射的:
-
trampoline页面。它映射在虚拟地址空间的顶部。用户页表具有与此相同的映射。注意到,一个物理页面(包括trampoline代码)在内核的虚拟地址空间中映射了两次:一次在虚拟地址空间的顶部,一次是直接映射。
-
内核堆栈页面。每个进程都有自己的内核堆栈,该堆栈被映射到较高的位置,因此xv6在其下方可以留下未映射的保护页。保护页的PTE无效(即,PTE_V置0),因此,如果内核溢出内核堆栈,则很可能会导致异常,并且内核会出现紧急情况。如果没有保护页,溢出的堆栈将覆盖其他内核内存,从而导致错误的操作,系统很可能会崩溃。
内核通过高内存映射使用其堆栈时,内核也可以通过直接映射的地址访问它们。替代设计可能只具有直接映射,并在直接映射的地址处使用堆栈。然而,在这种安排下,提供保护页将涉及取消映射虚拟地址,否则这些虚拟地址将引用物理内存,因此将很难使用。
内核通过置标志位PTE_R(READ)和PTE__X(EXECUTE)映射trampoline的页面和内核文本。内核从这些页面读取并执行指令。内核通过置标志位PTE_R(RREAD)和PTE_W(WRITE)映射其他页面,以便它可以在那些页面中读取和写入内存。保护页面的映射无效。
物理内存分配
内核必须在运行时为页表,用户内存,内核堆栈和管道缓冲区分配和释放物理内存。
xv6使用内核末尾与PHYSTOP之间的物理内存进行运行时分配。它以一个页面(4096个字节)为粒度进行分配和释放。它通过一个链表来记录那些空闲的页面。分配即将被分配的页面从链表中删除。释放即将被释放的页面添加到列表中。
进程地址空间
每个进程都有一个单独的页表,并且xv6在进程之间切换时,它也会切换对应的页表。如下图所示,进程的用户内存从虚拟地址0开始,可以增长到MAXVA(kernel / riscv.h:348),从而允许进程在原则上寻址256GB的内存。
当进程向xv6请求更多的用户内存时,xv6首先使用kalloc分配物理页面。然后,它将页表项(PTE)添加到流程的页表中,该页表指向新的物理页。xv6在这些页表项(PTE)中设置PTE_W ,PTE_X ,PTE_R ,PTE_U和PTE_V标志位。大多数进程不会使用整个用户地址空间。对于未使用的页表项(PTE),xv6将PTE_V(VALID)标志位置0。
我们在这里看到一些使用页表的带来的好处。首先,不同进程的页表将用户地址转换为物理内存的不同页面,以便每个进程都有专用的用户内存。其次,每个进程将其内存视为具有从零开始的连续虚拟地址,而进程的实际物理内存可能是非连续的。第三,内核在用户地址空间的顶部映射一个带有trampoline代码的页面,因此有一页物理内存会出现在在所有的用户地址空间。
图3.4更详细地显示了xv6中正在执行的进程的用户内存的布局。堆栈是单独的一个页面,包含了由exec创建时的初始内容。包含命令行参数以及它们的指针数组的字符串位于堆栈的最顶端。在该值之下是允许程序从main开始的值,就像函数main(argc,argv)刚刚被调用一样。
为了检测用户堆栈是否溢出已分配的堆栈内存,xv6在堆栈的正下方放置了一个无效的保护页。如果用户堆栈溢出,并且该进程尝试使用堆栈下面的地址,则硬件将引发页面错误异常(page fault exception),因为该映射无效。实际的操作系统可能会在用户堆栈溢出时自动为其分配更多的内存。
实际情况
与大多数操作系统一样,xv6使用分页硬件来保护和映射内存。大多数操作系统在分页上有更精妙的用法,比如通过结合分页和页面错误异常,xv6并没有这样做。
xv6的内核使用虚拟地址和物理地址之间的直接映射,以及它假定在地址0x8000000处存在物理RAM(内核期望加载的位置),这些使得xv6得以简化。这适用于QEMU,但是在真正的硬件上,这样做并不好。实际的硬件将RAM和设备放置在不可预测的物理地址上,因此(例如)在0x8000000处可能并没有RAM,而xv6却希望可以在这里存储内核。更厉害的内核设计充分利用页表,使得可以将任意硬件物理内存布局转换为可预测的内核虚拟地址布局。
RISC-V支持物理地址级别的保护,但是xv6没有使用这个特性。
在具有大量内存的机器上,可以使用RISC-V对“超级页面”的支持。当物理内存较小时,小页面是更有意义的,它允许以精细的粒度分配分页到磁盘。例如,如果一个程序仅使用8KB的内存,那么为它提供整个4MB的物理内存超级页面就会造成浪费。而在具有大量RAM的计算机上,较大的页面会更有意义,这样可以减少页表操作的开销。
xv6内核缺少类似malloc这样的可以为小对象提供内存的分配器,这使得内核无法使用需要动态分配的复杂数据结构。
内存分配是一个长期的热门话题,其基本问题是如何有效地利用有限的内存,以及如何为将来未知的请求做准备。今天,人们更关心速度而非空间效率。此外,一个更精细的内核可能会分配许多不同大小的小块,而不是分配固定大小的块(如xv6中是4096个字节)。一个真正的内核分配器既要处理大的分配。也要处理小的分配。