[MIT 6.1810]Xv6 Chapter 4
Traps and system calls
有三种情况会导致CPU暂停普通指令的执行,并强制转移控制权到处理该事件的特殊代码。
- 系统调用:当用户程序执行 
ecall指令请求内核为其执行某项操作时,就会发生系统调用。 - 异常:当指令(无论是用户指令还是内核指令)执行了非法操作时,例如除以零或使用无效的虚拟地址,就会触发异常。
 - 设备中断:当某个设备发出信号表示它需要处理时,例如磁盘硬件完成了读写请求,就会发生设备中断。
 
xv6中用trap来代表以上三种情况。通常的执行流程如下:
- trap强制将控制权转移到内核
 - 内核保存寄存器和其他状态
 - 内核执行相应的处理代码
 - 内核恢复寄存器和其他状态,跳转到原来的代码继续执行
 
Xv6 的trap处理分为四个阶段:
- 第一阶段是 RISC-V CPU 执行的硬件操作
 - 第二阶段是一些汇编指令用于准备内核 C 代码
 - 第三阶段是 C 函数决定如何处理陷阱
 - 最后是系统调用或设备驱动程序服务例程。
 
尽管这三种trap类型之间有很多共同点,理论上可以用一条代码路径处理所有trap,但实际上将来自用户空间和来自内核空间的trap分开处理更为方便。处理trap的内核代码(汇编或 C)通常被称为处理程序,最开始的处理程序指令通常用汇编编写,称为向量(vector)。
4.1 RISC-V trap machinery
每个RISC-V的CPU都有一套控制寄存器,内核可以读写这些控制寄存器来告诉CPU如何处理trap或者了解发生了什么trap。riscv.h中包含了xv6用到的定义。
一些比较重要的寄存器:
- stvec:内核在此寄存器中写入trap处理程序的地址;RISC-V 发生trap时会跳转到 
stvec中的地址去处理trap。 - sepc:发生trap时,RISC-V 将程序计数器(PC)的值保存到此寄存器中(因为 PC 随后会被 
stvec中的值覆盖)。sret(从陷阱返回)指令会将sepc的值复制回 PC。内核可以通过写入sepc来控制sret的返回地址。 - scause:RISC-V 在此寄存器中存储一个数字,描述trap发生的原因。
 - sscratch:trap处理程序使用此寄存器帮助避免在保存用户寄存器之前覆盖它们。
 - sstatus:
sstatus中的 SIE(supervisor interrupt enable) 位控制设备中断是否启用。如果内核清除 SIE 位,RISC-V 将推迟设备中断,直到内核设置 SIE 位。SPP 位则指示trap是来自用户态还是S态,并且控制sret的返回态。 
以上寄存器只能在S态访问,用户态不能访问。每个CPU都有自己的一套控制寄存器,在某个时间可能有多个CPU在处理trap。
当需要处理一个trap时,硬件进行如下操作:
- 如果sstatus中的SIE位没有设置,并且trap的种类是设备中断,则不进行下列操作。
 - 清除sstatus中的SIE位来禁用设备中断。
 - 将pc的值拷贝到sepc中
 - 将当前的模式保存到sstatus中的SPP位中
 - 设置scause寄存器的值来标识trap的原因
 - 切换到S态
 - 将stvec的值拷贝到pc
 - 开始执行pc指向的指令
 
需要注意的是,硬件并不负责将页表切换到内核页表,也不负责切换到内核栈,也不会保存除了pc以外的其他寄存器的值,因此内核的软件必须负责这些。
4.2 Traps from user space
xv6中处理来自用户态的trap的路径大概如下:
- uservec(kernel/trampoline.S)
 - usertrap(kernel/trap.c)
 - 返回后,usertrapret(kernel/trap.c)
 - userret(kernel/trampoline.S)
 
