从零开始设计RISC-V处理器——五级流水线之数据冒险
系列文章目录(一)从零开始设计RISC-V处理器——指令系统(二)从零开始设计RISC-V处理器——单周期处理器的设计(三)从零开始设计RISC-V处理器——单周期处理器的仿真(四)从零开始设计RISC-V处理器——ALU的优化(五)从零开始设计RISC-V处理器——五级流水线之数据通路的设计(六)从零开始设计RISC-V处理器——五级流水线之控制器的设计(七)从零开始设计RISC-V处理器——五
系列文章目录
(一)从零开始设计RISC-V处理器——指令系统
(二)从零开始设计RISC-V处理器——单周期处理器的设计
(三)从零开始设计RISC-V处理器——单周期处理器的仿真
(四)从零开始设计RISC-V处理器——ALU的优化
(五)从零开始设计RISC-V处理器——五级流水线之数据通路的设计
(六)从零开始设计RISC-V处理器——五级流水线之控制器的设计
(七)从零开始设计RISC-V处理器——五级流水线之数据冒险
(八)从零开始设计RISC-V处理器——五级流水线之控制冒险
(九)从零开始设计RISC-V处理器——五级流水线之分支计算前移
(十)从零开始设计RISC-V处理器——五级流水线之静态预测
前言
上一篇文章已经完成了具有流水线结构RISC-V处理器的设计,但是在测试指令的时候发现,有些情况下指令并不能正确执行,这就是所谓的流水线冒险。本文重点介绍流水线冒险的概念以及数据冒险的解决方案。
一、流水线冒险
流水线冒险,是由于流水线机制引起的下一个周期中下一条指令无法正常执行的问题,一般分为以下三种冒险:
(1)结构冒险,一般是由于硬件不支持多条指令在同一时钟周期引起的硬件冲突问题,目前我们设计的流水线结构暂时不会涉及到这种情况。
(2)数据冒险,流水线机制中,当一条指令的执行结果还没有写回寄存器而后面的指令要使用前者目标寄存器的值的时候,就会发生数据冒险。
(3)控制冒险,在遇到条件分支指令时,由于跳转条件是否成立在执行阶段才知道,因此流水线就需要停顿或者冲刷指令才能正确运行。
以上就是常见的三种冒险,实际上我们现在只需要解决数据冒险和控制冒险。
二、数据冒险
回想一下之前设计的流水线的阶段划分,一共五个阶段,取指,译码,执行,访存,写回,五个阶段彼此独立,同时工作。
试想以下情况:
(1)执行以下两条指令
addi x1,x0,1
addi x2,x1,1
当第二条指令处在译码阶段的时候,需要读取寄存器X1的值,但是此时第一条指令处于执行阶段,按照正常的流水线进度,需要等到第五个周期之后才能将X1的结果写回。因此这个时候读出来的X1的值是旧值,这是我们不希望出现的情况。
要想解决此问题,最容易的方法就是停顿流水线,等待上一条指令的结果写回寄存器之后,再对下一条指令译码。但是这样做会大大降低流水线执行指令的效率。
第二种方法是避免这种情况的出现,比如调整指令的顺序,在两条指令中间插入其他不相关的指令,就像上一篇文章中进行测试的指令一样,刻意地避开这种情况。但是这种方法指标不治本。
第三种方法,通过添加硬件来解决。
试想,虽然第一条指令需要等第五个时钟周期之后才能写回寄存器,但是这条指令的执行结果在第三个时钟周期结束的时候就已经出来了。
同样的,虽然第二条指令在第三个时钟周期就要读取X1的值,但是真正使用X1的值是在第四个时钟周期开始的时候。
所以,我们可以打破常规,在第一条指令执行结束的时候(第三个周期末)就将待写回的X1的结果传递到第二条指令的执行阶段(第四个周期始)。
这样就可以避免停顿并且不需要额外的调整指令的顺序。
(2)执行以下三条指令
addi x1,x0,1
addi x2,x0,2
addi x3,x1,2
这种情况下,第一条和第二条指令能够正常运行,但是第三条指令出现了数据冒险。
当第三条指令处于译码阶段的时候,需要读取X1的值,但是此时第一条指令
处于访存的阶段,同样不能读取到最新的X1的值。
按照上面的分析方法,就需要在第一条指令访存阶段结束的时候(第四个周期末),将待写回的X1的结果传递到第三条指令的执行阶段开始的时候(第五个周期始)。
(3)执行以下四条指令
addi x1,x0,1
addi x2,x0,2
addi x3,x0,3
addi x4,x1,3
这种情况下,前三条指令都能正常执行。
当第四条指令处于译码阶段的时候,需要读取寄存器X1的值,但是此时第一条指令处在写回阶段,或许此处描述成“当第四条指令刚进入译码阶段的时候,需要读取寄存器X1的值,但是此时第一条指令刚进入写回阶段”更为恰当。
也就是说,虽然第一条指令已经进入了写回阶段,但是并没有完全将寄存器X1的结果写回到寄存器,可能正在写回的“路上”。
以上只是个人的直观理解,说法并不完全准确。
严格的说法应该从时序角度理解,
当第四条指令处于译码阶段的时候(第五个时钟周期),需要读取寄存器X1的值。
在之前的设计中,写寄存器发生在时钟的上升沿。
always@(posedge clk )
begin
if(W_en & (Rd!=0))
regs[Rd]<=Wr_data;
end
在上升沿采集信号,采集到的是这个时钟周期前一时刻的信号,也就是第四个时钟周期末的信号。
但是待写回的X1的值在第五个时钟周期才能到来(第一条指令进入写回阶段)。
所以在这种情况下仍然会发生冒险。
但是这种情况相比于讨论的前两种发生冒险的情况已经“好”多了。
为什么说是“好”多了呢?
因为引起这种冒险的时间差远远小于前两种,仅仅是因为错过了一瞬间而引起的冒险(采集到的信号是第四个时钟周期末的值,但我们期望采集的值在第五个时钟周期到来)。
有了以上的分析,解决这种冒险的办法自然就有了。
首先,可以参考前两种冒险的解决方案,添加路径,将数据前递。
其次,我们可以通过改变写寄存器时采集信号的时间来使其采集到正确的结果。
改成下降沿采集信号:
always@(negedge clk )
begin
if(W_en & (Rd!=0))
regs[Rd]<=Wr_data;
end
由于读寄存器的操作是组合逻辑,所以出现的现象是,在第五个周期的高电平期间,读的X1的值是旧的值,而在第五个 周期的低电平期间,读的X1的值是最新的值。这样,在第四条指令的执行阶段,采集到的X1的信号就是最新的值而不会发生冒险。
(4)执行以下指令
lw x1,0,x0
addi x2,x1,1
addi x3,x1,2
addi x4,x1,3
上面的四条指令同时出现了以上提到的三种冒险,但又有所区别。
上面讨论的三种冒险的情况有一个共同点,就是在执行阶段结束的时候,带写回X1的结果就已经知道了,所以我们可以通过将数据前递解决冒险。
但是现在面对的是访存指令,访存指令的结果要在访存结束后才能知道。
对于第二条指令来说,第二条指令处在译码阶段的时候,第一条指令处在执行阶段,第二条指令在第三个时钟周期开始的时候(执行阶段)就需要使用最新的X1的值,而此时第一条指令才刚刚进入访存阶段,还没有来的及读出待写回X1的值。
所以这种情况下的数据前递是无效的,只能通过等待一个时钟周期,等第一条指令访存阶段结束后,再进行数据前递。
对于第三条和第四条指令,则不需要额外修改,之前的数据前递的方法仍然有效。
以上的情景通俗一点来讲,就是第二条指令跟的太紧了,要数据要的太急,只能等待一会儿,而第三条和第四条指令跟的没有那么紧,所以不需要额外等待。
(5)执行以下指令
lw x1,0,x0
sw x0,0,x1
lw x2,0,x0
sw x2,1,x0
由情景(4)得出结论,在发生加载——使用型冒险时,需要停顿一个时钟周期,是因为当前指令在执行阶段需要使用上一条在执行阶段的load指令的目标寄存器的数值,但是load指令的目标寄存器的数值在访存阶段才能读出来,所以需要等待。
而现在的情景又有所不同,因为load指令后面跟的是store指令,store指令的两个源寄存器rs1和rs2是有区别的,rs1寄存器里的数用来计算地址,在EX阶段使用,rs2寄存器里面的数据用来作为写入数据存储器的数,在MEM阶段使用。
因此当store指令的rs2与load指令的目标寄存器相同时,是不需要停顿的,只需要将数据前递即可。
以上就是我们面临的五种冒险的分析,简单总结如下:
a.在一个周期开始,EX 阶段要使用上一条处在 EX 阶段指令的执行结果,此时我们将 EX/MEM 寄存器的数据前递。
b.在一个周期开始,EX 阶段要使用上一条处在 MEM 阶段指令的执行结果,此时我们将 MEM/WB 寄存器的数据前递。
c.在一个周期开始,EX 阶段要使用上一条处在 WB 阶段指令的执行结果,此时不需要前递(寄存器堆前递机制)
d.在第一种情况下,如果是上一条是访存指令,即发生加载—使用型冒险。则需要停顿一个周期。
e.在发生加载——使用型冒险的时候,如果是load后跟着store指令,并且load指令的rd与store指令的rs1 不同而与rs2相同,则不需要停顿,只需要将MEM/WB 寄存器的数据前递到MEM阶段。
三、代码设计
我们再次将五种冒险进行一个梳理,做一个非常简洁的分类:三种需要情况数据前递,一种情况需要停顿,一种使用寄存器堆前递机制解决。
对于使用寄存器堆前递机制的情况,只需要将寄存器堆的代码修改如下:
`include "define.v"
//`define INITIAL
module registers(
clk,
rst_n,
W_en,
Rs1,
Rs2,
Rd,
Wr_data,
Rd_data1,
Rd_data2
);
input clk;
input rst_n;
input W_en;
input [4:0]Rs1;
input [4:0]Rs2;
input [4:0]Rd;
input [31:0]Wr_data;
output [31:0]Rd_data1;
output [31:0]Rd_data2;
reg [31:0] regs [31:0];
///write
`ifdef INITIAL
always@(negedge clk )
begin
if(W_en & (Rd!=0))
regs[Rd]<=Wr_data;
end
`else
always@(negedge clk )
begin
if(!rst_n)
begin
regs[0]<=`zeroword;
regs[1]<=`zeroword;
regs[2]<=`zeroword;
regs[3]<=`zeroword;
regs[4]<=`zeroword;
regs[5]<=`zeroword;
regs[6]<=`zeroword;
regs[7]<=`zeroword;
regs[8]<=`zeroword;
regs[9]<=`zeroword;
regs[10]<=`zeroword;
regs[11]<=`zeroword;
regs[12]<=`zeroword;
regs[13]<=`zeroword;
regs[14]<=`zeroword;
regs[15]<=`zeroword;
regs[16]<=`zeroword;
regs[17]<=`zeroword;
regs[18]<=`zeroword;
regs[19]<=`zeroword;
regs[20]<=`zeroword;
regs[21]<=`zeroword;
regs[22]<=`zeroword;
regs[23]<=`zeroword;
regs[24]<=`zeroword;
regs[25]<=`zeroword;
regs[26]<=`zeroword;
regs[27]<=`zeroword;
regs[28]<=`zeroword;
regs[29]<=`zeroword;
regs[30]<=`zeroword;
regs[31]<=`zeroword;
end
else if(W_en & (Rd!=0))
regs[Rd]<=Wr_data;
end
`endif
//read
assign Rd_data1=(Rs1==5'd0)?`zeroword: regs[Rs1];
assign Rd_data2=(Rs2==5'd0)?`zeroword: regs[Rs2];
endmodule
对于三种需要数据前递的情况,首先分析一下我们需要将哪些信号进行传递。
上表是在之前的基础上完善的,
标红的信号为新添加的信号,黄底的信号为需要前递的信号。
我们想要前递正确的数据,就需要分辨三种不同的情况,并且给出标志信号。这就是我们将要设计的前递检测单元。
前递检测单元在执行阶段判断,给出三组控制信号forwardA,forwardB,forwardC,其具体含义如下:
控制信号 | 数据源 | 解释 |
---|---|---|
forwardA =1X | EX/MEM | ALU的第一个源操作数来自于EX/MEM流水线寄存器的数据前递 |
forwardA =01 | MEM/WB | ALU的第一个源操作数来自于MEM/WB流水线寄存器的数据前递 |
forwardA =00 | ID/EX | ALU的第一个源操作数来自于ID/EX流水线寄存器的数据 ,不需要前递 |
forwardB=1X | EX/MEM | ALU的第二个源操作数来自于EX/MEM流水线寄存器的数据前递 |
forwardB=01 | MEM/WB | ALU的第二个源操作数来自于MEM/WB流水线寄存器的数据前递 |
forwardB=00 | ID/EX | ALU的第二个源操作数来自于ID/EX流水线寄存器的数据 ,不需要前递 |
forwardC=1 | MEM/WB | 写入数据存储器的数据来自于MEM/WB流水线寄存器的数据前递 |
forwardC=0 | EX/MEM | 写入数据存储器的数据来自于EX/MEM流水线寄存器的数据,不需要前递 |
对于forwardA,forwardB,在当前的执行阶段就会发生作用并且不需要传递下去,而forwardC信号在访存阶段才会使用,因此需要扩展EX/MEM流水线寄存器。
前递检测单元的代码设计如下:
//以上就是我们面临的五种冒险的分析,简单总结如下:
//a.在一个周期开始,EX 阶段要使用上一条处在 EX 阶段指令的执行结果,此时我们将 EX/MEM 寄存器的数据前递。
//b.在一个周期开始,EX 阶段要使用上一条处在 MEM 阶段指令的执行结果,此时我们将 MEM/WB 寄存器的数据前递。
//c.在一个周期开始,EX 阶段要使用上一条处在 WB 阶段指令的执行结果,此时不需要前递(寄存器堆前递机制)
//d.在第一种情况下,如果是上一条是访存指令,即发生加载—使用型冒险。则需要停顿一个周期。
//e.在发生加载——使用型冒险的时候,如果是load后跟着store指令,并且load指令的rd与store指令的rs1 不同而与rs2相同,则不需要停顿,只需要将MEM/WB 寄存器的数据前递到MEM阶段。
module forward_unit(
input [4:0]Rs1_id_ex_o,
input [4:0]Rs2_id_ex_o,
input [4:0]Rd_ex_mem_o,
input [4:0]Rd_mem_wb_o,
input RegWrite_ex_mem_o,
input RegWrite_mem_wb_o,
input MemWrite_id_ex_o,
input MemRead_ex_mem_o,
output [1:0]forwardA,
output [1:0]forwardB,
output forwardC
);
assign forwardA[1]=(RegWrite_ex_mem_o &&(Rd_ex_mem_o!=5'd0)&&(Rd_ex_mem_o==Rs1_id_ex_o));
assign forwardA[0]=(RegWrite_mem_wb_o && (Rd_mem_wb_o !=5'd0) &&(Rd_mem_wb_o==Rs1_id_ex_o));
assign forwardB[1]=(RegWrite_ex_mem_o &&(Rd_ex_mem_o!=5'd0)&&(Rd_ex_mem_o==Rs2_id_ex_o));
assign forwardB[0]=(RegWrite_mem_wb_o && (Rd_mem_wb_o !=5'd0) &&(Rd_mem_wb_o==Rs2_id_ex_o));
assign forwardC=(RegWrite_ex_mem_o &&(Rd_ex_mem_o!=5'd0)&&(Rd_ex_mem_o!=Rs1_id_ex_o)&& (Rd_ex_mem_o==Rs2_id_ex_o)&& MemWrite_id_ex_o && MemRead_ex_mem_o );
endmodule
加入数据前递之后,ALU的数据来源变成三个,分别是ID/EX流水线寄存器的数值,EX/MEM流水线寄存器前递的数值和MEM/WB流水线寄存器前递的数值。
因此要添加三选一选择器,代码如下:
module mux3_1(
input [31:0]din1,
input [31:0]din2,
input [31:0]din3,
input [1:0]sel,
output [31:0]dout
);
assign dout=sel[1] ? din1 : sel[0] ? din2 : din3 ;
endmodule
将正确的数据前递,依靠三选一选择器以及前递检测单元产生的控制信号,将以上模块在ex_stage模块实例化,新增代码如下:
///forwarding
forward_unit forward_unit_inst (
.Rs1_id_ex_o(Rs1_ex_i),
.Rs2_id_ex_o(Rs2_ex_i),
.Rd_ex_mem_o(Rd_ex_mem_o),
.Rd_mem_wb_o(Rd_mem_wb_o),
.RegWrite_ex_mem_o(RegWrite_ex_mem_o),
.RegWrite_mem_wb_o(RegWrite_mem_wb_o),
.MemWrite_id_ex_o(MemWrite_id_ex_o),
.MemRead_ex_mem_o(MemRead_ex_mem_o),
.forwardA(forwardA),
.forwardB(forwardB),
.forwardC(forwardC)
);
///forwardA
mux3_1 mux3_1_forwardA (
.din1(ALU_result_ex_mem_o),
.din2(ALU_result_mem_wb_o),
.din3(Rd_data1_ex_i),
.sel(forwardA),
.dout(A)
);
///forwardB
mux3_1 mux3_1_forwardB (
.din1(ALU_result_ex_mem_o),
.din2(ALU_result_mem_wb_o),
.din3(Rd_data2_ex_i),
.sel(forwardB),
.dout(B)
);
还有一组前递发生在访存阶段,由于数据来源只有两种,用forwardC控制一个二选一选择器即可。
此处为了使得模块化的设计更加条理清晰,新增一个mem_stage模块。
module mem_stage(
input [31:0]Rd_data2_mem_i,
input [31:0]loaddata_mem_wb_o,
input forwardC_mem_i,
output [31:0]Wr_mem_data
);
mux mem_mux (
.data1(loaddata_mem_wb_o),
.data2(Rd_data2_mem_i),
.sel(forwardC_mem_i),
.dout(Wr_mem_data)
);
endmodule
以上就是数据冒险的处理,除了以上展示的代码以外,还需要对顶层模块加以修改,添加前递信号的路径,只需要根据五种情况仔细分析即可,有点像小学的连线题的感觉,这里不再展示代码。
四、仿真与调试
测试第一组指令:
addi x1,x0,1
addi x2,x1,1
addi x3,x2,1
addi x4,x3,1
add x5,x2,x3
add x6,x2,x4
add x7,x2,x5
add x13,x6,x7
仿真结果如下:
第一组指令出现了三种数据冒险,通过仿真可以看到均能正确执行。
测试第二组指令:
addi x3,x0,7
addi x1,x0,3
sw x1,0,x0
lw x2,0,x0
sw x2,4,x0
lw x3,4,x0
仿真结果如下:
通过波形图可以看到,在执行第一条sw指令的时候就出错了,这里的sw指令与上一条指令发生了第一种冒险,但是在上面第一组的测试中已经验证了第一种冒险能够正常执行了,那么问题处在了哪里呢?
很容易想到,是sw指令的问题,由于sw指令的特殊之处,在访存阶段才会使用源寄存器的读出的数据,因此这里特别容易忽略掉,也就是传入EX/MEM流水线寄存器的Rd_data2应该是经过多选器选择之后的数据(也就是前递之后的数据)而不是ID/EX流水线寄存器的Rd_data2(前递之前的数据)。
在顶层模块修改之后,重新仿真,得到正确结果。
以上测试了四种冒险,没有测试加载——使用型冒险需要停顿的情况,这一情况我们在下一篇文章中同控制冒险一起解决。
总结
以上就是数据冒险相关的内容,我们分析了可能出现的五种数据冒险的情况,然后提出了相应的解决办法。
简单来说就是,添加一个数据前递单元,判断五种冒险情况,产生三组选择器控制信号,分别控制三个选择器(两个三选一和一个二选一),从而选择正确的数据。
实际上我们目前只解决了四种冒险,由于加载——使用型需要停顿,所以本篇文章暂时不考虑,在下一篇文章解决控制冒险的时候,一起解决这种情况。
更多推荐
所有评论(0)