计组P7课下作业总结
P7设计文档
CP0
我们首先需要厘清协处理器CP0的功能是什么:处理中断。
在遇到某一个CPU运行错误的时候,CPU要向CP0进行反馈,此时CP0会将CPU引导跳转到特定的程序(Exception handler)处理错误,处理完再跳转回到原来的CPU执行位置。
注意:CP0的工作只有记录错误情况和跳转,并不需要考虑如何解决CPU的运行错误,所以我们只需要输入错误情况以及跳转位置就可以。
根据教程,我们需要设计四个寄存器:SR,Cause,EPC,PRId,他们的功能分别是:
- SR:状态寄存器,用来记录系统的状态以对系统进行控制,由一些控制信号组成。
- Cause:记录传入的是什么异常。包括中断源和造成中断的原因编码等等。
- EPC:用来记录异常/中断时发生的PC(位置),这样处理完之后才能返回错误位置继续执行CPU中的后续指令。
- PRID:代表处理器ID,主要用来实现一些比较个性化的编码。
在本题中由于我们要实现的目标较为简易,所以对于SR和Cause寄存器,我们也只需要它们中间的有效位数,大概如下:
- SR的整体表达为{16’b0,im,8’b0,exl,ie},其中:im(SR[15:10])表示允许发生的中断,exl(SR[1])表示是否处于中断异常中,ie(SR[0])表示是否允许中断。
- Cause[31]用来标记延迟槽,Cause[15:10]表示发生了哪个中断,Cause[6:2]表示发生异常的原因。
我把教程中的功能表格附加如下:
现在,我们要针对CP0的功能构建组合、时序逻辑:
首先,它需要能够感应到受害指令的存在,并且根据受害PC和宏观PC将受害PC存入EPC。所以我们要分别判断是否存在中断、异常。
所以判断方法大概如下:
1 |
|
对于中断(IntReq),需要允许发生的中断发生,所以需要把HWInt和IM按位与,只要有一种终端发生就可以,所以就把结果再每一位之间或以下。同时需要允许发生中断并且不处于中断异常中。
对于其他的内部异常,我们只要判断Exc按位与(存在发生异常的原因)和!EXL再与即可(因为IE只负责中断的使能)。
然后我们再考虑:如果出现了异常,那么我们应该对什么进行修改?应该就是对四个寄存器进行值的修改。如下:
1 |
|
注意:跳转到不对齐的地址时,受害指令是 PC 值不正确的指令(即需要向 EPC 写入不对齐的地址)。(教程内容)
还要考虑存取指令的实现,其实这里和grf的原理基本相同,但是要注意SR,Cause,EPC,PRID的编号是12~15号,要根据编号判断CP0A3。实现如下:
1 |
|
最后就是如果Req==0,也就是已经在异常中或者还没有发生异常的状况:
首先就是如果在异常中并且检测到eret指令,则需要结束异常程序:只需要让EXL为0即可。
其次就是在每一个时钟周期,我们都需要实时更新中断的情况,也就是HWInt。
1 |
|
最后给出设定端口:
端口 | 方向 | 功能 |
---|---|---|
CLK | I | 时钟信号输入 |
Reset | I | 同步复位信号输入 |
CP0Wr | I | CP0写使能信号 |
CP0A3[4:0] | I | CP0写入寄存器地址 |
CP0WD[31:0] | I | 需要向CP0写入的数据 |
PC[31:0] | I | 受害指令的地址,写入EPC |
BD | I | 延迟槽标记,标记受害指令是不是延迟槽指令 |
HWInt[15:10] | I | 标记发生了哪个中断 |
Exc[6:2] | I | 表示发生异常的原因 |
ERET | I | eret指令的控制信号,如果输入eret要将EXL置位0 |
CP0Out[31:0] | O | 从CP0寄存器中读出的数据 |
EPCOut[31:0] | O | 异常结束时输出保存的EPC数据 |
ReqOut | O | 输出当前是否正在异常状态 |
中断异常流水
我们要完成的事情有以下几个:判断某种异常是否发生、对异常进行选择和流水、对出现异常时的操作进行实现。
发生异常
首先,我们需要判断异常是否发生。因此我们需要先对需要判断什么异常进行了解。
异常与中断码 | 助记符与名称 | 指令与指令类型 | 描述 |
---|---|---|---|
0 | Int(外部中断) | 所有指令 | 中断请求,来源于计时器与外部中断。 |
4 | AdEL(取指异常) | 所有指令 | PC 地址未字对齐。 |
4 | AdEL(取指异常) | 所有指令 | |
4 | AdEL(取指异常) | lw | 取数地址未与 4 字节对齐。 |
4 | AdEL(取指异常) | lh | 取数地址未与 2 字节对齐。 |
4 | AdEL(取指异常) | lh,lb | 取 Timer 寄存器的值。 |
4 | AdEL(取指异常) | load型指令 | 计算地址时加法溢出。 |
4 | AdEL(取指异常) | load型指令 | 取数地址超出 DM、Timer0、Timer1、中断发生器的范围。 |
5 | AdES(存数异常) | sw | 存数地址未 4 字节对齐。 |
5 | AdES(存数异常) | sh | 存数地址未 2 字节对齐。 |
5 | AdES(存数异常) | sh,sb | 存 Timer 寄存器的值。 |
5 | AdES(存数异常) | store 型指令 | 计算地址加法溢出。 |
5 | AdES(存数异常) | store 型指令 | 向计时器的 Count 寄存器存值。 |
5 | AdES(存数异常) | store 型指令 | 存数地址超出 DM、Timer0、Timer1、中断发生器的范围。 |
8 | Syscall(系统调用) | syscall | 系统调用。 |
10 | RI(未知指令) | - | 未知的指令码。 |
12 | Ov(溢出异常) | add, addi, sub | 算术溢出。 |
紧接着,我们需要判断这些异常都发生在流水线的哪一级(用来判断优先级),同时要对受害指令做出一定调整,这样才能保证它到达M级之前不会因为错误已经导致CPU出现无法进行的错误情况。为了让内部异常能够在M级被识别,我们需要让ExcCode也进入流水。所以我们分类讨论:
对异常的选择和流水
F级错误
AdEL(取指异常):对于PC地址的异常,包括PC没有字对齐和PC地址超出了0x3000~0x6ffc的范围两种情况。针对这种情况:我们需要将ExcCode置为00100,并且对这条指令作出处理:将instr修改为nop。
我的代码为:
1 |
|
D级错误
RI(未知指令):只需要在CU中判断这条指令的opcode和funct是否在指令集之内即可。如果不满足也是将instr改为nop。同时将ExcCode置为01010。
Syscall(系统调用):在CU中判断出当前指令是一条系统调用指令,则也将instr改为nop。同时将ExcCode置为01000。
所以针对这两条指令,我在CU中进行检测,如果该指令是一条未知指令,我就让变量CU_RISyscall输出1,如果该指令是一条Syscall指令,那么我就让该变量输出2。然后我们同时在CU内和cpu内都将D级的机器码改成nop。在cpu中我的代码如下:
1 |
|
E级错误
Ov(计算类指令溢出):这是针对add/addi/sub三条指令的错误,在ALU中计算溢出。此时我们需要清除grf的写使能,防止溢出的错误数据被写入。同时将ExcCode置为01100。
AdEL(取数地址溢出):和Ov在ALU中的溢出是相同的,差异在于本条指令是一条load指令,因此是不同的内部异常类型。同样我们需要清除grf的写使能,同时将ExcCode置为00100。
AdES(存数地址溢出):和前两种错误在ALU中的行为依然相同,但是本条指令是一条save指令,因此我们需要清除的是DM的写使能,让错误数据不存入内存,同时将ExcCode置为00101。
因此我们不难发现,以上三种指令在ALU中的行为是一致的:溢出。所以我们只需要在ALU多一个溢出的判定输出就可以完成ALU内部的检测:
1 |
|
然后我们同样需要在cpu中流水ExcCode并且将对应的写使能信号进行置零。以下是我在cpu中写的代码:(没有在cpu模块中用Parameter或者宏定义操作确实比较不便)
1 |
|
M级错误
AdEL(取数地址未对齐、非法取数):非法取数即地址越界。对于这种异常我们需要清除grf的写使能,同时将ExcCode置为00100。
AdES(存数地址未对齐、非法存数):同理,非法存数也是地址越界。我们只需要清除DM写使能信号即可,同时将ExcCode置为00101。
由于我们已经将DM模块虚拟化放到了testbench中,所以我们在cpu中对M级给出的地址进行检测操作即可。要注意本题中DM的允许操作的地址为:0x0000_0000∼0x0000_2FFF和0x0000_7F00∼0x0000_7F0B,0x0000_7F10∼0x0000_7F1B,0x0000_7F20∼0x0000_7F23。同时由于0x0000_7F00∼0x0000_7F0B,0x0000_7F10∼0x0000_7F1B域,只支持lw和sw的操作,因此对于半字和字节相关存取操作我们只允许第一个域和第四个域的操作。我的代码如下(全部在cpu模块中):
1 |
|
则此时经过流水和优先级分析,这里的ExcCode就是这一条指令的最优先的指令。同时由于这条指令就是当前流水线里最优先的指令,因此它的异常一定是需要被最先响应的。所以我们可以确定此处的ExcCode就是我们需要输入CP0的内部异常数据。
BD流水
由于在CP0中针对非延迟槽指令和延迟槽指令的EPC返回地址不同。证明我们需要将BD信号(本条受害信号是否是延迟槽指令)也放入CP0,所以我们对BD也需要流水。
我们只需要在D级进行判断即可:如果当前D级指令是一条跳转指令(beq,bne,jal,jr),我们就将一个BD=1信号传输给D寄存器,这样下一条指令(即延迟槽指令)进入D级时BD也会跟随进入。
所以我们要做的操作就只有在D,E,M三级寄存器上都构建出数据通路,并且在D级(CU中)做好判断即可。
在CU中的BD控制信号判断:
1 |
|
出现异常时的操作
清空流水线
出现异常的操作,其实就是对M级及之前的流水线进行清空。对于一般的D,M寄存器,我们只需要在本来就有的Reset信号旁边并上一个Req信号即可。真正需要注意清空优先级的是PC和E级寄存器:
对于PC寄存器,Reset,Req和Stall情况下的操作是不同的:对于Reset,我们需要将PC置成0x3000,对于Req,我们需要将PC置成0x4180,而对于Stall,我们就是正常的暂停PC的改变,让它还是上一条指令的PC。所以我们需要用if,else-if结构来划定其优先级(我对于暂停的做法是赋给它使能信号,使能信号为0时就是暂停):
1 |
|
对于E级寄存器,重要的就是BD寄存器的情况。因为如果简单的将Reset信号和Req信号进行或操作,那么在暂停时也会导致BD被置为0,这样就错误了(引用教程中的话:因为在外部去看的话,会发现宏观 PC 是相同的,但是延迟槽标记是不同的,这显然是不正确的。如果在延迟槽指令被阻塞时产生中断,并且 nop 没有流水延迟槽标记,那么 EPC 就会被设置错误的值,无法通过评测),将BD在置零的时候进行判断即可:
1 |
|
还有就是E级时PC的情况。在暂停时E_PC也应该从D_PC中获取,这样如果这条插入的nop空泡进入M级时发生外部中断,存入EPC的时下一条被暂停指令的PC(因为被流水了)而不是空PC,否则eret后跳回位置就是0x0,就会出错。
1 |
|
乘除槽
我们需要阅读教程中的这一段说明文字:
在进入中断或异常状态时,如果受害指令及其后续指令已经改变了 MDU 的状态,则无需恢复。假设 CP0 在 M 级,MDU 在 E 级,考虑以下情况:
mult 在 E 级启动了乘法运算,流水到 M 级时产生了中断,此时无需停止乘法计算,其它乘除法指令同理。
mthi 在 E 级修改了 HI 寄存器,流水到 M 级时产生了中断,此时无需恢复 HI 寄存器的值,mtlo 同理。
mult 在 E 级,受害指令在 M 级,此时还未改变 MDU 状态,不应开始乘法计算,其它乘除法指令同理。
mthi 在 E 级,受害指令在 M 级,此时还未改变 MDU 状态,不应修改 HI 寄存器的值,mtlo 同理。
也即,由于我们需要消除异常指令及其后的指令流水带来的写入内存影响,所以对于异常指令,我们需要保证其写入位置使能关闭,这也就是我们为什么之前需要关闭grfwr和dmwr的原因,而后续指令由于最多只能进行到E级,正常情况下是不会改变grf和dm中的存储就会被req信号清空,所以不必考虑。
但是我们要注意到乘除模块是在E级的,而无论是mult,div这样会让模块开始运算的指令,抑或是mthi,mtlo这两个需要改变HI,LO寄存器值的运算,都是在M级发现错误时就已经开始运作的。所以我们需要保证如果是M出错而它们在E,就不让他们工作,而他们到M发生中断就不管了。也就是上面教程说的。我们增加一个判断信号即可:
1 |
|
添加指令
我们添加的指令包括mfc0,mtc0,eret。其中前两条指令就是和P6增加指令类似的操作(尤其类似mfhi,mflo,mthi,mtlo,都是对E级外置的一些寄存器的存储和读取过程)。
mfc0
mfc0的指令相关信息如下:
编码 | 31-26 | 25-21 | 20-16 | 15-11 | 10-0 |
---|---|---|---|---|---|
编码 | COP0:010000 | mfc0:00000 | rt:5 | rd:5 | 0:11 |
操作 | GPR[rt] <- CP0[rd] |
可见本指令并没有用到rs和rt寄存器在grf中存的值,因此tuse全为0,而生成新数据(抓取的CP0[rd]的值)在M级,因此tnew和读取指令,如lw相同,为3。
本指令输入CP0的读取寄存器编号(CP0A3)为M_Instr[15:11],也就是rd的位置。本条指令和之前的指令存在差别,靠后的rd指示的是读取寄存器位置而靠前的rt指示的是存入寄存器位置,我们需要注意(所以本条指令存入位置ATarget也为Instr[20:16],相当于grfwr=0)
还有一点,本指令可以类比mfhi,mflo,在M级同时有DM和CP0两个元件提供输出,因此我们需要对这两个输出进行选择。正如为了完成mfhi和mflo我们需要将一个ALUMHLOSel进行流水,这里我们也需要将DMCP0Sel进行流水,当其为0时输出DM的读取值,1时输出CP0的读取值。
mtc0
mtc0的指令相关信息如下:
编码 | 31-26 | 25-21 | 20-16 | 15-11 | 10-0 |
---|---|---|---|---|---|
编码 | COP0:010000 | mtc0:00100 | rt:5 | rd:5 | 0:11 |
操作 | CP0[rd] <- GPR[rt] |
本指令完全可以类比mthi,mtlo,只是存入位置在M级,需要改变Tuse,Tnew即可。同时注意存入的要是M级经过转发后的M_MUX_RD2。
eret
本条指令的功能其实就是在handler(异常处理程序)的最后将PC跳回原先的受害指令的PC,相当于退出函数的jr $ra的感觉。它的相关信息如下:
编码 | 31-26 | 25-6 | 5-0 |
---|---|---|---|
编码 | COP0:010000 | 10000_00000_00000_00000:20 | eret:011000 |
操作 | PC <- CP0[epc] |
这里我想将eret放在D级实现。因为很明显eret之后的指令(PC+4)没什么意义,我们需要的是epc后的指令。所以如果我们将eret放在M级CP0位置感应,我们就需要再次清空其后的流水线,所以我想在D级就判断eret。
但是很明显,D级判断eret无法阻止pc:eret+4这条指令在F级被读出。所以我们可以将PC进行选择,如果eret判断为1,我们就将读取epc中存储的指令地址指向的那条指令。代码如下:
1 |
|
但是这里还会碰到一个问题,那就是一旦我们在D级就进行判断,就有可能出现数据冒险。如果有eret(D)-mtc0(E/M)而且mtc0存入位置是EPC的情况,就有可能前面的存入指令还没有更改EPC,后面的eret已经读出了旧的EPC,就会出现问题。
我采用的方法是转发,我们只需要判断E,M两级指令是否为写入地址是14(EPC)的mtc0指令(op==30)即可,而且E级更新,优先级更高,一旦出现这种情况,就直接将GPR[rt](当然这里也是经过普通转发暂停处理完成后的)当作EPC进行输出。所以我们修改选择给im的pc即可,代码如下:
1 |
|
CPU封装
根据题意,我们需要将原本的顶层模块mips和新写的cp0进行连接后作为一个新的模块CPU,而这个CPU模块需要和其他的模块,例如Bridge,Timer模块组成新的顶层模块mips,这就需要我们对原本的顶层端口进行增加(其实就是增加了两个端口,并且原先的一些DM相关端口的功能发生了一定改变)
我给出的新端口设计如下:
端口 | 方向 | 功能 |
---|---|---|
clk | I | 时钟信号输入 |
reset | I | 同步复位信号输入 |
i_inst_addr[31:0] | O | 输出给testbench中的外置IM的机器码地址pc |
i_inst_rdata[31:0] | I | 在testbench中的外置IM根据pc传回的当前指令机器码 |
m_data_addr[31:0] | O | 需要输出给BRIDGE的存入地址,需要通过BRIDGE进行选择 |
m_data_wdata[31:0] | O | 输出到BRIDGE中的存储值 |
m_data_byteen[3:0] | O | 字节使能信号(解释:这个必须从这里输出,因为桥产生不了位选信息,需要输入到BRIDGE里处理是否为0,但是其他的写使能信号,直接从BRIDGE中生成) |
m_data_rdata[31:0] | I | 从BRIDGE输入的load指令的读取值 |
m_inst_addr[31:0] | O | 当前M级指令PC,输出给评测机,可用作宏观PC |
w_grf_we | O | grf写使能信号 |
w_grf_addr[4:0] | O | grf写入寄存器序号 |
w_grf_wdata[31:0] | O | grf待写入数据 |
w_inst_addr[31:0] | O | 当前W级指令PC |
HWInt[5:0] | I | 当前外部中断信号,输出给CP0 |
IntReq | O | CP0输出,表示目前的异常情况 |
Bridge
对于系统桥,我的理解其实就是一个组合逻辑的“提示器”,我们将DM,两个Timer和中断发生器当作四个不同地址的外设,此时我们需要确定M级相关的store,load指令应该存取到哪个地址,哪个外设。此时我们就需要用Bridge来进行内存操作和真正的外设之间的连接。
参考博客给出的Bridge模块相关接口:
端口 | 方向 | 功能 |
---|---|---|
Addr[31:0] | I | ALU计算出的存取操作写入地址(base+offset) |
M_byteen[3:0] | I | 从CPU发出的存入操作使能信号(不确定是用于DM还是中断发生器) |
DMOut[31:0] | I | 从DM中读出的数据 |
TC0Out[31:0] | I | 从TC0中读出的数据 |
TC1Out[31:0] | I | 从TC1中读出的数据 |
DMAddr[31:0] | O | 要写入DM外设的地址,若不写则置0 |
TC0Addr[31:0] | O | 要写入TC0外设的地址,若不写则置0 |
TC1Addr[31:0] | O | 要写入TC1外设的地址,若不写则置0 |
DM_byteen[3:0] | O | DM的存入操作使能信号(用来测评) |
TC0Wr | O | TC0的写使能信号(因为只能存入整字,所以一位即可) |
TC1Wr | O | TC1的写使能信号(因为只能存入整字,所以一位即可) |
CPUIn[31:0] | O | 从这些外设的输出中选出真正CPU想要读取的数据,输出回CPU |
IGAddr[31:0] | O | 要响应中断发生器的地址,要输出给Testbench |
IG_byteen[3:0] | O | 要响应中断发生器地址,存入的操作使能信号,输出给Testbench |
要注意,中断发生器这一外设是一个简化版的用于模拟的外设,如果存入这一位置,我们只要给出我们的存入地址和存入的使能信号(4位)即可,同时load这一位置直接赋0,不用在CPUIn处做过多判断(?)
写Bridge时我的思路就是:有什么东西需要output就assign什么东西,assign的方式是:CPU给出的Addr如果在外设范围内就是CPU给出的那个东西,否则就是0。
1 |
|
还有:CPU给出的存入数据是不需要通过桥的,因为看上面的图我们也能发现:其实蓝线只是穿过了Bridge而并没有什么改变,还是平等的赋给所有外设,是否真正写入是通过写使能信号的赋能。
计时器
计时器的代码在教程中有。我个人就把它当成一个地址不同的,有输入有输出的,只能整字存取的DM即可,不再赘述。
思考题
相当于本题的中断发生器,就是在外设上做操作的时候就把中断请求发给了CPU,然后CPU就响应不同的中断,并通过这种方式读取这些操作。
因为这样比较方便管理,一方面只要出现中断就可以在下一个上升沿直接跳转到指定地点而不需要进行任何读取,另一方面我们也能确定这些异常处理程序不会和其他程序有PC冲突。当然用户提供程序也可以,但是这样就让我们需要每次都重新确定这个程序的位置(不是固定值),就很麻烦。
因为外设不同,所占据的地址也不同,一旦有很多外设就要在CPU中装入非常多的多路选择器来确定要写入的位置,所以我们可以把选择传输过程抽象成Bridge和CPU分离,便于这个逻辑的管理。
模式0:载入计数器后开始计时,当计数器倒计时到0的时候就让控制寄存器使能信号变成0。只有当外界给使能信号重新赋值为1才会重新把要计的时间载入计数器。模式1:只要计时器倒计时变成0就自动重新载入,同时使能信号也是只持续一个周期。
宏观指令就会出现PC=0,这显然和现实情况不符。所以需要在暂停时E保留的是PC和BD,这样是为了让这条Nop指令在外部看来和后续的被延迟的指令拥有同样的外部属性。
作为一条跳转指令,如果其延迟槽出错,那么要返回的EPC就是这条跳转指令,但是由于rd和rs完全相同,那么会导致第二次跳转到与第一次不同的位置导致错误(相当于如果rd和rs相等,那么这个操作就不可逆了)。