Categories: 硬件设计.

前言

本文基于一个自行设计的 LoongArch32 (LA32) 指令集 CPU,采用 STAR 框架(Situation-Task-Action-Result)整理设计过程中的架构决策、问题定位与调试经验。该 CPU 在 Verilator 仿真验证环境下主频达到 75MHz,已通过龙芯官方功能测试集(Chiplab 环境下 58 个测试点)。在运行 CoreMark 等性能测试程序的调试过程中,我定位并修复了 I-Cache 取指响应重放问题和流水线 RAW 冒险的边界缺陷。本文将围绕这两个问题展开详细的代码级分析。


Situation(背景)

项目概述

本项目实现了一个支持 LoongArch32 精简指令集的流水线 CPU,用于龙芯杯(NSCSCC)竞赛。主要特性如下:

参数 配置
指令集 LoongArch32 (LA32R)
主频 75 MHz(Verilator 仿真验证)
流水线深度 6 级(IF → ID → EXE1 → EXE2 → MEM → WB)
I-Cache 2 路组相联,256 组,128-bit 行(4 字),共 16KB
D-Cache 2 路组相联,256 组,128-bit 行,Write-Back,共 16KB
分支预测 16 项直接映射 BTB
地址翻译 TLB(软件管理)
总线接口 AXI4(I/D 共享 axi_bridge)
异常处理 WB 阶段统一检测(trap_unit)
验证方法 Verilator + NEMU 在线 difftest

流水线结构

CPU 采用 6 级流水线设计,各级功能划分如下:

1
2
3
4
5
6
7
┌──────┐    ┌──────┐    ┌───────┐    ┌───────┐    ┌──────┐    ┌──────┐
│ IF │───▶│ ID │───▶│ EXE1 │───▶│ EXE2 │───▶│ MEM │───▶│ WB │
│取指 │ │译码 │ │ALU计算│ │访存地址│ │数据访存│ │写回 │
│I-Cache│ │分支判定│ │乘除启动│ │D-Cache│ │结果选择│ │RegFile│
└──────┘ └──────┘ └───────┘ └───────┘ └──────┘ └──────┘
↑ ↓
└────────────────── PC 重定向(异常/分支/ertn)─────────────┘

与经典的 5 级流水线相比,本设计将执行阶段拆分为 EXE1(ALU 运算)和 EXE2(访存地址发射)两级。这样做的好处是将 ALU 计算与 D-Cache 访问解耦,便于时序收敛;但代价是增加了流水线深度,进而加大了分支惩罚和数据冒险的停顿窗口。

冒险处理机制概述

本 CPU 采用 转发(Forwarding)+ 停顿(Stall) 相结合的冒险处理策略:

  • 转发:在 ID 阶段组合逻辑中,从 EXE1、EXE2、MEM、WB 四个后级阶段向前级转发数据
  • 停顿:通过 ready_goallow_in 信号阻塞流水线各级,处理无法通过转发解决的依赖

验证环境

CPU 代码集成在 Chiplab 仿真环境中,使用 Verilator 进行周期精确仿真,并通过 NEMU 模拟器进行在线 difftest(差异测试)——每个提交的指令都会与 NEMU 的执行结果进行比对,一旦出现寄存器或内存状态不一致,仿真立即停止并报告出错 PC。

测试集包括: - 功能测试:82 个测试用例(n1~n81 + tlb_initialization),覆盖全部 LA32 指令、异常处理、TLB 操作、Cache 维护操作(cacop)和原子指令,合计 58 个测试点 - 性能测试:20 个 benchmark 程序,包括 CoreMark、Dhrystone、SHA、快速排序等


Task(任务)

在通过全部功能测试后,运行 CoreMark 性能测试时 difftest 报告了指令执行不一致。调试任务需要解决两个核心问题:

  1. I-Cache 取指响应重放问题:在分支跳转或异常返回后,I-Cache 中一个尚未完成的旧路径取指请求的响应可能被错误地送入 ID 阶段,导致执行了已被丢弃路径上的指令。
  2. RAW 冒险处理边界缺陷:流水线的转发链覆盖了 EXE1~WB 四个后级,但遗漏了 ID→ID 窗口(生产者尚未离开 ID 阶段时消费者已进入)的依赖检测;此外 Load-Use 场景下的 store-data 转发存在数据通路缺陷。

任务目标是对这两个问题进行深入的代码级分析,定位根因并修复。


Action(行动)

一、I-Cache 取指响应重放问题

1.1 I-Cache 架构

I-Cache 采用 2 路组相联设计:

