文章摘要
加载中...|
此内容根据文章生成,并经过人工审核,仅用于文章内容的解释与总结 投诉

集创赛CPU有关笔记

7月7日更新

CPU代码仓库

https://github.com/Stomatra/RV32Final

笔记的用法以及查阅的基本方式

在笔记中可以看到各方面的内容,在阅读时建议一边阅读一边看代码,比如观察各个指令的流动方式等,这样可以更好帮助理解。并且要善于在VScode和浏览器中运用Ctrl+F查找关键字,快速定位到相关内容。

CPU工程基本结构

myCPU的外部接口:

SystemVerilog
//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 中取出指令,计算下一条指令的地址。

核心寄存器:

SystemVerilog
logic [31:0] pc_q;
logic [31:0] pc_next;

PC更新逻辑:

SystemVerilog
// 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
end

IF/ID 寄存器负责把当前取到的指令送到 ID 阶段:

SystemVerilog
ifid_pc    <= pc_q;
ifid_instr <= irom_data;

但遇到 load hazard、mem stall、div stall 时会保持。

ID 阶段

ID 阶段主要是指令译码阶段,负责解析指令的操作类型、操作数以及目标寄存器。ID 阶段还会读取寄存器文件中的源操作数,并生成控制信号供后续阶段使用。

ID 阶段主要干三件事:

  1. 从指令中拆出 opcode / rd / rs1 / rs2 / funct3 / funct7
SystemVerilog
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];
  1. 从寄存器堆读取 rs1 / rs2
  2. 根据 opcode 生成控制信号

ID 阶段的主控制器就是这个大 always_comb:

SystemVerilog
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 寄存器。

SystemVerilog
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_valex_rs2_val

SystemVerilog
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;
end

ALU 运算

SystemVerilog
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 阶段做:

SystemVerilog
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 给出:

SystemVerilog
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

SystemVerilog
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 对外的访存接口是:

SystemVerilog
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_stallperip_bridge 注释里明确写了 CPU 看到的 perip_rdata 是“地址给出后一拍有效”。

所以 CPU 里有:

SystemVerilog
assign mem_load_stall = exmem_valid && exmem_mem_req && !exmem_mem_write && !mem_stall_flag;

意思是:

每遇到 load,额外停一拍,等 perip_rdata 稳定

WB 阶段

WB 阶段主要负责将计算结果或访存结果写回寄存器文件。

MEM 阶段之后,结果进入 MEM/WB。

最终写回 RF 的是:

SystemVerilog
.wen   (memwb_rf_we && memwb_valid),
.waddr (memwb_rd),
.wdata (memwb_wdata)

mem_wb_data 的选择是:

SystemVerilog
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)是解决数据冒险的一种方法,通过将尚未写回寄存器文件的结果直接传递给需要使用该结果的指令,从而避免等待写回阶段。

假设有两条指令:

asm
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 区。

判断是否可以前递:

SystemVerilog
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:

SystemVerilog
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;
end

rs2 同理。

sub 到 EX 阶段时,x3 可以直接从 exmem_wb_data 前递过来。

ID 阶段同拍旁路:MEM/WB → ID

除了 EX forwarding,ID 阶段还有一个小旁路:

SystemVerilog
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;// 正常读寄存器堆
end

rs2 也一样。

它解决的是这种情况:

asm
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 一样直接前递。

典型例子:

asm
lw  x3, 0(x1)
add x4, x3, x5

add 下一拍就要用 x3,但 lw 的数据还没从 perip_rdata 回来,所以必须停。

代码里检测 load-use:

SystemVerilog
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 保持在这里:

SystemVerilog
else if (load_use_hazard || pc_ex_hazard || pc_mem_hazard || mem_load_stall || m_stall) begin
    pc_next = pc_q;
end

IF/ID 保持在这里:

SystemVerilog
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;
end

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 插 bubble 在这里:

SystemVerilog
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,就要额外等一拍。

代码:

SystemVerilog
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 操作数冒险(内容已过时,7月10日)

普通 ALU 可以从 EX/MEM 前递,但 branch/jalr 的 PC 选择链为了提频,不再吃 EXMEM 前递:

SystemVerilog
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 不再用复杂前递链去抢最新数据。

两个 hazard 定义在这里:

SystemVerilog
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。

它解决的是:

asm
add x1, x2, x3
beq x1, x0, label

或者:

asm
add x1, x2, x3
jalr x0, 0(x1)

这种指令如果不等,branch/jalr 在 EX 阶段可能拿旧 x1 算跳转结果。

现在处理方式是:

pc_ex_hazardpc_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 级定义区。

启动逻辑:

SystemVerilog
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 写入后级。

SystemVerilog
//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;
    ...
end

M 指令在 EX 等结果时,后级不能反复收到同一条指令,所以 EX/MEM 要清空。

store 数据冒险

store 有两个源:

rs1:地址基址 rs2:要写入内存的数据

代码里 id_uses_rs2 = 1,所以 store 的 rs2 也会参与 forwarding/hazard 判断。

EX 阶段 store 数据来自:

SystemVerilog
assign ex_store_data = idex_mem_write ? ex_rs2_val : 32'h0;

ex_rs2_val 已经经过 EX forwarding,所以这种情况一般不用停。

asm
add x3, x1, x2
sw  x3, 0(x4)

sw 到 EX 阶段时,写入数据 x3 可以从 EX/MEM 或 MEM/WB 前递得到。

但如果是:

asm
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 forwardingex_fwd_rs*_from_*ex_rs*_val
WB 同拍读冒险WB 写 x3,ID 同拍读 x3MEM/WB → ID 旁路id_rs1_val/id_rs2_val
load-use 冒险lw x3,... 后接 add ...,x3,...PC/IFID hold,IDEX bubbleload_use_hazard
load 读延迟任意 loadmem_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 / mretex_pc_redirect + flushex_pc_target/ex_pc_redirect
M 扩展多周期冒险mul/div/remm_stall hold 前级,EX/MEM bubblem_start/m_inflight/m_result_ready/m_stall

7月8日更新

rpt文件怎么看

路径为digital_twin.runs\impl_1\top_timing_summary_routed.rpt

WNS

WNS在Vivdao中可以直接显示,无需在文件中查找。

几个字段含义:

  • WNS:Worst Negative Slack,最差路径余量
  • TNS:Total Negative Slack,所有违规路径 slack 之和
  • Failing Endpoints:失败的终点数量

判断标准:

  • WNS > 0:过时序
  • WNS = 0 附近:勉强过,很危险
  • WNS < 0:没过时序,需要优化
  • TNS < 0:说明不止一条路径有问题

Max Delay Paths简介

text
Max Delay Paths
--------------------------------------------------------------------------------------
Slack (MET) :             16.534ns  (required time - arrival time)
  Source:                 uart_inst/rx_data_reg[2]/C
                            (rising edge-triggered cell FDCE clocked by clk_out1_pll  {[email protected] [email protected] period=20.000ns})
  Destination:            twin_controller_inst/status_buffer_reg[0][7]/CE
                            (rising edge-triggered cell FDCE clocked by clk_out1_pll  {[email protected] [email protected] period=20.000ns})
  Path Group:             clk_out1_pll
  Path Type:              Setup (Max at Slow Process Corner)
  Requirement:            20.000ns  (clk_out1_pll [email protected] - clk_out1_pll [email protected])
  Data Path Delay:        3.136ns  (logic 0.309ns (9.853%)  route 2.827ns (90.147%))
  Logic Levels:           2  (LUT5=1 LUT6=1)
  Clock Path Skew:        -0.055ns (DCD - SCD + CPR)
    Destination Clock Delay (DCD):    4.202ns = ( 24.202 - 20.000 ) 
    Source Clock Delay      (SCD):    4.589ns
    Clock Pessimism Removal (CPR):    0.331ns
  Clock Uncertainty:      0.074ns  ((TSJ^2 + DJ^2)^1/2) / 2 + PE
    Total System Jitter     (TSJ):    0.071ns
    Discrete Jitter          (DJ):    0.129ns
    Phase Error              (PE):    0.000ns

    Location             Delay type                Incr(ns)  Path(ns)    Netlist Resource(s)
  -------------------------------------------------------------------    -------------------
                         (clock clk_out1_pll rise edge)
                                                      0.000     0.000 r  
    AD12                                              0.000     0.000 r  i_sys_clk_p (IN)
                         net (fo=0)                   0.000     0.000    pll_inst/inst/clk_in1_p
    AD12                 IBUFDS (Prop_ibufds_I_O)     0.906     0.906 r  pll_inst/inst/clkin1_ibufgds/O
                         net (fo=1, routed)           2.299     3.205    pll_inst/inst/clk_in1_pll_buf
    BUFGCTRL_X0Y3        BUFG (Prop_bufg_I_O)         0.093     3.298 r  pll_inst/inst/clkin1_bufg1/O
                         net (fo=1, routed)           1.959     5.257    pll_inst/inst/clk_in1_pll
    PLLE2_ADV_X1Y0       PLLE2_ADV (Prop_plle2_adv_CLKIN1_CLKOUT0)
                                                     -4.519     0.738 r  pll_inst/inst/plle2_adv_inst/CLKOUT0
                         net (fo=1, routed)           2.467     3.205    pll_inst/inst/clk_out1_pll
    BUFGCTRL_X0Y1        BUFG (Prop_bufg_I_O)         0.093     3.298 r  pll_inst/inst/clkout1_buf/O
                         net (fo=512, routed)         1.291     4.589    uart_inst/CLK
    SLICE_X17Y200        FDCE                                         r  uart_inst/rx_data_reg[2]/C
  -------------------------------------------------------------------    -------------------
    SLICE_X17Y200        FDCE (Prop_fdce_C_Q)         0.223     4.812 f  uart_inst/rx_data_reg[2]/Q
                         net (fo=27, routed)          0.629     5.440    uart_inst/rx_data[2]
    SLICE_X17Y201        LUT6 (Prop_lut6_I2_O)        0.043     5.483 r  uart_inst/status_buffer[0][7]_i_2/O
                         net (fo=10, routed)          0.591     6.075    uart_inst/status_buffer[0][7]_i_2_n_0
    SLICE_X16Y203        LUT5 (Prop_lut5_I2_O)        0.043     6.118 r  uart_inst/status_buffer[0][7]_i_1/O
                         net (fo=144, routed)         1.607     7.725    twin_controller_inst/E[0]
    SLICE_X27Y208        FDCE                                         r  twin_controller_inst/status_buffer_reg[0][7]/CE
  -------------------------------------------------------------------    -------------------

                         (clock clk_out1_pll rise edge)
                                                     20.000    20.000 r  
    AD12                                              0.000    20.000 r  i_sys_clk_p (IN)
                         net (fo=0)                   0.000    20.000    pll_inst/inst/clk_in1_p
    AD12                 IBUFDS (Prop_ibufds_I_O)     0.803    20.803 r  pll_inst/inst/clkin1_ibufgds/O
                         net (fo=1, routed)           2.173    22.976    pll_inst/inst/clk_in1_pll_buf
    BUFGCTRL_X0Y3        BUFG (Prop_bufg_I_O)         0.083    23.059 r  pll_inst/inst/clkin1_bufg1/O
                         net (fo=1, routed)           1.796    24.855    pll_inst/inst/clk_in1_pll
    PLLE2_ADV_X1Y0       PLLE2_ADV (Prop_plle2_adv_CLKIN1_CLKOUT0)
                                                     -4.213    20.642 r  pll_inst/inst/plle2_adv_inst/CLKOUT0
                         net (fo=1, routed)           2.334    22.976    pll_inst/inst/clk_out1_pll
    BUFGCTRL_X0Y1        BUFG (Prop_bufg_I_O)         0.083    23.059 r  pll_inst/inst/clkout1_buf/O
                         net (fo=512, routed)         1.143    24.202    twin_controller_inst/clk_out1
    SLICE_X27Y208        FDCE                                         r  twin_controller_inst/status_buffer_reg[0][7]/C
                         clock pessimism              0.331    24.534    
                         clock uncertainty           -0.074    24.460    
    SLICE_X27Y208        FDCE (Setup_fdce_C_CE)      -0.201    24.259    twin_controller_inst/status_buffer_reg[0][7]
  -------------------------------------------------------------------
                         required time                         24.259    
                         arrival time                          -7.725    
  -------------------------------------------------------------------
                         slack                                 16.534

首先使用Ctrl+F搜索 VIOLATED,找到失败路径。

  1. Source / Destination

定位关键路径属于哪个模块、哪一组寄存器,快速定位代码位置。

这是最重要的两个字段。

比如:

Source:      xxx_reg[3]/C
Destination: yyy_reg[5]/D

意思是:

从 xxx_reg 输出 经过一堆组合逻辑 到 yyy_reg 输入

要根据名字判断它属于 CPU 哪一段。

