Skip to content
Published at:

21. 异常与中断

上一章介绍了 CSR 和三种特权模式。有了这些基础设施,我们现在可以深入理解 CPU 在遇到"意外情况"时的完整响应机制。RISC-V 将一切打断正常指令流的事件统一称作陷阱(Trap)——不论是非法指令、定时器到期、还是网卡数据到达,都走同一条硬件处理路径。这种统一设计大幅简化了硬件和控制软件(OS/固件)的结构。

异常 vs 中断 vs 陷阱

三个术语的精确定义

在 RISC-V 规范中,这三个词有严格区分:

术语英文本质举例
异常Exception同步事件——由当前指令的执行直接触发非法指令、ecall、断点、页错误、地址不对齐
中断Interrupt异步事件——由外部设备/定时器触发,与当前指令无关定时器到期、UART 接收数据、DMA 完成
陷阱Trap统称——异常和中断的总和一切打断正常执行流的硬件事件

关键区别在于时序的可预测性

  • 异常与特定指令绑定——每次执行那条非法指令,异常一定发生
  • 中断随时可能到达——硬件在当前指令执行完后、下一条指令执行前采样中断信号

为什么统一处理

RISC-V 有意将异常和中断统一为陷阱处理框架。好处:

  1. 一套硬件保存/恢复机制mepc/mcause/mtval 对异常和中断同样适用
  2. 统一的入口和出口:都通过 mtvec 进入处理程序,通过 mret/sret 返回
  3. 统一的分发逻辑:处理程序读 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 字节内放不下时使用无条件跳转指令扩展。

asm
# 设置 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

asm
# 陷阱处理程序读取故障地址
    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):

编号名称触发场景
0Instruction address misaligned跳转目标地址未对齐
1Instruction access fault取指时发生物理内存访问错误
2Illegal instruction操作码在实现的 ISA 中不存在
3Breakpointebreak 指令或调试断点
4Load address misalignedload 地址不对齐(非对齐访问未使能时)
5Load access faultload 操作发生物理内存访问错误
6Store/AMO address misalignedstore/AMO 地址不对齐
7Store/AMO access faultstore/AMO 发生物理内存访问错误
8Environment call from U-mode用户态 ecall
9Environment call from S-mode内核态 ecall(S 模式调用 M 模式固件)
11Environment call from M-modeM 模式 ecall(较少使用)
12Instruction page fault取指时页表翻译失败(虚拟内存系统)
13Load page faultLoad 时页表翻译失败
15Store/AMO page faultStore/AMO 时页表翻译失败

中断编码(mcause[XLEN-1]=1):

编号名称触发场景
1Supervisor software interruptS 模式间 hart 间中断(IPI)
3Machine software interruptM 模式间 hart 间中断
5Supervisor timer interruptS 模式定时器中断
7Machine timer interruptM 模式定时器中断
9Supervisor external interruptS 模式外部中断(PLIC 分发)
11Machine external interruptM 模式外部中断

值得注意的是,编号 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            # 跳回原位置

这个自动保存-恢复协议的设计目的是:

  1. 避免嵌套陷阱:进入陷阱后立即关中断(MIE=0),防止处理程序处理到一半又被中断打断
  2. 允许手动开中断:处理程序可以在保存上下文后重新开中断(将 MIE 置 1),支持中断嵌套
  3. 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
  继续执行被打断的代码

对应的汇编框架:

asm
    .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,是通过内存地址访问的):

寄存器访问方式描述
mtimeMMIO(平台定义地址)单调递增的 64 位计数器,由恒定频率时钟驱动
mtimecmpMMIO(平台定义地址)64 位比较值;当 mtime >= mtimecmp 时触发定时器中断

QEMU virt 平台的典型地址:

mtime:     0x0200BFF8(RV64)
mtimecmp:  0x02004000 + 8 × hart_id(每个 hart 独立)

时钟频率通常为 10 MHz(QEMU 默认)——这意味着 mtime 每 100ns 加 1。

定时器中断处理流程

asm
# ---- 设置首次定时器中断 ----
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

三个容易出错的地方:

  1. mtimecmp 必须在处理程序中更新:如果不在处理程序中写 mtimecmp,中断将持续触发(mtime 只增不减,中断条件一直成立)
  2. mmio 读取 mtime 是易失的:必须每次都从 MMIO 地址加载,不能将之前的值缓存在寄存器中假设它不变
  3. MIE 的位精确性mie.MTIE(bit 7) 和 mip.MTIP(bit 7) 是定时器中断的配对使能和挂起位——两处都要正确操作

完整 bare-metal 定时器示例

以下是一个完整的 bare-metal 中断驱动 tick 计数器:

asm
    .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_handlerhandle_mtimret → 回到 wfi → 再次等待。这是一个最小但完整的定时器驱动系统。

异常委托与中断委托

在支持 S 模式的系统中,M 模式固件可以将某些陷阱"下放"给 S 模式:

asm
# 将 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,自己仅处理最底层不可恢复事件