xv6 book Chapter4 中文翻译

〇、前言

本文是 xv6 book 第四章的翻译,以下将开始翻译。

一、(译文)第四章 陷入和系统调用

这里有三种事件会导致 CPU 暂停正常执行指令,并强制转移控制权到处理该事件的特殊代码。一种情况是系统调用,当用户程序执行 ecall 指令请求内核为其执行某些操作时。另一种情况是异常:指令(用户或内核)执行了一些非法操作,比如除以零或使用无效的虚拟地址。第三种情况是设备中断,当设备发出信号需要注意时,例如当磁盘硬件完成读取或写入请求时。

本书将这些情况统称为“陷阱”。通常情况下,在陷阱发生时正在执行的代码将稍后需要恢复,并且不应该意识到发生了任何特殊情况。换句话说,我们通常希望陷阱是透明的;这对于中断特别重要,因为被中断的代码通常不会预料到这种情况。通常的顺序是:陷阱强制将控制权转移到内核;内核保存寄存器和其他状态,以便可以恢复执行;内核执行适当的处理程序代码(例如系统调用实现或设备驱动程序);内核恢复保存的状态并从陷阱返回;原始代码从中断处继续执行。

xv6 内核处理所有陷阱。这对于系统调用是很自然的。对于中断来说也是合理的,因为隔离要求用户进程不直接使用设备,并且因为只有内核具有处理设备所需的状态。对于异常也是合理的,因为 xv6 对来自用户空间的所有异常都会终止有问题的程序。

xv6 的陷阱处理分为四个阶段:RISC-V CPU 执行的硬件动作,准备用于内核 C 代码的汇编“向量”,决定如何处理陷阱的 C 陷阱处理程序,以及系统调用或设备驱动服务例程。虽然三种陷阱类型之间的共性表明内核可以使用单个代码路径处理所有陷阱,但实际上为三种不同情况准备单独的汇编向量和 C 陷阱处理程序更加方便:来自用户空间的陷阱、来自内核空间的陷阱以及定时器中断。

4.1 RISC-V 陷入机制

每个 RISC-V CPU 都有一组控制寄存器,内核写入这些寄存器以告诉 CPU 如何处理陷阱,并且内核可以读取这些寄存器以了解发生的陷阱信息。RISC-V 文档包含了完整的说明。riscv.hkernel/riscv.h:1)包含了 xv6 使用的定义。以下是最重要的寄存器概述:

  • stvec:内核在此处写入其陷阱处理程序的地址;RISC-V 会跳转到这里来处理陷阱。
  • sepc:当发生陷阱时,RISC-V 会将程序计数器保存在这里(因为此时程序计数器被覆写为 stvec 的值)。sret(从陷阱返回)指令将 sepc 的值复制到程序计数器 pc 中。内核可以写入 sepc 控制 sret 返回的位置。
  • scause:RISC-V 在此处放置一个描述陷阱原因的编号。
  • sscratch:内核在这里放置一个在陷阱处理程序开始时非常方便的值。
  • sstatus:sstatus 中的 SIE 位控制设备中断是否启用。如果内核清除了 SIE,RISC-V 将推迟设备中断,直到内核设置 SIE。SPP 位指示陷阱来自用户模式还是监管模式,并控制 sret 返回的模式。

以上寄存器与在监管模式下处理的陷阱相关,并且在用户模式下无法读取或写入这些寄存器。对于在机器模式下处理的陷阱,有一组等效的控制寄存器;xv6 仅在定时器中断的特殊情况下使用它们。

多核芯片上的每个 CPU 都有自己的一组这些寄存器,并且可能有多个 CPU 同时处理一个陷阱。