1
2
3
4
5
6
7
8
// icache_two_way.v
reg valid0 [0:255]; // Way 0 有效位
reg valid1 [0:255]; // Way 1 有效位
reg [19:0] tag0 [0:255]; // Way 0 标签 (PA[31:12])
reg [19:0] tag1 [0:255]; // Way 1 标签
reg [127:0] data0 [0:255]; // Way 0 缓存行 (4 个字)
reg [127:0] data1 [0:255]; // Way 1 缓存行
reg used [0:255]; // LRU 替换位

地址分解:Tag(20-bit) | Index(8-bit) | Word(2-bit) | Byte(2-bit)

I-Cache 采用阻塞式(Blocking)状态机,同一时刻只处理一个取指请求。Cache Miss 时通过 AXI 总线从内存 Refill 一个 128-bit 缓存行(4 拍)。

1.2 问题现象

运行 CoreMark 时,difftest 在 pc=0x1c0001ac 处报告 store 指令执行结果与 NEMU 不一致。具体表现为:

  • 0x1c0001ac 处的 st.w 指令使用了错误的基地址 $r12 = 0x19
  • 该地址触发 ALE(地址对齐异常),badvaddr = 0x19
  • 0x1c0001b8 处的 ori $r12, $r12, 0x19 才是写入 0x19$r12 的指令

这说明 0x1c0001acst.w 指令在 $r120x1c0001b8 修改之后又被重新执行了一次。

1.3 根因分析

通过在 core_top.v 中添加 I-Cache 取指状态追踪日志([ICACHE-LATE-1AC]),确认了以下时序:

1
2
3
4
5
6
7
Cycle N    : IF 发起 pc=0x1c0001ac 的取指请求 (if_req_pending=1)
Cycle N+1 : ID 阶段检测到分支跳转,PC 重定向到新地址
→ 旧的 0x1c0001ac 请求应被丢弃
Cycle N+2 : 0x1c0001b8 (ori $r12, 0x19) 流入并执行,$r12 被修改
Cycle N+3 : I-Cache 对 0x1c0001ac 的 AXI 响应终于返回 (data_ok)
→ 旧响应被错误地接收为有效指令,0x1c0001ac 的 st.w 重新进入 ID
Cycle N+4 : st.w 使用已被污染的 $r12=0x19 作为基地址 → ALE 异常

问题的根因在于:当 PC 重定向发生时,I-Cache/AXI 总线上可能有一个尚未返回响应的旧取指请求(outstanding fetch)。这个旧请求的响应在重定向之后才返回,但原始设计中没有机制来识别和丢弃这个”过期”响应。

在原始代码中,取指响应的接收逻辑过于简单:

1
2
3
4
5
6
7
8
9
// 原始逻辑(有问题):只要 data_ok 就接收响应
always @(posedge clk) begin
if (inst_sram_data_ok) begin
// 直接将响应送入 ID 阶段,不检查是否已被重定向
if_to_id_valid <= 1'b1;
if_to_id_pc <= if_req_pc;
if_to_id_inst <= inst_sram_rdata;
end
end

1.4 修复方案

修复的核心思路是引入 取指请求生命周期管理,跟踪每个取指请求从发起到响应返回的完整状态,并在 PC 重定向时标记待丢弃的响应:

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
53
54
55
56
57
58
// 修复后的 IF 取指响应管理逻辑 (core_top.v)

reg if_req_pending; // 是否有未完成的取指请求
reg [31:0] if_req_pc; // 未完成请求的 PC
reg if_discard_pending; // 是否需要丢弃下一个响应
reg if_resp_valid; // 响应缓冲有效
reg [31:0] if_resp_pc; // 响应 PC
reg [31:0] if_resp_inst; // 响应指令

// PC 重定向信号:分支跳转 / BTB 预测失败 / 异常
wire if_fetch_redirect = id_br_taken_safe || btb_mispredict ||
br_cancel_fallthrough || if_pending_wrong_after_resp;

// 响应是否应该被丢弃
wire inst_resp_should_discard = if_discard_pending || if_fetch_redirect;

// 只在 data_ok 返回且不应丢弃时才接收响应
wire accept_inst_resp = inst_sram_data_ok && inst_resp_has_request
&& !inst_resp_should_discard;

always @(posedge clk) begin
if (rst || wb_ex || wb_is_ertn) begin
if_req_pending <= 1'b0;
if_discard_pending <= 1'b0;
if_resp_valid <= 1'b0;
end else begin
// 1. 重定向发生时,标记待丢弃
if (if_fetch_redirect) begin
if (if_req_pending)
if_discard_pending <= 1'b1; // 有未完成请求,标记丢弃
if_resp_valid <= 1'b0; // 清除已缓冲响应
end

