RooKie_Z P5 Verilog流水线CPU设计文档
写在前面
这是RooKie_Z的P5流水线CPU设计文档,在课上测试中本CPU取得了 满分💯的成绩。
总体设计概述
本次要求实现的指令集为add, sub, ori, lw, sw, beq, lui, jal, jr, nop,与P3、P4相同,但是工作量却天差地别,我的黑眼圈也很能说明这点。言归正传,在考虑到更好地满足转发的要求与利于课上进行增量开发的前提下,我对于P4的代码进行了一部分重构,但总体上更改并不大,具体的设计方案参考自高小鹏老师的PPT和CoekJan学长的博客,他们的详尽叙述帮我省下了大量琢磨AT方法的时间。CoekJan,YYDS!!!
五级流水线各级部件
数据通路
添加转发后的数据通路
注:MDU为乘除法单元,供P6使用
关键模块介绍
总体而言,在总体架构不作大的修改的前提下,修改了一部分控制信号,同时添加了流水线各级寄存器和虚拟的阻塞控制单元,对于每一级流水线都设计了相应的译码器。
命名规则
- 对于各模块文件,均采用对于元件的文件命名,均为
流水线层级_元件英文简称,例如D_GRF.v,E_ALU.v等,实例化时命名为大写首字母小写英文名,例如Alu,Grf等 - 对于流水线寄存器文件命名为
两边的流水线层级_REG,例如FD_REG.v,DE_REG.v,实例化时命名为大写英文层级,例如FD - 每一级的控制信号和临时的
wire均以本级的名称开头,如E_ALUOp,M_DMOp等 - 在流水线中参与流水的信息遵从以下约定(以D级为例)
PC和Instr命名以流水线层级开头,如D_PC,D_Instr- 寄存器地址分别为
D_rs_addr,D_rt_addr,读出数据为D_rs,D_rt - 转发得到的寄存器数据(直接读取也视为一种转发)记作
D_FWD_rs_data,D_FWD_rt_data - 即将写入的寄存器地址为
E_A3,即将写入的数据记作E_WD,选择信号为E_WDSel
F级(取指)
- F级不需要转发,阻塞时停止读入指令,置低PC_WE
- 本级输入来自D级的NPC,输出F_PC和F_Instr,参与流水
IFU
| 信号名称 | 方向 | 功能描述 |
|---|---|---|
| NPC[31:0] | I | 待写入PC的指令地址 |
| clk | I | 时钟信号 |
| reset | I | 同步复位信号 |
| PC_WE | I | PC的写使能 |
| PC | O | 当前指令地址 |
| Instr[31:0] | O | 32位的指令值 |
FD_REG(F/D级流水线寄存器)
| 信号名称 | 方向 | 功能描述 |
|---|---|---|
| clk | I | 时钟信号 |
| reset | I | 同步复位信号 |
| flush | I | 寄存器刷新信号(阻塞时使用) |
| F_PC | I | F级PC的指令地址 |
| F_Instr[31:0] | I | 时钟信号 |
| D_PC | O | D级PC的指令地址 |
| D_Instr[31:0] | O | 32位的指令值 |
D级(译码)
- 本级需要处理来自E, M, W级的转发,其中W级为寄存器内部转发,另外两个分别是
D_FWD_rs,D_FWD_rt,在CMP和NPC中需要用 - 本级的输入是来自F级的
PC和Instr,输出是D_rs,D_rt,D_Ext_Out,D_PC和D_Instr,这些参与流水,还有输出到F级的NPC - 本级元件较多,是最复杂的一级
D_GRF
端口说明
| 信号名称 | 方向 | 功能描述 |
|---|---|---|
| A1[4:0] | I | 5位地址输入信号,将其储存的数据读出到RD1 |
| A2[4:0] | I | 5位地址输入信号,将其储存的数据读出到RD2 |
| A3[4:0] | I | 5位地址输入信号,将其作为写入数据的目标寄存器 |
| RD1[31:0] | O | 输出A1指定的寄存器中的32位数据 |
| RD2[31:0] | O | 输出A2指定的寄存器中的32位数据 |
| WD[31:0] | I | 32位数据输入信号 |
| clk | I | 时钟信号 |
| reset | I | 同步复位信号,将32个寄存器中的数据清零;1:复位;0:无效 |
- 这次删去了
WE写使能信号,因为如果我们不写寄存器,可以把A3设为0,就相当于不写寄存器了
Ps :这是我灵光一现想到的办法,受了P4课上访存指令的启发,如果有指令要在读出之后针对数据判断是否写入GRF,那么WE信号不仅要一直参与流水,还要用于控制每级的转发与阻塞,利用0寄存器的特点就可以规避这一切的麻烦!!!
控制信号说明
1. D_A3
直接给出待写入寄存器的地址,弃用了在P4中利用A3Sel进行选择的设计,这是因为P5采用分布式译码,每一级都需要A3的信息,因此在CTRL里面直接集成了
2. D_WDSel
| 控制信号值 | 功能 |
|---|---|
WDSel_DMout |
选择写入寄存器的数据来自DM |
WDSel_ALUout |
选择写入寄存器的数据来自ALU运算结果 |
WDSel_PC8 |
选择写入寄存器的数据为当前流水线层级中的PC+8 |
D_EXT
将16位二进制数进行零扩展或符号扩展到32位
控制信号说明
| 控制信号值 | 功能 |
|---|---|
EXT_unsigned |
零扩展 |
EXT_signed |
符号扩展 |
D_CMP(分支比较)
参考P3中CPU内BranchDefine子电路生成的CMP模块,来生成jump信号,判断分支是否跳转,link是否写入
目前实现了beq, bne, bgtz, bltz等指令
代码实现:
1 |
|
端口说明
| 端口名称 | 方向 | 功能描述 |
|---|---|---|
| rs[31:0] | I | 转发后$rs寄存器的值 |
| rt[31:0] | I | 转发后$rt寄存器的值 |
| CMPOp[2:0] | I | 控制信号 |
| jump | O | 指示分支是否跳转 |
D_NPC(次地址计算)
有了CMP之后,NPC的功能也更加简洁,只需根据NPCOp和jump信号输出NPC信号的值就行
实际上NPC横跨了F级和D级两级,因为同时会输入F_PC和D_PC,前者正常跳转F_PC+4用,后者则用于流水PC值,后面转发PC+8的时候用
这次我们弃用了P4中直接输出PC+4的设计,转而让PC信号参与流水,在需要转发时计算PC+8
端口说明
| 端口名称 | 方向 | 功能描述 |
|---|---|---|
| F_PC[31:0] | I | 32位输入当前F级地址 |
| D_PC[31:0] | I | 32位输入当前D级地址 |
| imm[31:0] | I | 32位立即数 |
| jump | I | 指示b类型指令是否跳转 |
| NPCOp[2:0] | I | 控制信号 |
| RD1_rs[31:0] | I | $ra寄存器保存的32位地址 |
| NPC[31:0] | O | 32位输出次地址 |
控制信号说明
| 控制信号值 | 功能 |
|---|---|
NPC_pc4 |
NPC = F_PC+4 |
NPC_b |
执行beq等b类指令 |
NPC_jal |
执行j,jal指令 |
NPC_jalr |
执行jalr,jr指令 |
DE_REG(D/E级流水线寄存器)
输入
D_PC,D_Instr,D_Ext_Out,此外上一级的$rs和$rt的值也要参与流水,即D_FWD_rs,D_FWD_rt需要参与流水,这是由于指令序列sw, nop, add的存在,sw在M级需要使用$rt的数据,但是在E级不会再进行转发(因为在D级已经转发过了),因此需要让正确的$rt值参与流水输出
E_PC,E_Instr,E_Ext_Out,E_rs,E_rt,ALU需要这些信息
| 信号名称 | 方向 | 功能描述 |
|---|---|---|
| clk | I | 时钟信号 |
| reset | I | 同步复位信号 |
| flush | I | 寄存器刷新信号(阻塞时使用) |
| D_PC[31:0] | I | D级PC的指令地址 |
| D_Instr[31:0] | I | 32位的指令值 |
| D_Ext_Out[31:0] | I | 16位立即数经EXT扩展的结果 |
| D_rs[31:0] | I | 32位的寄存器数据 |
| D_rt[31:0] | I | 32位的寄存器数据 |
| E_PC[31:0] | O | E级PC的指令地址 |
| E_Instr[31:0] | O | 32位的指令值 |
| E_Ext_Out[31:0] | O | 16位立即数经EXT扩展的结果 |
| E_rs[31:0] | O | 32位的寄存器数据 |
| E_rt[31:0] | O | 32位的寄存器数据 |
E级(执行)
E_ALU
- 相比于P4,ALU做了很大的变动,添加了
ALUSrcA信号选择A运算数的来源,这是为了便于扩展sll和sllv类指令的原因取消了shamt信号,shamt信号从ALUSrcB中选择进入ALU中
端口说明
| 信号名称 | 方向 | 功能描述 |
|---|---|---|
| A[31:0] | I | 32位输入运算数A |
| B[31:0] | I | 32位输入运算数B |
| ALUOp[4:0] | I | 控制信号 |
| ALUOut[31:0] | O | 32位输出运算结果 |
控制信号说明
1. ALUOp
| 控制信号值 | 功能 |
|---|---|
ALU_add |
执行加法运算 |
ALU_sub |
执行减法运算 |
ALU_or |
执行逻辑或运算 |
ALU_lui |
执行lui指令 |
2. ALUSrcA
| 控制信号值 | 功能 |
|---|---|
SrcA_rt |
对于sll和sllv等移位指令,选择$rt的值 |
SrcA_rs |
对于其他大部分运算指令,采用 $rs的值 |
3. ALUSrcB
| 控制信号值 | 功能 |
|---|---|
SrcB_rt |
选择处理完转发后$rt寄存器中的值进行运算 |
SrcB_imm |
选择立即数进行运算 |
SrcB_shamt |
使用{27'b0, E_ALUshamt}得到32为扩展移位数 |
SrcB_rs |
考虑到sllv指令要求可变的位移数,这里可以选择{27'b0, E_FWD_rs_data[4:0]},即$rs寄存器中的数据作为移位数 |
EM_REG(E/M级流水线寄存器)
- 输入
E_PC,E_Instr,此外上一级的ALUOut参与流水,即E_ALU_Out,E_Ext_Out需要参与流水,这是因为ALUOut可能是待写入或读取的内存地址,另外,上一级的rt值需要参与流水,因此还需要输入E_FWD_rt,这是因为sw指令会向内存中写入$rt的数据 - 输出
M_PC,M_Instr,M_ALU_Out,M_DM_Out
| 信号名称 | 方向 | 功能描述 |
|---|---|---|
| clk | I | 时钟信号 |
| reset | I | 同步复位信号 |
| flush | I | 寄存器刷新信号(阻塞时使用) |
| E_PC[31:0] | I | E级PC的指令地址 |
| E_Instr[31:0] | I | 32位的指令值 |
| E_Ext_Out[31:0] | I | 16位立即数经EXT扩展的结果 |
| E_rt[31:0] | I | 32位的寄存器数据 |
| E_ALU_Out[31:0] | I | 32位的ALU运算结果 |
| M_PC[31:0] | O | M级PC的指令地址 |
| M_Instr[31:0] | O | 32位的指令值 |
| M_Ext_Out[31:0] | O | 16位立即数经EXT扩展的结果 |
| M_ALU_Out[31:0] | O | 32位的ALU运算结果 |
| M_rt[31:0] | O | 32位的寄存器数据 |
M级(储存)
M_DM
端口说明
| 信号名称 | 方向 | 功能描述 |
|---|---|---|
| Addr[31:0] | I | 待操作的内存地址 |
| WD[31:0] | I | 待写入内存的值 |
| clk | I | 时钟信号 |
| reset | I | 同步复位信号 |
| DMWr | I | 写使能信号;1:写入有效;0:写入无效 |
| DMOp[2:0] | I | 控制信号 |
| DMout[31:0] | O | 输入地址指向的内存中储存的值 |
控制信号说明
| 控制信号值 | 功能 |
|---|---|
DM_w |
对应lw和sw指令,写入或读取整个字 |
DM_h |
对应lh和sh指令,写入或读取半字 |
DM_b |
对应lb和sb指令,写入或读取整个字 |
DM_hu |
对应lhu指令 |
DM_bu |
对应lbu指令 |
W级(回写)
MW_REG(M/W级流水线寄存器)
| 信号名称 | 方向 | 功能描述 |
|---|---|---|
| clk | I | 时钟信号 |
| reset | I | 同步复位信号 |
| flush | I | 寄存器刷新信号(阻塞时使用) |
| M_PC[31:0] | I | M级PC的指令地址 |
| M_Instr[31:0] | I | 32位的指令值 |
| M_DM_Out[31:0] | I | 从内存中读取的值 |
| M_ALU_Out[31:0] | I | 32位的ALU运算结果 |
| W_PC[31:0] | O | W级PC的指令地址 |
| W_Instr[31:0] | O | 32位的指令值 |
| W_DM_Out[31:0] | O | 从内存中读取的值 |
| W_ALU_Out[31:0] | O | 32位的ALU运算结果 |
数据通路分析
| 指令 | opcode | funct | NPCOp | A3Sel | WDSel | EXTOp | GRFWE | ALUSRCB | ALUOp | DMWr | DMOp |
|---|---|---|---|---|---|---|---|---|---|---|---|
| add | 000000 | 100000 | NPC_PC4 |
A3Sel_rd |
WDSel_ALUout |
X | 1 | SrcB_rt |
ALU_add |
0 | X |
| sub | 000000 | 100010 | NPC_PC4 |
A3Sel_rd |
WDSel_ALUout |
X | 1 | SrcB_rt |
ALU_sub |
0 | X |
| ori | 001101 | X | NPC_PC4 |
A3Sel_rt |
WDSel_ALUout |
EXT_unsigned |
1 | SrcB_imm |
ALU_or |
0 | X |
| lw | 100011 | X | NPC_PC4 |
A3Sel_rt |
WDSel_DMout |
EXT_signed |
1 | SrcB_imm |
ALU_add |
0 | DM_w |
| sw | 101011 | X | NPC_PC4 |
X | WDSel_DMout |
EXT_signed |
0 | SrcB_imm |
ALU_add |
1 | DM_w |
| beq | 000100 | X | NPC_branch |
X | X | X | 0 | X | X | 0 | X |
| lui | 001111 | X | NPC_PC4 |
A3Sel_rt |
WDSel_ALUout |
X | 1 | SrcB_imm |
ALU_lui |
0 | X |
| jal | 000011 | X | NPC_jal |
A3Sel_ra |
WDSel_PC8 |
X | 1 | X | X | 0 | X |
| jr | 000000 | 001000 | NPC_jalr |
X | X | X | 0 | X | X | X | X |
本CPU采用分布式译码,在每一级均设有译码器,解码出各级所需信息
冲突处理方法
转发(Forwarding)
对于转发,我们直接采用 AT 法 + 暴力转发,首先要搞明白转发到哪,转发什么。 对于每一个流水线层级,我们要能够确定当前这一级正在执行的指令要写什么数据,向哪里写,因此就要维护 GRFWD (解决转发啥)和 GRFA3 (解决转发到哪)这两个值,我们转发需要去关注的也就是这两个数据,这些信号都可以从 Control 里面译码读出来 简单来说就是,我们需要在每一级都知道本级需要从哪读数据,要写到哪,要写啥,现在不知道没事,总之在这条指令从流水线消失之前,我们肯定知道,并且可以根据这些再经过判断做转发。
在每一个需要用转发数据的地方,我们去比较要用的数据的 GPR 地址和前面正在维护的要写的 GRFA3 的地址,如果相同,那就意味着我们要写的寄存器已经被用了,但是这时前面获得的值显然是错误的,这时候直接转发过去就好了
这里还要考虑优先级的问题,流水线寄存器生成的WD越靠近这条指令,得到的数据就越新,我们就越倾向于优先使用这些数据
利用 AT 法,如果不阻塞就意味着一定能够在使用该寄存器的值之前获得正确的值,如果我们要用的时候,这个正确的值还没有算出来,那肯定不行,这时候我们就阻塞,如果能算出来,那么之前转发的错误的值不用去管它,最后总能得到一个正确的值去覆盖原先错误的值
如果我们还不知道要写的值是啥,那这个时候 GRFA3 就给正确的地址, GRFWD 就给 32'bz ,这时还不能做转发,但是如果写的阻塞模块正确,这个值就不可能被转发,因为这种情况如果出现就已经被阻塞在 D 级了
综合考量各种指令序列,我们得到了转发的旁路:
- D级需求: E->D(如序列
jal-add), M->D(如序列jal-nop-add)(W->D隐藏于GRF的内部转发中); - E级需求: M->E(如序列
add-add), W->E(如序列add-nop-add) - M级需求: W->M(如序列
add-sw)
具体设计见前文图片
代码实现
W to D:
1 | // 寄存器内部转发 |
D级转发处理:
1 | //D级写回数据 |
E级转发处理:
1 | //转发 |
M级转发处理:
1 | //转发 |
阻塞(Stall)
对于阻塞的处理,直接采用教程中的AT方法,设计一个 Stall 模块,专门负责处理阻塞时流水线寄存器的 flush 和 WE 信号就行
只在 D 级进行阻塞,阻塞控制器接受当前 D,E,M 级的指令输入,处理分析指令类别,算出当前D的Tuse,和E、M的Tnew,再进行相应的计算
代码实现:
1 | //阻塞逻辑 |
测试方案:
先进行弱测,即跑完了提交窗口的测试数据,没有问题。
单条指令功能测试:
ori测试:
1 | ori $t0, $t0, 1 |
add测试:
1 | ori $t0, $t0, 2 |
sub测试:
1 | ori $t0, $t0, 2 |
lui测试:
1 | lui $t0, 0x7fff |
beq测试:
1 | #三次跳转 |
j,jal,jr测试:
1 | jal A |
sw,lw测试:
1 | ori $t0, $t0, 4 #t0 = 4 |
之后对于各种边界情况和转发阻塞情况,进行覆盖测试:
提供一组超强测样例:
1 |
|
对拍输出一致,或许对吧……
思考题
我们使用提前分支判断的方法尽早产生结果来减少因不确定而带来的开销,但实际上这种方法并非总能提高效率,请从流水线冒险的角度思考其原因并给出一个指令序列的例子。
在回答这个问题之前,我们需要知道,在原本的设计中,
beq的分支结果应该在E级产生,现在改在D级产生,这就造成了如下所示的问题:1
2
3lw $t0, 0($0)
nop
beq $t1, $t0, label原本的设计中,beq在E级,lw在W级,正好转发就可以给到新生成的
$t0的值,而现在,beq在D级就要新的$t0的值,来不及转发,被迫暂停了,效率降低。因为延迟槽的存在,对于 jal 等需要将指令地址写入寄存器的指令,要写回 PC + 8,请思考为什么这样设计?
因为延迟槽的存在,跳转指令的后一条必然会执行,所以需要把PC+8写入寄存器,不然jr后延迟槽内的指令又会再执行一次
我们要求所有转发数据都来源于流水寄存器而不能是功能部件(如 DM、ALU),请思考为什么?
不妨举一个例子,假设这是各个阶段的时间:
F D E M W 50 100 100 150 100 如果此时D阶段需要E的ALU计算的结果,假设数据刚进入D和E级(即刚刚有效沿到来),我们要ALUOut,所以需要100s等待其出结果,然后过了100s后结果出来了,可以转发给D级了,但是此时还没完,D拿到了这个数据,需要对其进行处理,所以又花了100s。也就是说,为了保证正确性,我们的周期最少是200s,而其实150s就够了(即最大周期M级——150s)。只是因为我们进行了这个错误的设计。
我们为什么要使用 GPR 内部转发?该如何实现?
为了保持W级的写入和此时D级的读出是同一个值,规避数据冒险。
代码实现:
1
2
3
4
5// 寄存器内部转发
assign RD1 = (A1 == 5'b00000) ? 32'h0000_0000 :
(A3 == A1 && A1 != 5'b00000) ? WD : grf[A1];
assign RD2 = (A2 == 5'b00000) ? 32'h0000_0000 :
(A3 == A2 && A2 != 5'b00000) ? WD : grf[A2];我们转发时数据的需求者和供给者可能来源于哪些位置?共有哪些转发数据通路?
详见前文转发实现部分
在课上测试时,我们需要你现场实现新的指令,对于这些新的指令,你可能需要在原有的数据通路上做哪些扩展或修改?提示:你可以对指令进行分类,思考每一类指令可能修改或扩展哪些位置。
- 高内聚低耦合原理:大多数只需要改变控制信号以及相应的功能模块增加功能,但不排除某些需要修改
mips.v内的数据通路 - 对于计算类:首先改变CTRL,添加相关的wire,注意每个指令的控制信号的对应,之后就直接修改ALU就行了。
- 对于访存类:改变CTRL,并修改DM增加相应的功能,改变DMOp,并且在
mips.v的顶层模块内增添相应控制信号。 - 对于跳转类:修改CTRL,并在CMP内增添相应功能,同时根据链接条件增添相应check信号。
- 高内聚低耦合原理:大多数只需要改变控制信号以及相应的功能模块增加功能,但不排除某些需要修改
简要描述你的译码器架构,并思考该架构的优势以及不足。
我采用了分布式译码;
优势当然是实现更加容易实现,不需要考察每一级具体要保留和传递哪些译码上的信息,实现起来比较容易,并且进行增量开发更加容易。
缺点当然是对于资源浪费严重,而且需要实例化的端口太多了,容易编译错误,而且导线太多,容易犯些隐蔽的错误!!!
主观上来说,我觉得分布式译码就是一个赖子办法,不具备设计上的美感,集中式译码才能体现对于流水线的理解和设计的能力,
奈何我不具备,只好用分布式了……
RooKie_Z P5 Verilog流水线CPU设计文档