xv6-riscv-源代码阅读.中断与异常

XV6 源代码阅读——中断与异常

说明

  • 阅读的代码是 xv6-riscv 版本的
  • 涉及到的文件如下
    • kernel
      • kernelvsc.Sproc.cstart.csyscall.ctrampoline.Strap.criscv.hentry.S
    • user
      • sh.cinitcode.S

问题回答

(1) 问题 1

问题

  • 什么是用户态和内核态?两者有何区别?什么是中断和系统调用?两者有何区别?计算机在运行时,是如何确定当前处于用户态还是内核态的?

回答

  • 在 RISC-V 中,有 3 种权限不同的模式(Machine mode、Supervisor mode、User mode)
    • machine mode 拥有所有的特权,一般在启动时候用于配置电脑的环境
    • supervisor mode 的权限相对低些,可以执行特权指令,例如是否使能中断等
    • user mode 的权限级别最低,完成一些特殊功能的时候需要通过系统调用进入 supervisor mode
  • 内核态可以运行在 machine mode 和 supervisor mode 下,用户态只能运行在 user mode
  • 中断与系统调用
    • 中断分为外中断和内中断,外中断包括 I/O 中断、时钟中断等,内中断包括异常、系统调用和中止
    • 系统调用指的是处于用户态下的程序需要完成一些只有内核态才能完成的功能时,通过系统调用的方式进入内核态,从而实现功能的行为
      • 主动调用,返回到下一条指令
      • 一种中断,需要利用中断的机制来实现
  • 在 RISC-V 中,通过寄存器 sstatus 中保存的 SPP 位来判断是处于内核态 (1) 还是用户态 (0)

(2) 问题 2

问题

  • 什么是中断描述符,中断描述符表?在XV6 里是用什么数据结构表示的?

回答

  • RISC-V 中直接在 stvec 寄存器中设定中断处理程序的基地址,然后根据引起中断的不同原因分别进行操作,stvec 可以有两种取值 uservec / kernelvec
  • 如果是一种系统调用的话,在 uservec 中通过 syscall 进行具体功能的调用,参数通过 a 类寄存器传递,具体的系统调用号保存在寄存器 a7 里面,然后在进行 syscall 的时候,直接在数组通过系统调用号索引出具体的系统调用函数
1
2
3
4
5
6
# user/initcode.S
start:
la a0, init
la a1, argv
li a7, SYS_exec
ecall # ecall 陷入内核, 然后执行 uservec(在第三部分提具体的机制)

(3) 一次中断的过程

  • 我们使用 trap 来泛指中断
    • 系统调用(system call):用户态程序通过调用 ecall 进入内核态实现功能
    • 异常(exception):用户态或者内核态程序的操作非法(除零、地址无效等)
    • 设备中断(device interrupt):例如硬件读写等
  • Xv6 trap 的处理过程主要有 4 步
    • RISC-V CPU 硬件做一些相关工作
    • 为内核执行 C 代码准备一个向量组
    • C trap 处理程序
    • 最后通过 system call 或者 device-driver 进行处理
  • 硬件在中断中做的事情如下
    • 判断 trap 类型,如果是设备中断而且寄存器 sstatus 中的 SIE 位为 0 ,那么结束(下列都不执行)
    • 将寄存器 sstatus 中的 SIE 位设置为 0(关掉设备中断)
    • 将 pc 拷贝到寄存器 sepc 中
    • 保存当前模式(user/supervisor)到 sstatus 的 SPP 位中
    • 将 scause 寄存器设置为引起 trap 的原因
    • 设置模式为 supervisor mode
    • 将寄存器 stvec 的内容拷贝到 pc 中
      • stvec 寄存器保存 trap 处理程序的地址
    • 开始按照新的 pc 执行代码
  • 硬件在中断中的行为
    • 没有切换页表、没有切换栈、没有保存其他寄存器
    • 这样的话相对高效,不是所有程序都需要切换页表的,而且不同程序保存寄存器的方式也不一样,硬件只做必要的事情
  • Xv6 在实现 trap 的时候将其分为如下 3 类,分别对应不同的处理程序
    • traps from kernel space
    • traps from user space
    • timer interrupts
  • 我们接下来探讨这 3 类 trap 的不同实现

