从零开始学RISC:第八篇
前递:快人一步
激烈的斗争
我们做完了吗?还没完。
在 之前 的测试里,程序没有任何数据冒险——在实际运行中这几乎是不可能的。我们简单修改一点:
1 | addi x1,x0,1 |
然后运行。发生了什么?我们期望看到x7输出的是x1和x2变化后相加的值,应当为28。但是计算结果输出了错误的3!
这就是最经典的“数据冒险”。我们看看发生了什么。
1 | x1 x2 x7 IF ID EX MEM WB |
这是流水线示意图。可以看到,因为写回在WB级才被完成,导致第三条指令在ID级取到了旧的值。直到第六个周期结束,源寄存器的值才更新完毕。
空泡与阻塞
为了不让计算取到错误的值,我们可以等。时间,会给出答案。在前面的计算还没完成时,让译码级一直等待,直到从寄存器堆中读取正确的值就行。我们需要对数据进行判断:如果两条指令之间存在数据关联,则给出一个信号,之后IF级的程序计数器根据信号来决定是否停止、ID/EX级流水线寄存器根据信号进行冲刷。
从上面的例子可知,只要间隔不超过三条的指令,都有着数据冒险的可能。因此,判断一下即可。记得判断一下写入的目标是否为0,毕竟0被塞了多少东西也不会吭一声。之后,再定义一个判断信号,用于控制冲刷:
1 | always_comb begin |
然后接到CPU顶层模块的对应位置。测试一下!
可以看到,流水线被停顿了很久,但是至少结果出来了。
与其停滞不前,不如大步向前
流水线停顿确实能解决数据冒险的问题,但是效率太低了。我们直接把EX级算出来的结果送到前面一级用就是了。上面的信号改个名字就可以拿来用了。
1 | // 前递数据选择 |
然后接入前递使能到ID/EX级流水线寄存器:
1 | // 前递信号与数据 |
再测试一下,可以看到提前了三个周期便算出了结果,这个延时刚好对应上面算出的数据相关性间隔。波形图上x7紧接着x2的变化,和预期的一样!
1 | x1 x2 x7 IF ID EX MEM WB |
Load-Use冒险
除了常见的依赖冒险之外,还有一种“取数-使用”型冒险。我们举一个简单的例子:
1 | addi x1,x0,1 |
可以看到,本应当计算出4的x5现在只是1。
原因是要先从DRAM中读出值存入寄存器,之后再读取寄存器值进行计算,但读出的值在送到寄存器之前就需要被使用,怎么办?我们也可以加入前递。
可以吗?
L-Type指令在流水线中要经过MEM级才能得到从DRAM返回的数据,而普通的前递是把 EX 级产生的 ALU 结果直接送给后续指令的 EX 阶段使用。换句话说,L-Type的数据在 EX 之后、MEM 之后才可用。单靠常规 的EX2EX 转发是无法消除的。
那我们加一个MEM2EX级的前递路径不就行了?想法是好的,但是流水线执行坏了:
- L-Type的数据往往在 MEM 级的末期才可用(这里是MUX之后输出的结果),而 EX 的操作数通常在该周期早期就要用到,会出现时序错误;
- 插入一个气泡更为简单,也更容易控制流水线流动。
那直接插气泡就行了,何乐而不为呢?
1 | // Load_use 冒险判断 |
事实上,对于Load-Use类冒险,最标准的做法就是插入空泡。这也是几乎教科书中都提及的方法。
分支跳转:Jumpin'
B-Type类指令压根不需要写寄存器,只是在EX级计算出是否跳转后更新PC值。我们在 执行级 已经写好了下一PC计算模块,其中take_branch输出就是是否跳转。在跳转时,我们需要让程序计数器的下一PC取到跳转目标地址,并冲刷IF/ID级的流水线寄存器。为了分别以后接入分支跳转预测模块,我们预留一个输入端口。
1 | logic branch_predicted_result; |
然后写个简单的分支跳转测试:
1 | addi x1, x0, 5 # x1 = 5 |
运行一下:
可以看到程序计数器接受了branch_op信号并正确选择了分支跳转结果。
为什么x3变为0后,过了四个周期,x4才变为10?这是因为我们直接阻塞流水线,x3的值可以通过旁路提前送过去,但是只有在beq执行完毕后才能得知是否跳转。跳转目标的指令addi x4在beq指令 EX 级结束后的下一周期才进入IF级:
1 | x3 x4 IF ID EX MEM WB |
数据冒险控制模块 HazardUnit.sv
1 |
|
复杂测试
到此,我们的CPU已经完成70%了。运行一些复杂的汇编程序,不出意外的话,是不会有问题的。
1 | # ======================================== |
效果非常好!
为什么才完成70%?那是因为我们还没有支持lb/lh和sb/sh一类指令。在这之前,我们还有一个历史遗留问题需要去解决,那就是无法被综合的同步写异步读DRAM……