Xv6 的trap处理设计中一个重要的限制是 RISC-V 硬件在触发trap时不会切换页表。这意味着 stvec 中的trap处理程序地址必须在用户页表中有一个有效的映射,因为trap处理代码开始执行时,用户页表仍然生效。此外,Xv6 的trap处理代码还需要切换到内核页表;为了在切换到内核页表后能够继续执行,内核页表也必须为 stvec 指向的处理程序地址提供映射。
Xv6 通过使用trampoline page来满足这些要求。该页包含了 uservec,即 Xv6 的trap处理代码,而 stvec 指向这个代码。该页被映射在每个进程的页表中,地址是 TRAMPOLINE,该页同样也被映射在内核页表中的 TRAMPOLINE 地址上。由于该页在内核地址空间中和用户地址空间中映射在相同的地址,因此trap处理程序在切换到内核页表后可以继续执行。
uservec 的代码在 trampoline.S 文件中(kernel/trampoline.S:22)。当 uservec 开始执行时,所有32个寄存器都包含了被中断的用户代码的值。这些32个寄存器的值需要保存到内存中,以便稍后内核在返回用户空间之前恢复它们。将这些值存储到内存需要使用一个寄存器来存放内存地址,但此时没有任何通用寄存器可用。RISC-V 提供了 sscratch 寄存器作为帮助。在 uservec 的开头,csrw 指令将 a0 保存到 sscratch 中。这样,uservec 就有了一个寄存器(a0)可以使用。
uservec 的下一个任务是保存32个用户寄存器。内核为每个进程分配了一页内存用于保存一个 trapframe 结构体,其中包含保存这32个用户寄存器的空间(见 kernel/proc.h:43)。因为此时 satp 仍然指向用户页表,因此 uservec 需要 trapframe 映射到用户地址空间中。Xv6 将每个进程的 trapframe 映射在该进程用户页表中的虚拟地址 TRAPFRAME,这个地址位于 TRAMPOLINE 的下方。每个进程的 p->trapframe 也指向 trapframe,但使用的是物理地址,以便内核通过内核页表访问它。
1  |  | 
因此,uservec 将地址 TRAPFRAME 加载到 a0 中,并将所有用户寄存器保存在此处,包括从 sscratch 中读取的a0。
1  |  | 
trapframe 还包含当前进程的内核栈地址、当前 CPU 的 hartid、usertrap 函数的地址以及内核页表的地址。uservec 从中读取这些值,将 satp 切换到内核页表,并跳转到 usertrap。注意在切换页表之前以及之后需要执行 sfence.vma 指令,第一次确保所有之前的内存操作使用的都是用户页表,并且操作都已经完成,然后切换到内核页表,再次执行该指令,确保TLB中缓存的之前用户页表的PTE都已经被刷新。
1  |  | 
usertrap 的任务是确定陷阱的原因,进行处理并返回(见 kernel/trap.c:37)。它首先将 stvec 改为指向 kernelvec,以便内核中发生的陷阱由 kernelvec 处理。它保存 sepc 寄存器(硬件保存的用户程序计数器),因为 usertrap 可能会调用 yield 切换到另一个进程的内核线程,而该进程可能会返回用户空间,并修改 sepc。根据scause寄存器的值进行trap原因的判断(由硬件设置):如果陷阱是系统调用,usertrap 调用 syscall 处理;如果是设备中断,调用 devintr;否则是异常,内核会终止出错的进程。在处理系统调用时,系统调用路径会将保存的用户程序计数器增加4,因为 RISC-V 在系统调用的情况下,会将程序计数器停留在 ecall 指令上,但用户代码需要从后续的指令继续执行。
1  |  | 
返回用户空间的第一步是调用 usertrapret(见 kernel/trap.c:90)。这个函数设置 RISC-V 控制寄存器,为用户空间的未来陷阱做好准备:将 stvec 设置为 uservec 并准备 trapframe 字段,这是 uservec 依赖的内容。usertrapret 将 sepc 设置为先前保存的用户程序计数器。最后,usertrapret 调用位于trampoline page上的 userret,该函数的代码映射在用户和内核页表中,原因是 userret 的汇编代码需要切换页表。
1  |  | 
usertrapret 调用 userret 时,将进程的用户页表的指针传递给 a0(见 kernel/trampoline.S:101)。userret 将 satp 切换到进程的用户页表。回想一下,用户页表映射了trampoline page和 TRAPFRAME,但没有其他内核内容。trampoline page在用户和内核页表中的相同虚拟地址映射允许 userret 在切换 satp 后继续执行。从此时开始,userret 能使用的唯一数据是寄存器的内容和 trapframe 的内容。userret 将 TRAPFRAME 地址加载到 a0,通过 a0 从 trapframe 中恢复保存的用户寄存器,恢复保存的 a0,并执行 sret 返回用户空间。
1  |  | 
4.3 Code:Calling system calls
首先将系统调用的参数放进寄存器a0-a6中,然后将系统调用号放进寄存器a7中。 usertrap 会判断当前发生的trap是系统调用,然后调用 syscall 函数去执行。 syscall 函数从trapframe中取得参数和系统调用号,然后用系统调用号作为索引取得系统调用处理程序的地址执行。当系统调用返回的时候,会把返回值存放在 p->trapframe->a0 中。
initcode.S系统调用 exec(init, argv)
1  |  | 
1  |  | 
4.4 Code:System call arguments
系统调用需要获取用户调用系统调用时传递的参数。这个参数最开始被放到用户寄存器中,之后在 usertrap 中被保存到trapframe中。内核通过 argint 、argaddr 、argfd 从trapframe中获取第n个系统调用参数(整数、指针、文件描述符)。它们都通过 argraw 来获取trapframe中的寄存器的值。syscall在usertrap中执行,此时已经切换到S态,寄存器的值都被保存到trapframe中,但是由于已经切换到内核页表,因此无法通过虚拟地址访问trapframe,可以通过p->trapframe直接获取trapframe的物理地址进行访问。
1  |  | 
有些系统调用会传递指针作为参数,这会导致两个问题:
- 用户程序可能是恶意的或者有bug,导致传递进来的地址是无效的。
 - 由于内核页表和用户页表映射不同,用户传递的地址无法直接使用。
 
