Computer System II Lab1 :基于Stall的五级流水线

引言:

单周期CPU固然可以顺序,安全地执行指令,但不可否认的是会有大量时间的浪费。本实验将单周期CPU优化为基于stall的流水线CPU。通过IF,ID,EXE,MEM,WB,五个阶段分别完成对应的任务,并通过一些手段避免冲突,在安全执行指令的同时,大大提高CPU的运行效率。

1 实验目的

在这个实验中我们要实现Pipeline——流水线CPU

2 实验原理:

将上学期写过的单周期CPU改为流水线CPU,并实现5级stall。通过IF,ID,EXE,MEM,WB,五个阶段分别完成对应的任务,实现指令的执行并与此同时大大提高CPU的运行速率。

3 实验步骤

3-1 SCPU to PipeLine

由上述原理图可以看到,我们在SCPU中使用的许多模块在PipeLine中仍然需要用到,且这些模块的内部结构并未发生很大变化。

所以为将SCPU改写成PipeLine首先要明确Pipeline的每个阶段所要用到的模块。

同时我们注意到除了各个阶段的划分,Pipeline中还添加了四个阶段间寄存器分别为:

分别在阶段和阶段间进行数据传输和信号传递

所以预实验主要分为以下几个部分:

SCPU回顾与模块划分,阶段间寄存器的设置,阶段间寄存器与模块间的布局引线。

3-1-1 SCPU回顾与模块划分

3-1-1-1 SCPU模块功能回顾

在SCPU中,我们主要有以下几个模块:

  • ALU : 计算模块,主要负责将传入的一组数据根据传入的操作信号(op_code)进行不同的计算操作并返回计算结果
  • RAM:CPU的内存,在代码段加载程序所要运行的指令,在数据段进行内存的访问和读写
  • Regs:寄存器组,由32个512位寄存器构成。
  • Immegen:立即数生成模块,从传入的指令中提取立即数。
  • Mux:多路选择器组,包括pc写回 Mux4,立即数选择Mux2等
  • Bomb:本人所添加的模块,用于判断两数据的大小关系(包括有符号数与无符号数)并传出less和alu_option供跳转模块判断
  • SCPU:单周期CPU主模块,在该模块中主要进行pc的层加与inst的迭代
  • Control: 控制模块,用于分析指令并输出控制信号。
3-1-1-2 根据Pipeline阶段进行模块划分

现在我们可以根据这些模块的功能,参考Pipeline中的五个阶段简单分个类:

graph TD;
emperor3((RAM)) --> IF;
emperor((Regs))-->WB;
emperor --> ID;
emperor2((Control)) --> ID;

emperor4((ALU))--> EXE;
emperor5((Bomb)) --> ID;
emperor6((Immgen)) --> ID;
emperor3 --> MEM;
emperor7((MUX)) --> ID;
emperor7 --> EXE;

嗯,看起来也不是很多。

请注意!在这里我将Mux选择(寄存器or立即数)与Immgen解码均放在ID部分,ID传给EXE的只有数据和op_code。这个更改将贯通Pipeline的始终。

具体解释一下:

  • RAM:RAM中有数据段也有内存段,所以使用RAM结构的有两个阶段,分别是IF和MEM:
1
2
3
4
5
6
7
8
9
10
11
RAM ram( // 时序电路,均为时钟上升沿读写。
.clk(clk),
.rstn(rstn),
.rw_wmode(rw_wmode), //mem
.rw_addr(addr_out>>3), //mem
.rw_wdata(rw_wdata), //mem
.rw_wmask(mask_offset), //mem
.rw_rdata(Ram_to_Path_temp), //mem
.ro_addr(((npc_sel_EXE)?pc_in:((IF_stall)?pc_IF:(pc_IF+4)))>>3), //IF
.ro_rdata(inst_temp) //IF
);

可以看到对于接口来讲,MEM和IF并不冲突,且均在时钟上升沿进行取指令or读写内存,所以可以很好地共用RAM结构。

  • Regs:Regs结构需要用的阶段有WB(写回),ID译码。两者也不冲突,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
Regs register( //Regs模块
.clk(clk),
.rst(rstn),
.we(reg_write),
.read_addr_1(inst_ID[19:15]), // ID
.read_addr_2(inst_ID[24:20]), // ID
.write_addr(inst_WB[11:7]), // WB
.write_data(Ram_to_Path), // WB
.read_data_1(read_data_1), // ID
.read_data_2(read_data_2), // ID
.debug_reg(debug_reg), // ?
.fin_reg(fin_reg) // ?
);

但请注意,在Regs内部,WB的数据写回是通过时序电路控制的,也就是说,仅在时钟上升沿进行数据的传入写回

而ID的译码,即访问寄存器中数值,是通过组合电路进行的,也就是说二者的写回和读取有可能不同步。由于我们将实验指导中推荐放在EXE阶段的MUX和Immgen均放在了ID阶段,所以这里需要特别注意,尤其是后期加入stall控制拍数的时候。

剩下的结构就没什么好说的了:

  • Control:对传入ID阶段的指令进行译码。

  • Immgen:将inst中的立即数提取出来,

  • Mux:通过Control传出的控制信号进行数据的选择(寄存器 or 立即数)。或在EXE阶段判断指令跳转和pc写回。

  • Bomb:在ID阶段对译码出的两操作数做预先的数值比较,为后续br_taken判断做铺垫。

这里展示一下Bomb的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module Bomb(
input [63:0]I0,
input [63:0]I1,
output [1:0]less,
output [63:0]alu_option
);
reg less_reg;
always @(*) begin
if(I0 < I1) less_reg = 1;
else less_reg = 0;

end
assign alu_option = I0 - I1;
assign less = less_reg;
endmodule

3-1-2 阶段间寄存器的设置

首先我们应该明确什么是阶段间寄存器:

阶段间寄存器(也称为流水线寄存器或流水线暂存器)是一种在流水线架构的处理器设计中使用的硬件元素。它位于处理器的各个流水线阶段之间,用于存储前一阶段产生的中间结果,以便下一阶段可以在下一个时钟周期中使用这些结果。 这样的设计可以降低不同阶段之间的依赖性,允许每个阶段独立地在其自己的速率上运行,从而提高处理器的整体性能。阶段间寄存器还有助于减少数据冒险和控制冒险,这些都是流水线设计中常见的问题。

OK, 简而言之,我们各个阶段的执行都是同步的,也就是说,在阶段和阶段之间不可能通过组合电路连接(会产生严重的数据干扰与冲突)。换句话说,在同一个时钟周期内,阶段与阶段间的数据,信号,包括PC,INST,都是独立的,互不干扰的。

那么在时钟信号更迭时,我们就需要一个东西在阶段间进行指令/数据传输,以满足下一个时钟周期各个阶段有事可做。

明确,这个东西需要有以下几点特性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
module IFID(
input clk,
input rst,
input stall,
input flush,
input [63:0]pc_IF,
input[31:0]inst_IF,
input valid_IF,
input [63:0]pc_4_IF,
output reg [63:0]pc_ID,
output reg [31:0]inst_ID,
output reg valid_ID,
output reg [63:0]pc_4_ID
);

always @(posedge clk) begin
if(rst|flush)begin
pc_ID <= 64'b0;
inst_ID <= 32'b0;
valid_ID <= 1'b0;
pc_4_ID <= 64'b0;

end
else if(~stall)begin
pc_ID <= pc_IF;
inst_ID <= inst_IF;
valid_ID <= valid_IF;
pc_4_ID <= pc_4_IF;
end
end

endmodule
  • 时序电路控制:为了保证阶段与阶段间在同一个时钟周期内指令数据互不干扰,阶段间寄存器一定要用时序电路控制。一方面,在时钟信号更迭时可以瞬间在阶段间传递数据。另一方面,在每一个时钟周期内对相邻阶段起到保护作用,保证其独立性

  • 信号/数据接受与发送:阶段间寄存器一个巨大的作用是在阶段与阶段间传递指令和数据,这个功能通过一个always_pse_clk块还是很好完成的。

  • stall与flush:我认为这是阶段间寄存器最重要的作用之一。在Pipeline中会有很多数据冲突与信号冲突。阶段间寄存器可以将产生冲突或即将产生冲突的阶段隔断,或使上一个阶段等待下一个阶段,或使下一个阶段插入bubble,来解决冲突。(在后面会着重介绍)

  • 尽可能多的数据传递:因为,也说不准后面的阶段需要什么数据或信号,那就尽量多传一点,到时候解决数据冲突也很容易。

类似于上面展示出的IFID阶段间寄存器,我们可以写出IDEXE, EXEMEM, MEMWB。代码在此不表~

3-1-3 阶段间寄存器与模块间布局引线

嗯,在这个部分嘛,我们需要在通过阶段间寄存器把阶段与阶段的模块之间连上线。各个阶段间需要传递的数据主要是:

  • IF ->ID : pc, inst
  • ID -> EXE: rs1, rs2, op_code, control, pc , inst, rd
  • EXE -> MEM : ALU_result, control, pc, inst, rd
  • MEM -> WB : control , inst, ALU_result, rd, pc

但实际上我们需要传递的信号与数据远远不止这些,还有比如:

  • rs1_name/rs2_name : 辅助冲突判断,stall的关键判断信号
  • valid_X : 表示该阶段现处理指令是否为bubble
  • br_taken : EXE阶段跳转指令传出信号,传入IF强行改变pc读取跳转地址处指令。并将ID,EXE现阶段指令flush掉

等等。 阶段间寄存器在模块外连线如下图所示:(拿IDEXE来举例)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
IDEXE IDEXE1(
.clk(clk),
.rst(!rstn),

.stall(IDEXE_stall),
.flush(IDEXE_flush),

.pc_ID(pc_ID), // ID阶段的pc
.pc_4_ID(pc_4_ID), //ID的pc+4
.inst_ID(inst_ID), //ID阶段的inst
.valid_ID(valid_ID), //ID阶段处理的指令是否为bubble
.rs1_ID(rs1_data_ID), //ID阶段rs1的data
.rs2_ID(rs2_data_ID), //ID阶段rs2的data
.rs1_name_ID(rs1_ID), //ID阶段rs1的名称
.rs2_name_ID(rs2_ID), //ID阶段rs2的名称
.rs2_MEM_ID(rs2_MEM_ID), //ID阶段需要传给MEM阶段进行存内存的数据。
.b_ID(b_ID), // ID阶段的信号控制流
.alu_option_ID(alu_option_ID),
.less_ID(less_ID), //ID阶段的辅助跳转判断变量
// input
.pc_EXE(pc_EXE),
.inst_EXE(inst_EXE),
.pc_4_EXE(pc_4_EXE),
.valid_EXE(valid_EXE),
.rs1_EXE(rs1_data_EXE),
.rs2_EXE(rs2_data_EXE),
.b_EXE(b_EXE),
.rs2_MEM_EXE(rs2_MEM_EXE),
.rs1_name_EXE(rs1_EXE),
.rs2_name_EXE(rs2_EXE),
.alu_option_EXE(alu_option_EXE),
.less_EXE(less_EXE)
//output
);

各种信号在每次时钟信号上升沿进行传递,在其他时间隔离,保证了阶段与阶段间指令处理的独立性。

按照上述IDEXE的信号传递逻辑对IFID, EXEMEM, MEMWB三模块进行模块内外的布局引线。

3-2 Stall and Flush

在Pipeline中,冲突是非常常见的现象,具体分为以下几类:

  • 结构冲突:是指令在重叠执行的过程中,硬件资源满足不了指令重叠执行的要求,发生硬件资源冲突而产生的冲突。

但在我们写的MIPS流水,使用stall方案来解决,不存在结构冲突,由此结构冲突的内容及解决方案在此不表。

  • 数据冲突:是指在同时重叠执行的几条指令中,一条指令依赖于前面指令执行结果数据,但是又得不到时发生的冲突。
  • 控制冲突:它是指流水线中的分支指令或者其他需要改写PC的指令造成的冲突。

3-2-1 数据冲突

数据冲突主要分为以下几种情况:

3-2-1-1 写后读冲突(RAW:Read After Write)

指令j的执行需要使用指令i的计算结果,但是当它们在流水线中重叠执行时,指令j可能在指令i将其计算结果写入之前就先行对保存该计算结果的寄存器进行了读操作,这样指令j读出的寄存器值就是错误的。

例如:

1
2
add x1, x2, x3
sub x4, x5, x1

其中,sub指令需要用到add指令的rd寄存器x1。但当sub指令处于EXE阶段时,add指令并未进行写回,也就是说,sub指令需要等待add指令写回之后才可进行EXE的操作,此时得到的x1的值是写回之后的新值。

3-2-1-2 写后写冲突(WAW:Write After Write)

指令j和指令i的目的操作数相同,但是当它们在流水线中重叠执行时,指令j可能在指令i将其计算结果写入之前就先行对保存该计算结果的寄存器进行了写操作,这样就导致了寄存器写入顺序的错误,此时,目的寄存器的内容是指令i写入的值,而不是指令j写入的值。(i把j结果冲掉了)

通俗来讲,也就是我们所说的吞指令。

但在我们的MIPS指令流水中并不会发生WAW冲突,只有RAW冲突。

3-2-2 控制冲突

控制冲突主要发生在 bne,beq,jal,jalr等跳转指令的执行过程中。

在我们所写的MIPS指令流水中,默认把跳转判断放在EXE阶段进行。当然该判断可以放在EXE之后的任何阶段,只不过需要bubble次数更多,CPU性能会有所下降而已~

当EXE阶段判断指令跳转时,需要更改pcIF为跳转的pc,并将IF, ID的现处理指令flush掉。

3-2-3 RaceControl

那么为了解决数据冲突和控制冲突,我们需要两个信号去控制阶段间寄存器,让他们在本拍内:

  • 暂停数据传输 --> stall
  • 插入空指令(bubble) -->flush

首先我们需要Pipeline中另一个非常重要的模块去给四个阶段间寄存器传输stall和flush信号。

也就是RaceControl模块

graph TD;
empe(RaceControl) -.Stall/flush.-> IF;
empe(RaceControl) -.Stall/flush.-> IF/ID;
empe(RaceControl) -.Stall/flush.-> ID/EXE;
empe(RaceControl) -.Stall/flush.-> EXE/MEM;
empe(RaceControl) -.Stall/flush.-> MEM/WB;

下面我们推演一下,如何通过stall和flush去正确执行两拍具有数据冲突和控制冲突的指令

3-2-3-1 数据冲突stall推演

现在我们有两条指令:

1
2
add x1, x3, x4
sub x5, x2, x1

现在我们将第一条指令统称为ADD,第二条指令成为SUB,bubble的指令成为NOP。(默认ADD的前继指令均为NOP)

  • 在利用Pipeline处理这两条指令时,ADD先进入,SUB后进入,假设此时ADD到了ID阶段,SUB则应在IF阶段。
graph TD;
IF --> SUB;
ID --> ADD;
EXE --> NOP;
MEM --> NOP;
WB --> NOP;
  • 走一拍,此时ADD到了EXE阶段,SUB到了IF阶段。
graph TD;
IF --> O(NOP);
ID --> SUB;
EXE --> ADD;
MEM --> NOP;
WB --> NOP;

此时EXE阶段执行操作数的计算。我们再走一拍试试看:

graph TD;
IF --> O(NOP);
ID --> O;
EXE --> SUB;
MEM --> ADD;
WB --> NOP;

此时SUB指令到了EXE阶段。这时我们对SUB指令的操作数进行分析:

graph LR;
SUB --> rs1 ;
rs1 --> x2;
SUB -->rs2 
rs2 --> x1;
ADD --> rd;
rd --> WB ;
WB --> x1;

也就是说,SUB指令的操作数x1是ADD指令的rd寄存器且当SUB指令执行到EXE阶段时,ADD指令还没有进行x1的写回,导致数据冲突。这时候我们不能让它再走了,要将SUB指令在ID段停住,等待ADD指令走完。

  • RaceControl触发,ID_EXE_STALL,IF_ID_STALL,PC_STALL

因为停ID不能只停ID,前面的所有阶段都要等待EXE,MEM,WB走完,所以当IDstall的时候IF, PC也不能走。

graph LR;
RaceControl -.Stall.-> IF ;
IF --> O(NOP);
RaceControl -.Stall.-> ID ;
ID --> SUB;
RaceControl -.Flush.-> EXE;
EXE --> O1(BUBBLE);
RaceControl -.->MEM ;
MEM --> ADD;
RaceControl -.->WB;
WB --> NOP;
  • 等待3拍,ADD指令走完,SUB指令STALL解除。继续执行。此时x1寄存器已经写回,数据冲突解除。

由此可知:

  • 当发生数据冲突时,必须在冲突的两条指令在ID, EXE阶段就进行STALL信号的传输

  • 后一条指令必须等前一条指令走完全程,才可以解除STALL

  • 发生数据冲突,只有IF, ID在Stall, EXE插Bubble

我们可以利用如下的表达式判断是否需要Stall:

1
data_race_if = ((((rs1_ID == rd_EXE)|(rs2_ID == rd_EXE))&(rd_reg_write))|((re_MEM_MEM)&((rs1_ID == rd_MEM)|(rs2_ID == rd_MEM)))|((reg_WB)&((rs1_ID == rd_WB)|(rs2_ID == rd_WB))));

简而言之,只要ID阶段的rs1和rs2和EXE,MEM,WB阶段的任何一个rd相等,都需要Stall,等待冲突的那条指令走完

3-2-3-2 控制冲突Stall推演

控制冲突发生在指令跳转的时候。我们有如下代码段:

1
2
3
4
5
6
beq x0, 0, label
add ...
sub ...
...
Label:
lui .....

假定beq指令发生跳转,且我们判断跳转并触发控制冲突的阶段为EXE。

  • 现在假设beq指令走到EXE阶段
graph TD;
IF --> SUB;
ID --> ADD;
EXE --> BEQ;
MEM --> NOP;
WB --> NOP;
  • 判断跳转,EXE阶段计算出跳转的地址并传出跳转信号给IF阶段,改变下一条指令的pc, 并改变IF当前所取的指令
graph LR;
EXE --> PC_jump;
EXE --> br_taken;
br_taken --> IF;
IF --> pc-next ;
pc-next --> PC_jump;
IF --> inst;
PC_jump --> RAM ;
 RAM --> inst;
  • ID阶段刷新, EXE阶段插入bubble,走一拍:
graph TD;
IF --> lui;
ID --> NOP;
EXE --> NOP;
MEM --> BEQ;
WB --> O(NOP);

这样我们就解决了控制冲突。

