北航2021届计组 - 支持中断的CPU
Posted living_frontier
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了北航2021届计组 - 支持中断的CPU相关的知识,希望对你有一定的参考价值。
北航2021级计组支持中断的CPU
I t i s a l l a b o u t c o n t r o l , c h e a t a n d t r a d e o f f . It\\space\\space is\\space\\space all\\space\\space about\\space\\space control,\\space cheat\\space\\space and\\space\\space tradeoff. It is all about control, cheat and tradeoff.
一、总论
1.1 关于需求
我认为,在P7,我们设计的最终目的是实现一个看上去像单周期CPU的、具有反馈控制接口的流水线CPU。如果再将目标细化一些,那么就是两个小目标,那就是:
- 从外部(也就是软件程序开发者)看,流水线CPU被封装成了单周期CPU
- CPU可以为外部提供反馈信息,并且可以让外部根据反馈信息进而更改CPU的运行状态(整个过程叫做反馈,实现了这个功能的CPU具有了软硬件交互接口)
其中第一个需求,体现的是对硬件良好的封装能力,也就是极高的设计能力,设计者必须同时站在CPU内部和外部角度,从外部去检查没有流水线的任何细节漏出,从内部去实现这种伪装。对于第二个需求,体现的是对控制和接口更高层次的理解,我们的CPU的控制会变得更加复杂,会具有反馈和二次控制的功能,同时,软硬件接口交互也加大了理解的难度,因为有一部分功能交由软件负责,而我们不负责软件。
所以,在这一节,有两个重要的认知,第一个就是明确我们的需求是什么,第二个是,排除一些错误的观念,比如我一开始设计的时候,以为目标是像P6一样,最重要的是实现一个模块(比如说乘除模块),然后加上相关的指令就好了。但是在P7,实现了桥,计时器,协处理器只是很小很小,几乎完全不体现设计难度的部分,这是因为这些功能只是功能模块,与封装和接口的关系不太大。
如果再说的透彻一些,P7是对封装的理解与实践。
1.2 关于封装
首先先说一下,我们封装的目标就是把CPU弄得像设置了延迟槽的MARS一样。任何设计的需求的不清晰,都应该参照MARS的行为(而不应该根据宏观PC、犯罪指令或者其他二级理解概念进行理解分析)。
那么我们离这个目标差距在哪里呢?因为我们的CPU是流水线模式,所以在同一个时间节点会有多条指令并行处理,也就是说,我们在封装的时候的基本矛盾,就是多条指令并行的客观事实与单条指令顺序执行的封装假象之间的矛盾。
为了实现这个目标,我们需要实现以下具体需求:
- 为外界提供一个像正常的单周期PC行为(不会闲的没事跳变)的PC(在一个流水线里一般都有五个不同的PC),这个PC也就是我们说的宏观PC。
- 宏观PC这个概念不是内部设计构造出的概念,而是外部对内部设计的需求。对于宏观PC对应的指令,该指令之前的所有指令序列对 CPU 的更新已完成,该指令及其之后的指令序列对 CPU 的更新未完成
具体的实现下文介绍。
1.3 关于接口
我觉得接口最重要的概念是声明(不知道学术一点是不是叫协议),就是接口的双方需要具有相应的约束条件,比如说如果软件声明了不在异常处理程序中使用乘除模块hi和lo的值(可能只读不用),那么在硬件实现的时候,陷入中断的时候就可以不暂停乘除运算。所以这个的重中之重,就是理解教程中的软件到底承诺了什么,对于这种承诺的理解偏差,是导致很多人包括我,错误的原因。这也是P7除了封装以外最大的难度,因为封装只是设计能力,但是理解软件约束,考察的是理解能力。
1.4 关于信号
信号是控制实现的载体。在P4的时候,我将信号分成了三种(植物分类学爱好者的神奇癖好):使能、选择、功能。可以看到,这三种信号其实实现的一个目的,就是让CPU按照要求工作,比如说add来了,那么ALU就需要运行加法,寄存器就需要把结果存储进去。
但是随着设计需求的不断提高,我们发现有很多信号是没有办法进行这样的归类,在进入P5以后,比如说A值,T值,条件指令的condition,会发现这些信号是没有办法按照上面的分类进行归类的,这是因为他们是完全不一样的信号,他们不直接决定CPU的功能,而是间接地控制CPU,比如给出T值,T不会直接连到流水线寄存器的使能端,而是通过一个T值的比较,进而生成了stall信号。这些信号是二级信号。
然后我们在P7会发现,会有新的异常相关的信号,比如ExcCode,ExcReq,还有外部的reset信号(稍微特殊一点,容忍一下),或者IntReq信号,这些东西,也是没办法归类的。
我在新的信号分类体系中,我将其分为静态信号和动态信号,静态信号就是只用肉眼看汇编就可以分析出来的信号,比如说j就是跳转,目标就是那个地址,动态信号就是需要在运行的时候才能确定的信号,比如beq需不需要跳转?哪个数据需要转发?异常在哪条指令发生?中断啥时候来?这些都是不运行肯定确定不了的。
我为什么要在一个讲如何搭CPU的博客里讲信号。这是因为只有在有了这个明确的分类,我们才能意识到,我们在实现P7的时候,实现的信号几乎都是以动态信号的考察,实现动态信号比实现静态信号更为复杂,因为动态信号更加考验逻辑,静态信号啥时候像AT法那么难理解了。但是AT法还是有明确教程的提醒,大家不至于放松警惕,而P7教程的短小和实现的自由,很容易让人忽略这个点,比如说很多人(包括我)没有意识到各个清空信号的优先级问题(就是那个著名的缺少中断6/10)。如果在设计之初,就可以意识到这个事情,那么就不会粗心大意了。
1.5 关于中断
先明确一下本文的各个概念(只是约定,为了概念没有被混淆),不正常的情况分为两种:内部异常和外部中断,外部中断包括计时器中断和tb中断,所有不正常的情况都会导致CPU进入中断,也就是异常处理程序。我在整篇文章都会只用这几个概念:内部异常、外部中断、计时器中断、tb中断、中断。每个概念都不会有别的意思,也不会与其他概念混淆。
中断其实与过程(函数)调用极其相似,在《深入理解计算机系统》这本书中,说“中断就是不确定发生位置的过程调用。”我觉得是一个很好的类比。当发生中断的时候,就好像一个函数调用一样,会跳到一个固定的位置0x4180(仅是我们的CPU),然后开始一系列指令,最后用eret返回(就好像jr一样)。然后经过这个函数,我们就可以重新执行原来的程序了。
可以看到和函数调用具有很多的相似性。但是同时也应该意识到,正是因为发生中断的位置是无法确定的,所以要想将中断封装成单周期CPU,是有很大的难度的。
1.6 关于实现
首先要强调,P7是很花费时间的,我是从上周开始做准备,看各种资料,从上周五开始写代码,写了周五一天,周六一天,周日晚饭前写完了,然后开始做测试,debug,要是没有先是两位最彪悍的头脑帮我肉眼debug,然后另一个彪悍的头脑给出了彪悍的数据,我是不可能在周日晚上11点de完的,而这之后,为了保证CPU没啥大问题,我又进行了周一晚七点到十一点的对拍,然后现在在写我的设计文档。虽然我确实手慢脑子笨,但是也不得不说,P7挺花时间的。
吴佬给出了一个实现顺序是先CP0,然后桥,然后计时器。我当时就是按照这个顺序走的,但是效果不太好。原因是正如前文阐述,P7的难点不在模块的实现,而是在封装和接口的设计,就好像P5的难点不在于流水线寄存器该咋写,而是在转发和阻塞逻辑的设计。所以我觉得应该先设计中断相关,不需要设计任何模块,就可以完成这个设计,只需要假定自己已经有了一个信号Req,是中断信号,只要这个信号置1,就会中断就能完成大部分设计。
实现流程大约一下几步(没有深思熟虑,单纯给出任务量)
- 各个模块对内部异常的检测
- ExcCode的流水
- 各个模块的内部异常的容忍处理(比如变成nop之类的)
- 各个模块对中断的反应(比如流水寄存器需要清空,写的东西要清掉,或者不写)
- 实现CP0
- 实现桥
- 实现mips整体架构(感觉可以先实现这个,然后实现桥会更好)
- 实现三条指令,实现相关控制信号
- 提交,然后debug(当然可能AC)
- 了解tb功能和MARS的导出,使其可以读两个文件
- 对拍,在对拍过程中发现要么是CPU错了(只有这种是有用的),要么是CPU实现的不一致,要么是tb错了,要么是汇编写错了。感觉情侣档做这个一定很有效,这个比做过山车刺激多了,吊桥效应拉满了。对完拍的我看岳哥哥都眉清目秀的。
- 改bug
随着有大佬通过课上强测、自动化对拍机的开发和新的tb的产生,之后的实现速度应该会加快,这里跪求一波。
最后说一下对于资料的学习,首先声明,因为我学习的某些独特性,以下建议我不负责,请勿随意模仿。教程提供了很多资料,我感觉还是《SMRL》最香,然后要是能看到《深入理解计算机系统》,那么从Y-86一路看下来,最后看中断,还是挺好的。我没有看课件,《软硬件接口》我觉得写得不太清楚,《数字电路设计》也是同样的毛病。
二、新模块
2.1 CPU模块
这里不是简简单单的将P6的mips模块改成CPU就完事了,CPU的端口是需要设计的(可以发挥设计能力了),这里其实也体现了接口的一个特性,因为CPU成了内部模块,所以之前那些最外部模块的接口要求,CPU模块就不用理会了,比如又可以把端口的命名风格跟自己的命名风格统一了,而不用考虑tb接口的命名风格了。
比较值得注意的是,CPU相比于原来的mips模块,多了两个功能性端口(其他端口一般只是改个名),一个是HWInt,用来接收中断;一个是IntReq,用于测试约定。
端口 | 方向 | 解释 |
---|---|---|
clk | IN | 时钟信号,保留 |
reset | IN | 复位信号,保留 |
i_inst_addr | OUT | 取指地址,因为IM的取指是不通过桥的,所以保留原有设计就好了 |
i_inst_rdata | IN | 取出来的指令,保留 |
CPUIn | IN | ProcessorReadDate,这个与之前不一样了,是因为ProcessorReadDate的来源不在只有DM了,还有计时器,所以这个数据需要接受BRIDGE的输入 |
HWInt | IN | 用于传递来自计时器和外部的中断信号 |
VAdd | OUT | 这个也是,CPU只生成一个地址,具体根据地址选择不同的IO,是桥的功能,所以需要输入到BRIDGE中 |
CPUOut | OUT | ProcessorWriteDate,这个需要输入到BRIDGE中 |
CPUByteEn | OUT | 这个必须从这里输出,因为桥产生不了位选信息,需要输入到BRIDGE里处理是否为0,但是其他的写使能信号,直接从BRIDGE中生成 |
IntReq | OUT | 为了测试约定,跨过地址限制去写7f20 |
m_inst_addr | OUT | 评测用的,保留就好了 |
w_grf_we | OUT | grf 写使能信号,评测用的,保留 |
w_grf_addr | OUT | grf 待写入寄存器地址,评测用的,保留 |
w_grf_wdata | OUT | grf 待写入数据,评测用的,保留 |
w_inst_addr | OUT | W 级 PC,评测用的,保留 |
macroscopic_pc | OUT | 宏观PC |
2.2 BE模块
桥的端口声明:
端口 | 方向 | 位数 | 解释 |
---|---|---|---|
VAdd | IN | 32 | 获得CPU需要的地址,根据这个地址判断需要选择哪个设备读写数据 |
CPUByteEn | IN | 4 | 这个信号仅依靠BRIDGE无法自己产生,所以需要输入 |
DMOut | IN | 32 | 需要输入DMOut(m_data_rdata),挑选要读出哪个数据 |
TC0Out | IN | 32 | 需要输入TC0Out,挑选要读出哪个数据 |
TC1Out | IN | 32 | 需要输入TC0Out,挑选要读出哪个数据 |
DMAdd | OUT | 32 | 如果读写DM,那么这要从这里输出,否则置0,需要连接m_data_addr |
TC0Add | OUT | 30 | 如果读写TC0,那么就要从这里输出,否则置0,只有30位的原因是只支持按字读写 |
TC1Add | OUT | 30 | 如果读写TC0,那么就要从这里输出,否则置0 |
DMByteEn | OUT | 4 | 如果是在写DM,那么这个值就与CPU产生的ByteEn一样,不然就置0,连接m_data_byteen |
TC0WE | OUT | 1 | 如果是在写TC0(需要结合CPUByteEn判断),那么就置1,否则置0 |
TC1WE | OUT | 1 | 如果是在写TC1(需要结合CPUByteEn判断),那么就置1,否则置0 |
BEOut | OUT | 32 | 需要在DMOut,TC0Out,TC0Out中挑选一个,输出到CPUIn中 |
可以看出,桥功能的实现,最重要的就是根据VAdd判断到底选择了哪个IO设备,也就是说,里面会有三个线,来根据VAdd的范围来判断命中了哪个仪器。然后才能对输入输出的各种数据进行选择。
桥的功能,主要是根据VAdd范围,来判断写使能信号和读数据,但是写数据还是直接连接在了相应端口,所以确实不太美观。
其实m_data_addr,TC0Add,TC1Add都可以统一成VAdd,因为反正有写使能信号和读选择把关,这样处理的原因只是因为美观和规范(CPU只通过桥与存储部件沟通,而不是自己直接沟通,复杂的mips设计里应该会涉及对VAdd的操作,而不是直接截断这种简单处理),没有任何实际意义。
2.3 计时器模块
计时器还是一个挺常见的中断源的(这个在嵌入式开发中特别常见,我记得51单片机上应该是装了4个?),虽然不像键盘鼠标那么好理解,但是跟CPU是在同一个时钟域,所以外部中断处理起来还是很方便的。在书上应该也有介绍,大家可以有兴趣了解一下。
这个是给的源文件,所以难度就在于看懂它,主要是要注意是30位的地址,连端口的时候注意不要输入一个32位的地址就好了。然后但是为了理解,我就又自己写了一个TC,采用的是我在Pre那篇文章里提的状态机风格,应该很好读懂,列于下:
`timescale 1ns / 1ps
module myTC(
input clk,
input reset,
input en,
input [31:0] TCAdd,
input [31:0] TCIn,
output reg [31:0] TCOut,
output IRQ
);
parameter
INIT = 0, //初始态,对应的是代码中的IDLE
LOAD = 1, //加载态,加载Count中内容
CNT = 2, //计数态
ZERO = 3; //计数为0态,对应代码中INT,但是其实不准确,因为IRQ在IDLE中可能也置1
//这里没有采用声明数组的方式,用这个更清楚楚一些
reg [1:0] status, nextStatus;
reg [31:0] Ctrl, nextCtrl, Preset, nextPreset, Count, nextCount;
reg isZero, nextIsZero;
//Ctrl[3] IM被置位1的时候允许中断
//Ctrl[2:1] Mode模式位
//Ctrl[0] 寄存器使能位
reg IEn, CntEn;
reg [1:0] Mode;
always @(*) begin
IEn = Ctrl[3];
Mode = Ctrl[2:1];
CntEn = Ctrl[0];
end
//当允许中断,且isZero为0的时候,就可以发出中断请求
assign IRQ = IEn & isZero;
//做一个地址映射
always @(*) begin
case(TCAdd)
0 : TCOut = Ctrl;
1 : TCOut = Preset;
2 : TCOut = Count;
default : TCOut = Ctrl;
endcase
end
/*
这段代码出现在了官方代码中,其实是为了保证display的正确输出
如果store Ctrl寄存器只store低四位,前面的高位不能修改,只能是0
所以用load做中间变量达到这个效果,display才是正确的
*/
//wire [31:0] load = (TCAdd[3:2] == 0) ? 28'h0, TCIn[3:0] : TCIn;
// about status
always @(*) begin
if(en)
nextStatus = status;
else begin
case(status)
INIT :
//如果使能,才进行加载
if(CntEn) nextStatus = LOAD;
//否则维持原态
else nextStatus = INIT;
LOAD :
nextStatus = CNT;
CNT :
if(CntEn) begin
if(Count > 1) nextStatus = status;
else nextStatus = ZERO; //计数器到0了
end
//这是要重新加载新数的前兆,即停止计数,所以状态要回到初态
else
nextStatus = INIT;
ZERO :
nextStatus = INIT;
default :
nextStatus = INIT;
endcase
end
end
// about Ctrl
always @(*) begin
if(en) begin
if(TCAdd[3:2] == 2'b00) //写Ctrl寄存器的时候,只写后四位
nextCtrl = 28'h0, TCIn[3:0];
else
nextCtrl = Ctrl;
end
else begin
case(status)
INIT, LOAD, CNT :
nextCtrl = Ctrl;
ZERO : begin
nextCtrl = Ctrl;
if(Mode == 2'd0)// 在模式0下,CntEn只维持一个周期
CntEn = 0;
end
default :
nextCtrl = Ctrl;
endcase
end
end
//about Preset
always @(*) begin
if(en) begin
if(TCAdd[3:2] == 2'b01)
nextPreset = TCIn;
else
nextPreset = Preset;
end
else begin
case(status)
INIT, LOAD, CNT, ZERO :
nextPreset = Preset;
default :
nextPreset = Preset;
endcase
end
end
// about Count
always @(*) begin
if(en) begin
if(TCAdd[3:2] == 2'b10)
nextCount = TCIn;
else
nextCount = Count;
end
else begin
case(status)
INIT :
nextCount = Count;
LOAD :
nextCount = Preset;
CNT :
nextCount = Count - 1;
ZERO :
nextCount = Count;
default :
nextCount = Count;
endcase
end
end
//about isZero
always @(*) begin
if(en)
nextIsZero = isZero;
else begin
case(status)
INIT : begin
if(CntEn)
nextIsZero = 0;
else
nextIsZero = isZero;
end
LOAD :
nextIsZero = isZero;
CNT : begin
if(Count == 1)
nextIsZero = 1;
else
nextIsZero = isZero;
end
ZERO : begin
if(Mode == 2'd1) //模式1只产生一个周期的高位信号
nextIsZero = 0;
else //模式0会一直产生
nextIsZero = isZero;
end
endcase
end
end
always @(posedge clk) begin
if(reset) begin
status <= 0;
Ctrl <= 0;
Preset <= 0;
Count <= 0;
isZero <= 0;
end
else begin
status <= nextStatus;
Ctrl <= nextCtrl;
Preset <= nextPreset;
Count <= nextCount;
isZero <= nextIsZero;
end
end
endmodule
还有一个坑点是Count是只读的,所以如果storeCount寄存器,会异常,我也不知道为啥没有注意到。
状态图如下
在模式0下:当计数器倒计数为 0 后,计数器停止计数,此时控制寄存器中的使能 Enable 自动变为 0。当使能 Enable 被设置为 1 后,初值寄存器值再次被加载至计数器, 计数器重新启动倒计数。 模式 0 通常用于产生定时中断。例如,为操作系统的时间片调度机制提供定 时。模式 0 下的中断信号将持续有效,直至控制寄存器中的中断屏蔽位被设置为 0。
在模式1下:当计数器倒计数为 0 后,初值寄存器值被自动加载至计数器,计数器继续倒 计数。 模式 1 通常用于产生周期性脉冲。例如,可以用模式 1 产生步进电机所需的 步进控制信号。不同于模式 0,模式 1 下计数器每次计数循环中只产生一周期的中断信号。
模式0计时结束后,一直保持中断,直到en或IM被修改,模式1计时结束后,中断一个周期,再重新计数。可以理解为中断保持的逻辑不同。
2.4 CP0模块
CP0是系统控制协处理器,CP1是浮点协处理器。
通用寄存器k0,k1(26,27号寄存器)是两个(由软件约定)预留下来的用于异常处理代码中的通用寄存器
CP0中不仅有教程中要求的四个寄存器,还有其他加起来总共大约是16个的寄存器(MIPS刚出现的时候,最多可以有32个CP0寄存器),所以课上要求的编号是十几就不奇怪了,在这里记录一下编号
寄存器 | 编号 |
---|---|
SR | 12 |
Cause | 13 |
EPC | 14 |
PRId | 15 |
那么即使是四个寄存器,也有很多功能域,我们需要用哪些挑选哪些功能域去实现呢?
最简单的办法,就是教程的提交要求里面都给写好了,照着实现就好了。如果是从理论出发的话,那么功能域的实现其实是一个软硬件交互的问题,每实现一个功能域,软件(这里指的就是异常处理程序)对硬件的控制或者了解就多了一些,如果不实现BD,那么异常处理程序就无法研究延迟槽指令或者分支指令。
以上是一些小知识的补充,跟具体实现关系不大。
虽然我一直在弱化新模块的难度,但是其实新模块的功能实现还是挺难的(要是不与中断相比的话),具体到CP0,需要注意的有以下几点:
- 要利用输入的HWInt去做外部中断请求判断,而不是内部的IP,否则会慢一个周期
- 需要单独引出一个tbReq,用来响应tb中断,这是测试约定(后面会细说)
- 关于EPC存入的是哪一个值,需要利用BDIn信号进行判断
- Req来临的时候,是没有办法用mt来写东西的,所以当写寄存器的时候,有一个优先级判断(关于所有的写信号处理,后面有总结)
CP0的结构:
寄存器 | 功能域 | 位域 | 解释 |
---|---|---|---|
SR(State Register) | IM(InterruptMask) | 15:10 | 分别对应六个外部中断,相应位置 1 表示允许中断,置 0 表示禁止中断。这是一个被动的功能,只能通过mtc0这个指令修改,通过修改这个功能域,我们可以忽视一些中断 |
EXL(ExceptionLevel) | 1 | 任何异常发生时置位,这会强制进入核心态并禁止中断 | |
IE(InterruptEnable) | 0 | 全局中断使能,该位置 1 表示允许中断,置 0 表示禁止中断 | |
Cause | BD(BranchDelay) | 31 | 当置1的时候,EPC指向分支指令,否则指向当前指令 |
IP(InterruptPriority) | 15:10 | 为 6 位待决的中断位,分别对应 6 个外部中断,相应位置 1 表示有中断,置 0 表示无中断。这个会每个周期被修改一次,修改的内容来自计时器和外部中断(就是输入是HardwireIntReq)。 | |
ExcCode | 6:2 | 异常编码,记录当前发生的是什么异常 | |
EPC | - | - | EPC 寄存器负责保存中断/异常时的 PC 值 |
PrId(Processer ID) | - | - | 通常存入处理器 ID,可以用于实现个性的编码 |
关于为啥IP这个名字这么奇怪,是因为因为咱们的CPU不支持不同中断的分类处理,同时也不支持不同中断同时发生时优先级比较,那么IP的功能相当于只被限制在了记录有没有中断,然后与InterruptMask配合,控制中断的使能,起不到优先级的作用。
CP0的端口声明
端口 | 方向 | 位数 | 解释 |
---|---|---|---|
clk | IN | 1 | 时钟信号 |
reset | IN | 1 | 复位信号 |
en | IN | 1 | 写使能信号 |
CP0Add | IN | 5 | 寄存器地址 |
CP0In | IN | 32 | CP0写入数据 |
CP0Out | OUT | 32 | CP0读出数据 |
VPC | IN | 32 | 受害PC |
BDIn | IN | 1 | 是否是延迟槽指令 |
ExcCodeIn | IN | 5 | 记录异常类型 |
HWInt | IN | 6 | 输入中断信号 |
EXLClr | IN | 1 | 用来复位exl |
EPCOut | OUT | 32 | EPC的值 |
Req | OUT | 1 | 进入处理程序请求 |
tbReq | OUT | 1 | 有外部中断请求产生,主要是为了测试约定 |
CP0之所以这么多个端口,是因为除了正常的读和写功能之外,CP0还支持一些奇奇怪怪的读写功能,这些读写数据都需要另外开辟端口。
2.5 MDU乘除模块
因为P6我重构CPU来着,所以就没时间写博客了,在这里补一下乘除模块的实现。确实如果老老实实地等10个周期,然后算出结果,也可以立刻算出结果,然后用一个10周期的计数器产生busy信号,这样的话,两个状态机是解偶联的,更容易看懂和实现,而且根据P7的要求,也不要求中断的时候对MDU进行操作,所以这种“偷工减料”不会遭报应,还是挺好的一个设计思路。
计时逻辑如下
// about cnt
always @(*) begin
if(cnt > 0)
nextCnt = cnt - 1;
else
case(MDUOP)
MULT, MULTU : nextCnt = 5;
DIV, DIVU : nextCnt = 10;
default : nextCnt = 0;
endcase
end
always @(*) begin
if(nextCnt == 0)
nextBusy = 0;
else
nextBusy = 1;
end
always @(posedge clk) begin
if(reset) begin
cnt <= 0;
busy <= 0;
hi <= 0;
lo <= 0;
end
else begin
cnt <= nextCnt;
busy <= nextBusy;
hi <= nextHi;
lo <= nextLo;
end
end
而且我用微薄的知识觉得,用计时器(就是所有的方法)其实不太像客观世界,如果乘除算的慢,应该是用中断提高吞吐量,而不是用计时器模拟一个延迟,所以可能大家都摆烂,五十步笑百步而已。
三、内部异常
3.1 内部异常的检测
这个部分教程提供的很全面了,此外,关于“犯罪指令 != 受害指令”,其实可以忽略这部分讲解的,因为等弄懂这个概念,会发现只要按部就班的布置异常的检测,这个部分一点问题都没有。反而是想要一上来区分这个概念的,有很大几率犯错误。
需要处理的事件是来自CPU的内部的,在P7中我们需要实现的异常检测有这样几种(除此之外,比较常见的还有系统调用,断点调试):
ExcCode的编码必须遵守规范,不然在交互的时候会出现问题
异常 | 编码 | 指令类型 | 情况 |
---|---|---|---|
Int | 0 | - | 中断 |
AdEL(AddError_LoadInstr) | 4 | 所有 | PC地址未字对齐 |
所有 | PC地址超过 0x3000 ~ 0x6ffc | ||
AdEl(AddError_LoadData) | lw | 取数地址未与 4 字节对齐 | |
lh,lhu | 取数地址未与 2 字节对齐 | ||
lh,lhu,lb,lbu | 不能取Timer中的值(应该是因为Timer只支持整字存取) | ||
load | 计算地址时加法溢出 | ||
load | 地址不在0x0000 - 0x2fffc 或 0x7f00 - 0x7f08 或 0x7f10 - 0x7f18 | ||
AdES(AddError_StoreData) | 5 | sw | 存数地址未与 4 字节对齐 |
sh | 存数地址未与 2 字节对齐 | ||
sh,sb | 不能向Timer中存值(应该是因为Timer只支持整字存取) | ||
store | 不能向Timer中的Counter写值 | ||
store | 计算地址时加法溢出 | ||
store | 地址不在0x0000 - 0x2ffff 或 0x7f00 - 0x7f08 或 0x7f10 - 0x7f18 | ||
RI | 10 | 所有指令 | 出现未知指令(注意,已知指令中没有nop) |
Ov(Overflow) | 12 | add,addi,sub | 算数溢出 |
这个的实现就由相应的功能部件完成就好了,非常简单,所以建议先从这里实践。
关于异常的检测,有的时候只依靠功能部件是实现不了的,所以有的时候还需要CU的辅助。比如我的ALU默认就是在进行加法运算,如果不考虑指令有没有用到ALU,很可能在一个没有加法操作的指令处产生内部异常。在下面的那个表格里会有介绍。
3.2 内部异常的容忍
在陷入中断处理内部异常之前,我们需要保证发生内部异常的指令能流到处理内部异常的流水级,所以我们需要对内部异常指令有一定的容忍度。教程里介绍了RI和取指地址未对齐,都是视为nop,这里全面的介绍一下:
流水级 | 检测部件 | 异常类型 | 异常条件 | 处理方式 |
---|---|---|---|---|
F | IFU | AdEL | 取指越界,未对齐 | 视为nop,也就是修改F_instr为0 |
D | CU | RI | 指令未识别 | 视为nop,将默认识别从nop改为none,行为与nop相同 |
E | ALU | Ov | 算数溢出 | 按照无符号方法进行,因为反正也写不进寄存器(Req清零),得先修改CU,让ALU意识到这是在进行有符号运算 |
E | ALU | AdEL | 计算地址加法溢出 | 按照无符号方法进行,通过ExcCode流水到M级处理,得先修改CU,让ALU意识到这是在进行地址运算 |
E | ALU | AdES | 计算地址加法溢出 | 按照无符号方法进行,通过ExcCode流水到M级处理,得先修改CU,让ALU意识到这是在进行地址运算 |
M | DMI | AdEL | 地址未对齐、地址越界 | 通过CU使Byteen为0,这样就写不进去了,读的话不用处理,反正清零 |
M | DMI | AdES | 地址未对齐、地址越界 | 不处理,清零即可 |
其实还可以一当产生内部异常,那么就视这条指令为nop,也挺好的,就是我没有采用。
3.3 异常码的流水
首先,必须将异常信号ExcCode流水,而不能直接处理,这是因为可能后面的指令发生异常的时间比前面指令发生异常的时间要早,比如说 j(D)- sw(E),如果这两个都是异常指令,那么j在D级就会产生异常,sw在M级产生异常(假设这个异常是超范围了),那么如果不流水,那么就是先处理 j 异常,显然不符合我们的要求,因为sw异常被忽略了(sw继续往后流,前面的流水级开始流异常处理程序,等异常返回之后,就会直接到跳转目标指令了,sw的异常没有得到处理)。所以我们将异常信号流水以后,就可以先处理sw异常,然后运行到 j ,再处理 j 异常。
流水异常码的本质是将CPU封装成一个单周期CPU,那些内部异常的指令实际上是发生了,但是我们要将其视为没有发生,因为他们比我们的宏观PC要大,我们从外部处理内部异常,考虑的只有宏观PC对应的那条指令。
这里有教程写会有一个内部异常优先级的问题,但是我觉得一条指令最多有一个异常,所以用优先级阐述好像不太合适,不过在实现方法上,还是很像具有优先级的形式的,也无怪乎有人视其为优先级问题了:
assign E_ExcCode = (Raw_E_ExcCode)? Raw_E_ExcCode :
(E_Ov)? `EXC_Ov :
(E_AdEL)? `EXC_AdEL :
(E_AdES)? `EXC_AdES :
0;
这段代码的意思是,如果E级之前就有异常了,那么E级的异常码就是原来的异常码,如果没异常,那么如果E级发生了溢出,那么异常码就是Ov,如果发生了其他异常,那么就是对应的其他异常码。
四、外部中断
4.1 直观认识
中断与异常的区别是,中断不来自CPU的内部(或者说指令的执行过程),而是来自外部,比如说敲击键盘,鼠标,计时器)。我们有两种中断形式,一种是来自计时器的中断(因为有两个计时器,所以有两个中断信号:Timer0_IntReq,Timer1_IntReq),还要一个是tb提供的中断(interrupt),所以总共我们的CPU要可以接受三个中断,而在MIPS中CP0的设计,最多是六个,这也是IM和IP都是6位的原因。
按照教程要求,六位的IP输入内容,只有三位有效,我们这样接
assign HWInt = 3'd0, interrupt, TC1IRQ, TC0IRQ;
具体到我们的CPU,我们理一下外部中断的结构,官方提供的tb会在宏观PC等于某一固定值的时候,给出一个中断信号,也就是interruption置1,然后这个信号输入到我们mips模块中,在mips模块中,有两个计时器,计时器也可以产生中断,那么就是三个中断信号,我们把它们联合成一个叫做HWInt(Hardwire Interruption)的六位的信号(之所以是6位,是约定),然后把这个HWInt信号输入到CPU模块中,然后这个信号再输入到CP0模块中,然后CP0模块根据掩码和中断使能,来判断要不要产生外部中断信号
wire IntReq = (|(HWInt & `IM)) & (~`EXL) & `IE;
4.2 tb中断的响应
因为测试的约定,我们需要在响应了tb中断信号以后,给tb一个反馈信号,这样tb才能从1置0,具体的效果,可以看官方提供了在“tb_test”(这个名字有误导性)一个退化版的mips模块(好像只能响应中断信号)。interrupt维持了一个周期,从一个下降沿到另一个下降沿。
我们通过阅读教程提供的“tb_interrupt_demo.v”这个文件就可以发现,如果想把tb产生的interrupt置0,需要满足第2、3行的条件
always @(negedge clk) begin
if (~reset && interrupt && |m_data_byteen) begin
if (fixed_addr == 32'h7F20) begin
interrupt <= 0;
end
end
end
因此,在mips模块中需要注意,这里m_data_addr除了DMAdd0x0000 - 0x2ffff这个范围,还有一个值是0x7f20,这个值是为了方便更好的与评测系统沟通产生的(是一种测试约定),当外部中断发生以后,需要写这个地址,无论写什么东西都可以,所以为了达到这种效果,需要在CPU上加上一个输出端口,来说明已经产生了外部中断请求,然后m_data_addr连接一个复用器,来实现这个功能。同时m_data_byteen也有这个问题,必须置1才能满足第2行的条件判断,所以也需要连接复用器
但是官方的反馈的实现,是将interrupt作为判断条件,来往7f20中写值,像这样
assign m_data_addr = (interrupt)? 32'h7f20 : DMAdd;
assign m_data_byteen = (interrupt)? 4'b1111 : DMByteEn;
但是这样不太合理(据说不会在评测的时候出问题),因为有tb中断,不意味着一定会产生中断,因为还有其他SR的信号卡着呢,所以优雅的做法是在CP0里声明一个反馈信号,然后把这个反馈信号输出到mips这个层级
//CP0模块
assign tbReq = (HWInt[2] & SR[12] & (~`EXL) & `IE);
//mips模块
assign m_data_addr = (tbReq)? 32'h7f20 : DMAdd;
assign m_data_byteen = (tbReq)? 4'b1111 : DMByteEn;
然后你就会发现,这个方法行不通,这是因为tbReq在下降沿产生,但是到了下一个上升沿,因为进入中断,所以EXL置1,所以tbReq就为0了,但是tb中的interrupt需要在下降沿检测这个信号,所以下降沿就发现tbReq为0,所以就没办法复位为0了,具体波形图如下
可以看到tbReq只维持了半个周期,所以为了延长tbReq的输出,我们做一个寄存器来延缓一个周期,具体写法如下
//为了处理测试约定
initial begin
tmpTbReq <= 0;
end
always @(posedge clk) begin
tmpTbReq <= tbReq;
end
assign m_data_addr = (tmpTbReq)? 32'h7f20 : DMAdd;
assign m_data_byteen = (tmpTbReq)? 4'b1111 : DMByteEn;
然后就可以看到合乎情理的波形图了(因为用的是杰哥的图,所以它的寄存器的名字叫Req,等价于代码里的tmpTbReq):
4.3 外部中断的类型
外部中断的特殊之处就在于它可以发生在任何一条指令上,所以比内部异常限制了指令的类别,外部中断更加自由。其难度或者说要点主要体现在:
- 对于任何一条被中断的指令,他的功能,无论是写寄存器,还是写主存,还是跳转,都不能发挥作用
- 中断的优先级是要高于异常的,所以如果同时发生,那么应该选择中断
- 在阻塞状态下发生中断,中断的优先级是要高于阻塞的
- 延迟槽指令被中断,需要注意延迟槽指令是有一个特殊标记的
- 延迟槽指令被阻塞产生了nop,然后nop被中断,此时nop也应该具有延迟槽标记,总的来说,延迟槽标记是与PC匹配的,而不是与具体的指令匹配的
五、陷入中断
5.1 避免写入数据影响和CP0的流水级
其实是很朴素的认识,当一个指令中断以后,我们就不执行后面的指令直到hanlder结束,但是实际上对于一个流水线CPU,同时会执行多个指令,我们必须把他们的影响都消除掉。指令的功能一般就三个:读,写,跳转。其中读没啥关系,不会对任何事情造成影响,跳转我们下一节再分析。那么主要分析一下写数据的影响,写的目标有E级的MDU的hi和lo寄存器,M级的DM、Timer、CP0寄存器,W级的GRF。如果我们将宏观PC设为M级PC我们都需要将其写使能关掉(用Req信号),除了W级的GRFEn。如果是设在了E级,那么只需要关掉MDU的写使能。(其实流水线寄存器也可以用这种方法分析,这为5.2清空流水线提供了另一个理论角度支持)
这里可以思考一个问题,就是CP0要放在哪一个流水级,如果按照上面的分析,如果不考虑另外构造一些结构,那么最好放在E级,因为E级的时候没有一条指令执行了写操作,这样我们才可以清空流水级,避免写入数据影响。如果放到M级实现,那么mthi和mtlo如果在M级被外部中断,那么他们的影响(写了E级的MDU),是没有办法挽回的。
但是在E级实现CP0比较困难,是因为指令在M级有可能会产生异常,所以这个异常信号可能是需要通过向前转发才能解决,因为我是在M级实现的(当时把hi和lo忘了),所以具体的E级的实现细节不太清楚。
但是评测机制考虑到了这种情况,它是允许M级CP0的设计的,也就是说,如果中断发生在M级的mtlo和mthi,那么是可以不撤回其在E级写入的数据的。这是比较让人欣慰的地方。
此外,造成影响的指令还有乘除法指令mult这一类,这一类是无论将CP0设计在哪一个流水级,都没有办法很简易的解决的。这个问题在客观世界的MIPS上也有,那么他们是怎样解决的呢?
最初的体系结构允许乘除法运算不停止,就是就算是发生中断也不停止。但是这样会造成问题,比如这个
mflo $8
mult $9, $10
如果发生中断的是mflo指令,那么返回的时候还是重新执行mflo,但是此时mult已经将新值存入lo了,读出的值可能就是新值了,所以我们在软件编程的时候要避免这种情况(在这两个指令之间插最少2个非乘除指令)
我们好像实现的就是这种最初的体系结构,是依靠软件而非硬件避免这个漏洞的。
实际上,好像乘除是不需要处理的,因而一条或多条指令的执行效果可以被理解为对内存、各类寄存器等时序部件产生的影响。有些指令连续地重复执行多次后时序部件数据可能保持一致,如 mul、mthi 等;而有的则并不一定保持一致,如 add、sub 等。这就是了一部分理由,只要我们保证异常处理程序中没有mfhi和mflo,就不会产生任何问题(据说助教哥哥保证了)。
上面这种现象叫做等幂原理,这个原理在教程中被阐述成了执行效果,大概说的就是这个意思。
5.2 清空流水线
我们要实现支持异常与中断的CPU,有一个概念是绕不过的,就是宏观PC,其定义是这样的“该指令之前的所有指令序列对 CPU 的更新已完成,该指令及其之后的指令序列对 CPU 的更新未完成”。为了达到这种效果,就要求我们对流水线上的PC值有一个很好的控制,但是在实现CPU的过程中,需要很多插入nop操作(准确的说,是不让一些流水级的指令产生影响),比如说复位reset,阻塞时对E级的flush,进入handler时对D,E,W流水线的Req清空(如果实在M级进入handler),eret时对其后指令的清空。
在我原来的实现中,阻塞flush(当阻塞发生的时候,E级插入的nop)的实现其实就是把stall直接接入到了E级流水线寄存器的reset端口中,但是这种流水线寄存器是粗糙的,这是因为reset是把PC重新调整成0x3000(这是复位的要求),而这种清空显然是没法满足我们对于宏观PC的要求的,如果贸然的将flush理解为reset,那么宏观PC有一段时间就会变成0x3000,这显然是我们不希望看到的。不同的清空,对其中PC寄存器(不是F级那个)的要求也就不一样,大致有以下几种情况:
对于reset信号,其中的PC寄存器,当信号来临后,恢复到0x3000,不然刚开始一段时间,宏观PC(我的M_PC)会有一个不定值。
对于flush信号(其实就是stall信号),其中的PC寄存器,我们需要让其用下一条指令的值,也就是说,E级一旦flush,他的PC就会变成此时D级的PC。
对于Req信号(中断来了),同样需要清空D,E,M流水线,那么可不可以用我们刚刚实现的flush功能呢,不可以,这是因为宏观PC的定义是“该指令之前的所有指令序列对 CPU 的更新已完成,该指令及其之后的指令序列对 CPU 的更新未完成”,所以如果让中断后的D,E,M的PC保持,其他值清空,那么就像已经执行过这些指令一样,显然是不合理的,所以对于Req信号,我们对PC的要求是让其清零的时候变成0x4180,这样才合理。
此外,IFU也需要类似的修正(因为里面有PC寄存器)。
信号 | 流水寄存器中PC行为 |
---|---|
reset | 复位到0x3000 |
flush | 用下一个值 |
Req | 复位到0x4180 |
eret | 不在这里实现,用去掉延迟槽的方法实现 |
5.3 EPC的写入
发生中断的一个重要行为就是将中断指令的PC写入EPC,有点像函数跳转之前,要将返回地址写入$ra。但是其实这么说不严谨,应当这样表述,对于异常情况,其实只用考虑是不是延迟槽指令,如果是延迟槽指令,那么存的是异常指令PC - 4,如果不是,那么就存PC。这样造成的结果就是,返回的时候会重新执行异常指令(如果异常处理程序不对EPC进行修改的话)对于中断情况,EPC存入的是中断来临的那个周期的M级的PC。具体的原因和用意教程里介绍了。
所以我们发现,判断一个指令是不是延迟槽指令,决定了我写入EPC的行为。那么应该怎样判断一条指令是不是延迟槽指令呢?我们说,必须在F级判断,然后流水,这是因为等到了M级在判断存在如下问题:当异常指令进入M级(准确的说,是CP0所在的流水级)的时候,直接看前一条指令是不是跳转指令,这个方法不行,因为跳转指令和延迟槽之间可能会插入nop(因为阻塞),比如
lw $t0, 0($0)
j A
add $t0, $t0, $t0
A
add(E)- nop(M)- j(W),这时如果add溢出了,那么等add到M级的时候,会发现他前面是nop(W),此时就会被认为add不是延迟槽,就会错误。
所以我们需要在F级就进行判读,看D级的指令是不是跳转指令。另外,需要注意的是,按照教程要求,无论跳转指令跳不跳转,都需要将其后的那条指令视为延迟槽指令,所以判断标准不是NPCOP,而是是不是跳转指令。因此,BD是一个需要流水的信号。
至于为什么当一个异常指令是延迟槽指令的时候,就需要存入EPC - 4,也就是上一条的跳转指令,这是因为如果返回的是延迟槽指令,那么就顺序执行了,跳转指令就作废了。所以要回到跳转指令,那么为什么可以这样做呢?这是因为跳转指令具有等幂性。
5.4 清空优先级
正如5.2介绍的,有多个信号都会控制流水线寄存器,所有有可能同时会有多个信号会控制同一个寄存器,那么寄存器该展现怎样的行为呢?这是一个需要考虑的事情,比如说D级处于被阻塞状态,此时Req来了,那么他就应该立刻被清空,而不是保持原值,正在Req的时候,reset来了,那么CPU应该立刻复位,而不是进行异常处理,据说这个处理不当就会造成评测机中的缺少中断情况。故下列表展示优先级:
信号 | 优先级 |
---|---|
reset | 最高,因为复位大于一切 |
Req | 次高,因为中断请求比内部阻塞重要 |
flush/stall | 最低,因为是流水线信号,外部人员看不到 |
然后我们考虑,哪些寄存器中的项需要优先级,只有两个,一个是PC,原因之前论述过了,一个是BD,他在flush的时候需要保持原来的信息,因为在外部去看的话,会发现宏观PC是相同的,但是延迟槽标记是不同的,这显然是不正确的,如果针对被延迟槽的延迟槽指令下中断,那么下到了nop上,EPC就会被置位错误,造成评测的错误。
5.5 流水线寄存器支持
流水线寄存器主要需要改三个方面,一个是对不同清零信号的处理,一个是不同清零信号的优先级问题,一个是增加相应的数据项。
对于第1,2个问题,其实可以放在一起实现,这里提供两种方法:
always @(*) begin
if(reset) nextPC = 32'以上是关于北航2021届计组 - 支持中断的CPU的主要内容,如果未能解决你的问题,请参考以下文章