当需要触发陷阱时,RISC-V 硬件对所有陷阱类型(除定时器中断外)执行以下操作:

  1. 如果陷阱是设备中断,并且 sstatus 中的 SIE 位被清除,则不执行以下任何操作;
  2. 通过清除 SIE 来禁用中断;
  3. 将程序计数器 pc 的值复制到 sepc
  4. sstatus 的 SPP 位中保存当前模式(用户模式或监管模式);
  5. 设置 scause 反映陷阱的原因;
  6. 将模式设置为监管模式;
  7. stvec 的值复制到 pc
  8. 开始在新的程序计数器 pc 上执行。

请注意,CPU 在陷阱发生时不会切换到内核页表,不会切换到内核中的堆栈,并且不会保存除 pc 之外的任何寄存器。这些任务必须由内核软件执行。

CPU 在陷阱期间做最少的工作之一是为软件提供灵活性;例如,某些操作系统在某些情况下不需要进行页表切换,这可以提高性能。

你可能会想知道 CPU 硬件的陷阱处理序列是否可以进一步简化。例如,假设 CPU 不切换程序计数器。那么,在运行用户指令的同时,一个陷阱可以切换到监管模式。这些用户指令可能会破坏用户/内核隔离,例如通过修改 satp 寄存器以指向允许访问所有物理内存的页表。因此,CPU 切换到内核指定的指令地址,即 stvec,是非常重要的。

4.2 来自用户态的陷入

陷阱可能在用户空间执行时发生,如果用户程序进行系统调用(ecall 指令),或者执行了非法操作,或者设备发生了中断。从用户空间发生陷阱的高级路径是 userveckernel/trampoline.S:16),然后是 usertrapkernel/trap.c:37);返回时是 usertrapretkernel/trap.c:90),然后是 userretkernel/trampoline.S:16)。

与来自内核的陷阱相比,来自用户代码的陷阱更具挑战性,因为 satp 指向不映射内核的用户页表,并且堆栈指针可能包含无效甚至恶意的值。由于 RISC-V 硬件在陷阱期间不会切换页表,因此用户页表必须包含一个用于 uservec 的映射,即 stvec 指向的陷阱向量指令。uservec 必须将 satp 切换到内核页表;为了在切换后继续执行指令,uservec 必须在内核页表中与用户页表相同地址映射。

xv6 使用一个包含 uservec 的跳板页面来满足这些约束。xv6 在内核页表和每个用户页表中都映射了跳板页面的相同虚拟地址。这个虚拟地址是 TRAMPOLINE(正如我们在图 2.3 和图 3.3 中看到的)。跳板的内容在 trampoline.S 中设置,(在执行用户代码时)stvec 被设置为 userveckernel/trampoline.S:16)。

uservec 开始时,所有 32 个寄存器都包含被中断代码所拥有的值。但是 uservec 需要能够修改某些寄存器,以设置 satp 并生成要保存寄存器的地址。RISC-V 在 sscratch 寄存器的形式上提供了帮助。uservec 开始处的 csrrw 指令交换了 a0sscratch 的内容。现在,用户代码的 a0 已经被保存;uservec 有一个寄存器(a0)可以使用;而 a0 包含了内核之前放置在 sscratch 中的值。

uservec 的下一个任务是保存用户寄存器。在进入用户空间之前,内核之前将 sscratch 设置为指向每个进程的 trapframe,其中(除其他内容外)有空间保存所有用户寄存器(kernel/proc.h:44)。因为 satp 仍然引用用户页表,uservec 需要将 trapframe 映射到用户地址空间。在创建每个进程时,xv6 为进程的 trapframe 分配一个页面,并安排它始终映射到用户虚拟地址 TRAPFRAME,即 TRAMPOLINE 的正下方。进程的 p->trapframe 也指向 trapframe,尽管是其物理地址,因此内核可以通过内核页表使用它。

因此,在交换了 a0 和 sscratch 后,a0 包含指向当前进程 trapframe 的指针。uservec 现在在那里保存了所有用户寄存器,包括从 sscratch 读取的用户的 a0

trapframe 包含指向当前进程内核栈、当前 CPU 的 hartidusertrap 地址和内核页表地址的指针。uservec 检索这些值,将 satp 切换到内核页表,并调用 usertrap

