上次做完单周期CPU后开始思考多周期的相关设计,最近总算做出来一个马马虎虎的。先来说说思路。
要求
原理
1.多周期
多周期CPU指的是将整个CPU的执行过程分成几个阶段,每个阶段用一个时钟周期去完成,然后开始执行下一条指令,一般将一条指令的执行分为以下几个阶段:
(1)取指令(IF):根据程序计数器pc中的指令地址,从指令寄存器中取出对应指令,同时pc自动递增产生相邻的下一条指令的地址(pc+4),但是遇到跳转指令和分支指令,如beq,j,jal,jr则需要更进一步的处理
(2)指令译码(ID):对从指令寄存器中取出的指令进行解析,将这条指令的操作码、相关寄存器、操作数、地址等等分离,传入到相应的组件中去
(3)指令执行(EXE):根据指令译码得到的操作控制信号,具体执行指令动作,这里主要是ALU的运算操作
(4)存储器访问(MEM):对于需要访问存储的操作,比如sw,lw,完成从存储器中读取数据并传出,或者是向存储器进行写操作
(5)结果写回(WB):指令执行的结果或者是存储器访问得到的结果写回相应的寄存器中
对于不同的指令,有些阶段是不需要的,比如跳转类型指令,只有IF,ID两个阶段,sw指令没有WB阶段。因此,不同的指令会在不同数量的时钟周期内完成,因此叫多周期。
2.要实现的指令格式
MIPS指令的三种格式:
其中,
op:为操作码;
rs:只读。为第1个源操作数寄存器,寄存器地址(编号)是00000~11111,00~1F;
rt:可读可写。为第2个源操作数寄存器,或目的操作数寄存器,寄存器地址(同上);
rd:只写。为目的操作数寄存器,寄存器地址(同上);
sa:为位移量(shift amt),移位指令用于指定移多少位;
funct:为功能码,在寄存器类型指令中(R类型)用来指定指令的功能与操作码配合使用;
immediate:为16位立即数,用作无符号的逻辑操作数、有符号的算术操作数、数据加载(Laod)/数据保存(Store)指令的数据地址字节偏移量和分支指令中相对程序计数器(PC)的有符号偏移量;
address:为地址。
3.将不同阶段状态化
根据不同指令的操作流程,可以将在某阶段具有相同特性的几个指令分到一个状态内,比如对于EXE阶段,对add来说,它的下一个阶段是WB,而对于beq来说,则是IF,这样就不能把它们分到一个状态内,根据不同指令完成所需要的具体阶段,可以绘制出下图
4.状态转换的实现
用三位二进制来表示不同的状态,则可以通过D触发器来实现状态的转换。根据状态转移图可知,下一个状态不仅和当前状态有关,还和操作码有关,因此D触发器的输入应该考虑到当前的状态以及操作码。当一个时钟周期过后,状态发生转移。
根据我的理解,多周期就是说在不同的阶段只做当前阶段的事情,比如IF阶段就只用完成从PC中传出指令地址然后到指令寄存器中获取指令,同时完成PC+4,而对于其他组件是做无用功的,或者说是不干活的,要实现这一点就要用到控制单元中发出的不同控制信号,因此状态转移的实现应该放到控制单元中。
5.数据传递
根据数据通路图
图中的IR指令寄存器使得指令代码保持稳定,IRWre使得IR在适当的时间获取正确的指令,pc写使能控制信号PCWre确保了pc的适时修改,这样能确保多周期CPU的稳定工作,而ADR、BDR、ALUoutDR和DBDR四个寄存器不需要写使能信号,其作用是将数据通路切分,比如ADR和BDR会将寄存器组传来的数据堵塞,当EXE阶段到来时获取寄存器组传来的值再传到数据选择其中然后供ALU使用,这样人为制造了小的延迟,保证正确的数据在正确的时机传入到正确的组件当中。
关于ALUOp,几乎每个指令都有EXE阶段(除了跳转指令),而EXE阶段的核心就是ALU,对于不同的操作,ALU执行着不同的运算
6.关于上升沿,下降沿
为了起到延迟的作用,本次实验中的PC,Control Unit,IR,ADR,BDR,ALUoutDR,DBDR的时钟边沿触发需要深入考虑,在实验中我发现,对于同一个时钟边沿,控制单元此时状态转换产生的控制信号无法立刻被其他组件拿来使用,必须等到下一次相同的时钟边沿到来才能使用。也就是说,无法立刻使用新值。比如对于PC和控制单元,若是PC在始终上升沿发生状态转换,如果PC的写操作也是在上升沿触发,如果到达IF阶段,控制单元此时发出的写使能信号无法在瞬间被PC捕获并且完成写操作,而要等到下一个周期才能完成,然而此时已经是ID阶段了,因此会违背多周期的性质,可能会造成许多错误,因此需要认真考虑。
分析
1.CPU架构建立
根据数据通路图,一共有21个组件,根据其相应的联系,我分成了以下几个组:
1.1.PC组:
用于获取当前要执行的指令地址,并产生下一条指令的地址,包括:
1.1.1.PC:
获取当前要执行的指令地址,如果是置零操作,则将指令地址重置为1,受到时钟的控制,在时钟下降沿获取指令或者完成置零操作
输入:CLK,RST,PCWre(写使能信号),NextPC
输出:CurPC
当时钟下降沿到来时,若PCWre为1,PC获取NextPC作为当前要执行的指令的地址。
1.1.2.ALU_For_Next(PC4获取加法器):
获取当前指令临近的下一条指令的地址
1.1.3.ALU_For_Beq(beq指令相关跳转加法器):
当beq操作需要跳转时,从位扩展器获取beq要跳转的跳转条数,与PC4做加法获取目标地址
输入:PC4,Aim(跳转条数)
输出:BeqPC
由于一条指令是4个字节,因此PC4应与Aim * 4相加
1.1.4.Extend_For_Address(地址扩展器):
对于跳转指令,由于一条指令32位,操作码占了6位,因此需要对指令中获取的相关地址进行扩展,具体原理是:由于MIPS32的指令代码长度占4个字节,所以指令地址二进制数最低2位均为0,将指令地址放进指令代码中时,可省掉。这样,除了最高6位操作码外,还有26位可用于存放地址,事实上,可存放28位地址,剩下最高4位由pc+4最高4位拼接上。
输入:PC4,Address(26位)
输出:JumpPC
1.1.5.Mux_FourToOne_PC(四选一指令地址选择器):
对于不同的操作,下一条指令的地址可能会不同,比如add、sub类型的指令,下一条指令地址就是PC4;而对于beq类型指令,可能是BeqPC;对于jr指令,则是来自于31号寄存器的值;而j,jal则应该是JumpPC,而具体选择哪一个,则是控制单元的事情。
输入:PC4,BeqPC,JrPC,JumpPC,PCSrc(来自Control Unit)
输出:NextPC
1.2.控制组:
1.2.1.Control Unit(控制单元):
作为CPU的大脑,Control Unit负责发送相关控制信号给不同的组件。在我的理解中,不同的阶段,其实只有处在该阶段的相关组件才在进行正常的工作,其他组件则做的是无用功或者停止工作的。比如WB阶段,工作的组件就是寄存器组,此时PC不能获取下一条指令地址,ALU也不能做别的运算来影响这个阶段。如何保证只有相关组件正常工作,而其他组件不影响整个流程,这也用到了控制信号。所以控制信号应该不仅由操作码来决定,同时也应该由当前阶段来决定。
首先解决如何实现状态转移,应该有两个数据,一个是CurState另外一个是NextState。这里用到了D触发器,自然想到了通过卡洛图来实现,根据不同类型的操作码,状态转移应该是这样实现的:
注意:000(IF)->001(ID)在程序运行的开始控制单元是没有获取任何操作码的,因此在初始阶段应该执行这个操作
至于后面当控制单元需要再由000获取001时该怎么办,此时并没有传入当前的指令,我在这里的选择是直接无视这个问题,因为对于任意一条指令,000->001是无条件的,因此即使传入到控制单元的操作码还是上一条的也能正确完成NextState的获取。
完成了状态的转换的实现,就要考虑控制信号的发出了,对于不同状态,不同指令,发出的有用的控制信号的数量,具体的值是不同的,根据数据通路图,我绘制了这样的真值表
带*号表示在改变状态时必须把这些值设为非写状态,对于PCWre,如果一直处于1,则每个阶段它都会获取下一条指令的地址,错误;对于IRWre,虽然只要PC不会改变读取的指令也不会改变,但是这样做能够确保IR只在ID阶段工作,可以做到严格的阶段分工明确,而RD,WR和RegWre,则是出于数据保护的思考,确保只有在需要的时候才进行读取或写入,这样确保了寄存器组和存储器中的数据始终正确和安全。
至于为什么把WrRegDSrc和RegDst、PCSrc放在ID阶段,因为j类型指令只到ID阶段,由此可以推出,对寄存器组的读取也是在ID阶段。由此可以推出不同阶段正常工作的寄存器组件:
IF:
PC,ALU_For_Next,ALU_For_Beq,Extend_For_Address,InstructionMemory
ID:
IR,Mux_ThreeToOne_WriteReg,Sign_Zero_Extend,RegisterFile,Mux_FourToOne_PC,Mux_TwoToOne_For_WriteData
EXE:
ADR,BDR,Mux_TwoToOne_For_InputA,Mux_TwoToOne_For_InputB,ALU,Mux_TwoToOne_Data
MEM:
ALUoutDR,DataMemory
WB:
DBDR,RegisterFile
关于何时状态转移,我是以PC为参照考虑的,PC获取地址是在下降沿获取的,如果控制单元也是在下降沿改变状态的,则它传输的PCWre不能瞬间被PC获取并进行取地址操作,如果是在上升沿改变状态,则接下来的一个下降沿PC就可以顺利获取指令地址。
而NextState的获取,以及针对不同状态不同信号的处理,放在了一个always@( * )中来时时监控。信号的输出用了case语句
接下来的其他组件就要根据已经定好的PC和Control Unit的触发边沿进行确认。
1.3.指令组:
完成取出指令以及对指令的解析:
1.3.1.InstructionMemory(指令存储器):
初始将指令载入,通过PC传来的地址获取相应的指令
输入:CurPC,InstMemRW
输出:instcode
指令寄存器我规定存储单元度位8位,用大端规则存储,即指令高位存到寄存器低
地址,低位存到高地址,这是$readmemb方法默认实现的,取操作码就可以通过
instcode = { InstMemory[CurPC], InstMemory[CurPC + 1], InstMemory[CurPc + 2], InstMemory[CurPC + 3] };
1.3.2.IR(指令寄存器):
将从指令存储器中获取的指令载入,对其进行解析,将相关的op,rs,rt,rd,sa等分离出来。
这里同样用到了时钟信号,因为指令从IF阶段就已经获取,只是被IR“拒之门外”,因此当控制单元更改状态为ID,将IRWre置为1时,若IR是上升沿触发,此时相关数据都已经存在,可以一瞬间完成指令的载入,而下降沿触发也同样可以完成,为了达到延迟效果,这里我选了下降沿触发。
输入:CLK,instcode,IRWre
输出:op,rs,rt,rd,sa,immediate,address
1.4.寄存器访问组:
包括对寄存器组相应输入的选择以及寄存器组本身:
1.4.1.Mux_ThreeToOne_WriteReg(三选一写寄存器选择器):
对于写寄存器操作,目标对象可以是rd,rt,同时,对于jal指令,在跳转之前需要将PC4的值写入31号寄存器,因此写寄存器有三个选择
输入:rt,rd,ReDst
输出:WriteReg
1.4.2.Mux_TwoToOne_Data(二选一数据选择器):
这个组件同样适用于ALU的输入端B的选择,以及对存储器的输出和ALU结果的输出的选择。当指令为jal时,需要将PC4写入31号寄存器。
输入:WrRegDSrc,PC4,DB
输出:WriteData
1.4.3.RegisterFile(寄存器组):
获取相应寄存器的值,或者对相应寄存器进行写操作
输入:RegWre,CLK,Reg1,Reg2,WriteReg,WriteData
输出:DataOut1,DataOut2
由于0号寄存器不可写,固定是0,因此我实际上只有31个寄存器,写操作是在时钟下降沿进行的,因为对于WB阶段,状态转换是在上升沿的,但是DBDR要在下降沿才能将数据传入,这时候如果采用下降沿写寄存器的话来不及,因此我选择上升沿写,在WB阶段结束的最后那一刻完成状态的转换,同时完成写寄存器的操作
1.5.位扩展组:
1.5.1.Sign_Zero_Extend(位扩展器):
指令中的立即数是16位的,要进入ALU的输入端则必须变为32位,逻辑操作(与、或)使用的是无符号扩展;加、减等操作使用的是有符号扩展
输入:Imm_Number,ExtSel
输出:Result
1.6.计算组:
核心组件是ALU,包括数据的获取,以及ALU的运算
1.6.1.DR:
包括ADR,BDR,ALUoutDR,DBDR,作用就是起到延迟效果,保证CPU工作的稳定性,我设置的是时钟下降沿时载入数据并输出,原因是由于EXE阶段只有ADR和BDR起延迟效果,状态转移是在上升沿,所以只能是下降沿载入数据,ALUoutDR在MEM阶段才会被使用,也是MEM阶段唯一起延迟效果的,因此下降沿没问题。同理,由于数据存储器是随时读,因此DBDR下降沿也没有问题。
1.6.2.Mux_TwoToOne_For_InputA(二选一ALU输入端A数据选择器):
通常ALU的A输入端口是寄存器组1号输出端口输出的rs寄存器的值,但是当操作为sll时,应该是指令中的sa段,但是sa只有5位,所以这个选择器的功能有两个:1.将sa通过无符号扩展变为32位;2.根据控制信号ALUSrcA输入到ALU的值应该是寄存器组入的值还是sa的值
输入:ALUSrcA,DataIn,sa
输出:DataOut
1.6.3.Mux_TwoToOne_For_InputB(二选一ALU输入端B数据选择器):
同1.4.2原理相同
1.6.4.ALU(运算单元):
大多数操作都可能用到ALU,包add,sll,sub等,ALU的是对数据进行基本的算术操作,根据实验内容部分ALU运算表可以定相关方法,zero输出端是运算结果是否为0的,要用处是在beq中,两个值是否相等就是将其相减并结果是否与0相等,ALU输出的值可能是一个具体的数据(如add),也可能是个地址(如sw、lw)
输入:Reg1,Reg2,ALUOp
输出:result,zero
1.7.存储器组:
1.7.1.DataMemory(数据存储器):
lw操作和sw操作关到了数据的存储,我就制作了一个数据存储器用于存放数据。为了保该模CPU的稳定性,我设计数据存储器的读操作是随时的,存储单元度为8位,同样用大端规则存储。为了保证数据的安全性,对于写操作,我用了时钟。同样,由于状态转换是上升沿,因此写操作在下降沿触发最合适,但是ALUoutDR在下降沿才能传入正确的数据,因此只能用上升沿触发
1.7.2.MuxTwoToOne_For_DBDR(二选一DBDR数据选择器):
选择DBDR的输入数据是来自ALU还是数据存储器,与1.4.2原理相同
1.7.3.DBDR:
与1.6.1原理相同
MCPU(多周期CPU):
作为顶层模块,包含上述所有组件
输入:CLK,RST
输出:CurPC,instcode
由上文可以绘制结构图:
根据数据通路图即结构图将相关数据正确地传入传出即可。
仿真测试:
在InstructionMemory模块中取了$readmemb方法,所以通过文件写入,指令真值表为
在instruction.txt中写:
创建仿真组件MCPU_sim:
实验心得
本次实验使我再次认识到了数据通路图的重要性,尽管多周期CPU很复杂,有相比于单周期更多的组件,更多的逻辑,但是只要仔细分析数据通路图就会发现,整个多周期CPU其实是由许多独立的组件各司其职,然后有Control Unit来统领全局的。因此我用了自上而下的设计思想,把一个多周期CPU层层拆分,根据各个组件完成的功能把它们分到多个组中,然后逐步完善底层设计。
多周期与单周期不同,单周期是在一个周期内,所以组件都在工作,因此控制单元很容易分配工作,但是多周期每个周期只有一部分组件在工作,而其他组件,有些可以让它做无用功,有些则必须使其停止工作,比如ALU就可以让它做无用功,但是数据存储器就不行,如果此时数据存储器做的是写操作,可能会更改数据存储器中的相关数据,这样子就无法做到数据保护,因此,为了数据安全性,在非MEM阶段,数据存储器必须停止工作,即RD,WR都设为高阻态。
另外,本实验基础组件与单周期CPU相似,但是关于时钟周期上却要下很大的功夫,如PC该何时读取下一条地址,IR何时载入指令,各个DR何时将数据获取,寄存器组何时实现写操作以及数据存储器何时完成读、写操作。实验一开始,由于思路不清晰以及对数据通路图的认识不够深入,经常出现如寄存器组来不及写入数据等问题。后来我是通过递进式思考,即先将PC的触发边沿确定,然后确定控制单元,接下来以此作为参照确定IR的触发边沿,以此类推,最后较为合理的推出了各个组件触发边沿之间的关系。
本次实验使我在理解了单周期CPU的基础上对多周期CPU的工作机制有了更深入的了解。同时也认识到控制单元在CPU中举重若轻的地位。可以说,控制单元是CPU的灵魂、大脑,控制单元的好坏直接影响到了CPU的工作效率。对于控制单元该如何调配各个组件,在设计的时候应该静下心仔细思考。期间我绘制了状态关系图和相关真值表,发现这样做以后自己的思路更加清晰了,这为我以后的实验提供了一个重要的启示:不要光是想,将思路、过程写下来会使得自己更清楚自己应该做什么。
同时我也认识到了在一个CPU中,只要有任何一个组件的任何一个步骤发生错误,都可能导致CPU整个运行发生不可想象的错误,虽然设计多周期CPU比较复杂,但是我们应该时时刻刻保持细心,绝不能容忍一丝错误。
与单周期CPU实验相比,这次实验我有了一些改进,同时也有一些不足点
改进:1.严格地将不同功能的组件独立出来,使得各个组件分工明确,各司其职
2.考虑到了数据的保护,保证了CPU执行时期的安全性
不足点:1.时钟触发边沿的选择还是有点漏洞,比如寄存器组写的触发沿是时钟上升沿,其实严格来讲,此时已经是下一个阶段,不符合多周期的本质
2.对于控制单元的编写存在代码冗杂,我会接下来思考更好的定义方式