CPU 50-instructions big hw.

发布于 2020-01-21  4927 次阅读


实验目标

你需要用verilog语言实现一个简易的多周期流水线CPU,并且要支持50条基本指令(如lb,sw,mult,addi,beq等),在此基础上还可以实现TLB功能。

只做了50条指令的部分,TLB因为时间原因写完了但没有做充分的测试。

基本按照实验指导的图来画,做了一点修改。(这张图帮了很多忙,见下)

设计思路是先画图=》写CU,根据指令要求在图上做修改=》写CU代码生成文件=》写转发代码生成文件=》写堵塞代码生成文件=》写各元件代码=》总架构拼装。

control unit

这部分和单周期差不多照葫芦画瓢,不过对于多出来的很多指令要加很多额外的mux.

比如:slt判断无符号/有符号A和B的大小,我曾以为简单做减法就可以了,实际上要做很多判断,设计时alu会输出一个"<0"值。

比如:j,jal,jalr,jr有很多区别,需要认真读pdf才能明白。

比如:beq,bne,blez,bgez,...需要很多控制器,最后我把它们如何判断都丢到CMP模块里了。

列完表之后还要写一个代码生成器,还要另写一个表用来判断当前读取的东西对应哪条指令。

ps:做完后与同学们讨论,发现它们的每一个控制单元都有不同的名称,而我为了方便把所有单元统一命名为0~36了,这样层间转移也很方便。

生成出来的代码大概长这样:(id为指令编号,cid为类编号,后面有解释)

