Traps
Contents
Traps 概述
Traps (中断)使得 CPU 放弃当前执行的指令转而执行特定的程序,有三种发生中断的情形:
- 通过
ecall
指令执行系统调用。 - 产生异常,如除零错误,虚拟地址转换失败等异常。
- 硬件设备产生的中断信号,例如键盘输入,磁盘读写完成时产生的中断。
中断处理的流程是,中断产生后陷入内核,内核保存寄存器上下文状态,然后执行相应的中断处理程序,最后恢复上下文继续执行。
risvc-v 有一些特定的寄存器来处理中断。
stvec
:内核将中断处理程序的地址写到这个寄存器里,当发生中断时 CPU 跳转到这里执行。sepc
:中断发生时会将当前的指定寄存器pc
的值存到sepc
里,当中断返回时可以恢复执行。scause
:risc-v 将中断产生的原因存到这里。sscratch
: 内核在这里写入一个在中断程序开始时有用的值。sstatus
: SIE 位表示中断开关,SPP 位表示中断发生在 user mode 还是 supervisor mode 以及返回的 mode。
在多核 CPU 中,每个 CPU 都有上述寄存器,每个 CPU 可能同时处理中断。中断发生时硬件的处理流程。
- 如果是设备中断,将 sstatus 中的 SIE 位清 0,然后跳过以下步骤。
- 如果不是设备中断,将 sstatus 中的 SIE 位清 0,关闭设备中断。
- 将 pc 的值复制到
sepc
中。 - 将发生中断时的模式(user mode 还是 supervisor mode)写入 sstatus 中的 SPP 位。
- 设置 scause 的内容,指明中断发生的原因。
- 设置模式为 supervisor mode。
- 将 stvec 的值复制到 pc 中。
- 从新的 pc 值开始执行。
从上面的硬件处理中断的流程来看,中断发生时,硬件做的事情实际上很少,CPU 没有切换内核页表,没有切换内核栈,也没有保存任何的寄存器(除了 PC)。因此,内核中的代码必须完成以上这些工作。让 CPU 完成尽可能少的工作,其中一个原因是为内核代码提供灵活性。例如,一些操作系统在中断发生之后可能并不需要切换页表(在这样的设计里,用户空间和内核空间使用同一个页表,按照用户空间在低地址,内核空间在高地址的方式设计),如果我们的内核可以自己选择不切换页表,就省去了相关操作,从而提升了性能。
用户空间的中断
用户空间发生中断的主要处理流程是:从 uservec (kernel/trampoline.S:16)
开始,然后调用 usertrap (kernel/trap.c:37)
, 当返回时,调用 usertrapret (kernel/trap.c:90)
,最后调用 userret (kernel/trampoline.S:16)
返回。
用户空间发生中断时因为页表不会切换,所以用户页表需要映射 uservec
的地址,同时 uservec
会将用户页表切换到内核页表,要保证 uservec
在切换到内核页表后能继续执行,需要内核页表和用户页表对 uservec
映射到相同的虚拟地址。
当 uservec
开始执行时,被中断的程序的寄存器的值需要保留,那么这些值存到哪里呢?risc-v 通过 sscratch
寄存器来存储每个进程的 trapframe
页地址,uservec
会通过 csrrw
指令交换 sscratch
和 a0
的值,这样用户代码的 a0
值被保存到了 sscratch
,uservec
可以通过 a0 的值来向 trapframe
里写入要保存的的寄存器的值。
在进入用户空间之前,内核就就将每个进程的 trapframe
地址写入 sscratch
寄存器,uservec
在保存被中断的程序的寄存器时,仍然处于用户空间,所以需要把 trapframe
映射到用户空间,当每个进程创建时会分配 trapframe
页,就映射在 TRAMPOLINE 下面一页 TRAPFRAME,同时进程结构体的 p->trapframe
保存了 trampframe
的物理地址。
uservec
保存的寄存器里的值有:进程的内核栈,当前的 cpu id , usertrap
的地址, 内核页表的地址,通过这些值就可以切换到内核页表,跳转到 usertrap
执行。
usertrap
根据 trap 产生的原因分别处理中断,在这之前会将 stvec
改成 kernelvec
,因为此时已经是在内核执行, 同时会保存 sepc
以防被其他进程的中断修改,中断处理完成后跳转到 usertrapret
执行,usrtrapret
首先恢复 stvec
为 uservec
,然后准备 uservec
需要的 trapframe
的值,恢复 sepc
的值,跳转到 userret
执行,调用 userret
传递两个参数:用户页表,用户的 trapframe
地址,分别存在 a0 a1 寄存器。userret
首先切换页表,接着恢复保存在 trapframe
里寄存器的值,然后为下个 trap
准备 trapframe
,最后跳转到用户空间执行。
系统调用的参数
中断发生时,内核通过 trapframe 获取系统调用参数。
一些系统调用会传递指针作为用户参数,内核必须使用这些指针,读或写这些属于用户进程的物理内存。例如,exec
就给内核传递了一个 argv
指针,指向一系列的命令行参数。
使用这些用户指针有两个挑战。一是用户进程可能是有漏洞或者有恶意的,因此它会传递一个无效的指针,甚至是一个企图访问,属于其它用户进程内容,或者属于内核内容的指针。二是内核页表和用户页表的不同所造成的,因为各自的映射不同,在使用内核页表时,不能用简单的指令访问这些用户地址。
内核实现了专门的函数,用于安全地从用户地址中复制内容到内核缓冲区中。
来看 fetchstr(kernel/syscall.c)
这个例子,众多系统调用,如 exec
,就是用它来复制位于用户空间的参数。fetchstr
将主要的工作交给 copyinstr
来完成。
copyinstr(kernel/vm.c)
的作用是,给定一个用户页表,从用户虚拟地址 srcva
(例如用户缓冲区),安全地拷贝最多 max
个字节到内核的 dst
位置中。首先 copyinstr
调用 walkaddr
来为 srcva
找到对应的物理地址 pa0
,由于内核的虚拟地址和物理地址一一对应,我们将 pa0
作为虚拟地址,便可以直接地从 pa0
中拷贝字节流到 dst
中。
内核空间的中断
当内核在 CPU 上执行时,内核将 stvec
指向 kernelvec (kernel/kernelvec.S:10)
上的代码。kernelvec
可以使用之前设置的内核页表以及内核堆栈。中断发生时首先保存所有寄存器,kernelvec
将寄存器的值保存在被中断的线程的内核堆栈上,如果中断导致切换到不同的线程,由于每个线程都有自己的内核栈,它们的寄存器的值分别保存在自己的内核堆栈上,不会被相互覆盖,当中断返回时,每个线程的寄存器值也可以正常恢复。kernelvec
保存寄存器的值后跳转到 kerneltrap (kernel/trap.c:134)
处理相应的中断。内核中断分为两种:设备中断和异常。内核调用 devintr (kernel/trap.c:177)
检查并处理设备中断。如果是异常,内核调用 panic 停止执行。如果是时钟中断,并且进程的内核线程正在运行(而不是调度程序线程),kerneltrap
调用 yield
让出 CPU 让其他线程有机会运行,当其他线程调用 yield
时线程就从中断中恢复运行。
当 kerneltrap
的工作完成后,它需要返回到中断之前的状态执行。因为 yield
可能会修改 spec
的值和 sstatus
的值,kerneltrap
在启动时保存它们。中断处理完成后恢复那些寄存器并返回到 kernelvec (kernel/kernelvec.S:48)
执行, kernelvec
从保存的寄存器中弹出堆栈并执行 sret
,恢复中断的内核代码。
kernelvec
写到 stvec
是发生在 usertrap
里,即用户空间到内核空间的过程中,在这个过程中有一段时间窗口执行的是内核代码但是 stvec
的值却是指向 uservec
的,如果在这个窗口里发生时间中断,执行的是 uservec
,此时就会因为无法处理这个中断而 panic ,所以在这个窗口里要关闭中断,直到设置 stvec
的值完成后才开启中断。
缺页中断
当 CPU 通过虚拟地址寻找不到相应的物理地址的时候就会触发缺页中断,这里 xv6 对缺页中断的处理是,对于用户空间的缺页中断,直接杀死进程,如果是内核产生的,则让内核 panic 。在现实使用的操作系统中,缺页中断通常用来实现写时复制(COW),这个后面有个单独的实验。
RISC-V assembly
关于 risc-v 汇编,在系统调用那个 lab 有简单的梳理。这里直接来看关于 call.asm 里的汇编代码提出的几个问题。
|
|
a. 那些寄存器存放函数参数,哪个寄存器保存值 13 ?
risc-v 的函数参数保存在 参数寄存器,如果函数调用时需要传递更多的参数,则可以用这些寄存器,但注意用于传递参数的寄存器最多只有 8 个 (a0-a7),如果还有更多的参数则要利用栈。
b. g、f 函数在哪里调用?
f 和 g 的调用都被编译器内联优化了,编译器将这两个函数的调用返回值在编译期就计算出来。
c. printf 的地址在哪?
0000000000000630, pc (0x30) + 1536 = 0x630。
d. 调用 printf 后 ra 的值是?
0x38。
e. 关于字节序的问题
He110 World 0x726c6400 不需要 (因为编译器会转换)。
f. y 的值是多少?
a1 寄存器的值。
Backtrace
要实现函数调用栈跟踪。这里可以参考一下系统调用那里的函数调用栈帧关系,就比较好做了。
根据提示实现,首先添加函数定义:
|
|
将获取 fp 值的内联汇编代码添加到 kern/riscv.h。
实现 backtrace:
|
|
在 sys_sleep 调用 backtrace:
|
|
Alarm
要求通过时间中断来实现监控进程的 CPU 使用情况,主要是实现一个处理时间中断程序。
根据提示来实现。
首先修改 Makefile 加入 alarmtest:
|
|
增加 sigalarm, sigreturn 两个系统调用的定义:
|
|
需要在进程结构体中保存 alarm 的时间间隔,用户处理函数,以及经过了多少个 tick:
|
|
初始化这些变量:
|
|
在时钟中断产生时,判断是否已经到了指定的间隔,如果已经到了指定间隔,跳转到 handler 执行,这里有 p->trapfram->epc
指针是中断返回后程序继续执行的地址,所以要跳转到 handler 执行,需要将它的值改成 handler 的地址,当然在修改之前需要先把它的值保存下来,由于用户的 hander 程序也有可能发生中断会改变 p->trapfram
里的值,所以这里可以把整个 trapframe
保存下来,用户的 handler 在结束时会调 sigreturn
系统调用,我们可以在 sigreturn
中恢复 trapframe
。为了避免在一个时间间隔里执行多次 handler,只有在 handler 成功返回时才将保存的 ticks 重置。
|
|