前言
本文基于一个自行设计的 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 | ┌──────┐ ┌──────┐ ┌───────┐ ┌───────┐ ┌──────┐ ┌──────┐ |
与经典的 5 级流水线相比,本设计将执行阶段拆分为 EXE1(ALU 运算)和 EXE2(访存地址发射)两级。这样做的好处是将 ALU 计算与 D-Cache 访问解耦,便于时序收敛;但代价是增加了流水线深度,进而加大了分支惩罚和数据冒险的停顿窗口。
冒险处理机制概述
本 CPU 采用 转发(Forwarding)+ 停顿(Stall) 相结合的冒险处理策略:
- 转发:在 ID 阶段组合逻辑中,从 EXE1、EXE2、MEM、WB 四个后级阶段向前级转发数据
- 停顿:通过
ready_go和allow_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 报告了指令执行不一致。调试任务需要解决两个核心问题:
- I-Cache 取指响应重放问题:在分支跳转或异常返回后,I-Cache 中一个尚未完成的旧路径取指请求的响应可能被错误地送入 ID 阶段,导致执行了已被丢弃路径上的指令。
- RAW 冒险处理边界缺陷:流水线的转发链覆盖了 EXE1~WB 四个后级,但遗漏了 ID→ID 窗口(生产者尚未离开 ID 阶段时消费者已进入)的依赖检测;此外 Load-Use 场景下的 store-data 转发存在数据通路缺陷。
任务目标是对这两个问题进行深入的代码级分析,定位根因并修复。
Action(行动)
一、I-Cache 取指响应重放问题
1.1 I-Cache 架构
I-Cache 采用 2 路组相联设计:
1 | // icache_two_way.v |
地址分解: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的指令
这说明 0x1c0001ac 的 st.w 指令在
$r12 被 0x1c0001b8
修改之后又被重新执行了一次。
1.3 根因分析
通过在 core_top.v 中添加 I-Cache
取指状态追踪日志([ICACHE-LATE-1AC]),确认了以下时序:
1 | Cycle N : IF 发起 pc=0x1c0001ac 的取指请求 (if_req_pending=1) |
问题的根因在于:当 PC 重定向发生时,I-Cache/AXI 总线上可能有一个尚未返回响应的旧取指请求(outstanding fetch)。这个旧请求的响应在重定向之后才返回,但原始设计中没有机制来识别和丢弃这个”过期”响应。
在原始代码中,取指响应的接收逻辑过于简单:
1 | // 原始逻辑(有问题):只要 data_ok 就接收响应 |
1.4 修复方案
修复的核心思路是引入 取指请求生命周期管理,跟踪每个取指请求从发起到响应返回的完整状态,并在 PC 重定向时标记待丢弃的响应:
1 | // 修复后的 IF 取指响应管理逻辑 (core_top.v) |
修复后,IF 到 ID 的数据通路改为通过 if_resp_valid
缓冲:
1 | assign if_to_id_valid = if_resp_valid; |
这个修复确保了:即使 AXI 总线的取指响应在 PC
重定向之后才返回,也不会被错误地送入流水线。if_discard_pending
机制精确地跟踪了”哪个请求应该被丢弃”,避免了误丢有效响应的问题。
1.5 关于 I-Cache 一致性的更广泛讨论
上述问题本质上是 取指一致性(Fetch Coherence) 问题,而非传统意义上的多核 Cache 一致性问题。在单核场景下,I-Cache 一致性主要涉及以下场景:
自修改代码(Self-Modifying Code):程序在运行时修改了自己的代码段。LoongArch 通过
cacop指令进行 I-Cache 无效化操作来保证一致性。本设计支持cacopop0(Index Invalidate)和 op2(Hit Invalidate),功能测试中n73_icacop_op0和n75_icacop_op1均已通过。异常返回后的取指:
ertn指令返回到异常前的 PC,此时如果 I-Cache 中有旧路径的取指请求尚未完成,就会出现上述问题。这是本次修复的重点。D-Cache Write-Back 与 I-Cache 的一致性:当 D-Cache 使用 Write-Back 策略时,如果代码段的数据被修改但尚未写回内存,I-Cache 可能读到旧数据。本设计中代码段和数据段使用不同的物理页面,且
cacop指令可以强制写回和无效化,因此在功能测试中未暴露此问题。
二、RAW 冒险处理问题
2.1 转发机制
本 CPU 的数据转发在 ID 阶段完成,通过组合逻辑从四个后级阶段(EXE1、EXE2、MEM、WB)中选择最新的写入数据:
1 | // core_top.v - ID 阶段转发逻辑 |
转发优先级为 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 | // core_top.v - ID→ID RAW 冒险检测 |
2.3 问题二:Load-Use 冒险的停顿机制
当 load
指令(ld.w/ld.h/ld.b)在 MEM
阶段读取 D-Cache 数据时,其结果尚未写回,无法通过标准的转发通路传递给 ID
阶段的消费者。本设计通过在
if_ready_go、id_ready_go 和
pipline_is_not_stalled 中检测 MEM 阶段的 load
指令依赖来停顿流水线:
1 | // core_top.v - Load-Use 冒险停顿 |
需要注意的是,MEM 阶段的 mem_ref_we 和
mem_rd 来自 load 指令的目标寄存器信息,而
mem_alu_result 在 load
完成前不可用,因此只能停顿而非转发。
2.4 问题三:Store-Data 转发缺陷
在调试 CoreMark 时,difftest 在 pc=0x1c000078 处报告
store 指令写入的数据与 NEMU 不一致。追踪发现这是一条
ld.w → st.w 序列:
1 | ld.w $r15, [$r12 + 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.v、ID_stage.v、ExE_reg.v
中修补了 store 数据的转发路径,确保 MEM 阶段返回的 load
数据能被同一周期内需要 store 数据的指令使用。
2.5 冒险检测的冗余设计
值得注意的是,本设计的冒险停顿信号在
if_ready_go、id_ready_go、wb_ready_go
和 pipline_is_not_stalled
四处进行了冗余检测:
1 | // 四处独立但相同的冒险检测逻辑 |
这种冗余设计虽然增加了代码维护成本,但确保了在任何一级的停顿信号传播延迟下,冒险不会被遗漏。代价是可能引入不必要的额外停顿周期。