集创赛CPU有关笔记
7月7日更新
CPU代码仓库:
https://github.com/Stomatra/RV32Final
CPU工程基本结构
myCPU的外部接口:
//IROM 取指接口
output logic [11:0] irom_addr,//CPU 当前要取的指令
input logic [31:0] irom_data,//IROM 返回的 32 位指令
//perip 数据/外设访问接口
output logic [31:0] perip_addr,//load/store 访问的数据地址
output logic perip_wen,//是否写
output logic [1:0] perip_mask,//写字节/半字/字
output logic [31:0] perip_wdata,//写入数据
input logic [31:0] perip_rdata//读回数据CPU的五个阶段(五级流水线):
IF :取指
ID :译码 + 读寄存器,只决定“这条指令要干什么”
EX :执行 ALU / 分支 / CSR / M扩展 / trap 为真正计算的阶段
MEM :访存
WB :写回寄存器,写回 x0~x31
IF 阶段
IF 阶段主要是取指阶段,主要是从 IROM 中取出指令,计算下一条指令的地址。
核心寄存器:
logic [31:0] pc_q;
logic [31:0] pc_next;PC更新逻辑:
// IF 级 PC 更新优先级:redirect > stall/hold > 顺序 +4。
always_comb begin
if (ex_pc_redirect) begin
//如果 EX 阶段要求跳转,PC = 跳转目标
//包括:branch_taken jal jalr ecall mret mul_helper
pc_next = ex_pc_target;
end else if (load_use_hazard || pc_ex_hazard || pc_mem_hazard || mem_load_stall || m_stall) begin
//如果存在相关冒险或停顿,PC 保持不变
pc_next = pc_q;
end else begin
//顺序执行,PC + 4
pc_next = pc_q + 32'd4;
end
end
// IF 级 PC 寄存器,在 reset 时初始化为 RESET_PC。
always_ff @(posedge cpu_clk or posedge cpu_rst) begin
if (cpu_rst) begin
pc_q <= RESET_PC;
end else begin
pc_q <= pc_next;
end
endIF/ID 寄存器负责把当前取到的指令送到 ID 阶段:
ifid_pc <= pc_q;
ifid_instr <= irom_data;但遇到 load hazard、mem stall、div stall 时会保持。
ID 阶段
ID 阶段主要是指令译码阶段,负责解析指令的操作类型、操作数以及目标寄存器。ID 阶段还会读取寄存器文件中的源操作数,并生成控制信号供后续阶段使用。
ID 阶段主要干三件事:
- 从指令中拆出 opcode / rd / rs1 / rs2 / funct3 / funct7
assign opcode = instr[6:0];
assign rd = instr[11:7];
assign funct3 = instr[14:12];
assign rs1 = instr[19:15];
assign rs2 = instr[24:20];
assign funct7 = instr[31:25];- 从寄存器堆读取 rs1 / rs2
- 根据 opcode 生成控制信号
ID 阶段的主控制器就是这个大 always_comb:
always_comb begin
id_uses_rs1 = 1'b0;
id_uses_rs2 = 1'b0;
id_rf_we = 1'b0;
id_wb_sel = WB_SRC_ALU;
...
if (ifid_valid) begin
case (id_opcode)
...
endcase
end
end这段负责告诉后面的流水线:
- 这条指令用不用 rs1
- 用不用 rs2
- 写不写 rd
- 写回来源是什么
- ALU 做什么
- 是不是访存
- 是不是 CSR
- 是不是 M 扩展
ID/EX 流水寄存器
把指令信息送到 EX。ID 阶段译码后,不能直接执行,而是存入 ID/EX 寄存器。
always_ff @(posedge cpu_clk or posedge cpu_rst) begin
if (cpu_rst) begin
idex_valid <= 1'b0;
idex_pc <= 32'h0;
idex_rs1 <= 5'h0;
idex_rs2 <= 5'h0;
...
end else if (mem_load_stall || m_stall) begin
// hold IDEX - memory read stall
end else if (ex_pc_redirect || load_use_hazard || pc_ex_hazard || pc_mem_hazard) begin
idex_valid <= 1'b0;
idex_pc <= 32'h0;
idex_rs1 <= 5'h0;
idex_rs2 <= 5'h0;
...
end else if (id_mul_helper_hit) begin
idex_valid <= 1'b1;
idex_pc <= ifid_pc;
idex_rs1 <= 5'h0;
idex_rs2 <= 5'h0;
...
end else begin
idex_valid <= ifid_valid;
idex_pc <= ifid_pc;
idex_rs1 <= id_rs1;
idex_rs2 <= id_rs2;
...
end
end它的作用是:
ID 阶段:这条指令要干什么
ID/EX 保存:下一拍 EX 阶段照着这些信号执行
遇到 flush 时,这些信号会被清零。遇到 mem_load_stall || div_stall 时,ID/EX 会保持不动。
EX 阶段
EX 阶段主要是执行阶段,负责进行算术运算、逻辑运算、分支判断等操作。EX 阶段会根据 ID 阶段传递过来的控制信号和操作数,执行相应的操作,并将结果传递给 MEM 阶段。
目前EX阶段主要负责:
- ALU 运算
- branch 判断
- jal/jalr 目标地址
- CSR 读写计算
- ecall/mret trap 跳转
- RV32M 乘除法结果选择
- store 写数据准备
ALU 输入选择
EX 阶段先通过前递逻辑得到 ex_rs1_val 和 ex_rs2_val。
if (ex_fwd_rs1_from_exmem) begin
ex_rs1_val = exmem_wb_data;
end else if (ex_fwd_rs1_from_memwb) begin
ex_rs1_val = memwb_wdata;
endALU 运算
ALU #(32) u_alu (
.A(ex_alu_a),
.B(ex_alu_b),
.ALUOp(idex_alu_op),
.Result(ex_alu_y)
);普通 RV32I 的 ADD、SUB、AND、OR、XOR、SLT、SLL、SRL、SRA 都走这里。
分支和跳转
分支比较在 EX 阶段做:
if (idex_pc_sel == PC_SRC_BRANCH) begin
ex_cmp_eq = (ex_pc_rs1_val == ex_pc_rs2_val);
ex_cmp_lt_signed = ($signed(ex_pc_rs1_val) < $signed(ex_pc_rs2_val));
ex_cmp_lt_unsigned = (ex_pc_rs1_val < ex_pc_rs2_val);
end然后根据 BEQ/BNE/BLT/BGE 等决定 ex_br_take。
PC 跳转目标统一由 ex_pc_target 给出:
if (ex_trap_redirect) begin
ex_pc_target = ex_trap_target;
end else if (idex_mul_helper) begin
ex_pc_target = idex_mul_helper_ra;
end else begin
case (idex_pc_sel)
PC_SRC_BRANCH: ex_pc_target = ex_br_take ? ex_pc_plus_imm : ex_pc4;
PC_SRC_JAL: ex_pc_target = ex_pc_plus_imm;
PC_SRC_JALR: ex_pc_target = ex_jalr_target;
default: ex_pc_target = ex_pc4;
endcase
end真正决定要不要跳的是 ex_pc_redirect:
if (ex_trap_enter || ex_trap_return) begin
ex_pc_redirect = 1'b1;
end else if (idex_mul_helper) begin
ex_pc_redirect = 1'b1;
end else begin
case (idex_pc_sel)
PC_SRC_BRANCH: if (ex_br_take) ex_pc_redirect = 1'b1;
PC_SRC_JAL: ex_pc_redirect = 1'b1;
PC_SRC_JALR: ex_pc_redirect = 1'b1;
endcase
end一句话:所有改变 PC 的东西,最后都归到 ex_pc_redirect + ex_pc_target
MEM 阶段
MEM 阶段主要是访存阶段,负责对数据存储器进行读写操作。MEM 阶段会根据 EX 阶段传递过来的控制信号和地址,进行相应的访存操作,并将结果传递给 WB 阶段。
load/store 不直接访问 DRAM,而是通过 perip_bridge。
CPU 对外的访存接口是:
assign perip_addr = exmem_alu_y;
assign perip_wen = exmem_valid && exmem_mem_req && exmem_mem_write;
assign perip_mask = exmem_mem_mask;
assign perip_wdata = exmem_store_data;
`perip_bridge` 里面把地址分成:
```SystemVerilog
DRAM:0x8010_0000 ~ 0x8013_FFFF
SW0 :0x8020_0000
SW1 :0x8020_0004
KEY :0x8020_0010
SEG :0x8020_0020
LED :0x8020_0040
CNT :0x8020_0050这些地址在 perip_bridge.sv 里定义。
外设桥里所有读源都统一打一拍,因此 CPU 这边每次 load 会插入 mem_load_stall。perip_bridge 注释里明确写了 CPU 看到的 perip_rdata 是“地址给出后一拍有效”。
所以 CPU 里有:
assign mem_load_stall = exmem_valid && exmem_mem_req && !exmem_mem_write && !mem_stall_flag;意思是:
每遇到 load,额外停一拍,等 perip_rdata 稳定
WB 阶段
WB 阶段主要负责将计算结果或访存结果写回寄存器文件。
MEM 阶段之后,结果进入 MEM/WB。
最终写回 RF 的是:
.wen (memwb_rf_we && memwb_valid),
.waddr (memwb_rd),
.wdata (memwb_wdata)mem_wb_data 的选择是:
assign mem_wb_data = (exmem_wb_sel == WB_SRC_MEM) ? mem_load_data : exmem_wb_data;也就是说:
如果是 load,写回 perip_rdata 处理后的数据,否则,写回 EX 阶段已经算好的 exmem_wb_data。
这个 CPU 的主线就是:PC 取指 → ID 译码生成控制信号 → ID/EX 保存 → EX 执行 ALU/CSR/trap/RV32M → EX/MEM 访存或传递结果 → MEM/WB 写回寄存器。
重要变量
rd
rd 是指令的目标寄存器编号,位于指令的 [11:7] 位。它表示当前指令执行后,结果将写入哪个寄存器。
rs1
rs1 是指令的第一个源寄存器编号,位于指令的 [19:15] 位。它表示当前指令需要读取的第一个操作数所在的寄存器。
rs2
rs2 是指令的第二个源寄存器编号,位于指令的 [24:20] 位。它表示当前指令需要读取的第二个操作数所在的寄存器。
funct3
funct3 是指令的功能码,位于指令的 [14:12] 位。它用于区分同一类指令的不同操作,例如在 ALU 指令中,funct3 可以表示加法、减法、与、或等操作。
funct7
funct7 是指令的功能扩展码,位于指令的 [31:25] 位。它用于进一步区分同一类指令的不同操作,通常与 funct3 结合使用,以确定具体的操作类型。
opcode
opcode 是指令的操作码,位于指令的 [6:0] 位。它用于确定指令的类型和操作类别,例如 ALU 指令、跳转指令、访存指令等。
冒险处理
普通ALU运算冒险
前递
前递(forwarding)是解决数据冒险的一种方法,通过将尚未写回寄存器文件的结果直接传递给需要使用该结果的指令,从而避免等待写回阶段。
假设有两条指令:
add x3, x1, x2
sub x4, x3, x5当 sub 指令到达 EX 阶段时,x3 的值还没有写回寄存器文件,但可以直接从 EX/MEM 阶段的 exmem_wb_data 前递过来,这样 sub 就可以立即使用 x3 的值进行计算,而不必等待写回阶段完成。
普通 ALU 运算冒险主要发生在 EX 阶段,当指令需要使用前一条指令的结果时,如果结果尚未写回寄存器文件,就会产生数据冒险。
普通数据冒险主要靠 forwarding。
代码里先定义了四组比较:
ex_match_rs1_exmemex_match_rs1_memwbex_match_rs2_exmemex_match_rs2_memwb
这些信号用于判断:
当前 EX 阶段要用的 rs1/rs2, 是不是等于后面 EX/MEM 或 MEM/WB 阶段即将写回的 rd。
相关信号定义在 EX 级 forwarding 区。
判断是否可以前递:
assign exmem_can_forward = exmem_rf_we && (exmem_rd != 5'h0) && (exmem_wb_sel != WB_SRC_MEM);
assign memwb_can_forward = memwb_rf_we && (memwb_rd != 5'h0);这句话的意思是:
EX/MEM 可以前递:
- 必须会写寄存器
- rd 不能是 x0
- 不能是 load,因为 load 数据此时还没回来
MEM/WB 可以前递:
- 只要会写寄存器且 rd 不是 x0
然后根据命中结果决定 ex_rs1_val / ex_rs2_val:
ex_rs1_val = idex_rs1_val;
if (ex_fwd_rs1_from_exmem) begin
ex_rs1_val = exmem_wb_data;
end else if (ex_fwd_rs1_from_memwb) begin
ex_rs1_val = memwb_wdata;
endrs2 同理。
sub 到 EX 阶段时,x3 可以直接从 exmem_wb_data 前递过来。
ID 阶段同拍旁路:MEM/WB → ID
除了 EX forwarding,ID 阶段还有一个小旁路:
if (id_rs1 == 5'd0) begin
id_rs1_val = 32'h0;// x0 永远是 0
end else if (memwb_can_forward && (memwb_rd == id_rs1)) begin
id_rs1_val = memwb_wdata;// MEM/WB → ID 旁路
end else begin
id_rs1_val = rf_rs1_raw;// 正常读寄存器堆
endrs2 也一样。
它解决的是这种情况:
add x3, x1, x2
nop
nop
sub x4, x3, x5当第一条指令正在 WB 写回时,ID 阶段直接拿 memwb_wdata,避免因为寄存器堆读写同拍顺序不确定导致读到旧值。
Load-use 冒险
Load-use 冒险主要发生在 EX 阶段,当一条指令需要使用前一条 load 指令的结果时,如果结果尚未从数据存储器中读取出来,就会产生数据冒险。
load 比普通 ALU 麻烦,因为 load 的数据要到 MEM/WB 附近才有效,EX/MEM 阶段还拿不到正确数据,所以不能像 ALU 一样直接前递。
典型例子:
lw x3, 0(x1)
add x4, x3, x5add 下一拍就要用 x3,但 lw 的数据还没从 perip_rdata 回来,所以必须停。
代码里检测 load-use:
assign load_use_hazard = ifid_valid && idex_valid && idex_rf_we &&
(idex_wb_sel == WB_SRC_MEM) && (idex_rd != 5'h0) &&
((id_uses_rs1 && (id_rs1 == idex_rd)) ||
(id_uses_rs2 && (id_rs2 == idex_rd)));翻译一下:
- ID/EX 里是一条 load;
- 它会写 rd;
- 当前 IF/ID 这条指令要用 rs1 或 rs2;
- 并且 rs1/rs2 等于 load 的 rd;
- 那么就产生 load_use_hazard。
处理方式是:
- PC 保持
- IF/ID 保持
- ID/EX 插 bubble
PC 保持在这里:
else if (load_use_hazard || pc_ex_hazard || pc_mem_hazard || mem_load_stall || m_stall) begin
pc_next = pc_q;
endIF/ID 保持在这里:
else if (load_use_hazard || pc_ex_hazard || pc_mem_hazard || mem_load_stall || m_stall) begin
ifid_pc <= ifid_pc;
ifid_instr <= ifid_instr;
endelse if (!load_use_hazard && !pc_ex_hazard && !pc_mem_hazard && !mem_load_stall && !m_stall) begin ifid_pc <= pc_q; ifid_instr <= irom_data; end
ID/EX 插 bubble 在这里:
else if (ex_pc_redirect || load_use_hazard || pc_ex_hazard || pc_mem_hazard) begin
idex_valid <= 1'b0;
...
idex_rf_we <= 1'b0;
idex_mem_req <= 1'b0;
idex_is_m_ext <= 1'b0;
end这就是经典的:load-use stall 一拍 + 插入空泡
MEM load stall
这个工程里,DRAM/MMIO 读数据不是立刻回来,而是统一打一拍。所以只要 EX/MEM 阶段是 load,就要额外等一拍。
代码:
assign mem_load_stall = exmem_valid && exmem_mem_req && !exmem_mem_write && !mem_stall_flag;意思是:
- EX/MEM 是有效访存;
- 它是 load,不是 store;
- 而且这一拍还没 stall 过;
- 那么 mem_load_stall = 1。
mem_stall_flag 用来保证每个 load 只额外停一拍,不会无限停。
处理方式:
- PC hold
- IF/ID hold
- ID/EX hold
- EX/MEM hold
- MEM/WB hold
其中 ID/EX 在 mem_load_stall 时保持。 MEM/WB 在 mem_load_stall 时也保持旧值,避免错误写回。
这和 load-use 不一样:
load_use_hazard:为了下一条用 load 结果,插 bubble mem_load_stall:为了等 perip_rdata 稳定,整体 hold 一拍
branch / jalr 的 PC 操作数冒险:现在选择“等”,不前递
普通 ALU 可以从 EX/MEM 前递,但 branch/jalr 的 PC 选择链为了提频,不再吃 EXMEM 前递:
assign ex_pc_fwd_rs1_from_exmem = 1'b0;
assign ex_pc_fwd_rs1_from_memwb = 1'b0;
assign ex_pc_fwd_rs2_from_exmem = 1'b0;
assign ex_pc_fwd_rs2_from_memwb = 1'b0;也就是说,分支和 JALR 不再用复杂前递链去抢最新数据,而是在 ID 阶段发现不安全时直接等。
两个 hazard 定义在这里:
assign pc_ex_hazard = ifid_valid && idex_valid && idex_rf_we &&
(idex_wb_sel != WB_SRC_MEM) && (idex_rd != 5'h0) &&
((((id_pc_sel == PC_SRC_BRANCH) || (id_pc_sel == PC_SRC_JALR)) && (id_rs1 == idex_rd)) ||
((id_pc_sel == PC_SRC_BRANCH) && (id_rs2 == idex_rd)));
assign pc_mem_hazard = ifid_valid && exmem_valid && exmem_rf_we &&
(exmem_wb_sel != WB_SRC_MEM) && (exmem_rd != 5'h0) &&
((((id_pc_sel == PC_SRC_BRANCH) || (id_pc_sel == PC_SRC_JALR)) && (id_rs1 == exmem_rd)) ||
((id_pc_sel == PC_SRC_BRANCH) && (id_rs2 == exmem_rd)));翻译一下:
pc_ex_hazard:
- 当前 IF/ID 中的指令是 branch 或 jalr(
id_pc_sel == PC_SRC_BRANCH/JALR) - 且使用的操作数 rs1 或 rs2(分支用两个,jalr 用 rs1)恰好是 ID/EX 中正在执行的指令的目标寄存器(
idex_rd) - 且该寄存器是有效写回的(
idex_rf_we && idex_wb_sel != WB_SRC_MEM) - 问题:branch/jalr 需要在 EX 阶段就能拿到 rs1/rs2 的值来判断跳转,但这个值还在 ID/EX 中没有计算出来
- 产生冒险的条件:branch/jalr 的 rs1/rs2 等于 ID/EX 的 rd,且 ID/EX 会写回寄存器,且不是 load。
pc_mem_hazard:
- 当前 IF/ID 中的指令是 branch 或 jalr
- 使用的 rs1 或 rs2 恰好是 EX/MEM 中指令的目标寄存器(
exmem_rd) - 且该寄存器是有效写回的
- 问题:值还在 EX/MEM 中,还没有进入 MEM 阶段完成,
- 产生冒险的条件:branch/jalr 的 rs1/rs2 等于 EX/MEM 的 rd,且 EX/MEM 会写回寄存器,且不是 load。
它解决的是:
add x1, x2, x3
beq x1, x0, label或者:
add x1, x2, x3
jalr x0, 0(x1)这种指令如果不等,branch/jalr 在 EX 阶段可能拿旧 x1 算跳转结果。
现在处理方式是:
pc_ex_hazard 或 pc_mem_hazard 出现:
- PC hold
- IF/ID hold
- ID/EX 插 bubble
PC hold 在 PC 更新逻辑里。 IF/ID valid 也在 pc_ex_hazard || pc_mem_hazard 时保持。 ID/EX 在这两个 hazard 时清 bubble。
普通 ALU 数据冒险:前递 分支/jalr 的 PC 数据冒险:等待
这是为了缩短 PC redirect 关键路径。
M 扩展多周期冒险:m_stall
新版代码把 M 扩展做成了一个小状态机:
- m_inflight:M 指令正在执行
- m_result_ready:M 指令结果已经准备好
- m_stall:当前 ID/EX 是 M 指令但结果还没好
相关信号在 EX 级定义区。
启动逻辑:
assign m_start = idex_valid && idex_is_m_ext && !m_inflight && !m_result_ready && !ex_pc_redirect;
assign mul_start = m_start && ex_m_is_mul;
assign div_start = m_inflight && m_is_div_reg && !m_div_started;
assign m_stall = idex_valid && idex_is_m_ext && !m_result_ready;意思是:
M 指令进入 EX 后,先启动 M 状态机; 结果没好之前,m_stall=1; m_stall=1 时 PC/IFID/IDEX hold; EX/MEM 插 bubble; 结果好了后,放行一拍,把 ex_m_result 写入后级。
//PC hold:
else if (load_use_hazard || pc_ex_hazard || pc_mem_hazard || mem_load_stall || m_stall) begin
pc_next = pc_q;
end
//IF/ID hold:
else if (!load_use_hazard && !pc_ex_hazard && !pc_mem_hazard && !mem_load_stall && !m_stall) begin
ifid_pc <= pc_q;
ifid_instr <= irom_data;
end
//ID/EX hold:
else if (mem_load_stall || m_stall) begin
// hold IDEX
end
//EX/MEM 在 m_stall 时清 bubble:
else if (m_stall) begin
exmem_valid <= 1'b0;
exmem_rf_we <= 1'b0;
exmem_mem_req <= 1'b0;
...
endM 指令在 EX 等结果时,后级不能反复收到同一条指令,所以 EX/MEM 要清空。
store 数据冒险
store 有两个源:
rs1:地址基址 rs2:要写入内存的数据
代码里 id_uses_rs2 = 1,所以 store 的 rs2 也会参与 forwarding/hazard 判断。
EX 阶段 store 数据来自:
assign ex_store_data = idex_mem_write ? ex_rs2_val : 32'h0;而 ex_rs2_val 已经经过 EX forwarding,所以这种情况一般不用停。
add x3, x1, x2
sw x3, 0(x4)sw 到 EX 阶段时,写入数据 x3 可以从 EX/MEM 或 MEM/WB 前递得到。
但如果是:
lw x3, 0(x1)
sw x3, 0(x4)这仍会被 load_use_hazard 检测到,因为 store 使用 rs2,且 load 的 rd 等于 store 的 rs2。
总结
| 冒险类型 | 例子 | 处理方法 | 代码位置 |
|---|---|---|---|
| 普通 ALU 数据冒险 | add x3,... 后接 sub x4,x3,... | EX/MEM 或 MEM/WB forwarding | ex_fwd_rs*_from_* 和 ex_rs*_val |
| WB 同拍读冒险 | WB 写 x3,ID 同拍读 x3 | MEM/WB → ID 旁路 | id_rs1_val/id_rs2_val |
| load-use 冒险 | lw x3,... 后接 add ...,x3,... | PC/IFID hold,IDEX bubble | load_use_hazard |
| load 读延迟 | 任意 load | mem_load_stall 额外等一拍 | mem_load_stall/mem_stall_flag |
| branch/jalr 操作数冒险 | add x1,... 后接 beq x1,... | 不走 PC 前递,等待数据稳定 | pc_ex_hazard/pc_mem_hazard |
| 控制冒险 | branch taken / jal / jalr / ecall / mret | ex_pc_redirect + flush | ex_pc_target/ex_pc_redirect |
| M 扩展多周期冒险 | mul/div/rem | m_stall hold 前级,EX/MEM bubble | m_start/m_inflight/m_result_ready/m_stall |
