快乐的春饼

xv6 学习笔记:Lab Traps

Lab Traps

学习

  • xv6 book 第 4 章
  • kernel/trap.c, kernel/trampline.S

riscv

在 xv6 学习笔记:Lab System calls 中介绍了几个寄存器。要处理 trap 的话,还要使用以下寄存器:

stvec (Supervisor Trap Vector Base Address Register) 寄存器被内核写入 trap 处理程序的地址;RISC-V跳转到这里来处理trap。

sscratch (Supervisor Scratch Register) 寄存器被内核放置了一个值,在trap处理程序开始时可以方便地使用。

riscv 寄存器

reg    | name  | saver  | description
-------+-------+--------+------------
x0     | zero  |        | hardwired zero
x1     | ra    | caller | return address
x2     | sp    | callee | stack pointer
x3     | gp    |        | global pointer
x4     | tp    |        | thread pointer
x5-7   | t0-2  | caller | temporary registers
x8     | s0/fp | callee | saved register / frame pointer
x9     | s1    | callee | saved register
x10-11 | a0-1  | caller | function arguments / return values
x12-17 | a2-7  | caller | function arguments
x18-27 | s2-11 | callee | saved registers
x28-31 | t3-6  | caller | temporary registers
pc     |       |        | program counter

系统调用过程

以 exec 系统调用为例,进程调用 exec 函数,首先会在 usys.S 中找到 exec 对应的代码:

.global exec
exec:
 li a7, SYS_exec
 ecall
 ret

SYS_exec是 exec 的系统调用号,它被加载到 a7 寄存器中。然后调用 ecall。

ecall 会读取 stvec 寄存器并将 pc 设为其值。而 stvec 在之前就已经被设置成了 uservec 的地址。所以 ecall 后,就会执行 uservec 函数。同时,ecall 也使得机器从 U-mode 转为 S-mode。ecall 触发 trap,RISC-V 总是在开始使用 trap 时通过清除 sstatus 的 SIE 位来禁用中断。

uservec 定义在 trampoline.S 中。uservec 首先把各个寄存器的值存到 TRAPFRAME 页中。注意此时虽然以 S-mode 运行,但仍然使用进程页表而非内核页表。然后,uservec 把内核栈指针、hartid、trap 处理程序 usertrap 的地址、内核页表地址(在 TRAPFRAME 保存的 satp 中)加载到寄存器里。然后刷新 TLB 缓存,写入装入了内核页表地址的 satp,切换到内核页表。然后跳转到 usertrap。

usertrap 只处理 U-mode 的 trap,所以开头会检查 sstatus 的 SPP 位,确保 trap 来自 U-mode。因为现在已经在内核中了,所以把 stvec 改为 kernelvec 的地址,用来处理内核的中断或异常。并保存进程的程序计数器。然后检查 scause 是否为 8 (Environment call from U-mode),即系统调用(执行 ecall)产生了这个 trap。如果是,则调用 syscall 函数,执行具体的系统调用代码。执行完系统调用后,再依次反向做上面的动作,包括写 stvec 为 uservec 的地址,写 trapframe 的内核相关字段,把 sstatus 的 SPP 为设为 0(这样 sret 的时候就一定返回到 U-mode),然后把 sepc 设为 trapframe 里保存的 epc,最后返回装入了用户页表地址的新 satp(将其保存在 a0 寄存器中)。

注意 uservec 最后一条指令jalr t0,这条指令不仅调用了 usertrap,还使得 usertrap 返回后会跳到 jalr 的下一条指令,即 userret 处执行。usertrap 返回后切换到进程页表,把 trapframe 里的字段都取回各个寄存器。最后通过 sret 返回到 sepc 指定的地址,并切回 U-mode。

练习

Backtrace

栈帧的布局:

Stack
                   .
                   .
      +->          .
      |   +-----------------+   |
      |   | return address  |   |
      |   |   previous fp ------+
      |   | saved registers |
      |   | local variables |
      |   |       ...       | <-+
      |   +-----------------+   |
      |   | return address  |   |
      +------ previous fp   |   |
          | saved registers |   |
          | local variables |   |
      +-> |       ...       |   |
      |   +-----------------+   |
      |   | return address  |   |
      |   |   previous fp ------+
      |   | saved registers |
      |   | local variables |
      |   |       ...       | <-+
      |   +-----------------+   |
      |   | return address  |   |
      +------ previous fp   |   |
          | saved registers |   |
          | local variables |   |
  $fp --> |       ...       |   |
          +-----------------+   |
          | return address  |   |
          |   previous fp ------+
          | saved registers |
  $sp --> | local variables |
          +-----------------+

注意指针类型的加减法和整数类型的不一样。下面第二种写法是错的。

fp = *(uint64 *)(fp - 16);
fp = *((uint64 *)fp - 16);

Alarm

理清思路很重要,代码可以不用很多。

sepc 是关键的寄存器。修改它就能控制中断返回之后从哪里开始执行代码。

alarm 的作用是在一个进程使用一定的 CPU 时间后,调用进程指定的 handler 发出警报。这意味着,不论这一进程是否获得了充足的 CPU 时间,都以它获得的实际 CPU 时间计算是否发出警报。clockintr 表明每隔约 0.1s,ticks 就会+1,并触发时钟中断。所以,每个进程每次进入时钟中断就意味着该进程又使用了 1 tick 的 CPU 时间。

alarm handler 运行时,如果运行时间长,可能会再次触发 alarm,这种情况不能出现。

执行 alarm handler 时,handler 也有自己的栈帧,会修改寄存器的值。特别是,handler 运行的时候也有可能陷入中断,uservec 会无条件把 handler 相关的寄存器保存到 trapframe 中,从而导致原有 trapframe 丢失。所以,要将发生 alarm 时的 trapframe 另外保存下来。同样可以保存在进程的 TRAMPLINE 页中,那里还有很多空间未使用。当 handler 调用 sigreturn 时,把原来的 trapframe 复制回原处。

我的代码