第四章 陷阱与系统调用

有三种事件会让 CPU 搁置正常的指令执行流并强制将控制转移到处理该事件的特殊代码。一种情况是系统调用,当一个用户程序执行 ecall 指令请求内核做一些事情。另一种情况是例外:一个指令做了一些不合法的,例如除以0,或者使用无效的虚拟地址。第三种情况是设备中断,当一个设备信号发出需要注意的信号,例如当硬盘完成了一次读或者写请求。

本书使用 陷阱(trap) 作为这些通用情况的术语。通常来说,当代码在执行时发生 陷阱 ,后续需要恢复,并且不需要知道发生了什么。所以,我们经常希望 陷阱 是透明的。这对中断来说尤其重要,这是被中断代码通常不期望的。通常的流程是 陷阱 降至将控制权转给内核;内核保存寄存器和其他状态以让执行能够恢复;内核执行合适的处理代码;内核回复保存的状态并从 陷阱 中恢复;原来的代码从离开的地方恢复。

xv6 内核处理了所有的 陷阱。这对系统调用来说是很自然的。这对中断来说是有意义的,因为隔离性要求用户程序不直接使用设备,且只有内核拥有设备处理需要的状态。这对例外来说也是有意义的,因为 xv6 对所有的例外响应都是杀死对应的程序。

xv6 对陷阱的处理有四个阶段:RISC-V cpu 采用硬件操作,一个为内核准备的 C 代码。一个 C 陷阱处理代码决定如何处理这个陷阱,是系统调用还是设备驱动服务。虽然三种陷阱的共性表明可以用一个代码进行处理,事实上对三种情况处理更方便,来自用户空间的陷阱,来自内核空间的陷阱,时钟中断。

4.1 RISC-V 陷阱装置

每个 RISC-V CPU 有一组内核可写的控制寄存器以告诉 CPU 如何处理陷阱,同时内核可以读这些寄存器以理解到发生了什么陷阱。RISC-V 的文档有这些全部的定义。riscv.h 包含了 xv6 使用的定义。这有一份大纲包含重要的寄存器。

  • stvec:内核将陷阱处理代码的地址写在这里;RISC-V 跳转到这里以处理陷阱。
  • sepc:当陷阱发生的时候,RISC-V 将程序计数器保存在这里,因为 pc 会被 stvec 重写。sret 指令会拷贝 sepc 到 pc 中,内和可以写 sepc 来控制 sret 的目标地址。
  • scause: RISC-V 放一个数字在这里,描述陷阱发生的原因。
  • sscratch: 内核在这里放置一个值,在陷阱开始处理之前使用。(所以到底是干啥的)
  • sstatus: 这个寄存器的 SIE 比特位控制哪个设备终端被允许。如果内核清除了 SIE,RISC-V 将会推迟设备终端,直到内核重新设置 SIE。SPP 比特位表明这些陷阱来自用户模式还是监督者模式,并且控制 sret 返回到哪个模式。

上述寄存器是在 s-mod 中处理陷阱使用的,它们不能在用户模式下读写。还有一组等效的控制寄存器用于 m-mod;xv6 仅将他们用于定时器中断这个特殊情况。

多核上的每个 CPU 都有自己的寄存器,并且可能会有很多 CPU 同时处理陷阱。

当需要强制进入陷阱时,RISC-V 硬件会进行以下步骤(除了时钟中断)

  1. 如果是设备中断,并且 sstatus 的 SIE 位被清除,不做任何操作
  2. 清除 SIE ,以关中断
  3. 将 pc 值复制到 spec
  4. 在 sstatus 的 SPP 位上保存当前模式
  5. 设置 scause 保存陷阱原因
  6. 设置为 s-mod
  7. 将 pc 值设置为 stvec 的值(这个stvec 的值是谁设置的)
  8. 在 新的 pc 处开始执行

注意,CPU 并不切换内和页表,不切换内核栈,不保存除了 pc 以外的寄存器值。内核必须执行这些内容。CPU 在陷阱期间做少量工作的原因是给系统提供灵活性。例如,一些操作系统在某些情况下不需要页表,这能提升性能。

你可能想知道 CPU 硬件陷阱处理是否还能进一步简化。例如,认为 CPU 不需要切换程序计数器。然后陷阱能切换到 s-mod 当在运行用户指令时。这些用户指令会破坏用户内核的隔离性,例如通过修改 satp 寄存器指向页表以访问所有的物理内存。因此,CPU 切换到内核指定地址很重要。