usertrap 的任务是确定陷阱的原因,处理它,并返回(kernel/trap.c:37)。如上所述,它首先更改 stvec,以便在内核中发生陷阱时由 kernelvec 处理。它保存 sepc(保存的用户程序计数器),因为在 usertrap 中可能会有进程切换,可能会导致 sepc 被覆写。如果陷阱是系统调用,syscall 处理它;如果是设备中断,devintr 处理;否则是异常,内核会终止出错的进程。系统调用路径将保存的用户 pc 值加上四,因为在系统调用的情况下,RISC-V 会让程序指针指向 ecall 指令。在退出时,usertrap 检查进程是否已被终止或应该让出 CPU(如果此陷阱是定时器中断)。

返回到用户空间的第一步是调用 usertrapretkernel/trap.c:90)。该函数设置 RISC-V 控制寄存器以准备从用户空间发生未来陷阱。这包括将 stvec 更改为指向 uservec,准备 uservec 依赖的 trapframe 字段,并将 sepc 设置为先前保存的用户程序计数器。最后,usertrapret 在映射到用户和内核页表中的跳板页面上调用 userret;原因在于 userret 中的汇编代码将切换页表。

usertrapret 调用 userret 时在 a0 中传递了指向进程用户页表的指针,并将 TRAPFRAME 传递到 a1 中(kernel/trampoline.S:88)。userretsatp 切换到进程的用户页表。回想一下,用户页表将跳板页面和 TRAPFRAME 映射,但不映射内核的其他内容。再次强调,跳板页面在用户和内核页表中映射到相同的虚拟地址是允许 uservec 在更改 satp 后继续执行的原因。

userrettrapframe 中保存的用户 a0 复制到 sscratch 中,以备稍后与 TRAPFRAME 进行交换。从这一点开始,userret 只能使用寄存器内容和 trapframe 的内容。接下来,userrettrapframe 中恢复保存的用户寄存器,对 a0sscratch 进行最后一次交换以恢复用户 a0 并保存 TRAPFRAME 以供下一个陷阱使用,最后使用 sret 返回到用户空间。

4.3 代码:调用系统调用

第二章以 initcode.S 调用 exec 系统调用(user/initcode.S:11)结束。让我们看一下用户调用是如何到达内核中 exec 系统调用的实现的。

用户代码将 exec 的参数放置在寄存器 a0a1 中,并将系统调用号放置在 a7 中。系统调用号与 syscalls 数组中的条目相匹配,这是一个函数指针表(kernel/syscall.c:108)。ecall 指令会进入内核并执行 uservecusertrap,然后是 syscall,正如我们前面所看到的。

syscallkernel/syscall.c:133)从 trapframe 中保存的 a7 中检索系统调用号,并将其用作 syscalls 的索引。对于第一个系统调用,a7 包含 SYS_execkernel/syscall.h:8),导致调用系统调用实现函数 sys_exec

当系统调用实现函数返回时,syscall 将其返回值记录在 p->trapframe->a0 中。这将导致对 exec() 的原始用户空间调用返回该值,因为 RISC-V 上的 C 调用约定将返回值放置在 a0 中。系统调用通常返回负数来表示错误,而返回零或正数表示成功。如果系统调用号无效,syscall 将打印错误并返回 -1

4.4 代码:系统调用参数

内核中的系统调用实现需要找到用户代码传递的参数。因为用户代码调用系统调用包装函数,参数最初位于 RISC-V C 调用约定放置的位置:寄存器中。内核陷阱代码将用户寄存器保存到当前进程的陷阱帧中,内核代码可以在其中找到这些寄存器。函数 argintargaddrargfd 从陷阱帧中检索第 n 个系统调用参数,作为整数、指针或文件描述符。它们都调用 argraw 来检索适当的保存的用户寄存器(kernel/syscall.c:35)。

