跳转至

Lab4 异常与特权

有道以统之,法虽少,足以化矣。——《淮南子·泰族训》

在《计算机组成原理》课程中,我们已经简要学习过有关于异常处理与特权指令的部分内容。在本次实验中,我们将基于 Lab3 中实现的 CPU,进一步完善异常处理与特权指令的实现。

本次实验的主要任务如下:

  • 了解 RISC-V 异常处理的基本原理
  • 在流水线中添加相关的控制状态寄存器,并能够正确处理 CSR 读写指令
  • 在流水线中添加对于 ecallmret 指令的支持,使得处理器可以响应系统调用异常
请获取 Lab4 所需的代码框架

请将工作目录切换到 Lab4,运行以下命令:

shell
$ ./zinit.sh

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 读写的指令。这里我们给出一种实现思路:

  1. 将 CSR 寄存器(CSR.sv)放在 ID 阶段,在 ID 段对 CSR 发起读操作。CSR 我们不建议使用寄存器堆的方式来实现,建议使用单个寄存器的方式实现
  2. 在 EX 段为 CSR 指令专门添加一个 CSR 指令计算模块(Priv.sv),用来计算 CSR 指令的结果并写回 CSR;同时在 ALU 的 rs2_sel 中添加一个 CSR 的选项,用来选择将 CSR 的数据加上 0,用于后续写入通用寄存器。
  3. 由于 CSR 读写指令可能会修改机器状态,因此后续取出的指令有可能有特权级读取问题。所以我们需要在 CSR 读写指令到达 EX 段时对之前的流水线进行一次冲刷操作(Hazard.sv),冲刷后的第一条指令地址为 pc_ex + 4
  4. 在 WB 段,我们需要把 CSR 指令读出的内容写入通用寄存器。

在这种实现方法中,我们并不需要考虑 CSR 指令的相关问题。仔细思考,这样的处理为什么不会导致 CSR 指令之间存在相关?

Task 2.2

请完成能够支持 CSR 比较的 difftest:

  • 你需要在 CSR.sv 中调用 sim.cpp 中实现的 set_csr_ptr 函数,将 CSR 的指针传递给仿真环境。你可以在 CSR.sv 中用下述方法声明:

    Verilog
    import "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

image-20230818095348744

mstatus 维护了一个两位的 PRV 字段用来表示当前机器特权级,以及一个 IE 字段用来表示当前中断使能状态。对于 PRV 字段,我们只涉及到 11(机器模式)和 00(用户模式)两种情况;而对于 IE 字段,我们的实验中暂时不会涉及。

为了支持中断嵌套,mstatus 维护了一个 PRVIE 的栈。当异常触发(也就是异常侦测模块发出异常使能信号时),这个 mstatus 会左移 3 位,然后将新的 PRV(这里为 11)和 IE(暂时设为 0)压入栈顶。

3.2.2 mtvec

image-20230818151610248

mtvec 保存了中断向量表的入口地址。所谓中断向量表,就是处理器触发异常中断后立刻跳转到的一个代码段,而这个中断向量表的起始地址往往是操作系统上电之后主动通过 CSR 读写指令配置的。

在我们的实验中,由于任意流水段段可能会有异常触发,因此 mtvec 必须有一个全局的输出接口(mtvec_global)来使得其存储内容可以被 Hazard 模块实时看到。

3.2.3 mcause

image-20230818151805239

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

image-20230818151859742

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

3.2.4 mepc

image-20230818152133561

mepc 保存了异常触发时的指令地址。这个指令地址恰好就是流到 WB 段的 PC 值。因此 mepcmcause 一样,也需要一个单独的写口,这个写口以异常使能为写使能,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 流水线设计小结

在本次实验中,对流水线的修改较多。我们在此为大家整理一下流水线的修改内容:

image-20231013151700074

  • 在 ID 段添加 CSR 寄存器组,用来进行 CSR 的读写。同时,对于一些特殊的寄存器,需要留出对应的单独额外的输入输出接口:

    • mtvecmtvec_global(输出,用来将 mtvec 的值传递到各个流水级);
    • mepcmepc_global(输出,用来将 mepc 的值传递到各个流水级)、pc_wb(输入,用来将 WB 段的 PC 值传递到 mepc);
    • mcausemcause_in(输入,用来将异常编号传递到 mcause);
    • mstatusexception_en(输入,WB 段异常信号)、mret_en(输入,WB 段 mret 信号)。
  • 在 EX 段添加 CSR 指令计算模块,用来计算 CSR 指令的结果。同时,对于 ALU 的 rs2_sel,需要添加一个 CSR 的选项,用来选择 CSR 读出的数据;

  • 在 WB 段添加 CSR 异常侦测模块,用来侦测异常并生成异常编号和异常使能信号;
  • 修改Hazard,使其能支持:

    • EX 段由于 CSR 读写指令、ecall 指令和 mret 指令导致的冲刷
    • WB 段由于异常触发导致的冲刷
  • 为了支持上述功能,你还需要对段间寄存器进行一定的修改。

5 实验报告

本次实验无需提交实验报告。