构建五级流水线——lab5
本文最后更新于 2026年5月26日 20:31:58
注:已完成两个bonus任务 ### 一、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 的标准是什么呢?这就需要将 commit_fire 这个点拿出来重新审视一番了。在前几个 Lab 中,这个布尔值用来向 Difftest 说明当前是否进行一次提交,拆成自然语言就是当前允许 mem_wb 信号有效且正常流动。也就是一条指令只要经过 mem_wb 这个门槛,进入了只会写入寄存器的 WB 阶段,就绝对不会再出现任何报错了,过完 WB 阶段,它就彻底被执行完毕了。 1
assign commit_fire = mem_wb_valid && ctrl_mem_wb_enable;ECALL 和 MRET 一路走到 WB 阶段之后再更改 CSR、特权级别、PC 等。否则,需要刷新流水线时,它们已经造成的状态改变难以被恢复。
Part I —— ECALL 与 MRET 指令
二、准备工作——新建枚举类型
为了表示新增的特权级别,在 mypkg 中新建枚举类型: 1
2
3
4
5typedef enum u2 {
PRIV_U = 2'b00,
PRIV_S = 2'b01,
PRIV_M = 2'b11
} priv_t;MSTATUS 寄存器吗?在 Lab4 中,我们提到 MSTATUS “包含一系列状态和控制位”,本次 Lab 中,我们将针对其中四位进行更改: MIE 和 MPIE 共同用于控制 CPU 是否响应异常;MPRV 用于核对特权级别;MPP 是两位字段,决定 trap 发生前的特权级别,便于处理完异常后还原至原有的特权级别。
1 | |
三、先搞定 ECALL 与 MRET 指令
1. 这两条指令都是什么?
MRET (M return)一般出现在异常处理完成之后,让 CPU 退回到异常开始之前的状态(包括特权级别、中断状态和 PC 指针)。它单独调用,不涉及任何寄存器读写操作,除了开头结尾的 funct 和 opcode,其余部分都是零,机器码为 32'h30200073。
ECALL(environment call)指令用于触发系统调用,是程序特权级别不够时让系统在更高级别下代为完成的请示。它也是单独调用,具有固定二进制码 32'h00000073。调用 ECALL 前,程序会通过应用 程序二进制接口(ABI) 约定将系统调用号和参数存入特定寄存器中。
具体这两套指令要做什么,在 wiki 上写的很详细。这里假设只实现 U 和 M 模式,S 模式作为 bonus 放在后面实现。
2. 识别、传递、实例化
由于机器码固定,直接在 decoder 中筛选即可: 1
2
3output u1 is_ecall, is_mret;
assign is_ecall = (inst == 32'h00000073);
assign is_mret = (inst == 32'h30200073);
在 id_ex、ex_mem、mem_wb 中传递它们,同样为了防止意外识别成异常信号,只在 valid 时传递 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18id_ex,ex_mem,mem_wb_reg.sv
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
endcore 中实例化它们: 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
31core.sv
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 进行处理。它们都涉及到 MCAUSE、MEPC、MTVAL、priv 四个量:
① ECALL 指令:主动记录异常发生时的系统状态,导致特权级别升高。根据 wiki 界面,我们需要完成如下事项:
1 | |
其中,“跳转到 MTVEC,冲刷流水线”这一条将在第四板块详解,其他条目如下: - 首先,判断当前是否是一条有效的 ECALL 指令(为兼容后续 Lab 更加广泛的异常,直接用 trap 命名):valid 信号用于告知当前是否是一条有效的 trap 指令,由 commit_fire 和 is_trap 共同决定 1
2trap_unit.sv
assign trap_valid = commit_fire && mem_wb_is_trap;PC 至 MEPC:发生异常时的 PC 就是 ECALL 指令,等到实际调用时再将 PC+4 1
2trap_unit.sv
assign trap_mepc = mem_wb_pc;1
2
3
4csr_file.sv
if (trap_commit) begin
mepc = trap_mepc;
endMCAUSE:由于此 CPU 简化了特权级别,只涉及 U 和 M 两种,根据规则规定,如果 ECALL 发生时当前处在 User 级别,则原因编号为 8,M 对应 11 1
2trap_unit.sv
assign trap_cause = (current_priv == PRIV_U) ? 64'd8 : 64'd11;MPIE、MIE: 1
2
3
4
5csr_file.sv
if (trap_commit) begin
mstatus[MSTATUS_MPIE_BIT] = mstatus_reg[MSTATUS_MIE_BIT];
mstatus[MSTATUS_MIE_BIT] = 1'b0;
endMPP 1
2trap_unit.sv
assign trap_from_priv = current_priv;1
2
3
4csr_file.sv
if (trap_commit) begin
mstatus[MSTATUS_MPP_HIGH:MSTATUS_MPP_LOW] = trap_from_priv;
endM 1
2
3
4core.sv
else if (trap_unit_trap_valid) begin
current_priv <= PRIV_M;
endMTVAL。尽管当前 ECALL 指令不需要记录 MTVAL,但在 Lab6 中处理其他异常时就需要记录这个值了 1
2
3trap_unit.sv
assign trap_tval = 64'b0;
assign trap_from_priv = current_priv;1
2
3
4csr_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
5csr_file.sv
else if (mret_commit) begin
mstatus[MSTATUS_MIE_BIT] = mstatus_reg[MSTATUS_MPIE_BIT];
mstatus[MSTATUS_MPIE_BIT] = 1'b1;
endMPP 值 mret_previous_priv 提取出来,存入 current_priv 1
2
3trap_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
6core.sv
always_ff @(posedge clk)
else if (trap_unit_mret_valid) begin
current_priv <= trap_unit_mret_previous_priv;
end
endcsr_file 本身做成了向外暴露组合逻辑接口,而内部的时序逻辑接口是晚一拍再写入的。这本身没有错,因为作为一个触发器,它必须满足时序逻辑。那么对应的,current_priv 本身也必须做成组合+时序的模式,并将这个组合接口暴露给 Difftest。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22core.sv
priv_t current_priv, current_priv_comb;
always_comb begin
current_priv_comb = current_priv;
if (reset) begin
current_priv_comb = PRIV_M;
end else if (trap_unit_trap_valid) begin
current_priv_comb = PRIV_M;
end else if (trap_unit_mret_valid) begin
current_priv_comb = csr_mstatus_reg_mpp;
end
end
always_ff @(posedge clk) begin
if (reset) begin
current_priv <= PRIV_M;
end else begin
current_priv <= current_priv_comb;
end
end
DifftestCSRState DifftestCSRState(
.priviledgeMode (current_priv_comb),
);MPP 已经废弃了,因此将其设置为最低权限的 U 模式,确保 MRET 指令结束后旧权限一定为 U;同时用 csr_file 内部旧的 mstatus_reg.MPP 判断返回目标是否为 M,若否,则将 MPRV 设置为 0。这里不把 trap_unit 的 mret_previous_priv 再接回 csr_file,避免 csr_mstatus 和 mstatus 更新逻辑之间形成组合环。 1
2
3
4
5csr_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_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
2
3
4
5
6
7
8
9
10
11
12module 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);
endmodulecore 中进行实例化,注意异常的 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
2
3
4
5
6
7core.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 最后检查 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
2
3
463 60 59 44 43 0
---------------------------------------------------------------------
| MODE | ASID | PPN |
---------------------------------------------------------------------MODE,取值为 8 时代表使用 Sv39 页表模式,取值为 0 时代表不进行页表翻译。PPN 的全称是 Physical Page Number,指的是真实物理页号。
3. MMU(Memory Management Unit)
整个翻译过程由 CPU 中的 MMU 模块完成。CSAPP 中文版第三版图 9-2 清晰展示了 MMU 扮演的职责: 
4. 核心计算
1) 地址系统
我们采用 64 位地址系统,其中真实物理地址仅占据低 56 位,高 8 位全部为 0。 > 2^56B=64PB=64*10^6GB,系统不会具有这么夸张的内存大小 ##### 2) 位数划分原因 - 页大小为 4KB,这也意味着任何一个页起始地址的低 12 位都必须为 0。整个翻译过程中只记录 56 位中剩下的 44 位记录 PPN 的部分。 - PTE 大小为 8B。因此一页能够容纳的 PTE 数量 = 4KB/8B=512=2^9
3) 如何进行翻译
我们实现的是 Sv39 三级页表,这里的 39=9*3+12。三个 9 对应三个 VPN(Virtual Page Number),指的是我们要找的 PTE 处在某一页的第几个;最后的十二位是 Page Offset,是真实地址的偏移量。 每个 PTE 的高 10 位是保留位,必须为 0;中间的 44 位是 PPN,左移 10 位后即可得到下一层页表的根地址。 递归查询到第三层后,将第三层 PTE 中的 PPN 左移 12 位,再拼上页内偏移量 offset,开头补上 8 个 0,就得到了最终的真实物理地址。 
三、实施思路
在前几个 Lab,CPU 处于“裸机”状态,所有输入输出都是物理真实地址。而本次 Lab 中的所有地址都需要通过 MMU 模块,因为 ibus 和 dbus 的地址都有可能需要翻译。而 MMU 本身需要访问真实物理地址,常规的 ibus 返回的 data 宽度只有 32 位,无法使用。助教提供了两种方法。其中方法二将 MMU 放在 CBus 外侧,已经处在 CPU 外部,因而需要更改 SimTop 和 VTop,难以直接获取 CSR 寄存器中存储的 satp 值,较为混乱。这里采用方法一。这里 MMU 的输出还需要 DBusToCBus 进行一层额外的转换:
对于外部总线接口,它根本看不到一条指令是 ibus 还是 dbus,所有的请求都通过 CBus 返回。因此,在 CBus 之前设置 MMU,可以继续保持 CBus 负责全部请求的架构。相对地,需要复用已有的 dbus 通路,利用其 64 位宽度的特性整合 ibus 和 dbus 的所有请求,并统一发送给 MMU。
1. DBusArbiter
把 ibus 和 dbus 整合在一起,就涉及到了优先级问题。 这里类似 ctrl 中的做法,如果有 dbus,就优先处理 dbus;没有 dbus 再去处理 ibus。这是因为 mem 阶段的访存需求没有结束时,不能引入新的上游需求,否则会导致堵塞。
可得主循环逻辑: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18dbus_arbiter.sv
always_ff @(posedge clk) begin
if (reset) begin
busy <= 1'b0;
saved_select_is_dbus <= 1'b0;
saved_req <= '0;
saved_ibus_upper_word <= 1'b0;
end else if (busy) begin
if (oresp.data_ok) begin
busy <= 1'b0;
end
end else if (dreq.valid || ireq.valid) begin
busy <= 1'b1;
saved_select_is_dbus <= dreq.valid;
saved_req <= selected_req;
saved_ibus_upper_word <= ireq.addr[2];
end
endreset,直接清零 - 一旦 oresp 准备就绪,busy 在下一拍变回 0 - 如果有 ireq 或者 dreq,意味着 CBus 被占用,设置布尔值 busy 来记录,dbus 为高优先级 - 为防止输入变化导致 selected 发生变化,当时钟上升沿来临时,将 selected_req 压入 saved_req,保存当前正在处理的需求,实现触发器逻辑 - 对于 ibus,由于只用到 32 位,需要选择 64 位的前半或者后半。地址是字节编码的,也就是无论前半后半,地址的最后两位一定都是 0,我们看第 2 位(0 开始计数)的奇偶性即可 - 如果不再busy,则当前总线没有请求。这时不需要手动清零saved,因为下一次请求来临时会读取当拍变动的selected值。如果这次请求仍然跨拍,saved会被自动更新为selected值。
用组合逻辑,将 selected_req 与外部输入连接起来。对于 dbus,直接传递 dbus_req_t 即可;而 ibus 相当于一个 32 位不写入数据的写指令: 1
2
3
4
5
6
7
8
9
10
11
12
13
14dbus_arbiter.sv
always_comb begin
selected_req = '0;
if (dreq.valid) begin
selected_req = dreq;
end else if (ireq.valid) begin
selected_req.valid = ireq.valid;
selected_req.addr = ireq.addr;
selected_req.size = MSIZE4;
selected_req.strobe = 8'b0;
selected_req.data = 64'b0;
end
end
最终输出通过 busy 来决定:空闲时直接连 selected_req,繁忙时连接 saved_req。 1
2dbus_arbiter.sv
assign oreq = busy ? saved_req : selected_req;dbus 直接用,ibus 需要特殊处理: 1
2
3
4
5
6
7
8
9
10
11
12always_comb begin
dresp = '0;
iresp = '0;
if (busy && saved_select_is_dbus) begin
dresp = oresp;
end else if (busy) begin
iresp.addr_ok = oresp.addr_ok;
iresp.data_ok = oresp.data_ok;
iresp.data = saved_ibus_upper_word ? oresp.data[63:32] : oresp.data[31:0];
end
endireq 变成了无效请求,取指和访存都统一通过 core 中的 dbus 输入输出端口。因此,core 中强制 ireq = 0,并新增 fetch_ireq、fetch_iresp 端口接回 pc、if_id;同时新增 mem_dreq 和 mem_dresp,接回 mem。最后将这四个新增端口接入 ctrl 和 dbus_arbiter,完成原先的 flush 控制和新增的 arbiter 选择模块,由 arbiter 统一输出 dreq,并接收 dresp。
2. MMU
接下来,MMU 会接管 dresp 和 dreq,经过处理之后再通过 core 向外输出。 先不管 bonus 的事情,直接分成三个阶段 L2、L1、L0,以及空闲、透传和计算完毕,定义六个 MMU 状态,并实例化: 1
2
3
4
5
6
7
8
9
10
11mmu.sv
typedef enum u3 {
MMU_IDLE = 3'd0,
MMU_DIRECT = 3'd1,
MMU_L2 = 3'd2,
MMU_L1 = 3'd3,
MMU_L0 = 3'd4,
MMU_ACCESS = 3'd5
} mmu_state_t;
mmu_state_t state;
类似 dbus_arbiter,MMU 本身也是一个状态机,需要记录当前状态,防止数值变动。尤其是原始 satp 的低 12 位作为 offset,要一直留到最后一阶段: 1
2mmu.sv
dbus_req_t saved_req;satp 高 4 位的 mode 决定是否要启用三级翻译,得到布尔值 translation_enabled。 - l{i}_page_base 则是每一轮的基地址中间变量,l0_addr 是最终翻译出来的地址,也作为中间变量;l2_page_base 直接由 satp 获得基地址,l1_page_base、l0_page_base 则是根据每一轮外部返回的 data 得到基地址;l0_paddr 则是根据最后一轮外部返回数据与原先 satp 的低 12 位拼接得到物理地址。 - 定义 page_index 和 page_base,用于每一阶段保存基地址和 satp 中每一轮的索引值。 - 所需页表项的物理地址用 pte_addr,其计算方式为 页表基地址[索引] = 页表基地址 + 索引 * 8。 - L2、L1、L0 三个阶段并不是真实的下游请求,我们用 pte_req 来指代这三个阶段发送出去的请求,同时直接用组合逻辑拼出 pte_addr 的 valid、addr、size、strobe、data。此逻辑与 mem 一样,实际上是发送一个读请求,因此请求有效,地址为计算出的 pte_addr,size 为 8B,掩码全 0 表示不写入数据,data 为 0 因为不是写入操作: 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
31mmu.sv
u1 translation_enabled;
assign translation_enabled = (current_priv != PRIV_M) && (satp[63:60] == 4'd8);
u64 l2_page_base, l1_page_base, l0_page_base, l0_paddr;
assign l2_page_base = {8'b0, satp[43:0], 12'b0};
assign l1_page_base = {8'b0, dresp.data[53:10], 12'b0};
assign l0_page_base = {8'b0, dresp.data[53:10], 12'b0};
assign l0_paddr = {8'b0, dresp.data[53:10], saved_req.addr[11:0]};
u64 page_base;
u9 page_index;
always_comb begin
case (state)
MMU_L2: page_index = saved_req.addr[38:30];
MMU_L1: page_index = saved_req.addr[29:21];
MMU_L0: page_index = saved_req.addr[20:12];
default: page_index = 9'b0;
endcase
end
u64 pte_addr;
assign pte_addr = page_base + {52'b0, page_index, 3'b0};
dbus_req_t pte_req;
assign pte_req.valid = 1'b1;
assign pte_req.addr = pte_addr;
assign pte_req.size = MSIZE8;
assign pte_req.strobe = 8'b0;
assign pte_req.data = 64'b0;
core 的 core_req,向上游输出 core_resp;接收上游的 dreq,向下游发放 dresp: 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
31
32
33
34
35
36mmu.sv
input dbus_req_t core_req,
input dbus_resp_t dresp,
output dbus_resp_t core_resp,
output dbus_req_t dreq,
always_comb begin
dreq = '0;
core_resp = '0;
case (state)
MMU_IDLE: begin
dreq = '0;
core_resp = '0;
end
MMU_DIRECT: begin
dreq = saved_req;
core_resp = dresp;
end
MMU_L2, MMU_L1, MMU_L0: begin
dreq.valid = 1'b1;
dreq.addr = pte_addr;
dreq.size = MSIZE8;
dreq.strobe = 8'b0;
dreq.data = 64'b0;
end
MMU_ACCESS: begin
dreq = saved_req;
core_resp = dresp;
end
default: begin
dreq = '0;
core_resp = '0;
end
endcase
enddreq 并使其有效;翻译完成时将最终结果发给上游,并接收上游回复传给下游。
主循环如下: 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53mmu.sv
always_ff @(posedge clk) begin
if (reset) begin
state <= MMU_IDLE;
saved_req <= '0;
page_base <= 64'b0;
end else begin
case (state)
MMU_IDLE: begin
if (core_req.valid) begin
saved_req <= core_req;
if (translation_enabled) begin
state <= MMU_L2;
page_base <= l2_page_base;
end else begin
state <= MMU_DIRECT;
end
end
end
MMU_DIRECT: begin
if (dresp.data_ok) begin
state <= MMU_IDLE;
end
end
MMU_L2: begin
if (dresp.data_ok) begin
state <= MMU_L1;
page_base <= l1_page_base;
end
end
MMU_L1: begin
if (dresp.data_ok) begin
state <= MMU_L0;
page_base <= l0_page_base;
end
end
MMU_L0: begin
if (dresp.data_ok) begin
state <= MMU_ACCESS;
saved_req.addr <= l0_paddr;
end
end
MMU_ACCESS: begin
if (dresp.data_ok) begin
state <= MMU_IDLE;
end
end
default: begin
state <= MMU_IDLE;
end
endcase
end
endreset 直接重置 MMU 状态;空闲时,如果下游请求有效,则保存下游请求;如果下游有效且需要开启翻译,则转到 L2 状态,并计算 L2 基地址作为基地址;若下游有效且不需要开启翻译,则转到 DIRECT 状态;L2、L1、L0 同理,算完后将状态还原为空闲。
整个过程不需要引入额外的 stall,因为上游数据没返回的时候,ctrl 模块会正常执行 stall 指令
TODO 改接线名字 ### 四、最终输出 
五、总结
我认为,整个 CPU 可以看作“表”、“里”两部分,其中“表”部分是经由外界(Difftest)检查的接口,它们需要保持时序逻辑以满足实时性;“里”部分则是完整的 CPU,它采用时序逻辑,在下一拍才会真正更新自己的值。整个 CPU 的运行与“表”部分无关,但为了通过测试不得不采取时序+组合的双重逻辑。
Part III —— Bonus
一、巨页
1. 巨页的性质
三次翻译需要与内存交互三次,带来巨大的延迟。为了保证流水线的效率,我们允许提前终止查找,减少翻译次数。这使得原本用于索引下级页表的 VPN 位自动并入 offset,从而成倍增大页的大小,这就是巨页。受限于硬件电路,在 Sv39 中,我们只能分别增加 9 位与 18 位,每次扩大 2^9 次方,最终得到 2MB 和 1GB 的巨页。
这带来两个好处:首先是翻译次数减少,与内存交互次数减少,从而提升了查找效率,减少延迟;第二是充分利用 TLB(Translation Lookaside Buffer,快表,本 Lab 中尚未实现),通过提升 TLB 命中率减少重新查找的次数。
相对地,页大小增大会导致内存碎片化:一个小程序完全用不到2MB甚至更大的内存,但一次性只能分配这么大,带来了更加严重的内存浪费;同时,清理一个巨页的开销也更大,也影响了其灵活性。一般只在大内存需求的特定环境下使用巨页。
需要注意,本次 Lab 中假设三层查找一定对应一个正确的地址,假设 flag 位一定正确。现有的流水线尚不支持额外的异常处理。 #### 2. 巨页的原理 三种页大小对应的拼接如下 1
2
3L2 叶子: {8'b0, pte[53:28], va[29:0]} // 1GB 页
L1 叶子: {8'b0, pte[53:19], va[20:0]} // 2MB 页
L0 叶子: {8'b0, pte[53:10], va[11:0]} // 4KB 页PPN 是否是叶子节点呢?这时我们就需要用到 PTE 的 flag 位了。根据规定,如果 flag 的低 1 和 3 位中有任何一位是 1,则意味着我们走到了翻译的终点。 #### 3. 改动 我们需要额外记录一次和两次翻译得到的物理地址,并按照各自的拼接方式连线: 1
2
3
4mmu.sv
u64 l1_paddr, l0_paddr;
assign l2_paddr = {8'b0, dresp.data[53:28], saved_req.addr[29:0]};
assign l1_paddr = {8'b0, dresp.data[53:19], saved_req.addr[20:0]};pte_is_leaf 进行记录: 1
2
3mmu.sv
u1 pte_is_leaf;
assign pte_is_leaf = dresp.data[1] || dresp.data[3];ACCESS 阶段: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20mmu.sv
always_ff @(posedge clk) begin
end else begin
case (state)
MMU_L2: begin
if (dresp.data_ok) begin
if (pte_is_leaf) begin
state <= MMU_ACCESS;
saved_req.addr <= l2_paddr;
end
end
end
MMU_L1: begin
if (dresp.data_ok) begin
if (pte_is_leaf) begin
state <= MMU_ACCESS;
saved_req.addr <= l1_paddr;
end
end
end
可以看到,IPC 确实提升了不少,流水线的效率确实提升了。
二、S 模式
1. 背景介绍与复用思路
S 模式是什么,为什么我们要实现它?这里我们需要指导 M、S、U 模式分别处理什么任务 根据 Lab wiki 上的表述: 1
2
3M 模式是机器模式,权限最高,通常是固件或者最底层的管理代码在跑。M 模式有 CPU 完整的访问权,且是 CPU 必选项。
S 模式是监管者模式,一般是操作系统内核所在。
U 模式是用户模式,通常是用户代码运行的地方。M 模式处理,在 CPU 中,我们通过 medeleg 和 mideleg 两个寄存器来控制特权级别的跳转。
从上一个 Lab 中 sstatus 就是 mstatus 的子集就可以看出,M 模式和 S 模式的许多操作是类似的。因此,我们可以将 mret 和 sret 直接抽象为 xret 指令,实现高效的复用逻辑。
2.具体实施
1)新增枚举类型和 mstatus 位数,并在 decoder 中识别新指令
根据资料,mstatus 寄存器中与 S 指令有关的是这几位: 1
2
3
4
5
6mypkg.sv
typedef enum logic [5:0] {
MSTATUS_SIE_BIT = 6'd1,
MSTATUS_SPIE_BIT = 6'd5,
MSTATUS_SPP_BIT = 6'd8,
} mstatus_bit_t;
接着,我们定义抽象化的 xret 枚举类型 1
2
3
4
5
6mypkg.sv
typedef enum u2 {
XRET_NONE = 2'd0,
XRET_M = 2'd1,
XRET_S = 2'd2
} xret_t;decoder 中,我们去除原本的 is_mret 输出端口,统一替换成 xret_type,确定 ret 类型后打包成 xret_t 类型输出: 1
2
3
4
5
6
7decoder.sv
-output u1 is_ecall, is_mret,
+output u1 is_ecall,
+output xret_t xret_type,
-assign is_mret = (inst == 32'h30200073);
+assign xret_type = (inst == 32'h30200073) ? XRET_M :
+ (inst == 32'h10200073) ? XRET_S : XRET_NONE;mem_wb 寄存器 同样为了满足“精确异常”,需要在 WB 阶段进行判断,因此修改原有的 mret 接口,改成 xret_type 接口 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19id_ex.sv
- input u1 is_mret_in,
+ input xret_t xret_type_in,
- output u1 is_mret_out,
+ output xret_t xret_type_out,
always_ff @(posedge clk) begin
if (reset) begin
- is_mret_out <= 1'b0;
+ xret_type_out <= XRET_NONE;
end else if (enable) begin
if (valid_in) begin
- is_mret_out <= is_mret_in;
+ xret_type_out <= xret_type_in;
end else begin
- is_mret_out <= 1'b0;
+ xret_type_out <= XRET_NONE;
end
end
end
3)trap_unit
在这里,我们需要兼容 S 模式的操作: - 删去 mret 判断逻辑,改为用 xret 代替,这涉及到 sepc、stvec 的引入,对应到 redirect 信号的相关判断 - 对于 mcause,可以直接用 trap_cause = {60'b0, 1'b1, 1'b0, current_priv}; 进行接线,因为 MCAUSE 正好等于四种特权级别对应的数值加 8 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16trap_unit.sv
- input u1 mem_wb_is_mret,
+ input xret_t mem_wb_xret_type,
+ input u64 csr_sepc,
+ input u64 csr_stvec,
- output u1 mret_valid,
+ output u1 xret_valid,
- assign trap_cause = (current_priv == PRIV_U) ? 64'd8 : 64'd11;
+ assign trap_cause = {60'b0, 1'b1, 1'b0, current_priv};
- assign mret_valid = commit_fire && mem_wb_is_mret;
+ assign xret_valid = commit_fire && (mem_wb_xret_type != XRET_NONE);
- assign redirect_valid = trap_valid || mret_valid;
+ assign redirect_valid = trap_valid || xret_valid;
为满足职责分离,并减小性能开销,部分非 M 模式触发的异常应该直接在 S 模式下处理。这涉及到了 medeleg 寄存器,根据要求,当非 M 状态下出现异常,若 medeleg[mcause]=1,则应该在 S 模式下处理,因此需要重新考虑跳转的目标 PC: 1
2
3
4
5
6trap_unit.sv
input u64 csr_medeleg,
output priv_t trap_target_priv,
assign trap_target_priv = ((current_priv != PRIV_M) && csr_medeleg[trap_cause[5:0]]) ? PRIV_S : PRIV_M;
assign redirect_pc = trap_valid ? ((trap_target_priv == PRIV_S) ? csr_stvec : csr_mtvec) :
((mem_wb_xret_type == XRET_S) ? csr_sepc : csr_mepc);medeleg 取后四位即可,这里为适配 64 位的寄存器本身,直接截取后六位。效果是一样的
4)完成 S 模式下 CSR 寄存器的操作
同样地,我们先用 xret 取代 mret 的操作: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19csr_file.sv
- input u1 mret_commit,
+ input u1 xret_commit,
+ input xret_t xret_type,
- output priv_t mstatus_reg_mpp,
+ output priv_t xret_return_priv,
- assign mstatus_reg_mpp = priv_t'(mstatus_reg[MSTATUS_MPP_HIGH:MSTATUS_MPP_LOW]);
+ assign xret_return_priv = (xret_type == XRET_M) ?
+ priv_t'(mstatus_reg[MSTATUS_MPP_HIGH:MSTATUS_MPP_LOW]) :
+ (mstatus_reg[MSTATUS_SPP_BIT] ? PRIV_S : PRIV_U);
- if (trap_commit) begin
+ if (trap_commit && (trap_target_priv == PRIV_M)) begin
- end else if (mret_commit) begin
- mstatus[MSTATUS_MIE_BIT] = mstatus_reg[MSTATUS_MPIE_BIT];
- mstatus[MSTATUS_MPIE_BIT] = 1'b1;
- 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;
- endif-else 判断逻辑我们略去不提。对于 xret 指令要返回的特权级别,如果是 mret,复用之前的逻辑;如果不是,需要根据 mstatus_reg 中记录 SPP 的位来判断返回至哪个特权级别。
接着,我们处理 S 模式新增逻辑: mstatus 的处理思路与 M 模式完全一致,sepc、scause、stval 直接填写输入的 trap_* 的值。 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
31
32
33//处理trap
input priv_t trap_target_priv,
if (trap_commit && (trap_target_priv == PRIV_M)) begin
//m模式,不用改动
end else if (trap_commit && (trap_target_priv == PRIV_S)) begin
//完全仿照m模式
sepc = trap_epc;
scause = trap_cause;
stval = trap_tval;
mstatus[MSTATUS_SPIE_BIT] = mstatus_reg[MSTATUS_SIE_BIT];
mstatus[MSTATUS_SIE_BIT] = 1'b0;
mstatus[MSTATUS_SPP_BIT] = (trap_from_priv == PRIV_S);
//处理ret
end else if (xret_commit) begin
unique case (xret_type)
XRET_M: begin
mstatus[MSTATUS_MIE_BIT] = mstatus_reg[MSTATUS_MPIE_BIT];
mstatus[MSTATUS_MPIE_BIT] = 1'b1;
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
end
XRET_S: begin
mstatus[MSTATUS_SIE_BIT] = mstatus_reg[MSTATUS_SPIE_BIT];
mstatus[MSTATUS_SPIE_BIT] = 1'b1;
mstatus[MSTATUS_SPP_BIT] = 1'b0;
mstatus[MSTATUS_MPRV_BIT] = 1'b0;
end
default: begin
end
endcase
endS 模式,写入寄存器的 mask 也需要对应调整:medeleg 需要暴露 U 和 S 模式的位以供写入;sstatus 需要暴露 SIE、SPIE、SPP 三位以供修改。 1
2
3
4
5
6
7
8
9
10
11
12csr_file.sv
output u64 medeleg_reg_value,
assign medeleg_reg_value = medeleg_reg;
localparam u64 MEDELEG_WRITABLE_MASK = (64'b1 << 8) | (64'b1 << 9);
localparam u64 SSTATUS_LOCAL_MASK = SSTATUS_MASK |
(64'b1 << MSTATUS_SIE_BIT) |
(64'b1 << MSTATUS_SPIE_BIT) |
(64'b1 << MSTATUS_SPP_BIT);
CSR_SSTATUS: mstatus = (mstatus_reg & ~(SSTATUS_LOCAL_MASK & MSTATUS_MASK)) |
(csr_write_data & SSTATUS_LOCAL_MASK & MSTATUS_MASK);
CSR_MEDELEG: medeleg = csr_write_data & MEDELEG_WRITABLE_MASK;
sstatus = mstatus & SSTATUS_LOCAL_MASK;core 中进行实例化 略去纯模块化接口。我们需要注意 current_priv_comb 的赋值逻辑,以及 trap_kill_mem 的判断逻辑。 1
2
3
4
5
6
7
8
9
10
11
12
13core.sv
always_comb begin
current_priv_comb = current_priv;
if (reset) begin
current_priv_comb = PRIV_M;
end else if (trap_unit_trap_valid) begin
current_priv_comb = trap_unit_trap_target_priv;
end else if (trap_unit_xret_valid) begin
current_priv_comb = csr_xret_return_priv;
end
end
assign trap_kill_mem = mem_wb_valid && (mem_wb_is_trap || (mem_wb_xret_type != XRET_NONE));
三、完成bonus之后的最终输出
在vscode中:
在串口调试助手上: 
X、常见错误:组合环
组合逻辑被视作实时变化,因此一系列组合逻辑一旦形成环,编译时会直接报错。解决方法就是打破这个环,用一个时序逻辑代替它 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23verilator --lint-only -sv -UVERILATOR -Wall -Wno-IMPORTSTAR -Wno-STMTDLY -Wno-declfilename -Wno-UNUSED -Wno-EOFNEWLINE vsrc/include/config.sv vsrc/include/common.sv vsrc/include/csr.sv vsrc/src/utils/mypkg.sv vsrc/src/utils/dbus_arbiter.sv vsrc/src/utils/mmu.sv vsrc/src/utils/alu.sv vsrc/src/utils/multiplier.sv vsrc/src/basics/pc.sv vsrc/src/basics/decoder.sv vsrc/src/utils/regfile.sv vsrc/src/utils/csr_file.sv vsrc/src/utils/csr_cal.sv vsrc/src/utils/trap_unit.sv vsrc/src/utils/forward.sv vsrc/src/basics/ex.sv vsrc/src/basics/mem.sv vsrc/src/basics/wb.sv vsrc/src/utils/ctrl.sv vsrc/src/regs/if_id.sv vsrc/src/regs/id_ex.sv vsrc/src/regs/ex_mem.sv vsrc/src/regs/mem_wb.sv vsrc/src/core.sv
%Warning-UNOPTFLAT: vsrc/src/core.sv:78:6: Signal unoptimizable: Circular combinational logic: 'core.csr_file_write_enable'
78 | u1 csr_file_write_enable;
| ^~~~~~~~~~~~~~~~~~~~~
... For warning description see https://verilator.org/warn/UNOPTFLAT?v=5.032
... Use "/* verilator lint_off UNOPTFLAT */" and lint_on around source to disable this message.
vsrc/src/core.sv:78:6: Example path: core.csr_file_write_enable
vsrc/src/utils/csr_file.sv:47:5: Example path: ALWAYS
vsrc/src/core.sv:82:64: Example path: core.csr_satp
vsrc/src/utils/mmu.sv:40:32: Example path: ASSIGNW
vsrc/src/utils/mmu.sv:38:8: Example path: core.u_mmu.translation_enabled
vsrc/src/utils/mmu.sv:57:5: Example path: ALWAYS
vsrc/src/core.sv:50:14: Example path: core.mmu_core_resp
vsrc/src/utils/dbus_arbiter.sv:43:5: Example path: ALWAYS
vsrc/src/core.sv:48:14: Example path: core.mem_dresp
vsrc/src/utils/ctrl.sv:62:26: Example path: ASSIGNW
vsrc/src/core.sv:100:59: Example path: core.ctrl_ex_mem_enable
vsrc/src/core.sv:226:21: Example path: ASSIGNW
vsrc/src/core.sv:194:6: Example path: core.commit_fire
vsrc/src/core.sv:229:31: Example path: ASSIGNW
vsrc/src/core.sv:78:6: Example path: core.csr_file_write_enable
%Error: Exiting due to 1 warning(s)
Y、吐槽
Codex 小插曲 #### 1. 一个错误 我本来就觉得这个 mret_previous_priv 在 trap_unit 和 csr_file 之间反复横跳很绕,当时还问 Gemini 问了好久,试图理解。好吧其实就是写错了( 要注意,mret_previous_priv 本身来自 csr_file 中的 MSTATUS,因此不能再作为 csr_file 的输入,否则,trap_unit 和 csr_file 二者都是组合逻辑,会导致数值振荡,模拟时会报错。相对地,core 中写入 current_priv 的是时序逻辑,因此不会报错 1
2
3
4
5
6core.sv
always_ff @(posedge clk) begin
end else if (trap_unit_mret_valid) begin
current_priv <= trap_unit_mret_previous_priv;
end
endtrap_unit 中的 mret_previous_priv。如何获取旧的 MPP 值呢?我们不能直接从 MSTATUS 中读取,因为 MRET 指令在这一拍到来时,组合逻辑中的 MPP 会直接被修改为 U 模式,这不一定与旧的 MPP 一致。我们需要从时序逻辑 mstatus_reg 中导出旧的 MPP 值,因为 mstatus_reg 下一回合才会更新。 最终,trap_unit 删除了以下部分: 1
2
3input 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
2output 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
2. 工程错误
让 codex 写一份执行命令并且依次执行而不运行实际验证指令的效果往往不会很好,因为小细节需要真实运行才能看出来,所以不能按照它给出的实施计划,比如第一步就接线之类的,最后可能完全不是这样。report 也不应该在早期写,后面可能会发现都是错的。最好的方式可能还真是全部完成之后再写。
参考文献
这些资料对于我搞清楚这个 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 感谢大佬!