下面我们可以根据上述的推演写出RaceControl的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
module RaceControl(
// input IF_stall,
// input MEM_stall,
input npc_sel_EXE,

input [4:0]rs1_ID,
input [4:0]rs2_ID,

// input [4:0]rs1_EXE,
// input rs1_use_EXE,
// input [4:0]rs2_EXE,
// input rs2_use_EXE,
input [4:0]rd_EXE,
input [4:0]rd_MEM,
input [4:0]rd_WB,
input rd_reg_write,
input re_MEM_MEM,
input we_MEM_EXE,
input reg_WB,
output IF_stall,

// output MEM_stall,
output pc_stall,
output IFID_stall,
output IFID_flush,
output IDEXE_flush,
output IDEXE_stall

// output EXEMEM_stall,
// output EXEMEM_flush
// output MEMWB_stall,
// output MEMWB_flush

);

wire data_race_if = ((((rs1_ID == rd_EXE)|(rs2_ID == rd_EXE))&(rd_reg_write))|((re_MEM_MEM)&((rs1_ID == rd_MEM)|(rs2_ID == rd_MEM)))|((reg_WB)&((rs1_ID == rd_WB)|(rs2_ID == rd_WB))));
wire predict_flush = npc_sel_EXE;
assign IF_stall = data_race_if;
assign pc_stall = IF_stall;
assign IFID_stall = data_race_if ;
assign IFID_flush = ~IFID_stall & IF_stall | predict_flush;
assign IDEXE_stall = data_race_if;
assign IDEXE_flush = IDEXE_stall | predict_flush;

// assign EXEMEM_flush = predict_flush;
// assign EXEMEM_stall =


endmodule

3-3 Dram to Bram

在MEM访存阶段,我们在SCPU中使用的是Dram,而在Pipeline中需要改成Bram。

  • DRAM代表动态随机存取存储器(Dynamic Random Access Memory)。它是一种常见的随机存储器,用于存储数据和指令。DRAM使用电容存储数据,需要定期刷新电容以保持数据的稳定性。它是易失性存储器,意味着当电源关闭时,存储在DRAM中的数据将会丢失。DRAM的优点包括较高的存储密度和相对较低的成本,但它的访问速度相对较慢。

  • BRAM代表块随机存取存储器(Block Random Access Memory)。它是一种集成在现场可编程逻辑器件(FPGA)中的存储器类型。BRAM是用于在FPGA中存储数据和配置信息的内部存储器。与DRAM不同,BRAM是静态存储器,它使用触发器来存储数据,无需刷新操作。BRAM的优点包括较快的访问速度、低功耗和可编程性。它在FPGA设计中经常用于实现缓冲区、存储器和算法逻辑等功能。

最大的区别是:

在取内存的时候不能再一个时钟周期内就取出,需要在上一个时钟周期就把需要读/写的地址传入RAM,在下一个时钟周期才能将数据读出来/写进去。由此,为保证MEM阶段就已经完成数据的读出/写入,我们需要把大部分的内存操作提前一拍,即在EXE阶段完成:

使用Dram的模块划分:

graph TD;
EXE;
MEM --> Datatrunc;
MEM --> Maskgen;
MEM --> DataPKG;

现在为在MEM阶段就已经完成数据的读出/写入,我们需要将将MaskGen,DataPKG模块提前一拍,即在EXE阶段完成

graph TD;
EXE --> DataPKG;
EXE --> MaskGen;
MEM --> DataTrunc;

DataTrunc模块作用是将读出的数据进行加工,所以在MEM阶段进行即可。

EXE阶段的执行代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
module EXE(
input [31:0]inst,
input [31:0]pc,
input [21:0]b,
input [63:0]rs1,
input [63:0]rs2,
input [63:0]alu_option,
input valid_EXE,
input less,
input [4:0]rs1_name,
input [4:0]rs2_name,
input [63:0]rs2_MEM_EXE,
output rd_reg_write_EXE,
output [63:0]ALU_result,
output [63:0]pc_next,
output [4:0]rd_EXE,
output [63:0]addr_out,
output [7:0]mask_offset,
output rw_wmode,
output [63:0]rw_wdata,
output br_taken
);

ALU alu( //ALU计算模块
.a(rs1),
.b(rs2),
.alu_op(b[15:12]),
.res_p(ALU_result)
);

// wire [63:0]pc_next;
// wire br_taken;
assign rd_reg_write_EXE = b[21];
wire pc_src;
assign pc_src = b[19];
wire [2:0]bralu_op;
assign bralu_op = b[11:9];
wire [63:0]pc_wire;
assign pc_wire = pc;
assign rd_EXE = inst[11:7];

Mux4_32_pc mux3( //Mux4_32_pc模块
.I0(pc_wire + 4),
.I1(ALU_result),
.K(pc_src), // wire pc_src = b[19];
.branch_and(bralu_op), // assign bralu_op = b[11:9];
.alu_option(alu_option),
.less(less),
.result(pc_next),
.br_taken(br_taken)
);

