构建五级流水线——lab5
本文最后更新于 2026年5月12日 12:57:16
一、lab5背景
在这个 Lab 中,我们需要真正处理异常和中断,需要完成 MRET、ECALL 和 MMU(支持 Sv39 页表)。
说了这么老半天,到底什么是异常,什么是中断?
具体的细节会在实现时说明,根据资料,大致可以这样归类:
- 异常:运行中的程序自己出了问题(系统内部产生),如缺页异常,除零异常
- 中断:程序外的外部事件对运行中的程序造成了影响(系统外部产生),如键盘输入
Ctrl+C,关机
处理异常时,需要遵循精确异常的宗旨,即异常指令之前的所有指令必须走完它们的生命周期,而异常指令之后的所有指令不能对计算机(包括内存、通用寄存器、CSR 寄存器、PC、特权级别等)造成任何影响。
这听上去非常合理,毕竟这样才方便我们专门针对异常进行处理:发现是异常指令时,直接清空整条流水线即可。
然而并不是所有指令都只在 WB 阶段才对系统发生作用,比如对于 store 指令,会在 MEM 阶段将数值写入内存:
当 trap 指令走到 mem_wb 时,在同一拍,store 指令处在 ex_mem。这一拍中,trap_unit 发现需要清空流水线,但是下一拍才能将这个信息广播出去。然而,这一拍中,MEM 就会将数据写入内存,导致流水线清空不完全。同理,dbus 访存也需要被阻止。
实际上,下一拍传递到 mem_wb 的任何信号都需要被 flush 掉,无论它是否是 store 指令。不然这条指令依然会被 commit 导致报错。在第四板块中会详细处理。
那么,如何衡量一条指令的生命周期呢?这就需要将 commit_fire 这个点拿出来重新审视一番了。在前几个 Lab 中,这个布尔值用来向 Difftest 说明当前是否进行一次提交,拆成自然语言就是当前允许 mem_wb 信号有效且正常流动。也就是一条指令只要经过 mem_wb 这个门槛,进入了只会写入寄存器的 WB 阶段,就绝对不会再出现任何报错了,过完 WB 阶段,它就彻底被执行完毕了。
1 | |
因此,我们需要让两条异常指令 ECALL 和 MRET 一路走到 WB 阶段之后再更改 CSR、特权级别、PC 等。否则,需要刷新流水线时,它们已经造成的状态改变难以被恢复
Part I —— ecall与mret指令
二、准备工作——新建枚举类型
为了表示新增的特权级别,在 mypkg 中新建枚举类型:
1 | |
还记得 Lab4 中的 MSTATUS 寄存器吗?在 Lab4 中,我们提到 MSTATUS “包含一系列状态和控制位”,这里我们正是针对 MSTATUS 中的具体四位进行更改。其中 MIE 和 MPIE 共同用于控制 CPU 是否响应异常;MPRV 用于核对特权级别;MPP 是两位字段,决定 trap 发生前的特权级别,便于处理完异常后还原至原有的特权级别。
1 | |
现在新的特权级别已经能直观地看出来了。在 core 中,我们保存当前的特权级别 current_priv,并且让它在 reset 时回到 M 状态,对应 Lab 中“开机时处在 M 状态”的要求。
1 | |
接着,将它接入 DifftestCSRState。这里 Difftest 的 priviledgeMode 接口是 2 位,而 current_priv 本身已经按 RISC-V 规范编码成 2 位,所以直接连接即可:
1 | |
三、先搞定 ECALL 与 MRET 指令
1. 这两条指令都是什么?
MRET 一般出现在异常处理完成之后,让 CPU 退回到异常开始之前的状态,这包括特权级别、中断状态和 PC 指针。它单独调用,不涉及任何寄存器读写操作,除了开头结尾的 funct 和 opcode,其余部分都是零,机器码为 32'h30200073。ECALL(environment call)指令用于触发系统调用,是程序特权级别不够时让系统在更高级别下代为完成的请示。它也是单独调用,具有固定二进制码 32'h00000073。调用 ECALL 前,程序会通过应用 程序二进制接口(ABI) 约定将系统调用号和参数存入特定寄存器中。
具体这两套指令要做什么,在 Lab 的 Wiki 上写的很详细。这里假设只实现 U 和 M 模式,S 模式作为 bonus 放在后面实现。
2. 识别、传递、实例化
由于机器码固定,直接在 decoder 中筛选即可:
1 | |
在 id_ex、ex_mem、mem_wb 中传递它们,同样为了防止意外识别成异常信号,只在 valid 时传递
1 | |
最后在 core 中实例化它们
1 | |
3. trap 信号控制单元
这两条指令的处理方式又与之前不同,因此我在 utils 文件夹中新建 trap_unit 进行处理。它们都涉及到 MCAUSE、MEPC、MTVAL、priv 四个量:
① ECALL 指令:主动记录异常发生时的系统状态,导致特权级别升高。根据 Wiki 界面,我们需要完成如下事项:
1 | |
其中,“跳转到 MTVEC,冲刷流水线”这一条将在第四板块详解,其他条目如下:
- 首先,判断当前是否是一条有效的
ECALL指令:valid信号用于告知当前是否是一条有效的trap指令,由commit_fire和is_ecall共同决定
1 | |
- 保存当前
PC至MEPC:发生异常时的PC就是ECALL指令,等到实际调用时再将PC+4
1 | |
1 | |
- 设置
MCAUSE:由于此CPU简化了特权级别,只涉及U和M两种,根据规则规定,如果ECALL发生时当前处在User级别,则原因编号为 8,M对应 11
1 | |
- 设置
MPIE、MIE:
1 | |
- 最后记录当前的特权状态,并存入
MPP
1 | |
1 | |
- 特权级别设置为 M
1 | |
- 额外地,设置
MTVAL。尽管当前ECALL指令不需要记录MTVAL,但在 Lab6 中处理其他异常时就需要记录这个值了
1 | |
1 | |
② MRET 指令:负责在异常处理完成之后将状态进行还原,导致特权级别降低
需要注意,当一个异常发出时,CPU 所处的状态可能并不是最高权限的 M 模式,但是在实际处理异常时,CPU 会被强制切换成 M 模式,通过更高的访问权限来尝试解决问题。但异常如果包含恶意,通过 M 模式把重要的信息泄露出去就危险了。因此,需要记录异常发生时处在的权限等级(用 MSTATUS 中的 MPP 位进行记录),并设置 MPRV=1 表示处理时需要伪装自己是 MPP 中的权限等级进行处理。实际执行中我们采用防御性编程,只要异常发出时的权限不是 M,就让 MPRV=0。毕竟权限为 M 不代表着一定要伪装成 MPP 中的值。
根据 RISC-V 中的要求:
1 | |
也就是说 MRET 要做以下几件事:
- 将
MIE还原为MPIE中存下来的旧值,MPIE设置为 1,
1 | |
- 将当前的
MPP值mret_previous_priv提取出来,存入current_priv
1 | |
1 | |
- 此时记录旧权限的
MPP已经废弃了,因此将其设置为最低权限的U模式,确保MRET指令结束后旧权限一定为U;同时用csr_file内部旧的mstatus_reg.MPP判断返回目标是否为M,若否,则将MPRV设置为 0。这里不把trap_unit的mret_previous_priv再接回csr_file,避免csr_mstatus和mstatus更新逻辑之间形成组合环。
1 | |
③ 最后,在 core 中实例化并连线。
首先引入 trap_unit,并声明 trap_unit 和 csr_file 之间需要连接的信号:
1 | |
接着,在 core 中更新 current_priv:
1 | |
然后,将 trap_unit 产生的 trap 和 MRET 信息接到 csr_file,这里只展示新增信号:
1 | |
最后实例化 trap_unit,把 WB 阶段的 ECALL/MRET 标记、当前特权级和相关 CSR 接进去:
1 | |
四、让 PC 和 flush 指令受到异常指令的控制
1. 前一拍不是 ECALL/MRET
这一步紧接上一步,将 PC 重定向和 flush 真正落实到流水线中。
是否要跳转实例化为 redirect_valid,只要 trap 或者 MRET 其中一个生效就进行跳转
异常引起的 PC 跳转在代码中实例化为 redirect_pc,对于 ECALL 指令,需要跳转到 CPU 专用异常处理地址 MTVEC,MRET 则是要返回异常出现的地址 MEPC
1 | |
对应地,在 ctrl 模块中引入 redirect_valid,以应用最新的 flush 信号。由于 mem_wb 信号为当前处理阶段,不能 flush,其他三个寄存器都需要 flush
1 | |
最后在 core 中进行实例化,注意异常的 PC 跳转优先级最高,因此在原有 Lab3 的跳转逻辑上加上 trap 的判断,并连入 pc 模块。
1 | |
2. 前一拍是 ECALL/MRET
无论任何在 MRET/ECALL 后的指令,都需要防止它们提交,也就是需要引入 mem_wb_flush。需要注意这个 flush 不能和前几个 flush 一样用 commit_flush 决定,因为 commit_flush 来自 redirect_valid,而这个信号在 ECALL 指令的下一拍才产生,已经来不及拦截 MEM 阶段的行为了,因此作出如下修改:
首先是通过 mem_wb 的值拦截 store 和 dbus,在 core 中新增 trap_kill_mem,表示需要拦截这次 MEM 访存行为,并接入 ex_mem;对于 dbus,直接修改 valid 值;同时,直接拦截 mem_wb 的 valid 信号,这样就不用管当前信号是否是 store
1 | |
五、连接 Difftest
最后检查 DifftestCSRState。current_priv 已经是 2 位的 RISC-V 特权级编码,因此可以直接接到 priviledgeMode。同时,ECALL/MRET 修改过的 MSTATUS、MEPC、MCAUSE、MTVAL 和 MTVEC 都需要从 csr_file 的输出接入 Difftest。
1 | |
Part II —— MMU
一、基本概念
1. Page、Page Table
页(page)指的是固定大小的内存块,也是内存管理的最小基本单位。页表(page table)是一个页表项(Page Table Entry, PTE)数组,每个 PTE 对应一个虚拟页。
2. Virtual Memory、Physical Memory
我们知道,一个程序需要内存才能运行。对于 S 级和 U 级的程序,直接让它们见到真实的物理地址是不安全的,它们只能拿到虚拟地址(Virtual Address, VA)。在执行时,CPU 需要把虚拟地址翻译成真实物理地址(Physical Address, PA),而这个翻译过程就会用到 page table。
存储页表根地址和分页模式的是 satp 寄存器。对于本 Lab 实现的 Sv39 页表,satp 的构成如下所示(摘自 Lab Wiki):
1 | |
其中,最高 4 位是分页模式 MODE,取值为 8 时代表使用 Sv39 页表模式,取值为 0 时代表不进行页表翻译。PPN 的全称是 Physical Page Number,指的是真实物理页号。
3. MMU(Memory Management Unit)
这个翻译职责由 CPU 中的 MMU 模块完成。CSAPP 中文版第三版图 9-2 清晰展示了 MMU 扮演的职责:
二、如何进行翻译
这张表是理解翻译过程的核心。我们采用 64 位地址系统,其中真实物理地址仅占据低 56 位,高 8 位全部为 0(否则报错)。一个页的大小为 4KB,这也意味着任何一个页起始地址的低 12 位都必须为 0。为了方便,整个翻译过程中只记录前 44 位,也就是 PPN。
而 Sv39 中的 39 指的是虚拟地址的有效位数。虚拟地址只使用低 39 位,高位需要进行符号扩展(否则报错)。低 12 位是真实地址中的页内偏移量(offset);[38:30]、[29:21]、[20:12] 则分别是三层页表的索引地址。
每一层页表都会根据对应的索引地址,从根地址开始的 512 * 64 / 8 = 4KB 的页中进行查询,得到一个 64 位的 PTE。每个 PTE 的高 10 位是保留位,必须为 0(否则报错);中间的 44 位是 PPN,左移 12 位后即可得到下一层页表的根地址。
递归查询到第三层后,将第三层 PTE 中的 PPN 左移 12 位,再拼上页内偏移量 offset,开头补上 8 个 0,就得到了最终的真实物理地址。
三、实施思路
核心难点是如何通过 satp 将虚拟地址翻译成真实物理地址。
X、bonus:S模式
实际上,可以直接用 trap_cause = {60'b0, 1'b1, 1'b0, current_priv}; 进行接线,因为 MCAUSE 正好等于四种特权级别对应的数值加 8
Y、吐槽
Codex 小插曲
我本来就觉得这个 mret_previous_priv 在 trap_unit 和 csr_file 之间反复横跳很绕,当时还问 Gemini 问了好久,试图理解。好吧其实就是写错了(
要注意,mret_previous_priv 本身来自 csr_file 中的 MSTATUS,因此不能再作为 csr_file 的输入,否则,trap_unit 和 csr_file 二者都是组合逻辑,会导致数值振荡,模拟时会报错。相对地,core 中写入 current_priv 的是时序逻辑,因此不会报错
1 | |
我感觉这个逻辑很混乱,最终直接删除了 trap_unit 中的 mret_previous_priv。如何获取旧的 MPP 值呢?我们不能直接从 MSTATUS 中读取,因为 MRET 指令在这一拍到来时,组合逻辑中的 MPP 会直接被修改为 U 模式,这不一定与旧的 MPP 一致。我们需要从时序逻辑 mstatus_reg 中导出旧的 MPP 值,因为 mstatus_reg 下一回合才会更新。
最终,trap_unit 删除了以下部分:
1 | |
csr_file 中导出 mstatus_reg 值
1 | |
core 中获取 mstatus_reg 的值:
1 | |
总之,codex很好用但不是万能的,很多时候会把事情搞复杂,不如人脑判断,或者整个项目完全不由人插手
参考文献
这些资料对于我搞清楚这个lab到底在干什么很有帮助: