MIT6.S081 ---- Preparation: Read chapter 4

Preparation: Read Chapter 4

有三种事件会造成CPU放弃正常的指令执行,强制将控制权交给一段特殊的代码处理这个事件:

  • 一种情况是一个系统调用,当一个用户程序执行ecall指令请求内核为它做事的时候。
  • 一种情况是异常(exception):一条指令(内核或者用户)做一些非法操作,如除零或使用一个无效的虚拟地址。
  • 一种情况是设备中断(device interrupt)当一个设备发出需要注意的信号,例如当磁盘硬件完成读写请求。

本书使用trap作为上述情况的通用术语。通常,执行在trap期间的任何代码稍后都需要恢复,并且不需要知道发生了什么。我们经常需要traps透明,这对设备中断特别重要。通常的流程是:

  • trap强制将控制权转移给内核
  • 内核保存寄存器和其他状态,便于后来恢复执行
  • 内核执行合适的handler代码(如系统调用实现或设备驱动)
  • 内核恢复保存的状态并从trap返回
  • 原来的代码会在之前停止的地方继续执行

xv6在内核中处理所有的traps,traps不会交给用户代码。在内核中处理traps对于系统调用很正常;对于中断是有意义的,因为隔离性要求只允许内核使用物理设备,而且内核是一种可以在多进程间共享设备的方便机制;对于异常也是有意义的,因为xv6通过kill所有有问题的程序响应用户空间的所有异常。

xv6 trap处理过程分为四步:

  • RISC-CPU采取的硬件措施
  • 一些为内核C代码做准备的汇编指令
  • 一个决定如何处理trap的C函数
  • 系统调用或者设备驱动服务程序
    三种trap类型(异常、中断、系统调用)的共性表明内核可以用单一的code path处理所有的traps,对于三种不同的情况,使用单独的代码比较方便:来自用户空间的traps,来自内核空间的traps,时钟中断。处理一个trap的内核代码(汇编或者C)通常称为 handler,第一部分handler指令通常用汇编写成(而不是C),有时称为vector

RISC-V trap machinery

每个RISC-V CPU有一组控制寄存器:内核能写这些寄存器,通知CPU如何处理traps;内核能读这些寄存器,查找已经发生的trap。risc.h(kernel/risc.h:1)包含xv6使用的定义。这里有关于最重要的寄存器的概述:

  • stvec:(Exception Program Counter)内核将它的trap handler的地址写在该寄存器。RISC-V跳转到stvec地址处理trap。
  • sepc:当trap发生时,RISC-V在spec保存PC(因为PC被stvec的值覆盖了),记录触发中断的指令地址。
  • sret:(return from trap)将sepc的值写到PC。内核通过写sepc控制sret指令返回的位置。
  • scause:RISC-V将一个数写在这里,表明trap的原因。
  • sscratch:内核在这里放一个值,该值在trap handler的开始很有用。(这个寄存器在实现线程时起作用,在用户态保存内核地址,在内核态为0)。
  • sstatussstatus的SIE位(Supervisor Interrupt Enable)控制设备中断是否开启。如果内核clear SIE位,RISC-V将推迟设备中断,直到内核设置SIE位。SSP位表明一个trap是来自user-mode还是supervidor-mode并且控制sret返回的mode。

以上寄存器和supervisor-mode下的traps处理有关,它们不可能在user-mode下被读写。在machine-mode下有一组相似的控制寄存器,xv6只在定时中断的特殊情况下使用它们。

每个CPU在一个多核芯片上有自己的一组寄存器,在任何时间,都可能有多个CPU在处理中断。

当需要强制进入trap时,RISC-V硬件为所有的trap类型(除时钟中断)做如下处理:

  • 如果trap是一个设备中断,sstatus的SIE位被clear,则不做下述操作。
  • 通过clearsstatus的SIE位关中断。
  • 复制PC值到sepc
  • sstatus的SSP位保存当前mode(user或者supervisor)。
  • 设置scause记录trap的原因。
  • 设置mode位supervisor。
  • 复制stvec的值到PC。
  • 从新的PC开始执行。

内核软件必须完成(CPU不负责这些):

  • 切换到内核页表
  • 切换到内核栈
  • 保存除了PC的任何寄存器
    原因是:CPU在trap期间CPU做少量的工作为软件提供灵活性。如:有些操作系统会省略页表的切换增加trap的性能。(硬件切换页表的话这个操作就是必做的,软件的话可以根据情况调整)。

为了加快trap思考上面这些步骤是否能省略是很有价值的。尽管在一些情况下可以使用更简单的顺序工作,但是通常省略许多步骤会很危险。例如,假设CPU没有切换PC,来自用户空间的trap可能在运行用户指令的时候切换到supervisor-mode。这些用户指令可能打破用户/内核的隔离性。例如通过修改satp寄存器指向一个允许访问所有物理内存的页表。
因此,CPU必须切换到一个内核指定的指令地址,称为stvec

来自用户空间的traps

xv6处理traps方式不同,这取决于它执行在内核,还是执行在用户代码。本节讲后者,4.5节讲前者。

