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 可能同时处理中断。中断发生时硬件的处理流程。

  1. 如果是设备中断,将 sstatus 中的 SIE 位清 0,然后跳过以下步骤。
  2. 如果不是设备中断,将 sstatus 中的 SIE 位清 0,关闭设备中断。
  3. 将 pc 的值复制到 sepc 中。
  4. 将发生中断时的模式(user mode 还是 supervisor mode)写入 sstatus 中的 SPP 位。
  5. 设置 scause 的内容,指明中断发生的原因。
  6. 设置模式为 supervisor mode。
  7. 将 stvec 的值复制到 pc 中。
  8. 从新的 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 指令交换 sscratcha0 的值,这样用户代码的 a0 值被保存到了 sscratchuservec 可以通过 a0 的值来向 trapframe 里写入要保存的的寄存器的值。

在进入用户空间之前,内核就就将每个进程的 trapframe 地址写入 sscratch 寄存器,uservec 在保存被中断的程序的寄存器时,仍然处于用户空间,所以需要把 trapframe 映射到用户空间,当每个进程创建时会分配 trapframe 页,就映射在 TRAMPOLINE 下面一页 TRAPFRAME,同时进程结构体的 p->trapframe 保存了 trampframe 的物理地址。

uservec 保存的寄存器里的值有:进程的内核栈,当前的 cpu id , usertrap 的地址, 内核页表的地址,通过这些值就可以切换到内核页表,跳转到 usertrap 执行。

usertrap 根据 trap 产生的原因分别处理中断,在这之前会将 stvec 改成 kernelvec ,因为此时已经是在内核执行, 同时会保存 sepc 以防被其他进程的中断修改,中断处理完成后跳转到 usertrapret 执行,usrtrapret 首先恢复 stvecuservec,然后准备 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 里的汇编代码提出的几个问题。

 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
int f(int x) {
   e:	1141                	addi	sp,sp,-16
  10:	e422                	sd	s0,8(sp)
  12:	0800                	addi	s0,sp,16
  return g(x);
}
  14:	250d                	addiw	a0,a0,3
  16:	6422                	ld	s0,8(sp)
  18:	0141                	addi	sp,sp,16
  1a:	8082                	ret

000000000000001c <main>:

void main(void) {
  1c:	1141                	addi	sp,sp,-16
  1e:	e406                	sd	ra,8(sp)
  20:	e022                	sd	s0,0(sp)
  22:	0800                	addi	s0,sp,16
  printf("%d %d\n", f(8)+1, 13);
  24:	4635                	li	a2,13
  26:	45b1                	li	a1,12
  28:	00000517          	auipc	a0,0x0
  2c:	7b050513          	addi	a0,a0,1968 # 7d8 <malloc+0xea>
  30:	00000097          	auipc	ra,0x0
  34:	600080e7          	jalr	1536(ra) # 630 <printf>
  exit(0);
  38:	4501                	li	a0,0
  3a:	00000097          	auipc	ra,0x0
  3e:	27e080e7          	jalr	638(ra) # 2b8 <exit>

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

要实现函数调用栈跟踪。这里可以参考一下系统调用那里的函数调用栈帧关系,就比较好做了。

根据提示实现,首先添加函数定义:

1
void            backtrace();

将获取 fp 值的内联汇编代码添加到 kern/riscv.h。

实现 backtrace:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
void
backtrace()
{
  printf("backtrace:\n");
  uint64 fp = r_fp();
  uint64 start = PGROUNDDOWN(fp);
  uint64 end = PGROUNDUP(fp);
  while (fp> start && fp < end) {
    uint64 ra = *(uint64*)(fp - 8);
    printf("%p\n", ra);
    fp = *(uint64*)(fp - 16);
  }
}

在 sys_sleep 调用 backtrace:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
uint64
sys_sleep(void)
{
  int n;
  uint ticks0;

  if(argint(0, &n) < 0)
    return -1;
  acquire(&tickslock);
  ticks0 = ticks;
  while(ticks - ticks0 < n){
    if(myproc()->killed){
      release(&tickslock);
      return -1;
    }
    sleep(&ticks, &tickslock);
  }
  release(&tickslock);
  backtrace();
  return 0;
}

Alarm

要求通过时间中断来实现监控进程的 CPU 使用情况,主要是实现一个处理时间中断程序。

根据提示来实现。

首先修改 Makefile 加入 alarmtest:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
UPROGS=\
	$U/_cat\
	$U/_echo\
	$U/_forktest\
	$U/_grep\
	$U/_init\
	$U/_kill\
	$U/_ln\
	$U/_ls\
	$U/_mkdir\
	$U/_rm\
	$U/_sh\
	$U/_stressfs\
	$U/_usertests\
	$U/_grind\
	$U/_wc\
	$U/_zombie\
	$U/_alarmtest\

增加 sigalarm, sigreturn 两个系统调用的定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
//user.h
int sigalarm(int ticks, void (*handler)());
int sigreturn(void);
//user.pl
entry("sigalarm");
entry("sigreturn");
//syscall.h
#define SYS_sigalarm  22
#define SYS_sigreturn  23
//syscall.c
[SYS_sigalarm]   sys_sigalarm,
[SYS_sigreturn]   sys_sigreturn,

需要在进程结构体中保存 alarm 的时间间隔,用户处理函数,以及经过了多少个 tick:

1
2
3
4
//proc.h
int alarm_interval;
uint64 handler;
int ticks;

初始化这些变量:

1
2
3
4
5
6
//proc.c
found:
p->pid = allocpid();
p->ticks = 0;
p->alarm_interval = 0;
p->handler = -1;

在时钟中断产生时,判断是否已经到了指定的间隔,如果已经到了指定间隔,跳转到 handler 执行,这里有 p->trapfram->epc 指针是中断返回后程序继续执行的地址,所以要跳转到 handler 执行,需要将它的值改成 handler 的地址,当然在修改之前需要先把它的值保存下来,由于用户的 hander 程序也有可能发生中断会改变 p->trapfram 里的值,所以这里可以把整个 trapframe 保存下来,用户的 handler 在结束时会调 sigreturn 系统调用,我们可以在 sigreturn 中恢复 trapframe 。为了避免在一个时间间隔里执行多次 handler,只有在 handler 成功返回时才将保存的 ticks 重置。

 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
//trap.c
 } else if((which_dev = devintr()) != 0){
    if(which_dev == 2) {
      p->ticks++;
      if(p->ticks == p->alarm_interval) {
        if(p->handler >= 0) {
          p->ntrapframe = *p->trapframe;
          p->trapframe->epc = p->handler;
        }
      }
    }
    // ok
  }

//proc.h
struct trapframe ntrapframe;

//sysproc.c
uint64
sys_sigreturn(void)
{
  *myproc()->trapframe = myproc()->ntrapframe;
  myproc()->ticks = 0;
  return 0;
}