内核提供了函数来从用户地址中读写数据。
 exec 使用 fetchstr 来从用户空间中获取一个字符串参数。 fetchstr 调用 copyinstr 来完成数据拷贝的任务。
1  |  | 
 copyinstr 至多从用户页表pagetable中的虚拟地址srcva指定的地方拷贝max字节到dst指定的地方。
因为pagetable不是当前的页表,所以 copyinstr 使用 walkaddr 在pagetable中查找虚拟地址srcva,并获得相应的物理地址pa0。之后可以直接进行拷贝操作。类似的,copyout 将数据从内核拷贝到用户空间中。
4.5 Traps from kernel space
xv6以一种不同的方式处理来自内核的trap。当进入内核后,usertrap会将stvec的值指向kernelvec。因为执行kernelvec的时候,一定已经在S态了,satp已经是内核页表,并且sp也指向内核栈。kernelvec将32个寄存器的值保存到内核栈中。之后会跳转到kerneltrap执行。
1  |  | 
kerneltrap只处理两种类型的trap:设备中断和异常。它调用devintr来处理设备中断。如果不是设备中断,那就一定是异常,内核中的异常通常意味着fatal error,内核调用panic之后停止执行。
如果kerneltrap处理的是时钟中断,并且当前进程的内核线程正在运行,kerneltrap会调用yield将CPU使用权让出来。
1  |  | 
当kerneltrap完成后,它需要返回之前被中断的代码继续执行。因为yield可能会破坏sepc和sstatus中的值,所以kerneltrap需要在开始的时候保存它们。因此,kerneltrap完成后需要先恢复这些控制寄存器的值,然后返回到kernelvec。kernelvec从栈中恢复寄存器的值,然后执行sret指令,将sepc的值拷贝到pc中。
1  |  | 
当从用户态进入内核的时候,xv6会设置CPU的stvec指向kernelvec。在内核开始执行和stvec指向kernelvec之间会有一段时间(执行uservec的代码的时候已经处于S态,但是直到usertrap中才修改stvec的值指向kernelvec),RISC-V在处理一个trap的时候关闭设备中断,usertrap直到设置好stvec之后才重新使能设备中断,因此可以确保这段时间不会有设备中断,否则就会导致来自S态的trap错误的被uservec处理。
4.6 Page-fault exceptions
xv6对异常的处理比较简单,如果异常来自用户态,则内核杀死发生异常的进程,如果异常来自S态,则内核panics。真实的操作系统往往有更加复杂的处理方式。
写时复制
许多内核通过页错误来实现写实复制(copy on write, COW)fork。考虑xv6实现的fork,通过uvmcopy分配内存并将父进程的内存拷贝一份。如果可以共享父进程的内存将提高效率,然后,普通的实现将导致父子进程互相干扰。
为了解决这个问题,父子进程可以通过恰当的设置页表权限和页错误来解决这个问题。当:
- 某个虚拟地址在页表中没有映射
 - 页表项的v位置0
 - 页表项的某些权限位禁止操作
 
