有三种事件会导致CPU搁置普通指令的执行,强制将控制权转移给处理该事件的特殊代码。一种情况是系统调用,当用户程序执行ecall指令要求内核为其做某事时。另一种情况是异常:一条指令(用户或内核)做了一些非法的事情,如除以零或使用无效的虚拟地址。第三种情况是设备中断,当一个设备发出需要注意的信号时,例如当磁盘硬件完成一个读写请求时。
本书使用trap作为这些情况的通用术语。xv6内核会处理所有的trap。
代码在执行时发生trap,之后都会被恢复,而且不需要意识到发生了什么特殊的事情。通常的顺序是:trap迫使控制权转移到内核;内核保存寄存器和其他状态,以便恢复执行;内核执行适当的处理程序代码(例如,系统调用实现或设备驱动程序);内核恢复保存的状态,并从trap中返回;代码从原来的地方恢复。
Xv6 trap 处理分为四个阶段:RISC-V CPU采取的硬件行为,为内核C代码准备的汇编入口,处理trap的C 处理程序,以及系统调用或设备驱动服务。虽然三种trap类型之间的共性表明,内核可以用单一的代码入口处理所有的trap,但事实证明,为三种不同的情况,即来自用户空间的trap、来自内核空间的trap和定时器中断,设置单独的汇编入口和C trap handler是很方便的。处理trap的内核代码(汇编器或C)通常被称为handler;第一个handler指令通常是用汇编语言(而不是C)编写的,有时被称为vector。
4.1 RISC-V trap machinery
这里是最重要的寄存器的概述。
- stvec:内核在这里写下trap处理程序的地址;RISC-V到这里来处理trap。
- sepc:当trap发生时,RISC-V会将程序计数器保存在这里(因为PC会被stvec覆盖)。sret(从trap中返回)指令将sepc复制到pc中。内核可以写sepc来控制sret的返回到哪里。
- scause:RISC -V在这里放了一个数字,描述了trap的原因。
- sscratch:内核在这里放置了一个值,这个值会方便trap 恢复/储存用户上下文。
- sstatus::sstatus中的SIE位控制设备中断是否被启用,如果内核清除SIE,RISC-V将推迟设备中断,直到内核设置SIE。SPP位表示trap是来自用户模式还是监督者模式,并控制sret返回到什么模式。
多核芯片上的每个CPU都有自己的一组这些寄存器,而且在任何时候都可能有多个CPU在处理一个trap。
注意,CPU不会切换到内核页表,不会切换到内核中的栈,也不会保存pc以外的任何寄存器。内核软件必须执行这些任务。CPU在trap期间做很少的工作的一个原因是为了给软件提供灵活性,例如,一些操作系统在某些情况下不需要页表切换,这可以提高性能。
4.2 Traps from user space
有点复杂,看书
在用户页表和内核页表中,trampoline页被映射在相同的虚拟地址上,这也是允许uservec在改变satp后继续执行的原因。
4.3 Code: Calling system calls
让我们来看看用户调用是如何在内核中实现exec系统调用的。
当sys_exec函数返回时,syscall将其返回值记录在p->trapframe->a0中。用户空间的exec()将会返回该值,因为RISC-V上的C调用通常将返回值放在a0中。系统调用返回负数表示错误,0或正数表示成功。如果系统调用号无效,syscall会打印错误并返回-1。
4.4 Code: System call arguments
一些系统调用传递指针作为参数,而内核必须使用这些指针来读取或写入用户内存。例如,exec系统调用会向内核传递一个指向用户空间中的字符串的指针数组。这些指针带来了两个挑战。首先,用户程序可能是错误的或恶意的,可能会传递给内核一个无效的指针或一个旨在欺骗内核访问内核内存而不是用户内存的指针。第二,xv6内核页表映射与用户页表映射不一样,所以内核不能使用普通指令从用户提供的地址加载或存储。
内核实现了安全地将数据复制到用户提供的地址或从用户提供的地址复制数据的函数。例如fetchstr(kernel/syscall.c:25)。文件系统调用,如exec,使用fetchstr从用户空间中检索字符串文件名参数,fetchstr调用copyinstr来做这些困难的工作。
copyinstr (kernel/vm.c:398) 将用户页表 pagetable中的虚拟地址 srcva复制到 dst,需指定最大复制字节数。由于pagetable不是当前的页表,因此copyinstr使用walkaddr(调用walk函数)在软件中模拟分页硬件的操作,以确定srcva的物理地址pa0。内核将每个物理RAM地址映射到相应的内核虚拟地址,因此copyinstr可以直接将字符串字节从pa0复制到dst。walkaddr (kernel/vm.c:95)检查用户提供的虚拟地址是否是进程用户地址空间的一部分,所以程序不能欺骗内核读取其他内存。类似的函数copyout,可以将数据从内核复制到用户提供的地址。
4.5 Traps from kernel space
根据正在执行的是用户代码还是内核代码,Xv6对CPU trap寄存器的配置略有不同。当内核在CPU上执行时,内核将stvec指向kernelvec(kernel/kernelve.S:10)处的汇编代码。由于xv6已经在内核中,因此kernelvec可以依赖于将satp设置为内核页表,以及依赖于指向有效内核栈的栈指针。kernelvec将所有32个寄存器push到栈上,稍后将从栈中恢复它们,以便中断的内核代码可以在不受干扰的情况下继续执行。
当CPU从用户空间进入内核时,xv6将CPU的stvec设置为kernelvec;您可以在usertrap(kernel/trap.c:29)中看到这一点。内核已开始执行,但stvec仍设置为uservec的时间窗口,在此时间窗口内不发生设备中断非常重要。幸运的是,RISC-V总是在开始捕获trap时禁用中断,而xv6直到设置stvec之后才会再次启用中断。
4.6 Page-fault exceptions
Xv6对异常的响应是相当固定:如果一个异常发生在用户空间,内核就会杀死故障进程。如果一个异常发生在内核中,内核就会panic。真正的操作系统通常会以更有趣的方式进行响应。
举个例子,许多内核使用页面故障来实现_写时复制(copy-on-write,cow)_fork。要解释写时复制fork,可以想一想xv6的fork,在第3章中介绍过。fork通过调用uvmcopy(kernel/vm.c:301)为子进程分配物理内存,并将父进程的内存复制到子进程中,使子进程拥有与父进程相同的内存内容。如果子进程和父进程能够共享父进程的物理内存,效率会更高。然而,直接实现这种方法是行不通的,因为父进程和子进程对共享栈和堆的写入会中断彼此的执行。
COW fork中的基本设计是父进程和子进程最初共享所有的物理页面,但将它们映射设置为只读(清除PTE_W flag)。父级和子级可以从共享的物理内存中读取。如果其中一个写入给定的页面,例如当子进程或父进程执行store指令时,RISC-V CPU会引发一个页面错误异常。内核的trap处理程序的响应方式是分配一个新的物理内存页面,并将故障地址映射到的物理页面复制到其中。内核将出错进程的页表中的相关PTE更改为指向副本并允许写入和读取,然后在导致错误的指令处恢复出错进程。由于PTE允许写入,因此重新执行的指令现在将无错误执行。写入时复制需要记录(book-keeping)以帮助决定何时可以释放物理页面,因为根据fork、页面错误、执行和退出的历史记录,每个页面可以由不同数量的页表引用。这种book-keeping方式实现了一个重要的优化:如果进程出现存储页错误,并且只从该进程的页表引用这个物理页,则不需要复制。
写入时复制使fork速度更快,因为fork不需要复制内存。在写入时,一些内存将不得不在稍后复制,但通常情况下,大多数内存永远不需要复制。一个常见的例子是fork后跟着exec:在fork之后可能会写入几页,但随后子进程的exec会释放从父进程继承的大部分内存。写入时复制fork消除了复制此内存的需要。此外,fork是透明的:不需要修改应用程序就能让它们受益。
除了fork之外,页表和页面错误的结合,将会有更多种有趣的可能性的应用。另一个被广泛使用的特性叫做惰性分配 (lazy allocation),它有两个部分。首先,当一个应用程序调用sbrk请求更多内存时,内核会注意到大小的增加,但不会分配物理内存,也不会为新的虚拟地址范围创建PTE。第二,当这些新地址中的一个出现页面错误时,内核分配物理内存并将其映射到页表中,就像fork一样,内核可以实现对应用程序透明的惰性分配。
由于应用程序通常要求比它们所需的内存更多的内存,因此惰性分配是一种优势:对于应用程序从未使用过的页面,内核根本不需要做任何工作。此外,如果应用程序要求将地址空间增加很多,那么不使用惰性分配的sbrk代价很高:如果应用程序要求1 GB内存,内核必须分配262,144个4096字节的页面,并使其内容为零。惰性分配允许这一成本随着时间的推移而分摊。另一方面,惰性分配会带来页面错误的额外开销,这涉及内核/用户转换。操作系统可以通过为每个页面错误分配一批连续的页面而不是一个页面,以及专门针对此类页面错误的内核进入/退出代码来降低这一成本。
另一个利用页面错误的广泛使用的功能是请求分页(demand paging)。在exec中,xv6会急切地将应用程序的所有文本和数据加载到内存中。由于应用程序可能很大,而且从磁盘读取的成本很高,因此用户可能会注意到这种启动成本:当用户从shell启动大型应用程序时,用户可能需要很长时间才能看到响应。为了缩短响应时间,现代内核为用户地址空间创建页表,但将页面的PTE标记为无效。发生页面错误时,内核从磁盘读取页面内容并将其映射到用户地址空间。就像fork和惰性分配一样,内核可以对应用程序透明地实现这一特性。
计算机上运行的程序可能需要比计算机RAM更多的内存。为了优雅地应对,操作系统可以实现到磁盘的分页(paging from disk)。其想法是只将一小部分用户页面存储在RAM中,其余的存储在磁盘上的分页区(paging area)中。内核将对应存储在分页区(因此不在RAM中)的内存的PTE标记为无效。如果应用程序尝试使用已调出(paged out)到磁盘的其中一个页面,应用程序将导致页面错误,并且该页面必须被调入(paged in):内核trap处理程序将分配一页物理RAM,将该页面从磁盘读取到RAM中,并修改相关PTE以指向RAM。
如果需要调入页面,但没有空闲的物理RAM,该怎么办呢?在这种情况下,内核必须首先释放物理页面,方法是将其调出(paging it out)或逐出(evicting)到磁盘上的分页区域,并将引用该物理页面的PTE标记为无效。逐出代价很高,所以如果不频繁分页,分页效果最好:如果应用程序只使用其内存分页的一个子集,而且这些子集的并集适合RAM。此属性通常被称为具有良好的引用局部性(good locality of reference)。与许多虚拟内存技术一样,内核通常以对应用程序透明的方式实现磁盘分页。
无论硬件提供多少RAM,计算机通常使用很少或没有可用的(free)物理内存进行操作。例如,云提供商在一台机器上多路复用多个客户,以经济高效地使用他们的硬件。又如,用户在智能手机上使用少量物理内存运行许多应用程序。在这样的设置中,分配页面可能需要首先逐出现有页面。因此,当空闲物理内存稀缺时,分配是昂贵的。
当空闲内存稀缺时,惰性分配和请求分页特别有利。在sbrk或exec中急于分配内存会产生额外的逐出成本,以使内存可用。此外,还存在浪费急切工作的风险,因为在应用程序使用页面之前,操作系统可能已经将其逐出。
结合了分页和分页错误异常的其他功能包括自动扩展栈(automatically extending stacks)和内存映射文件(memory-mapped files)。
4.7 Real world
trampoline和trapframe可能看起来过于复杂。一个驱动力是,RISC-V在强制设置trap时故意尽可能少做,以允许非常快速的trap处理的可能性,这被证明是重要的。因此,内核trap处理程序的前几条指令必须在用户环境中有效地执行:用户页表和用户寄存器内容。而且trap处理程序最初对有用的事实一无所知,比如正在运行的进程的ID或内核页表的地址。解决方案是可能的,因为RISC-V提供了受保护的地方,内核可以在进入用户空间之前在其中隐藏信息:sscratch寄存器,以及指向内核内存但由于缺少PTE_U而受到保护的用户页表项。Xv6的trampoline和trapframe利用了这些RISC-V功能。
如果将内核内存映射到每个进程的用户页表中(使用适当的PTE权限标志),就不需要特殊的trampoline页了。这也将消除从用户空间trap进入内核时对页表切换的需求。这也可以让内核中的系统调用实现利用当前进程的用户内存被映射的优势,从而允许内核代码直接解引用用户指针。许多操作系统已经使用这些想法来提高效率。Xv6没有实现这些想法,以减少由于无意使用用户指针而导致内核出现安全漏洞的机会,并减少一些复杂性,以确保用户和内核虚拟地址不重叠。
生产操作系统实现写入时复制fork、惰性分配、按需分页、分页到磁盘、内存映射文件等。此外,生产操作系统将尝试将所有物理内存用于应用程序或高速缓存(例如,文件系统的缓冲区高速缓存,我们将在后面的8.2节中介绍)。在这一点上,xv6很朴素:您希望操作系统使用您付费的物理内存,但xv6没有。此外,如果xv6内存不足,它会向正在运行的应用程序返回一个错误或终止它,而不是例如,收回另一个应用程序的一个页面。