Lab4 异常与特权
有道以统之,法虽少,足以化矣。——《淮南子·泰族训》
在《计算机组成原理》课程中,我们已经简要学习过有关于异常处理与特权指令的部分内容。在本次实验中,我们将基于 Lab3 中实现的 CPU,进一步完善异常处理与特权指令的实现。
本次实验的主要任务如下:
- 了解 RISC-V 异常处理的基本原理
- 在流水线中添加相关的控制状态寄存器,并能够正确处理 CSR 读写指令
- 在流水线中添加对于
ecall和mret指令的支持,使得处理器可以响应系统调用异常
1 中断与异常
在处理器执行过程中,会遇到打断正常指令流运行的情况,其中包括但不限于:
- 系统调用
- 访存地址错误
- 指令不存在
- 外部设备中断信号
一旦处理器侦测到这些中断和异常,CPU 就需要跳转到特定的程序来处理这些异常。
中断与异常的区别
中断和异常都是打断正常指令流运行的情况。它们的区别在于,中断是由外部设备发起的,而异常是在处理器内部产生的。在实现的过程中,中断是外设“附加”在某条指令上的,而异常是由某条指令“触发”的。
然而,中断和异常在处理逻辑上是很类似的,我们也经常会混用这两个词。
2 控制状态寄存器
当侦测到中断或异常时,在跳转到相应的处理程序之前,CPU 需要保存一些出错现场的信息,包括异常类型、触发异常的指令地址、触发异常时系统的特权级等。这些信息需要在异常触发的位置被立刻记录,并能够很方便地被 CPU 读取。因此,我们需要在处理器中添加一些寄存器来存储这些信息,这些寄存器被称为控制状态寄存器(Control and Status Register, CSR)。
CSR 与特权指令
CSR 中的信息都是非常重要的处理器内部信息,因此应当禁止用户程序用普通的指令访问 CSR。RISC-V 定义了一些特权指令,专门用来对 CSR 进行读写,你可以点击此处查看。
在我们的实验中,我们只会涉及到如下和异常相关的 CSR 寄存器:
mstatus:记录了处理器的一些状态信息,包括特权级、中断使能、中断优先级等。mtvec:记录了中断向量表的入口地址。mepc:记录了异常触发时的指令地址。mcause:记录了异常触发时的异常类型。
这些寄存器并不是普通的通用寄存器,它们的行为也远远不止于读写。但在这个阶段,我们将先实现这些寄存器的读写功能,这些寄存器的其他功能我们将会在下一节中讲述。
Task 2.1
请参考 RISC-V 手册,在流水线中添加 CSR 寄存器并实现六条有关于 CSR 读写的指令。这里我们给出一种实现思路:
- 将 CSR 寄存器(CSR.sv)放在 ID 阶段,在 ID 段对 CSR 发起读操作。CSR 我们不建议使用寄存器堆的方式来实现,建议使用单个寄存器的方式实现。
- 在 EX 段为 CSR 指令专门添加一个 CSR 指令计算模块(Priv.sv),用来计算 CSR 指令的结果并写回 CSR;同时在 ALU 的
rs2_sel中添加一个 CSR 的选项,用来选择将 CSR 的数据加上 0,用于后续写入通用寄存器。 - 由于 CSR 读写指令可能会修改机器状态,因此后续取出的指令有可能有特权级读取问题。所以我们需要在 CSR 读写指令到达 EX 段时对之前的流水线进行一次冲刷操作(Hazard.sv),冲刷后的第一条指令地址为
pc_ex + 4。 - 在 WB 段,我们需要把 CSR 指令读出的内容写入通用寄存器。
在这种实现方法中,我们并不需要考虑 CSR 指令的相关问题。仔细思考,这样的处理为什么不会导致 CSR 指令之间存在相关?
Task 2.2
请完成能够支持 CSR 比较的 difftest:
-
你需要在 CSR.sv 中调用 sim.cpp 中实现的
set_csr_ptr函数,将 CSR 的指针传递给仿真环境。你可以在 CSR.sv 中用下述方法声明:Verilogimport "DPI-C" function void set_csr_ptr(input logic [31:0] m1 [], input logic [31:0] m2 [], input logic [31:0] m3 [], input logic [31:0] m4 []);之后使用一个 initial 块来调用这个函数,将 CSR 的指针传递给仿真环境。
-
在 sim.cpp 中的
set_state函数中,你需要在将 CSR 的值传递给仿真环境。注意我们在sim_cpu结构体中定义了一个csr结构体,可以用来存储 CSR 的值,详见 include/common.h。 - 在上一个实验中实现的 difftest 中,你需要进一步完善寄存器比较函数,使其能够支持 CSR 的比较。
在 picotest/main.S 中,TEST 规定了执行哪些测试。你需要添加对于 CSR 六条读写指令的 TEST,之后重新编译生成 picotest,并通过全部 picotest。
Advance Task
如果你有阅读我们提供的 RISC-V 特权指令集手册,就会发现一个有意思的现象:CSR 并不是每个位都可以写的。例如,mtvec 的末两位必须永远保持为 0,mstatus 的 [XLEN-2:22] 位也应该保持为 0。你需要阅读手册完成上述四个 CSR 对于不同位写入的处理。
这一部分我们没有提供测试用例,picotest 中的测试用例的输入全部都是符合 CSR 位约定的(也就是不会往保持 0 的位写非 0 值);你也不必进行测试,只需要实现完成后在检查时和助教阐明实现思路即可。
3 RISC-V 异常处理流程
3.1 侦测异常
流水线的任何一个阶段都有可能触发异常,例如在 LS 段可能有访存地址非对齐的异常,在 ID 段可能有指令不存在和系统调用异常等等。除此以外,异常还拥有优先级,即一条指令同时触发了多个异常,那么优先级高的异常将会被处理,而优先级低的异常将会被忽略。因此,我们需要把异常信息沿流水线传递到 WB 段,在 WB 段使用一个模块进行统一处理。
这个模块就是异常侦测模块,它获取流水线中流来的异常信号,然后生成对应的异常编号,并向流水线发出一个异常信号。
Task 3.1
请在流水线中实现 ecall 系统调用异常侦测。
ecall 指令的异常会在 ID 段被检测到,之后随着流水线一直流到 WB 段。WB 段的异常侦测模块(Exp_Commit.sv)侦测到这个信号后,将会生成一个异常编号(请自行查阅 RISC-V 手册来找到 ecall 的异常编号),并将这个异常编号和一个异常使能信号输出到流水线中。
在这个阶段,你只需要实现这部分内容,至于异常编号和异常使能信号最终流向哪里,请看下一节的讲解。
3.2 响应异常
在异常侦测模块送出异常信号的那一刻,控制状态寄存器组完成了诸多工作:
3.2.1 mstatus

