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)。 -
sstatus
:sstatus
的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,则不做下述操作。 - 通过clear
sstatus
的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_U
flag,traps在supervisor-mode下在这里开始执行。因为trampoline页被映射在内核地址空间的相同地址,在切换到内核页表之后,trampoline能继续执行。
对于uservec
trap handler的代码,在trampoline.S
(kernel/trampoline.S:16)。当uservec
开始执行时,所有32个寄存器包含有被中断的用户代码所拥有的值。这32个值需要被保存在内存中,当trap返回用户空间时需要恢复。存储到内存需要使用寄存器来保存地址,但是当前没有可用的通用寄存器。所幸RISC-V以sscratch
寄存器的形式提供了帮助。uservec
起始的csrrw
指令交换a0
和sscratch
寄存器的内容。现在用户代码的a0
被保存在sscratch
;uservec
有一个寄存器a0
可以使用,a0
的值内核之前放在了sscratch
中。
uservec
的下一个任务是保存32个用户寄存器。在进入用户空间之前,内核设置sscratch
指向一个进程的trapframe
结构(这里有一部分空间用来保存32个用户寄存器)(kernel/proc.h:44)。因为satp
指向用户页表,uservec
需要trapframe映射在用户地址空间。当创建每个进程的时候,xv6为进程的trapframe分配一页,总是将它映射在虚拟地址TRAMFRAME
处,在TRAMPOLINE
的下面。尽管内核能通过内核页表找到它的物理地址去使用trapframe,进程的p->trapframe
也指向trampframe。
交换a0
和sscratch
之后,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)。内核杀死错误进程。
- 如果trap是一个系统调用,
-
usertrap
检查进程是否被killed或者需要yield CPU(如果这个trap是一个时钟中断)。
返回用户空间的第一步是调用usertrapret
(kernel/trap.c:90),这个函数设置RISC-V控制寄存器为以后来自用户空间的trap做准备:
- 改变
stvec
,指向uservec
- 准备
uservec
依赖的trapframe字段 - 设置
sepc
为之前保存的PC - 最后,
usertrapret
调用trampoline页(被映射在用户页表和内核页表)上的userret
。(被映射在用户和内核页表:因为userret
中的汇编代码将切换页表)。
usertrapret
:调用userret
将TRAPFRAME
保存到a0
,将进程的用户页表的指针保存在a1
(kernel/trampoline.S:88)。userret
:
- 切换
satp
到进程的用户页表。用户页表映射了trampoline页面和TRAPFRAME
,没有映射内核的其他内容。事实是:trampoline页被映射在内核和用户页表相同的虚拟地址,这使得uservec
在切换satp
后继续执行。 - 将trapframe中保存的用户寄存器
a0
复制到sscratch
为下次与TRAPFRAME交换做准备。
从这点看,userret
能使用的数据只有:寄存器的内容和trapframe的内容。下个userret
从trapframe恢复保存的用户寄存器,交换a0
和sscratch
恢复用户寄存器a0
并为下个trap保存TRAPFRAME
,执行sret
返回用户空间。
Code: Calling system calls
Chapter2讲了initcode.S
调用exec
系统调用(user/initcode.S:11)。本节讲用户调用如何进入exec
系统调用在内核中的实现。
initcode.S
将exec
的参数放在寄存器a0
和a1
中,系统调用编号放在a7
中。system call numbers匹配syscalls
数组(syscalls
是一个函数指针表)。ecall
指令trap进内核,执行uservec
,usertrap
,syscall
。
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,内核能在这里找到寄存器的值。内核函数argint
,argaddr
,argfd
从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
使用walkaddr
(walkaddr
调用walk
)在pagetable
中查找srcva
,产生物理地址pa0
(kernel/vm.c:405)。内核映射每个物理地址到相应的内核虚拟地址,所以copyinstr
能直接从pa0
复制字符串字节到dst
。walkaddr
(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
可能破坏了sepc
和sstatus
中的previous mode,所以kerneltrap
在启动时需要保存它们。它恢复这些控制寄存器,返回到kernelvec
(kernel/kernelvec.S:48)。kernelvec
从栈中弹出保存的寄存器,执行sret
,复制sepc
到PC,恢复中断的内核代码。
有意义的思考:如果kerneltrap
因为时钟中断调用yield
,trap返回如何发生。
当CPU从用户空间进入内核空间时,xv6设置CPU的stvec
为kernelvec
(见usertrap
(kernel/trap.c:29));有个时间窗口:内核开始执行但stvec
仍然设置为uservec
,这期间没有设备中断至关重要。幸运的是当开始trap时,RISC-V总是关中断的,而xv6在设置stvec
之前不会开中断。