构建五级流水线——lab5

本文最后更新于 2026年5月12日 12:57:16

一、lab5背景

在这个 Lab 中,我们需要真正处理异常和中断,需要完成 MRETECALLMMU(支持 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
assign commit_fire = mem_wb_valid && ctrl_mem_wb_enable;

因此,我们需要让两条异常指令 ECALLMRET 一路走到 WB 阶段之后再更改 CSR、特权级别、PC 等。否则,需要刷新流水线时,它们已经造成的状态改变难以被恢复

Part I —— ecall与mret指令

二、准备工作——新建枚举类型

为了表示新增的特权级别,在 mypkg 中新建枚举类型:

1
2
3
4
5
typedef enum u2 {
PRIV_U = 2'b00,
PRIV_S = 2'b01,
PRIV_M = 2'b11
} priv_t;

还记得 Lab4 中的 MSTATUS 寄存器吗?在 Lab4 中,我们提到 MSTATUS “包含一系列状态和控制位”,这里我们正是针对 MSTATUS 中的具体四位进行更改。其中 MIEMPIE 共同用于控制 CPU 是否响应异常;MPRV 用于核对特权级别;MPP 是两位字段,决定 trap 发生前的特权级别,便于处理完异常后还原至原有的特权级别。

1
2
3
4
5
6
7
8
typedef enum logic [5:0] {
MSTATUS_MIE_BIT = 6'd3,
MSTATUS_MPIE_BIT = 6'd7,
MSTATUS_MPRV_BIT = 6'd17
} mstatus_bit_t;

localparam int unsigned MSTATUS_MPP_LOW = 11;
localparam int unsigned MSTATUS_MPP_HIGH = 12;

现在新的特权级别已经能直观地看出来了。在 core 中,我们保存当前的特权级别 current_priv,并且让它在 reset 时回到 M 状态,对应 Lab 中“开机时处在 M 状态”的要求。

1
2
3
4
5
6
7
priv_t current_priv;

always_ff @(posedge clk) begin
if (reset) begin
current_priv <= PRIV_M;
end
end

接着,将它接入 DifftestCSRState。这里 DifftestpriviledgeMode 接口是 2 位,而 current_priv 本身已经按 RISC-V 规范编码成 2 位,所以直接连接即可:

1
2
3
DifftestCSRState DifftestCSRState(
.priviledgeMode(current_priv)
);

三、先搞定 ECALLMRET 指令

1. 这两条指令都是什么?

MRET 一般出现在异常处理完成之后,让 CPU 退回到异常开始之前的状态,这包括特权级别、中断状态和 PC 指针。它单独调用,不涉及任何寄存器读写操作,除了开头结尾的 functopcode,其余部分都是零,机器码为 32'h30200073
ECALL(environment call)指令用于触发系统调用,是程序特权级别不够时让系统在更高级别下代为完成的请示。它也是单独调用,具有固定二进制码 32'h00000073。调用 ECALL 前,程序会通过应用 程序二进制接口(ABI) 约定将系统调用号和参数存入特定寄存器中。
具体这两套指令要做什么,在 Lab 的 Wiki 上写的很详细。这里假设只实现 UM 模式,S 模式作为 bonus 放在后面实现。

2. 识别、传递、实例化

由于机器码固定,直接在 decoder 中筛选即可:

1
2
3
output u1 is_ecall, is_mret;
assign is_ecall = (inst == 32'h00000073);
assign is_mret = (inst == 32'h30200073);

id_exex_memmem_wb 中传递它们,同样为了防止意外识别成异常信号,只在 valid 时传递

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
input  u1 is_trap_in,  is_mret_in;
output u1 is_trap_out, is_mret_out;

always_ff @(posedge clk) begin
if (reset) begin
is_trap_out <= 1'b0;
is_mret_out <= 1'b0;
end else if (enable) begin
if (valid_in) begin
is_trap_out <= is_trap_in;
is_mret_out <= is_mret_in;
end else begin
is_trap_out <= 1'b0;
is_mret_out <= 1'b0;
end
end
end

最后在 core 中实例化它们

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
u1 decoder_is_ecall, decoder_is_mret;
u1 id_ex_is_ecall, id_ex_is_mret;
u1 ex_mem_is_ecall, ex_mem_is_mret;
u1 mem_wb_is_ecall, mem_wb_is_mret;

decoder u_decoder(
.is_ecall (decoder_is_ecall),
.is_mret (decoder_is_mret)
);

id_ex u_id_ex(
.is_trap_in (decoder_is_ecall),
.is_mret_in (decoder_is_mret),
.is_trap_out (id_ex_is_ecall),
.is_mret_out (id_ex_is_mret)
);

ex_mem u_ex_mem(
.is_trap_in (id_ex_is_ecall),
.is_mret_in (id_ex_is_mret),
.is_trap_out (ex_mem_is_ecall),
.is_mret_out (ex_mem_is_mret)
);

mem_wb u_mem_wb(
.is_trap_in (ex_mem_is_ecall),
.is_mret_in (ex_mem_is_mret),
.is_trap_out (mem_wb_is_ecall),
.is_mret_out (mem_wb_is_mret)
);

3. trap 信号控制单元

这两条指令的处理方式又与之前不同,因此我在 utils 文件夹中新建 trap_unit 进行处理。它们都涉及到 MCAUSEMEPCMTVALpriv 四个量:

ECALL 指令:主动记录异常发生时的系统状态,导致特权级别升高。根据 Wiki 界面,我们需要完成如下事项:
1
2
3
4
5
当处理异常的时候,你应该:
保存当前 PC 到 MEPC
设置 CSR:MCAUSE 参考 表格 ,我们这里是 8 或 11。
设置 CSR:MPIE 设置为原来的 MIE,MIE 设置为 0。MPP 设置为当前(执行之前)的特权级别。
特权级别设置为 M

其中,“跳转到 MTVEC,冲刷流水线”这一条将在第四板块详解,其他条目如下:

  • 首先,判断当前是否是一条有效的 ECALL 指令:valid 信号用于告知当前是否是一条有效的 trap 指令,由 commit_fireis_ecall 共同决定
1
2
trap_unit.sv
assign trap_valid = commit_fire && mem_wb_is_ecall;
  • 保存当前 PCMEPC:发生异常时的 PC 就是 ECALL 指令,等到实际调用时再将 PC+4
1
2
trap_unit.sv
assign trap_mepc = mem_wb_pc;
1
2
3
4
csr_file.sv
if (trap_commit) begin
mepc = trap_mepc;
end
  • 设置 MCAUSE:由于此 CPU 简化了特权级别,只涉及 UM 两种,根据规则规定,如果 ECALL 发生时当前处在 User 级别,则原因编号为 8,M 对应 11
1
2
trap_unit.sv
assign trap_cause = (current_priv == PRIV_U) ? 64'd8 : 64'd11;
  • 设置 MPIEMIE
1
2
3
4
5
csr_file.sv
if (trap_commit) begin
mstatus[MSTATUS_MPIE_BIT] = mstatus_reg[MSTATUS_MIE_BIT];
mstatus[MSTATUS_MIE_BIT] = 1'b0;
end
  • 最后记录当前的特权状态,并存入 MPP
1
2
trap_unit.sv
assign trap_from_priv = current_priv;
1
2
3
4
csr_file.sv
if (trap_commit) begin
mstatus[MSTATUS_MPP_HIGH:MSTATUS_MPP_LOW] = trap_from_priv;
end
  • 特权级别设置为 M
1
2
3
4
core.sv
else if (trap_unit_trap_valid) begin
current_priv <= PRIV_M;
end
  • 额外地,设置 MTVAL。尽管当前 ECALL 指令不需要记录 MTVAL,但在 Lab6 中处理其他异常时就需要记录这个值了
1
2
3
trap_unit.sv 
assign trap_tval = 64'b0;
assign trap_from_priv = current_priv;
1
2
3
4
csr_file.sv
if (trap_commit) begin
mtval = trap_mtval;
end
MRET 指令:负责在异常处理完成之后将状态进行还原,导致特权级别降低

需要注意,当一个异常发出时,CPU 所处的状态可能并不是最高权限的 M 模式,但是在实际处理异常时,CPU 会被强制切换成 M 模式,通过更高的访问权限来尝试解决问题。但异常如果包含恶意,通过 M 模式把重要的信息泄露出去就危险了。因此,需要记录异常发生时处在的权限等级(用 MSTATUS 中的 MPP 位进行记录),并设置 MPRV=1 表示处理时需要伪装自己是 MPP 中的权限等级进行处理。实际执行中我们采用防御性编程,只要异常发出时的权限不是 M,就让 MPRV=0。毕竟权限为 M 不代表着一定要伪装成 MPP 中的值。
根据 RISC-V 中的要求:

1
An MRET or SRET instruction is used to return from a trap in M-mode or S-mode respectively. When executing an xRET instruction, supposing xPP holds the value y, xIE is set to xPIE; the privilege mode is changed to y; xPIE is set to 1; and xPP is set to the least-privileged supported mode (U if U-mode is implemented, else M). If y≠M, xRET also sets MPRV=0.

也就是说 MRET 要做以下几件事:

  • MIE 还原为 MPIE 中存下来的旧值,MPIE 设置为 1,
1
2
3
4
5
csr_file.sv
else if (mret_commit) begin
mstatus[MSTATUS_MIE_BIT] = mstatus_reg[MSTATUS_MPIE_BIT];
mstatus[MSTATUS_MPIE_BIT] = 1'b1;
end
  • 将当前的 MPPmret_previous_priv 提取出来,存入 current_priv
1
2
3
trap_unit.sv
assign mret_valid = commit_fire && mem_wb_is_mret;
assign mret_previous_priv = priv_t'(csr_mstatus[MSTATUS_MPP_HIGH:MSTATUS_MPP_LOW]);
1
2
3
4
5
6
core.sv
always_ff @(posedge clk) begin
end else if (trap_unit_mret_valid) begin
current_priv <= trap_unit_mret_previous_priv;
end
end
  • 此时记录旧权限的 MPP 已经废弃了,因此将其设置为最低权限的 U 模式,确保 MRET 指令结束后旧权限一定为 U;同时用 csr_file 内部旧的 mstatus_reg.MPP 判断返回目标是否为 M,若否,则将 MPRV 设置为 0。这里不把 trap_unitmret_previous_priv 再接回 csr_file,避免 csr_mstatusmstatus 更新逻辑之间形成组合环。
1
2
3
4
5
csr_file.sv
mstatus[MSTATUS_MPP_HIGH:MSTATUS_MPP_LOW] = PRIV_U;
if (mstatus_reg[MSTATUS_MPP_HIGH:MSTATUS_MPP_LOW] != PRIV_M) begin
mstatus[MSTATUS_MPRV_BIT] = 1'b0;
end
③ 最后,在 core 中实例化并连线。

首先引入 trap_unit,并声明 trap_unitcsr_file 之间需要连接的信号:

1
2
3
4
5
`include "src/utils/trap_unit.sv"

u1 trap_unit_trap_valid, trap_unit_mret_valid;
u64 trap_unit_trap_mepc, trap_unit_trap_cause, trap_unit_trap_tval;
priv_t trap_unit_trap_from_priv, csr_mstatus_reg_mpp;

接着,在 core 中更新 current_priv

1
2
3
4
5
6
7
8
9
always_ff @(posedge clk) begin
if (reset) begin
current_priv <= PRIV_M;
end else if (trap_unit_trap_valid) begin
current_priv <= PRIV_M;
end else if (trap_unit_mret_valid) begin
current_priv <= csr_mstatus_reg_mpp;
end
end

然后,将 trap_unit 产生的 trapMRET 信息接到 csr_file,这里只展示新增信号:

1
2
3
4
5
6
7
8
9
10
csr_file u_csr_file(
// ...
.trap_commit (trap_unit_trap_valid),
.trap_mepc (trap_unit_trap_mepc),
.trap_mcause (trap_unit_trap_cause),
.trap_mtval (trap_unit_trap_tval),
.trap_from_priv (trap_unit_trap_from_priv),
.mret_commit (trap_unit_mret_valid),
.mstatus_reg_mpp (csr_mstatus_reg_mpp)
);

最后实例化 trap_unit,把 WB 阶段的 ECALL/MRET 标记、当前特权级和相关 CSR 接进去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
trap_unit u_trap_unit(
.commit_fire (commit_fire),
.mem_wb_is_ecall (mem_wb_is_ecall),
.mem_wb_is_mret (mem_wb_is_mret),
.mem_wb_pc (mem_wb_pc),
.current_priv (current_priv),
.csr_mepc (csr_mepc),
.csr_mtvec (csr_mtvec),
.trap_valid (trap_unit_trap_valid),
.trap_mepc (trap_unit_trap_mepc),
.trap_cause (trap_unit_trap_cause),
.trap_tval (trap_unit_trap_tval),
.trap_from_priv (trap_unit_trap_from_priv),
.mret_valid (trap_unit_mret_valid)
);

四、让 PCflush 指令受到异常指令的控制

1. 前一拍不是 ECALL/MRET

这一步紧接上一步,将 PC 重定向和 flush 真正落实到流水线中。
是否要跳转实例化为 redirect_valid,只要 trap 或者 MRET 其中一个生效就进行跳转
异常引起的 PC 跳转在代码中实例化为 redirect_pc,对于 ECALL 指令,需要跳转到 CPU 专用异常处理地址 MTVECMRET 则是要返回异常出现的地址 MEPC

1
2
3
4
5
6
7
8
9
module trap_unit
(
output u1 redirect_valid,
output u64 redirect_pc
);

assign redirect_valid = trap_valid || mret_valid;
assign redirect_pc = trap_valid ? csr_mtvec : csr_mepc;
endmodule

对应地,在 ctrl 模块中引入 redirect_valid,以应用最新的 flush 信号。由于 mem_wb 信号为当前处理阶段,不能 flush,其他三个寄存器都需要 flush

1
2
3
4
5
6
7
8
9
10
11
12
module ctrl
(
input u1 ex_mem_read, should_jump, commit_flush, ex_is_mul, ex_mul_done
);

assign if_id_flush = (global_enable && (should_jump || commit_flush) || pending_jump);

assign id_ex_flush = (global_enable && (should_jump || commit_flush) || pending_jump) |
(global_enable && load_use_stall);

assign ex_mem_flush = (global_enable && commit_flush) || (global_enable && mul_stall);
endmodule

最后在 core 中进行实例化,注意异常的 PC 跳转优先级最高,因此在原有 Lab3 的跳转逻辑上加上 trap 的判断,并连入 pc 模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
u1  trap_unit_redirect_valid, pc_should_jump;
u64 trap_unit_redirect_pc, pc_jump_addr;

assign pc_should_jump = trap_unit_redirect_valid ? 1'b1 : ex_should_jump_real;
assign pc_jump_addr = trap_unit_redirect_valid ? trap_unit_redirect_pc : ex_jump_addr_real;

pc u_pc(
.should_jump(pc_should_jump),
.jump_addr (pc_jump_addr)
);

trap_unit u_trap_unit(
.redirect_valid (trap_unit_redirect_valid),
.redirect_pc (trap_unit_redirect_pc)
);

ctrl u_ctrl(
.should_jump (pc_should_jump),
.commit_flush (trap_unit_redirect_valid)
);

2. 前一拍是 ECALL/MRET

无论任何在 MRET/ECALL 后的指令,都需要防止它们提交,也就是需要引入 mem_wb_flush。需要注意这个 flush 不能和前几个 flush 一样用 commit_flush 决定,因为 commit_flush 来自 redirect_valid,而这个信号在 ECALL 指令的下一拍才产生,已经来不及拦截 MEM 阶段的行为了,因此作出如下修改:
首先是通过 mem_wb 的值拦截 storedbus,在 core 中新增 trap_kill_mem,表示需要拦截这次 MEM 访存行为,并接入 ex_mem;对于 dbus,直接修改 valid 值;同时,直接拦截 mem_wbvalid 信号,这样就不用管当前信号是否是 store

1
2
3
4
5
6
7
core.sv
u1 trap_kill_mem;
assign trap_kill_mem = mem_wb_valid && (mem_wb_is_ecall || mem_wb_is_mret);
assign dreq.valid = mem_dreq_valid && ~trap_kill_mem;
mem_wb u_mem_wb(
.valid_in (ex_mem_valid && ~trap_kill_mem),
);

五、连接 Difftest

最后检查 DifftestCSRStatecurrent_priv 已经是 2 位的 RISC-V 特权级编码,因此可以直接接到 priviledgeMode。同时,ECALL/MRET 修改过的 MSTATUSMEPCMCAUSEMTVALMTVEC 都需要从 csr_file 的输出接入 Difftest。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
DifftestCSRState DifftestCSRState(
.priviledgeMode (current_priv),
.mstatus (csr_mstatus),
.mepc (csr_mepc),
.mtval (csr_mtval),
.mtvec (csr_mtvec),
.mcause (csr_mcause),
.satp (csr_satp),
.mip (csr_mip),
.mie (csr_mie),
.mscratch (csr_mscratch),
.mideleg (csr_mideleg),
.medeleg (csr_medeleg)
);

Part II —— MMU

一、基本概念

1. PagePage Table

页(page)指的是固定大小的内存块,也是内存管理的最小基本单位。页表(page table)是一个页表项(Page Table Entry, PTE)数组,每个 PTE 对应一个虚拟页。

2. Virtual MemoryPhysical Memory

我们知道,一个程序需要内存才能运行。对于 S 级和 U 级的程序,直接让它们见到真实的物理地址是不安全的,它们只能拿到虚拟地址(Virtual Address, VA)。在执行时,CPU 需要把虚拟地址翻译成真实物理地址(Physical Address, PA),而这个翻译过程就会用到 page table

存储页表根地址和分页模式的是 satp 寄存器。对于本 Lab 实现的 Sv39 页表,satp 的构成如下所示(摘自 Lab Wiki):

1
2
3
4
63       60 59                  44 43                                0
---------------------------------------------------------------------
| MODE | ASID | PPN |
---------------------------------------------------------------------

其中,最高 4 位是分页模式 MODE,取值为 8 时代表使用 Sv39 页表模式,取值为 0 时代表不进行页表翻译。PPN 的全称是 Physical Page Number,指的是真实物理页号。

3. MMUMemory 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_privtrap_unitcsr_file 之间反复横跳很绕,当时还问 Gemini 问了好久,试图理解。好吧其实就是写错了(
要注意,mret_previous_priv 本身来自 csr_file 中的 MSTATUS,因此不能再作为 csr_file 的输入,否则,trap_unitcsr_file 二者都是组合逻辑,会导致数值振荡,模拟时会报错。相对地,core 中写入 current_priv 的是时序逻辑,因此不会报错

1
2
3
4
5
   always_ff @(posedge clk) begin
end else if (trap_unit_mret_valid) begin
current_priv <= trap_unit_mret_previous_priv;
end
end

我感觉这个逻辑很混乱,最终直接删除了 trap_unit 中的 mret_previous_priv。如何获取旧的 MPP 值呢?我们不能直接从 MSTATUS 中读取,因为 MRET 指令在这一拍到来时,组合逻辑中的 MPP 会直接被修改为 U 模式,这不一定与旧的 MPP 一致。我们需要从时序逻辑 mstatus_reg 中导出旧的 MPP 值,因为 mstatus_reg 下一回合才会更新。
最终,trap_unit 删除了以下部分:

1
2
3
input  u64    csr_mstatus,
output priv_t mret_previous_priv,
assign mret_previous_priv = priv_t'(csr_mstatus[MSTATUS_MPP_HIGH:MSTATUS_MPP_LOW]);

csr_file 中导出 mstatus_reg

1
2
output priv_t mstatus_reg_mpp,
assign mstatus_reg_mpp = priv_t'(mstatus_reg[MSTATUS_MPP_HIGH:MSTATUS_MPP_LOW]);

core 中获取 mstatus_reg 的值:

1
2
3
4
5
6
   priv_t csr_mstatus_reg_mpp;
always_ff @(posedge clk) begin
end else if (trap_unit_mret_valid) begin
current_priv <= csr_mstatus_reg_mpp;
end
end

总之,codex很好用但不是万能的,很多时候会把事情搞复杂,不如人脑判断,或者整个项目完全不由人插手

参考文献

这些资料对于我搞清楚这个lab到底在干什么很有帮助:

  1. https://www.cnblogs.com/LuoboLiam/p/13411709.html
  2. https://foxsen.github.io/archbase/%E6%8C%87%E4%BB%A4%E6%B5%81%E6%B0%B4%E7%BA%BF.html#sec-precise-exception
  3. https://zhuanlan.zhihu.com/p/561136976
    感谢大佬!

构建五级流水线——lab5
https://travellingsheep.github.io/2026/05/10/构建五级流水线——lab5/
作者
trs62
发布于
2026年5月10日
许可协议