Traps from kernel mode

  • 在内核模式下,trap 只有两类:exceptions 、device interrupt
  • 当一个 trap 发生的时候,首先硬件开始工作,配置好上面提到的环境信息
  • 此时 stvec 指向了 kernelvec 的起始地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# kernel/kernelvec.S
.globl kerneltrap
.globl kernelvec
.align 4
kernelvec:
# 在栈上开辟一块空间用于保存寄存器
addi sp, sp, -256
sd ra, 0(sp)
# ... 保存所有寄存器
sd t6, 240(sp)

# 调用 C 处理程序
call kerneltrap

# 恢复寄存器到之前的状态
ld ra, 0(sp)
# ... 恢复所有的寄存器(除了 tp)
ld t6, 240(sp)

# 恢复栈指针
addi sp, sp, 256

# 返回到之前的运行状态
sret
  • kernelvec 首先在栈上开辟一块空间,将所有的寄存器都保存下来
    • 因为本身就是在内核栈中,直接保存在栈上就行了
  • 调用 trap 的处理程序 kerneltrap
  • 返回后恢复所有寄存器到原来的状态
    • 不需要恢复 tp(thread pointer)
  • kerneltrap 的代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// kernel/trap.c
void kerneltrap() {
int which_dev = 0;
uint64 sepc = r_sepc();
uint64 sstatus = r_sstatus();
uint64 scause = r_scause();

if((sstatus & SSTATUS_SPP) == 0)
panic("kerneltrap: not from supervisor mode");
if(intr_get() != 0)
panic("kerneltrap: interrupts enabled");

if((which_dev = devintr()) == 0){
printf("scause %p\n", scause);
printf("sepc=%p stval=%p\n", r_sepc(), r_stval());
panic("kerneltrap");
}

// give up the CPU if this is a timer interrupt.
if(which_dev == 2 && myproc() != 0 && myproc()->state == RUNNING)
yield();

// the yield() may have caused some traps to occur,
// so restore trap registers for use by kernelvec.S's sepc instruction.
w_sepc(sepc);
w_sstatus(sstatus);
}
  • kerneltrap 首先保存寄存器 sstatus、sepc
    • 因为可能在 yield() 的时候出发新的 trap,因此需要保存这些寄存器的值
  • 权限判断,触发 kerneltrap 的必须是内核态
  • 判断现在的 sstatus 寄存器的 SIE 位是否为 1(是否打开设备中断)
    • 如果打开了说明之前有可能出错,正常情况下应该是关闭的
    • 出错原因?
  • 判断 trap 的原因【通过 devintr() 函数判断】
    • 如果是 exceptions ,那么肯定是内核出现错误了
    • 如果是 device interrupt 的话,那么一定是时钟中断,调用 yield() 让出执行权
  • 最后恢复寄存器 sstatus、sepc 的值
一个问题
  • 如果 yield() 的时候触发了 trap 怎么进入 kerneltrap 呢?在 user mode 下,stvec 指向的是 usertrap?
    • 在调用 usertrap 的时候设置 stvec 为 kerneltrap 即可
    • 由于 RISC-V 硬件在进入 trap 的时候会关闭中断,因此就算有一小段时间 stvec 的值不正确也没事
    • 具体 yield() 的机制还没学习 // TODO

Traps from user space

  • 除了上面提到的两种 trap 之外,用户态下的 trap 还有通过 syscall 触发的系统调用
  • 大体的运作逻辑是这样
    • uservec、usertrap、 usertrapret、userret
  • 这一部分相对复杂的原因硬件在进入 trap 的时候是没有切换页表的,因此我们在查找具体的页表内容的时候需要通过当前进程中保存的页表地址取计算得到
  • 首先系统进入 trap 之后,硬件会做上面提到的工作
    • 但是此时,stvec 指向的地址是 uservec
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# trampoline.S
uservec:
# 交换 a0 和 sscratch 寄存器的值
# so that a0 is TRAPFRAME
csrrw a0, sscratch, a0

# 将寄存器保存到当前进程的 trapframe 中
sd ra, 40(a0)
# ... 保存寄存去
sd t6, 280(a0)

# 同时也保存 a0
csrr t0, sscratch
sd t0, 112(a0)

# 从 user mode 的 traptable 中恢复一些内核的信息
ld sp, 8(a0)
ld tp, 32(a0)
ld t0, 16(a0)
ld t1, 0(a0)
csrw satp, t1
sfence.vma zero, zero

# a0 is no longer valid, since the kernel page
# table does not specially map p->tf.

# jump to usertrap(), which does not return
jr t0
  • 在进行系统调用之前,p->trapframe 的起始地址会被保存在 sscratch 寄存器中
  • 因此在运行 usertrap 的时候可以利用 sscatch 寄存器中保存的值找到 user mode 下的页表地址
  • usertrap 首先将所有的寄存器保存在 p->trapframe 中
  • 然后将内核页表的地址修改为和用户的页表地址一致
  • 跳转到 usertrap 执行
  • usertrap 代码如下(基本思路和之前差不多)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
void usertrap(void) {
int which_dev = 0;
if((r_sstatus() & SSTATUS_SPP) != 0)
panic("usertrap: not from user mode");

// 设置 stvec 为 kernelvec
w_stvec((uint64)kernelvec);

struct proc *p = myproc();

// 保存PC, 否则可能会有其他的 usertrap 修改它
p->trapframe->epc = r_sepc();

if(r_scause() == 8){
// system call

if(p->killed)
exit(-1);

// 系统调用返回下一条命令
p->trapframe->epc += 4;

// 做完寄存器的操作之后打开设备中断
intr_on();

syscall(); // 系统调用
} else if((which_dev = devintr()) != 0){
// ok
} else {
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
p->killed = 1;
}

if(p->killed)
exit(-1);

// give up the CPU if this is a timer interrupt.
if(which_dev == 2)
yield();

usertrapret();
}
  • usertrap 的流程如下
    • 判断权限(要求是用户态)
    • 重新修改 stvec 为 kernelvec
      • 当前所处的状态是内核态
      • 之前提到的 yield 问题
    • 保存一些状态值,避免新的 usertrap 会修改它
      • PC 等
    • 判断具体引起 trap 的原因
      • 如果是 syscall
        • 判断进程是否存活
        • 修改 PC = PC + 4(系统调用返回下一条命令)
        • 打开设备中断
        • syscall 进行系统调用
      • 如果是 exception
        • 杀死进程并报错
      • 如果是 device interrupt
        • yield
    • 最后调用 usertrapret 返回
      • 这一部分主要恢复之前保存的状态

Time interrupt

  • 具体的机制和上面谈到的一样
  • time interrupt 的启动
    • 在操作系统启动的时候,开启 time interrupt

思考

  • 很多的设计都是一些 trade-off
    • 例如在 RISC-V 中,只让硬件做简单的工作,而不用进行一些复杂的操作(页表切换、栈切换等)
    • 这样的设计让不需要做复杂操作的程序运行得更快了(例如 kernelvec)
    • 但是同时需要做上述操作的程序处理起来就更加复杂,而且不是硬件直接做的话,速度上也会有所下降(例如 uservec)
  • 很多设计的思想其实是相通的,例如这里的 kernelvec 调用的时候需要自己保存一些状态,避免在此时再次引发 trap 的时候修改这些状态量。这和编译器上面递归函数调用的时候需要保存参数等是一致的。

参考资料

  • https://pdos.csail.mit.edu/6.828/2019/xv6/book-riscv-rev0.pdf
  • https://github.com/mit-pdos/xv6-riscv