【一生一芯 / PA】异常响应机制:从 ecall 到 mtvec 再到 mret 的完整代码解析

代码环境:
NEMU:~/ysyx-workbench/nemu
AM:~/ysyx-workbench/abstract-machine
ISA:riscv32


一、异常响应机制到底在解决什么问题

在 PA3 中,我们要让程序之间能切换执行流。
但讲义特别强调:

不能用 call / jal 随便跳转
必须提供一种“限制入口”的执行流切换方式

这种机制就是:

异常响应机制

它的核心流程是:

异常 / 中断发生
  -> 硬件保存返回地址
  -> 硬件保存异常原因
  -> 硬件跳转到固定入口 mtvec
  -> 软件保存上下文
  -> 软件处理异常
  -> 软件通过 mret 返回

在 RISC-V 中,这三个关键 CSR 是:

mtvec  -> 异常入口
mepc   -> 异常返回地址
mcause -> 异常原因

二、NEMU 硬件侧:isa_raise_intr()

文件:

nemu/src/isa/riscv32/system/intr.c
word_t isa_raise_intr(word_t NO, vaddr_t epc) {
    // 1. 保存返回地址
    csr_write(MEPC_ADDR, epc);

    // 2. 保存异常原因
    csr_write(MCAUSE_ADDR, NO);

    // 3. 特殊处理 ebreak
    if (NO == 3) {
        nemu_state.state = NEMU_END;
        nemu_state.halt_pc = epc;
        nemu_state.halt_ret = 0;
        return epc;
    }

    // 4. 读取异常入口
    word_t mtvec = csr_read(MTVEC_ADDR);

    // 5. 返回 mtvec,并保证 4 字节对齐
    return mtvec & ~0x3;
}

逐行解释

csr_write(MEPC_ADDR, epc);

保存异常发生时的 PC,将来 mret 要返回这里。

csr_write(MCAUSE_ADDR, NO);

保存异常原因。

例如:

11          -> ecall
3           -> ebreak
0x80000007  -> 机器定时器中断
if (NO == 3)

你的代码把 ebreak 特殊处理为 NEMU 正常结束。

return mtvec & ~0x3;

把 PC 修改为 mtvec,这就是异常入口跳转。


三、AM 软件侧:cte_init() 设置异常入口

文件:

abstract-machine/am/src/riscv/nemu/cte.c
bool cte_init(Context*(*handler)(Event, Context*)) {
  asm volatile("csrw mtvec, %0" : : "r"(__am_asm_trap));
  user_handler = handler;
  return true;
}

解释

csrw mtvec, __am_asm_trap

把异常入口设置为汇编函数 __am_asm_trap。

这样,无论用户程序如何触发异常,CPU 都只能跳转到:

mtvec = __am_asm_trap

这就是“限制入口”的具体实现。


四、汇编异常入口:保存和恢复上下文

文件:

abstract-machine/am/src/riscv/nemu/trap.S
__am_asm_trap:
  addi sp, sp, -CONTEXT_SIZE

  MAP(REGS, PUSH)

  csrr t0, mcause
  csrr t1, mstatus
  csrr t2, mepc

  STORE t2, OFFSET_EPC(sp)
  STORE t0, OFFSET_CAUSE(sp)
  STORE t1, OFFSET_STATUS(sp)

  mv a0, sp
  call __am_irq_handle

  mv sp, a0

  LOAD t1, OFFSET_STATUS(sp)
  LOAD t2, OFFSET_EPC(sp)
  csrw mstatus, t1
  csrw mepc, t2

  MAP(REGS, POP)

  addi sp, sp, CONTEXT_SIZE
  mret

逐段解释

1. 保存通用寄存器
addi sp, sp, -CONTEXT_SIZE
MAP(REGS, PUSH)

在栈上开辟上下文空间,保存所有通用寄存器。

2. 读取 CSR
csrr t0, mcause
csrr t1, mstatus
csrr t2, mepc

把异常原因、状态和返回地址读出来。

3. 保存到 Context
STORE t2, OFFSET_EPC(sp)
STORE t0, OFFSET_CAUSE(sp)
STORE t1, OFFSET_STATUS(sp)

写入 C 结构体 Context。

4. 调用 C 处理函数
mv a0, sp
call __am_irq_handle

把 Context * 传给 C 函数。

5. 恢复上下文
csrw mstatus, t1
csrw mepc, t2
MAP(REGS, POP)
mret

恢复寄存器和 CSR,最后 mret 返回。


五、C 语言异常分发