4.2 用户空间陷阱

在用户空间,陷阱会发生在执行系统调用,或者做一些非法操作,或者如果设备中断。来自最高级的用户陷阱是 uservec(kernel/trampoline.S),然后是 usertrap(kernel/trap.c) 然后是返回 usertrapret(kernel/trap.c) 然后是 userret(kernel/trampoline.S)

来自用户代码的陷阱比内核更具有挑战性,因为 satp 指向的用户页表没有内核地址的映射,并且对战指针可能包含无效的或者恶意的值。

因为 RISC-V 硬件在中断期间不切换页表,用户页表必须包含 uservec 的映射。而uservec 必须 切换 satp 到内核页表上。为了在切换后继续执行指令,uservec 必须在内核页表中映射用户页表相同的地址。这个虚拟地址就是 TRAMPOLINE。这段代码的内容就是 trampoline.Sstvec 将会被设置为 uservec

uservec 启动的时候,所有的 32 个寄存器都包含着中断代码所用的值。但是 uservec 需要能够修改一些寄存器的值,以设置 satp,并且生成保存寄存器的地址。 RISC-V 提供了一个 sscratch 寄存器协助完成这个事情。csrrw 指令一开始交换 a0sscratch。现在,用户代码的 a0 被保存了。uservec 就有了一个 a0 寄存器使用。这个 a0 就是内核先前放在 sscratch 中的值。

uservec 的下一个任务是保存用户寄存器。在进入用户空间之前,内核提前设置了 sscratch 来指向没一个进程的 trapframe 。这个 陷阱帧保存用于保存所有的用户寄存器。因为 satp 指向用户页表,uservec 需要在用户空间映射。在 xv6 创建没一个线程时,xv6 分配一个页,用于进程的陷阱帧,并且将其映射到虚拟地址 TRAPFRAME,这在 TRAMPOLINE 下。进程的 p->trapframe 指向的是物理地址,这样内核可以直接使用。

因此在交换 a0sscratch 后,a0 指向当前进程的trapframeuservec 保存了所有的用户寄存器,包括 a0。

trapframe 包含当前进程的内核栈,当前cpu 的 id。usertrap 的地址。内核页表的地址。uservec 会检索这些值,切换 satp 到内核页表,然后调用 usertrap

usertrap 的工作是检测陷阱的原因,处理,并返回。就像上边提到的,首先修改 stvec 以让 kernelvec 能处理这个陷阱。再次保存 spec 的值,因为可能因为程序切换 usertrap 重写 sepc 。如果这个陷阱是系统调用,syscall 处理。如果是设备中断devintr。不然就是一个例外,内核会杀死这个错误的程序。如果是系统调用的话,会将保存的 pc 值加上 4,因为系统调用会让程序指针指向 ecall 指令。在返回时,usertrap 会检查程序是否被杀死或应该让出 cpu(时钟中断)。

返回用户空间第一步是调用 usertrapret。这个函数会设置 RISC-V 的控制寄存器,为后来的用户空间陷阱做准备。这会让 stvec 指向 uservec,准备 trapframe 的字段,将 sepc 设置为保存的程序计数器的值。最后,usertrapret 调用在内核和用户都有映射的 userret。因为 userret 将会切换页表。

usertrapret 调用 userret 通过在 a0 中传递程序的用户页表,a1 传递 TRAPFRAME。userret 切换 satp 到进程用户页表。userret 拷贝trapframe 保存的用户 a0 到 sscratch 中,以在 TRAPFRAME 中交换。从这一点来说,userret 能够使用的数据是寄存器和陷阱中的内容。接下来,userret 从 trapframe 中恢复保存的用户寄存器。最终交换 a0 和 sscratch 以回复用户 a0 并为下一次陷阱保存 TRAPFRAME。最后使用 sret 返回用户空间。

4.3 code: 进行系统调用

第二章在 initcode.S 调用 exec 系统调用结束。让我们看看内核是如何实现用户调用 exec 系统调用的。

用户代码将参数放在 a0,a1 中传递给 exec。并将系统调用号放在 a7 中。系统调用匹配 syscalls 数组。ecall 指令陷入内核,并执行 uservec,usertrap 最后执行 syscall。(这里讲的有点混乱,匹配 syscalls 是在处理中断之后才做的。而且第一次 initcode 从内核返回用户态也没讲到。)

