构建五级流水线——labplus
本文最后更新于 2026年7月5日 14:27:24
Lab +
之前已完成的 Bonus
| 属于 | Bonus 列表 | 评定方式 | 附注 |
|---|---|---|---|
| Lab 4 | 简述一下此次 lab 中各个 csr 寄存器的作用 |
Lab 4 报告 | 在 Lab 4 作业中已经提交,用红字标出了 |
| Lab 4 | 思考为什么一定要刷新流水线? | Lab 4 报告 | 在 Lab 4 作业中已经提交,用红字标出了 |
| Lab 4 | 实现 csr.sv 给定的所有寄存器,包括那些以 s 开头的寄存器(如 stvec 等等) |
Lab 4 报告 / Lab + 报告 | 在 Lab 4 作业中已经提交,用红字标出了,由于篇幅原因没有将代码改动写出。在 Lab + 中详细给出涉及到的代码改动 |
| Lab 5 | MMU 支持巨页 |
Lab 5 报告 / Lab + 报告 | 在 Lab 5 报告的 Bonus 分区写了如何实现,但受限于篇幅删除了代码部分,本次 Lab 将给出完整的 Lab 5 报告 |
| Lab 5 | 实现 MMU 的缺页异常 |
Lab 5 报告 / Lab + 报告 | 根据 Wiki,实际上是 Lab 6 的 Bonus,因此我在 Lab 6 的作业中完成了,完整的实验报告已经在本次作业中提交了 |
| Lab 5 | 实现 S 模式 |
Lab 5 报告 / Lab + 报告 | 在 Lab 5 报告的 Bonus 分区写了如何实现,但受限于篇幅删除了代码部分,本次 Lab 将给出完整的 Lab 5 报告 |
| Lab 6 | 编写中断处理程序,在时钟中断时打印一些内容 | Lab 6 报告 | 完整的实验报告已经在本次作业中提交了 |
注:Wiki 上 Lab 5 的 Bonus 是实现
S模式和巨页,Lab 6 的 Bonus 是实现MMU的缺页异常和编写中断处理程序。缺页异常我是在 Lab 6 中提交的
本次完成的 Bonus
| 属于 | Bonus 列表 | 评定方式 |
|---|---|---|
| 任意 Lab 完成 | 乘除法 | lab1-extra |
| Lab 6 | 思考时钟中断为什么使用 MMIO 计时器 |
Lab 6 报告 |
| Lab + | PMP、U 模式、特权拦截 |
Lab + 报告 |
| Lab + | 简单性能优化——分支预测与 I-Cache |
Lab + 报告 |
| Lab + | 原子指令 | Lab + 报告 |
| Lab + | 运行 xv6,可以进入用户终端,尝试上板,但小板子资源不够,无法通过 implementation |
Lab + 报告 |
“思考时钟中断为什么使用
MMIO计时器”这个 Bonus 在 Wiki 的“特权架构”页面上,做 Lab 6 的时候没看到,在 Lab + 中补上。
通过
make test-labplus-4还要求完成U模式以及完成特权拦截(特权等级不够或者非法操作),这两个我在完成PMP的过程中顺便完成了。这里我实现的是针对test-labplus-4的最小特权拦截(特权等级不够、未实现CSR,写入只读CSR)。
Lab 4:实现以 s 开头的寄存器 - 代码部分
一、S-mode 相关寄存器
这一类寄存器包括 stvec、sscratch、sepc、scause、stval、sie、sip。其中 stvec、sscratch、sepc、scause、stval 在 csr_file 中新增真实寄存器;sie 和 sip 不单独建寄存器,而是分别从 mie、mip 中用 S_INTERRUPT_MASK 提取监管者模式相关位。
首先,csr_file 新增 S-mode CSR 输出端口、内部寄存器和 sie/sip 使用的 mask。
1 | |
组合输出中把新增寄存器值导出,使同一拍写入时可以通过 write-first 旁路被外部和 Difftest 看到。
1 | |
写 CSR 时,sip 只替换 mip 中监管者中断位,sie 只替换 mie 中监管者中断位;stvec 复用 mtvec 的 mask,其余 S-mode CSR 直接写入。
1 | |
读 CSR 时,sip 和 sie 分别返回 mip/mie 的监管者中断位,其他 S-mode CSR 返回对应寄存器的可见值。
1 | |
时序逻辑中给新增寄存器补上 reset 清零和正常更新。
1 | |
顶层 core 新增 S-mode CSR 连线,并把这些输出接到 csr_file 实例上。
1 | |
1 | |
Difftest 原先这些 S-mode CSR 端口接常量 0, Lab 4 Bonus 中改为接入真实 CSR 状态。
1 | |
二、委托寄存器
第二类是 medeleg 和 mideleg。它们用于记录哪些异常或中断可以委托给监管者模式处理。 Lab 4 中只实现 CSR 读写和 Difftest 接线,真正根据委托结果进入 S-mode 的 trap 逻辑在后续 Lab 5 才补上。
csr_file 新增两个输出端口和两个内部寄存器。
1 | |
组合输出中导出 medeleg/mideleg 当前值。
1 | |
写入时分别按照 MEDELEG_MASK 和 MIDELEG_MASK 保留合法位。
1 | |
读 CSR 时返回这两个委托寄存器。
1 | |
时序逻辑中同样补上 reset 和更新。
1 | |
顶层增加 medeleg/mideleg 连线,并接入 csr_file 和 DifftestCSRState。
1 | |
1 | |
1 | |
三、PMP 寄存器
第三类是 pmpcfg0 和 pmpaddr0。在 Lab 4 Bonus 阶段,这两个寄存器只是在 csr_file 内部补齐读写;当时没有真正接入 PMP 权限检查,也没有 Difftest 端口需要连接。
csr_file 内部新增 pmpcfg0/pmpaddr0 寄存器和组合可见值。
1 | |
组合输出中把内部寄存器值送到临时可见值。
1 | |
写 CSR 时直接更新 pmpcfg0 和 pmpaddr0 的可见值。
1 | |
读 CSR 时返回 pmpcfg0/pmpaddr0。
1 | |
时序逻辑中补上 reset 和正常更新。
1 | |
Lab 5:MMU 支持巨页,以及 S 模式
注:已完成两个Bonus 任务,并且通过了袁神的测试 ### 一、Lab 5 背景 在这个 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 寄存器吗?在 Lab 4 中,我们提到 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,但在 Lab 6 中处理其他异常时就需要记录这个值了 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 跳转优先级最高,因此在原有 Lab 3 的跳转逻辑上加上 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)
参考文献
这些资料对于我搞清楚这个 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 感谢大佬!
乘除法
一、Lab 1 背景介绍
在 Lab 1 中,我们完成了基本的加减法运算,在 Bonus 部分,我们将会实现乘除法。为什么乘除法不能像加减法那样在 alu 中用 * 和 / 来简单表示呢?这里我们需要对 CPU 频率和周期数的计算有更深入的了解:
在 CPU 中,每当时钟信号来临时,寄存器的值会被输入到各个模块中进行运算,等待其中的组合逻辑运算完成并将数据存入寄存器之后才能进入下一个时钟周期。这意味着,时钟周期受限于关键路径,即时钟周期必须大于等于两个寄存器之间延迟最长的组合逻辑。对于加法和减法,使用超前进位加法器可以在\(O(lgn)\)时间内完成计算,但乘除法复杂度高很多,强行放在一个时钟周期内完成会导致时钟频率大大下降,严重拖慢 CPU 的运行速率。
为了确保 CPU 的速度,我们需要将乘除法分散在多个周期内完成,也就是本次 Lab 的主题。
二、Lab 1 目标
在 Lab 1 Bonus 中,我们需要完成以下指令:
mul,mulw:1
mul x3, x1, x2 # 将x1*x2的低64位结果写入x3
这个指令会截断最终结果,只保留后 64 位,相当于对\(2^{64}\)进行取模运算。而 $X_{unsigned} = X_{signed} + 2^{64} $ ,因此 mul 指令对于有/无符号数得到的计算结果完全相同,不需要加以区分。
div,divu,divw,divuw,rem,remu,remw,remuw:
div:求商rem:求余数1
2
3
4
5
6
7
8
9li x1, 13
li x2, -5
# 有符号操作
div x3, x1, x2 # x3 = 13 / -5 = -2 (向零舍入)
rem x4, x1, x2 # x4 = 13 % -5 = 3 (余数符号同被除数 x1)
# 无符号操作
# 此时 x2 的 -5 会被当作一个极大的无符号正数 (0xFFFFFFFB)
divu x5, x1, x2 # x5 = 13 / 0xFFFFFFFB = 0
remu x6, x1, x2 # x6 = 13 % 0xFFFFFFFB = 13
注:带 w 后缀的是32 位版本的指令,只处理操作数的低 32 位,最后再把32 位结果符号扩展成64 位写回寄存器,
divuw和remuw也一样进行符号扩展。
注:先截断操作数是必要操作,尤其在除法中,如果高32 位非空,不进行截断往往会带来错误的结果
- 边界条件 除数为0时,根据规定:
div/divu/divw/divuw:返回全1(对应有符号数的-1和无符号数的最大值)rem/remu:返回被除数,以满足被除数 = 除数 × 商 + 余数;remw/remuw:先将被除数截断至 32 位,再进行符号扩展
最小负数/(-1)=最小负数: > 补码中,有符号数的负数比整数多一位,原先结果为正,但超出表示范围,只能溢出,最终结果等于最小负数;余数仍然是0
三、乘法
本lab中,我采用移位乘法,思路非常接近快速幂: > 对于一个x位乘法A*B,进行x轮操作,每轮操作将A左移一位,再将B右移一位,每次根据B的最低位判断是否要加上A,最终汇报结果
这里的乘法器是一个状态机,需要存储当前的状态,后续状态也与当前状态有关。
乘法器共有三种状态:IDLE,BUSY,DONE,分别表示空闲,正在进行运算和运算完成。 - IDLE 表示乘法器可以接收 start 信号开始计算,将操作数加载进来 - BUSY 是乘法器正在被占用 - DONE 用于指示当前拍已经完成计算,可以让下一条指令进来了,下一拍再次变回 IDLE 完成循环。
具体实现如下:
1. 加入新的指令
首先,我们需要先为两条乘法指令在 mypkg 中定义枚举类型: 1
2
3
4
5
6mypkg.sv
typedef enum logic [1:0] {
MUL_NONE = 2'd0,
MUL_MUL = 2'd1,
MUL_MULW = 2'd2
} mul_t;utils 中新建 multiplier。作为一个时序逻辑,它需要接受 clk 信号和 reset 信号。同时,decoder 将会提供各类操作信号(start,use_w,rs1_num,rs2_num),其中 start 是新出现的符号,表示乘法器需要开始运算了。最终,乘法器要将内部状态暴露出去,输出 busy 和 done,以及计算结果 result。 1
2
3
4
5
6
7
8
9
10
11
12multiplier.sv
module multiplier
import common::*;
(
input logic clk,
input logic reset,
input u1 start,use_w,
input u64 rs1_num,rs2_num,
output u1 busy,done,
output u64 result
);
接着定义内部状态 state,acc 表示accumulator,用于存放中间运算结果,multiplicand 存放被乘数rs1,multiplier_num 存放乘数rs2。count 用于计数,round则是循环上限。busy 和 done 信号通过内部状态导出。 1
2
3
4
5
6
7
8
9
10
11
12
13
14multiplier.sv
typedef enum logic [1:0] {
MUL_IDLE = 2'd0,
MUL_BUSY = 2'd1,
MUL_DONE = 2'd2
} state_t;
state_t state;
u7 count, rounds;
u64 acc, multiplicand, multiplier_num;
u64 addend, acc_next;
assign busy = (state == MUL_BUSY);
assign done = (state == MUL_DONE);count 进行计数。addend 是每轮乘法的加数,根据当前乘数尾数决定是否增加被乘数,acc_next 是下一步计算结果,利用历史结果 acc 加上 addend 得到。
之后根据内部状态决定如何运行:start 会让 IDLE 转为 BUSY,并加载数据;BUSY 一共round-1轮,每次将 acc 更新为 acc_next,改变乘数、被乘数;当进行round-1轮之后,更新状态为 DONE,同时直接将 acc_next 的数据拿出来交给结果;一轮之后将 DONE 还原为 IDLE。 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
45multiplier.sv
assign addend = multiplier_num[0] ? multiplicand : 64'b0;
assign acc_next = acc + addend;
always_ff @(posedge clk) begin
if (reset) begin
state <= MUL_IDLE;
count <= 7'b0;
rounds <= 7'b0;
acc <= 64'b0;
multiplicand <= 64'b0;
multiplier_num <= 64'b0;
result <= 64'b0;
end else begin
case (state)
MUL_IDLE: begin
if (start) begin
state <= MUL_BUSY;
count <= 7'b0;
rounds <= use_w ? 7'd32 : 7'd64;
acc <= 64'b0;
multiplicand <= use_w ? {32'b0, rs1_num[31:0]} : rs1_num;
multiplier_num <= use_w ? {32'b0, rs2_num[31:0]} : rs2_num;
end
end
MUL_BUSY: begin
acc <= acc_next;
multiplicand <= multiplicand << 1;
multiplier_num <= multiplier_num >> 1;
count <= count + 7'd1;
if (count == (rounds - 7'd1)) begin
state <= MUL_DONE;
result <= use_w ? {{32{acc_next[31]}}, acc_next[31:0]} : acc_next;
end
end
MUL_DONE: begin
state <= MUL_IDLE;
end
default: begin
state <= MUL_IDLE;
end
endcase
end
end
3. 在 decoder 中输出对应信号
乘法信号 is_mul 需要额外根据 funct7 和 funct3 判断,以与普通的寄存器操作区分开 1
2decoder.sv
assign is_mul = ((is_alu_r || is_alu_rw) && (funct7 == 7'b0000001) && (funct3 == 3'b000));
并在下面判断 mul_op 1
2
3
4
5
6
7
8
9
10
11
12
13decoder.sv
else if (is_alu_r) begin
if (funct7 == 7'b0000001) begin
case(funct3)
3'b000: mul_op = MUL_MUL;
default: op = ALU_WRONG;
endcase
else if (is_alu_rw) begin
if (funct7 == 7'b0000001) begin
case(funct3)
3'b000: mul_op = MUL_MULW;
default: op = ALU_WRONG;
endcase
4. 将 is_mul 和 mul_op 从 id_ex 传递给 ex
代码整体比较常规,这里掠过 需要注意 bubble 时要将 is_mul 清零,防止意外启动乘法器
5. 将 multiplier 包装进 ex 模块
ex 模块需要额外接收 clk 和 reset 信号,decoder 解析出来的 mul_op,is_mul 信号转交给 multiplier,并将乘法器状态 mul_done 向外输出。 1
2
3
4
5
6ex.sv
input logic clk,
input u1 reset,
input mul_t mul_op,
input u1 is_mul,
output u1 mul_done,alu 信号与乘法器结果区分开,于是将原先的 alu_result 重命名为 alu_result_normal;定义 mul_busy 导出乘法器状态,mul_use_w 用于指导乘法器是否进行32 位计算;定义 mul_start 用于指导 multiplier 何时开始计算
1 | |
这里只需要判断是乘法指令,且当前乘法器不在运行即可,DONE 状态根本不理会 start 信号 最终根据当前指令是否是乘法指令来选择 ex 输出
1 | |
6. 完成乘法器的流水线控制
乘法器在计算时,ex 阶段不能流入新的指令,因此 pc,if_id,id_ex 全都要 stall;而 ex_mem 需要塞气泡,代表这是无效指令。待乘法器由 BUSY 转为 DONE 阶段时,stall 取消,让新的指令流进来;bubble 取消,让乘法器的结果流出去。 而当一条乘法指令进来时,乘法器从 IDLE 转为 BUSY,这一拍 ex_mem 会根据 mul_result 将答案传出去,导致错误。为了防止这一行为,我们应该在识别出当前指令是乘法指令时就进行拦截,且这一拦截优先级高于 DONE 信号的判断
1 | |
接着把这个信号接入: 1
2
3
4ctrl.sv
assign pc_enable = global_enable && ~load_use_stall && ~ibus_stall && ~mul_stall;
assign if_id_enable = global_enable && ~load_use_stall && ~mul_stall;
assign id_ex_enable = global_enable && ~mul_stall;ex_mem 专属的 flush: 1
2ctrl.sv
assign ex_mem_flush = global_enable && mul_stall;
7. 在 core 中完成接线和实例化
这次接线比较清晰,略去代码部分
四、除法
整体思路可以复用,具体实施过程中采用移位除法,复用乘法的 stall 逻辑。
1. 在 mypkg 中定义新的除法枚举类型
1 | |
2. 完成除法模块 divide
为了复用除法模块,我们的目标是完成一个只负责无符号运算,兼容32 位与64 位运算及除零的 divide 模块。有符号运算会先在 ex 中进行预处理,这里按下不表。
1)核心逻辑与伪代码:从被除数的高位向低位依次处理。若余数大于除数,则减去除数,商对应位数上变成1。
1 | |
2)代码如下:
初始化(reset 略去了):
- 根据是否是32 位运算决定最多进行多少轮移位
- 看情况将除数、被除数截取到32 位
rounds表示轮数,count表示进行了几轮- 如果被除数是0,直接进入边界处理(结束计算、商全1、余数为被除数)
- 否则进入
BUSY模式开始计算: - 32 位运算时,将被除数低 32 位放在商的高32 位,后续全部为0,而不是低 32 位。 > 这样做是合理的:因为32 位运算只进行32轮,因此从高到低的32 位内必须把除法解决掉
- 余数为0
- 定义
use_w,divisor的reg寄存器,用于保存输入内容;定义remainder的reg寄存器,用于保存中间结果
1 | |
运行:
- 商左移1位,接上0;商的最高位接到余数后面
- 若商大于余数:减去余数,商最后一位为1
remainder_shift需要在常规remainder的基础上额外继承商的一位,因此定义成65位寄存器。由循环不变量知remainder[65]=1不会造成溢出:- 上一轮结束后
remainder_reg<divisor。开始时remainder_reg是0,因此开始时成立。 - 本轮:
remainder_shift=remainder_reg* 2 +next_bit,所以remainder_shift< 2 *divisor - 当
remainder_shift>=divisor时:remainder_next=remainder_shift-divisor。因此remainder_next<divisor。因此remainder_next一定能放进64 位 remainder_next和divisor_ext需要参与remainder_shift的运算,所以都定义成65位寄存器- 如果只剩1轮,下次直接变回
DIV_DONE,防止多一轮等待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
26divide.sv
assign remainder_shift = {remainder_reg[63:0], quotient[63]};
assign quotient_shift = {quotient[62:0], 1'b0};
assign divisor_ext = {1'b0, divisor_reg};
always_comb begin
if (remainder_shift >= divisor_ext) begin
remainder_next = remainder_shift - divisor_ext;
quotient_next = quotient_shift | 64'd1;
end else begin
remainder_next = remainder_shift;
quotient_next = quotient_shift;
end
end
DIV_BUSY: begin
count <= count + 7'd1;
quotient <= quotient_next;
remainder_reg <= remainder_next;
if (count == (rounds - 7'd1)) begin
state <= DIV_DONE;
quotient <= quotient_result;
remainder <= remainder_result;
end
end
状态接手
- 向外展示除法器状态
- 如果使用32 位,则商和余数都截取低 32 位,高位全部为0
1
2
3
4
5divide.sv
assign busy = (state == DIV_BUSY);
assign done = (state == DIV_DONE);
assign quotient_result = use_w_reg ? {32'b0, quotient_next[31:0]} : quotient_next;
assign remainder_result = use_w_reg ? {32'b0, remainder_next[31:0]} : remainder_next[63:0];
3. 在 ex 阶段处理除法器相关内容
divide 模块只负责无符号除法,所以在 ex 阶段需要先把RISC-V除法指令转换成无符号除法器能处理的形式。这里主要完成四件事: - 根据 div_op 判断是否是32 位运算、是否是有符号运算、最终返回商还是余数 - 对有符号除法先取绝对值,送入无符号 divide - 处理除0和最小负数除以-1这两个边界条件 - 在多周期过程中锁存修正结果需要的信息,避免 stall 期间前递数据变化影响最终结果
首先在 ex 模块端口中加入除法控制信号,并定义除法器需要的中间变量: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16ex.sv
input div_t div_op,
input u1 is_mul, is_div, mem_read, mem_write,
output u1 mul_done, div_done,
u64 alu_result_normal,mul_result,div_result;
u64 div_dividend,div_divisor,div_quotient,div_remainder;
u64 div_quotient_signed,div_remainder_signed,div_result_pre;
u64 div_zero_quotient,div_zero_remainder,div_overflow_quotient;
u64 div_original_dividend_reg;
u1 div_busy,div_start,div_use_w,div_is_signed,div_is_rem;
u1 div_rs1_sign,div_rs2_sign,div_quotient_neg;
u1 div_by_zero,div_overflow;
u1 div_use_w_reg,div_is_rem_reg,div_rs1_sign_reg,div_quotient_neg_reg;
u1 div_by_zero_reg,div_overflow_reg;
随后根据 div_op 拆分指令语义。div_use_w 决定除法器执行32轮还是64轮;div_is_signed 决定是否需要对操作数取绝对值;div_is_rem 决定最终选择商还是余数。对于有符号除法,送入 divide 前需要将负数转为正数。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23ex.sv
assign div_start = is_div && ~div_busy;
assign div_use_w = (div_op == DIV_DIVW) || (div_op == DIV_DIVUW) ||
(div_op == DIV_REMW) || (div_op == DIV_REMUW);
assign div_is_signed = (div_op == DIV_DIV) || (div_op == DIV_REM) ||
(div_op == DIV_DIVW) || (div_op == DIV_REMW);
assign div_is_rem = (div_op == DIV_REM) || (div_op == DIV_REMU) ||
(div_op == DIV_REMW) || (div_op == DIV_REMUW);
assign div_rs1_sign = div_is_signed && (div_use_w ? forward_rs1_val[31] : forward_rs1_val[63]);
assign div_rs2_sign = div_is_signed && (div_use_w ? forward_rs2_val[31] : forward_rs2_val[63]);
assign div_quotient_neg = div_rs1_sign ^ div_rs2_sign;
assign div_dividend = div_is_signed ?
(div_use_w ?
{32'b0, (div_rs1_sign ? (~forward_rs1_val[31:0] + 32'd1) : forward_rs1_val[31:0])} :
(div_rs1_sign ? (~forward_rs1_val + 64'd1) : forward_rs1_val)) :
(div_use_w ? {32'b0, forward_rs1_val[31:0]} : forward_rs1_val);
assign div_divisor = div_is_signed ?
(div_use_w ?
{32'b0, (div_rs2_sign ? (~forward_rs2_val[31:0] + 32'd1) : forward_rs2_val[31:0])} :
(div_rs2_sign ? (~forward_rs2_val + 64'd1) : forward_rs2_val)) :
(div_use_w ? {32'b0, forward_rs2_val[31:0]} : forward_rs2_val);
除法的边界条件也在 ex 中统一处理。除数为0时,商为全1,余数为原被除数;有符号最小负数除以-1时,商保持最小负数,余数为0。由于除法器会运行多个周期,所以这些信息在 div_start 时锁存下来,保证 DONE 周期使用的仍然是同一条除法指令的属性。 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
27ex.sv
assign div_by_zero = div_use_w ? (forward_rs2_val[31:0] == 32'b0) : (forward_rs2_val == 64'b0);
assign div_overflow = div_is_signed &&
((div_use_w && (forward_rs1_val[31:0] == 32'h8000_0000) &&
(forward_rs2_val[31:0] == 32'hffff_ffff)) ||
(!div_use_w && (forward_rs1_val == 64'h8000_0000_0000_0000) &&
(forward_rs2_val == 64'hffff_ffff_ffff_ffff)));
always_ff @(posedge clk) begin
if (reset) begin
div_original_dividend_reg <= 64'b0;
div_use_w_reg <= 1'b0;
div_is_rem_reg <= 1'b0;
div_rs1_sign_reg <= 1'b0;
div_quotient_neg_reg <= 1'b0;
div_by_zero_reg <= 1'b0;
div_overflow_reg <= 1'b0;
end else if (div_start) begin
div_original_dividend_reg <= forward_rs1_val;
div_use_w_reg <= div_use_w;
div_is_rem_reg <= div_is_rem;
div_rs1_sign_reg <= div_rs1_sign;
div_quotient_neg_reg <= div_quotient_neg;
div_by_zero_reg <= div_by_zero;
div_overflow_reg <= div_overflow;
end
end
当 divide 给出无符号商和余数后,ex 再根据前面锁存的信息修正符号并选择最终结果。对于所有w后缀指令,最终都取低 32 位并符号扩展到64 位。 1
2
3
4
5
6
7
8
9
10ex.sv
assign div_quotient_signed = div_quotient_neg_reg ? (~div_quotient + 64'd1) : div_quotient;
assign div_remainder_signed = div_rs1_sign_reg ? (~div_remainder + 64'd1) : div_remainder;
assign div_zero_quotient = div_use_w_reg ? {32'b0, 32'hffff_ffff} : 64'hffff_ffff_ffff_ffff;
assign div_zero_remainder = div_use_w_reg ? {32'b0, div_original_dividend_reg[31:0]} : div_original_dividend_reg;
assign div_overflow_quotient = div_use_w_reg ? {32'b0, 32'h8000_0000} : 64'h8000_0000_0000_0000;
assign div_result_pre = div_by_zero_reg ? (div_is_rem_reg ? div_zero_remainder : div_zero_quotient) :
div_overflow_reg ? (div_is_rem_reg ? 64'b0 : div_overflow_quotient) :
div_is_rem_reg ? div_remainder_signed : div_quotient_signed;
assign div_result = div_use_w_reg ? {{32{div_result_pre[31]}}, div_result_pre[31:0]} : div_result_pre;
最后实例化 divide 模块,并在 ex 阶段输出结果时让除法优先于乘法和普通 ALU 结果。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16ex.sv
divide u_divide(
.clk (clk),
.reset (reset),
.start (div_start),
.use_w (div_use_w),
.dividend (div_dividend),
.divisor (div_divisor),
.busy (div_busy),
.done (div_done),
.quotient (div_quotient),
.remainder (div_remainder)
);
assign alu_result = is_div ? div_result :
is_mul ? mul_result : alu_result_normal;
4. ctrl 阶段复用乘法器逻辑
除法和乘法一样是多周期执行单元,所以流水线控制可以直接复用原先乘法的 stall 策略:当 EX 阶段存在除法指令且 div_done 还没有拉高时,冻结 PC/IF_ID/ID_EX,同时向 EX/MEM 注入气泡;等 DONE 周期到来后,stall 取消,除法结果进入后级。
为了避免为乘法和除法分别写两套控制,ctrl 中将它们合并成 multi_cycle_stall: 1
2
3
4
5
6
7ctrl.sv
input u1 ex_mem_read, should_jump, commit_flush, ex_is_mul, ex_mul_done, ex_is_div, ex_div_done,
logic mul_stall, div_stall, multi_cycle_stall;
assign mul_stall = ex_is_mul && ~ex_mul_done;
assign div_stall = ex_is_div && ~ex_div_done;
assign multi_cycle_stall = mul_stall || div_stall;
之后原先使用 mul_stall 的位置统一改为 multi_cycle_stall。这样乘法和除法都遵循相同的流水线行为。 1
2
3
4
5
6ctrl.sv
assign pc_enable = global_enable && ~load_use_stall && ~ibus_stall && ~multi_cycle_stall;
assign if_id_enable = global_enable && ~load_use_stall && ~multi_cycle_stall;
assign id_ex_enable = global_enable && ~multi_cycle_stall;
assign ex_mem_flush = (global_enable && commit_flush) || (global_enable && multi_cycle_stall);
5. 信号传递复用乘法器传递逻辑
除法信号的传递链路和乘法保持一致:decoder 产生 is_div/div_op,id_ex 寄存这两个信号,最后在 core 中分别送到 ex 执行单元和 ctrl 控制单元。
首先在 decoder 中增加除法输出信号。is_div 只识别 funct7=0000001 且 funct3 为 100/101/110/111 的M扩展指令,避免和普通寄存器 ALU 指令混淆。 1
2
3
4
5
6
7
8
9
10
11
12decoder.sv
output u1 is_mul,
output u1 is_div,
output u1 is_ecall,
output u1 is_valid_inst,
output xret_t xret_type,
output mul_t mul_op,
output div_t div_op
assign is_div = ((is_alu_r || is_alu_rw) && (funct7 == 7'b0000001) &&
((funct3 == 3'b100) || (funct3 == 3'b101) ||
(funct3 == 3'b110) || (funct3 == 3'b111)));
合法指令判断中也要放开除法和取余对应的 funct3,否则这些指令会被非法指令异常截断。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20decoder.sv
7'b0110011: begin // register ALU
case (funct7)
7'b0000001: is_valid_inst = (funct3 == 3'b000) ||
(funct3 == 3'b100) ||
(funct3 == 3'b101) ||
(funct3 == 3'b110) ||
(funct3 == 3'b111); // mul/div/rem
endcase
end
7'b0111011: begin // register word ALU
case (funct7)
7'b0000001: is_valid_inst = (funct3 == 3'b000) ||
(funct3 == 3'b100) ||
(funct3 == 3'b101) ||
(funct3 == 3'b110) ||
(funct3 == 3'b111); // mulw/divw/remw
endcase
end
随后根据 funct3 生成具体的 div_op。普通64 位指令和w后缀指令使用同一套 funct3 编码,只是枚举值不同。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22decoder.sv
else if (is_alu_r) begin
if (funct7 == 7'b0000001) begin
case(funct3)
3'b100: div_op = DIV_DIV;
3'b101: div_op = DIV_DIVU;
3'b110: div_op = DIV_REM;
3'b111: div_op = DIV_REMU;
endcase
end
end
else if (is_alu_rw) begin
if (funct7 == 7'b0000001) begin
case(funct3)
3'b100: div_op = DIV_DIVW;
3'b101: div_op = DIV_DIVUW;
3'b110: div_op = DIV_REMW;
3'b111: div_op = DIV_REMUW;
endcase
end
end
id_ex 中加入 is_div/div_op 输入输出。和 is_mul 一样,is_div 属于控制信号,只有 valid_in 有效时才允许传递;bubble 时必须清零,防止无效指令误启动除法器。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23id_ex.sv
input u1 is_mul_in, is_div_in,
input mul_t mul_op_in,
input div_t div_op_in,
output u1 is_mul_out, is_div_out,
output mul_t mul_op_out,
output div_t div_op_out
always_ff @(posedge clk) begin
if (reset) begin
is_div_out <= 1'b0;
div_op_out <= DIV_NONE;
end else if (enable) begin
div_op_out <= div_op_in;
if (valid_in) begin
is_div_out <= is_div_in;
end else begin
is_div_out <= 1'b0;
end
end
end
在 core 中先声明 decoder 侧和 ID/EX 侧的除法信号,再把 decoder 实例、ID/EX 实例、EX 实例和 ctrl 实例串起来。 1
2
3
4
5
6
7
8core.sv
u1 decoder_is_mul, decoder_is_div;
mul_t decoder_mul_op;
div_t decoder_div_op;
u1 id_ex_is_mul, id_ex_is_div;
mul_t id_ex_mul_op;
div_t id_ex_div_op;
decoder 实例输出 decoder_is_div/decoder_div_op: 1
2
3
4
5
6
7core.sv
decoder u_decoder(
.is_mul (decoder_is_mul),
.is_div (decoder_is_div),
.mul_op (decoder_mul_op),
.div_op (decoder_div_op)
);
ID/EX 实例负责把 decoder 阶段的除法控制信号送到 EX 阶段。异常指令不应该启动除法器,所以 is_div_in 和 is_mul_in 一样要与 ~id_exc_valid 相与。 1
2
3
4
5
6
7
8
9
10
11
12core.sv
id_ex u_id_ex(
.is_mul_in (decoder_is_mul && ~id_exc_valid),
.is_div_in (decoder_is_div && ~id_exc_valid),
.mul_op_in (decoder_mul_op),
.div_op_in (decoder_div_op),
.is_mul_out (id_ex_is_mul),
.is_div_out (id_ex_is_div),
.mul_op_out (id_ex_mul_op),
.div_op_out (id_ex_div_op)
);
最后,id_ex_is_div/id_ex_div_op 送入 ex 真正启动除法器,ex_div_done 再回到 ctrl 参与多周期 stall 判断。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16core.sv
ctrl u_ctrl(
.ex_is_mul (id_ex_is_mul),
.ex_mul_done (ex_mul_done),
.ex_is_div (id_ex_is_div),
.ex_div_done (ex_div_done)
);
ex u_ex(
.mul_op (id_ex_mul_op),
.div_op (id_ex_div_op),
.is_mul (id_ex_is_mul),
.is_div (id_ex_is_div),
.mul_done (ex_mul_done),
.div_done (ex_div_done)
);
五、通过截图
思考时钟中断为什么使用 MMIO 计时器
因为现代的 CPU 往往是多核的,如果将计时器内置于 CPU 内部,多核之间的时间难以同步。相对地,使用 MMIO 计时器可以在外部设定一个全局统一的时间模块,保证整个系统在时间上的一致性。 同时,CPU 一旦因为省电而进入休眠状态会停止运行。如果 mtime 是 CPU 内部的 CSR 寄存器,它会停止计数,导致系统无法记录时间。相对地,MMIO 寄存器不会因为 CPU 的断电停止计时。 并且,如果将计时器放在 CPU 内部,会导致频繁的寄存器值比较以及冲刷流水线,降低 CPU 的运行效率。
而 mcycle 记录的是 CPU 的运行周期,与 CPU 的当前运行频率有关,无法衡量真实世界的时间,因此无法使用 mycycle 进行比较。
PMP
一、PMP 的背景原理
1. 组成
PMP 分成 pmpcfg 和 pmpaddr。根据 csr_file 的内容可知,本 Lab 要求实现的是单 pmpcfg + 单 pmpaddr 的组合。 ### 2. pmpaddr 是地址右移 2 位后的值。实际处理过程中需要左移两位。 ### 3. pmpcfg 单个 PMP 条目(entry)的 pmpcfg 一共 8 位,在本实现中只使用 pmpcfg0[7:0],也就是 entry 0。分别代表以下含义:
| 位 | 名称 | 含义 |
|---|---|---|
0 |
R |
允许读 |
1 |
W |
允许写 |
2 |
X |
允许取指执行 |
4:3 |
A |
地址匹配模式 |
7 |
L |
锁定位 |
其中,A 位最为重要,它决定了 CPU 应该如何处理 pmpaddr 中的值。根据 pmpcfg[4:3] 的值,一共分成 4 类:
1)A=0
表示不启用 PMP 表项,不匹配任何地址
2)A=1
表示Top of range: - 若当前 PMP 不是第0个,匹配 \((pmpaddr_{i-1} << 2) \le y < (pmpaddr_i << 2)\) - 若当前 PMP 是第0个,匹配 \(0 \le y < (pmpaddr_0 << 2)\)
3)A=2
表示Naturally aligned four-byte region,表示从 \((pmpaddr << 2)\) 开始的4字节(包含)
4)A=3
表示Naturally aligned power-of-two region,\(\ge\) 8 bytes 这时会从低位开始寻找连续1的个数,设找到n个,则表示从 (pmpaddr & ~((1 << x) - 1)) << 2 开始的\(2^{n+3}\)个字节 
二、PMP 的具体实施
1. pmp_checker
这个模块用于判断地址是否匹配,输入物理地址、访问大小、访问类型、当前特权级和 PMP 配置,输出本次访问是否允许以及对应的 access fault cause。 1
2
3
4
5
6
7
8
9
10
11
12
13
14pmp_checker.sv
module pmp_checker
import common::*;
import my_pkg::*;
(
input u64 paddr,
input msize_t access_size,
input mmu_access_t access_type,
input priv_t current_priv,
input u64 pmpcfg0, pmpaddr0,
output u1 allow,
output u64 fault_cause
);
pmpcfg0[7:0] 被拆分成权限位、地址匹配模式和锁定位。PMP 拒绝访问时按照原始访问类型产生 access fault:取指为 1,load 为 5,store 为 7。
1 | |
定义辅助函数msize_to_bytes,将枚举类型对应回具体数值
1 | |
定义辅助函数count_low_ones,检查从最后一位开始的连续1的个数
1 | |
根据 A 位的值具体对应规则。需要注意边界条件: - TOR时pmpaddr不能为0。当pmpaddr=0时,对应[0,0),区间为空。但由于数值溢出的原因,计算时反而会匹配[0,0xffff_ffff_ffff_ffff),导致错误 - 当pmpaddr全为1时,会左移64 位,属于不规范操作。因此单独处理 - 连续1的个数>=61位时,左移位数>=64,同样单独处理,解释为匹配所有地址
1 | |
权限判断遵循 M/S/U 的差异:L=0 时 M 模式不受限制;S/U 模式必须匹配 entry 并满足对应 R/W/X 权限;未匹配时 S/U 拒绝、M 模式允许。
1 | |
为了避免只检查起始地址导致跨界访问漏判,checker 先根据访存大小计算 [paddr, access_last]。只有完整访问范围落入 PMP 区间,才认为地址匹配。
1 | |
最终判断式如下 1
2
3
4
5pmp_checker.sv
assign addr_match = range_valid &&
!access_overflow &&
(paddr >= range_start) &&
(access_last <= range_end);
2. csr_file
CSR 文件把 pmpcfg0 和 pmpaddr0 作为输出暴露给顶层,并实现 L 位锁定语义。锁定后对 pmpcfg0 和 pmpaddr0 的后续写入都会被忽略,直到 reset。
1 | |
由于只实现 pmpcfg0,因此截取低8位,且5、6位保持为0 1
2
3
4
5
6
7
8
9csr_file.sv
function automatic u64 legalize_pmpcfg0(input u64 value);
begin
legalize_pmpcfg0 = {56'b0, value[7], 2'b0, value[4:0]};
end
endfunction
pmpcfg0_reg <= (csr_write_enable && (csr_write_addr == CSR_PMPCFG0) && !pmpcfg0_reg[7]) ?legalize_pmpcfg0(csr_write_data) : pmpcfg0_reg;
pmpaddr0_reg <= (csr_write_enable && (csr_write_addr == CSR_PMPADDR0) && !pmpcfg0_reg[7]) ?csr_write_data : pmpaddr0_reg;
3. core
就是普通的连线:加入 pmp_checker include;声明从 CSR 文件接出的 PMP 配置线;把配置线分别接到 csr_file 和 MMU。
1 | |
4. MMU
每一次访存请求都需要经过 pmp_checker 的判断,如果判断地址不可用,直接报错。 具体实施如下:
mmu.sv 的接口新增 pmpcfg0/pmpaddr0 输入,并新增 PMP 检查结果与 access fault cause 信号。saved_access_fault_cause 用于页表 walk 被 PMP 拒绝时按原始访问类型报告 access fault。
1 | |
mmu.sv 中一共例化五个 pmp_checker:原始请求、页表 PTE 读取、以及 L2/L1/L0 三级页表叶子命中后的最终物理地址检查。
1 | |
saved_access_fault_cause 用于把页表 walk 期间的 PMP 拒绝转换成原始取指、load、store 对应的 access fault。
1 | |
页表 walk 发出 PTE 读取请求前增加 pte_pmp_allow 门控,避免在 PMP 已拒绝的情况下继续访问总线。
1 | |
未开启分页时,MMU_IDLE 在进入 MMU_DIRECT 前检查原始物理地址。PMP 拒绝时直接进入 MMU_FAULT。
1 | |
MMU_L2 状态先检查 PTE 读取地址是否被 PMP 允许,再在 L2 叶子 PTE 命中时检查 l2_paddr。允许后才更新 saved_req.addr。
1 | |
MMU_L1 状态同样先检查 PTE 读取,再在 L1 叶子 PTE 命中时检查 l1_paddr。
1 | |
MMU_L0 状态检查 PTE 读取后,对最终 4KB 页物理地址 l0_paddr 做 PMP 检查。通过后进入 MMU_ACCESS。
1 | |
三、输出与报错
经过测试,无法通过 make test-labplus-4,程序长时间无法退出。
经过排查源码,发现错误原因是这行代码: 1
80006048: 34402373 csrr t1,mipU 模式,无权访问 mip 寄存器,应该直接报异常 解决办法也很直接,U 模式下的非法请求直接拦截报异常就行。整体上分成这几步: ### 1. 检查 U 模式实现 得益于 Lab 5 中的实现 S 模式 Bonus,直接复用已有的 xret_type 通路即可,没有代码改动
2. 检查当前 CSR 访问是否合法
分成三个部分: - 特权级别够不够 - CSR 寄存器是否存在 - CSR 寄存器是否允许写入 这些直接新开一个 csr_checker 模块来统一判断
3. 判断当前指令是否合法
除了 CSR 之外,ecall,mret 等system opcode指令需要额外检查特权级别是否足够。直接新开一个 sys_inst_checker 专门检查
四、进一步改进
1. U 模式
不用改
2. checkers
由于新增两个checker,算上刚刚完成的 pmp_checker,直接新增一个checkers文件夹单独存放这三个。
2.1 csr_checker
首先,新增 csr_checker 模块统一检查 CSR 访问是否合法,主要分成三类:CSR 是否存在、当前特权级是否足够、当前 CSR 指令是否会写入只读 CSR。这样 U 模式访问 M 态 CSR、访问未实现 CSR、写入只读 CSR 时,都能在译码阶段被归类为非法指令异常。
三种错误分别对应csr_exists,csr_priv_ok,csr_write_ok三种报错,代码如下: - csr_exists:直接根据 CSR 寄存器的名字来判断是否存在 - csr_priv_ok:当前特权级别是否大于csr_addr[9:8]位置上要求的特权级别。这可以直接比较,因为mypkg中priv_t对各个模式的数学定义与 CSR 指令规定的一致 - csr_write_ok:判断涉及到写入时,当前 CSR 寄存器是否可以写入。 - csr_read_only:通过 CSR 指令的[11:10]判断是否是只读的 - csr_write:判断是否是写入的RW类型;RS与RC需要根据zimm与rs1的值判断 代码如下:
1 | |
然后,在 core 中引入 csr_checker,把 decoder 解出的 CSR 信息和当前特权级传进去,并拿到合法性检查结果。 1
2
3
4core.sv
`include "src/checkers/csr_checker.sv"
u1 csr_checker_csr_exists, csr_checker_csr_priv_ok, csr_checker_csr_write_ok, csr_checker_csr_access_illegal;
1 | |
最后把 csr_access_illegal 接入 ID 阶段的非法指令异常生成逻辑。这样非法 CSR 访问同样属于非法指令,cause=2,可以与~decoder_is_valid_inst或在一起,并把原始指令写入 tval。
1 | |
1 | |
2.2 sys_inst_checker
新增 sys_inst_checker 模块检查非 CSR 类 system 指令的特权级是否合法。这里主要检查 mret、sret、sfence.vma 这三类指令:mret 只能在 M 模式执行,sret 和 sfence.vma 至少需要 S 模式,U 模式执行时应该触发 illegal instruction。
1 | |
在 core 中引入并例化 sys_inst_checker。它直接使用当前 IF/ID 阶段指令、decoder 已经解出的 xret_type 和当前特权级 current_priv。
1 | |
1 | |
最后把 sys_inst_access_illegal 合并到 ID 阶段的非法指令异常判断中。这样 system 指令权限不足时,会和非法 CSR 访问一样产生 cause 为 2 的 illegal instruction。
1 | |
五、依然报错
仍然陷入死循环。让 AI 帮忙看一下测试文件,发现除了 PMP 还需要实现更多内容。根据终端输出,它实际跑了 all-test-privfull.bin。根据文件名,需要实现全套特权架构。这超出了 PMP 这个 Bonus 的范畴。依据 Bonus 的 Wiki 文档,我在实现正确的 PMP 之后决定放弃通过 test-labplus-4 这个测试
## 六、参考资料 1. 知乎-RISC-V PMP 物理内存保护机制详解 2. RISC-V官方手册: The RISC-V Instruction Set Manual Volume II: Privileged Architecture
简单性能优化:分支预测与 I-Cache
一、思路:
一共有jal,jalr,branch三种跳转指令。由于jalr需要阅读寄存器内容,这涉及到forward转发。因此实现分支预测比较麻烦,准确性提升也不会很高。相对地,jal和branch都只涉及imm立即数的计算,实现方便且准确性更高。因此,先实现jal和branch的分支预测。 具体实施则是在 core 中根据当前 pc 和imm计算出预测 pc,交给 pc。ex 阶段检测是否算错,错了就 flush 掉 ex 之后的新指令。ctrl 的相关 flush 信号也需要同步改进。最后在 ex 阶段实现准确率计算。 冲刷流水线的优先级为异常/中断/csr/mret > 分支跳转
二、具体实施
1. 第一版
第一版实现了完整的静态预测、错误修正和准确率统计链路,但 ID 阶段预测信号额外依赖 ctrl_pc_enable 和 fetch_iresp.data_ok,导致很多 jal 在取指等待时无法及时发出预测,最后退化为 EX 阶段修正。
1.1 core.sv
在 core.sv 中新增分支预测相关信号。ID 阶段根据已有 decoder 输出产生预测方向和预测目标,EX 阶段再比较预测结果和真实跳转结果。
1 | |
PC 的跳转来源按优先级选择。异常、xret 等提交级重定向优先级最高,其次是 EX 阶段发现预测错误后的修正,最后才是 ID 阶段的预测跳转。
1 | |
在 decoder 实例化之后,根据当前 ID 阶段指令进行静态预测。目前只预测 jal 和 branch,预测目标为 if_id_pc + decoder_imm。
1 | |
这里的 ctrl_pc_enable 和 fetch_iresp.data_ok 限制是第一版的问题来源。取指总线等待下一条指令返回时,ID 阶段虽然已经拿到了当前跳转指令,却无法及时发出预测跳转,导致 jal 基本退化为 EX 阶段修正,准确率统计中 jump_success 异常偏低。
ctrl 接口拆分为 ID 阶段预测跳转和 EX 阶段修正跳转。预测跳转只需要清掉 IF/ID 中顺序取回的错误指令;EX 阶段修正才需要继续清掉 ID/EX。
1 | |
预测结果需要随流水线传入 EX 阶段,因此 core.sv 在实例化 id_ex 时新增预测方向和预测目标的输入输出连接。
1 | |
1.2 id_ex.sv
id_ex.sv 新增 pred_taken 和 pred_target 端口,把 ID 阶段预测结果随流水线传到 EX 阶段。
1 | |
无效指令或复位时清空预测控制信号,避免气泡在 EX 阶段被误判为真实预测。
1 | |
1.3 core.sv
EX 阶段使用已有的真实跳转结果 ex_should_jump_valid 和 ex_jump_addr,与 ID 阶段传下来的预测结果比较。如果方向或目标错误,就产生 ex_mispredict,并把 PC 修正为真实目标。
1 | |
1.4 ctrl.sv
ctrl.sv 将原来的跳转冲刷拆成 predict_redirect 和 ex_redirect。ID 阶段预测跳转只冲刷 IF/ID;EX 阶段发现预测错误或提交级重定向时才冲刷 IF/ID 和 ID/EX。
1 | |
1.5 bp_stat.sv
新增 bp_stat.sv 用于记录预测准确率。统计点放在 EX 阶段,因为此时同时拥有预测结果和真实结果。为避免流水线暂停时重复计数,模块额外接收 stall 信号。
1 | |
模块内部统计条件分支和跳转指令的总数、成功数,以及跳转目标错误次数。方向正确且目标正确时,才算预测成功。
1 | |
1 | |
在 VERILATOR 环境下,统计结果会周期性写入 bp_accuracy.log,用于后续报告中展示预测准确率。
1 | |
1 | |
1.6 core.sv
最后在 core.sv 中实例化该统计模块,将 EX 阶段的预测结果和真实结果接入。
1 | |
2. 第二版
第二版修复第一版暴露出的两个问题:第一,ID 阶段预测信号不能依赖下一条取指是否已经返回;第二,pc.sv 中新的重定向必须可以覆盖旧的 pending_jump 目标。
2.1 core.sv
去掉 id_predict_redirect 中的 ctrl_pc_enable 和 fetch_iresp.data_ok 限制。这样 ID 阶段只要已经拿到当前跳转指令,就可以立即发出预测跳转;PC 如果暂时不能更新,会由 pc.sv 中的 pending_jump 机制锁存目标。 > 但即便如此,由于ibus卡住了 pc,pc 就算收到了跳转请求也无法马上跳转。这导致第二版并没有真正改进运行速度。
1 | |
2.2 pc.sv
调整 pc.sv 中 next_pc 的优先级。新的 should_jump 优先于旧的 latched_jump,因此当 EX 阶段发现 ID 阶段旧预测错误时,可以用 EX 修正目标覆盖旧预测目标。
1 | |
3. 第三版
第二版只把指令粗略分成 branch 和 jump,但不同跳转指令的预测难点并不一样。如果全部混在一起看,就无法判断下一步应该优先做 BTFNT、BTB、BHT 还是 RAS。
| 指令/场景分类 | 新增统计项 | 为什么需要单独看 | 对后续优化的意义 |
|---|---|---|---|
| 条件分支实际跳转 | branch_taken_success |
条件分支预测失败可能来自方向判断错误,taken和not taken混在一起会掩盖问题 | 判断当前策略是否偏向不跳转,决定是否需要 BHT |
| 条件分支实际不跳转 | branch_not_taken_success |
前向条件分支常见于普通 if 路径,不跳转比例可能较高 |
和taken统计对比,判断静态策略是否失衡 |
| 后向条件分支 | branch_backward_success |
后向分支通常对应循环,BTFNT 会默认预测taken |
判断 BTFNT 是否值得加入 |
| 前向条件分支 | branch_forward_success |
前向分支通常对应跳过某段代码,BTFNT 会默认预测not taken |
判断前向分支是否应该保持顺序取指 |
直接跳转 jal |
jal_success、jal_wrong_target |
jal 目标由立即数决定,理论上最容易预测正确 |
如果该项低,说明预测时机或目标传递有问题;如果高,说明 BTB 可优先服务无条件跳转 |
间接跳转 jalr |
jalr_success、jalr_wrong_target |
jalr 目标来自寄存器,ID 阶段很难提前准确得到目标 |
判断是否需要 BTB 记录历史目标 |
| 函数调用 | call_success |
call一般会产生固定跳转目标和返回地址 | 为后续 RAS 压栈提供依据 |
| 函数返回 | ret_success |
ret目标来自返回地址寄存器,普通 BTB 可能不稳定 |
如果该项低,说明需要 RAS 预测返回地址 |
3.1 core.sv
在 core.sv 中新增统计分类信号。bp_branch_backward 使用立即数符号位区分前向/后向条件分支;bp_is_call 根据 rd=x1/x5 识别调用;bp_is_ret 根据 jalr x0, 0(x1/x5) 识别返回。
1 | |
1 | |
实例化 bp_stat 时,把新增分类信号传入统计模块。
1 | |
3.2 bp_stat.sv
bp_stat.sv 接口新增 branch_backward、is_call 和 is_ret,用于接收 core.sv 中拆出来的跳转分类。
1 | |
新增不同分类的计数器。原来的 total_branch/succ_branch 和 total_jump/succ_jump 继续保留,保证总体准确率仍然可以直接查看。
1 | |
复位时清空所有新增统计项。
1 | |
条件分支统计拆成实际跳转/实际不跳转,以及后向/前向两个维度。
1 | |
跳转指令统计拆成 jal、jalr、call和ret,方便判断目标错误主要来自哪一种跳转。
1 | |
最后把拆分后的统计项写入 bp_accuracy.log。
1 | |
这些统计项仍然统一在 EX 阶段采样,因为 EX 阶段同时拥有预测结果、真实跳转方向和真实目标地址。这样既能保留总体准确率,也能判断预测失败主要来自方向错误、目标错误,还是某一类跳转指令本身不适合当前的 ID 阶段静态预测。
4. 第四版
第四版把第二版、第三版中的“无脑预测跳转”改成 BTFNT 静态策略。ID 阶段仍然复用已有的 decoder_imm 计算预测目标,但条件分支只有在立即数为负数时才预测跳转,也就是后向分支预测taken,前向分支预测not taken。jal 仍然是无条件跳转,所以继续预测taken。
1 | |
这里删除了旧版本中 decoder_is_jal || decoder_is_branch 的策略,避免所有前向条件分支都被预测为跳转。预测结果仍然通过已有的 id_predict_redirect 送给 PC;如果预测错误,EX 阶段原有的 ex_mispredict 和 ex_correct_pc 会继续负责修正 PC 并冲刷流水线。
5. 第五版
第五版实现了虚拟 PC 索引的8B direct-mapped I-Cache。由于当前下级 dbus_resp_t.data 本身是64 位,而 CPU 前端一次只消费32 位指令,因此 I-Cache 以8B作为 line 大小,一次 miss 优先取回两条指令;命中时直接向 IF 阶段返回32 位指令,避免每条指令都进入 MMU 和下级总线等待。
5.1 新增 icache.sv
icache 模块保持 CPU 侧 ibus_req_t/ibus_resp_t 接口不变,同时在 miss 侧使用 dbus_req_t/dbus_resp_t 接入仲裁器。默认 INDEX_BITS = 10,即1024行,每行8B,数据容量8KiB。
1 | |
内部使用虚拟 PC 划分 tag/index/offset。为了兼容 PMP 边界,valid_table 每行使用2位half-valid,分别表示低 32 位和高32 位是否有效。
1 | |
命中时直接返回当前half对应的32 位指令。flush 同周期会屏蔽命中,避免 satp 或 PMP 配置变化时继续消费旧 line。
1 | |
miss 状态机优先发起8B取指。若8B fill成功,整行写入并把两个half都置为有效。
1 | |
如果8B fill因为 PMP 边界等原因异常,不能直接向 CPU 报告异常,而是降级到4B精确取当前指令。4B成功时只写入当前half,另一个half保持无效,避免旧tag残留造成假命中。
1 | |
1 | |
如果 miss 期间发生 flush,则设置 miss_killed,禁止旧 miss 返回后写入cache。
1 | |
5.2 改造 DBusArbiter
原来的取指侧接口是 ibus_req_t/ibus_resp_t,仲裁器内部固定把取指请求转成 MSIZE4,并只返回32 位。现在 I-Cache miss 侧已经生成了完整 dbus_req_t,所以仲裁器取指侧改为 dbus_req_t/dbus_resp_t,并把完整64 位响应返回给 I-Cache。
1 | |
仲裁优先级仍然是数据访存优先,I-Cache miss 其次;I-Cache miss 必须按 MMU_REQ_INST 进入 MMU,保证取指页错误和 PMP 执行权限检查仍然使用取指语义。
1 | |
1 | |
5.3 在 core.sv 中接入 I-Cache
顶层新增 csr_pkg 导入和 I-Cache 相关模块包含,用于检测 satp/PMP CSR 写入并实例化新模块。
1 | |
取指路径拆成 CPU 侧 fetch_ireq/fetch_iresp 和 miss 侧 icache_miss_req/icache_miss_resp。
1 | |
PC 侧仍然只发普通 ibus_req_t,I-Cache 在命中时直接产生 fetch_iresp,miss 时通过 icache_miss_req 访问下级。
1 | |
I-Cache 命中率统计模块接收 I-Cache 事件信号,并以 CPU 真正消费响应的 icache_access_fire 作为访问计数口径。
1 | |
虚拟 PC I-Cache 在 satp 变化、PMP 配置变化、sfence.vma 提交、trap/xret重定向时清空。这样可以避免虚拟地址空间或执行权限改变后继续命中旧 line。
1 | |
DBusArbiter 不再接 CPU 侧取指请求,而是接 I-Cache miss 侧请求;CPU 侧 fetch_iresp 完全由 I-Cache 产生。
1 | |
5.4 新增 icache_stat.sv
icache_stat 仿照 bp_stat 周期性写出 icache_stat.log,用于判断 I-Cache 命中率、miss 次数、PMP fallback次数以及 miss 等待周期。
1 | |
1 | |
1 | |
5.5 修正预测重定向与发射时序
接入 I-Cache 后,测试中发现 ID 阶段预测跳转不能在流水线冻结时提前改变 PC。否则会出现“目标地址指令被取回并提交,但跳转指令自己没有进入流水线提交”的错误。因此新增 id_predict_fire,只有当 ID/EX 允许接收当前 ID 指令时才真正发出预测重定向,并把该值写入 id_ex.pred_taken。
1 | |
1 | |
三、测试结果
1. 第一版
只实现分支预测后的结果
| 测试项 | 测试内容 | 结果 | 最短时间/ms | 单项分数 |
|---|---|---|---|---|
| qsort | Quick sort | Passed | 7 | 73057 |
| queen | Queen placement | Passed | 11 | 42790 |
| bf | Brainf**k interpreter | Passed | 62 | 38182 |
| fib | Fibonacci number | Passed | 181 | 15645 |
| sieve | Eratosthenes sieve | Passed | 112 | 35143 |
| 15pz | A* 15-puzzle search | Passed | 16 | 28037 |
| dinic | Dinic’s maxflow algorithm | Passed | 28 | 38864 |
| lzip | Lzip compression | Passed | 18 | 42183 |
| ssort | Suffix sort | Passed | 8 | 56300 |
| md5 | MD5 digest | Passed | 88 | 19589 |
| 汇总项 | 数值 |
|---|---|
| MicroBench Marks | 38979 |
| 对比基准 | 100000 Marks(i7-7700K @ 4.20GHz) |
Total time |
624 ms |
total guest instructions |
1970040897 |
| instrCnt | 1970040897 |
| cycleCnt | 16228969451 |
| IPC | 0.121390 |
| Guest cycle spent | 16228969453 |
Host time spent |
4583442 ms |
准确率如下:
| 指标 | 原始计数 | 比例 |
|---|---|---|
| branch_success | 65936775/232585661 | 28.349458% |
| jump_success | 0/53196949 | 0.000000% |
| jump_wrong_target | 27869775/53196949 | 52.389800% |
2. 第二版
经过改进,修正了jal预测正确率为0的问题
| 测试项 | 测试内容 | 结果 | 最短时间/ms | 单项分数 |
|---|---|---|---|---|
| qsort | Quick sort | Passed | 7 | 73057 |
| queen | Queen placement | Passed | 11 | 42790 |
| bf | Brainf**k interpreter | Passed | 68 | 34813 |
| fib | Fibonacci number | Passed | 181 | 15645 |
| sieve | Eratosthenes sieve | Passed | 114 | 34527 |
| 15pz | A* 15-puzzle search | Passed | 17 | 26388 |
| dinic | Dinic’s maxflow algorithm | Passed | 29 | 37524 |
| lzip | Lzip compression | Passed | 19 | 39963 |
| ssort | Suffix sort | Passed | 8 | 56300 |
| md5 | MD5 digest | Passed | 90 | 19154 |
| 汇总项 | 数值 |
|---|---|
| MicroBench Marks | 38016 |
| 对比基准 | 100000 Marks(i7-7700K @ 4.20GHz) |
Total time |
636 ms |
total guest instructions |
1970040897 |
| instrCnt | 1970040897 |
| cycleCnt | 16536774792 |
| IPC | 0.119131 |
| Guest cycle spent | 16536774794 |
Host time spent |
4799442 ms |
准确率如下:
| 指标 | 原始计数 | 比例 |
|---|---|---|
| branch_success | 166599630/232517557 | 71.650344% |
| jump_success | 25314860/53184635 | 47.598070% |
| jump_wrong_target | 27869775/53184635 | 52.401930% |
从结果上看,预测错误的代价超过了预测正确的收益。
3. 第三版
重新设计准确率预测函数,得到如下:
| 指标 | 原始计数 | 比例 |
|---|---|---|
| total_branch | 232517557 | - |
| branch_success | 166599630/232517557 | 71.650344% |
| branch_taken_success | 166599630/166599630 | 100.000000% |
| branch_not_taken_success | 0/65917927 | 0.000000% |
| branch_backward_success | 144241715/162640547 | 88.687426% |
| branch_forward_success | 22357915/69877010 | 31.996096% |
| total_jump | 53184635 | - |
| jump_success | 25314860/53184635 | 47.598070% |
| jump_wrong_target | 27869775/53184635 | 52.401930% |
| jal_success | 25314860/25314860 | 100.000000% |
| jal_wrong_target | 0/25314860 | 0.000000% |
| jalr_success | 0/27869775 | 0.000000% |
| jalr_wrong_target | 27869775/27869775 | 100.000000% |
| call_success | 12869974/12870713 | 99.994258% |
| ret_success | 0/12870710 | 0.000000% |
这一指标符合前两版的无脑跳转策略的预期。尤其是前向分支正确率只有31.99%;ret_success完全错误,这需要 BTB 或 RAS。下一版将会先尝试 BTFNT。
4. 第四版
加入BTNFT之后,分支预测终于带来了正收益。 
整理后的测试指标如下:
| 测试项 | 测试内容 | 结果 | 最短时间/ms | 单项分数 |
|---|---|---|---|---|
| qsort | Quick sort | Passed | 7 | 73057 |
| queen | Queen placement | Passed | 11 | 42790 |
| bf | Brainf**k interpreter | Passed | 64 | 36989 |
| fib | Fibonacci number | Passed | 182 | 15559 |
| sieve | Eratosthenes sieve | Passed | 113 | 34832 |
| 15pz | A* 15-puzzle search | Passed | 16 | 28037 |
| dinic | Dinic’s maxflow algorithm | Passed | 28 | 38864 |
| lzip | Lzip compression | Passed | 18 | 42183 |
| ssort | Suffix sort | Passed | 7 | 64342 |
| md5 | MD5 digest | Passed | 89 | 19369 |
| 汇总项 | 数值 |
|---|---|
| MicroBench Marks | 39602 |
| 对比基准 | 100000 Marks(i7-7700K @ 4.20GHz) |
Total time |
627 ms |
total guest instructions |
1970040897 |
| instrCnt | 1970040897 |
| cycleCnt | 16314895787 |
| IPC | 0.120751 |
| Guest cycle spent | 16314895789 |
Host time spent |
4750051 ms |
最新 bp_accuracy.log 统计如下:
| 指标 | 原始计数 | 比例 |
|---|---|---|
| total_branch | 232561549 | - |
| branch_success | 191788391/232561549 | 82.467799% |
| branch_taken_success | 144261350/166631459 | 86.575099% |
| branch_not_taken_success | 47527041/65930090 | 72.087026% |
| branch_backward_success | 144261350/162664399 | 88.686492% |
| branch_forward_success | 47527041/69897150 | 67.995678% |
| total_jump | 53192581 | - |
| jump_success | 25322806/53192581 | 47.605898% |
| jump_wrong_target | 27869775/53192581 | 52.394102% |
| jal_success | 25322806/25322806 | 100.000000% |
| jal_wrong_target | 0/25322806 | 0.000000% |
| jalr_success | 0/27869775 | 0.000000% |
| jalr_wrong_target | 27869775/27869775 | 100.000000% |
| call_success | 12869974/12870713 | 99.994258% |
| ret_success | 0/12870710 | 0.000000% |
可以看出,BTNFT牺牲了一部分 taken 分支正确率,但换来了大量 not-taken 分支正确率,净收益很明显。尤其前向分支从第三版提到的约 31.99%,提升到约 68.00%。
5. 第五版
进一步优化后,test-labplus-2 的 MicroBench 结果如下:
| 测试项 | 测试内容 | 结果 | 最短时间/ms | 单项分数 |
|---|---|---|---|---|
| qsort | Quick sort | Passed | 2 | 255700 |
| queen | Queen placement | Passed | 3 | 156900 |
| bf | Brainf**k interpreter | Passed | 19 | 124594 |
| fib | Fibonacci number | Passed | 92 | 30780 |
| sieve | Eratosthenes sieve | Passed | 29 | 135727 |
| 15pz | A* 15-puzzle search | Passed | 6 | 74766 |
| dinic | Dinic’s maxflow algorithm | Passed | 9 | 120911 |
| lzip | Lzip compression | Passed | 5 | 151860 |
| ssort | Suffix sort | Passed | 3 | 150133 |
| md5 | MD5 digest | Passed | 22 | 78359 |
| 汇总项 | 数值 |
|---|---|
| MicroBench Marks | 127973 |
| 对比基准 | 100000 Marks(i7-7700K @ 4.20GHz) |
Total time |
228 ms |
total guest instructions |
1970040961 |
| instrCnt | 1970040961 |
| cycleCnt | 5933598745 |
| IPC | 0.332015 |
| Guest cycle spent | 5933598747 |
Host time spent |
1840308 ms |
第五版对应的两个统计文件末尾指标如下。
bp_accuracy.log 最后一组完整统计如下:
| 指标 | 原始计数 | 比例 |
|---|---|---|
| total_branch | 232109725 | - |
| branch_success | 191505046/232109725 | 82.506257% |
| branch_taken_success | 144059688/166304702 | 86.623942% |
| branch_not_taken_success | 47445358/65805023 | 72.099903% |
| branch_backward_success | 144059688/162419353 | 88.696135% |
| branch_forward_success | 47445358/69690372 | 68.080219% |
| total_jump | 53110905 | - |
| jump_success | 25241128/53110905 | 47.525321% |
| jump_wrong_target | 27869777/53110905 | 52.474679% |
| jal_success | 25241128/25241128 | 100.000000% |
| jal_wrong_target | 0/25241128 | 0.000000% |
| jalr_success | 0/27869777 | 0.000000% |
| jalr_wrong_target | 27869777/27869777 | 100.000000% |
| call_success | 12869974/12870714 | 99.994251% |
| ret_success | 0/12870711 | 0.000000% |
加入 I-Cache 后,分支预测统计仍然保持在第四版 BTFNT 附近:总条件分支正确率约82.51%,后向分支正确率约88.70%,前向分支正确率约68.08%。jal 预测仍然为100%,但 jalr 和 ret 仍然没有有效目标预测。
icache_stat.log 最后一组完整统计如下:
| 指标 | 原始计数 | 比例或含义 |
|---|---|---|
| icache_access | 2012663728 | - |
| icache_hit | 2012175201/2012663728 | 99.975727% |
| icache_miss | 975820/2012663728 | 0.048484% |
| icache_fill | 975820 | - |
| icache_fill_exc | 0 | - |
| icache_fallback_4b | 0 | - |
| icache_flush | 0 | - |
| icache_stall_cycle | 6391173 | - |
| icache_avg_miss_stall | 6 | 统计文件中的整数平均值 |
| icache_avg_miss_stall_exact | 6391173/975820 | 6.549541 cycles/miss |
I-Cache 命中率约99.98%,说明MicroBench的指令局部性很强,8KiB直接映射 I-Cache 已经能够覆盖绝大多数热点取指。第五版分数显著提升,甚至超过了参考的i7-7700K @ 4.20GHz。主要原因是原先大量取指访问需要经过总线、MMU 和仲裁等待,而现在绝大多数取指可以直接在 I-Cache 中命中返回。
四、总结
本次 Bonus 优化可以分成两条主线:第一条是分支预测,第二条是取指缓存。前几版中,简单的静态预测虽然能够让部分跳转提前发生,但由于策略过于粗糙,预测错误带来的冲刷流水线代价会抵消甚至超过预测正确的收益。加入 BTFNT 后,条件分支预测开始体现出稳定正收益,总条件分支正确率稳定在约82%左右,其中后向分支接近89%,前向分支约68%,说明循环和普通前向分支的方向特征确实能被静态规则利用。不过当前 jalr 和 ret 仍然没有有效目标预测,后续如果继续优化,应优先加入 BTB 和 RAS。
第五版的主要提升来自8KiB直接映射 I-Cache。MicroBench具有很强的指令局部性,最终 I-Cache 命中率达到约99.98%,平均每次 miss 等待约6.55个周期。由于原本取指路径需要经过总线、MMU 和仲裁等待,I-Cache 命中后可以直接返回指令,前端等待大幅减少,因此 cycleCnt 从第四版的约16.31B降低到第五版的约5.93B,IPC从约0.121提升到约0.332,MicroBench分数也从39602提升到127973。这个分数超过参考i7并不表示仿真 CPU 真实性能超过i7,而是说明在该测试的 guest cycle计分口径下,取指瓶颈被显著缓解。
原子指令
一、实现目标
本部分实现了 RISC-V A 扩展中 Bonus 要求的 32 位原子指令,包括 amoswap.w、amoadd.w、amoxor.w、amoand.w、amoor.w、amomin.w、amomax.w、amominu.w、amomaxu.w,以及 lr.w、sc.w。由于当前 CPU 是单核,实现重点不是多核内存一致性,而是保证单条原子指令在流水线中不会被中断或异常拆开提交。
整体数据流为:decoder 识别原子指令并产生 is_amo/is_lr/is_sc/amo_op,通过 id_ex 和 ex_mem 传到 mem 阶段;mem 阶段用状态机完成 AMO 的读-改-写,用两个 entry 保存 LR/SC 的 reservation set;ctrl 在 AMO 中间阶段冻结流水线,防止原子操作半途被提交或打断。
二、指令类型定义
在 mypkg.sv 中增加 amo_t,用于区分不同 AMO 运算。LR.W 和 SC.W 不需要进入 amo_t,它们通过单独的 is_lr/is_sc 信号判断。
1 | |
三、译码阶段
decoder 新增原子指令输出端口,并取出 funct5 字段。A 扩展的 opcode 为 0101111,本实验只实现 .w,所以还要求 funct3 == 3'b010。
1 | |
根据 funct5 区分 LR.W、SC.W 和各类 AMO 指令。其中 lr.w 要求 rs2 字段为 0。
1 | |
在合法指令判断中加入原子指令。
1 | |
原子指令需要写回 rd,其中 lr.w 和 AMO 需要读内存,sc.w 和 AMO 需要写内存。AMO 的地址为 rs1 + 0,所以复用 ALU 加法通路,并将 imm 置 0。
1 | |
1 | |
四、流水线寄存器传递
id_ex 增加原子指令控制信号,在 reset 和 bubble 时清零,防止无效指令误触发原子访存。
1 | |
1 | |
ex_mem 同样增加原子指令控制信号,传给 mem 阶段实际执行。
1 | |
1 | |
mem_wb 增加 sc_failed 信号,用于最后连接 difftest 的 scFailed 字段。
1 | |
1 | |
五、EX 阶段异常分类
AMO 同时具有读和写语义,但 misaligned 时应按 store/AMO 类异常处理。因此 ex 增加 is_amo 输入,并在 load misaligned 判断中排除 AMO。
1 | |
1 | |
六、MEM 阶段原子操作
mem 阶段新增时序状态机、原子控制输入、sc_failed 和 atomic_busy 输出。atomic_busy 用来冻结流水线,避免 AMO 在读旧值后、写新值前被提交。
1 | |
AMO_READ 用来等待读旧值,AMO_WRITE 用来把计算后的新值写回原地址。Reservation Set 用两个 entry 保存地址,满足实验中“SC 前最多两个 LR”的假设。
1 | |
访存请求生成时,普通 load/store 保持原行为;lr.w 只读,sc.w 在 reservation 命中时才写,AMO 则先读后写。所有原子指令都以 MSIZE4 访问。
1 | |
写请求根据当前实际请求地址生成位移和 strobe。AMO 写回的是 amo_new_word,SC 成功时写入 rs2 低 32 位。
1 | |
AMO 只处理 32 位数据。写回 rd 的旧值需要符号扩展,sc.w 写回 0 表示成功、1 表示失败。
1 | |
AMO 读旧值返回后一拍进入 AMO_WRITE,此时 atomic_busy 拉高,让流水线继续冻结,确保不会出现“旧值已经写回 rd,但新值还没写回内存”的半提交状态。
1 | |
状态机在 reset 或 kill 时回到空闲。读旧值成功后保存旧值、新值和地址,随后发起写请求,写完成后回到 AMO_IDLE。
1 | |
lr.w 成功读取后设置 reservation;sc.w 无论成功或失败都会清空当前核的 reservation。普通 store 或 AMO 写入对应地址时,也会清除命中的 reservation。
1 | |
七、流水线控制
原子指令和 load 一样,写回结果到 MEM/WB 后才可靠,因此 load-use 冒险检测增加 ex_late_wb。另外 mem_atomic_busy 会压低 global_enable,让 AMO 读改写的中间周期冻结流水线。
1 | |
1 | |
八、顶层连接
core 中增加原子指令信号,并按 IF/ID、ID/EX、EX/MEM、MEM/WB 的方向传递。
1 | |
译码器实例接出原子指令信号。
1 | |
控制器实例接入 ex_late_wb 和 mem_atomic_busy。
1 | |
id_ex 实例把 decoder 的原子信号送入 EX 阶段。
1 | |
ex 实例接入 is_amo,用于异常类型判断。
1 | |
ex_mem 实例继续传递原子控制信号。
1 | |
mem 实例执行原子访存,并输出 atomic_busy 和 sc_failed。
1 | |
mem_wb 实例保存 sc_failed,最后在 difftest 提交时使用。
1 | |
1 | |
通过截图
xv6
一、相较于完整 xv6,当前 CPU 还缺什么
1. 特权架构
这涉及异常/中断处理时的特权级别切换,相对来说已经比较完善。因此首先我补完了这个部分。
1)将更多异常和中断开放给 S 态委托
原先 medeleg 只开放了 U/S 态 ecall,mideleg 仍受 include 中的空 mask 限制。为了让 xv6 在 S 态处理缺页、非法指令、访问异常以及 S 级中断,我在 csr_file 中改成使用本地可写 mask。
1 | |
写入委托寄存器时,medeleg 和 mideleg 都按新的本地 mask 保留合法位。
1 | |
2)根据 mideleg 把课程中断输入映射到 S 级 pending 位
课程顶层只提供 swint/trint/exint 三根输入,原本它们只进入 M 级 pending 位。这里在 mideleg 打开对应位时,把 M 级 pending 映射为 S 级 pending,并清掉对应的外部 M 级 pending。
1 | |
组合输出阶段先用寄存器中的 mideleg_reg 计算当前可见的 mip,在同一拍 CSR 写入修改 mideleg 后,再用新的 mideleg 重新计算一次,保证委托写入后 pending 映射能立刻反映到 CSR 输出。
1 | |
3)interrupt_unit 同时支持 M/S 两组中断
interrupt_unit 新增 mideleg 输入,用来判断 S 级中断是否已经委托。
1 | |
模块内部补齐 S 级中断位、pending、enable、delegated 和 can-take 信号。
1 | |
M 级中断沿用原本规则:当前不是 M 态时天然可被 M 态接管,当前是 M 态时需要 mstatus.MIE=1。S 级中断在 U 态天然可接管,在 S 态需要 sstatus.SIE=1。
1 | |
各类 pending、enable 和 delegated 组合成 can-take 条件。S 级中断必须满足 mideleg 对应位有效。
1 | |
最终中断 cause 从原来的 M 级 3/7/11 扩展为同时支持 S 级 1/5/9。当前接管顺序采用 M 级优先,即 MEIP -> MSIP -> MTIP -> SEIP -> SSIP -> STIP。这样当 M/S 中断同时 pending 时,会先进入 M 态 trap,避免 S 级委托中断压住 M 级中断。
1 | |
4)trap_unit 使用 mideleg 判断中断目标特权级
trap_unit 新增 csr_mideleg 输入。同步异常看 medeleg,中断看 mideleg,且只有当前不是 M 态时才允许委托到 S 态。
1 | |
重定向 PC 由目标特权级先选择 stvec/mtvec,再在后续 mtvec/stvec 小节中拆出对齐后的 base 和 mode,避免把 mode 位当作真实 PC 低位。
5)顶层接入 mideleg
core 将 CSR 中的 mideleg 接给 interrupt_unit,使中断单元能判断 S 级中断是否可接管。
1 | |
同时将 mideleg 接给 trap_unit,使提交阶段可以根据中断委托位选择写入 sepc/scause/stval 还是 mepc/mcause/mtval。
1 | |
6)mtvec/stvec 保留 mode 位,trap 跳转时再计算入口
原先为了避免软件写入 vectored mode 后把 mtvec/stvec 的 mode bit 当作 PC 低位,我在写 CSR 时直接清掉低 2 位,只保存对齐后的 trap base。这个做法可以避免跳到非对齐地址,但会改变 CSR 本身的架构可见值:例如 Lab 4 测试中写入 mtvec=0xd 时,参考模型保留 mode=1,而 CPU 会读出 0xc,导致 Difftest 的 CSR 状态对不上。
因此这里把策略改成:CSR 内部保留合法的 mode=0/1,只清掉当前不支持的 bit1;真正发生 trap 时,再在 trap_unit 中拆出 {tvec[63:2], 2'b00} 作为对齐后的 base。这样既兼容 Lab 4 对 mtvec/stvec 可见值的检查,也能让 xv6 写入 vectored mode 时不会把 mode 位误用成跳转地址。
改动完成后, Lab 4 测试仍然可以通过
1 | |
mtvec/stvec 写入路径改为调用新的合法化函数。写入时不再清 bit0,因此 mode=1 可以作为 CSR 状态被读回;bit1 仍清零,避免保留编码进入后续 trap 入口计算。
1 | |
trap_unit 中新增中间信号,先根据目标特权级选择 csr_stvec 或 csr_mtvec,再拆出对齐后的 base。若当前是 interrupt 且 mode 为 2'b01,则按 vectored mode 计算 base + 4 * cause;同步异常仍跳 base。
1 | |
最后,redirect_pc 不再直接使用 CSR 原值,而是使用已经计算好的 trap_redirect_pc。这样 CSR 里的 mode 位可以保留给 Difftest 和软件读取,PC 跳转路径仍然始终使用对齐后的入口地址。
1 | |
2. MMU
MMU 涉及的改动较少,只局限于访存部分,也比较好做
这一步主要补上 xv6 运行用户态时必须依赖的 SUM/MXR/MPRV 语义。PTE A/D 暂时不在硬件中自动写回:为了兼容 Lab 5 官方镜像和未预置 A/D 的 xv6 页表,当前 CPU 侧选择忽略 A/D 位,不再因为 A=0 或 store 时 D=0 触发 page fault。
原本也可以修改 xv6,让 xv6 在创建页表项时就把 A/D 置 1,也就是由操作系统软件预置访问位和脏位。但这样会让 CPU 侧仍依赖软件配合;而 Lab 5 官方镜像中的 mappages() 只写入 PA2PTE(pa) | perm | PTE_V,没有预置 A/D,因此硬件侧继续严格检查 A/D 会导致合法页第一次访问时被误判成 page fault。
硬件自动设置 A/D 也不是单纯改一个判断条件。MMU 在页表遍历时一旦发现 A=0 或 store 时 D=0,就需要额外发起一次页表项写回,等待总线完成,再重新尝试原始访存。这会给当前已经有多级页表遍历、PMP 检查、I/D 总线仲裁和流水线冻结的控制路径引入新的 stall 和重试状态,容易让整条流水线的提交、异常和访存副作用控制变得复杂。因此当前先跳过 PTE A/D 自动写回,也不把 A/D 作为 page fault 条件,只保留 V/R/W/X/U/SUM/MXR 和巨页对齐等关键权限检查。
1)补充 SUM/MXR 位定义
MPRV 原先已经存在,这里继续补充 SUM 和 MXR 的位号,供 CSR 和 MMU 共用。
1 | |
2)让 sstatus 暴露并可写 SUM/MXR
xv6 通常通过 sstatus 控制 SUM/MXR,所以不能只让 mstatus 写掩码包含这些位,还需要让 sstatus 的本地 mask 暴露它们。
1 | |
3)把 mstatus 接入 MMU
MMU 需要读取 MPRV/MPP/SUM/MXR,因此顶层把 CSR 文件输出的 csr_mstatus 接入 MMU。
1 | |
MMU 模块接口对应增加 mstatus 输入。
1 | |
4)计算数据访存的有效特权级
取指始终使用当前特权级;数据访存则在 MPRV=1 时使用 MPP 作为有效访存特权级。为了避免非法 MPP 值污染权限判断,我先用 legalize_priv 做一次合法化。
1 | |
PMP 检查和是否启用地址翻译都改用 effective_priv。这样 M 态设置 MPRV+MPP 后,数据访存会按目标特权级执行权限检查。
1 | |
5)锁存 SUM/MXR 并实现 S/U 页权限判断
页表遍历可能跨多个周期,因此请求进入 MMU 时需要锁存有效特权级以及当时的 SUM/MXR,保证同一次访问的权限判断稳定。
1 | |
1 | |
权限判断中,U 态仍只能访问 PTE_U=1 的页;S 态取指不能从用户页取指;S 态访问用户数据页则需要 SUM=1。
1 | |
6)实现 MXR
原先 load 只能访问 PTE_R=1 的页;加入 MXR 后,当 MXR=1 时,load 也允许读取 execute-only 页。
1 | |
3. 中间调试
做到这里发现 Lab 4,5,6都过不了了,于是开始排错
1) Lab 4:mtvec/stvec 不能在 CSR 内部直接清低两位
Lab 4 挂掉的原因是 mtvec/stvec 的 CSR 可见值和参考模型不一致。原先为了避免 trap 时跳到带 mode 位的非对齐地址,我在写 CSR 时直接清掉低两位;但 mtvec/stvec 低两位本来就是架构可见的 mode 字段,测试写入 0xd 时参考模型会保留 mode=1,CPU 若读出 0xc 就会 Difftest 失败。
因此修正为:CSR 中保留合法的 mode=0/1,只清掉不支持的 bit1;真正跳转时再由 trap_unit 拆出对齐后的 base。
1 | |
mtvec/stvec 写入路径调用新的合法化函数,保留 bit0 作为 mode。
1 | |
trap 重定向时单独计算入口地址。同步异常跳 base;中断且 mode 为 vectored 时跳 base + 4 * cause。
1 | |
1 | |
2) Lab 5:PMP 未启用时不能把 S/U 访问全部拒绝
继续跑 xv6 时,第一次进入用户态后原先报 mcause=1,也就是 instruction access fault。排查后发现不是 Sv39 地址格式错误,而是 pmpcfg0.A=0 时没有任何 PMP entry 生效,原逻辑把 S/U 态未匹配 PMP entry 当作拒绝访问。课程的 PMP 测试会在进入用户态前显式配置 PMP,但 xv6 当前启动路径没有配置 PMP,所以空 PMP 状态不应该拦截所有 S/U 访问。
这里新增 pmp_active 表示 PMP entry 是否启用。
1 | |
权限判断中,若 pmpcfg0.A=0,直接认为 PMP 未启用并放行;只有显式配置 PMP 后,才继续按 M 态特例、地址匹配和 R/W/X 权限判断。
1 | |
3) Lab 5:CPU 侧暂时放行 PTE A/D
PMP 放行后,xv6 第一次进入用户态取指不再是 access fault,而变成 mcause=12 的 instruction page fault。原因是当前 CPU 保留了 Lab 5 缺页异常中对 PTE A/D 的检查:A=0 会 page fault,store 时 D=0 也会 page fault。
但 Lab 5 官方镜像和当前 xv6 的 mappages() 都是按基础 flags 建页表,新建 leaf PTE 不一定预置 A/D。如果 CPU 继续严格检查 A/D,合法映射也会在第一次访问时反复触发 page fault。硬件自动写回 A/D 又需要额外的 PTE 写回和重试状态机,改动范围比较大。因此这里选择更小的兼容方案:CPU 侧暂时忽略 A/D 位。
修改后,A=0、D=0 不再参与 page fault 判断;但 V/R/W/X/U/SUM/MXR、非叶子项、非法 W=1 && R=0、巨页低位不对齐等检查仍然保留。
1 | |
4) Lab 5:修正 flush 后旧取指 miss 的上下文
继续跑 xv6 时,A/D 放行后不再触发 page fault,但在用户态 ecall 附近出现 0x7ffff0010 的 RAM out of range。这个地址是用户态顺序预取产生的虚拟 PC,本来应该按 U 态和用户页表走 Sv39 翻译;但旧取指 miss 可能在 trap flush 后才被 MMU 接收,如果仍使用当时全局的 M 态 current_priv,MMU 会走 direct path,把虚拟地址直接送到 RAM。
这一步补上两个约束:第一,flush 后的 I-Cache miss 不再继续向下游发请求;第二,DBus 仲裁器锁存请求发起时的特权级,MMU 使用锁存后的 mmu_core_priv 判断是否需要翻译。
首先在 core 中增加 MMU 请求对应的特权级信号。
1 | |
重定向时,PC 不能继续等待旧取指返回,否则旧 PC 会在 flush 后重新发起同一个 miss。但普通 jalr/branch 不能绕过取指响应,否则会出现“PC 已跳到目标地址,但 IF/ID 收到的还是顺序地址指令”的错配。因此这里单独增加 pc_trap_pending_jump,只允许 trap/xret 这类会 flush 前端的重定向在旧取指未返回时推进 PC。
1 | |
DBusArbiter 接收 current_priv_comb,这样 mret/sret/trap 提交同拍的新取指请求会锁存即将生效的特权级;同时将锁存后的 oreq_priv 送给 MMU。
1 | |
MMU 不再直接使用全局 current_priv,而是使用仲裁器随请求输出的 mmu_core_priv。
1 | |
DBusArbiter 接口新增 flush_ireq、current_priv 和 oreq_priv。内部增加 saved_kill_iresp 用于丢弃已经被 flush 的旧取指响应,增加 selected_priv/saved_priv 用于锁存请求发起时的特权级。
1 | |
组合选择请求时,同步选出对应的 selected_priv。取指和访存都记录当前传入的请求上下文。
1 | |
仲裁器输出请求时,若处于 busy 状态就输出已经锁存的地址、访问类型和特权级。若旧取指已经被 flush 标记为 killed,则不再上报取指异常,也不把响应送回 I-Cache。
1 | |
时序部分在接收请求时锁存 saved_priv。如果 busy 期间发生取指 flush,则只标记旧取指响应为 killed,等原请求完成后清除标记。
1 | |
最后修改 icache 的 miss 输出。IC_MISS8 和 IC_MISS4 只有在没有被 flush/kill 时才继续向下游发 miss 请求。
1 | |
1 | |
I-Cache 状态机在 miss 等待期间收到 flush 时直接回到 IC_IDLE,不再保留旧 miss 继续等待返回。
1 | |
1 | |
5) Lab 6:把异步中断收敛到提交边界处理
m_trap_test 会在 csrsi mstatus,8 打开 MIE 后立刻执行 csrci mstatus,8 关闭中断,因此 timer interrupt 只有一个很短的提交边界窗口可以被接收。原先 IF 阶段也会注入中断,加入 ICache/MMU/flush 后,这个入口容易受到前端 PC 和旧取指响应影响。因此这里关闭旧 IF 中断入口,新增提交边界中断检测,让异步中断统一以 mem_wb_pc + 4 作为 epc 进入 trap。
1 | |
旧 IF 阶段中断入口保留实例但不再接收中断,避免同一个 pending interrupt 被前端路径抢先消费。
1 | |
新增提交边界中断检测。它只在当前提交指令本身没有同步异常、也不是 xret 时生效,并且使用 csr_file 给出的 pre-trap CSR 视图判断中断是否可接收。
1 | |
csr_file 增加 pre-trap CSR 输出。这里的 pre-trap 视图表示“当前 CSR 写入已经生效,但 trap 对 CSR 的修改还没有覆盖”的状态,用来支持 csrsi mstatus,8 同拍触发 timer interrupt。
1 | |
pre-trap 视图单独用一个组合块计算,只依赖 CSR 寄存器和当前提交的 CSR 写入,不依赖 trap_commit。这样可以避免 commit_interrupt_valid -> trap_unit_trap_valid -> csr_file -> commit_interrupt_valid 的组合环。
1 | |
trap 保存 MPIE/SPIE 时也改用 pre-trap 的中断使能位。这样当 csrsi mstatus,8 同拍触发 M 态 timer interrupt 时,MPIE 保存的是已经打开后的 MIE=1。
1 | |
core 将 pre-trap 视图接到 trap_unit。异常委托判断也使用 pre-trap 的 medeleg/mideleg,避免 trap 修改后的 CSR 输出反过来影响本次 trap 选择。
1 | |
1 | |
trap_unit 新增提交边界中断输入,并把同步异常和提交边界中断统一成一组 trap_req_*。同步异常优先;没有同步异常时,才使用提交边界中断的 pc/cause/tval。
1 | |
1 | |
二、尝试跑 xv6
中间经历了多次调整
1. 将 xv6 与 CPU 对齐
为了让 xv6 能在当前 CPU 上进入内核并打印启动信息,这一阶段主要做了两类事情:先把 xv6 镜像重新编译成不含压缩指令的 rv64ima 版本,并补齐内核启动早期会访问到的 UART、CSR、中断和取指刷新行为;随后再把 fence/fence.i 作为合法空操作处理,并增加 +xv6_debug 下的 trap/xret 调试输出。
本次改动内涉及文件如下:
xv6-riscv/Makefile:去掉rv64gc,改用rv64ima/lp64生成无RVC的xv6内核。ready-to-run/xv6/kernel.bin:更新为重新生成后的无压缩指令xv6镜像。vsrc/src/basics/mem.sv:增加xv616550UART的最小兼容逻辑,把字符输出转发到课程UART地址。vsrc/src/checkers/csr_checker.sv、vsrc/src/utils/csr_file.sv:补齐xv6启动中会访问的CSR,并提供trap前CSR视图。vsrc/src/utils/trap_unit.sv、vsrc/src/core.sv:把中断移动到提交点处理,避免早期中断和流水线提交顺序冲突。vsrc/src/utils/dbus_arbiter.sv、vsrc/src/utils/icache.sv:在跳转、trap或flush后杀掉旧取指响应,并把MMU请求的特权级锁存在总线请求上。vsrc/src/basics/decoder.sv:识别fence/fence.i,当前按no-op合法指令执行。
2. 重新生成不含压缩指令的 xv6 镜像
原来的 xv6 使用 rv64gc,会生成当前 CPU 尚未实现的压缩指令。这里把内核编译选项改为 rv64ima/lp64,同时汇编文件规则也保持同样的 ISA 和 ABI。
1 | |
ready-to-run/xv6/kernel.bin 也同步替换为重新生成后的镜像,二进制大小从 30784 字节变为 38976 字节,后续仿真加载的就是这个无 RVC 版本。
3. 兼容 xv6 启动早期 UART 访问
xv6 默认访问的是 0x10000000 附近的 16550 UART,而课程环境里实际能打印字符的是 0x40600004。因此在访存阶段增加一层最小兼容:读 LSR 时直接返回 THR 空闲,写 THR 时转发到课程 UART,其余 16550 初始化寄存器写入不真正下发到总线。
1 | |
访存请求先统一生成 mem_req_valid、mem_req_write 和原始地址 raw_dreq_addr。如果命中 xv6 UART 的 THR 写,就把地址改写到课程 UART;如果是 LSR 读或其它初始化寄存器访问,则不向外部总线发真实请求。
1 | |
因为 UART LSR 读不会真的访问总线,所以读数据在 MEM 内部直接构造为 0x20,表示发送保持寄存器为空,xv6 可以继续输出字符。
1 | |
写掩码使用统一后的 mem_req_write,避免被 UART 兼容逻辑改写地址后仍然沿用旧的普通访存判断。
1 | |
MMIO 判定改为看原始地址,而不是看可能已经被 UART 转发后的 dreq_addr。
1 | |
xv6 初始化 UART 时会写 LCR 的 DLAB 位。这里保存 LCR,是为了区分 “向 THR 输出字符” 和 “DLAB 置位时写 divisor latch” 两种访问。
1 | |
4. 补齐 xv6 会访问的 CSR
xv6 启动时会访问 mcounteren、menvcfg、time 和 stimecmp。检查器里先把这些 CSR 认定为存在,避免合法 CSR 被报成非法指令。
1 | |
1 | |
CSR 文件中增加这些寄存器,同时输出一组 pre_trap_* 信号,供同一条提交指令既写 CSR 又触发 trap/interrupt 时使用。
1 | |
pre_trap_* 先从当前 CSR 值出发,再叠加本周期 CSR 写入的效果。这样中断判断和 trap 委托使用的是提交点上更准确的 CSR 状态。
1 | |
进入 trap 时,MPIE/SPIE 使用 pre_trap_mstatus 里的中断使能位,而不是旧寄存器值。
1 | |
CSR 读路径中,time 直接复用 mcycle,mcounteren/menvcfg/stimecmp 返回对应寄存器。
1 | |
时序写回中给新增 CSR 增加复位和写入逻辑。
1 | |
5. 把中断移动到提交点处理
之前中断判断在流水线较早位置,容易和正在提交的 CSR 写、异常、xret 顺序冲突。这里新增提交点中断信号,由 WB 提交时决定是否接收中断。
1 | |
旧的早期中断单元不再接收中断,新的提交点中断单元只在当前提交没有异常、也不是 xret 时生效,并使用 pre_trap_* CSR 视图。
1 | |
csr_file 实例把 trap 前 CSR 视图接出来,供提交点中断单元和 trap 单元共同使用。
1 | |
trap_unit 同时接收异常和提交点中断。异常优先;没有异常时,提交点中断可以生成 trap 请求。
1 | |
委托判断、向量偏移和最终 trap 输出都统一改用 trap_req_*,从而让异常和中断共用同一条 trap 重定向路径。
1 | |
core 中的 trap_unit 实例同步接入提交点中断,并把委托 CSR 换成 pre_trap_* 版本。
1 | |
6. 修正 trap 重定向和旧取指响应
trap/xret 产生重定向时,如果 PC 当周期不能真正更新,需要把这次 trap 跳转暂存下来,直到 IF/ID 可以接收新的 PC。否则 xv6 早期 trap 可能被流水线停顿吞掉。
1 | |
总线仲裁器增加 flush_ireq 和 current_priv 输入,并把选中的请求特权级锁存到 saved_priv。这样 MMU 看到的是发起请求时的特权级,而不是响应回来时可能已经变化的特权级。
1 | |
1 | |
当取指请求在等待响应时遇到 flush,仲裁器标记 saved_kill_iresp,后续即使旧响应回来也不再送给取指端,也不再产生对应的取指异常。
1 | |
core 里把取指 flush 和当前组合特权级接入仲裁器,再把仲裁器输出的请求特权级送给 MMU。
1 | |
I-Cache 在 miss 状态下如果遇到 flush,就不再继续发旧 miss 请求,也不会把旧 miss 响应填入缓存或返回给 IF。
1 | |
状态机也在 IC_MISS8/IC_MISS4 两个状态里优先响应 flush,直接回到 IC_IDLE。
1 | |
7. 支持 fence/fence.i 并增加 xv6 调试输出
xv6 启动路径里会出现 fence 或 fence.i。当前 CPU 暂时没有乱序和真实 I/D cache 一致性问题,所以先把 0001111 opcode 识别为合法 no-op 指令。
1 | |
1 | |
最后,为了排查 xv6 卡住时到底是在 trap 还是 xret 附近循环,core 在 Verilator 下增加 +xv6_debug 调试输出。不开 plusarg 时不影响正常输出。
1 | |
最终输出
最终终端可以打印“xv6 kernel is booting” 
三、继续排查 xv6 启动卡住问题
本轮继续排查 xv6 只能打印 xv6 kernel is booting 的问题。修改范围分为三类:一是为课程仿真器补磁盘后端和仿真器初始化修复;二是对 xv6 启动路径做临时定位与启动加速;三是在 MMU fault 分支加入临时输出,定位开启分页后的异常来源。当前结论是:执行到 kvminithart() 开启分页后,下一次串口输出访问了 0x40600004,但 xv6 内核页表没有映射该课程 UART MMIO 地址,触发 page fault;由于此时 stvec 尚未设置,异常入口为 0,最终 PC 掉到 0x0。
1. 仿真器参数初始化修复
EmuArgs 原先没有初始化 enable_jtag。这会导致不传 --enable-jtag 时也可能随机启用 JTAG,尝试监听 23334 端口并在端口冲突时 abort。这里补默认值为 false。
1 | |
2. 在 difftest RAM 中加入简化磁盘 MMIO
ram.sv 新增 simple_disk_rw DPI 接口,并记录简化磁盘的 block、buffer 地址和状态。
1 | |
在 NONE 状态的立即写路径中,捕获 0x10001000/08/10/18 四个寄存器;其中 0x10001010 写入 1 代表读,写入 2 代表写。同时屏蔽 PLIC 地址范围写入,避免 PLIC 初始化污染普通 RAM。
1 | |
同样的写处理也补到 WRITE 状态,保证带等待周期的写请求也能访问简化磁盘寄存器。
1 | |
复位时初始化磁盘寄存器,状态默认为完成。
1 | |
读路径中,PLIC 范围读返回 0;简化磁盘寄存器可以被 CPU 轮询。
1 | |
3. difftest C++ 侧实现 fs.img 读写
ram.cpp 增加 simple_disk_fp 和 open_simple_disk(),按固定顺序查找 xv6-riscv/fs.img、fs.img、ready-to-run/xv6/fs.img。
1 | |
仿真结束时关闭磁盘镜像文件。
1 | |
新增 simple_disk_rw():每个 xv6 block 按 1024 字节处理,把 guest 物理地址转换为 host RAM 偏移后读写 fs.img。
1 | |
ram.h 对外声明该 DPI 函数。
1 | |
4. xv6 内核启动加速
memset() 从逐字节写改为先构造 64 位 pattern,再按 8 字节批量写。这样可以显著降低 kinit() 清理大量 4KB 页时的周期数。
1 | |
kalloc.c 临时删除 kfree() 和 kalloc() 中用于调试悬空引用的整页填充,减少启动阶段大量无必要写内存。
1 | |
5. xv6 启动路径临时定位输出
main() 对每个启动阶段前后增加 printk(),用来确认当前卡在 kvminithart()。
1 | |
forkret() 中增加 fsinit() 和 kexec() 阶段输出,判断是否已经进入第一个进程的内核上下文。
1 | |
fsinit() 和日志恢复路径增加阶段输出,用来继续确认后续是否卡在文件系统初始化。
1 | |
1 | |
kexec() 成功后输出加载的程序入口。
1 | |
syscall() 输出前 32 次系统调用,避免成功进入用户态后仍无法判断运行位置。
1 | |
virtio_disk_rw() 输出前 64 次磁盘请求,确认是否读到了 fs.img 以及访问了哪些块。
1 | |
6. MMU 临时 fault 输出
为了定位开启分页后的异常,MMU 在 bad virtual address、PMP fault、L2/L1/L0 页表项 fault、leaf PMP fault 等分支输出访问类型、特权级、虚拟地址、PTE 地址和 PTE 内容。
1 | |
1 | |
1 | |
1 | |
1 | |
7. 当前仿真现象与下一步
重新生成 ready-to-run/xv6/kernel.bin 后,有限周期仿真可以打印到:
1 | |
随后 MMU 输出第一条关键 fault:
1 | |
这说明开启分页后,S 态 store 访问课程 UART 地址 0x40600004,但 xv6 的 kernel page table 没有映射该 MMIO 页。后续反复出现 vaddr=0 的取指 fault,是因为 trap vector 尚未设置,异常重定向到了 0。下一步应在 xv6-riscv/kernel/vm.c 或 memlayout.h 中补充课程 UART MMIO 映射,并在定位结束后删除本节标注的临时 printk() 和 MMU $display() 调试代码。
四、最终修正:让 xv6 跑到 shell
前面定位出的 0x40600004 fault 最终不是通过修改 xv6 页表解决,而是把 UART 地址转换移到仿真 RAM 外设层处理:CPU 访存阶段继续把 xv6 的物理地址 0x10000000 交给 MMU 翻译,避免在开启页表后访问 xv6 没有映射的课程 UART 地址。
1 | |
xv6 的调度器会执行 wfi,因此在译码阶段把 0x10500073 作为合法 system 指令处理。
1 | |
仿真 RAM 新增 xv6 UART 物理地址输出路径,保留原课程测试使用的 0x40600004 输出路径。
1 | |
仿真 RAM 新增简单磁盘 MMIO 寄存器,用 0x10001000 写块号、0x10001008 写缓冲区物理地址、0x10001010 写命令并同步调用 DPI 函数。
1 | |
仿真 RAM 对 xv6 访问的 PLIC 区间返回 0,避免没有实现完整 PLIC 时出现无意义内存读写。
1 | |
C++ RAM 后端打开 xv6 文件系统镜像,并把 guest 物理地址 0x80000000 之后的地址映射到仿真 RAM 指针中读写 1024 字节块。
1 | |
1 | |
为了避免 JTAG 开关未初始化导致仿真器意外监听端口,EmuArgs 构造函数里显式关闭 JTAG。
1 | |
定位完成后删除了 xv6 内核中的阶段性 printk()、系统调用 trace、磁盘 trace,以及 MMU 中的临时 $display() fault 输出;这些只是调试信息,不属于最终实现路径。
五、总结
1. 对 xv6 源码的假设与对应修改
假设课程 CPU 当前只支持 rv64ima,因此 xv6 使用 rv64ima 编译,避免生成压缩指令和当前工具链不支持的 zicsr/zifencei 写法。
1 | |
假设仿真器不实现完整 virtio-mmio block device,因此 xv6 磁盘驱动改成同步简单 MMIO 协议。
1 | |
假设仿真 UART 没有真实 TX 中断,因此 uartwrite() 改为轮询 LSR 后直接写 THR,避免控制台输出睡眠等待中断。
1 | |
假设启动性能比内存填充调试更重要,因此 memset() 用 64 位批量写提升清零速度。
1 | |
假设本实验不依赖 xv6 用 junk pattern 检查释放页和新分配页,因此去掉 kfree()/kalloc() 中的大块填充以减少启动周期。
1 | |
2. 如何从头编译 xv6 并用本 CPU 运行
先在 xv6-riscv 目录生成文件系统镜像和内核 ELF。
1 | |
如果看到 /bin/sh: 1: qemu-system-riscv64: not found,但后面 riscv64-unknown-elf-ld 成功生成了 kernel/kernel,这条 qemu 探测提示可以忽略,因为这里不使用 qemu 运行 xv6。
把 xv6 内核 ELF 转成 CPU 仿真器加载的裸二进制镜像。
1 | |
回到项目根目录重新编译 CPU 仿真器。
1 | |
用有限周期运行 xv6,必须带 --max-cycles,防止 shell 等输入时无限跑。
1 | |
成功现象是看到 xv6 启动并进入 shell 提示符。
1 | |
如果之后出现 EXCEEDING CYCLE/INSTR LIMIT,并且 $ 已经打印出来,表示 xv6 已进入 shell 后等待输入,有限周期退出是预期行为。
六、上板
为了复用 Lab 3 的上板流程,先把 xv6 的裸二进制内核转换成 Vivado BRAM IP 可以加载的 64 位宽 .coe 文件。该文件由 ready-to-run/xv6/kernel.bin 生成,当前大小约 81KB,对应的 kernel.bin 约 38KB,可以放入现有深度为 21000、宽度为 64 位的 BRAM。
1 | |
修改 Vivado 的 bram_0 IP 配置,让初始化文件从 Lab 5 镜像切换到 xv6 镜像。
1 | |
板上原有 UART 外设只识别课程测试地址 0x40600004/0x40600008,因此新增 xv6 原生 UART 地址常量,分别对应 THR 和 LSR。
1 | |
为了让 xv6 的 uartwrite() 轮询可以继续执行,板上 device 在读取 XV6_UART_LSR 时返回 0x20,表示 TX idle。
1 | |
发送条件同时兼容原课程 UART 地址和 xv6 UART THR 地址;如果 UART 发送状态机忙,则阻止重复投递字符。
1 | |
原课程 UART 写字符位于 wdata[39:32],xv6 UART 写 THR 时字符位于 wdata[7:0],因此根据地址选择不同字节作为发送数据。
1 | |
Vivado 仿真输出分支也同步兼容 xv6 UART 地址,便于在 behavioral simulation 中直接观察 xv6 输出。
1 | |
Vivado 中也可以通过图形界面修改同一个 BRAM 初始化位置:在 IP Sources 中右键 bram_0,选择 Re-customize IP,在初始化文件选项里选择 ready-to-run/xv6/kernel.coe,之后重新生成 output products、综合、实现和 bitstream。直接修改 .xci 的好处是配置可以随仓库保存,后续重新打开工程时路径可追踪。
第一次上板尝试
synthesis 通过,implementation 报错 
从 Vivado 报错看,implementation 失败的主因不是语法错误或 .coe 文件格式错误,而是目标 FPGA 资源不足:当前设计需要 16595 个 F7 Mux,但 Basys3 目标器件只有 16300 个可用 F7 Mux,因此 DRC 阶段直接失败,placer 没有继续执行。
1 | |
同时还有一个次要约束问题:Basys-3-Master.xdc 第 132 行约束了 RsRx,但当前 basys3_top 只导出了 RsTx,没有 RsRx 输入端口,因此 Vivado 找不到该端口对象;这不是本次 implementation 失败的主因,但会产生 critical warning,后续可以注释该约束或补一个未使用的 RsRx 端口。
1 | |
如果需要小幅减少资源占用,优先选择不影响“打印 xv6 启动信息”的功能裁剪:第一优先级是去掉板上暂时不需要的完整磁盘路径,因为当前上板阶段只追 xv6 kernel is booting,不追进入 shell;第二优先级是裁剪 AMO/原子指令路径,因为早期内核打印阶段通常不依赖完整原子指令;第三优先级是裁剪复杂 PMP/权限检查或调试输出相关逻辑;最后才考虑裁剪 MMU、CSR、trap 或 mret/sret,因为这些是 xv6 正常进入 S 态和后续用户态的核心路径。
1 | |
本次实际先选择裁剪 PMP 检查器,而不是裁剪 AMO 或 MMU:PMP 逻辑包含 64 位范围比较、NAPOT 地址范围计算、权限选择等组合逻辑,容易消耗 F7 Mux;同时 xv6 早期启动通常只需要 PMP 放行 S 态访问内存,因此板上路径可以先简化为 always allow,而 Verilator 路径仍保留完整 PMP 逻辑,尽量不影响已有测试。
1 | |
完整 PMP 检查代码被包进 VERILATOR 分支中,因此软件仿真、difftest 和之前的实验测试仍使用原来的 PMP 行为;Vivado 上板综合时只综合简化分支,以减少资源占用。
1 | |
第二次上板尝试
第二次尝试中,Vivado 已经使用简化后的 PMP 检查器重新 synthesis,但 implementation 仍然在 DRC 阶段失败,并且不再只是 F7 Mux 略微超出,而是 LUT、寄存器和多级 mux 等多类资源均明显超过 Basys3 目标器件容量。
1 | |
从这些数据可以看出,当前完整 labplus CPU 设计已经远超 Basys3 板卡资源:Slice LUT 约为可用资源的 2.8 倍,Slice Register 约为可用资源的 3.1 倍。PMP 简化属于低风险的小幅裁剪,无法改变整体资源规模。因此结论是:当前目标板 FPGA 资源不足,无法在保持现有完整功能的前提下完成 xv6 上板。