的时候,CPU会发起一个页错误异常。
xv6中区分三种页错误:
- 读页错误
 - 写页错误
 - 指令页错误
 
通过scause寄存器指明页错误的种类,stval寄存器保存发生错误的虚拟地址。
COWfork的想法是初始的时候父子进程共享相同的物理页,但是父子进程页表中相应的页表项标记为只读(PTE_W置0)。父子进程可以顺利的读共享的页,但是当任何一个进程试图写共享的页的时候,会引发一个页错误。内核此时在复制一份共享的页面,然后修改相应进程的页表中的页表项,然后返回到发生异常的指令重新执行。
COW需要一个记录来帮助决定什么时候可以释放掉一个物理页面,因为每个物理页面可能会被多个进程共享。有了这个记录,在发生写页错误的时候,内核可以根据记录来查看当前共享这个物理页面的进程数,如果只有一个进程,那么就无需进行复制(假设父子进程共享一个页面,然后子进程退出了,父进程此时再写会触发页错误,但是无需复制)。
lazy allocation
通过页错误,我们还可以实现lazy allocation。当一个进程通过sbrk申请更多内存的时候,内核仅仅记录大小的更改,但是不实际申请物理页和创建页表项。但在这些新的虚拟地址上发生页错误的时候,内核再实际去申请物理内存。
lazy allocation的优势:
- 因为应用总是会申请更多的内存(实际不需要这么多),所以lazy allocation很有必要。
 - 当应用申请很大的内存的时候,一次sbrk的开销是很大的,可以通过lazy allocation平均这些开销。
 
lazy allocation的缺点:
会引发页错误,导致用户态到S态的切换。可以通过每次申请一连串的页来减少页错误的次数;也可以通过特化页错误的代码来降低开销。
请求分页
在xv6中,调用exec的时候会把程序的完整镜像加载到内存中,如果镜像很大,这个开销会非常大。现代操作系统通过请求分页机制来分摊这个开销。通常只是在页表中建立相应的页表项,但并不实际读取硬盘,将页表项的PTE_V字段置0,当程序实际访问该虚拟地址指向的内容的时候,在将物理页读入内存。
运行在计算机上的程序可能需要的内存超出计算机的 RAM。为了解决这个问题,操作系统可以实现磁盘分页。其基本思路是将仅一部分用户页面存储在 RAM 中,而将其余页面存储在磁盘的分页区域中。内核会将对应于存储在分页区域(即不在 RAM 中)的内存的页表项(PTE)标记为无效。如果应用程序尝试使用已经分页到磁盘的页面,就会发生页面错误,此时需要将该页面调入:内核的trap处理程序将分配一页物理 RAM,将页面从磁盘读入 RAM,并修改相关的 PTE 以指向该 RAM。
如果需要调入的页面时没有空闲的物理 RAM,会发生什么呢?在这种情况下,内核必须首先通过将一页物理内存分页出去或驱逐到磁盘的分页区域来释放一页物理页面,并将引用该物理页面的 PTE 标记为无效。驱逐操作开销较大,因此分页性能最佳的情况是尽量少发生页面错误:如果应用程序只使用其内存页面的一个子集,而这些子集的并集可以分配在 RAM 中,这种属性通常被称为良好的局部性。
在许多虚拟内存技术中,内核通常以对应用程序透明的方式实现磁盘分页。尽管硬件提供了大量的 RAM,但计算机通常会有很少或没有空闲的物理内存。例如,云服务提供商通常在一台机器上多路复用多个客户,以高效利用硬件成本;另一个例子是用户在智能手机上运行多个应用程序,而物理内存又非常有限。在这种情况下,分配一个页面可能需要首先驱逐一个现有页面。因此,当空闲物理内存稀缺时,分配成本较高。
懒惰分配和按需分页在空闲内存稀缺且程序仅积极使用其分配内存的一部分时尤其有利。这些技术还可以避免在页面被分配或加载但从未使用或在使用之前被驱逐时浪费的工作。
结合分页和页面错误异常的其他特性包括自动扩展堆栈和内存映射文件(memory-mapped files),即程序通过 mmap 系统调用映射到其地址空间的文件,使得程序可以通过加载和存储指令读取和写入这些文件。