21. 异常与中断
上一章介绍了 CSR 和三种特权模式。有了这些基础设施,我们现在可以深入理解 CPU 在遇到"意外情况"时的完整响应机制。RISC-V 将一切打断正常指令流的事件统一称作陷阱(Trap)——不论是非法指令、定时器到期、还是网卡数据到达,都走同一条硬件处理路径。这种统一设计大幅简化了硬件和控制软件(OS/固件)的结构。
异常 vs 中断 vs 陷阱
三个术语的精确定义
在 RISC-V 规范中,这三个词有严格区分:
| 术语 | 英文 | 本质 | 举例 |
|---|---|---|---|
| 异常 | Exception | 同步事件——由当前指令的执行直接触发 | 非法指令、ecall、断点、页错误、地址不对齐 |
| 中断 | Interrupt | 异步事件——由外部设备/定时器触发,与当前指令无关 | 定时器到期、UART 接收数据、DMA 完成 |
| 陷阱 | Trap | 统称——异常和中断的总和 | 一切打断正常执行流的硬件事件 |
关键区别在于时序的可预测性:
- 异常与特定指令绑定——每次执行那条非法指令,异常一定发生
- 中断随时可能到达——硬件在当前指令执行完后、下一条指令执行前采样中断信号
为什么统一处理
RISC-V 有意将异常和中断统一为陷阱处理框架。好处:
- 一套硬件保存/恢复机制:
mepc/mcause/mtval对异常和中断同样适用 - 统一的入口和出口:都通过
mtvec进入处理程序,通过mret/sret返回 - 统一的分发逻辑:处理程序读
mcause的最高位判断是中断还是异常,然后走对应分支
唯一的区别在于 mepc 的含义:对于异常,mepc 指向触发异常的指令(返回时需要重新执行或跳过);对于中断,mepc 指向尚未执行的下一条指令(返回时继续执行)。
陷阱相关 CSR 总览
第 20 章已经介绍了这些 CSR 的地址和基本用途,这里深入它们的陷阱相关语义。
mtvec:陷阱向量的两种模式
mtvec(Machine Trap Vector)决定了陷阱发生后 PC 跳转到哪里:
mtvec[1:0] = 00 → Direct 模式
PC ← BASE (mtvec 的高位部分,4 字节对齐)
mtvec[1:0] = 01 → Vectored 模式
PC ← BASE + 4 × cause_number (仅对中断有效)Direct 模式(最常用):所有陷阱都跳转到同一地址。处理程序通过 mcause 来区分处理。
Vectored 模式:硬件根据中断编号自动计算跳转偏移。异常仍跳转到 BASE。此模式仅在中断向量表场景下有意义,可以减少处理程序的查表开销——但要求所有向量入口在 4 字节内放不下时使用无条件跳转指令扩展。
# 设置 Direct 模式陷阱向量
la t0, trap_handler
csrw mtvec, t0 # bit[1:0] = 00(地址 4 字节对齐)
# 设置 Vectored 模式陷阱向量
la t0, vector_table
ori t0, t0, 1 # bit[0] = 1 使能 Vectored 模式
csrw mtvec, t0
# Vectored 模式下的中断向量表
.align 6 # 256 字节对齐(最多 64 个向量)
vector_table:
j default_handler # cause 0
j ssi_handler # cause 1: Supervisor 软件中断
j default_handler # cause 2
j msi_handler # cause 3: Machine 软件中断
# ... 等等mepc:异常程序计数器
当陷阱发生时,硬件将当前 PC(或下一条指令的 PC)保存到 mepc:
# 陷阱处理程序读取故障地址
csrr t0, mepc # t0 = 触发异常的指令地址
# 对于 ecall 指令,mepc 指向 ecall 本身
# 返回时:mepc+4 跳过 ecall,执行下一条指令
addi t0, t0, 4
csrw mepc, t0 # 返回后继续执行下一条指令处理程序可以根据需要修改 mepc,以此来改变返回位置。这对于系统调用(ecall 返回后应执行下一条指令)、断点调试(单步执行)等场景至关重要。
mcause:原因编码
mcause 是陷阱处理程序的核心分发依据。它的位布局:
mcause[XLEN-1] = Interrupt 位:1 = 中断,0 = 异常
mcause[XLEN-2:0] = 异常/中断编号完整的编码表:
异常编码(mcause[XLEN-1]=0):
| 编号 | 名称 | 触发场景 |
|---|---|---|
| 0 | Instruction address misaligned | 跳转目标地址未对齐 |
| 1 | Instruction access fault | 取指时发生物理内存访问错误 |
| 2 | Illegal instruction | 操作码在实现的 ISA 中不存在 |
| 3 | Breakpoint | ebreak 指令或调试断点 |
| 4 | Load address misaligned | load 地址不对齐(非对齐访问未使能时) |
| 5 | Load access fault | load 操作发生物理内存访问错误 |
| 6 | Store/AMO address misaligned | store/AMO 地址不对齐 |
| 7 | Store/AMO access fault | store/AMO 发生物理内存访问错误 |
| 8 | Environment call from U-mode | 用户态 ecall |
| 9 | Environment call from S-mode | 内核态 ecall(S 模式调用 M 模式固件) |
| 11 | Environment call from M-mode | M 模式 ecall(较少使用) |
| 12 | Instruction page fault | 取指时页表翻译失败(虚拟内存系统) |
| 13 | Load page fault | Load 时页表翻译失败 |
| 15 | Store/AMO page fault | Store/AMO 时页表翻译失败 |
中断编码(mcause[XLEN-1]=1):
| 编号 | 名称 | 触发场景 |
|---|---|---|
| 1 | Supervisor software interrupt | S 模式间 hart 间中断(IPI) |
| 3 | Machine software interrupt | M 模式间 hart 间中断 |
| 5 | Supervisor timer interrupt | S 模式定时器中断 |
| 7 | Machine timer interrupt | M 模式定时器中断 |
| 9 | Supervisor external interrupt | S 模式外部中断(PLIC 分发) |
| 11 | Machine external interrupt | M 模式外部中断 |
值得注意的是,编号 10/14 等跳过的编码保留给将来使用——RISC-V 设计者的远见。
mtval:附加信息
mtval 的内容取决于陷阱类型:
- 非法指令(cause=2):
mtval包含非法指令的编码本身 - 地址不对齐(cause=0/4/6):
mtval包含问题地址 - 页错误(cause=12/13/15):
mtval包含引发错误的虚拟地址 - 断点(cause=3):
mtval的值是实现定义的(implementation-defined)
这个寄存器是可选的——在某些轻量级实现中 mtval 始终为 0。但对于调试和 OS 开发,mtval 提供的附加信息在定位问题时价值极大。
mstatus:陷阱上下文保存与恢复
陷阱发生时,mstatus 的关键字段自动执行以下操作:
陷阱进入时(硬件自动):
mstatus.MPIE ← mstatus.MIE # 保存旧的中断使能
mstatus.MIE ← 0 # 关全局中断
mstatus.MPP ← 当前特权级 # 保存旧的特权级别
mret 执行时(硬件自动):
mstatus.MIE ← mstatus.MPIE # 恢复旧的中断使能
mstatus.MPIE ← 1 # 重置 MPIE(为下次陷阱准备)
mstatus.MPP ← 0 (U 模式) # 重置 MPP
PC ← mepc # 跳回原位置这个自动保存-恢复协议的设计目的是:
- 避免嵌套陷阱:进入陷阱后立即关中断(MIE=0),防止处理程序处理到一半又被中断打断
- 允许手动开中断:处理程序可以在保存上下文后重新开中断(将 MIE 置 1),支持中断嵌套
- MPP 记录来源:
mret通过 MPP 知道要返回到哪个特权级——这是跨特权级返回的必要信息
陷阱处理完整流程(6 步)
以一个完整的 M 模式陷阱处理为例,展示从头到尾的 6 步流程:
第 1 步:硬件自动保存
mepc ← PC(异常)或 PC+4(中断)
mcause ← 原因编码
mtval ← 附加信息(如有)
mstatus.MPP ← 当前特权级
mstatus.MPIE ← mstatus.MIE
mstatus.MIE ← 0
PC ← mtvec(跳转到处理程序)
第 2 步:软件保存上下文
将所有可能被破坏的通用寄存器压入栈或暂存区
这是 trap_handler 的前几行代码
第 3 步:读取 mcause 并分发
根据最高位判断中断 vs 异常
根据低 12 位编号跳转到对应的处理逻辑
第 4 步:执行具体处理
异常处理:打印错误、kill 进程、修复页表等
中断处理:更新 mtimecmp、读取外设数据等
第 5 步:恢复上下文
从栈或暂存区恢复所有通用寄存器
第 6 步:mret 返回
PC ← mepc
特权级 ← mstatus.MPP
MIE ← mstatus.MPIE
继续执行被打断的代码对应的汇编框架:
.align 4
trap_handler:
# ---- 第 2 步:保存上下文 ----
addi sp, sp, -32*8 # 在栈上分配 32×8 字节空间
sd ra, 0(sp) # 保存返回地址
sd t0, 8(sp) # 保存临时寄存器
sd t1, 16(sp)
sd t2, 24(sp)
# ... 保存 t3-t6, a0-a7, s0-s11, gp, tp ...
# ---- 第 3 步:读 mcause 并分发 ----
csrr t0, mcause
bge t0, x0, is_exception # 最高位为 0 → 异常
# 最高位为 1 → 中断
is_interrupt:
andi t1, t0, 0xFFF # 提取中断编号
li t2, 7 # Machine timer interrupt = 7
beq t1, t2, handle_mti
li t2, 3 # Machine software interrupt = 3
beq t1, t2, handle_msi
li t2, 11 # Machine external interrupt = 11
beq t1, t2, handle_mei
j trap_exit # 未知中断,直接返回
is_exception:
andi t1, t0, 0xFFF # 提取异常编号
li t2, 2 # Illegal instruction
beq t1, t2, handle_illegal
li t2, 8 # ecall from U-mode
beq t1, t2, handle_ecall
# ... 其他异常处理 ...
# ---- 第 4 步后段 + 第 5 步 + 第 6 步 ----
trap_exit:
# 恢复上下文
ld ra, 0(sp)
ld t0, 8(sp)
ld t1, 16(sp)
ld t2, 24(sp)
# ... 恢复所有寄存器 ...
addi sp, sp, 32*8 # 回收栈空间
mret # 返回被打断的代码定时器中断详解
定时器中断是操作系统实现抢占式调度的基石——没有它,一个死循环就能霸占整个 CPU。
mtime 与 mtimecmp
定时器机制由两个 MMIO 寄存器组成(注意:不是 CSR,是通过内存地址访问的):
| 寄存器 | 访问方式 | 描述 |
|---|---|---|
mtime | MMIO(平台定义地址) | 单调递增的 64 位计数器,由恒定频率时钟驱动 |
mtimecmp | MMIO(平台定义地址) | 64 位比较值;当 mtime >= mtimecmp 时触发定时器中断 |
QEMU virt 平台的典型地址:
mtime: 0x0200BFF8(RV64)
mtimecmp: 0x02004000 + 8 × hart_id(每个 hart 独立)时钟频率通常为 10 MHz(QEMU 默认)——这意味着 mtime 每 100ns 加 1。
定时器中断处理流程
# ---- 设置首次定时器中断 ----
setup_timer:
li t0, 0x200BFF8 # mtime 的 MMIO 地址
ld t1, 0(t0) # t1 = 当前 mtime 值
li t2, 10000000 # 10M 个 tick ≈ 1 秒(10 MHz 时钟)
add t1, t1, t2 # t1 = 下次触发时间
li t0, 0x2004000 # mtimecmp 的 MMIO 地址
sd t1, 0(t0) # 写 mtimecmp
# 使能 M 模式定时器中断
li t0, (1 << 7) # MTIE = bit 7
csrs mie, t0 # 开定时器中断使能
csrs mstatus, (1 << 3) # 开 M 模式全局中断
ret
# ---- 定时器中断处理程序 ----
handle_mti:
# (上下文已在 trap_handler 中保存)
# 1. 确认:读 mip 检查 MTIP 位
csrr t0, mip
andi t0, t0, (1 << 7) # MTIP = bit 7
beqz t0, trap_exit # 不是定时器中断,跳过
# 2. 设置下次中断
li t0, 0x200BFF8 # mtime 地址
ld t1, 0(t0) # t1 = 当前 mtime
li t2, 10000000 # 1 秒后再次触发
add t1, t1, t2
li t0, 0x2004000 # mtimecmp 地址
sd t1, 0(t0)
# 3. 执行定时器相关逻辑(如进程调度)
# call schedule 或简单的计数器递增
la t0, tick_count
ld t1, 0(t0)
addi t1, t1, 1
sd t1, 0(t0)
# 4. 跳转到共享退出路径(恢复上下文 + mret)
j trap_exit三个容易出错的地方:
- mtimecmp 必须在处理程序中更新:如果不在处理程序中写 mtimecmp,中断将持续触发(mtime 只增不减,中断条件一直成立)
- mmio 读取 mtime 是易失的:必须每次都从 MMIO 地址加载,不能将之前的值缓存在寄存器中假设它不变
- MIE 的位精确性:
mie.MTIE(bit 7) 和mip.MTIP(bit 7) 是定时器中断的配对使能和挂起位——两处都要正确操作
完整 bare-metal 定时器示例
以下是一个完整的 bare-metal 中断驱动 tick 计数器:
.section .data
tick_count: .dword 0
.section .text
.globl _start
_start:
la sp, _stack_top
la t0, trap_handler
csrw mtvec, t0 # 设置 Direct 模式陷阱向量
call setup_timer # 初始化定时器
csrs mstatus, (1 << 3) # 全局开中断
main_loop:
wfi # 等待中断(WFI = Wait For Interrupt)
# 中断返回后,继续 wfi
j main_loop
# trap_handler 和 handle_mti 的定义如上所示程序的控制流是:wfi → 定时器中断 → trap_handler → handle_mti → mret → 回到 wfi → 再次等待。这是一个最小但完整的定时器驱动系统。
异常委托与中断委托
在支持 S 模式的系统中,M 模式固件可以将某些陷阱"下放"给 S 模式:
# 将 U 模式 ecall(异常 8)委托给 S 模式
li t0, (1 << 8)
csrs medeleg, t0 # 委托的异常不再在 M 模式处理
# 将 S 模式定时器中断(中断 5)委托给 S 模式
li t0, (1 << 5)
csrs mideleg, t0 # 委托的中断不再在 M 模式处理委托之后,这些陷阱会直接使用 sepc/scause/stval/stvec 等 S 模式 CSR,而不再经过 mepc/mcause 等 M 模式 CSR。这是 Linux 内核启动流程中的关键步骤——OpenSBI 固件将定时器、外部中断等委托给 Linux 内核,自身只保留对不可恢复错误的处理。
本章要点
- 陷阱(Trap)是异常(同步)和中断(异步)的统一抽象——都被
mtvec→ CSR 保存 → 分发 →mret这一条管道处理 mtvec控制陷阱去向(Direct 单入口 / Vectored 按编号偏移),mcause的最高位区分中断/异常,低 12 位提供精确的原因编号- 陷阱处理的 6 步流程:硬件自动保存 CSR → 软件压栈寄存器 → 读 mcause 分发 → 执行处理逻辑 → 出栈恢复 → mret 返回
- 定时器中断依赖 MMIO 寄存器 mtime/mtimecmp(非 CSR)——处理程序中必须写 mtimecmp 为新值来"应答"中断,否则中断将持续触发
- 委托机制(
medeleg/mideleg)让 M 模式固件将大部分陷阱下放给 S 模式 OS,自己仅处理最底层不可恢复事件