//part1
assign id=(im[31:26]==6'b100000)?1:(
(im[31:26]==6'b100100)?2:(
(im[31:26]==6'b100001)?3:(
(im[31:26]==6'b100101)?4:(
...
//part2
assign ctrl[0]=(id==37)|(id==38)|(id==39)|(id==40)|(id==41)|(id==42)|(id==43)|(id==44)|(id==45)|(id==46);
assign ctrl[1]=(id==1)|(id==2)|(id==3)|(id==4)|(id==5)|(id==6)|(id==7)|(id==8)|(id==27)|(id==28)|(id==34)|(id==35)|(id==37)|(id==38)|(id==39)|(id==40)|(id==41)|(id==42);
...
//part3
assign cid=(id==1||id==2||id==3||id==4||id==5)?0:((id==6||id==7||id==8)?1:((id==13||id==14||id==15||id==16)?2:((id==27||id==28||id==29||id==30||id==31||id==34||id==35)?3:((id==17||id==18||id==19)?4: ...

callback unit

如何科学地设计转发?一种方法是将50条指令归类,则转发操作只发生在类与类之间。

最开始设计为I类,R类,实际完全行不通:I类里有rs,rt的,也有rt,rd的...最后按照读寄存器编号(rs,rt,rd)和写寄存器编号(rt,rd)划分,分支类、一般类、读写类各自划分,最后划分了15类。

分支类在ID阶段执行,一般类在EX阶段执行,读写类在EX、MEM阶段执行,所以各有不同。

按照类之间进行转发就很容易了:只有可能是EX/MEM,MEM/WB向ID或EX转发。

这个逻辑一般为:前面阶段的指令X需要读寄存器A,而后面某一阶段的指令Y需要写寄存器A。

电路上,对于是否转发的判断,只需读取段间寄存器的编号值和类值即可。

tips: MTHI,MTLO,MFHI,MFLO也有转发/堵塞机制。

tips: LUI单独算做一类,因为它只有写。

最终生成的代码大概长下面这样:

assign ctrl[0]=0|(!EX2MEM_nop&&IF2ID_cid==6&&EX2MEM_cid==5&&EX2MEM_rd==IF2ID_rs)|(!EX2MEM_nop...

这里有一个EX2MEM_nop标记,因为nop当作对0寄存器的SLL指令,可能造成错误的转发/堵塞,所以要记一个nop。

block unit

和转发没有什么大区别:对于出现的冒险,转发做不了的就由堵塞单元来完成。

堵塞时:输出clr和en,将ID/EX段清空(插入nop),将IF/ID段和PC段使能设为0。

堵塞有三种:L/S指令造成堵塞,分支指令堵塞(与L/S结合可能造成2次堵塞),乘除法堵塞。

为了方便对于分支堵塞我并没有设计分支预测。

other unit

比如im_8k,由于增长到了8k,对于读入地址需要人为减去一个偏移量了。

比如dm_8k,现在增加了半字读写,所以地址可以不为4的整数倍了,所以要把[1:0]给BEEXT进行判断。

比如mdu (乘除法单元),乘除法的写法如下:

...
            if(mdu_op==2'b00) res = $unsigned(mdu_d1) * $unsigned(mdu_d2);
            else if(mdu_op==2'b01) res = $signed(mdu_d1) * $signed(mdu_d2);
            else if(mdu_op==2'b10)
            begin
                res = $unsigned(mdu_d1) / $unsigned(mdu_d2);
                rem = $unsigned(mdu_d1) % $unsigned(mdu_d2);  
            end
            else 
            begin
                res = $signed(mdu_d1) / $signed(mdu_d2);
                rem = $signed(mdu_d1) % $signed(mdu_d2);  
            end
...

注意除法和乘法的hi/lo存的东西的意义不一样。

比如GPR,“先写后读”也要注意能不能写。

比如PC+8,到WB阶段再写的PC一定是PC+8,但beq,bne做PC加法时的PC是PC+4.

另外建议always里不要<=(非堵塞)和=(堵塞)混用,会产生无法预料的操作顺序。。

测试

将所有元件组装完毕后进行测试:我写了一个专门的测试集生成文件,能够生成不同难度等级的测试数据:从单I型指令,单R型指令到I,R,SLL指令,乘除法混合,再加上分支/读写指令...

比较正确性也很简单,直接将代码粘到Mars里运行,比对verilog和Mars的结果的不同(为了方便,只需比较\$s0~\$s3寄存器的值。)

tips:自己测试过单周期准备的10指令数据后,基本没有再遇到设计层面(转发堵塞缺失的问题),测试div指令时出现过错误,原因是verilog计算/0出现xxx而Mars忽略不计;另外SB,SH制定地址不一定是4对齐的。

TLB

关于TLB的匹配逻辑,你需要将输入为[31:2]的虚拟地址vpn转为物理地址ppn[12:2]给dm,[1:0]当作offset不变,如果tlb miss交给程序处理(CPU模拟测试没这个,所以默认tlb hit.)对tlb表项的修改间接通过CP0实现。

一个核心问题是如何处理mfc0,mtc0,tlp系列指令的执行时间,在不同阶段执行会导致不同的冒险问题。最终我将CP0寄存器堆的执行(mfc0,mtc0的执行)和TLB表都放在MEM阶段(当然CP0仍然在WB写回,保证各指令的写回时间不会产生冲突),这样CP0的冒险问题实际和L/S类似了。(如果放在EX阶段执行mfc0,mtc0可能更好,因为它和乘除法mthi/ho指令的逻辑基本一致了)。

关于TLB表的更新问题,我设置TLB相关指令在MEM阶段执行,这样做的好处在于对于上一条指令五阶段写回产生的数据冒险问题,可以设置CP0内部转发而回避。

最终TLB和CP0的接口都异常复杂,CP0通过tlbwi,tlbwr指令对TLB写,TLB通过tlbr,tlbp指令对CP0写,相互的通路非常多。另一个point是通过vpn找ppn的操作应当是纯组合逻辑的,也就是要写TLB表那么多的等值判断再或起来,如果表项很多会导致生成的代码巨长。。(而且寄存器开太多会爆simulation内存)

总结

在实现50条指令前你需要做好大量的准备工作,元件组装设计就需要很大的功夫。有时候设计到一半突然发现某个指令类别不对/缺了堵塞也是常有发生的。但总体上只要有耐心,整个设计过程也不会很难,代码编写就更简单了(也容易出错);verilog的全值监控可以说相当好用了,跟踪调试bug虽然慢了点,但一定能发现问题。当所有bug都已消除,CPU正确执行了50条指令的全部内容时,回过神来,你会发现自己真的完成了相当了不得的工作...

附某张自制架构图(无转发和控制,很粗糙 :joy: ):


WRYYYYYYYYY!