mstatus 维护了一个两位的 PRV 字段用来表示当前机器特权级,以及一个 IE 字段用来表示当前中断使能状态。对于 PRV 字段,我们只涉及到 11(机器模式)和 00(用户模式)两种情况;而对于 IE 字段,我们的实验中暂时不会涉及。
为了支持中断嵌套,mstatus 维护了一个 PRV 和 IE 的栈。当异常触发(也就是异常侦测模块发出异常使能信号时),这个 mstatus 会左移 3 位,然后将新的 PRV(这里为 11)和 IE(暂时设为 0)压入栈顶。
3.2.2 mtvec

mtvec 保存了中断向量表的入口地址。所谓中断向量表,就是处理器触发异常中断后立刻跳转到的一个代码段,而这个中断向量表的起始地址往往是操作系统上电之后主动通过 CSR 读写指令配置的。
在我们的实验中,由于任意流水段段可能会有异常触发,因此 mtvec 必须有一个全局的输出接口(mtvec_global)来使得其存储内容可以被 Hazard 模块实时看到。
3.2.3 mcause

mcause 保存了异常触发时的异常编号。这个异常编号是由异常侦测模块生成的,然后在 WB 段被写入 mcause 中。异常编号的约定如下图所示:

这张图表也体现了异常侦测模块应该生成的异常编号。在流水线中,mcause 寄存器不仅有一个 CSR 统一的写口,还需要一个单独的写口,这个写口以异常使能为写使能,异常编码为写数据。
3.2.4 mepc