// 2. 新请求发出时记录 PC
if (inst_sram_req && inst_sram_addr_ok) begin
if_req_pc <= pc_inst_addr;
if_req_pending <= 1'b1;
if_discard_pending <= if_fetch_redirect;
end

// 3. 响应返回时处理
if (inst_sram_data_ok) begin
if_req_pending <= 1'b0;
if_discard_pending <= 1'b0;
if (accept_inst_resp) begin
if_resp_pc <= if_req_pending ? if_req_pc : pc_inst_addr;
if_resp_inst <= inst_sram_rdata;
if_resp_valid <= 1'b1;
end
// 如果 should_discard,则直接丢弃,不送入 ID
end

// 4. ID 消费响应
if (if_resp_valid && id_allow_in) begin
if_resp_valid <= 1'b0;
end
end
end

修复后,IF 到 ID 的数据通路改为通过 if_resp_valid 缓冲:

1
2
3
assign if_to_id_valid = if_resp_valid;
assign if_to_id_pc = if_resp_valid ? if_resp_pc : 32'b0;
assign if_to_id_inst = if_resp_valid ? if_resp_inst : 32'h02800000;

这个修复确保了:即使 AXI 总线的取指响应在 PC 重定向之后才返回,也不会被错误地送入流水线。if_discard_pending 机制精确地跟踪了”哪个请求应该被丢弃”,避免了误丢有效响应的问题。

1.5 关于 I-Cache 一致性的更广泛讨论

上述问题本质上是 取指一致性(Fetch Coherence) 问题,而非传统意义上的多核 Cache 一致性问题。在单核场景下,I-Cache 一致性主要涉及以下场景:

  1. 自修改代码(Self-Modifying Code):程序在运行时修改了自己的代码段。LoongArch 通过 cacop 指令进行 I-Cache 无效化操作来保证一致性。本设计支持 cacop op0(Index Invalidate)和 op2(Hit Invalidate),功能测试中 n73_icacop_op0n75_icacop_op1 均已通过。

  2. 异常返回后的取指ertn 指令返回到异常前的 PC,此时如果 I-Cache 中有旧路径的取指请求尚未完成,就会出现上述问题。这是本次修复的重点。

  3. D-Cache Write-Back 与 I-Cache 的一致性:当 D-Cache 使用 Write-Back 策略时,如果代码段的数据被修改但尚未写回内存,I-Cache 可能读到旧数据。本设计中代码段和数据段使用不同的物理页面,且 cacop 指令可以强制写回和无效化,因此在功能测试中未暴露此问题。


二、RAW 冒险处理问题

2.1 转发机制