一些系统调用将指针作为参数传递,内核必须使用这些指针来读取或写入用户内存。例如,exec 系统调用将用户空间中指向字符串参数的指针数组传递给内核。这些指针带来了两个挑战。首先,用户程序可能存在错误或恶意,并且可能向内核传递无效指针或旨在欺骗内核以访问内核内存而不是用户内存的指针。其次,xv6 内核页表映射与用户页表映射不同,因此内核无法使用普通指令从用户提供的地址加载或存储数据。

内核实现了安全地在内核和用户提供的地址之间传输数据的函数。fetchstr 就是一个例子(kernel/syscall.c:25)。像 exec 这样的文件系统调用使用 fetchstr 从用户空间检索字符串文件名参数。fetchstr 调用 copyinstr 来完成繁重的工作。

copyinstrkernel/vm.c:406)从用户页表 pagetable 中的虚拟地址 srcva 处复制最多 max 字节到 dst 中。它使用 walkaddr(调用 walk)在软件中遍历页表,以确定 srcva 的物理地址 pa0。由于内核将所有物理 RAM 地址映射到相同的内核虚拟地址,copyinstr 可以直接从 pa0 复制字符串字节到 dstwalkaddrkernel/vm.c:95)检查用户提供的虚拟地址是否属于进程的用户地址空间,因此程序无法欺骗内核读取其他内存。类似的函数 copyout 将数据从内核复制到用户提供的地址。

4.5 来自内核态的陷入

xv6根据执行的是用户代码还是内核代码,有些不同地配置了CPU陷阱寄存器。当内核在CPU上执行时,内核将stvec指向kernelvec的汇编代码(kernel/kernelvec.S:10)。由于xv6已经在内核中,kernelvec可以依赖于satp被设置为内核页表,并且堆栈指针指向有效的内核堆栈。kernelvec 保存所有寄存器,以便中断的代码最终可以无干扰地恢复执行。

kernelvec 将寄存器保存在中断的内核线程堆栈上,这是有道理的,因为寄存器值属于该线程。如果陷阱导致切换到不同的线程,这一点尤其重要——在这种情况下,陷阱实际上会在新线程的堆栈上返回,而中断的线程的保存寄存器则安全地留在其堆栈上。

kernelvec保存寄存器后跳转到kerneltrapkernel/trap.c:134)。kerneltrap准备好处理两种类型的陷阱:设备中断和异常。它调用devintrkernel/trap.c:177)来检查并处理前者。如果陷阱不是设备中断,那么它必定是异常,在xv6内核中发生异常总是致命错误;内核调用panic并停止执行。

如果kerneltrap是由于定时器中断而被调用的,并且一个进程的内核线程正在运行(而不是调度器线程),kerneltrap调用yield来让其他线程有机会运行。在某个时刻,其中一个线程将会让出,让我们的线程和它的kerneltrap再次恢复。第七章解释了yield中发生的情况。

kerneltrap的工作完成时,它需要返回被陷阱中断的代码。因为yield可能干扰了保存的sepcsstatus中保存的上一个模式,kerneltrap在启动时将它们保存。现在它恢复这些控制寄存器,并返回到kernelveckernel/kernelvec.S:48)。kernelvec从堆栈中弹出保存的寄存器,并执行sret,这会将sepc复制到pc并恢复中断的内核代码。

如果kerneltrap由于定时器中断而调用yield,值得思考陷阱返回是如何发生的。

xv6在CPU从用户空间进入内核时将stvec设置为kernelvec;你可以在usertrapkernel/trap.c:29)中看到这一点。在内核执行但stvec设置为uservec的时刻存在一个时间窗口,这时关闭设备中断至关重要。幸运的是,RISC-V在开始接收陷阱时总是禁用中断,直到设置完stvec后xv6才会再次启用中断。

4.6 页面错误异常

对异常的响应在xv6中相当无趣:如果异常发生在用户空间,内核会终止出错的进程。如果异常发生在内核中,内核将发生紧急情况。真实的操作系统通常以更有趣的方式作出响应。