syscall 检索 trapframe 中存储的 a7 值,对于第一个系统调用,a7 包含 SYS_exec,调用系统实现函数 sys_exec

当系统调用实现返回时,syscall 记录返回值在 p->trapframe->a0 中。用户的 exec() 将会返回这个值,因为 RISC-V 的 c 函数调用约定使用 a0 作为返回值。系统调用约定返回负值表示错误。零 或者 正数表示成功。如果系统调用号不合法,返回 -1。

4.4 系统调用参数

内核中的系统调用实现需要找到用户传递的参数。因为用户使用函数封装调用,参数根据 RISC-V 的c 函数调用的原则放在寄存器中。内核陷阱代码保存用户寄存器到进程的陷阱帧中,内核代码能够找到这些值。函数 argint,argaddrargfd 在陷阱帧中检索第 n 个系统调用参数,这些参数是整数,指针,或者文件描述符。他们都会调用 argraw 来检索用户寄存器保存的值。

一些系统调用传递指针作为参数,内核必须使用这些指针来读写用户的内存。例如 exec 系统调用,传递一个指向参数的指针数组。这些指针提出了两个挑战。首先,用户程序可能是有害的,可能给内核传递一个非法指针或者这个指针欺骗内核访问内核地址空间而不是用户地址空间。其次,xv6 内核页表并没有映射用户页表,所以内核不能使用普通的指令从用户地址读写。

内核实现了安全的函数传递数据。fetchstr 是一个例子。文件系统调用例如 exec 使用 fetchstr 检索来自用户空间的文件名。fetchstr 调用 copyinstr 来完成这个工作。

copyinstr 拷贝从 srcva 开始的最多 max 个字节。它使用 walkaddr 函数以在页表上找到 srcva 对应的物理地址 pa0。因为内核直接映射了所有的物理地址,copyinstr 能够直接从 pa0 拷贝字符串到 dstwalkaddr 检查用户提供的虚拟地址是用户地址空间的一部分。所以程序不能误导内核读取其他内存。还有一个很相似的函数 copyout,从内核拷贝数据到用户提供的地址。

4.5 来自内核的陷阱

xv6 根据是否用户或内核代码正在执行,配置 CPU 陷阱寄存器有些不同。当 CPU 在执行内核代码时,内核 stvec 指向 kernelvec。因为 xv6 已经在内核中,kernelvecsatp 已经指向内核页表,以及指向有效内核栈的栈指针。kernelvec 保存所有的寄存器以从中断中恢复。

kernelvec 将寄存器保存在陷入中断的内核线程栈上,这是有意义的,因为这些寄存器属于该线程。这对那些造成线程切换的陷阱来说尤其重要,因为陷阱将会返回到一个新的线程上,被保存的寄存器值仍然安全的在其自己的栈上。

在保存寄存器后,kernelvec 跳转到 kerneltrapkerneltrap 有两种类型的陷阱:设备中断和例外。调用 devintr 来处理设备中断。如过陷阱不是设备终端,就是一个例外,这在 xv6 里会始终造成一个致命错误。内核将调用 panic 并停止执行。

如果 kerneltrap 是由于时钟中断造成的,并且一个进程的内核线程正在执行(不是调度线程),kerneltrap 调用 yield 以让出 cpu 来让其他线程选择运行。在某一时刻,其他的某个线程也会执行 yield,来让我们的线程和 kerneltrap 再次运行。第七章将会解释 yeild 发生了什么。

kerneltrap 完成时,它需要返回被中断的代码。由于 yield 可能造成 sepc 和 保存的 sstatus 改变,kerneltrap 在开始时保存了它们,现在,恢复这些控制寄存器并返回到 kernelvec 中。kernelvec 弹出保存在栈上的寄存器,并执行 sret。这会拷贝 sepc 的值到 pc 并从中断的内核代码恢复。

值得思考的是,kerneltrap 由于时钟中断调用 yield 时陷阱最终是如何返回的。

当 CPU 从用户态进入内核态时,xv6 设置 CPU 的 stveckernelvec。当内核开始执行到设置 stvec 设置 uservec 有一个时间窗口,在该期间禁用设备中断相当重要。幸运的是,RISC-V 总是在 trap 期间禁用中断,并且 xv6 在设置 stvec 之前都不允许启用中断。

4.6 页表例外