当用户程序在用户空间执行时,调用了系统调用(ecall指令),或者做了一些非法操作,或者来了一个设备中断,这时引起了一个trap。来自用户空间的trap的*(调用链)path是:

  • uservec(kernel/trampoline.S:16)
  • usertrap(kernel/trap.c:37)
    当返回时
  • usertrapret(kernel/trap.c:90)
  • userret(kernel/trampoline.S:88)

xv6的trap handling的设计的主要限制是当trap到来时RISC-V硬件不切换页表。这意味着stvec的trap handler地址必须在用户页表中有一个有效的映射,因为当trap handing代码开始执行时,那个页表必须有效。再者,xv6的trap handling代码需要切换到内核页表,为了能在切换后继续执行,内核页表必须也有一个对于stvec指向的handler的映射。

xv6使用trampoline页满足这些要求。trampoline页包含uservec以及stvec指向的xv6 trap handling代码。trampoline页被映射在每个进程的页表中,地址为TRAMPOLINE,在虚拟地址空间的末尾,在程序自己使用的内存的上面。trampoline页也被映射在内核页的TRAMPOLINE地址。因为trampoline页被映射在用户页表,有PTE_Uflag,traps在supervisor-mode下在这里开始执行。因为trampoline页被映射在内核地址空间的相同地址,在切换到内核页表之后,trampoline能继续执行。

对于uservectrap handler的代码,在trampoline.S(kernel/trampoline.S:16)。当uservec开始执行时,所有32个寄存器包含有被中断的用户代码所拥有的值。这32个值需要被保存在内存中,当trap返回用户空间时需要恢复。存储到内存需要使用寄存器来保存地址,但是当前没有可用的通用寄存器。所幸RISC-V以sscratch寄存器的形式提供了帮助。uservec起始的csrrw指令交换a0sscratch寄存器的内容。现在用户代码的a0被保存在sscratchuservec有一个寄存器a0可以使用,a0的值内核之前放在了sscratch中。

uservec的下一个任务是保存32个用户寄存器。在进入用户空间之前,内核设置sscratch指向一个进程的trapframe结构(这里有一部分空间用来保存32个用户寄存器)(kernel/proc.h:44)。因为satp指向用户页表,uservec需要trapframe映射在用户地址空间。当创建每个进程的时候,xv6为进程的trapframe分配一页,总是将它映射在虚拟地址TRAMFRAME处,在TRAMPOLINE的下面。尽管内核能通过内核页表找到它的物理地址去使用trapframe,进程的p->trapframe也指向trampframe。

交换a0sscratch之后,a0有指向当前进程trapframe的指针。uservec保存所有用户寄存器,包括用户的a0,从sscratch中读取。

trapframe包含:

  • 当前进程的内核栈的地址
  • 当前进程CPU的hartid
  • usertrap函数的地址
  • 内核页表的地址
    uservec恢复这些值,切换satp指向内核页表,调用usertrap

usertrap的任务是形成trap的原因并返回。(kernel/trap.c:37):

  • 首先改变stvec以便内核中的trap由kernelvec处理而不是uservec处理。
  • 保存sepc寄存器(保存的用户PC),因为usertrap可能调用yield切换到另一个进程的内核线程(kernel thread),而该进程可能返回用户空间,在这期间,它可能修改sepc
  • 分支处理:
    • 如果trap是一个系统调用,usertrap调用syscall处理它。在此之前,将保存的PC加4:因为RISC-V在系统调用这个情况中将PC指向了ecall指令,但是用户代码需要在下一条指令恢复执行。
    • 如果是一个设备中断,devintr
    • 否则就是异常(exception)。内核杀死错误进程。
  • usertrap检查进程是否被killed或者需要yield CPU(如果这个trap是一个时钟中断)。

返回用户空间的第一步是调用usertrapret(kernel/trap.c:90),这个函数设置RISC-V控制寄存器为以后来自用户空间的trap做准备:

  • 改变stvec,指向uservec
  • 准备uservec依赖的trapframe字段
  • 设置sepc为之前保存的PC
  • 最后,usertrapret调用trampoline页(被映射在用户页表和内核页表)上的userret。(被映射在用户和内核页表:因为userret中的汇编代码将切换页表)。

usertrapret:调用userretTRAPFRAME保存到a0,将进程的用户页表的指针保存在a1(kernel/trampoline.S:88)。
userret

  • 切换satp到进程的用户页表。用户页表映射了trampoline页面和TRAPFRAME,没有映射内核的其他内容。事实是:trampoline页被映射在内核和用户页表相同的虚拟地址,这使得uservec在切换satp后继续执行。
  • 将trapframe中保存的用户寄存器a0复制到sscratch为下次与TRAPFRAME交换做准备。
    从这点看,userret能使用的数据只有:寄存器的内容和trapframe的内容。下个userret从trapframe恢复保存的用户寄存器,交换a0sscratch恢复用户寄存器a0并为下个trap保存TRAPFRAME,执行sret返回用户空间。

Code: Calling system calls

Chapter2讲了initcode.S调用exec系统调用(user/initcode.S:11)。本节讲用户调用如何进入exec系统调用在内核中的实现。

