页表是操作系统为每个进程提供自己私有地址空间和内存的机制。页表决定了内存地址的含义,以及物理内存的哪些部分可以被访问。它们允许 xv6 隔离不同进程的地址空间,并将它们映射到物理内存上。页表还提供了一个间接层次,允许 xv6 执行一些技巧:在几个地址空间中映射同一内存(trampoline 页),以及用一个未映射页来保护内核和用户的栈。
3.1 Paging hardware
RISC-V 指令(包括用户和内核)操作的是虚拟地址。机器的 RAM,或者说物理内存,是用物理地址来做索引的,RISC-V 分页硬件(一般指内存管理单元(Memory Management Unit, MMU))将这两种地址联系起来,通过将每个虚拟地址映射到物理地址上。
xv6 运行在 Sv39 RISC-V 上,这意味着只使用 64 位虚拟地址的底部 39 位,顶部 25 位未被使用。
一个 RISC-V 页表在逻辑上是一个 2^27(134,217,728)页表项(Page Table Entry, PTE)的数组。每个 PTE 包含一个 44 位的物理页号(Physical Page Number,PPN)和一些标志位。
分页硬件通过利用 39 位中的高 27 位索引到页表中找到一个 PTE 来转换一个虚拟地址,并计算出一个 56 位的物理地址,它的前 44 位来自于 PTE 中的 PPN,而它的后 12 位则是从原来的虚拟地址复制过来的。图 3.1 显示了这个过程,在逻辑上可以把页表看成是一个简单的 PTE 数组(更完整的描述见图 3.2)。页表让操作系统控制虚拟地址到物理地址的转换,其粒度为 4096(2^12)字节的对齐块。这样的分块称为页。
虚拟地址的前 25 位不用于转换地址;将来,RISC-V 可能会使用这些位来定义更多的转换层。物理地址也有增长的空间:在 PTE 格式中,物理页号还有 10 位的增长空间。
如图 3.2 所示,实际转换分三步进行。一个页表以三层树的形式存储在物理内存中。树的根部是一个 4096 字节的页表页,它包含 512 个 PTE,这些 PTE 包含树的下一级页表页的物理地址。每一页都包含 512 个 PTE,用于指向下一个页表或物理地址。分页硬件用 27 位中的顶 9 位选择根页表页中的 PTE,用中间 9 位选择树中下一级页表页中的 PTE,用底 9 位选择最后的 PTE。
如果转换一个地址所需的三个 PTE 中的任何一个不存在,分页硬件就会引发一个页面错误的异常(page-fault exception),让内核来处理这个异常(见第 4 章)。这种三层结构的一种好处是,当有大范围的虚拟地址没有被映射时,可以省略整个页表页。
为了避免从物理内存加载PTE的成本,RISC-V CPU在转换后备缓冲器(Translation Look-aside Buffer,TLB)中缓存页表条目。
每个 PTE 包含标志位,告诉分页硬件如何允许使用相关的虚拟地址。PTE_V 表示 PTE 是否存在:如果没有设置,对该页的引用会引起异常(即不允许)。PTE_R 控制是否允许指令读取到页。PTE_W 控制是否允许指令向写该页。PTE_X 控制 CPU 是否可以将页面的内容解释为指令并执行。PTE_U 控制是否允许用户模式下的指令访问页面;如果不设置 PTE_U,PTE 只能在监督者模式下使用。图 3.2 显示了这一切的工作原理。标志位和与页相关的结构体定义在(kernel/riscv.h)。
要告诉硬件使用页表,内核必须将根页表页的物理地址写入 satp 寄存器中。每个 CPU 都有自己的 satp 寄存器。一个 CPU 将使用自己的 satp 所指向的页表来翻译后续指令产生的所有地址。每个 CPU 都有自己的 satp,这样不同的 CPU 可以运行不同的进程,每个进程都有自己的页表所描述的私有地址空间。
关于术语的一些说明。物理内存指的是 DRAM 中的存储单元。物理存储器的一个字节有一个地址,称为物理地址。当指令操作虚拟地址时,分页硬件会将其翻译成物理地址,然后发送给 DRAM 硬件,以读取或写入存储。不像物理内存和虚拟地址,虚拟内存不是一个物理对象,而是指内核提供的管理物理内存和虚拟地址的抽象和机制的集合。
3.2 Kernel address space
Xv6为每个进程维护一个页表,描述每个进程的用户地址空间,外加一个描述内核地址空间的单页表。内核配置其地址空间的布局,使其能够通过可预测的虚拟地址访问物理内存和各种硬件资源。图3.3显示了这个设计是如何将内核虚拟地址映射到物理地址的。文件(kernel/memlayout.h)声明了xv6内核内存布局的常量。
QEMU模拟的计算机包含RAM(物理内存),从物理地址0x80000000,至少到0x86400000,xv6称之为PHYSTOP。
QEMU模拟还包括I/O设备,如磁盘接口。QEMU将设备接口作为memory-mapped(内存映射)控制寄存器暴露给软件,这些寄存器位于物理地址空间的0x80000000以下。内核可以通过读取/写入这些特殊的物理地址与设备进行交互;这种读取和写入与设备硬件而不是与RAM进行通信。
内核使用“直接映射”RAM和内存映射设备寄存器,也就是在虚拟地址上映射硬件资源,这些地址与物理地址相等。例如,内核本身在虚拟地址空间和物理内存中的位置都是KERNBASE=0x80000000。直接映射简化了读/写物理内存的内核代码。例如,当fork为子进程分配用户内存时,分配器返回该内存的物理地址;fork在将父进程的用户内存复制到子进程时,直接使用该地址作为虚拟地址。
有几个内核虚拟地址不是直接映射的:
- trampoline 页。它被映射在虚拟地址空间的顶端;用户页表也有这个映射。第4章讨论了trampoline 页的作用,但我们在这里看到了页表的一个有趣的用例;一个物理页(存放trampoline代码)在内核的虚拟地址空间中被映射了两次:一次是在虚拟地址空间的顶部,一次是直接映射。
- 内核栈页。每个进程都有自己的内核栈,内核栈被映射到地址高处,所以在它后面xv6可以留下一个未映射的守护页。守护页的PTE是无效的(设置PTE_V),这样如果内核溢出内核stack,很可能会引起异常,内核会报错。如果没有防护页,栈溢出时会覆盖其他内核内存,导致不正确的操作。报错还是比较好的。
当内核通过高地址映射使用stack时,它们也可以通过直接映射的地址(也就是KERNBASE的直接映射)被内核访问。
另一种的设计是只使用直接映射,并在直接映射的地址上使用stack。在这种安排中,提供保护页将涉及到取消映射虚拟地址,否则这些地址将指向物理内存,这将很难使用。
3.3 Code: creating an address space
每个RISC-V CPU都会在Translation Look-aside Buffer(TLB)中缓存页表项,当xv6改变页表时,必须告诉CPU使相应的缓存TLB项无效。如果它不这样做,那么在以后的某个时刻,TLB可能会使用一个旧的缓存映射,指向一个物理页,而这个物理页在此期间已经分配给了另一个进程,这样的话,一个进程可能会在其他进程的内存上“乱写乱画“。
3.4 Physical memory allocation
内核必须在运行时为页表、用户内存、内核堆栈和管道缓冲区分配和释放物理内存。 xv6使用内核地址结束到PHYSTOP之间的物理内存进行运行时分配。它每次分配和释放整个4096字节的页面。它通过保存空闲页链表,来记录哪些页是空闲的。分配包括从链表中删除一页;释放包括将释放的页面添加到空闲页链表中。
3.5 Code: Physical memory allocator
3.6 Process address space
每个进程都有一个单独的页表,当xv6在进程间切换时,也会改变页表。
一个进程的用户内存从虚拟地址0开始,可以增长到MAXVA(kernel/riscv.h:360),原则上允许一个进程寻址256GB的内存。
我们在这里看到了几个例子,是关于使用页表的。首先,不同的进程页表将用户地址转化为物理内存的不同页,这样每个进程都有私有的用户内存。第二,每个进程都认为自己的内存具有从零开始的连续的虚拟地址,而进程的物理内存可以是不连续的。第三,内核会映射带有trampoline代码的页,该trampoline处于用户地址空间顶端,因此,在所有地址空间中都会出现一页物理内存。
图3.4更详细地显示了xv6中执行进程的用户内存布局。栈只有一页,图中显示的是由exec创建的初始内容。包含命令行参数的字符串的值,以及指向这些参数的指针数组,位于栈的最顶端。正下方是允许程序在main启动的值,就像函数main(argc, argv)刚刚被调用一样。
此图来自《现代操作系统:原理与实现》
为了检测用户栈溢出分配的栈内存,xv6会通过清除PTE_U标志在stack的下方放置一个无效的保护页。如果用户栈溢出,而进程试图使用栈下面的地址,硬件会因为该映射无效而产生一个页错误异常,因为在用户模式下运行的程序无法访问保护页面。现实世界中的操作系统可能会在用户栈溢出时自动为其分配更多的内存。
3.7 Code: sbrk
xv6使用进程的页表不仅是为了告诉硬件如何映射用户虚拟地址,也是将其作为分配给该进程的物理地址的唯一记录。这就是为什么释放用户内存(uvmunmap中)需要检查用户页表的原因。
3.8 Code: exec
xv6应用程序用ELF格式来描述可执行文件,它定义在(kernel/elf.h)。
一个ELF二进制文件包括一个ELF头,elfhdr结构体(kernel/elf.h:6),后面是一个程序段头(program section header)序列,程序段头为一个结构体proghdr(kernel/elf.h:25)。每一个proghdr描述了一个必须加载到内存中的程序段;xv6程序只有一个程序段头,但其他系统可能有单独的指令段和数据段需要加载到内存。
Exec将ELF文件中的字节按ELF文件指定的地址加载到内存中。用户或进程可以将任何他们想要的地址放入ELF文件中。因此,Exec是有风险的,因为ELF文件中的地址可能会意外地或故意地指向内核。对于一个不小心的内核来说,后果可能从崩溃到恶意颠覆内核的隔离机制(即安全漏洞)。xv6执行了一些检查来避免这些风险。例如if(ph.vaddr + ph.memsz < ph.vaddr)检查总和是否溢出一个64位整数。危险的是,用户可以用指向用户选择的地址的 ph.vaddr和足够大的 ph.memsz来构造一个 ELF 二进制,使总和溢出到 0x1000,这看起来像是一个有效值。在旧版本的xv6中,用户地址空间也包含内核(但在用户模式下不可读/写),用户可以选择一个对应内核内存的地址,从而将ELF二进制中的数据复制到内核中。在RISC-V版本的xv6中,这是不可能的,因为内核有自己独立的页表;loadseg加载到进程的页表中,而不是内核的页表中。
内核开发人员很容易忽略一个关键的检查,现实中的内核有很长一段缺少检查的空档期,用户程序可以利用缺少这些检查来获得内核特权。xv6在验证需要提供给内核的用户程序数据的时候,并没有完全验证其是否是恶意的,恶意用户程序可能利用这些数据来绕过xv6的隔离。
3.9 Real world
Xv6的内核使用虚拟地址和物理地址之间的直接映射,这样会更简单,并假设在地址0x8000000处有物理RAM,即内核期望加载的地方。这在QEMU中是可行的,但是在真实的硬件上,它被证明是一个糟糕的想法;真实的硬件将RAM和设备放置在不可预测的物理地址上,例如在0x8000000处可能没有RAM,而xv6期望能够在那里存储内核。更好的内核设计利用页表将任意的硬件物理内存布局变成可预测的内核虚拟地址布局。
一个更复杂的内核可能会分配许多不同大小的小块,而不是(在xv6中)只分配4096字节的块;一个真正的内核分配器需要处理小块分配以及大块分配。