第五章 页面错误
当使用的虚拟地址在页表中没有映射、或映射的 PTE_V 标志被清除、或映射的权限位(PTE_R、PTE_W、PTE_X、PTE_U)禁止当前尝试的操作时,RISC-V CPU 会触发一个页面错误异常。
RISC-V 区分三种页面错误:
- 加载页面错误(由加载指令引起)
- 存储页面错误(由存储指令引起)
- 指令页面错误(由取指执行引起)
scause 寄存器指示页面错误的类型,stval 寄存器包含无法被转换的地址。
页表与页面错误的结合是一种强大的工具。页表为内核在虚拟地址与物理地址之间提供了一层间接性,使内核能够控制地址空间的结构和内容。页面错误允许内核拦截加载和存储操作,并通过修改页表,在运行时指定这些引用实际指向的数据。
内核可以利用这些能力来提升效率。例如,写时复制(copy-on-write)fork 允许内核在父进程和子进程之间透明地共享内存,避免复制那些双方都不会写入的页面所带来的开销。
应用程序开发者同样可以从中受益。其中一种方式是内存映射文件(memory-mapped files):内核利用分页机制,使文件内容出现在应用程序的地址空间中,并在发生页面错误时透明地读取文件块。另一种方式是惰性内存分配,它允许程序申请一个巨大的虚拟地址空间,但只为程序实际读取和写入的页面分配物理内存,从而推迟并减少开销。
xv6 仅将页面错误用于一个目的:惰性分配。
在继续之前,请阅读 kernel/sysproc.c 中的函数 sys_sbrk(),以及 kernel/vm.c 中的 vmfault。在 kernel/trap.c 和 kernel/vm.c 中搜索对 vmfault 的调用。
惰性分配
xv6 的惰性分配包含两个部分。首先,当应用程序通过调用 sbrk 并使用标志 SBRK_LAZY 来请求内存时,内核只会记录地址空间大小的增长,但不会分配物理内存,也不会为新的虚拟地址范围创建 PTE。其次,当对这些新地址之一发生页面错误时,内核才会分配一页物理内存,并将其映射到页表中。
内核以对应用程序透明的方式实现惰性分配:应用程序无需进行任何修改即可从中受益。
惰性分配对应用程序来说非常方便,因为它们不必准确预测自己需要多少内存。例如,一个应用程序可能需要处理输入数据,但事先并不知道输入会有多大。使用惰性分配,应用程序可以为最坏情况申请内存,但不必为这个最坏情况付出代价:对于应用程序从未使用过的页面,内核完全不需要做任何工作。
此外,如果应用程序请求大幅增长地址空间,那么在不使用惰性分配的情况下,sbrk 的代价是很高的:例如,当应用程序请求 1GB 内存时,内核必须分配并清零 262,144 个大小为 4096 字节的物理页面。惰性分配允许将这一成本分摊到时间上。另一方面,惰性分配会引入页面错误的额外开销,而页面错误涉及一次用户态/内核态的切换。操作系统可以通过在一次页面错误中分配一批连续页面,而不是单个页面,并为这种页面错误专门优化内核的进入和退出代码来降低这一成本(不过 xv6 并未采用这些优化)。
另一方面,当为一个惰性分配的页面处理页面错误时,内核可能会发现没有空闲内存可供分配。在这种情况下,内核无法轻易地向应用程序返回内存不足错误,而是会直接终止该应用程序。对于希望在分配失败时获得错误返回的应用程序,xv6 允许通过使用标志 SBRK_EAGER 调用 sbrk 来进行立即分配。
代码
系统调用 sbrk(n) 用于将进程的内存大小增加(如果 n 为负则减少)n 个字节,并返回新分配区域的起始地址(即原来的大小)。其在内核中的实现是 sys_sbrk。
如果应用程序指定了 SBRK_EAGER,该系统调用会通过函数 growproc 来实现。growproc 会调用 uvmalloc。uvmalloc 使用 kalloc 分配物理内存,将分配到的内存清零,并通过 mappages 向用户页表中添加 PTE。
如果应用程序采用惰性方式分配内存,sys_sbrk 仅仅将进程的大小(myproc()->sz)增加 n,并返回旧的大小;它不会分配物理内存,也不会向进程的页表中添加 PTE。
当进程对一个缺少有效页表映射的虚拟地址进行加载或存储操作时,CPU 会触发一个页面错误异常。usertrap 会检查这种情况,并调用 vmfault 来处理页面错误。vmfault 会检查发生错误的地址是否位于之前通过 sbrk 授权的区域内,然后使用 kalloc 分配一页物理内存,将分配到的页面清零,并通过 mappages 向用户页表中添加一个 PTE。xv6 会在新页面的 PTE 中设置 PTE_W、PTE_R、PTE_U 和 PTE_V 标志。随后,usertrap 会让进程从导致错误的那条指令继续执行。由于此时 PTE 已经有效,重新执行的加载或存储指令将不会再触发错误。
如果应用程序使用 sbrk 释放内存,sys_sbrk 会调用 shrinkproc,后者再调用 uvmdealloc。真正的工作由 uvmunmap 完成,它使用 walk 来查找 PTE。由于有些页面可能从未被进程使用过,因此也从未被 vmfault 分配,uvmunmap 会跳过没有设置 PTE_V 标志的 PTE。如果某个 PTE 映射是有效的,uvmunmap 会调用 kfree 来释放其所引用的物理内存。
需要注意的是,xv6 使用进程的页表不仅仅是为了告诉硬件如何映射用户虚拟地址,同时它也是记录哪些物理内存页面已分配给该进程的唯一依据。正因为如此,在释放用户内存(uvmunmap)时,必须检查用户页表。
现实世界:写时复制(COW)fork
许多内核(但不包括 xv6)使用页面错误来实现写时复制(Copy-On-Write,COW)的 fork。fork 系统调用保证:子进程在创建时所看到的内存,其初始内容与 fork 发生时父进程的内存内容相同。实现这一点的一种方式是,将父进程的整个内存复制到新分配的物理内存中供子进程使用;这正是 xv6 采用的方法。复制内存可能非常缓慢,如果子进程能够共享父进程的物理内存,将会更加高效。然而,这种直接共享的实现并不可行,因为父进程和子进程对共享的栈和堆进行写操作时,会相互干扰执行。
写时复制 fork 通过恰当地使用页表权限和页面错误,使父进程和子进程能够安全地共享物理内存。基本思路是:父进程和子进程最初共享所有物理页面,但在各自的页表中将这些页面都映射为只读(即清除 PTE_W 标志)。这样,父进程和子进程都可以从共享的物理内存中读取数据。
如果其中任意一个进程尝试向共享页面写入数据,RISC-V CPU 就会触发一个页面错误异常。支持 COW 的内核会对此作出响应:分配一页新的物理内存,并将共享页面的内容复制到这页新内存中。然后,内核会修改发生错误的进程页表中对应的 PTE,使其指向这份副本,并允许写入(同时仍允许读取),随后让进程从导致错误的那条指令继续执行。由于此时 PTE 已经允许写操作,重新执行的存储指令将不会再触发错误,并且只会修改该进程私有的页面副本,而不会影响共享页面。
写时复制需要一定的簿记信息来帮助决定何时可以释放物理页面,因为根据 fork、页面错误、exec 和 exit 的历史,每个物理页面可能会被不同数量的页表引用。这种簿记机制支持一个重要的优化:如果某个进程发生了存储页面错误,而对应的物理页面只被该进程的页表引用,那么无需进行复制。
写时复制使 fork 更加高效,因为 fork 本身不需要复制内存。一部分内存可能会在之后写入时才被复制,但通常情况下,大多数内存根本不需要被复制。一个常见的例子是 fork 之后紧接着调用 exec:在 fork 之后,可能只有少量页面会被写入,而随后子进程的 exec 会释放从父进程继承来的大部分内存。写时复制 fork 完全消除了复制这些内存的需要。
此外,COW fork 是透明的:应用程序无需做任何修改即可从中受益。
现实世界:按需分页
另一种广泛使用、并利用页面错误的特性是按需分页(demand paging)。在 exec 系统调用中,xv6 会在启动应用程序之前,将应用程序的所有代码段和数据段加载到内存中。由于应用程序可能很大,而从磁盘读取数据又需要时间,这种启动开销对用户来说可能是可感知的。
为了减少启动时间,现代内核在一开始并不会将可执行文件加载到内存中,而只是创建用户页表,并将所有 PTE 标记为无效。随后内核启动程序运行;每当程序第一次使用某个页面时,就会发生一次页面错误,内核在响应中从磁盘读取该页面的内容,并将其映射到用户地址空间中。与 COW fork 和惰性分配一样,内核可以以对应用程序透明的方式实现这一特性。
在一台计算机上运行的程序所需的内存,可能会超过计算机实际拥有的 RAM。为了优雅地应对这种情况,操作系统可以实现分页到磁盘。其思想是:只将一部分用户页面保存在 RAM 中,其余页面存放在磁盘上的分页区域中。内核会将那些对应于存放在分页区域(即不在 RAM 中)的内存的 PTE 标记为无效。
如果应用程序试图使用一个已经被换出到磁盘的页面,就会触发页面错误,此时该页面必须被换入:内核的陷入处理程序会分配一页物理 RAM,将页面内容从磁盘读入 RAM,并修改相应的 PTE,使其指向这块 RAM。
如果需要换入一个页面,但此时没有空闲的物理 RAM,会发生什么?在这种情况下,内核必须先通过将某个物理页面换出或驱逐到磁盘上的分页区域来释放一页物理内存,并将引用该物理页面的 PTE 标记为无效。页面驱逐的代价很高,因此分页机制在驱逐不频繁时性能最好:也就是说,应用程序只使用其内存页面的一个子集,并且这些子集的并集能够放入 RAM 中。这一特性通常被称为具有良好的引用局部性。与许多虚拟内存技术一样,内核通常以对应用程序透明的方式实现磁盘分页。
无论硬件提供了多少 RAM,计算机往往都会在几乎没有空闲物理内存的状态下运行。例如,云服务提供商会在一台机器上复用多个客户,以更高效地利用硬件成本;又如,用户会在物理内存较小的智能手机上同时运行许多应用程序。在这种环境下,分配一个页面往往需要先驱逐一个已有页面。因此,当空闲物理内存稀缺时,内存分配的代价是很高的。
当空闲内存稀缺且程序只积极使用其已分配内存的一小部分时,惰性分配和按需分页尤为有利。这些技术还可以避免这样一种浪费:某个页面被分配或加载后,却从未被使用,或者在使用之前就被驱逐。
现实世界:内存映射文件
其他将分页与页面错误异常相结合的特性还包括自动扩展栈和内存映射文件。内存映射文件是指程序通过 mmap 系统调用将文件映射到其地址空间中,从而可以使用加载和存储指令直接对文件内容进行读写。