本仓库使用verilog编写MIPS五级流水线CPU。
5级流水线,并实现forwarding相关电路。同时,我也实现了Branch指令在ID阶段提前跳转的功能,并做出了一系列调整保证CPU安全稳定的运行,成功避免了冒险的产生,加速了CPU的运行。
存储结构上采用哈佛结构,数据存储器与指令存储器分离。
设计的流水线CPU,能够实现大多数MIPS指令,在春季学期在单周期、多周期CPU上已实现的指令外,还增添了以下指令:lb、bne、blez、bgtz、bltz、jal、jalr、jr、jalr 等。
设计框图如下:
控制信号在我的代码中,由Control.v实现译码。根据指令的OpCode和Funct,将生成以下控制信号:Branch、RegWrite、RegDst、MemRead、MemWrite、MemtoReg、ALUSrc1、ALUSrc2、ExtOp、LuOp、Jop、LoadByte。
相比于多周期CPU,新增添的控制信号为JOp和LoadByte,前者用于指示该条指令是否为跳转指令,方便CPU进行跳转与stall;后者用于指示该条指令是否为lb指令,方便CPU从主存中直接取出字节。
将指令的执行阶段划分为5个阶段,分别为:指令获取(IF)、指令译码(ID)、计算执行(EX)、访问主存(MEM)、写回寄存器堆(WB)。每两个阶段间,设计一个暂存的寄存器,用于存储该条指令在接下来的阶段中会用到的控制信号。
由于总共需要有4组寄存器,来存取5个阶段间的信息传递,我将这4组寄存器命名为:IF_ID、ID_EX、EX_MEM、MEM_WB。其中IF_ID寄存器的输入有flush和hold信号,用于刷新与保持寄存器信息;ID_EX寄存器的输入有flush信号,用于刷新寄存器信息。它们的具体用法在下面涉及stall的时候详细介绍。
在分支指令或跳转指令后,由于两种指令我都设计为在ID阶段就完成跳转,因此在它们之后都只需要stall一个周期。stall的具体方法为:如果在ID阶段的Branch信号为真,或者JOp信号为真,则设置IF_ID寄存器的flush信号,使IF_ID寄存器在下一周期刷新,同时设置下一帧的PC为跳转的地址(若Branch指令判断为False,则PC还是会变为PC+4)。
设置flush_IFID的代码如下:
assign flush_IFID = Branch_ID || JOp_ID;设置PC下一帧的代码如下:
assign PC_new = (RegWrite_EX && Branch_ID && (Rw_EX == rs_ID || Rw_EX == rt_ID) && Load_EX) ? PC_now - 4 :
hold_IFID ? PC_now :
PCSrc_ID == 1 ? {PC_ID[31:28], rs_ID, rt_ID, rd_ID, Shamt_ID, Funct_ID, 2'b00} :
PCSrc_ID == 2 ? dataA_ID + 4:
Branch_ID ? PC_Branch :
PC_now + 4; 其中,第3行是针对j指令跳转的表达式,第4行是针对jr等指令跳转的表达式,第5行是针对Branch指令跳转的表达式。Branch指令在ID阶段就已完成判断,因此PC_Branch在ID阶段就已经被计算好,这样跳转就不会发生问题。
PC_Branch的计算方法如下
assign PC_Branch = Branch_ID && Zero ? PC_ID + 4 + ImmExtShift_ID : PC_ID + 4; 其中Zero信号会根据Branch指令的不同来对应产生,如beq指令产生两输入是否相等的信号,bne指令产生两输入是否不等的信号。
由于在ID阶段提前判断了分支指令,这里可能会产生数据冒险,因此分支指令前也可能需要stall。
细节而言,分为两种情况:
如果Branch的前一条指令是R型指令或计算型的I型指令,且前一条指令要写回的寄存器是分支指令需要用于比较的寄存器rs或rt时,会引起数据冒险。
如图所示,如果Branch前是R型指令或计算型的I型指令,且有数据冒险时,ALU的计算结果要到Branch指令的ID阶段结束之后才会被计算出来,这已经无法使用forwarding的方法让Branch指令正确运行了。此时需要让Branch指令stall一个周期后,再将前一条指令的ALUOut转发到Branch指令的ID阶段。如下图所示:
转发操作的实现在下面的转发单元中再仔细介绍,这里先介绍stall是如何实现的。
这里Branch指令需要stall一个周期,只需将IF_ID寄存器保持住,ID_EX寄存器刷新即可。
虽然在stall的时候,PC的值仍会变化,但是由于无论如何,当Branch指令执行完ID后,都会给PC一个新值,故此时stall不需要关注PC的变化。
如果分支前的指令是lb或lw指令,且Load出来的数据要被Branch指令用到的话,也会引起数据冒险。与情形一不同,此时数据最早出现在Load指令的MEM阶段,因此Branch指令需要stall两个周期。
数据冒险如图所示:
stall两个周期后,就可以实现转发,示意图如下:
这里stall执行起来相比情形一,略微复杂一些。
具体操作是:首先要flush寄存器IF_ID和寄存器ID_EX,然后需要将PC-4。这是因为如果仅仅hold IF_ID寄存器,只能stall一个周期;只有通过flush IF_ID寄存器的同时,将当前PC(即已经执行到Branch的ID阶段时,在IF阶段取出来的PC)重新置为PC-4才能保证stall两个周期。
置为PC-4时一定是正确的,这是因为我已经确定了前一条被执行的指令是Load指令,而不是跳转或分支指令。
控制信号flush_IFID、hold_IFID、flush_IDEX的逻辑如下:
assign flush_IFID = Branch_ID || JOp_ID;
assign hold_IFID = ((RegWrite_EX && Branch_ID && (Rw_EX == rs_ID || Rw_EX == rt_ID)) && Load_EX == 0) ||
(MemRead_EX && (rt_EX == rs_ID || rt_EX == rt_ID) && Load_EX); // next inst is branch && !Load, stall || load use hazard
assign flush_IDEX = (RegWrite_EX && Branch_ID && (Rw_EX == rs_ID || Rw_EX == rt_ID)) ||
(MemRead_EX && (rt_EX == rs_ID || rt_EX == rt_ID) && Load_EX);这里hold_IFID和flush_IFID的后面那部分是Load-Use冒险检测,前面那部分才是分支指令相关。
其中,flush_IFID与hold_IFID都是对IF_ID寄存器的控制,在不同情况下有着不同的优先级,具体实现代码如下:
always @(posedge clk or posedge reset) begin
if(reset || (flush_IFID && Load_EX)) begin
// flush
// ...
end
else if (hold_IFID) begin
// hold
// ...
end
else if (flush_IFID) begin
// flush
// ...
end
else begin
// decode
OpCode <= Instruction[31:26];
rs <= Instruction[25:21];
rt <= Instruction[20:16];
rd <= Instruction[15:11];
Shamt <= Instruction[10:6];
Funct <= Instruction[5:0];
PC_ID <= PC_IF;
end
end当目前EX阶段是Load指令时,flush_IFID比hold_IFID有着更高的优先级,这是因为此时需要stall两个周期;当目前EX阶段不是Load指令时,hold_IFID比flush_IFID有更高的优先级。
设置PC-4的代码如下:
assign PC_new = (RegWrite_EX && Branch_ID && (Rw_EX == rs_ID || Rw_EX == rt_ID) && Load_EX) ? PC_now - 4 :
hold_IFID ? PC_now :
PCSrc_ID == 1 ? {PC_ID[31:28], rs_ID, rt_ID, rd_ID, Shamt_ID, Funct_ID, 2'b00} :
PCSrc_ID == 2 ? dataA_ID + 4:
Branch_ID ? PC_Branch :
PC_now + 4; 第1行就是设置PC-4的代码,具体逻辑是:如果EX阶段是Load,下一条指令是Branch,且Load要写回的寄存器是Branch要用到的,则下一帧的PC设为PC-4。
当前一条指令是lb或lw,下一条指令是R型指令或计算型的I型指令,且Load要写入的寄存器会被下一条指令用到时,会引起数据冒险。此时在Load指令后需要stall一个周期。原理图如下:
Load出来的数据最早在MEM阶段后才出现,而Use的时候在EX阶段就已经需要了,因此Load后要stall一个周期,并转发LoadData。如下图所示:
具体实现为:执行到Load指令的EX阶段时,可以判断下一条指令是否为Use且是否存在数据冒险。如果存在,则在下一周期保持Use指令的IF_ID寄存器,并清空ID_EX寄存器。
代码上就是:
assign hold_IFID = ((RegWrite_EX && Branch_ID && (Rw_EX == rs_ID || Rw_EX == rt_ID)) && Load_EX == 0) ||
(MemRead_EX && (rt_EX == rs_ID || rt_EX == rt_ID) && Load_EX); // next inst is branch && !Load, stall || load use hazard
assign flush_IDEX = (RegWrite_EX && Branch_ID && (Rw_EX == rs_ID || Rw_EX == rt_ID)) ||
(MemRead_EX && (rt_EX == rs_ID || rt_EX == rt_ID) && Load_EX);上面代码中,hold_IFID与flush_IDEX的后半部分,是Load-Use的冒险检测部分。
由于在我的设计中,Branch指令需要在ID阶段提前判断,因此我需要实现转发到ID阶段的操作,以解决Branch指令中存在的数据冒险。
我设置了BrForwardingA和BrForwardingB两个转发单元控制信号,来控制ID阶段中Branch指令判断的两个输入。
Branch指令用于判断的两个输入变量,可以来自于三个方面:
WriteData_WB:即上一条指令从DataMem中取出的数据,适用于分支指令前为Load指令的场景。ALUOut_MEM:即上一条指令的ALU输出,适用于分支指令前为R型指令或计算型I型指令的场景。dataA_IDordataB_ID:直接从寄存器堆中根据rs与rt的值取出的数据,适用于没有数据冒险时的场景。
以上三个场景分别对应于BrForwarding控制信号为:2、1、0。
我设计的Branch转发单元实现如下:
assign BrForwardingA = rs == Rw_WB && Load_WB ? 2 : rs == Rw_MEM && RegWrite_MEM ? 1 : 0;
assign BrForwardingB = rt == Rw_WB && Load_WB ? 2 : rt == Rw_MEM && RegWrite_MEM ? 1 : 0;以BrForwardingA为例:
- 如果
rs == Rw_WB && Load_WB,说明前一条指令是Load(已经stall了两个周期),且写回的寄存器与rs相同,因此将BrForwardingA设为2。 - 如果
rs == Rw_MEM && RegWrite_MEM,说明前一条指令是R型指令或计算型I型指令(已经stall了一个周期),且写回的寄存器与rs相同,因此将BrForwardingA设为1。 - 没有数据冒险时,
BrForwardingA默认是0。
然后,ID阶段对Branch判断的输入BrJudger,会根据BrForwarding信号进行选择,代码如下:
assign BrJuderA = BrForwardingA == 1 ? ALUOut_MEM : BrForwardingA == 2 ? WriteData_WB : dataA_ID;
assign BrJuderB = BrForwardingB == 1 ? ALUOut_MEM : BrForwardingB == 2 ? WriteData_WB : dataB_ID;EX阶段ALU的输入,可能会有4种来源,分别是:
dataA_EXordataB_EX:从寄存器堆中读取出来并随流水线传到EX阶段的数据。- 移位量
Shamt或立即数ImmExtOut。 ALUOut_MEM:上一条指令的ALU计算结果。WriteData_WB:上上条指令ALU计算结果,或者是上条指令Load的结果。
我设置的转发选择信号为ALUChooseA与ALUChooseB。以上四个场景分别对应于ALUChoose为:0、1、2、3。
我设计的转发单元代码如下:
assign ALUChooseA = ALUSrcA_EX == 1 ? 1 :
(RegWrite_MEM && (Rw_MEM == rs_EX) && (Rw_MEM != 0)) ? 2 : // 优先判断MEM阶段,即前一条指令
(RegWrite_WB && (Rw_WB == rs_EX) && (Rw_WB != 0)) ? 3 : 0;
assign ALUChooseB = ALUSrcB_EX == 1 ? 1 :
(RegWrite_MEM && (Rw_MEM == rt_EX) && (Rw_MEM != 0)) ? 2 : // 优先判断MEM阶段,即前一条指令
(RegWrite_WB && (Rw_WB == rt_EX) && (Rw_WB != 0)) ? 3 : 0;这里,ALUSrcA_EX和ALUSrcB_EX是指令译码单元解码出来的
控制信号,用于指示是否要使用移位量或立即数。后面的判断就是关于转发的判断。
优先判断前一条指令是否满足转发条件,不满足时再判断前前条指令是否满足条件。
以ALUChooseA为例,判断的逻辑是:如果前一条指令要写回寄存器堆,且写回的寄存器为rs,且该寄存器不为$0,则将前一条指令的ALU输出转发到目前EX阶段指令的输入。如果前一条指令不满足转发条件,则看前前条指令(也包括前一条指令为Load的情况)。如果在WB阶段的要写回寄存器堆,且WB阶段写回的寄存器为rs,且该寄存器不是$0,则将要写回的值转发到ALU的输入。如果上述的转发条件都不满足,则直接使用从寄存器堆中读取的值。
有了ALUChoose信号后,就可以对ALU的输入进行选择,代码如下:
assign ALUinA = ALUChooseA == 1 ? {27'h0000000, Shamt_EX} :
ALUChooseA == 2 ? ALUOut_MEM :
ALUChooseA == 3 ? WriteData_WB: dataA_EX;
assign ALUinB = ALUChooseB == 1 ? ImmExtOut_EX :
ALUChooseB == 2 ? ALUOut_MEM :
ALUChooseB == 3 ? WriteData_WB: dataB_EX;数据存储器的大小我设置为512个字大小,字节地址从0x00000000到0x000007FF。
在字节地址为0x4000000C的位置,我设置其对应外部LEDs的控制信息;在字节地址为0x40000010的位置,我设置其对应七段数码管的控制信息。
Load Byre大体上和Load Word类似。我只是单独添加了一个LoadByte控制信号,并根据该控制信号来选择是LoadByte还是LoadWord。
大概思路是,先用LoadWord把一个字取出来,再根据地址的后2位,选取对应的Byte,并进行符号拓展后返回。
代码如下:
assign ReadData_MEM = LoadByte_MEM == 0 ? ReadData_Temp :
ALUOut_MEM[1:0] == 2'b00 ? {{24{ReadData_Temp[7]}}, ReadData_Temp[7:0]} :
ALUOut_MEM[1:0] == 2'b01 ? {{24{ReadData_Temp[15]}}, ReadData_Temp[15:8]} :
ALUOut_MEM[1:0] == 2'b10 ? {{24{ReadData_Temp[23]}}, ReadData_Temp[23:16]} :
{{24{ReadData_Temp[31]}}, ReadData_Temp[31:24]};其中,ReadData_Temp是从DataMemory中读取出的字。