mepc 保存了异常触发时的指令地址。这个指令地址恰好就是流到 WB 段的 PC 值。因此 mepc 和 mcause 一样,也需要一个单独的写口,这个写口以异常使能为写使能,WB 段的 PC 值为写数据。
同时,mepc 还需要一个全局的输出接口(mepc_global),这个接口可以将保存的异常 PC 值送到各个流水级,其作用我们将在稍后介绍。
3.2.5 流水线适配
如果 WB 段触发异常,那么意味着流水线中后续指令全部应当被冲刷,在 WB 段生成一个 flush 信号是毫无疑问的。
可是,这样真的能够解决问题吗?
思考一种情况:ecall 的下一条指令是 store 指令,它在 EX 段就已经发起对 DCache 的写请求了!ecall到达 WB 时,它已经完成了写操作,这就代表着流水线后续的指令执行效果无法抹除。为了解决这个问题,我们需要在 EX 段发现异常时也进行一次冲刷,这样就保证了流水线后续的指令执行效果都被抹除了。
为什么一定要等到 WB 段再处理异常?
除了 WB 段外,任何前面的流水级都会触发异常,例如 LS 段有可能触发访存非对齐例外,这些例外还要和其它例外进行优先级比较。因此,虽然我们的实验中只涉及 ecall 异常,但对于真实的计算系统而言,在 WB 段处理异常是毋庸置疑的。
Task 3.2
请将上一个任务中实现的异常侦测模块信号接入 CSR 和 Hazard 中,完成对 CSR 状态的修改和流水线的冲刷。特别注意的是,若 EX 段侦测到 ecall,那么也需要进行一次 IF1、IF2、ID 段的冲刷,这次冲刷的目标地址并不重要,因为后续 WB 段还会有一次冲刷,这次冲刷才会将 PC 更新为 mtvec_global 指示的地址。
在完成这部分内容后,你可以通过运行 picotest 中的 ecall 测试,来判断你的实现是否基本正确。
3.3 操作系统处理
处理器准备好所有的异常信息后,操作系统将跳转到中断向量表,并需要根据这些信息来进行处理。这个处理过程包括但不限于:
- 将通用寄存器和一些 CSR 保存到内存中;
- 根据被保存的
mcause,获取异常类型; - 在中断向量表中找到对应的异常处理程序,开始执行;
- 执行完成后,将保存的通用寄存器和 CSR 恢复,然后返回到异常触发的指令处继续执行。
前三步都是普通的指令来完成的,但第四步需要一条特殊的指令来完成,这就是 mret 指令。
当中断处理和寄存器恢复完成后,操作系统需要执行一条 mret 指令,这条指令会将 mepc 中保存的异常触发指令地址取出,然后跳转到这个地址处继续执行。同时,mstatus 也会右移 3 位,从而被恢复。
异常重复触发?
当一条 ecall 指令触发异常时,mepc 中保存的是 ecall 的指令地址。可能会有同学认为,异常处理完成后恢复 mepc 并跳转到这个地址后还会执行一条 ecall,造成异常重复触发。但在我们自己的设计中,有很多机会可以将其值调整为下一条指令的地址,这样就可以避免异常重复触发了。
Task 3.3
请在流水线中实现 mret 指令。这条指令和跳转指令的实现非常相似,但跳转的目标来自 mepc,并需要恢复 mstatus 的值,这个过程事实上是异常触发时操作的逆过程:
- 将
[11:0]位右移 3 位; - 将
[11:9]位写入3'b001(用户模式,中断使能有效)。
除此之外,由于 mret 指令和 CSR 读写指令一样,也会修改机器状态,因此也需要在 EX 段进行一次冲刷,目标地址是 mepc_global。
在完成这部分内容后,你可以通过运行 picotest 中的 mret 测试,来判断你的实现是否基本正确。
4 流水线设计小结
在本次实验中,对流水线的修改较多。我们在此为大家整理一下流水线的修改内容:

-
在 ID 段添加 CSR 寄存器组,用来进行 CSR 的读写。同时,对于一些特殊的寄存器,需要留出对应的单独额外的输入输出接口:
mtvec:mtvec_global(输出,用来将mtvec的值传递到各个流水级);mepc:mepc_global(输出,用来将mepc的值传递到各个流水级)、pc_wb(输入,用来将 WB 段的 PC 值传递到mepc);mcause:mcause_in(输入,用来将异常编号传递到mcause);mstatus:exception_en(输入,WB 段异常信号)、mret_en(输入,WB 段mret信号)。
-
在 EX 段添加 CSR 指令计算模块,用来计算 CSR 指令的结果。同时,对于 ALU 的
rs2_sel,需要添加一个 CSR 的选项,用来选择 CSR 读出的数据; - 在 WB 段添加 CSR 异常侦测模块,用来侦测异常并生成异常编号和异常使能信号;
-
修改Hazard,使其能支持:
- EX 段由于 CSR 读写指令、
ecall指令和mret指令导致的冲刷 - WB 段由于异常触发导致的冲刷
- EX 段由于 CSR 读写指令、
-
为了支持上述功能,你还需要对段间寄存器进行一定的修改。
5 实验报告
本次实验无需提交实验报告。