本 CPU 的数据转发在 ID 阶段完成,通过组合逻辑从四个后级阶段(EXE1、EXE2、MEM、WB)中选择最新的写入数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// core_top.v - ID 阶段转发逻辑
wire [31:0] id_src1_fwd;
assign id_src1_fwd = (exe1_ref_we && exe1_rd == rf_raddr1 && rf_raddr1 != 5'd0) ? exe1_alu_result :
(exe2_ref_we && exe2_rd == rf_raddr1 && rf_raddr1 != 5'd0) ? exe2_alu_result :
(mem_ref_we && mem_rd == rf_raddr1 && rf_raddr1 != 5'd0) ? mem_alu_result :
(wb_rf_we && wb_rd == rf_raddr1 && rf_raddr1 != 5'd0) ? rf_wdata :
rf_rdata1; // 无转发,读 RegFile

wire [31:0] id_src2_fwd;
assign id_src2_fwd = (exe1_ref_we && exe1_rd == rf_raddr2 && rf_raddr2 != 5'd0) ? exe1_alu_result :
(exe2_ref_we && exe2_rd == rf_raddr2 && rf_raddr2 != 5'd0) ? exe2_alu_result :
(mem_ref_we && mem_rd == rf_raddr2 && rf_raddr2 != 5'd0) ? mem_alu_result :
(wb_rf_we && wb_rd == rf_raddr2 && rf_raddr2 != 5'd0) ? rf_wdata :
rf_rdata2;

转发优先级为 EXE1 > EXE2 > MEM > WB,这是因为越接近 ID 的阶段拥有越新的数据。

2.2 问题一:ID→ID 窗口的冒险遗漏

问题分析:转发链覆盖了 EXE1~WB 四个阶段,但当一条指令在 ID 阶段写寄存器(id_ref_we 有效),而紧随其后的下一条指令已经进入 IF 阶段并正在读取同一个寄存器时,由于生产者尚未进入 EXE1,转发逻辑无法捕获这个依赖。

这个窗口在功能测试中不容易暴露——因为功能测试中 IF 和 ID 的推进节奏基本同步。但在性能测试中,I-Cache 命中时 IF 推进较快,而 ID 阶段可能因各种原因停顿,使得 IF 阶段的指令”追上”了 ID 阶段的指令。

修复:在 if_ready_go 中增加 ID→ID 冒险检测:

1
2
3
4
5
6
7
8
9
10
// core_top.v - ID→ID RAW 冒险检测
// 当 ID 中的指令写寄存器,而 IF 中的下一条指令读同一寄存器时,停顿 IF
wire next_rj = if_inst[9:5];
wire next_rk = if_inst[14:10];
wire next_reads_id_rd = id_ref_we && id_rd != 5'd0 &&
((next_rj == id_rd) || (next_rk == id_rd));

assign if_ready_go = rst ? 1'b1 :
next_reads_id_rd ? 1'b0 : // ID→ID 冒险,停顿 IF
// ... 其他停顿条件

2.3 问题二:Load-Use 冒险的停顿机制

当 load 指令(ld.w/ld.h/ld.b)在 MEM 阶段读取 D-Cache 数据时,其结果尚未写回,无法通过标准的转发通路传递给 ID 阶段的消费者。本设计通过在 if_ready_goid_ready_gopipline_is_not_stalled 中检测 MEM 阶段的 load 指令依赖来停顿流水线:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// core_top.v - Load-Use 冒险停顿
// 当 MEM 阶段有 load 指令且 ID 阶段的指令依赖其目标寄存器时,停顿
assign if_ready_go = /* ... */ :
(mem_ref_we && mem_rd != 0 &&
((id_src1_from_ref && (rf_raddr1 == mem_rd)) ||
(id_src2_from_ref && (rf_raddr2 == mem_rd)))) ? 1'b0 :
/* ... */;

// 同样的检测也出现在 id_ready_go 和 pipline_is_not_stalled 中
assign id_ready_go = /* ... */ :
(mem_ref_we && mem_rd != 0 &&
((id_src1_from_ref && (rf_raddr1 == mem_rd)) ||
(id_src2_from_ref && (rf_raddr2 == mem_rd)))) ? 1'b0 :
/* ... */;

assign pipline_is_not_stalled = /* ... */ :
(mem_ref_we && mem_rd != 0 &&
((id_src1_from_ref && (rf_raddr1 == mem_rd)) ||
(id_src2_from_ref && (rf_raddr2 == mem_rd)))) ? 1'b0 :
/* ... */;

需要注意的是,MEM 阶段的 mem_ref_wemem_rd 来自 load 指令的目标寄存器信息,而 mem_alu_result 在 load 完成前不可用,因此只能停顿而非转发。

2.4 问题三:Store-Data 转发缺陷

在调试 CoreMark 时,difftest 在 pc=0x1c000078 处报告 store 指令写入的数据与 NEMU 不一致。追踪发现这是一条 ld.w → st.w 序列:

1
2
3
ld.w  $r15, [$r12 + offset]    // 从内存加载数据到 $r15
...
st.w $r15, [$r13 + offset] // 将 $r15 存入内存

ld.w 的数据在 MEM 阶段返回时,st.w 已经在 EXE2 阶段需要 store 数据(src2)。但原始设计中,store 数据的转发通路没有覆盖 MEM 阶段 load 返回的数据——也就是说,D-Cache 返回的 load 数据没有被正确地转发给后一条 store 指令的写数据通路。

修复:追踪 dram_wdata/wdata/rkd/src2/store 信号链路,在 core_top.vID_stage.vExE_reg.v 中修补了 store 数据的转发路径,确保 MEM 阶段返回的 load 数据能被同一周期内需要 store 数据的指令使用。

2.5 冒险检测的冗余设计

值得注意的是,本设计的冒险停顿信号在 if_ready_goid_ready_gowb_ready_gopipline_is_not_stalled 四处进行了冗余检测

1
2
3
4
5
// 四处独立但相同的冒险检测逻辑
// 1. if_ready_go — 控制 IF→ID 推进
// 2. id_ready_go — 控制 ID→EXE1 推进
// 3. wb_ready_go — 控制 WB 阶段(与 inst_sram_data_ok 关联)
// 4. pipline_is_not_stalled — 全局停顿信号

这种冗余设计虽然增加了代码维护成本,但确保了在任何一级的停顿信号传播延迟下,冒险不会被遗漏。代价是可能引入不必要的额外停顿周期。