xv6 对于例外的响应相当无聊,如果例外发生在用户空间,内核将杀死这个错误的进程。如果例外发生在内核,内核会 panic。真实的操作系统在响应例外时会有很多有趣的方式。

例如,许多内核使用页表错误实现 copy-on-write (COW) fork。为了解释 copy-on-write fork,参考 xv6 的 forkfork 会造成子进程拥有和父进程同样的内存内容,通过调用 uvmcopy 分配物理内存并将父进程的内容拷贝给子进程。如果父进程和子进程共享物理内存,效率会更高。然而,一个直接的实现是行不通的,因为这会造成父子进程在各自堆栈进行读写时混乱。

父子进程可以 copy-on-write fork 安全的共享物理内存,这一技术通过 page fault。当一个 CPU 不能将虚拟地址翻译为物理地址时,CPU 生车一个 page-fault 例外。RISC-V 有是那种不同类型的页错误:load page faults 当加载指令不能翻译它的虚拟地址时,store page fault 当存储指令不能翻译它的虚拟地址时。instruction page faults 当地址上的指令不能执行的时候。scause 寄存器的值将会标识是哪种 page fault 并且 stval 寄存器包含了不能翻译的地址。

COW fork 的基本方案是父子进程最初共享所有的物理页,但将他们均映射为 read-only。因此,当父进程或子进程执行存储指令时,RISC-V CPU 发生一个 page-fault 例外。为了响应这个例外,内核生成一个包含该地址的页的拷贝。将这个页映射为读写到子进程的地址空间,另一个拷贝映射为读写到父进程地址空间。更新u页表后,内核重新执行造成此多无的进程。由于内核已经更新的关联的 PTE ,这次执行将不会再有这个错误。(译者注:这里的意思是,因为父子进程指向同样的物理地址空间,他们可以读,但并不能同时写,所以在写的时候,将他们指向不同的物理地址,这样就能分开了。但是译者认为。这样的做法比在 fork 时完整复制物理页表显得不够干净,将这种缺页错误分散在了程序运行的各个时段,并且每次陷入内核开销更大)

这个 COW 方案对 fork 来说是ok 的,因为子进程经常在调用 fork 后调用 exec,用新的地址空间替换原来的地址空间。这样的话,子进程将只会发生少量的 page-fault,并且内核能够避免完全拷贝。进一步来说,COW 是透明的,对应用程序来说不需要做任何修改即可使用。

页表和页错误的组合开辟了除 COW 以外的有趣可能。另一个被广泛使用的特性叫做lazy allocation,这包括两部分,首先,当一个程序掉哟个你 sbrk 时,内核增长其地址空间,但是将新的地址标记为 invalid。第二,当页错误发生在这个新地址上时,内核分配物理内存并将其映射到页表上。由于应用程序进程请求比他们需要的更多的内存,懒分配就有了优势,内核仅仅在应用程序实际使用的时候才为他们分配内存。和 COW 一样, 内核也能将其实现为对应用程序透明。

另一个广泛使用利用 page-fault 的是 paging from disk。如果一个应用程序需要的内存比物理内存还多,内核可以驱逐一些页,将他们写入硬盘并将他们的 PTEs 标志为 invalid。如果一些应用程序读或写驱逐页,CPU 会遇到页错误。内核能够检查错误的地址,从磁盘中读出内存内容到内存中,更新 PTE 为 valid 并指向内存,然后继续运行应用程序。为了给这个页腾出空间,内核可能需要取出其他的页。这个特性需要对应用程序无修改。

其他结合页表和页错误的特性还包括自动扩展栈和内存映射文件。

4.7 真实世界

如果将整个内核内存映射到每个进程的用户页表,就可以不再使用特殊的 trampoline page。从用户到内核是也不再需要切换页表。反过来,内核中的有了用户地址,也能直接解引用用户指针。许多操作系统使用这个想法提高效率。xv6 避免使用的原因是因为减少直接使用用户指针可能存在的安全问题,和减少内核虚拟地址和用户虚拟地址不重叠的复杂性。

4.8 练习

  1. copyincopyinstr 以软件的形式遍历页表。调整内核页表以有用户程序的映射,让 copyincopystr 能够直接使用 memcpy 拷贝系统调用参数到内核空间,以硬件的方式。
  2. 实现 lazy allocation
  3. 实现 COW fork

你可能感兴趣的:(第四章 陷阱与系统调用)