initcode.Sexec的参数放在寄存器a0a1中,系统调用编号放在a7中。system call numbers匹配syscalls数组(syscalls是一个函数指针表)。ecall指令trap进内核,执行uservecusertrapsyscall

syscall(kernel/syscall.c:133)从trapframe中保存的a7中恢复系统调用编号,使用这个编号去索引syscalls。对于第一个系统调用,a7含有SYS_exec(kernel/syscall.h:8),引出系统调用实现sys_exec函数的调用。

sys_exec返回时,syscall将返回值存到p->trapframe-a0中,这也是exec()函数的返回值,因为RISC-V的C调用约定将返回值放在a0中。系统调用返回负数通常表明errors。0或者正数表明success。如果系统调用号无效,syscall打印一个error并且返回-1。

Code: System call arguments

系统调用在内核中的实现需要找到被用户代码传递的参数。因为用户代码调用系统调用封装的函数,参数按照RISC-V C calling convention放在寄存器里。内核trap代码保存用户寄存器到当前进程的trap frame,内核能在这里找到寄存器的值。内核函数argintargaddrargfd从trap frame中恢复系统调用的参数作为一个整数、指针、文件描述符。它们都是调用argraw恢复被保存的用户寄存器(kernel/syscall.c:35)。

一些系统调用传递指针作为参数,内核必须使用这些指针去读写用户内存。如:exec系统调用传给内核一组指向用户空间字符串参数的指针。这些指针带来两个挑战:

  • 用户程序可能有bug或者是恶意的,可能传给内核一个无效的指针或者一个有意指针欺骗内核访问内核空间而不是用户空间。
  • xv6内核页表映射和用户页表映射不同,内核不可能使用普通的指令load/store用户提供的地址。

内核实现了可以安全地对用户提供的地址进行数据传输的函数。fetchstr是一个例子(kernel/syscall.c:25)。文件系统调用exec使用fetchstr从用户空间恢复字符串文件名参数。fetchstr调用copyinstr

copyinstr(kernel/vm.c:398)最多从用户页表pagetable的虚拟地址srcva复制max字节到dst。因为pagetable不是当前页表,copyinstr使用walkaddrwalkaddr调用walk)在pagetable中查找srcva,产生物理地址pa0(kernel/vm.c:405)。内核映射每个物理地址到相应的内核虚拟地址,所以copyinstr能直接从pa0复制字符串字节到dstwalkaddr(kernel/vm.c:104)检查用户提供的虚拟地址是否在用户地址空间内,所以应用程序不可能欺骗内核读取其他内存。一个类似的函数,copyout将数据从内核复制到用户提供的地址。

Traps from kernel space

xv6根据正在执行的是内核代码还是用户代码,对CPU trap寄存器的配置略有不同(内核这个情况主要是处理中断和异常的)。当内核正在一个CPU上执行时,内核将stvec指向汇编代码kernelvec(kernel/kernelvec.S:10)。因为xv6在内核中,kernelvec能依赖:已被设置为内核页表的satp,指向有效内核栈的栈指针。kernelvec将所有32个寄存器压入栈中,便于后来恢复它们使中断的内核代码可以不受干扰的执行。

kernelvec在被中断的内核线程的栈上保存寄存器,这是有意义的,因为寄存器的值属于该线程。如果trap导致切换到另一个线程,这点很重要,在这种情况下,trap将从新线程的栈上返回,将中断线程的保存的寄存器安全的保留在它的栈上。

保存寄存器后,kernelvec跳转到kerneltrap(kernel/trap.c:134)。kerneltrap为两种trap类型做了准备:设备中断和异常。调用devintr(kernel/trap.c:177)检查并处理中断 trap。如果trap不是一个设备中断,则必定是异常,如果发生在xv6内核,总是一个致命的error,内核调用panic并停止执行。

如果由于时钟中断调用kerneltrap,并且进程的内核线程正在运行(与调度线程相反),kerneltrap调用yield给其他线程一个运行的机会。在某个时刻,这些线程中的一个将yield,让我们的线程和它的kerneltrap再次恢复。第7章介绍yield

kerneltrap执行完毕,它将返回被trap中断的代码。因为yield可能破坏了sepcsstatus中的previous mode,所以kerneltrap在启动时需要保存它们。它恢复这些控制寄存器,返回到kernelvec(kernel/kernelvec.S:48)。
kernelvec从栈中弹出保存的寄存器,执行sret,复制sepc到PC,恢复中断的内核代码。

有意义的思考:如果kerneltrap因为时钟中断调用yield,trap返回如何发生。

当CPU从用户空间进入内核空间时,xv6设置CPU的stveckernelvec(见usertrap(kernel/trap.c:29));有个时间窗口:内核开始执行但stvec仍然设置为uservec,这期间没有设备中断至关重要。幸运的是当开始trap时,RISC-V总是关中断的,而xv6在设置stvec之前不会开中断。

上一篇:LibOpenCM3(二) 项目模板 Makefile分析


下一篇:"echo 0 > /proc/sys/kernel/hung_task_timeout_secs" disables this message.