// -----------------------------MEM--------------------------------//
assign addr_out = ALU_result;
assign rw_wmode = b[20];
reg [7:0]mask;
wire [2:0]width = b[2:0];
always @(*) begin
case(width)
3'b000 :begin mask = 8'b00000000;end
3'b001 : begin mask = 8'b11111111;end
3'b010 : begin mask = 8'b00001111;end
3'b011 :begin mask = 8'b00000011;end
3'b100 :begin mask = 8'b00000001;end
3'b101 :begin mask = 8'b00001111;end
3'b110 :begin mask = 8'b00000011;end
3'b111 :begin mask = 8'b00000001; end
default : begin
mask = 8'b11111111;
end
endcase
end

assign rw_wdata = rs2_MEM_EXE << ({3'b0,addr_out[2:0]}<<3); /*Path to Ram*/
assign mask_offset[7:0] = mask[7:0] << ({1'b0,addr_out[2:0]});
endmodule

这样就可以避免在执行sd,ld指令的时候因为Bram与Dram的差异而产生冲突啦~。

4 仿真与上板测试:

现在我们已经完成了所有Pipeline的模块编写,下面开始测试:

4-1 仿真测试:

将lab1文件夹中的v文件移至上学期的lab5文件夹并执行make TESTCASE=full样例测试:

pass~

4-2 上板测试:

拨下debug开关,观察pc_WB,数值为9A8,pass~

5 Questions

5-1 Test1 CPI

代码段如下,起始pc = 0xc ,结束pc = 0x24

1
2
3
4
5
6
7
8
9
000000000000000c <fibonacci>:
c: 002081b3 add gp,ra,sp
10: 003100b3 add ra,sp,gp
14: 00308133 add sp,ra,gp
18: fff20213 addi tp,tp,-1
1c: fe4018e3 bne zero,tp,c <fibonacci>
20: 63d00293 addi t0,zero,1597
24: 0c511e63 bne sp,t0,100 <fail>

5-1-2 PipeLine CPU

使用Pipeline CPU运行syn.hex,make wave观察仿真波形,调取test1处的的波段,计算运行的周期数(T)和指令条数(N),算出CPI。

起始时间

结束时间

周期数

共执行的指令条数

所以

5-1-3 SCPU

使用SCPU 运行syn.hex, make wave观察仿真波形,调取test1处的的波段,计算运行的周期数(T)和指令条数(N),算出CPI。

起始时间

结束时间

周期数

共执行的指令条数

所以 综上

5-2 Test2 CPI

代码段如下, 起始pc = 0x28,结束pc = 0x8c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
0000000000000028 <test2>:
28: 11000113 addi sp,zero,272
2c: 15000193 addi gp,zero,336
30: 00010083 lb ra,0(sp)
34: 00118023 sb ra,0(gp)
38: 00110083 lb ra,1(sp)
3c: 001180a3 sb ra,1(gp)
40: 00211083 lh ra,2(sp)
44: 00119123 sh ra,2(gp)
48: 00412083 lw ra,4(sp)
4c: 0011a223 sw ra,4(gp)
50: 00813083 ld ra,8(sp)
54: 0011b423 sd ra,8(gp)
58: 00014083 lbu ra,0(sp)
5c: 0011b823 sd ra,16(gp)
60: 00010083 lb ra,0(sp)
64: 0011bc23 sd ra,24(gp)
68: 00015083 lhu ra,0(sp)
6c: 0211b023 sd ra,32(gp)
70: 00011083 lh ra,0(sp)
74: 0211b423 sd ra,40(gp)
78: 00016083 lwu ra,0(sp)
7c: 0211b823 sd ra,48(gp)
80: 00012083 lw ra,0(sp)
84: 0211bc23 sd ra,56(gp)
88: 00800093 addi ra,zero,8
8c: 00800293 addi t0,zero,8

5-2-2 PipeLine CPU

使用Pipeline CPU运行syn.hex,make wave观察仿真波形,调取test1处的的波段,计算运行的周期数(T)和指令条数(N),算出CPI。

起始时间

结束时间

周期数

共执行的指令条数

所以

5-2-3 SCPU

使用SCPU 运行syn.hex, make wave观察仿真波形,调取test1处的的波段,计算运行的周期数(T)和指令条数(N),算出CPI。

起始时间

结束时间

周期数

共执行的指令条数

所以 综上

5-3 冲突情况归纳

5-3-1 数据冲突(Data Hazards):

  • 读后写(Read After Write,RAW)冲突:当一个指令在写入某个数据之后,紧接着的另一个指令要读取同一数据时发生。这可能导致后续指令读取到旧的数据值,因为写操作尚未完成。

  • 写后读(Write After Read,WAR)冲突:当一个指令在读取某个数据之后,紧接着的另一个指令要写入同一数据时发生。这可能导致后续指令读取到错误的数据,因为写操作会改变数据的值。

  • 写后写(Write After Write,WAW)冲突:当两个指令依次写入同一数据时发生。这可能导致后续指令读取到错误的数据,因为写操作之间存在竞争条件。

但请注意,在我们的Stall,MIPS流水中只存在WAR冲突,不存在其他两种冲突。

5-3-2 控制冲突(Control Hazards):

  • 分支冲突(Branch Hazards):当程序中的条件分支指令(如if语句或循环)无法立即确定分支目标时发生。这会导致流水线中的指令预测错误,需要清空流水线并重新开始执行。

  • 跳转冲突(Jump Hazards):当程序中的无条件跳转指令(如函数调用或跳转表)无法立即确定跳转目标时发生。与分支冲突类似,跳转冲突也会导致流水线中的指令预测错误,需要清空流水线(flush)重新开始执行。

更加具体的分析请看报告 3-2 Stall and Flush

5-4 ID与EXE,MEM,WB多阶段冲突

这个部分我们已经在 3-2-3-1 数据冲突与stall推演 中详细介绍过了。

首先需要明确,如果两条指令发生冲突并且stall正常作用,那么以下几种情况是不可能存在的。

  • 后一条指令当前阶段处于前一条指令之后,或与前一条指令处于相同阶段。 (I)
  • 后一条指令在EXE,MEM,WB的任一阶段时,前一条指令也位于其一。(II)
  • 后一条指令位于EXE阶段时,前一条指令仍未退出流水线。(III)

综上所述,如果ID阶段的读寄存器和EXE,MEM,WB阶段的多阶段写寄存器发生冲突的话,需要满足:

  • 位于EXE,MEM,WB三阶段的指令互不冲突:

由II得,若EXE,MEM,WB阶段中存在互相冲突的指令,那么后一条指令本应的ID阶段就Stall住,不可能进入EXE及后续阶段。

  • 结论:由此,我们可以得到,ID阶段读寄存器与三阶段多个写寄存器冲突的情况其实与 3-2-3-1中分析的情况的解决方案是一样的,因为EXE,MEM,WB阶段的指令互不冲突,那么他们就可以按照正常流水执行,位于ID阶段的指令只要等到最后一条与之冲突的指令退出流水线,再执行就OK了 。最后一条冲突指令位于EXE/MEM/WB时,只需将IF, ID, PC stall 3/2/1拍,EXE阶段不断插入Bubble即可。

  • 例如:

1
2
3
4
add x1, .... // WB
sub x1, .... // MEM
ld x1, .... // EXE
sltu x2, x1, 0x10 // ID

此时,stall三拍,等待前三条指令退出流水线

1
2
3
4
5
6
7
add x1, .... // over
sub x1, .... // over
ld x1, .... // over
nop // WB
nop // MEM
nop // EXE
sltu x2, x1, 0x10 // ID

ID阶段指令再进入EXE顺序执行就OK了。

5-5 数据冲突和控制冲突同时发生

有如下代码段:

1
2
3
add x1, x2, x3
beq x0, 0, label
sub x4, x5, x1 // 与add指令冲突

当 ADD指令到MEM阶段时,BEQ指令在EXE阶段,SUB指令在ID阶段,此时:

1
2
3
add x1, x2, x3 // MEM
beq x0, 0, label // EXE
sub x4, x5, x1 // ID

现在,由于出现了数据冲突与控制冲突同时发生的情况

这种情况下我们采取控制冲突优先的手段处理即:

  • EXE阶段之后的指令正常执行,IF , ID阶段flush信号触发, 在EXE阶段插入bubble
1
2
3
4
5
6
7
add .. //WB
beq .. //MEM
NOP .. //EXE
NOP .. //ID
LABEL:
inst1 .. //IF
inst2 ..
  • IF, ID阶段Stall不触发,IF阶段pc更新为跳转后的pc, 即PC_jump

然后按照新的pc顺序执行。

也就是说,在数据冲突和控制冲突同时发生的时候,我们优先考虑控制冲突,即按照处理控制冲突的方式处理。

5-6 Bram in Regs

根据 3-1 SCPU to Pipeline中的通路图,Regs模块被ID, WB两个阶段被用到。如果要将其改写为Bram模块我们需要做如下操作:

将Regs模块中的取寄存器,写寄存器操作全改为时序电路,即只在时钟上升沿触发,代码改写如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
module Regs (
input clk,
input rst,
input we,

input [4:0] read_addr_1,
input [4:0] read_addr_2,
input [4:0] write_addr,
input [63:0] write_data,
output reg [63:0] read_data_1,
output reg [63:0] read_data_2
);

integer i;
(* ram_style = "block" *)reg [63:0] register [0:31]; // x1 - x31, x0 keeps zero
// 这里不太确定是不是ram_style....但应该是这么写没错叭。。。?
always@(*)begin
register[0] = 0;
end
always @(posedge clk or negedge rst) begin
if (!rst) for (i=0 ; i < 32 ; i = i + 1) register[i][63:0] <= 64'h00000000 ; // reset
else if (we == 1&&write_addr!=0) begin
register[0] <= 0;
register[write_addr][63:0] <= write_data[63:0];
end // write register
read_data_1 <= register[read_addr_1]; // 时序电路
read_data_2 <= register[read_addr_2];
end
endmodule

同时,ID的取寄存器要提前一拍进行,即移到IF阶段。将需要读的寄存器地址在IF阶段就传给Regs模块,只需要改变Regs接口所传入的指令为inst_IF,而不是inst_ID,即可在下一拍通过read_data_1,read_data_2两条线自然传回ID,完成了寄存器数据的同步读取。

1
2
3
4
5
6
7
8
9
10
11
12
13
Regs register( //Regs模块
.clk(clk),
.rst(rstn),
.we(reg_write),
.read_addr_1(inst_IF[19:15]), // ID 改为inst_IF
.read_addr_2(inst_IF[24:20]), // ID 改为inst_IF
.write_addr(inst_WB[11:7]), // WB
.write_data(Ram_to_Path), // WB
.read_data_1(read_data_1), // ID
.read_data_2(read_data_2), // ID
.debug_reg(debug_reg), // ?
.fin_reg(fin_reg) // ?
);

ID的接口连线如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ID ID1(
.inst(inst_ID),
// .mask(mask),
.pc(pc_ID),
.valid_ID(valid_ID),
.read_data_1(read_data_1),
.read_data_2(read_data_2),
.rs1_name(rs1_ID),
.rs2_name(rs2_ID),
.rs1(rs1_data_ID),
.rs2(rs2_data_ID),

.b(b_ID), //decoder
// .pc_next(pc_in),
// .br_taken(npc_sel_EXE),

.alu_option(alu_option_ID),
.rs2_WB(rs2_MEM_ID),
.less(less_ID)
);

这样就完成了基本的Bram替换Dram,实现Regs模块。

但这么做可能会有一些问题比如:

  • 速度和延迟:与传统的寄存器相比,BRAM的访问速度较慢,因为它位于存储层次结构的较低级别。这会引入延迟,可能导致指令执行的性能下降。特别是在需要频繁读写寄存器的情况下,BRAM的延迟可能成为瓶颈

  • 容量限制:BRAM的容量相对有限,远远小于传统的寄存器文件。这意味着在使用BRAM实现寄存器组时,可能无法提供足够多的寄存器。如果设计需要大量的寄存器,BRAM的容量限制可能会成为问题。

  • 写冲突和竞争条件:当多个指令同时尝试写入同一个BRAM位置时,会发生写冲突和竞争条件。由于BRAM的写入操作是顺序执行的,这可能导致指令的顺序被打乱或数据被覆盖。需要适当的同步机制来处理这些冲突和竞争条件。

  • 接口和控制逻辑:使用BRAM作为寄存器组需要适当的接口和控制逻辑,以实现寄存器的读写操作。这涉及到将BRAM与处理器的指令集架构和总线系统进行适配,可能需要额外的复杂性和开发工作

结论:辩证地来看,我们需要根据实际的硬件环境来决定是否要采用Bram来实现寄存器组。

5-7 关键路径

打开vivado,综合,打开 t summary

找到slack最短的Path,即Path1, 此路径即为Pipeline的关键路径。看到时延

在计算机体系结构中,"slack"(松弛时间)是指指令流水线中的额外等待时间。它表示在特定情况下,由于冲突或其他原因,流水线中的某些阶段需要等待以便继续执行。 Slack的存在是由于指令流水线中的依赖关系和冲突。当一个指令需要等待之前的指令完成某个操作(如读取数据)时,就会产生松弛时间。这可能是由于数据冲突、控制冲突、结构冲突或资源冲突等原因引起的

将该路径用电路图展示出来:

对该电路图进行分析:.......

好吧分析不了一点根本看不明白是啥,而且这好像不是一条从IF到WB的完整通路,而是中间的一段回路?

该Path从ID_EXE阶段间寄存器起始,将rs1的值传入ALU_result_MEM,接着生成br_taken。

之后br_taken有传给EXE。。。。(这信号不是从EXE传出去的么,and为什么ALU_result_MEM会传给EXE,,这玩应不也是EXE传出去的么。。。)

之后,,好像到了IF。。。?嗯。。好像把什么莫名其妙的东西传给了IF阶段的pc。。。

好的,不妨让我们大胆猜测一下这是Pipeline在EXE阶段处理一条跳转指令。

  • EXE阶段产判断跳转指令的跳转条件,并生成br_taken信号

  • br_taken信号传递给Race_Control模块,生成IFID_Flush的信号传递给IFID阶段间寄存器插入bubble。

  • br_taken信号传递给Race_Control模块,生成IDEXE_Flush的信号传递给IDEXE阶段间寄存器插入bubble

  • br_taken信号指挥调度IF阶段的pc为跳转之后的pc,并在RAM模块处将下一条指令的地址设置为跳转之后指令的pc。

嗯,感觉确实做了蛮多事情的。