文件:

abstract-machine/am/src/riscv/nemu/cte.c
Context* __am_irq_handle(Context *c) {
  if (user_handler) {
    Event ev = {0};

    switch (c->mcause) {
      case 11:
        if (c->gpr[17] == -1) {
            ev.event = EVENT_YIELD;
        } else {
            ev.event = EVENT_SYSCALL;
        }
        c->mepc += 4;
        break;

      case 0x80000007:
        ev.event = EVENT_IRQ_TIMER;
        break;

      default:
        ev.event = EVENT_ERROR;
        break;
    }

    c = user_handler(ev, c);
    assert(c != NULL);
  }

  return c;
}

逐行解释

case 11:

mcause == 11 表示 ecall。

if (c->gpr[17] == -1)

如果 a7 = -1,说明是 yield(),否则是系统调用。

c->mepc += 4;

跳过 ecall 指令,返回后从下一条指令继续。

case 0x80000007:

mcause == 0x80000007 表示机器定时器中断。

ev.event = EVENT_IRQ_TIMER;

交给用户注册的 handler 处理。


六、用户侧事件处理函数

修改后的 trm.c:

static Context *event_handler(Event ev, Context *ctx) {
  switch (ev.event) {
    case EVENT_YIELD:
      break;

    case EVENT_SYSCALL:
      if (ctx->GPRx == 0) {
        halt(ctx->GPR1);
      }
      break;

    case EVENT_IRQ_TIMER:
      break;

    default:
      panic("Unhandled event");
      break;
  }

  return ctx;
}

这个 event_handler 就是未来操作系统的入口雏形。


七、定时器中断如何触发

文件:

nemu/src/device/intr.c
void dev_raise_intr() {
  cpu.pc = isa_raise_intr(0x80000007, cpu.pc);
}

解释

isa_raise_intr(0x80000007, cpu.pc)

表示:

异常原因 = 机器定时器中断
返回地址 = 当前 PC

然后:

pc = mtvec

进入 __am_asm_trap。


八、完整异常响应链路

1. yield() 链路

用户程序执行 ecall
        ↓
NEMU isa_raise_intr(11, pc)
        ↓
mcause = 11
mepc = pc
pc = mtvec
        ↓
__am_asm_trap
        ↓
保存 Context
        ↓
__am_irq_handle
        ↓
mcause == 11
a7 == -1 -> EVENT_YIELD
        ↓
event_handler
        ↓
返回 Context
        ↓
mret
        ↓
pc = mepc

2. 定时器中断链路

Linux 虚拟定时器
        ↓
alarm_sig_handler
        ↓
timer_intr
        ↓
dev_raise_intr
        ↓
isa_raise_intr(0x80000007, pc)
        ↓
mcause = 0x80000007
mepc = pc
pc = mtvec
        ↓
__am_asm_trap
        ↓
__am_irq_handle
        ↓
EVENT_IRQ_TIMER
        ↓
event_handler
        ↓
mret

九、测试验证

1. 编译 NEMU

cd ~/ysyx-workbench/nemu
make -j$(nproc)

2. 运行中断测试

export NEMU_HOME=~/ysyx-workbench/nemu
export AM_HOME=~/ysyx-workbench/abstract-machine

cd ~/ysyx-workbench/am-kernels/tests/am-tests

make ARCH=riscv32-nemu run mainargs=i

3. 成功标志

会看到异常处理链反复执行:

hello_intr
  -> __am_asm_trap
  -> __am_irq_handle
  -> simple_trap
  -> putch

最终输出:

ttttttttttt

其中:

t = 定时器中断
d = 设备中断
y = yield

因为 hello_intr() 是死循环,所以测试会一直运行,timeout 是正常的。


十、代码和讲义对照

异常响应机制对应代码
保存返回地址csr_write(MEPC_ADDR, epc)
保存异常原因csr_write(MCAUSE_ADDR, NO)
跳转异常入口return mtvec & ~0x3
设置异常入口cte_init -> csrw mtvec
保存上下文trap.S
异常分发__am_irq_handle
用户处理event_handler
异常返回mret

十一、总结

异常响应机制就是一套标准流程:

硬件:保存 mepc / mcause,跳转 mtvec
软件:保存上下文
软件:根据 mcause 分发事件
软件:处理事件
软件:mret 返回

你的代码现在已经具备这条完整链路。
接下来 PA3 要做的,就是在这个机制之上实现:

1. 操作系统入口 event_handler
2. 用户程序加载器
3. 批处理调度
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