LA 挑战赛:BRAM 的引入

BRAM

理想的效果是,IFU 在当前周期上升沿给 inst_sram 发射地址后,在当前周期内就可以拿到 inst_data,但是这是理性情况下。

异步 RAM 很方便,但是实际情况是,CPU 的频率非常快,比 RAM 快得多,因此如果为了保持异步访存,就得将 CPU 的频率降低,这很明显会损失性能(暂不考虑 inst cache)。

这里就引入了 BRAM,它在当前周期给 BRAM 发射地址,下一个周期才能拿到数据。

pre_IFU 的提出

为了解决这样的事,首先可以将 pre_IFU 单独拿出来,设计成一个辅助模块。

需要注意的是,这个模块并不是一个流水单元,因为它没有流水向下一个单元 IFU 传递缓存的数据。它实际上是一个外挂在 IFU 的组合电路。

1
2
3
4
5
6
7
8
9
10
11
12

wire [31:0] seq_pc;
wire [31:0] nextpc;

assign seq_pc = fs_pc + 32'h4;
assign nextpc = br_taken ? br_target : seq_pc;

assign inst_addr = fs_allowin ? nextpc : fs_pc;
assign next_pc = nextpc;

endmodule

这里的难点是,在 IDU 阻塞的时候,要保持 inst_sram 输出的数据。笔者仔细观察波形图后,发现当 IDU 阻塞的时候,可以让 inst_addr 变成上一个周期的值也就是 fs_pc,这样,被阻塞在 IDU 的指令一旦阻塞停止,就可以拿到 fs_pc 位置的指令重新执行。

访问 data_sram 信号的提前

和访问 inst_sram 同理,需要将 MEM 阶段的访存信号提前到 EXU 中,这样,当访存指令进行到 MEM,就可以完成 LOAD、STORE,解决了load_use、store_load 相关。

好的,这里给出 load_usestore_load 指令冲突的例子(使用类似汇编的指令格式):

1. Load-Use 指令冲突

场景: 一条 LOAD 指令从内存中读取数据到寄存器,紧随其后的指令需要立即使用该寄存器中的数据。由于 LOAD 指令通常需要多个时钟周期才能完成内存访问并将数据写回寄存器,因此后续指令在数据准备好之前尝试读取该寄存器时会发生冲突。

示例指令序列:

1
2
LOAD R1, 0(R2)   ; 从内存地址 R2 + 0 读取数据到寄存器 R1
ADD R3, R1, R4 ; 将寄存器 R1 和 R4 的值相加,结果存入 R3

流水线执行过程(假设一个简单的五级流水线:取指 (IF)、译码 (ID)、执行 (EX)、访存 (MEM)、写回 (WB)):

时钟周期 IFU (取指) IDU (译码) EXU (执行) MEM (访存) WB (写回)
1 LOAD R1, 0(R2)
2 ADD R3, R1, R4 LOAD R1, 0(R2)
3 ADD R3, R1, R4 LOAD R1, 0(R2) (计算地址)
4 ADD R3, R1, R4 (尝试读取 R1) LOAD R1, 0(R2) (访问内存)
5 ADD R3, R1, R4 LOAD R1, 0(R2) (写入 R1)

冲突分析:

在第 4 个时钟周期,ADD 指令进入执行阶段,需要读取寄存器 R1 的值。然而,LOAD 指令在第 5 个时钟周期才能完成写回操作,将数据写入 R1。因此,ADD 指令在执行时会读取到 R1 的旧值(或者一个无效的值),导致计算结果错误。这就是 load_use 指令冲突。

解决办法(部分):

  • 流水线停顿 (Pipeline Stall):ADD 指令的执行阶段暂停一个或多个时钟周期,直到 LOAD 指令完成写回。
  • 数据前递 (Data Forwarding/Bypassing):LOAD 指令在 MEM 阶段获得的数据直接转发给 ADD 指令的执行阶段,而无需等待其写回寄存器。

遗憾的是,如果访问 BRAM,上面的方法无效。笔者前一篇博客用这两种方法结合,解决的是 load_use 冲突,但那是基于异步 RAM。这里如果是 BRAM,那么 IDU 阶段的 ADD 指令需要等待两个周期,这样控制难度提升了一大截。

因此,这里的解决办法就是将访存信号提前到 EXU 中,这样原来的 load_use 控制依然有效,就像访问异步 RAM 那样。

2. Store-Load 指令冲突

场景: 一条 STORE 指令将数据写入内存的某个地址,紧随其后的 LOAD 指令尝试从相同的内存地址读取数据。如果 LOAD 指令在 STORE 指令完成写入之前就访问了内存,可能会读取到旧的数据。

示例指令序列:

1
2
STORE R5, 0(R6)  ; 将寄存器 R5 的值写入内存地址 R6 + 0
LOAD R7, 0(R6) ; 从内存地址 R6 + 0 读取数据到寄存器 R7

流水线执行过程:

时钟周期 IFU (取指) IDU (译码) EXU (执行) MEM (访存) WB (写回)
1 STORE R5, 0(R6)
2 LOAD R7, 0(R6) STORE R5, 0(R6)
3 LOAD R7, 0(R6) STORE R5, 0(R6) (计算地址)
4 LOAD R7, 0(R6) (计算地址) STORE R5, 0(R6) (写入内存)
5 LOAD R7, 0(R6) (读取内存) STORE R5, 0(R6)

冲突分析:

在第 5 个时钟周期,LOAD 指令进入访存阶段,尝试从内存地址 R6 + 0 读取数据。然而,STORE 指令在第 4 个时钟周期才完成写入内存的操作。如果 LOAD 指令在第 5 个时钟周期读取内存时,STORE 指令的写入操作尚未完成或者数据还未完全更新,那么 LOAD 指令可能会读取到 STORE 指令写入之前的数据,导致数据不一致。这就是 store_load 指令冲突。

解决办法(部分):

  • 内存访问顺序保证: 确保 LOAD 指令在 STORE 指令完成写入操作之后再进行读取。这可能需要流水线停顿。
  • 存储转发 (Store Forwarding): 如果 LOAD 指令尝试读取的地址与之前未完成的 STORE 指令的目标地址相同,可以将 STORE 指令要写入的数据直接转发给 LOAD 指令,而无需等待数据真正写入内存后再读出。这正是博文中提到的“访问 data_sram 信号的提前”所要解决的关键问题之一。通过将访存阶段提前到 EXU,可以在 EXU 阶段就进行地址比较和数据转发,避免流水线停顿。

这些例子清晰地说明了 load_usestore_load 指令冲突在流水线 CPU 中是如何发生的,以及为什么需要相应的机制来检测和解决这些冲突,以保证程序的正确执行并提高流水线的效率。

到目前为止,笔者实现的所有方法都不用解决这种情况,因为这种情况非常少见,遇到了再说。

通过了所有测试:

1
2
3
4
5
6
7
8
9
-->CPU 1 1c010150 04 00000000
-->REF 1 1c010150 04 00000000
i:60239
-->CPU 1 1c010154 01 1c010158
-->REF 1 1c010154 01 1c010158
i:60240
i:60241
-->CPU 1 1c000100 0c bfaff091
-->REF 1 1c000100 0c bfaff091

LA 挑战赛:BRAM 的引入
http://blog.luliang.online/2025/04/16/LA挑战赛5/
作者
Luyoung
发布于
2025年4月16日
许可协议