Skip to content

异常与中断:怎样离开程序,又准确回到原处 ​

三种入口,共用一套保存机制 ​

系统调用是程序主动执行 ecall;异常通常由当前指令引起,如缺页;中断来自设备或时钟,可能出现在任意指令之间。它们都需要离开当前执行流,但恢复规则不同。把三者统称为 trap 很方便,处理时却必须区分原因。

RISC-V 硬件会记录故障或返回相关 PC 到 sepc,记录原因到 scause,在适用的异常中把相关地址放到 stval,更新特权状态并跳到 stvec。它不会替 xv6 保存所有通用寄存器,也不会自动换上内核栈和内核页表。这部分由汇编入口完成。

2025 的 trampoline.S:uservec 先利用 sscratch 暂存用户 a0,再把用户寄存器保存到固定虚拟地址 TRAPFRAME。随后从 trapframe 读取内核栈、hart 标识和内核页表,切换 satp,调用 usertrap()。

为什么 trampoline 要两边映射 ​

进入 trap 的最初几条指令仍使用用户页表,但已经以 S 模式执行。切换 satp 后,下一条指令仍需能取到。因此 trampoline 代码在两套页表中映射到相同虚拟地址和相同物理代码页。

TRAPFRAME 也放在用户页表中,但没有 PTE_U。这里的“在用户页表中”只描述哪棵翻译树包含映射,不代表用户态有权访问。硬件已切换到 S 模式的 trampoline 可以访问,普通用户指令不可以。

sequenceDiagram
  participant U as 用户程序
  participant H as 硬件
  participant T as trampoline
  participant K as usertrap
  U->>H: ecall / fault / interrupt
  H->>T: S 模式,仍用用户页表
  T->>T: 保存用户寄存器,换内核栈和页表
  T->>K: 调用 usertrap
  K->>K: 分派原因,处理后 prepare_return
  K->>T: 返回用户 satp
  T->>T: 换用户页表,恢复寄存器
  T->>U: sret
查看流程图文本
sequenceDiagram
  participant U as 用户程序
  participant H as 硬件
  participant T as trampoline
  participant K as usertrap
  U->>H: ecall / fault / interrupt
  H->>T: S 模式,仍用用户页表
  T->>T: 保存用户寄存器,换内核栈和页表
  T->>K: 调用 usertrap
  K->>K: 分派原因,处理后 prepare_return
  K->>T: 返回用户 satp
  T->>T: 换用户页表,恢复寄存器
  T->>U: sret

2025 返回路径与常见错觉 ​

本版本 usertrap() 返回用户 satp;其末尾先调用 prepare_return() 设置 trapframe 内核字段、stvec、sstatus 和 sepc。汇编继续落到 userret,切换页表、恢复寄存器并 sret。不要凭旧版本记忆去找不存在的 usertrapret()。

系统调用情况下 sepc 指向 ecall,因此内核保存的 epc 增加 4。数据缺页被修复后不能增加 4,否则会跳过原来的读写指令。时钟中断也不能机械地给 PC 加 4,因为硬件记录的执行边界已经决定恢复位置。

kerneltrap() 是另一条入口:内核被中断时已经在内核页表和内核栈上。它仍需保护被打断的寄存器和控制状态。内核缺页经常表示内核自身错误,处理策略与用户非法访问后的杀进程不同。

从调用栈到 backtrace ​

RISC-V 调用约定中 ra 保存返回地址,s0 常作为 frame pointer。实验编译设置保留栈帧后,当前 fp 的负 8 字节处是保存的返回地址,负 16 字节处是上一个 fp。backtrace 反复跟随这条链,直到离开当前内核栈页。

但这不是所有编译器和所有优化设置下通用的栈格式。内联函数可能没有独立栈帧,尾调用也能改变栈结构。调试本实验必须看本次构建产生的 kernel.asm,而不是把源代码函数数目直接当成栈帧数目。

Alarm 是一次人为安排的用户控制流切换 ​

sigalarm(n, handler) 让进程消耗一定 CPU ticks 后运行 handler。内核不能直接把 handler 当内核函数调用:它是用户代码,必须以用户权限和用户页表运行。做法是在时钟 trap 中备份完整用户状态,把将要返回的 epc 改成 handler;handler 最后调用 sigreturn 恢复备份。

仅备份 ra 不够,因为中断可能落在任何指令之间;被打断代码仍然依赖 a0、临时寄存器等值。还必须阻止 handler 重入,否则第二次 alarm 会覆盖第一次备份。sigreturn 的返回值也不能随意设为 0:通用系统调用分派会把它写到 a0,覆盖刚恢复的 a0。

自测:为什么 handler 地址为 0 不能直接判定未注册?

在 xv6 的用户布局中,函数可能位于虚拟地址 0。应根据 interval 或单独的注册状态判断是否启用,不能把生产环境中的空指针直觉直接套过来。

自测:修改 trapframe.epc 后,程序会立刻跳到新地址吗?

不会。它先影响后续返回准备,最终由 sret 使用相应状态回到用户态。内核仍要完成当前处理、检查进程是否被杀死等步骤。

实践:traps、调试。来源:官方实验与 traps/cow 固定分支的 trampoline.S/trap.c。