常见名字对应:

  • ifid_* :IF/ID 流水寄存器
  • idex_* :ID/EX 流水寄存器
  • exmem_* :EX/MEM 流水寄存器
  • memwb_* :MEM/WB 流水寄存器
  • pc_q ` :PC 寄存器
  • csr_* :CSR 寄存器
  • m_* :M 扩展乘除法状态机
  1. Data Path Delay 信号从起点 FF 输出到终点 FF 输入的总延时,分为两部分:
  • logic:组合逻辑门延时(与代码层级、乘法器、加法器深度相关) logic 占比高:组合逻辑太复杂,应该拆逻辑/加流水
  • route:FPGA 布线走线延时(资源拥挤、跨区域布线会暴增)route 占比高:连线太长/扇出太大,应该减少扇出/复制寄存器/让逻辑更局部
  1. Logic Levels 组合逻辑级数,建议控制在 8 级以内;超过 12 级大概率出现负 Slack。
  2. Clock Skew 时钟偏斜 发射时钟比捕获时钟晚到,会吃掉 Slack;负值偏斜危害更大。
  3. Requirement 单时钟域 = 时钟周期;跨时钟域 = 两个时钟沿最小间隔。

如何根据路径定位到 CPU 代码

看到路径名之后,要把它映射到 CPU 流水线。

比如路径里出现:

  • Core_cpu/idex_rs1_val_reg
  • Core_cpu/exmem_wb_data_reg

这大概率是:

ID/EX → EX → EX/MEM

也就是 EX 阶段太长。

EX 阶段代码主要包括:

  • forwarding
  • ALU 输入选择
  • ALU 运算
  • branch 判断
  • CSR 读写
  • M 扩展结果选择
  • ex_wb_data 选择

现在 forwarding 的主路径在这里:

SystemVerilog
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;
end

rs2 同理。

EX 阶段写回数据选择在这里:

SystemVerilog
if (idex_mul_helper) begin
    ex_wb_data = ex_mul_helper_result;
end else if (idex_is_m_ext) begin
    ex_wb_data = ex_m_result;
end else begin
    case (idex_wb_sel)
        WB_SRC_PC4:   ex_wb_data = ex_pc4;
        WB_SRC_IMM_U: ex_wb_data = idex_imm;
        WB_SRC_CSR:   ex_wb_data = ex_csr_rdata;
        WB_SRC_ALU:   ex_wb_data = ex_alu_y;
    endcase
end

所以如果 timing path 是:

idex_rs1_val_reg → exmem_wb_data_reg

就重点怀疑:

  • forwarding mux
  • ALU
  • M 扩展选择
  • CSR 选择
  • ex_wb_data mux

看 rpt 时的实战流程

你以后拿到一个新的 .rpt,按这个顺序看:

  1. 看 WNS/TNS:WNS < 0:没过;WNS 很小:勉强过,还要优化

  2. 看 Clock Summary:找 CPU clock,也就是 clk_out2_pll

  3. 看 Intra Clock Table:找 clk_out2_pll 的 WNS

  4. 看 Source / Destination:判断是 IF、ID、EX、MEM、WB 哪一段

  5. 看 Data Path Delay:logic 高:拆组合逻辑;route 高:减扇出/减少远距离连接

  6. 看 Netlist Resource(s):找路径经过了哪些 mux、LUT、DSP、carry、寄存器

  7. 回到 myCPU.sv 对应代码修改

CPU 常见路径和优化方法对照表

rpt 路径特征可能对应代码优化方向
idex_* → exmem_wb_dataEX 阶段 ALU / forwarding / wb mux拆 EX,减少 mux,ALU 分拍
idex_* → pc_qbranch/jalr/trap redirect简化 redirect,提前计算 target,减少 PC forwarding
m_mul_uu_reg → ex_m_result_regMULH/MULHSU 高位修正再打一拍,拆 signed correction
exmem_* → memwb_*MEM/load 数据处理简化 load byte/half 选择,打一拍
memwb_wdata → id_rs*_valID 同拍旁路简化 ID bypass 或接受多停一拍
stall signal → many registersstall/flush 控制拆分 stall 信号,降低扇出
route 占比 > 70%布线问题减扇出、复制寄存器、层次化布局
logic levels 很多组合逻辑过深pipeline、predecode、拆 mux

一个完整例子

跑 220MHz,Vivado 报:

text
WNS = -0.420ns

Source:
student_top_inst/Core_cpu/idex_rs1_val_reg[7]/Q

Destination:
student_top_inst/Core_cpu/exmem_wb_data_reg[7]/D

Data Path Delay:
4.78ns
logic 3.65ns
route 1.13ns

Logic Levels:
12

Resources:
idex_rs1_val_reg
ex_rs1 forwarding mux
ALU
ex_wb_data mux
exmem_wb_data_reg

应该这样分析:

  1. Source 是 idex_rs1_val,Destination 是 exmem_wb_data
  2. 说明路径是 ID/EX → EX → EX/MEM
  3. 不是 PC path,也不是 MEM path
  4. logic 占 3.65ns,说明组合逻辑太深
  5. 经过 forwarding mux + ALU + wb mux

方案 1:把 EX 拆两拍 EX1:forwarding + 操作数选择 EX2:ALU + wb_data 选择

方案 2:移位指令多周期 因为 barrel shifter 可能很长

方案 3:简化 ex_wb_data mux 把 CSR/M/ALU/PC4 结果提前分开寄存

方案 4:如果是 M 指令路径 让 M 指令完全走 m_result_ready,不和普通 ALU 共用同一拍大 mux

优化 CPU 时就按这个规则

  • 看到 idex → exmem:优化 EX 阶段
  • 看到 exmem → memwb:优化 MEM 阶段
  • 看到 idex/exmem → pc_q:优化跳转/分支路径
  • 看到 m_* → m_*:优化乘除法状态机
  • 看到 route 占比高:减少扇出和长线
  • 看到 logic 占比高:拆组合逻辑或加流水

CPU 内部各类指令的流动

总览

  1. U 型指令:LUI / AUIPC
  2. 跳转指令:JAL / JALR
  3. 分支指令:BEQ / BNE / BLT / BGE / BLTU / BGEU
  4. Load 指令:LB / LH / LW / LBU / LHU
  5. Store 指令:SB / SH / SW
  6. 普通整数运算:ADDI... / ADD...
  7. CSR + trap:CSRRW... / ecall / mret
  8. RV32M:MUL... / DIV...

CPU 里基本都是按这条主线流动:

IF 取指 → ID 译码 → EX 执行 → MEM 访存 → WB 写回

U型指令

LUI

asm
lui rd, imm

作用:

rd = imm << 12

也就是直接把高 20 位立即数放进寄存器。

流动过程:

SystemVerilog
localparam logic [6:0]  OPC_LUI       = 7'b0110111; // LUI 指令,主要负责加载高 20 位立即数到寄存器。

localparam logic [2:0]  WB_SRC_IMM_U  = 3'd3;// 写回数据选择 U 型立即数。

//IF:取到 LUI 指令

mycpu_rv32_decode u_dec (
	.instr  (ifid_instr),
	.opcode (id_opcode),
	.funct3 (id_funct3),
	.funct7 (id_funct7),
	.rd     (id_rd),
	.rs1    (id_rs1),
	.rs2    (id_rs2)
);

//ID:识别 opcode = LUI,设置 `id_rf_we = 1`,`id_wb_sel = WB_SRC_IMM_U`

if (ifid_valid) begin
	case (id_opcode)
		OPC_LUI: begin
			id_rf_we  = 1'b1;
			id_wb_sel = WB_SRC_IMM_U;
		end
        //...
    endcase
end

//EX:不需要真正 ALU 运算,直接准备 U 型立即数

assign id_imm           = (id_opcode == OPC_BRANCH) ? {{19{ifid_instr[31]}}, ifid_instr[31], ifid_instr[7], ifid_instr[30:25], ifid_instr[11:8], 1'b0} :
							(id_opcode == OPC_JAL)    ? {{11{ifid_instr[31]}}, ifid_instr[31], ifid_instr[19:12], ifid_instr[20], ifid_instr[30:21], 1'b0} :
							id_imm_raw;//id_imm_raw 是 U 型立即数

//MEM:不访存
//WB:把立即数写回 rd

always_comb begin
	if (idex_mul_helper) begin
		ex_wb_data = ex_mul_helper_result;
	end else if (idex_is_m_ext) begin
		ex_wb_data = ex_m_result;
	end else begin
		case (idex_wb_sel)
			WB_SRC_IMM_U: ex_wb_data = idex_imm;
            //...
		endcase
	end
end

代码里 LUI 在 ID 主控制器里设置 id_rf_we=1,写回来源是 WB_SRC_IMM_U。

AUIPC

asm
auipc rd, imm

作用:

rd = PC + (imm << 12)

常用于生成基于 PC 的地址。

流动过程:

  • IF:取指
  • ID:识别 AUIPC,设置 ALU A = PC,ALU B = U 型立即数
  • EX:ALU 计算 PC + imm
  • MEM:不访存
  • WB:写回 rd

AUIPC 在 ID 阶段设置 id_alu_src_a_sel = ALU_SRC_A_PCid_alu_src_b_sel = ALU_SRC_B_IMM_U

跳转指令:JAL / JALR

JAL

asm
jal rd, offset

作用:

rd = PC + 4,PC = PC + offset

常用于函数调用。

流动过程:

  • IF:取到 JAL
  • ID:识别 JAL,设置写回 PC+4,同时设置 pc_sel = JAL
  • EX:计算跳转目标 PC + imm
  • EX:产生 ex_pc_redirect
  • IF:下一拍 PC 跳到目标地址
  • WB:rd 写入 PC+4

JAL 在 ID 阶段设置 id_wb_sel = WB_SRC_PC4,并设置 id_pc_sel = PC_SRC_JAL

JALR

asm
jalr rd, imm(rs1)

作用:

rd = PC + 4,PC = (rs1 + imm) & ~1

常用于函数返回,比如:

asm
jalr x0, 0(ra)

流动过程:

  • IF:取到 JALR
  • ID:读 rs1
  • EX:计算 rs1 + imm,并把最低位置 0
  • EX:产生 redirect
  • WB:rd 写入 PC+4

JALR 在 ID 阶段会使用 rs1,并设置 PC_SRC_JALR。 EX 阶段专门计算 ex_jalr_sum = ex_pc_rs1_val + idex_imm,再得到 ex_jalr_target

分支指令:BEQ / BNE / BLT / BGE / BLTU / BGEU

这些都是 B 型指令。

指令作用
beq rs1, rs2, offset相等则跳转
bne rs1, rs2, offset不相等则跳转
blt rs1, rs2, offset有符号小于则跳转
bge rs1, rs2, offset有符号大于等于则跳转
bltu rs1, rs2, offset无符号小于则跳转
bgeu rs1, rs2, offset无符号大于等于则跳转

流动过程:

  • IF:取分支指令
  • ID:读 rs1、rs2,设置 pc_sel = BRANCH
  • EX:比较 rs1 和 rs2
  • EX:如果满足条件,ex_pc_redirect = 1
  • IF:下一拍 PC 跳到 branch target
  • WB:不写寄存器

分支在 ID 阶段设置 id_uses_rs1=1id_uses_rs2=1id_pc_sel=PC_SRC_BRANCH

EX 阶段先比较:

rs1 == rs2signed(rs1) < signed(rs2)unsigned(rs1) < unsigned(rs2)

然后根据 funct3 判断是否跳转。

Load 指令:LB / LH / LW / LBU / LHU

指令作用
lb读 1 字节,有符号扩展
lh读 2 字节,有符号扩展
lw读 4 字节
lbu读 1 字节,无符号扩展
lhu读 2 字节,无符号扩展

流动过程:

  • IF:取 load 指令
  • ID:读 rs1,生成立即数
  • EX:ALU 计算地址 rs1 + imm
  • MEM:通过 perip_bridge 读 DRAM/MMIO
  • MEM:因为读数据晚一拍,所以 mem_load_stall 停一拍
  • WB:把读出的数据写回 rd

Load 在 ID 阶段设置:

id_uses_rs1 = 1id_rf_we = 1id_wb_sel = WB_SRC_MEMid_mem_req = 1

并根据 funct3 判断 LB/LH/LW/LBU/LHU。

load 数据在 MEM 阶段根据 funct3 做字节/半字扩展,比如有符号扩展或零扩展。

Store 指令:SB / SH / SW

指令作用
sb写 1 字节
sh写 2 字节
sw写 4 字节

流动过程:

  • IF:取 store 指令
  • ID:读 rs1 作为地址基址,读 rs2 作为写入数据
  • EX:ALU 计算地址 rs1 + imm
  • MEM:通过 perip_bridge 写 DRAM/MMIO
  • WB:不写寄存器

Store 在 ID 阶段设置:

id_uses_rs1 = 1id_uses_rs2 = 1id_mem_req = 1id_mem_write = 1

并根据 funct3 判断 SB/SH/SW。

EX 阶段 store 数据来自:

SystemVerilog
assign ex_store_data = idex_mem_write ? ex_rs2_val : 32'h0;

也就是说,真正写出去的是 EX 阶段准备好的 ex_rs2_val

I 型整数运算:OPIMM

指令作用
addird = rs1 + imm
slti有符号比较,rs1 < imm 则 rd=1
sltiu无符号比较
xori异或
ori
andi
slli逻辑左移
srli逻辑右移
srai算术右移

流动过程:

  • IF:取指
  • ID:读 rs1,生成 I 型立即数
  • EX:ALU 执行运算
  • MEM:不访存
  • WB:写回 rd

OPIMM 在 ID 阶段设置:

id_uses_rs1 = 1id_rf_we = 1id_wb_sel = WB_SRC_ALUALU A = rs1ALU B = imm

然后根据 funct3 选择 ALU 操作。

R 型整数运算:OP

指令作用
addrd = rs1 + rs2
subrd = rs1 - rs2
sll左移
slt有符号小于比较
sltu无符号小于比较
xor异或
srl逻辑右移
sra算术右移
or
and

流动过程:

  • IF:取指
  • ID:读 rs1、rs2
  • EX:ALU 运算
  • MEM:不访存
  • WB:写回 rd

普通 OP 指令在 ID 阶段设置:

id_uses_rs1 = 1id_uses_rs2 = 1id_rf_we = 1id_wb_sel = WB_SRC_ALU

如果 funct7 != 0000001,就按普通 ALU 指令译码。

CSR 指令

当前支持 6 条 zicsr 指令:

指令作用
csrrw rd, csr, rs1rd = old csr,csr = rs1
csrrs rd, csr, rs1rd = old csr,csr = old csr
csrrc rd, csr, rs1rd = old csr,csr = old csr & ~rs1
csrrwi rd, csr, uimmrd = old csr,csr = uimm
csrrsi rd, csr, uimmrd = old csr,csr = old csr
csrrci rd, csr, uimmrd = old csr,csr = old csr & ~uimm

目前主要 CSR 是:

mstatusmtvecmepcmcause

流动过程:

  • IF:取 CSR 指令
  • ID:识别 SYSTEM,判断是哪种 CSR 操作
  • ID:准备 csr_addr 和 csr_wdata
  • EX:读取旧 CSR 值 ex_csr_rdata
  • EX:计算新 CSR 值 ex_csr_wdata
  • EX:旧 CSR 值作为 ex_wb_data
  • MEM 的同时 CSR always_ff:真正更新 CSR 寄存器,同时MEM不访存
  • WB:rd 写入旧 CSR 值

CSR 指令在 ID 阶段的译码在 OPC_SYSTEM 分支里。

EX 阶段先根据 idex_csr_addr 读 CSR:

mstatus / mtvec / mepc / mcause

然后计算写入值。

ecall / mret

这两个不是普通 CSR 指令,而是 trap 控制指令。

ecall

作用:

  • 进入 trap
  • mepc = ecall 指令自己的 PC
  • mcause = 11
  • mstatus.MPIE = mstatus.MIE
  • mstatus.MIE = 0
  • PC = mtvec

流动过程:

  • IF:取 ecall
  • ID:识别 ifid_instr == 32'h00000073
  • EX:产生 ex_trap_enter
  • EX:ex_pc_redirect = 1,目标 = mtvec
  • CSR:更新 mepc/mcause/mstatus
  • IF/ID、ID/EX:flush
SystemVerilog
assign id_is_ecall = ifid_valid && (ifid_instr == 32'h0000_0073);
//负责识别 ecall 指令

assign ex_trap_enter = idex_valid && idex_is_ecall && !mem_load_stall;
//负责进入trap状态

assign ex_trap_redirect = ex_trap_enter || ex_trap_return;
    assign ex_trap_target =
        ex_trap_enter  ? {csr_mtvec[31:2], 2'b00} :
        ex_trap_return ? csr_mepc :
                         32'h0;
//其中ex_trap_enter成立,target=mtvec

if (ex_trap_enter || ex_trap_return) begin
    ex_pc_redirect = 1'b1;
//设置redirect

end else if (ex_trap_enter) begin
            // trap 进入:MPIE <= MIE, MIE <= 0, MPP <= M 模式
            csr_mstatus[7]      <= csr_mstatus[3];
            csr_mstatus[3]      <= 1'b0;
            csr_mstatus[12:11]  <= 2'b11;
            csr_mepc           <= idex_pc;
            csr_mcause         <= 32'd11;         // ECALL from M-mod

//负责:
//mepc = ecall 指令自己的 PC
//mcause = 11
//mstatus.MPIE = mstatus.MIE
//mstatus.MIE = 0

if (ex_trap_redirect) begin
    ex_pc_target = ex_trap_target;

//这里决定PC跳转至mttvec

ecall 在 ID 阶段用完整机器码识别。

mret

作用:

  • 从 trap 返回
  • PC = mepc
  • mstatus.MIE = mstatus.MPIE
  • mstatus.MPIE = 1

流动过程:

  • IF:取 mret
  • ID:识别 ifid_instr == 32'h30200073
  • EX:产生 ex_trap_return
  • EX:ex_pc_redirect = 1,目标 = mepc
  • CSR:恢复 mstatus
  • IF/ID、ID/EX:flush

trap 的目标选择是:

ecall -> mtvecmret -> mepc

CSR 自动更新在最后的 CSR always_ff 里。

RV32M 乘除法指令

当前支持 8 条 M 扩展:

指令作用
mul乘法低 32 位
mulh有符号 × 有符号,高 32 位
mulhsu有符号 × 无符号,高 32 位
mulhu无符号 × 无符号,高 32 位
div有符号除法
divu无符号除法
rem有符号取余
remu无符号取余

M 扩展识别条件是:

opcode = OPC_OPfunct7 = 7'b0000001

然后根据 funct3 区分 8 条 M 指令。

乘法流动

现在乘法不是直接一拍写回,而是通过 M 状态机:

  • IF:取 MUL/MULH/MULHSU/MULHU
  • ID:识别 M 指令,设置 id_m_op
  • EX:m_start 启动 M 状态机
  • EX:保存 rs1/rs2/op
  • EX:计算 unsigned 乘法 mul_uu
  • EX:下一步根据指令类型做高位修正
  • EX:m_result_ready = 1
  • EX:ex_m_result = ex_m_result_reg
  • MEM/WB:写回 rd

乘法现在统一先做无符号 32x32,再对 MULH/MULHSU 做符号修正。

乘法高位结果选择在这里:

  • MUL -> 低 32 位
  • MULH -> signed high
  • MULHSU -> signed/unsigned high
  • MULHU -> unsigned high

除法流动

除法走 Divider.sv 里的 rv32_divider 模块。

流动过程:

  • IF:取 DIV/DIVU/REM/REMU
  • ID:识别 M 指令,设置 id_m_op
  • EX:m_start 启动 M 状态机
  • EX:div_start 启动除法器
  • EX:m_stall = 1,CPU 前级暂停
  • Divider:多周期执行除法
  • Divider:done 后输出 result
  • EX:保存 ex_div_result 到 ex_m_result_reg
  • EX:m_result_ready = 1
  • MEM/WB:写回 rd

M 状态机负责启动乘除法、等待结果、标记 m_result_ready。

M 扩展结果最后统一走:

ex_wb_data = ex_m_result;

Divider 模块(7月9日补充)

Divider 接口
SystemVerilog
module rv32_divider (
    input  logic        clk,
    input  logic        rst,

    input  logic        start,
    input  logic [1:0]  op,
    input  logic [31:0] rs1,
    input  logic [31:0] rs2,

    output logic        busy,
    output logic        done,
    output logic [31:0] result
);
信号方向作用
clk输入时钟
rst输入复位
start输入CPU 请求启动一次除法
op输入选择 DIV/DIVU/REM/REMU
rs1输入被除数,也就是 dividend
rs2输入除数,也就是 divisor
busy输出除法器正在工作
done输出本次除法完成,结果有效
result输出最终商或余数
内部寄存器
SystemVerilog
logic [5:0]  cnt;
logic [31:0] dividend_orig;
logic [31:0] divisor_abs;
logic [31:0] dividend_shift;
logic [31:0] quotient;
logic [32:0] remainder;
logic        quotient_neg;
logic        remainder_neg;
logic        div_by_zero;
logic        signed_overflow;
寄存器作用
cnt计数器,做 32 轮除法
dividend_orig保存原始 rs1,用于除 0 时 REM 返回 rs1
divisor_abs除数的绝对值
dividend_shift被除数绝对值,逐位左移送入 remainder
quotient商,逐位生成
remainder当前余数,33 位是为了判断减法是否为负
quotient_neg最终商是否需要取负
remainder_neg最终余数是否需要取负
div_by_zero除数是否为 0
signed_overflow有符号除法溢出特例
先转成绝对值

这个除法器内部主体做的是无符号除法。对于 DIV 和 REM 这种有符号操作,先把操作数转成绝对值:

  • 如果是有符号除法,并且 rs1 是负数:

    • dividend_shift = abs(rs1)
  • 如果是有符号除法,并且 rs2 是负数:

    • divisor_abs = abs(rs2)

然后内部统一做:

  • abs(rs1) / abs(rs2)

算完之后再根据符号修正商和余数。

商和余数的符号

商的符号:rs1 和 rs2 异号则为负 余数的符号:跟被除数 rs1 一样

所以代码里:

SystemVerilog
quotient_neg  <= signed_op && (rs1[31] ^ rs2[31]);
remainder_neg <= signed_op && rs1[31];

最后如果需要负数,就取补码:

SystemVerilog
assign quotient_final  = quotient_neg  ? (~quotient + 32'd1) : quotient;
assign remainder_final = remainder_neg ? (~remainder[31:0] + 32'd1) : remainder[31:0];
普通除法:32 轮移位减法
数学原理(无符号整数除法)

被除数 A,除数 B,求 (A=Q×B+R),满足 (0R<B)

每一步只算 1 位商,核心操作只有两步:左移、减法。

核心规则

把余数寄存器左移 1 位,把被除数最高位移进余数低位;新余数 = 移位后的余数 − 除数 B;

  • 若减法无借位(够减):当前商位填 1,余数保留减法结果;
  • 若减法有借位(不够减):当前商位填 0,把余数恢复回移位前的值(算法名字 “恢复余数” 的来源)。
代码

如果不是除 0,也不是 signed overflow,就进入普通除法过程:

SystemVerilog
else if (cnt != 0) begin
    logic [32:0] rem_shift;
    logic [32:0] rem_sub;

    rem_shift = {remainder[31:0], dividend_shift[31]};
    rem_sub   = rem_shift - {1'b0, divisor_abs};

    dividend_shift <= {dividend_shift[30:0], 1'b0};

    if (!rem_sub[32]) begin
        remainder <= rem_sub;
        quotient  <= {quotient[30:0], 1'b1};
    end else begin
        remainder <= rem_shift;
        quotient  <= {quotient[30:0], 1'b0};
    end

    cnt <= cnt - 6'd1;
end

这就是经典的恢复余数除法。假设要算:dividend / divisor,内部每轮做:

  1. 把 remainder 左移 1 位,并把 dividend_shift 的最高位移进来
  2. 尝试 remainder - divisor
    • 如果够减:
      • remainder = remainder - divisor
      • quotient 下一位 = 1
    • 如果不够减:
      • remainder 保持
      • quotient 下一位 = 0
  3. dividend_shift 左移 1 位
  4. cnt 减 1

也就是从高位到低位,一位一位生成商。

完整流程总结

一次正常除法流程:

  1. start = 1 且 busy = 0
  2. Divider 保存 rs1、rs2、op
  3. 判断是 DIV/DIVU/REM/REMU
  4. 判断是否除 0
  5. 判断是否 signed overflow
  6. 如果有符号操作,把 rs1/rs2 转成绝对值
  7. 设置 cnt = 32,busy = 1
  8. 每拍执行一轮:
    • remainder 左移并拼入 dividend_shift 最高位
    • 尝试减 divisor_abs
    • 够减则商位为 1,不够减则商位为 0
    • dividend_shift 左移
    • cnt--
  9. cnt 到 0:
    • 对 quotient/remainder 做符号修正
    • 根据 want_rem 选择输出商或余数
    • busy = 0
    • done = 1
  10. CPU 接收 result

特殊的 NOP 类情况

当前 CPU 对没有专门实现的 opcode 或 SYSTEM 默认情况,基本就是:

  • 不写寄存器
  • 不访存
  • 不跳转
  • PC + 4

也就是效果接近 NOP。

典型:

asm
addi x0, x0, 0

本身就是标准 NOP。

像 fence、ebreak 如果没有单独 trap/功能处理,在当前控制器默认路径下也基本不会产生写回、访存、跳转,效果接近 NOP。

总结

现在这个 CPU 的所有指令,本质上都在做这几件事:

  • 算一个值 → 写回寄存器
  • 算一个地址 → load/store
  • 算一个条件 → 改 PC
  • 读写 CSR → trap 控制
  • 执行乘除法 → 等多周期结果再写回

而它们在 CPU 里的统一流动就是:

  • IF 取指
  • ID 译码 + 读寄存器
  • EX 执行 / 判断跳转 / CSR / M扩展
  • MEM 访问 DRAM 或外设
  • WB 写回寄存器

提升频率采取的措施

  1. 流水线化
  2. PC 跳转路径优化
  3. 用 stall 换掉复杂 forwarding
  4. load-use 冒险用 stall,而不是硬前递
  5. M 扩展乘除法改成多周期状态机
  6. 控制逻辑优化
  7. 组合逻辑收缩
  8. 针对 Vivado timing report 的方向性优化

流水线化

为什么流水线化能提高频率?

CPU 频率主要受最长组合路径限制。

CPU 频率主要受 最长组合路径 限制。

假设没有流水线,一条指令可能要在一个周期里完成:

PC取指

  • 指令译码
  • 读寄存器
  • 立即数生成
  • ALU计算
  • 分支判断
  • 访存
  • 写回选择
  • 写寄存器

这一整条链太长,时钟周期就必须很长。比如组合逻辑总延迟是:8ns那最高频率大约只能是:1 / 8ns = 125MHz

流水线化之后,把它拆成几段:

  • IF :取指
  • ID :译码 + 读寄存器
  • EX :执行 ALU / 分支 / CSR / M扩展
  • MEM :访存
  • WB :写回

如果每段最长只有 3ns 左右,那理论上频率就可以接近:

1 / 3ns ≈ 333MHz

当然实际还有寄存器建立时间、布线延迟、控制逻辑,所以不会这么理想,但核心思想就是:

用更多流水寄存器,换更短的单周期组合路径。

代价是:

  • 控制逻辑更复杂
  • 需要 forwarding
  • 需要 stall
  • 需要 flush

实际优化点:

  1. IF/ID、ID/EX、EX/MEM、MEM/WB 分界明确
  2. load 访问被统一成一拍返回,所以 load 会额外 stall
  3. branch/jalr 的 PC forwarding 被取消,用 stall 换短路径
  4. M 扩展乘除法不强行一拍完成,而是用 m_stall 多周期等待
  5. EX/MEM 在 m_stall 时插 bubble,避免后级重复执行同一条 M 指令

PC 跳转路径优化

当前 PC 更新的核心形式

现在代码里 PC 更新逻辑是:

SystemVerilog
if (ex_pc_redirect) begin
    pc_next = ex_pc_target;
end else if (load_use_hazard || pc_ex_hazard || pc_mem_hazard || mem_load_stall || m_stall) begin
    pc_next = pc_q;
end else begin
    pc_next = pc_q + 32'd4;
end

这段在代码中对应 PC 更新优先级:redirect > stall/hold > 顺序 +4。 这个结构很清楚,而且能减少乱七八糟的 PC 选择逻辑散在各处。

优化点一:所有跳转统一成 ex_pc_redirect + ex_pc_target

现在所有改变 PC 的行为,最后都归到两个信号:

  • ex_pc_redirect:要不要跳
  • ex_pc_target:跳到哪里

包括branch,jal,jalr,ecall,mret,mul_helper.

代码里 ex_pc_target 统一选择:

SystemVerilog
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

这样做的好处是:PC 更新模块不用关心“是哪种跳转”,它只看 ex_pc_redirectex_pc_target,等于把复杂的跳转类型判断集中在 EX 阶段,而不是让 PC 更新逻辑直接面对所有指令类型。

优化点二:PC 更新链很短

PC 更新只做三选一:

  • ex_pc_target
  • pc_q
  • pc_q + 4

没有在 PC 更新这里直接做:branch 比较,jalr 加法,CSR 选择,forwarding 选择,这些都提前在 EX 阶段算好。所以 PC 寄存器看到的是已经整理好的结果:

  • ex_pc_redirect
  • ex_pc_target
  • stall 条件
  • pc_q + 4

这比下面这种写法要好得多:

SystemVerilog
if (branch && rs1 == rs2)
    pc_next = pc + imm;
else if (jalr)
    pc_next = forwarded_rs1 + imm;
else if (ecall)
    pc_next = mtvec;
else if (mret)
    pc_next = mepc;
else if (stall)
    pc_next = pc_q;
else
    pc_next = pc_q + 4;

这种写法会让 PC 选择链变得很长。

优化点三:branch/jalr 不再吃 EX/MEM、MEM/WB 前递

这是最重要的 PC 路径优化。

以前如果 branch/jalr 要使用前一条指令刚算出来的寄存器值,可能需要这样:

asm
add x1, x2, x3
beq x1, x0, label

如果不处理,beq 在 EX 阶段比较 x1 时可能读到旧值。一种做法是:给 branch 比较链也接 forwarding。也就是:

  • EX/MEM → branch compare
  • MEM/WB → branch compare

但是这样会把 PC 路径拉得很长:

  • 前递比较器
  • 前递 mux
  • branch 比较器
  • branch target 选择
  • ex_pc_redirect
  • pc_next
  • pc_q

现在直接把 PC forwarding 关掉:

SystemVerilog
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;

这就是用 stall 换频率。

其他优化

  • pc_ex_hazard / pc_mem_hazard
    • 如果 branch/jalr 需要的寄存器在 EX/MEM 或 MEM/WB,还没写回,就 stall 等待
    • 这样 PC 更新链就不会被拉长
  • 对 JALR 和 branch 一样处理
  • trap 跳转路径也统一进 PC redirect

总结

现在这部分优化可以总结成三句话:

  1. 所有跳转统一成 ex_pc_redirect + ex_pc_target。
  2. PC 更新逻辑只做 redirect / hold / pc+4 三选一。
  3. branch/jalr 不再接复杂 forwarding,而是用 pc_ex_hazard / pc_mem_hazard 等待。

这种设计的核心取舍是:少数 branch/jalr 相关情况下多停几拍,换取 PC 关键路径更短,从而提高最高频率。

load 路径优化

load 路径为什么容易卡频率?

以这条指令为例:

asm
lw x3, 0(x1)

如果做成很激进的一拍完成,那么一条路径可能是:

  • idex_rs1_val
  • forwarding mux
  • ALU 算地址 rs1 + imm
  • perip_addr
  • perip_bridge 地址译码
  • DRAM / MMIO / counter 选择
  • perip_rdata
  • LB/LH/LW/LBU/LHU 数据扩展
  • mem_wb_data 选择
  • 写回寄存器

这条路径很长,尤其是它把 地址计算、外设选择、读数据、load 数据扩展 都串在一起。所以 load 路径优化的目标就是:不要追求 load 一拍完成;宁愿 load 多停一拍,也要让每一拍的组合逻辑变短。

现在的整体做法

当前 load 路径做了两件关键事情:

  1. perip_bridge 里把所有读源统一打一拍
  2. CPU 里用 mem_load_stall 为 load 额外停一拍

perip_bridge 里明确把 CPU 的数据总线拆到 DRAM、counter、MMIO 等目标,而且注释说明所有读源都统一打一拍,让 CPU 面对“所有读口都是 1 周期返回”的一致模型。

地址空间包括:

  • 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 里定义。

perip_bridge 为什么要打一拍?

perip_bridge 不是只接一个 DRAM,它还要判断访问的是:DRAM,counter,SW,KEY,SEG,LED,如果这些读数据全都组合返回,路径会变成:

  • CPU 地址
  • 地址比较
  • 选择 DRAM / counter / MMIO
  • 读数据
  • 选择最终 perip_rdata
  • CPU load 扩展

所以 perip_bridge 里做了寄存对齐:

SystemVerilog
sel_dram_r  <= sel_dram;
sel_cnt_r   <= sel_cnt;
sel_mmio_r  <= (sel_sw0 || sel_sw1 || sel_key || sel_seg);
mmio_rdata_r <= mmio_rdata;
cnt_rdata_r  <= cnt_rdata;

然后下一拍再选择 perip_rdata。

这就是 load 路径优化的关键:地址这一拍给出去;数据下一拍回来。

load-use 冒险和 load 路径优化的关系

load 路径优化还有一个配套问题:下一条指令如果马上用 load 的结果怎么办?例如:

asm
lw  x3, 0(x1)
add x4, x3, x5

add 需要 x3,但 x3 要等 load 从内存读回来之后才知道。所以代码里有:

SystemVerilog
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;当前 IF/ID 阶段的指令要用这个 load 的 rd;那么必须停。

处理方式是:PC hold,IF/ID hold,ID/EX 插 bubble.

所以 load 相关的暂停其实有两类:

  • load_use_hazard:

    • 解决“下一条指令马上用 load 结果”的数据冒险。
  • mem_load_stall:

    • 解决“perip_rdata 下一拍才稳定”的访存延迟。

这两个目的不同,但都是为了让 load 路径正确且不拖慢关键路径。

为什么不让 load 直接 EX/MEM forwarding?

普通 ALU 可以这样:

asm
add x3, x1, x2
sub x4, x3, x5

add 的结果在 EX 阶段已经算出来,所以 sub 可以从 EX/MEM 前递拿到 x3。但是 load 不行:

asm
lw  x3, 0(x1)
add x4, x3, x5

当 load 在 EX/MEM 时,它只是刚把地址送给 perip_bridge,真正的数据还没有稳定回来。所以代码里:

SystemVerilog
assign exmem_can_forward = exmem_rf_we && (exmem_rd != 5'h0) && (exmem_wb_sel != WB_SRC_MEM);

也就是 EX/MEM 允许前递,但 load 不允许从 EX/MEM 前递。

这很关键。否则会把 load 的地址或旧数据错误地当成 load 结果前递出去。

代价是什么?

代价也很明显:每条 load 都会多停一拍。也就是说:

asm
lw x3, 0(x1)

不会像理想流水线那样一路顺畅流到 WB,而是会因为 mem_load_stall 多等一拍。

如果后面紧跟使用 load 结果:

asm
lw  x3, 0(x1)
add x4, x3, x5

还会有 load-use 相关暂停。

所以这是典型取舍:降低单条 load 的性能,换取更高的 CPU 最高频率。

总结

  1. load 地址在 EX 阶段计算,进入 EX/MEM。
  2. EX/MEM 阶段把地址送给 perip_bridge。
  3. perip_bridge 把 DRAM / counter / MMIO 读数据统一打一拍。
  4. CPU 用 mem_load_stall 为每条 load 额外停一拍。
  5. load 数据稳定后,再做 LB/LH/LW/LBU/LHU 扩展。
  6. EX/MEM 不允许 load 结果前递,避免错误 forwarding。
  7. load-use hazard 单独检测,必要时插 bubble。

load 路径优化的核心是:不要让“算地址 + 读外设/内存 + 数据扩展 + 写回选择”挤在一个周期里,而是把读数据延后一拍,用 mem_load_stall 换更短的关键路径和更高频率。

M 扩展优化

为什么 M 扩展需要优化?

普通 ALU 指令,比如:

asm
add x3, x1, x2
and x4, x5, x6
slt x7, x8, x9

组合逻辑相对简单,一拍完成问题不大。

但是 M 扩展不一样。

乘法的问题

乘法尤其是:mulh, mulhsu, mulhu,不仅要做 32 位乘法,还要取高 32 位。有符号乘法还涉及符号扩展和修正。如果直接写$signed(rs1) * $signed(rs2)再一拍内接进 ex_wb_data,路径可能变成:

  • ID/EX 寄存器
  • forwarding mux
  • 32x32 乘法器/DSP
  • 高位选择/符号修正
  • ex_wb_data 大 mux
  • EX/MEM 寄存器

这条路径很容易卡频率。

除法的问题

除法更严重。如果直接组合写:result = rs1 / rs2;,综合出来会非常大、非常慢,基本不适合放进单周期 EX 路径。所以除法必须多周期。

现在 M 扩展的总体方案

现在代码里没有让 M 指令一拍跑完,而是加了一套 M 状态机。相关信号包括:

  • m_start
  • m_inflight
  • m_result_ready
  • m_div_started
  • m_is_div_reg
  • m_mul_raw_valid
  • m_op_reg
  • m_rs1_reg
  • m_rs2_reg
  • ex_m_result_reg

这些信号定义在 EX 级 M 扩展相关区域。它们的作用大概是:

  • m_start :M 指令开始执行
  • m_inflight :M 指令正在执行中
  • m_result_ready :M 指令结果已经准备好
  • m_is_div_reg :当前这条 M 指令是不是除法类
  • m_div_started :除法器是否已经启动过
  • m_mul_raw_valid:乘法原始结果是否已经寄存
  • m_op_reg :保存当前 M 指令类型
  • m_rs1_reg :保存 rs1 操作数
  • m_rs2_reg :保存 rs2 操作数
  • ex_m_result_reg:保存最终 M 指令结果

整体流程是:

  • M 指令进入 EX
  • m_start 启动
  • 保存操作数和操作类型
  • 如果是乘法,分几拍算出结果
  • 如果是除法,启动 Divider 等 done
  • 结果写入 ex_m_result_reg
  • m_result_ready = 1
  • 当前 M 指令放行进入 EX/MEM
  • 最后 WB 写回 rd

M 指令为什么要用 m_stall?

因为 M 指令不是一拍得到结果,所以当前 M 指令必须停在 ID/EX,也就是停在 EX 阶段等待结果。

为什么 EX/MEM 要插 bubble?

这样后级看到的是空泡,不会重复执行。

以下为7月9日更新

M 状态机的启动逻辑

代码里:

SystemVerilog
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;

含义:

  • m_start:

    • 当前 EX 阶段是 M 指令;
    • 没有正在执行的 M 指令;
    • 没有已经准备好的 M 结果;
    • 没有发生 PC redirect;
    • 那就启动 M 状态机。
  • mul_start:

    • m_start 且这条是乘法类。
  • div_start:

    • 当前 M 状态机正在执行;
    • 这条是除法类;
    • 还没启动过 Divider;
    • 那就给 Divider 一个 start。

注意这里有一个设计细节:M 指令一进入 EX,并不是直接放行;而是先被 m_stall 卡住,等状态机把结果算好。

乘法在流水线里具体走几拍?

以:

asm
mulh x3, x1, x2

为例,简化理解:

  • 第 1 拍:

    • M 指令进入 EX
    • m_start = 1
    • 保存 m_op_reg = MULH
    • 保存 m_rs1_reg = x1
    • 保存 m_rs2_reg = x2
    • m_inflight = 1
    • m_stall = 1
  • 第 2 拍:

    • 计算 unsigned 乘法 mul_uu
    • m_mul_uu_reg <= mul_uu
    • m_mul_raw_valid = 1
    • m_stall 继续为 1
  • 第 3 拍:

    • 根据 m_op_regm_mul_uu_reg 做符号修正
    • ex_m_result_reg <= ex_mul_result_comb
    • m_result_ready = 1
    • m_inflight = 0
  • 第 4 拍:

    • m_stall 解除
    • 当前 M 指令进入 EX/MEM
    • ex_wb_data = ex_m_result
  • 之后:

    • MEM/WB 写回 rd

状态机代码就在 always_ff 里:

  • m_start 时保存操作数;
  • m_inflight && !m_mul_raw_valid 时保存 unsigned product;
  • m_inflight 时生成最终乘法结果;
  • m_result_ready 后释放。

除法优化:独立 Divider 多周期执行

CPU 里先判断是不是除法类,然后根据 idex_m_op 转成 Divider 的 op,Divider 运行完成后,div_done = 1,CPU 把 ex_div_result 存到 ex_m_result_reg:

SystemVerilog
if (div_done) begin
    ex_m_result_reg <= ex_div_result;
    m_result_ready <= 1'b1;
    m_inflight <= 1'b0;
    m_div_started <= 1'b0;
end

这段逻辑在 M 状态机里。

为什么除法必须这样做?因为除法如果组合实现,路径会极长。

现在做成:CPU EX 阶段只负责启动 Divider;Divider 自己慢慢算;CPU 用 m_stall 等待;结果 ready 后再写回。这使得 CPU 主关键路径不再包含完整除法器。

除法耗时变长;但 CPU 可运行频率变高。

总结

M 扩展优化主要做了这些:

  1. 不让 MUL/DIV/REM 一拍完成。
  2. 增加 M 状态机:m_start / m_inflight / m_result_ready / m_stall。
  3. M 指令执行期间:PC hold,IF/ID hold,ID/EX hold,EX/MEM 插 bubble。
  4. 乘法统一先做 unsigned 32x32。
  5. MULH / MULHSU 用高位修正代替多个 signed 乘法器。
  6. 用 m_mul_uu_reg / ex_m_result_reg 把乘法路径拆成多拍。
  7. 除法放进独立 Divider,多周期等待 div_done。
  8. M 指令结果最后统一走 ex_m_result_reg,再复用普通写回路径。

M 扩展优化的核心是:把乘除法从 EX 单周期关键路径里拿出来,做成多周期状态机;乘法拆成“无符号乘法 + 符号修正”,除法交给 Divider,用 m_stall 等结果,从而用更高 CPI 换更高主频。

控制逻辑优化

  1. stall / bubble / flush 分开处理

    • stall:保持当前级
    • bubble:插入无效指令
    • flush:清除错误路径指令
  2. 针对不同 stall 采取不同动作

    • mem_load_stall:EX/MEM hold,因为 load 正在 MEM 等数据
    • m_stall:ID/EX hold,EX/MEM bubble,因为 M 指令还没完成,不能进入后级
  3. 减少错误路径和无效指令传播

    • ex_pc_redirect 后清 IF/ID valid
    • ID/EX bubble 时清 rf_we、mem_req、csr_op、is_m_ext 等控制信号
    • 避免无效指令继续激活 ALU、CSR、M 扩展、访存和写回路径

总结

  1. 流水线化

    • 用 IF/ID、ID/EX、EX/MEM、MEM/WB 切断长路径
  2. PC 跳转路径优化

    • 统一 ex_pc_redirect / ex_pc_target
    • 取消 branch/jalr 的 PC forwarding
    • pc_ex_hazard / pc_mem_hazard 等待
  3. forwarding 优化

    • 普通 ALU 保留 EX/MEM、MEM/WB forwarding
    • 共享 rs1/rs2 compare 命中线
    • MEM/WB 到 ID 做同拍旁路
  4. load 路径优化

    • load 数据统一一拍返回
    • mem_load_stall 等待 perip_rdata
    • 避免一拍内完成地址计算 + 读外设 + 数据扩展 + 写回
  5. M 扩展优化

    • M 指令多周期
    • 乘法拆成 unsigned product + signed correction
    • 除法放到独立 Divider
    • m_stall 暂停流水线等待结果
  6. 控制逻辑优化

    • stall / bubble / flush 分开处理
    • EX/MEM 在 m_stall 时清 bubble
    • 减少错误路径和无效指令传播
  7. 组合逻辑收缩

    • branch 比较只在 branch 时启用
    • store 数据非 store 时清零
    • PC 链、ALU 链、M 扩展链尽量拆开
  8. 针对 Vivado timing report 的方向性优化

    • 如果 idex → exmem 慢:优化 EX / ALU / wb mux
    • 如果 idex → pc_q 慢:优化 PC redirect
    • 如果 m_* 路径慢:继续拆乘除法
    • 如果 route 占比高:减少扇出、复制控制信号

在 Vivado 中打 ILA(如果能够实际上板)

(实际上这部分为了省时间直接让AI代做是最省事的,然后直接负责拿调试结果即可)

ILA 用来看什么?

ILA 就是 FPGA 里面的“逻辑分析仪”。它可以在真实板子运行时抓信号,比如:

  • PC 当前跑到哪里
  • 取到的指令是什么
  • 流水线 valid 有没有卡住
  • 有没有发生跳转
  • load/store 地址对不对
  • 写回寄存器的数据对不对
  • M 扩展有没有一直 stall

它适合定位这类问题:

  • 程序不跑
  • PC 卡住
  • 跳转跳错
  • load/store 错
  • LED/SEG 不输出
  • M 扩展卡死
  • CSR/trap 跳错

最推荐先抓的信号

不要一上来抓几百个信号。先抓这组,基本能判断 CPU 大方向有没有问题。

第一组:取指和 PC

  • pc_q
  • pc_next
  • irom_addr
  • irom_data
  • ifid_pc
  • ifid_instr
  • ifid_valid

这些信号能回答:

  • CPU 有没有从 0x80000000 开始跑?
  • PC 有没有递增?IROM 取出来的指令对不对?
  • IF/ID 是否有效?

myCPU 复位 PC 是 0x8000_0000,IROM 接口是 irom_addr/irom_data。 pc_q/pc_next/ifid_pc/ifid_instr/ifid_valid 这些信号在 IF 级定义。

第二组:流水线是否卡住

  • load_use_hazard
  • pc_ex_hazard
  • pc_mem_hazard
  • mem_load_stall
  • m_stall
  • ex_pc_redirect
  • ex_pc_target

这些信号能回答:

  • CPU 是不是被 load 卡住?
  • 是不是被 branch/jalr 等待卡住?
  • 是不是被 M 扩展卡住?
  • 是不是发生了跳转?
  • 跳转目标是多少?

这些冒险和 stall 信号在 ID/EX/EX 区域都有定义。

第三组:各级 valid

  • idex_valid
  • exmem_valid
  • memwb_valid

这些能看流水线有没有正常流动。 对应的 ID/EX、EX/MEM、MEM/WB valid 信号都在 myCPU.sv 里。

第四组:写回

  • memwb_rf_we
  • memwb_rd
  • memwb_wdata

这些能回答:

  • CPU 有没有写寄存器?
  • 写的是哪个 rd?
  • 写回数据对不对?

不要一开始就打完整寄存器堆,太浪费资源。先看写回总线最划算。

第五组:访存和外设

  • perip_addr
  • perip_wen
  • perip_mask
  • perip_wdata
  • perip_rdata
  • exmem_mem_req
  • exmem_mem_write

这些能回答:

  • 有没有访问 DRAM/MMIO?
  • store 地址对不对?
  • 写 LED/SEG 的数据对不对?
  • load 返回数据对不对?

student_top 里这些外设信号正好在 CPU 和 perip_bridge 之间。

第六组:M 扩展专用

如果怀疑乘除法问题,再加:

  • idex_is_m_ext
  • idex_m_op
  • m_start
  • m_inflight
  • m_result_ready
  • m_stall
  • div_start
  • div_done
  • ex_m_result_reg

这些 M 扩展信号在 myCPU.sv 中集中定义。

怎么在代码里标记 ILA 信号?

你可以在 myCPU.sv 里给关键信号加属性:

SystemVerilog
(* mark_debug = "true", keep = "true" *) logic [31:0] pc_q;
(* mark_debug = "true", keep = "true" *) logic [31:0] pc_next;

(* mark_debug = "true", keep = "true" *) logic [31:0] ifid_pc;
(* mark_debug = "true", keep = "true" *) logic [31:0] ifid_instr;
(* mark_debug = "true", keep = "true" *) logic        ifid_valid;

(* mark_debug = "true", keep = "true" *) logic        load_use_hazard;
(* mark_debug = "true", keep = "true" *) logic        pc_ex_hazard;
(* mark_debug = "true", keep = "true" *) logic        pc_mem_hazard;
(* mark_debug = "true", keep = "true" *) logic        mem_load_stall;
(* mark_debug = "true", keep = "true" *) logic        m_stall;

(* mark_debug = "true", keep = "true" *) logic        ex_pc_redirect;
(* mark_debug = "true", keep = "true" *) logic [31:0] ex_pc_target;

(* mark_debug = "true", keep = "true" *) logic        memwb_rf_we;
(* mark_debug = "true", keep = "true" *) logic [4:0]  memwb_rd;
(* mark_debug = "true", keep = "true" *) logic [31:0] memwb_wdata;

student_top.sv 里也可以给 CPU 外部接口加:

SystemVerilog
(* mark_debug = "true", keep = "true" *) logic [11:0] inst_addr;
(* mark_debug = "true", keep = "true" *) logic [31:0] instruction;

(* mark_debug = "true", keep = "true" *) logic [31:0] perip_addr;
(* mark_debug = "true", keep = "true" *) logic [31:0] perip_wdata;
(* mark_debug = "true", keep = "true" *) logic [31:0] perip_rdata;
(* mark_debug = "true", keep = "true" *) logic        perip_wen;
(* mark_debug = "true", keep = "true" *) logic [1:0]  perip_mask;

注意:mark_debug 会影响综合优化,可能降低最高频率,所以只在调试版本里加,最终提交版本要删掉。

Vivado GUI 里怎么打 ILA?

流程如下。

第一步:加 mark_debug

先按上面那样在代码里给关键信号加:

SystemVerilog
(* mark_debug = "true", keep = "true" *)

然后保存文件。

第二步:Run Synthesis

在 Vivado 左边:Flow Navigator → Run Synthesis,等综合完成。

第三步:打开综合后的设计

Open Synthesized Design

然后:Set Up Debug

第四步:Set Up Debug

进入向导后:

  1. 选择要观察的 nets
  2. 选择 clock
  3. 设置 sample depth
  4. 生成 debug core

CPU 信号必须用w_cpu_clk作为 ILA 采样时钟。因为 CPU 和 perip_bridge 都主要跑在 w_cpu_clk 下,student_top 里 CPU 实例和 perip_bridge 的 clk 都接了 w_cpu_clk。不要把 CPU 信号用 w_clk_50Mhz 采样。如果你要看 counter 相关的 50MHz 域,最好单独打一组 ILA,或者小心跨时钟问题。

第五步:设置 ILA 参数

建议第一版:

  • Sample depth:4096
  • Input pipe stages:1 或 2
  • Trigger ports:默认即可
  • Capture control:可以先不开

Input pipe stages 加 1~2 级可以改善 ILA 自己带来的时序压力,但波形会相对晚几拍,调试时记住这一点就行。

第六步:重新实现和生成 bitstream

ILA 加进去之后必须重新:Run Implementation,Generate Bitstream

Vivado 会同时生成:.bit .ltx

.bit 是比特流,.ltx 是 ILA 探针信息。后面 Hardware Manager 需要它来识别你抓的信号名。

上板后怎么抓?

第一步:Open Hardware Manager

Open Hardware Manager → Open Target → Auto Connect

第二步:Program Device

选择生成的:xxx.bit xxx.ltx,如果 Vivado 没自动带上 .ltx,你手动指定。

第三步:设置触发条件

比如想看程序有没有写 LED,可以设置触发:perip_wen == 1,更精确一点:perip_wen == 1,perip_addr == 32'h8020_0040这样当 CPU 写 LED 地址时,ILA 会停下来。

  • 如果想看 PC 跑到某个地方:pc_q == 32'h8000_0100
  • 如果想抓跳转:ex_pc_redirect == 1
  • 如果想抓 CPU 卡住:m_stall == 1或者mem_load_stall == 1

第四步:先 arm ILA,再释放 reset

最稳的做法:

  1. 让 CPU 保持 reset
  2. 点击 Run Trigger,ILA 进入等待
  3. 松开 reset
  4. CPU 开始跑
  5. ILA 抓到触发点

这样不会错过复位后最开始几条指令。

不同问题该怎么触发?

情况 A:程序完全不跑

触发方式:Trigger Now 直接看当前波形。重点看:

  • pc_q 是不是 0x80000000
  • pc_next 有没有变化
  • irom_data 是不是有效指令
  • ifid_valid 有没有起来

判断:

  • pc_q 一直不动:

    • 看 reset 是否一直有效
    • 看是不是某个 stall 一直为 1
  • irom_data 全是 0:

    • IROM 初始化可能不对
  • irom_data 是 00000013:

    • 可能全是 NOP,coe 没烧对

情况 B:程序跑飞

触发:ex_pc_redirect == 1重点看:

  • ex_pc_target
  • idex_pc
  • ifid_instr
  • idex_valid
  • pc_q

判断:

  • ex_pc_target 很奇怪:

    • branch/jal/jalr target 可能算错
  • 跳到了 0:

    • jalr rs1 或 CSR mepc/mtvec 可能不对
  • 反复 redirect:

    • branch 条件判断可能错

情况 C:load/store 错

触发:perip_wen == 1或者 load 时抓:exmem_mem_req == 1 && exmem_mem_write == 0

重点看:

  • perip_addr
  • perip_wen
  • perip_mask
  • perip_wdata
  • perip_rdata
  • mem_load_stall
  • memwb_wdata
  • memwb_rd

判断:

  • perip_addr 错:

    • ALU 地址计算 / 立即数 / rs1 值有问题
  • perip_wdata 错:

    • store 的 rs2 forwarding 可能有问题
  • perip_rdata 对,但 memwb_wdata 错:

    • LB/LH/LW/LBU/LHU 扩展可能有问题
  • mem_load_stall 一直为 1:

    • load stall 控制可能卡住

情况 D:M 扩展卡死

触发:m_stall == 1重点看:

  • idex_is_m_ext
  • idex_m_op
  • m_start
  • m_inflight
  • m_result_ready
  • div_start
  • div_done
  • ex_m_result_reg

判断:

  • m_start 没起来:

    • M 指令译码或启动条件有问题
  • m_inflight 一直为 1:

    • 乘法状态机没结束,或 div_done 没回来
  • div_start 有,div_done 没有:

    • Divider 可能没完成
  • m_result_ready 一直不上来:

    • M 状态机没有正确保存结果

情况 E:寄存器写回错

触发:memwb_rf_we == 1重点看:

  • memwb_rd
  • memwb_wdata
  • exmem_wb_data
  • exmem_wb_sel
  • idex_rd
  • idex_alu_op
  • ex_alu_y

判断:

  • rd 对,wdata 错:

    • EX 运算或 wb mux 错
  • wdata 对,rd 错:

    • 流水线 rd 传递错
  • rf_we 不该为 1 却为 1:

    • bubble/flush 没清干净
  • rf_we 该为 1 却为 0:

    • 译码控制或 valid 丢了

看波形时的基本顺序

抓到波形后不要乱看,按这个顺序:

  1. 看 pc_q

    • PC 是否从 0x80000000 开始?
    • 是否每次 +4?
    • 有没有跳转?
  2. 看 irom_data / ifid_instr

    • 指令是不是你 coe 里的机器码?
  3. 看 valid

    • ifid_valid / idex_valid / exmem_valid / memwb_valid 是否正常流动?
  4. 看 stall

    • 是不是 load_use_hazard / mem_load_stall / m_stall 把 CPU 卡住?
  5. 看 redirect

    • ex_pc_redirect 发生时 ex_pc_target 是否正确?
  6. 看写回

    • memwb_rf_we / memwb_rd / memwb_wdata 是否符合预期?
  7. 看外设

    • perip_wen / perip_addr / perip_wdata 是否正确?

最重要的几个坑

ILA 会影响时序

打 ILA 后可能原本 200MHz 能过,现在不过了。解决:

  • 减少 probes
  • 降低 sample depth
  • 给 ILA input pipe stages 加 1~2
  • 调试时先降 CPU 频率
  • 最终提交版本删掉 ILA

信号被综合优化掉

如果在 Set Up Debug 里找不到 pc_q 或 m_stall,说明可能被优化或改名。解决:

SystemVerilog
(* mark_debug = "true", keep = "true" *)

提前加在代码声明处。

跨时钟域乱抓

CPU 信号用:w_cpu_clk

counter 或慢速外设可能涉及:w_clk_50Mhz

不要随便把两个时钟域的信号塞进同一个 ILA 里。 要么只抓 CPU 域,要么分两个 ILA。

触发点放太晚

比如你想看程序从复位开始怎么跑,结果触发设成 LED 写入,可能前面的错误已经过去了。解决:

把 trigger position 调到中间或靠后,让 ILA 保存触发前的一段历史。例如 sample depth 4096,trigger position 设在 2048 左右,就能看到触发前后各一段。

总结

现在定位 CPU 问题,第一步就这样做:

  1. 先给 pc_q、irom_data、ifid_instr、valid、stall、redirect、writeback、perip 总线加 mark_debug。
  2. ILA clock 选 w_cpu_clk。
  3. sample depth 4096。
  4. 触发先用 Trigger Now,看 CPU 有没有跑。
  5. 再按问题设置触发:
    • 程序不跑:Trigger Now
    • 跳转错:ex_pc_redirect == 1
    • LED 不亮:perip_wen == 1 && perip_addr == 32'h8020_0040
    • M 扩展卡住:m_stall == 1
    • 写回错:memwb_rf_we == 1

打 ILA 不要贪多。先抓 PC、指令、valid、stall、redirect、写回和外设总线;看 CPU 是“没取到指令、被 stall 卡住、跳转跳错、写回错、还是访存错”。定位到大类之后,再加第二轮更细的信号。

7月9日相关上板问题

现象

LED灯只是亮起了1号灯,其他灯均不亮并且数码管数字显示00000000

第一轮调试:验证是否为CPU跑飞

最开始认为是仿真过了但是上板CPU跑飞。这是直接根据比赛要求中的内容给出的结论,但后续被证伪。

第二轮调试:验证是不是 M/MULHU 或 M 结果相关冒险

经过上板测试,询问后知道这不像“上板后 CPU 一上来就跑飞”,而是“CPU 至少跑完了第 1 项 RV32I 测试,但后面的测试没有通过/没有继续正确显示”,仿真最终 led=00000001,板上也是只有 1 号灯亮。

接着在 tb 中定位,做法:

  1. 从当前仿真使用的 generated/irom.mem 里 dump 并反汇编这些地址区间:

    • 0x80000640~0x80000720
    • 0x800007d0~0x80000860
    • 0x80000840~0x800008c0
  2. 在 tb_top 里新增“控制流追踪”,只在 branch/jal/jalr/ecall/mret 时打印:

    • pc、instr、redir、target、rs1/rs2值、是否跳转。
  3. 针对 0x80000640~0x80000720 主循环,追踪 x13/x14/x15/x10/x11/x1 的写回值。

    • 如果能层级访问 RF,就直接打印寄存器值;否则打印 memwb_rf_we、memwb_rd、memwb_wdata 并筛选这些寄存器。
  4. 生成一个 coe/mem 一致性检查脚本,打印:

    • generated/irom.mem、generated/dram.mem、Vivado IROM IP 使用的 irom.coe、Vivado DRAM IP 使用的 dram.coe 的前 32 个 word 和 hash。
    • 目标是确认仿真和上板使用的是同一套程序镜像。
  5. 最后重新跑长时窗仿真,输出:

    • 第一次 LED=1 后,程序进入了哪个循环;
    • 哪个 branch 导致一直停留在 0x800006xx;
    • 该 branch 的判断操作数是什么;
    • 是否出现下一阶段测试入口调用。

可以确定的是:不是上板独有问题;不是 IROM/DRAM 没初始化;不是 CPU 随机跑飞;而是仿真和上板都卡在同一个程序路径:LED 只写了一次 0x00000001,之后程序进入 0x80000670~0x800006dc 附近的循环,没有继续写 LED,也没有写 SEG。问题已经从“上板问题”变成了:测试程序为什么卡在 LED=1 后的 0x800006xx 循环?接下来进行验证是不是 M/MULHU 或 M 结果相关冒险导致循环不退出。

第三轮调试:验证新方向:load → branch 冒险

现在最该查的已经不是 MULHU 了。Copilot 这轮已经排除了两件事:

  1. MULHU 参考模型没有报错
  2. branch 在 EX 级看到的 x15 确实是非零,所以 bne 跳回 0x80000670 是“自洽”的

这里还有一个非常关键的问题没有证明:0x800006dc 的 branch 看到的 x15,到底是不是前一条 lw 正确读出来的 x15?

检查目的:比较 0x800006d8 这条 lw 写回的 x15 和 0x800006dc 这条 bne 在 EX 级实际看到的 x15 是否完全一致。

  • 如果不一致,问题就定位到:load → branch 冒险处理错误,
  • 如果一致,而且值确实一直非零,那再继续查这个 load 读的内存地址为什么一直非零。
  1. 检查 lw 0x800006d8 的写回值
  2. 检查 bne 0x800006dc 看到的 x15
  3. 如果 branch 看到值没错,再查 -36(s0) 这个内存变量

第四轮:问题为load → branch 冒险处理不完整

现在问题已经很明确:

  • 根因方向:load -> branch 冒险处理不完整
  • 具体触发点:0x800006d8 lw x15 后的 0x800006dc bne x15,x0
  • 直接表现:bne 看到旧 x15,导致一直跳回 0x80000670
  • 最终现象:LED 只写 1,SEG 不写,后续测试不推进

所以接下来直接修:

打开 MEM/WB 到 branch/jalr PC 比较链的 forwarding。

更改:

SystemVerilog
assign ex_pc_fwd_rs1_from_exmem = 1'b0;
assign ex_pc_fwd_rs1_from_memwb = ex_pc_use_rs1 && ex_match_rs1_memwb;//原1'b0
assign ex_pc_fwd_rs2_from_exmem = 1'b0;
assign ex_pc_fwd_rs2_from_memwb = ex_pc_use_rs2 && ex_match_rs2_memwb;//原1'b0

并更改了一些tcl文件。

7月10日相关上板问题

接着昨天的来。

现象

根本没写Z指令但是2号灯亮了,但是6号灯没亮,数码管显示37803276,但是结果是秒出的

这个结果比之前好多了,现在不是“CPU 不跑”了,而是:

  • RV32I 大概率已经过了;
  • 后续某个测试没过;
  • 现在要定位 6 号灯对应的具体测试。

“没写 Z 指令但 2 号灯亮”怎么解释?

  • 可能一:这份测试程序的 Z 测试没有真正测到你没实现的指令
    • 当前这份练习测试不一定真的测了所有 Z 指令。它可能只测了某条当前没有触发的指令,或者这份镜像里 Z 测试部分比较弱。
  • 可能二:2 号灯不是你以为的那个物理灯
    • 现在最保险的做法是:不要靠肉眼猜第几个灯,而是在仿真里看最终 virtual_led 的 bit 值。
  • 可能三:某条 Z 指令刚好被你现有译码“误打误撞”实现了
    • 比如有些 Z 指令编码可能落到你现有 OP 类路径里,但这个概率不大,不能依赖。

根据比赛说明,右侧 1~8 号灯用于显示 RV32I、Z 扩展、性能测试和异常测试等项目,数码管中蓝色部分显示 RV32I 通过条数,满分 37。

因此最初判断是:

RV32I 37 条应该已经通过; 后面某一项测试没有通过; 可能是第 6 项测试失败。

但是当时还不确定“6 号灯”到底对应 virtual_led 的哪个 bit。

第一轮调试:仿真和上板不一致,仿真仍然是 final_led=01

接着用 tb_top 做 80ms 长窗仿真,结果却还是:

LED WRITE: 00000001 SEG WRITE: 37800000 final_virtual_led[7:0] = 01

这和上板的 37803276、多个灯亮完全不一致。

一开始怀疑:

  1. 仿真没有用到最新 RTL;
  2. 上板烧的是旧 bit;
  3. Vivado IP 的 IROM/DRAM 镜像和仿真镜像不一致;
  4. bit 文件没有重新生成。

于是先暂停 CPU trace,转而检查工程构建闭环。

第二轮调试:检查 bit / IROM / DRAM 一致性

先检查当前 myCPU.sv 中前递修改是否存在,确认已经是修复后的版本。然后检查:

  • 仿真用的 irom.mem / dram.mem
  • Vivado IROM.xci / DRAM.xci 指向的 irom.coe / dram.coe
  • 实际生成 bit 的时间和 SHA256

一开始直接比较 .mem 和 .coe 文件 SHA256,发现不一致。但后来意识到这个比较方法不严谨,因为:.mem 和 .coe 文件格式不同,直接比较文件哈希没有意义。

正确方法是:分别解析 .mem 和 .coe,转成 32-bit word 序列,再比较规范化后的 word SHA。后来规范化比较结果为:

  • IROM: count 一致,SHA 一致
  • DRAM: count 一致,SHA 一致

说明仿真 mem 和 Vivado IP 引用的 coe 在实际初始化内容上是一致的。

第三轮调试:Vivado 重建 bit 失败,PLL / IROM IP run 问题

在尝试重新生成 bit 时,Vivado 构建一开始失败,报错包括:

module pll not found module pll_clk_wiz not found module IROM not found

原因不是 CPU 逻辑,而是 Vivado 工程里的 IP run 状态不一致。后来修复方法是:

  1. 使用正确的 run 属性 SRCSET;
  2. 在顶层 synth_1 之前,先 reset / launch 所有 IP 的 *_synth_1;
  3. 包括 pll_synth_1、IROM_synth_1、DRAM_synth_1;
  4. 然后再 reset / launch synth_1 和 impl_1;
  5. 最后 write_bitstream。

修复后成功生成新的普通 CPU bit:

  • top_current_20260710_120647.bit
  • SHA256 = 4F54459351CACC59976C5BB5E16A9010564DFC39607D792E55E347CE2440A97E

烧录这个新 bit 后,上板结果仍然是37803276,还是同一个灯不亮,因此排除了“烧错旧 bit”的可能性。

第四轮调试:静态分析 LED 写入:发现 LED 不是每项测试写一次,而是最终统一写出

之后静态反汇编 irom.coe,寻找对 LED 地址的写入。LED 地址在 perip_bridge.sv 中是: localparam LED_ADDR = 32'h8020_0040;CPU 对这个地址写入时,硬件直接执行:LED <= perip_wdata;最后输出:assign virtual_led_output = LED;也就是说 LED 值没有在 perip_bridge 里重排或重编码。

静态分析发现:

  • 0x80200040 只有一次最终写入;
  • 写入值不是直接常量;
  • 而是从上游状态变量聚合后写出。

因此之前“找每次 LED WRITE,看哪一项没推进”的思路不适用。

第五轮调试:反向切片 LED 状态值:先误以为要看 bit5 / bit6

从最终 LED 写入点 0x800007F8 反向切片,发现:

  • 最终写出的 LED 值来自寄存器 x14;
  • x14 来自栈槽位 -20(x8);
  • 该值由 x10 参数传入;
  • 上游还有一个全局状态变量 0x80100038。

静态分析中看到的两个 OR 常量是:0x04887020 0x90606090

其中:

  • 0x04887020 包含 bit5,不包含 bit6
  • 0x90606090 不包含 bit5,也不包含 bit6

所以一开始误以为“6 号灯”可能对应 bit5 或 bit6。但这个判断后来被 LED 物理映射修正了。

第六轮调试:做 8-bit walking-one 测试:发现低 8 位不是官方测试灯编号

为了确认物理灯和 virtual_led bit 的关系,先生成了一个 walking-one bit。第一次生成的版本断开了 UART / twin_controller,导致工具显示:"与串口连接断开",原因是 debug bit 直接替换了 top 或绕过了 twin_controller。后来修正为:保留 UART / twin_controller;只替换 student_top 输出到 student_virtual_led_src / student_virtual_seg_src 的部分。重新生成 top_led_walk_uart_ok.bit 后,串口正常。

8-bit walking-one 结果显示:virtual_led[0..7] 对应最底下一排 8 个灯按顺序亮。

这说明:低 8 位 bit0~bit7 并不是官方说的 1~8 号测试灯。因此之前围绕 bit5 / bit6 的分析暂时不成立。

第七轮调试:做 32-bit walking-one 测试:确认完整 LED 矩阵映射

继续生成 32-bit walking-one bit:

  • virtual_led = 32'h0000_0001 << idx
  • idx = 0..31
  • 数码管同步显示 idx

通过观察,得到 32 个 LED 的物理矩阵映射:

  • 31 30 29 28 27 26 25 24
  • 23 22 21 20 19 18 17 16
  • 15 14 13 12 11 10 9 8
  • 7 6 5 4 3 2 1 0

因此官方右侧 1~8 号测试灯对应的 bit 不是 0~7,而是:

  • 1号灯 = bit0
  • 2号灯 = bit1
  • 3号灯 = bit8
  • 4号灯 = bit9
  • 5号灯 = bit16
  • 6号灯 = bit17
  • 7号灯 = bit24
  • 8号灯 = bit25

这一步非常关键,因为它确认了:之前“不亮的 6 号灯”实际上对应 virtual_led[17],掩码是 0x00020000。所以后续分析应该围绕 bit17,而不是 bit5 / bit6。

重新理解 37803276 中的 8

一开始我们把 37803276 误拆成:37 | 803276 ms,觉得 803276 ms 不合理。后来查看 display_seg.sv,发现数码管硬件并不是把输入当十进制时间显示,而是把 32-bit 输入拆成 8 个 4-bit nibble,也就是按十六进制位显示。具体来说,display_seg 会在扫描过程中显示:

s[31:28], s[27:24], ..., s[3:0]

即 8 个十六进制半字节。所以:37803276 本质上是:SEG寄存器 = 32'h37803276

其中:

  • 37:表示 RV32I 37 条通过;
  • 8:不是硬件定义的独立标志,而是软件写入 SEG 值中的一个 nibble;
  • 03276:具体含义应为运行时长。

第八轮调试:mscratch 缺失

经过 LED 映射确认后,官方 6 号灯不是 bit5/bit6,而是:

官方 6 号灯 = virtual_led[17] = 0x0002_0000

Codex 进一步静态分析后,确认这个 bit17 对应的测试函数大概率是:L_800002EC

这个函数主要测试的是:

  • mtvec
  • mscratch 的 CSR 指令
  • ecall
  • mcause / mepc / mret
  • trap handler

其中最可疑的问题是:当前 CPU 没有实现 mscratch,导致官方程序测试 mscratch 的 csrrw/csrrs/csrrc/csrrwi/csrrsi/csrrci 时失败,因此 bit17 未被置位。当前优先修复方案是在 CSR 模块中补充 csr_mscratch 的读写支持。

更改

添加了这五行:

SystemVerilog

localparam logic [11:0] CSR_MSCRATCH = 12'h340;// CSR 寄存器 MSCRATCH 地址,用于保存临时数据。

logic [31:0] csr_mscratch; // CSR 级寄存器中的 mscratch 值,表示机器临时寄存器的值。


always_comb begin
	unique case (idex_csr_addr)
		CSR_MSTATUS: ex_csr_rdata = csr_mstatus;
		CSR_MTVEC:   ex_csr_rdata = csr_mtvec;
		CSR_MEPC:    ex_csr_rdata = csr_mepc;
		CSR_MCAUSE:  ex_csr_rdata = csr_mcause;
		CSR_MSCRATCH: ex_csr_rdata = csr_mscratch;//添加
		default:     ex_csr_rdata = 32'h0;
	endcase
end


if (cpu_rst) begin
	csr_mstatus <= 32'h0000_1800;
	csr_mtvec   <= 32'h0000_0000;
	csr_mepc    <= 32'h0000_0000;
	csr_mcause  <= 32'h0000_0000;
	csr_mscratch <= 32'h0000_0000;//添加


end else if (idex_valid && !mem_load_stall && ex_csr_we) begin
	unique case (idex_csr_addr)
		CSR_MSTATUS: csr_mstatus <= ex_csr_wdata;
		CSR_MTVEC:   csr_mtvec   <= ex_csr_wdata;
		CSR_MEPC:    csr_mepc    <= ex_csr_wdata;
		CSR_MCAUSE:  csr_mcause  <= ex_csr_wdata;
		CSR_MSCRATCH: csr_mscratch <= ex_csr_wdata;//添加
		default: begin end
	endcase
end

第一次结果

本次 routed timing summary 显示设计未完全满足时序约束。Design Timing Summary 中 WNS = -0.047 ns,TNS = -0.047 ns,失败端点数为 1,说明只有一条 setup 路径轻微违规;hold 和 pulse width 均通过。违规路径位于 clk_out2_pll 时钟域,该时钟周期为 5 ns,即 200 MHz。失败路径从 Core_cpu/memwb_rd_reg[3] 到 Core_cpu/pc_q_reg[8],属于 MEM/WB 阶段寄存器编号影响 PC 更新的组合路径,推测与 branch/jalr 的 MEM/WB 前递和 pc_next 选择逻辑有关。该路径 Data Path Delay 为 4.927 ns,其中 logic delay 为 0.966 ns,route delay 为 3.961 ns,布线延迟占约 80.4%,说明主要问题是布线和高扇出,而不是纯逻辑门级延迟。当前 slack 仅为 -0.047 ns,属于轻微时序违规,可以通过降低 CPU 时钟、重新布局布线、启用 phys_opt_design 或优化 PC 前递路径解决。

为解决 routed timing 中 WNS=-0.047ns 的轻微 setup 违规,尝试了多种实现策略。默认 retry 后仍为 -0.047ns,说明单纯重跑默认布局布线不能解决问题。但启用 phys_opt AggressiveExplore 后 WNS 提升到 +0.064ns,使用 Performance_Explore 后 WNS 提升到 +0.068ns,Performance_ExtraTimingOpt 后 WNS 为 +0.028ns,三者均满足时序。原始失败路径为 memwb_rd_reg[3] -> pc_q_reg[8],与 MEM/WB 前递到 PC 更新路径有关。优化后最差路径转移到乘法器或 DRAM 写回路径,说明原 PC 路径问题主要是布局布线边界问题,而非必须修改 CPU RTL 的功能性问题。后续采用 Performance_Explore 作为当前 bit 生成策略。

第二次结果

修复 mscratch 后,官方 1~8 号灯全部点亮,左侧总结果为对号。

但 SEG 仍显示 37803276。因此 SEG 中的 8 不再支持“失败标志”的解释,更可能是测试程序正常写入的显示字段。下一步通过反向追踪 SEG_ADDR=0x80200020 的最终写入值,确认 32'h37803276 的字段来源。

追踪 8 的含义

后续继续追踪数码管中 37803276 的字段含义。起初误以为后六位 803276 是完整的运行时间,因此怀疑中间的 8 可能代表异常或失败状态。但通过反汇编追踪对 SEG 地址 0x80200020 的写入发现,数码管显示值并不是单纯的十进制时间,而是由软件打包后的 32 位字段。

最终 SEG 值为 32'h37803276,其中 0x37 来自 0x80100000,表示 RV32I 37 条指令测试通过;0x8 来自 0x80100030,表示 M 扩展 8 个子测试通过;低 20 位 0x03276 来自 counter 计时/计数字段。因此数码管中的 8 是正常的 M 扩展通过计数,不是失败码。修复 mscratch 后,8 个测试灯全亮、左侧显示对号,但数码管仍为 37803276,也进一步证明该 8 与失败无关。

Mext-irom-v2 测试结果

8灯全亮,左侧对号,SEG=37803276。

今晚的一些共同问题

  1. branch/jalr专属的前递和普通的前递有什么区别?这两条链一样吗?
  2. 从memwb_rf_we_reg到pc_q_reg这条链为什么是最长的?为什么包含普通前递的这条链不和它一样长?
  3. 如果没有ex_pc_fwd_rs1_from_exmem这些东西的话,为什么同学说branch/jalr会走普通的前递逻辑也能运行?单独分出来的这个优势在哪里?

来自chatGPT:

你这三个问题其实都围绕一个核心:普通前递是给 ALU / store / M 扩展用的,branch/jalr 专属前递是给 PC 跳转决策用的。它们前半段可能共用,但后半段完全不一样。

1. branch/jalr 专属前递和普通前递有什么区别?

先说结论:

text
普通前递:让 ALU / store / M 扩展拿到最新操作数。
PC 专属前递:让 branch/jalr 的比较和跳转目标拿到最新操作数。

它们不是一条完全相同的链。你的代码里已经把它们拆成了两套变量:

systemverilog
ex_fwd_rs1_from_memwb       // 普通 EX 前递
ex_pc_fwd_rs1_from_memwb    // branch/jalr PC 专属前递

代码里也明确注释了:

text
ex_use_rs* 面向 ALU/store 数据链
ex_pc_use_rs* 面向 branch/jalr 的 PC 选择链

这部分在你的 myCPU.sv 里就是 ex_use_rs1_value/ex_use_rs2_valueex_pc_use_rs1/ex_pc_use_rs2 的区分。

1.1 普通前递链:MEM/WB → ALU / store / M

rs1 为例,普通 MEM/WB 前递的代码变量顺序是:

text
memwb_rf_we_reg

memwb_rf_we

memwb_can_forward

memwb_rd != 0

memwb_rd == idex_rs1

ex_match_rs1_memwb

ex_use_rs1_value

ex_fwd_rs1_from_memwb

memwb_wdata

ex_rs1_val

ex_alu_a

u_alu.A

ex_alu_y

ex_wb_data

EX/MEM pipeline register

对应代码里:

systemverilog
assign memwb_can_forward = memwb_rf_we && (memwb_rd != 5'h0);
assign ex_match_rs1_memwb = memwb_can_forward && (memwb_rd == idex_rs1);
assign ex_fwd_rs1_from_memwb = ex_use_rs1_value && ex_match_rs1_memwb;

这些是普通前递的命中判断。

然后真正选择操作数是在这里:

systemverilog
ex_rs1_val = idex_rs1_val;
if (ex_fwd_rs1_from_exmem)
    ex_rs1_val = exmem_wb_data;
else if (ex_fwd_rs1_from_memwb)
    ex_rs1_val = memwb_wdata;

也就是 ex_rs1_val 最后送给 ALU 的 ex_alu_a

rs2 类似:

text
memwb_rf_we_reg

memwb_rf_we

memwb_can_forward

memwb_rd == idex_rs2

ex_match_rs2_memwb

ex_use_rs2_value

ex_fwd_rs2_from_memwb

memwb_wdata

ex_rs2_val

ex_alu_b / ex_store_data
1.2 branch 专属前递链:MEM/WB → branch compare → PC

branch 用的是另一套变量:

text
ex_pc_rs1_val
ex_pc_rs2_val
ex_cmp_eq
ex_cmp_lt_signed
ex_cmp_lt_unsigned
ex_br_take
ex_pc_target
pc_next
pc_q

rs1 为例,如果开启 MEM/WB → PC 前递,链路是:

text
memwb_rf_we_reg

memwb_rf_we

memwb_can_forward

memwb_rd != 0

memwb_rd == idex_rs1

ex_match_rs1_memwb

ex_pc_use_rs1

ex_pc_fwd_rs1_from_memwb

memwb_wdata

ex_pc_rs1_val

ex_cmp_eq / ex_cmp_lt_signed / ex_cmp_lt_unsigned

ex_br_take

ex_pc_target

ex_pc_redirect

pc_next

pc_q_reg

branch 的 rs2 链路类似:

text
memwb_rf_we_reg

memwb_rf_we

memwb_can_forward

memwb_rd == idex_rs2

ex_match_rs2_memwb

ex_pc_use_rs2

ex_pc_fwd_rs2_from_memwb

memwb_wdata

ex_pc_rs2_val

ex_cmp_eq / ex_cmp_lt_signed / ex_cmp_lt_unsigned

ex_br_take

ex_pc_target

pc_next

pc_q_reg

你的代码里 branch 比较器明确用的是:

systemverilog
ex_pc_rs1_val
ex_pc_rs2_val

而不是普通的 ex_rs1_val/ex_rs2_val

然后 ex_br_take 决定是否跳转,最后进入 ex_pc_targetpc_next

1.3 jalr 专属前递链:MEM/WB → jalr target → PC

jalr 只用 rs1,不需要 rs2 比较。

链路是:

text
memwb_rf_we_reg

memwb_rf_we

memwb_can_forward

memwb_rd == idex_rs1

ex_match_rs1_memwb

ex_pc_use_rs1

ex_pc_fwd_rs1_from_memwb

memwb_wdata

ex_pc_rs1_val

ex_jalr_sum = ex_pc_rs1_val + idex_imm

ex_jalr_target = {ex_jalr_sum[31:1], 1'b0}

ex_pc_target

ex_pc_redirect

pc_next

pc_q_reg

你的代码里 jalr 目标地址就是用 ex_pc_rs1_val + idex_imm 单独算的。

2. 为什么 memwb_rf_we_reg -> pc_q_reg 这条链最长?

因为这条链不是单纯“前递一个数据”,而是:

text
前递命中判断

选择 branch/jalr 操作数

branch 比较或 jalr 加法

决定是否 redirect

选择 PC target

驱动 PC 寄存器的 D 或 CE

也就是说它最后控制的是 下一拍取指地址

2.1 MEM/WB -> PC 这条长链按代码变量展开

以 branch 的 rs1 为例:

text
memwb_rf_we_reg

memwb_rf_we

memwb_can_forward

memwb_rd

idex_rs1

ex_match_rs1_memwb

idex_pc_sel

ex_pc_use_rs1

ex_pc_fwd_rs1_from_memwb

memwb_wdata

ex_pc_rs1_val

ex_cmp_eq / ex_cmp_lt_signed / ex_cmp_lt_unsigned

idex_funct3

ex_br_take

ex_pc_redirect

ex_pc_target

pc_next

pc_q_reg[D] 或 pc_q_reg[CE]

其中几个特别容易拖长的点是:

  1. memwb_rf_we / memwb_can_forward 扇出很大;
  2. memwb_rd 要和 idex_rs1/idex_rs2 做比较;
  3. ex_pc_rs1_val/ex_pc_rs2_val 是 32 位 mux;
  4. branch compare 是 32 位比较;
  5. ex_br_take 还要看 idex_funct3;
  6. pc_next 是 32 位 PC mux;
  7. PC 寄存器本身还会影响 IF 级取指。

你的 timing report 里也显示,这条路径 route delay 特别大,说明主要是高扇出和布线距离问题,不是单个逻辑门太慢。

2.2 普通前递链按代码变量展开

普通前递,以 rs1 送 ALU 为例:

text
memwb_rf_we_reg

memwb_rf_we

memwb_can_forward

memwb_rd

idex_rs1

ex_match_rs1_memwb

idex_uses_rs1

ex_use_rs1_value

ex_fwd_rs1_from_memwb

memwb_wdata

ex_rs1_val

ex_alu_a

ALU

ex_alu_y

ex_wb_data

exmem_*_reg

看起来普通前递也有 ALU,好像也很长。但是它和 PC 链有两个区别。

第一,普通前递最后到的是:EX/MEM 流水寄存器,而 PC 前递最后到的是:pc_q_reg,PC 这条链还要控制跳转、flush、hold、PC mux,是全局控制路径。

第二,普通 ALU 链不会进入:

  • ex_pc_redirect
  • pc_next
  • pc_q_reg
  • IF/ID hold/flush

而 PC 链会。你的 pc_next 逻辑里 redirect 和 stall/hold 都会影响 PC 更新。

所以普通前递和 PC 前递虽然前半段都从 memwb_rf_we 开始,但后半段完全不同。

2.3 两条链的共同前半段

它们共同的前半段是:

text
memwb_rf_we_reg

memwb_rf_we

memwb_can_forward

memwb_rd != 0

memwb_rd == idex_rs1 / idex_rs2

ex_match_rs1_memwb / ex_match_rs2_memwb

这部分代码确实是共享的。

2.4 两条链从这里开始分叉

普通 ALU 前递:

text
ex_match_rs1_memwb

ex_fwd_rs1_from_memwb

ex_rs1_val

ex_alu_a

ALU

ex_wb_data

PC 前递:

text
ex_match_rs1_memwb

ex_pc_fwd_rs1_from_memwb

ex_pc_rs1_val

branch compare / jalr target

ex_pc_target

pc_next

pc_q

所以你可以这样理解:

  • 普通前递是“数据路径前递”;
  • PC 前递是“控制路径前递”。

控制路径通常更难跑高频,因为它不仅要算数据,还要决定下一拍整个流水线往哪走。

3. 如果没有 ex_pc_fwd_rs1_from_exmem 这些东西,为什么同学说 branch/jalr 走普通前递也能运行?

你同学说的情况是成立的,但前提是:他的代码里 branch/jalr 比较器直接使用普通前递后的 ex_rs1_val/ex_rs2_val

也就是说,他的设计可能是这样:

  • branch_rs1 = ex_rs1_val;
  • branch_rs2 = ex_rs2_val;
  • jalr_base = ex_rs1_val;

那普通前递链自然就能覆盖 branch/jalr。

比如:

text
memwb_wdata

ex_rs1_val

branch compare / jalr target

pc_next

这样确实不需要单独的:

  • ex_pc_fwd_rs1_from_memwb
  • ex_pc_fwd_rs2_from_memwb
3.1 但是你的代码不是这样

你的代码里普通操作数是:ex_rs1_valex_rs2_val,PC 专用操作数是:ex_pc_rs1_valex_pc_rs2_val

branch 比较器用的是 ex_pc_rs1_val/ex_pc_rs2_val

jalr 目标也用的是:ex_pc_rs1_val + idex_imm,不是 ex_rs1_val + idex_imm

所以在你的设计里,如果没有 ex_pc_fwd_*,branch/jalr 不会自动走普通前递,除非你把 branch/jalr 改成直接用 ex_rs1_val/ex_rs2_val

4. 那为什么还要单独分出 branch/jalr 前递?

核心优势是:普通 ALU 前递和 PC 前递可以采用不同策略。

这对你现在的时序优化非常重要。

4.1 普通 ALU 前递可以激进

普通 ALU 前递可以保留:

  • ex_fwd_rs1_from_exmem
  • ex_fwd_rs1_from_memwb
  • ex_fwd_rs2_from_exmem
  • ex_fwd_rs2_from_memwb

因为 ALU 结果最后进的是 EX/MEM 流水寄存器。

这条路径虽然也长,但通常比 PC redirect 更好优化。

4.2 PC 前递可以保守

PC 专属前递可以选择:

systemverilog
ex_pc_fwd_rs1_from_exmem = 1'b0;
ex_pc_fwd_rs1_from_memwb = 1'b0;
ex_pc_fwd_rs2_from_exmem = 1'b0;
ex_pc_fwd_rs2_from_memwb = 1'b0;

你当前代码里就是这样关掉的。

然后用 stall 解决 branch/jalr 依赖问题。

这样好处是:普通 ALU 仍然可以前递;branch/jalr 不再把 MEM/WB 反馈直接拉到 PC;PC 关键路径变短;更容易过 200MHz。

4.3 分开以后可以做不同 hazard 策略

例如:

普通 ALU 依赖 MEM/WB:直接前递,不 stall。branch/jalr 依赖 MEM/WB:多 stall 一拍,等 RF 写回后再进入 EX。这就是你现在准备做的优化方向。

如果普通和 PC 共用一套 ex_rs1_val,那你很难做到:ALU 继续前递;PC 不走这条长链。因为 branch/jalr 一旦也用 ex_rs1_val,普通前递网络就会自然进入 PC 逻辑。

5. 最直观的对比

不分开时
text
memwb_wdata

普通前递 mux ex_rs1_val

ALU / branch / jalr 全都用它

PC 路径被普通前递网络拖长

优点:代码简单;branch/jalr 自动享受普通前递。

缺点:PC 路径容易变成全设计最长路径;没法单独关掉 branch/jalr 的 MEM/WB 前递。

分开后

普通链:memwb_wdata -> ex_rs1_val -> ALU

PC链:memwb_wdata -> ex_pc_rs1_val -> branch/jalr -> pc_next

优点:

  • 可以保留普通前递;
  • 可以关闭 PC 前递;
  • 可以让 branch/jalr 多 stall 一拍;
  • 可以专门优化 PC 关键路径。

缺点:

  • 代码多一些;
  • hazard 控制要更仔细;
  • 如果关掉 PC 前递,必须补足 branch/jalr stall。

6. 回答你这三个问题的最终结论

问题 1

branch/jalr 专属前递和普通前递不完全一样。

它们共同的前半段是:

  • memwb_rf_we
  • memwb_can_forward
  • memwb_rd == idex_rs1/idex_rs2
  • ex_match_rs*_memwb

但后半段不同:

普通前递:ex_fwd_rs* -> ex_rs*_val -> ALU/store/M

PC前递:ex_pc_fwd_rs* -> ex_pc_rs*_val -> branch compare/jalr target -> pc_next

问题 2

memwb_rf_we_reg -> pc_q_reg 最长,是因为它不只是数据前递,还要经过:

  • 命中判断
  • 32位操作数 mux
  • branch compare / jalr add
  • branch decision
  • PC target mux
  • PC redirect
  • PC register D/CE

普通前递虽然也从 memwb_rf_we 开始,但它通常只到:ALU / EXMEM pipeline register,不会再进入全局 PC redirect 和 PC hold/flush 网络。=

问题 3

你同学说 branch/jalr 走普通前递也能运行,是因为他的 branch/jalr 可能直接使用:ex_rs1_val / ex_rs2_val,而你的代码中 branch/jalr 使用的是:ex_pc_rs1_val / ex_pc_rs2_val,所以你的设计必须单独处理 PC 前递或增加 stall。

单独分出来的最大优势是:**可以让普通 ALU 保持前递,但让 branch/jalr 不走 MEM/WB -> PC 这条超长反馈路径。**这正是现在优化时序的关键。

参考资料
赞赏博主