Appearance
页错误异常
Xv6 对异常的响应相当无聊:如果异常发生在用户空间,内核会杀死出错的进程。如果异常发生在内核中,内核会 panic。真正的操作系统通常会以更有趣的方式做出响应。
举个例子,许多内核使用页错误来实现写时复制(COW)fork。为了解释写时复制 fork,请考虑第 2 章中描述的 xv6 的 fork
。fork
导致子进程的初始内存内容与 fork 时父进程的内存内容相同。Xv6 使用 uvmcopy
(vm.c
) 来实现 fork,它为子进程分配物理内存并将父进程的内存复制到其中。如果子进程和父进程可以共享父进程的物理内存,效率会更高。然而,直接实现这一点是行不通的,因为它会导致父进程和子进程通过对共享堆栈和堆的写入来相互干扰彼此的执行。
父进程和子进程可以通过适当使用页表权限和页错误来安全地共享物理内存。当使用没有映射的虚拟地址,或者映射的 PTE_V
标志被清除,或者映射的权限位(PTE_R
、PTE_W
、PTE_X
、PTE_U
)禁止正在尝试的操作时,CPU 会引发页错误异常。RISC-V 区分三种类型的页错误:加载页错误(由加载指令引起)、存储页错误(由存储指令引起)和指令页错误(由获取要执行的指令引起)。scause
寄存器指示页错误的类型,stval
寄存器包含无法转换的地址。
COW fork 的基本计划是父进程和子进程最初共享所有物理页,但每个进程都将它们映射为只读(PTE_W
标志被清除)。父进程和子进程可以从共享的物理内存中读取。如果任何一方写入给定页面,RISC-V CPU 会引发页错误异常。内核的陷阱处理程序通过分配一个新的物理内存页并将出错地址映射到的物理页复制到其中来响应。内核更改出错进程页表中的相关 PTE,以指向副本并允许写入和读取,然后在导致错误的指令处恢复出错进程。因为 PTE 现在允许写入,所以重新执行的指令将不会出现错误地执行。写时复制需要簿记来帮助决定何时可以释放物理页,因为每个页可以被可变数量的页表引用,具体取决于 fork、页错误、exec 和退出的历史记录。这种簿记允许一个重要的优化:如果一个进程发生存储页错误并且物理页仅从该进程的页表引用,则不需要复制。
写时复制使 fork
更快,因为 fork
不需要复制内存。一些内存稍后在写入时必须被复制,但通常情况下,大部分内存永远不需要被复制。一个常见的例子是 fork
后跟 exec
:fork
之后可能会写入几页,但随后子进程的 exec
会释放从父进程继承的大部分内存。写时复制 fork
消除了复制这部分内存的需要。此外,COW fork 是透明的:应用程序无需修改即可受益。
页表和页错误的结合开启了除 COW fork 之外的各种有趣的可能性。另一个广泛使用的功能称为惰性分配,它有两个部分。首先,当应用程序通过调用 sbrk
请求更多内存时,内核会记录大小的增加,但不会分配物理内存,也不会为新的虚拟地址范围创建 PTE。其次,在这些新地址之一上发生页错误时,内核会分配一个物理内存页并将其映射到页表中。与 COW fork 一样,内核可以对应用程序透明地实现惰性分配。
由于应用程序通常会请求比它们需要的更多的内存,因此惰性分配是一个胜利:对于应用程序从不使用的页面,内核根本不需要做任何工作。此外,如果应用程序请求大幅增加地址空间,那么没有惰性分配的 sbrk
是昂贵的:如果一个应用程序请求一千兆字节的内存,内核必须分配并清零 262,144 个 4096 字节的页面。惰性分配允许将此成本分摊到一段时间内。另一方面,惰性分配会带来页错误的额外开销,这涉及用户/内核转换。操作系统可以通过为每个页错误分配一批连续的页面而不是一个页面,以及通过专门化此类页错误的内核进入/退出代码来降低此成本。
利用页错误的另一个广泛使用的功能是按需分页。在 exec
中,xv6 在启动应用程序之前将应用程序的所有文本和数据加载到内存中。由于应用程序可能很大并且从磁盘读取需要时间,因此这种启动成本对用户来说是显而易见的。为了减少启动时间,现代内核最初不会将可执行文件加载到内存中,而只是创建用户页表,并将所有 PTE 标记为无效。内核启动程序运行;每次程序第一次使用页面时,都会发生页错误,作为响应,内核从磁盘读取页面的内容并将其映射到用户地址空间中。与 COW fork 和惰性分配一样,内核可以对应用程序透明地实现此功能。
计算机上运行的程序可能需要比计算机拥有的 RAM 更多的内存。为了优雅地应对,操作系统可以实现分页到磁盘。其思想是仅在 RAM 中存储一小部分用户页面,其余部分存储在磁盘上的分页区域中。内核将对应于存储在分页区域中(因此不在 RAM 中)的内存的 PTE 标记为无效。如果应用程序尝试使用已“换出”到磁盘的页面之一,应用程序将发生页错误,并且该页面必须被“换入”:内核陷阱处理程序将分配一页物理 RAM,从磁盘将页面读入 RAM,并修改相关的 PTE 以指向 RAM。
如果需要换入一个页面但没有空闲的物理 RAM 会发生什么?在这种情况下,内核必须首先通过将其换出或“驱逐”到磁盘上的分页区域来释放一个物理页面,并将引用该物理页面的 PTE 标记为无效。驱逐是昂贵的,因此如果分页不频繁,其性能最佳:如果应用程序仅使用其内存页面的一个子集,并且这些子集的并集适合 RAM。此属性通常被称为具有良好的引用局部性。与许多虚拟内存技术一样,内核通常以对应用程序透明的方式实现分页到磁盘。
计算机通常在几乎没有或没有“空闲”物理内存的情况下运行,无论硬件提供多少 RAM。例如,云提供商在单台机器上多路复用许多客户,以经济高效地使用其硬件。另一个例子是,用户在少量物理内存的智能手机上运行许多应用程序。在这种情况下,分配一个页面可能需要首先驱逐一个现有页面。因此,当空闲物理内存稀缺时,分配是昂贵的。
当空闲内存稀缺且程序仅积极使用其分配内存的一小部分时,惰性分配和按需分页尤其有利。这些技术还可以避免在分配或加载但从未使用或在使用前被驱逐的页面上浪费的工作。
结合分页和页错误异常的其他功能包括自动扩展堆栈和内存映射文件,这些文件是程序使用 mmap
系统调用映射到其地址空间的文件,以便程序可以使用加载和存储指令来读写它们。