举例来说,许多内核使用页面错误来实现写时复制(COWfork。为了解释写时复制fork,考虑xv6中在第3章中描述的forkfork使得子进程具有与父进程相同的内存内容,通过调用uvmcopykernel/vm.c:309)为子进程分配物理内存并将父进程的内存复制到其中。如果子进程和父进程能够共享父进程的物理内存,将会更有效率。然而,这样的直接实现是行不通的,因为它会导致父进程和子进程通过对共享栈和堆的写入来干扰彼此的执行。

父进程和子进程可以通过写时复制fork安全地共享物理内存,这是由页面错误驱动的。当CPU无法将虚拟地址转换为物理地址时,CPU会生成一个页面错误异常。RISC-V有三种不同类型的页面错误:加载页面错误(当加载指令无法将其虚拟地址转换为物理地址时)、存储页面错误(当存储指令无法将其虚拟地址转换为物理地址时)和指令页面错误(当指令的地址无法转换时)。scause寄存器中的值指示页面错误的类型,而stval寄存器包含无法转换的地址。

写时复制fork的基本计划是父进程和子进程最初共享所有物理页面,但将它们映射为只读。因此,当子进程或父进程执行存储指令时,RISC-V CPU会引发页面错误异常。作为对该异常的响应,内核会复制包含出错地址的页面。它将一个副本映射为子进程地址空间的读/写,另一个副本映射为父进程地址空间的读/写。在更新页表后,内核会在引发异常的指令处恢复出错的进程。因为内核已更新相关的页表项以允许写入,所以引发异常的指令现在会无错误地执行。

这种写时复制计划对fork非常有效,因为通常子进程在fork后立即调用exec,用新的地址空间替换自己的地址空间。在这种常见情况下,子进程只会遇到少量页面错误,而内核可以避免进行完全的复制。此外,写时复制fork是透明的:应用程序无需进行任何修改即可受益。

页表和页面错误的组合除了写时复制fork之外还开辟了一系列有趣的可能性。另一个广泛使用的特性称为惰性分配,它有两个部分。首先,当应用程序调用sbrk时,内核会扩展地址空间,但在页表中将新地址标记为无效。其次,在这些新地址之一发生页面错误时,内核会分配物理内存并将其映射到页表中。由于应用程序通常请求的内存比实际需要的要多,惰性分配非常有用:内核只在应用程序实际使用内存时才分配内存。与写时复制fork类似,内核可以对应用程序透明地实现此特性。

利用页面错误的另一个广泛使用的特性是从磁盘进行页面分页。如果应用程序需要的内存超过了可用的物理RAM,内核可以驱逐一些页面:将它们写入磁盘等存储设备,并将它们的PTE标记为无效。如果应用程序读取或写入一个已被驱逐的页面,CPU将遇到页面错误。然后,内核可以检查出错地址。如果地址属于磁盘上的页面,内核会分配一个物理内存页面,将页面从磁盘读取到该内存中,更新PTE为有效并引用该内存,并恢复应用程序。为了为页面腾出空间,内核可能需要驱逐另一个页面。这个特性不需要对应用程序进行任何更改,并且在应用程序具有引用局部性(即它们在任何给定时间内只使用其内存的子集)时效果很好。

其他利用分页和页面错误异常结合的特性包括自动扩展堆栈和内存映射文件。

4.7 现实情况

如果将内核内存映射到每个进程的用户页表中(具有适当的PTE权限标志),就可以消除特殊的跳板页面的需求。这也将消除从用户空间陷入内核时需要进行页面表切换的需要。这反过来将允许内核中的系统调用实现利用当前进程的用户内存被映射,使得内核代码能够直接解引用用户指针。许多操作系统使用了这些想法来提高效率。xv6 避免了这些做法,以减少内核由于无意中使用用户指针而引发安全漏洞的可能性,并减少确保用户和内核虚拟地址不重叠所需的一些复杂性。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值