中断处理
这一部分和体系结构严重相关,我们这里仅仅展示我们在RISCV架构上的处理。
我们的执行环境为:在用户模式下发生中断,在监管模式进行处理。
我们以一个系统调用为例,详细讲解下我们破内核的中断处理框架:
进入内核之前的准备
1. 用户空间
用户程序发出系统调用,根据RISCV的函数调用约定,会使用寄存器a0~a6来传递参数,然后将系统调用号放在a7寄存器中,使用ecall指令,此时我们就会陷入内核。
进入内核的过渡
2. 硬件自动完成
- 禁中断(嵌套太麻烦了,或者至少上半部你别在来了)
- 切换到监管模式(内核态、管态)
sepc<-pc;保存pc指针(程序断点)Sscause:Trap发生的原因
处理完成上面这一部分后CPU跳转到stvec寄存器存放的地址(就是直接赋给PC,然后跳过去)。所以中断发生时候stvec寄存器需要含有某个地址,我们在用户程序运行时候赋值为uservec,在内核运行时为kernelvec。由这个地址进入下一步处理阶段,这两个函数也是我们自定义的两个函数。
这里又带来一个问题,CPU跳转到对应的函数,而这个函数显然是会放在内核空间。也就是说,我们必须把这个函数映射到用户空间,否则我们必须要手动切换页表,类似于XV6这样的处理方式。我们这里选择了一种比较简单的处理方法:直接将内核空间也映射到用户空间。这样,当跳转后执行的时候,由于位于内核态,内核空间的代码是可以直接执行的,省去的切换页表带来的开销。
探头进来啦
3. uservec
根据中断的处理流程我们知道,我们还需要由内核保存目前的通用寄存器,这一段代码位于 kernel/usevec.S中,里面记录了我们保存所有的通用寄存器到某个位置,但是:
- 这个地址怎么让汇编知道呢?
- 另外存到哪儿呢?
首先回答第二个问题,存到哪里。由于我们支持多道程序,程序和程序的中断是互不相关的。因此,这个地址一定是某个进程自己私有的一块空间。我们考虑了之后,有以下选择:
进程的内核栈
虚存中专门分一块空间
放在进程控制块可以访问的地方(不使用内核栈)
最后经过选择,我们采取了第三种方式。我们在每个thread_info中设置了一个trapframe来保存,如下所示:
// include/core/proc.h
struct trapframe {
/* 0 */ uint64_t kernel_sp;
/* 8 */ uint64_t epc;
/* 16 */ uint64_t ra;
/* 24 */ uint64_t sp;
/* 32 */ uint64_t gp;
/* 40 */ uint64_t tp;
...
};
struct thread_info {
struct trapframe *tf;
...
}现在来回答第一个问题,这个地址如何让汇编知道?
我们借用了RISCV中的sscratch寄存器来存放这个地址(thread_info->trapframe),因此在我们的这个内核中,sscratch寄存器被专门拿来负责做这件事,有可能会导致一些与标准规定不太符合的实现,但是我们也不考虑那么多~~
对于用户程序,设置sscratch的时机是在中断返回的时候。
另外,一个用户程序在运行的时候,该CPU上的sscratch是不会被改变的,因此当发生中断的时候,CPU可以直接用sscratch寄存器存放的信息,而不会导致其他问题。
这个寄存器另外的作用就是在中断发生时候作为保存通用寄存器的临时寄存器。简单理解,我们普通交换两个数,是不是一般都需要第三个中间临时变量的,这个一样的意思的。。
# kernel/trap/uservec.S
uservec:
# * sscratch 存放的是当前进程的 trapframe 地址
# * 当前的寄存器现场保存到 trapframe
# * 交换 a0, sscratch 的值,此时 a0 为 p->tf
csrrw a0, sscratch, a0
# 后面就可以直接使用 a0 寄存器去保存信息,比如:
# * 把寄存器保存
sd ra, 16(a0)
sd sp, 24(a0) # * 此时 sp 是用户栈顶
sd gp, 32(a0)
sd tp, 40(a0)
...
sd t6, 256(a0)
# 初始化内核栈栈指针
ld sp,0 (a0)
# 先保存原来 a0 的值
csrr t0, sscratch # 读取原来的 a0 (即 sscratch 的值) 到 t0
sd t0, 88(a0) # 保存原来的 a0
csrw sscratch, a0 # 写回 p->tf 到 sscratch
# 用户程序不需要返回,采用无条件跳转
j usertrap最后把控制权递交给usertrap函数。
4. 中断处理函数
上面uservec.S中的usertrap是我们在内核中使用C语言写的函数(/kernel/trap/trap.c),在这个函数里面,我们会根据是什么类型的中断来进行分流,然后跳转到具体的中断处理函数。可以简单认为就是一个switch-case。
那么具体怎么判断类型呢?欧,在前面的硬件自动处理一栏中有一个Sscause,这个里面就有记录,表示发生中断的原因。根据RISCV的规定,就系统调用而言,是8。那么我们就匹配上了,直接跳转过去执行。
顺便呢,我们需把sepc寄存器此时的信息保存在thread_ino->trapframe中。
这里为什么会说 "我们需把
sepc寄存器此时的信息保存在thread_inof->trapframe中"?如果是普通的当前程序中断,返回后执行同一个程序,貌似没有必要对
sepc进行保存,但是注意下面的情况:
- 如果当前程序被调度走了,我们回来的时候必须知道返回的断点在哪里,因此必须找个位置保存下来。
- .......
情况还有很多,这里就不举例了。如果新程序运行,原有的
sepc寄存器内容会被会覆盖的。所以你可以看到在trapframe中有一个epc,这里就是用来存放的。
// 用户 trap 处理函数 user_trap
void usertrap()
{
uint64_t scause = r_scause();
// like a "switch case(scause) go"
...
usertrapret();
}以系统调用为例,实际的处理函数是syscall(kernel/trap/trap.c)。对于系统调用,我们需要判断系统调用号,那么系统调用号又放在那里呢?a7,不过可不是我们现在此时此刻CPU的a7,这个a7是当初系统调用传参进入内核时候的a7,那么又存在哪里呢?thread_info->trapframe。对啦,这样就刚刚好实现了。
// kernel/trap/syscall.c
void syscall()
{
struct thread_info *p = myproc();
int n = p->tf->a7;
p->tf->a0 = (int64_t)syscalls[n]();
}系统调用的具体函数五花八门,这一部分就不产开叙述了。
5. 系统调用准备返回
这一段内容是在usertrapret中,这个函数很简单,为准备返回用户空间做一些环境准备,包括:
- 设置
uservec->stvec - 恢复断点
tf->sepc->epc - 设置
Sscratch(见上面uservec) - 去到实际的返回函数
userret
6. 返回用户空间
# kernel/trap/uservec.S
userret:
# * 读取 sscratch 的值,此时 a0 为 p->tf
csrr a0, sscratch
# * 把寄存器加载
ld ra, 16(a0)
ld sp, 24(a0)
ld gp, 32(a0)
...
# return to user mode and user pc.
# usertrapret() set up sstatus and sepc.
sret这里的sret会把sepc的值重新赋值给pc,同时CPU切换到用户态(当然了,是在usertrapret中设置了相关的标志位后才实现的)。sepc此时就是用户当初的那个断点。后面CPU就返回用户空间的原来位置继续执行。
返回用户空间
注意:系统调用的返回地址需要当前 ecall 的下一句。也就是在ecall发生trap,如果不做任何处理,那么返回后还是回到ecall,继续来。这可不是我们想要的结果。因此,我们需要让返回地址跳过这条指令,这条指令在 RISCV 中规定为 4字节。我们在usertrap函数中修改了 tf->epc +=4。那么,在
usertrapret中这个地址trapframe->epc被暂时放在了sepc寄存器,在usetrap的sret的时候,sepc->pc,此时就回到了正确的地址。