计算机组成 - 支持中断的 CPU

It  is  all  about  control, cheat  and  tradeoff.It\space\space is\space\space all\space\space about\space\space control,\space cheat\space\space and\space\space 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 一样)。然后经过这个函数,我们就可以重新执行原来的程序了。

image-20211214101033952

可以看到和函数调用具有很多的相似性。但是同时也应该意识到,正是因为发生中断的位置是无法确定的,所以要想将中断封装成单周期 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 寄存器,会异常,我也不知道为啥没有注意到。

状态图如下

image-20211214112709072

在模式 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 了,具体波形图如下

image-20211214162515250

可以看到 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):

image-20211214162829846

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'h3000;
else if(Req) nextPC = 32'h4180;
else if(flush) nextPC = PCIn;
else if(en) nextPC = PCIn;
else nextPC = PCOut;
end

if (reset | Req | flush) begin
instrOut <= 0;
EXTOutOut <= 0;
rsOutOut <= 0;
rtOutOut <= 0;
flag1Out <= 0;
BDOut <= (Req)? 0 : ((flush)? BDIn : 0);
ExcCodeOut <= 0;

end

对于第三条需求,我们这样考虑:肯定要加入 BD 和 ExcCode,因为 CP0 是支持读功能的,所以从 CP0Out 也应该进入流水线。

此外,为了应对条件指令,对于分布式译码,我在流水线寄存器中加入了 flag 来表示条件判断(其实叫 condtion 更好),其实不属于 P7 内容,但是一并记录在此(原来是 CMPOut 来实现部分这个功能,但是不太严谨)。

数据项 DREG EREG MREG WREG
instr 1 1 1 1
flag1 - 1 1 1
flag2 - - 1 1
flag3 - - - 1
PC 1 1 1 1
EXTOut - 1 1 1
rsOut - 1 1 1
rtOut - 1 1 1
ALUOut - - 1 1
MDUOUt - - 1 1
DMOut - - - 1
CP0Out - - - 1
BD 1 1 1 -
ExcCode 1 1 1 -
OvDM - - 1 -

这里插句闲话,我觉得命题趋势是把 risc 往 cisc 那个方向去命题,在 x86 里有条件码(Comand Code),看上去就跟条件指令很类似。条件指令本质上都可以拆成简单的指令执行,但是非要在一个指令中实现,就很 cisc。


六、指令功能

6.1 转发阻塞

关于转发,其实在写 P6 的时候,赵子齐哥哥就跟我探讨过关于 hi 和 lo 转发的问题,但是我当时太笨了,虽然他兴致盎然的说了一大堆,但是我一句都没有听懂(面带微笑,轻轻点头)。现在想想挺有道理的,mt 类指令都是写指令,肯定是可以作为转发源的,而且因为 Tnew 是 0,所以是很好的转发源,唯一需要考虑的是,AT 法就需要扩容了,地址判断需要更加精密的操作,才能判断是否可以转发,所以无论是 P6 还是 P7,我都没有实现转发。

其实还有一个折中的办法,是进行内部转发(小哥哥 yyds),这个实现起来比较容易,效果比较好,但是我依然没有实现。

所以我们面对这三条指令,只能用阻塞避免冒险,然后我们考虑阻塞,mfc0,eret 是读指令,所以不会阻塞别人,只有 mtc0 是写指令,会阻塞其他指令,那么对于 mfc0,不需要阻塞,也不会有冒险,所以只有 mtc0 后接 eret 这种情况会阻塞,那么单独写一个阻塞就好了,还挺容易的,写法如下:

wire eretStall = (D_order == `eret) && 
((E_order == `mtc0 && E_rd == 5'd14) || (M_order == `mtc0 && M_rd == 5'd14));

需要强调的是,这里的转发阻塞仅限于这三条指令(是新加的内容),比如说 mfc0 是可以给其他指令转发的,用原有的阻塞转发系统。

mtc0 是将 rtOut 中的内容写到以 rd 为编号的 CP0 寄存器中,所以这是一个又读又写的指令,但是需要注意的是,它的读,是可以利用转发和阻塞的,但是他的写目标,不在是通用寄存器,而是 CP0,所以与其产生阻塞转发的,会是其后的 mfc0(读 CP0)和 eret(读 CP0 中的 EPC),所以其实是可以考虑搭建一个新的转发阻塞体系(这个问题在乘除的时候也有),但是在这里我们只实现阻塞功能,而不实现转发功能。

6.2 消除延迟槽

对于 eret 指令,我们有两种实现方法,一种是 eret 到了 M 级在进行跳转,那么其后就会跟着 eret 后面的指令(在 D,E 级),那么就需要把他们都清空了,此时的 PC 应该是 EPC(后者 eret 的 PC,也就是 M_PC),但是无论是哪一个,都不是一个定值,都是需要加在流水线寄存器的 PC 输入端加 MUX 的,所以我没有采用。还有一种办法就是在 D 级就实现跳转功能,而且去掉延迟槽(注意,不是清空延迟槽),因为延迟槽之所以产生,是因为当跳转指令在 F 级的时候,NPC 根据这条指令计算出了下一条指令,所以当跳转指令进入 D 级的时候,F 级的 PC 就更新成了跳转指令的下一条指令,所以这个指令就会执行,哪怕跳转指令更新了 PC,那也是在一个周期以后了(如果是在 M 级跳转,那么就是三条延迟槽指令,其实描述的就是上面 eret 的行为),所以要想去掉延迟槽,必须在 i_inst_addr 进行选择,当 D 级为 eret 时,就选择输出 EPC 的值,而不是 PC 寄存器中的值,就可以达到清空延迟槽的目的。其实还有一种思路,就是跳转是在 F 级实现的,但是如果在 F 级实现跳转,因为 eret 是一个读指令,所以他也是有阻塞需求的,但是我们的阻塞功能不支持 F 级的读指令,所以看上去不太好实现。


七、测试对拍

7.1 测试内部异常

这个部分是可以和 MARS 对拍的,也可以用自动测评机(虽然我什么也没有)。只是需要加一些处理。

我们知道 MARS 窗口界面是没有办法导出 4180 以后的内容的,所以只能用命令行把我们写的 handler 导出,命令如下

java -jar E:\Mars4_5.jar a db mc CompactDataAtZero dump 0x00004180-0x00004ffc HexText E:\code_handler.txt E:\source.asm

但是一次一次弄太麻烦了,而且我个人觉得 handler 里内容都差不多(可能吧),里面也没有啥测试的要点,所以写一个通用的 handler 导出以后吃一辈子是一个可行的思路,这是我写的 handler:

.ktext 0x4180
mfc0 $k0, $12 #取出SR的值到k0,看看对不对
mfc0 $k0, $13 #取出Cause的值到k0,看看对不对
mfc0 $k0, $14 #取出EPC的值到k0,看看对不对

mfc0 $k0, $13
ori $k1, $0, 0x007c
and $k0, $k1, $k0 #这里取出了ExcCode
beq $0, $k0, ERET #如果是中断,那么就退出handler了,处理中断的方式就是接着执行这条指令
addi $k0, $k0, 0xffff #稍带手测一下beq的延迟槽
mfc0 $k0, $14 #取出EPC的值到k0
lui $1, 0xffff
ori $1, $1, 0xfffc
and $k1, $k1, $1 #给EPC对齐了
addu $k0, $k0, 4 #给EPC+4
mtc0 $k0, $14 #重新赋给EPC
j ERET #异常处理完成,处理异常的方式就是跳过这条指令
nop

ERET:
eret

导出以后是这个

401a6000
401a6800
401a7000
401a6800
341b007c
037ad024
101a000d
3c010000
3421ffff
0341d020
401a7000
3c01ffff
3421fffc
0361d824
3c010000
34210004
0341d021
409a7000
08001074
00000000
42000018

用的时候就直接把 16 进制码存进 handler.txt 文件,然后放在工程目录下,就好了。

但是因为我们 CPU 的功能的强大,所以我们对于编写主程序也有了一定的要求,这是我写的一个模板

.text
addi $t0, $t0, 0xfc01
mtc0 $t0, $12 #给SR赋初值,IM全置1允许中断,属于初始化部分

#异常的产生构造主体begin
lui $t0, 0x8000
lui $t1, 0x8000
add $t2, $t0, $t1

ori $t3, $t3, 1 #一个检测位,说明回到了异常下一条指令,也可以不要,那么就是异常一条接着一条
#异常的产生构造主体end
end:
beq $0, $0, end #这个必须要有,因为CPU会把xxxxxx识别成RI,然后就在异常里出不去了
nop

.ktext 0x4180
mfc0 $k0, $12 #取出SR的值到k0,看看对不对
mfc0 $k0, $13 #取出Cause的值到k0,看看对不对
mfc0 $k0, $14 #取出EPC的值到k0,看看对不对

mfc0 $k0, $13
ori $k1, $0, 0x007c
and $k0, $k1, $k0 #这里取出了ExcCode
beq $0, $k0, ERET #如果是中断,那么就退出handler了,处理中断的方式就是接着执行这条指令
addi $k0, $k0, 0xffff #稍带手测一下beq的延迟槽
mfc0 $k0, $14 #取出EPC的值到k0
lui $1, 0xffff
ori $1, $1, 0xfffc
and $k1, $k1, $1 #给EPC对齐了
addu $k0, $k0, 4 #给EPC+4
mtc0 $k0, $14 #重新赋给EPC
j ERET #异常处理完成,处理异常的方式就是跳过这条指令
nop

ERET:
eret

每次使用的时候,只需要替换中间的部分即可。相应的,因为需要往 IM 中读入两个部分的值,所以需要在 tb 中加一句话读取 handler.txt,就在读 code 那句话下面就可以了

$readmemh("handler.txt", inst, 32'h460); //460 = (4180 - 3000) >> 2;

7.2 测试外部中断

这个就没法白嫖往届学长的样例了,但是其实还是可以的,学长的样例只是缺少外置存储这一个设置,所以只要读懂 tb,就可以把学长的 tb 修改一下,就可以跑了。(只能说小哥哥 yyds)

但是无论怎样,测试外部中断的思路应该都是在 tb 上做文章,每一个 code 对应一个特殊的 tb,自动化生成强的测试数据,应该不太可能了(但是感觉 ch 哥哥还是实现了,就是不知道是不是强的)。

7.3 对拍

不知道多久才能有大佬搞出自动化来,反正我是 P7 第一次跟人对拍,太折磨了,P7 的测试数据又是人写的,所以有的时候不是 CPU 的问题,有的时候是 tb 的问题,有的时候又是汇编的问题,所以真的挺折磨人的,建议抱大腿。

不过话又说回来,P7 的评测机行为是真的没办法提供有效信息,所以除了肉眼 debug 之外,就只有对拍一条 debug 的途径了,所以只能说都是生死有命,富贵在天的事情。

7.4 课上测试

这一届是三个强测,第一个是测试无中断无异常,第二是测试异常,第三个是测试中断,然后还有一个加指令。通过测试的条件是必须通过第一个测试,然后后三个完成两个即可通过。

P7 的加指令是 tltiu,这是一条在 MIPS 指令集中出现的指令,所以具体的描述可以看 MIPS 指令集,然后实现这个挺简单的,如果在 CPU 模块内直接写行为语句的话,大概十分钟就能写完(大佬应该会更快)。


八、软件约束

8.1 异常处理程序的行为

这个部分是一个科普,我们看异常处理程序一般都包括这么几个部分

.ktext 0x4180
_entry:
mfc0 $k0, $14 #取出 EPC 的值到 k0,没用
mfc0 $k1, $13 #取出 Cause 值到k1
ori $k0, $0, 0x1000 #给 k0 的第 12 位赋 1,其实就是在栈上找个地方存放寄存器上下文
sw $sp, -4($k0)

addiu $k0, $k0, -256
move $sp, $k0

j _save_context
nop

_main_handler:
mfc0 $k0, $13
ori $k1, $0, 0x007c
and $k0, $k1, $k0 #这里取出了 ExcCode
beq $0, $k0, _restore_context #如果是中断,那么就restore,目的是把寄存器上下文恢复了
nop
mfc0 $k0, $14 #取出 EPC 的值到k0
addu $k0, $k0, 4 #给 EPC + 4
mtc0 $k0, $14 #重新赋给 EPC
j _restore_context #恢复上下文,总的来说,处理异常的方法就是跳过这条指令
nop

_restore:
eret

_save_context:
sw $1, 4($sp)
sw $2, 8($sp)
sw $3, 12($sp)
sw $4, 16($sp)
sw $5, 20($sp)
sw $6, 24($sp)
sw $7, 28($sp)
sw $8, 32($sp)
sw $9, 36($sp)
sw $10, 40($sp)
sw $11, 44($sp)
sw $12, 48($sp)
sw $13, 52($sp)
sw $14, 56($sp)
sw $15, 60($sp)
sw $16, 64($sp)
sw $17, 68($sp)
sw $18, 72($sp)
sw $19, 76($sp)
sw $20, 80($sp)
sw $21, 84($sp)
sw $22, 88($sp)
sw $23, 92($sp)
sw $24, 96($sp)
sw $25, 100($sp)
sw $26, 104($sp)
sw $27, 108($sp)
sw $28, 112($sp)
sw $29, 116($sp)
sw $30, 120($sp)
sw $31, 124($sp)
mfhi $k0 #取出 hi 的值
sw $k0, 128($sp) #把 hi 的值也保留了
mflo $k0 #取出 lo 的值
sw $k0, 132($sp) #把 lo 的值也保留了
j _main_handler
nop



_restore_context:
lw $1, 4($sp)
lw $2, 8($sp)
lw $3, 12($sp)
lw $4, 16($sp)
lw $5, 20($sp)
lw $6, 24($sp)
lw $7, 28($sp)
lw $8, 32($sp)
lw $9, 36($sp)
lw $10, 40($sp)
lw $11, 44($sp)
lw $12, 48($sp)
lw $13, 52($sp)
lw $14, 56($sp)
lw $15, 60($sp)
lw $16, 64($sp)
lw $17, 68($sp)
lw $18, 72($sp)
lw $19, 76($sp)
lw $20, 80($sp)
lw $21, 84($sp)
lw $22, 88($sp)
lw $23, 92($sp)
lw $24, 96($sp)
lw $25, 100($sp)
lw $26, 104($sp)
lw $27, 108($sp)
lw $28, 112($sp)
lw $29, 116($sp)
lw $30, 120($sp)
lw $31, 124($sp)
lw $k0, 128($sp)
mthi $k0 #复原hi的值
lw $k0, 132($sp)
mtlo $k0 #复原lo的值
j _restore
nop

#程序首先从这里运行
.text
ori $2, $0, 0x1001
mtc0 $2, $12 #让CP0全局中断置1,而且允许外部中断(不包括TC0,TC1)
ori $28, $0, 0x0000
ori $29, $0, 0x0000
lui $8, 0x7fff
lui $9, 0x7fff
add $10, $8, $9 #造成溢出
or $10, $8, $9

end:
beq $0, $0, end
nop

还挺容易读懂的,但是需要强调的是,这不是测试样例的规范,也就是说,测试样例中的 handler 不长这个样子。

8.2 其他约束

首先声明,整个 8.2 节的全部内容,我一点都不负责,因为 8.2 节的内容造成的后果,请自行承担!!!

  • 内部异常不需要考虑 IE
  • 乘除指令可以在中断发生时不被撤回
  • 若中断发生在 mthi 或者 mtlo,可以不撤回其操作
  • 若中断发生在 mthi 或者 mtlo 的上一条指令,那么必须撤回 mthi 或者 mtlo
  • EPC 在任何地方,任何情景都不需要考虑对齐

九、代码风格

9.1 指令分类

这是 P6 的内容,在这里介绍:

  • load : lw, lh, lhu, lb, lbu
  • store : sw, sh, sb
  • cal_r : add, addu, sub, subu, sllv, srlv, srav, slt, sltu, sll, srl, sra, and, or, xor, nor
  • cal_ia : addi, addiu, slti, sltiu
  • cal_il : andi, ori, xori
  • lui : lui
  • branch2 : beq, bne
  • branch1 : blez, bgtz, bltz, bgez
  • jump_i : j, jal
  • jump_r : jr, jalr
  • link : jal, jalr(这里需要注意的是,这个分类并不是一个很好的分类,jalr 的目的寄存器不是 31,所以两者的共性不是很多)
  • md : mult, multu, div, divu
  • mf : mfhi, mflo
  • mt : mthi, mtlo

指令与 CU 信号的关系

信号 load store cal_r cal_ia cal_il lui branch1/branch2 jump_i jump_r link md mf mt
CMPOP - - - - - - option - - - - - -
EXTOP SE16 SE16 - SE16 ZE16 LUI SE16 ZE26 - - - - -
NPCOP NORMAL NORMAL NORMAL NORMAL NORMAL NORMAL NORMAL/BRANCH J JR - NORMAL NORMAL NORMAL
PCSel F F F F F F F/D D - - F F F
JumpSel - - - - - - EXTOut EXTOut rsOut - - - -
ALUOP ADD ADD option option option - - - - - - - -
SrcBSel EXTOut EXTOut rtOut EXTOut EXTOut - - - - - - - -
MDUOP - - - - - - - - - - option option option
STOREOP - option - - - - - - - - - - -
ByteEn - option - - - - - - - - - - -
LOADOP option - - - - - - - - - - - -
GFREn 1 0 1 1 1 1 0 - - 1 0 1 0
GRFInSel DMOut - ALUOut ALUOut ALUOut EXTOut - - - PC8 - MDUOut -
GRFAdd3Sel rt - rd rt rt rt - - - 31/rd - rd -

指令与 T 值

分类 rsTuse rtTuse E_Tnew M_Tnew FWSrc
load 1 - 2 1 DMOut
cal_r 1 1 1 0 ALUOut
cal_ia 1 - 1 0 ALUOut
cal_il 1 - 1 0 ALUOut
lui - - 0 0 EXTOut
link - - 0 0 PC
mf - - 1 0 DMUOut
store 1 2 - - -
branch2 0 0 - - -
branch1 0 - - - -
jump_i - - - - -
jump_r 0 - - - -
md 1 1 - - -
mt 1 - - - -

指令与 A 值

分类 A1 A2 A3
load rs - rt
cal_r rs rt rd
cal_ia rs - rt
cal_il rs - rt
lui - - rt
link - - 31
store rs rt -
branch2 rs rt -
branch1 rs - -
jump_i - - -
jump_r rs - -
md rs rt -
mf - - rd
mt rs - -

因为是采用的是指令驱动型,所以我采用了宏定义的方式结合 case 语句来对指令进行分类,具体写法如下:

`define load	`lw, `lh, `lhu, `lb, `lbu
`define store `sw, `sh, `sb
`define cal_r `add, `addu, `sub, `subu, `sllv, `srlv, `srav, `slt, `sltu, `sll, `srl, `sra, `and, `or, `xor, `nor
`define cal_ia `addi, `addiu, `slti, `sltiu
`define cal_il `andi, `ori, `xori
`define branch2 `beq, `bne
`define branch1 `blez, `bgtz, `bltz, `bgez
`define jump_i `j, `jal
`define jump_r `jr, `jalr
`define link `jal, `jalr
`define md `mult, `multu, `div, `divu
`define mf `mfhi, `mflo
`define mt `mthi, `mtlo

//about SrcBSel
case(E_order)
`cal_r : SrcBSel = SrcBSel_rtOut;
`cal_ia, `cal_il, `load, `store : SrcBSel = SrcBSel_EXTOut;
default : SrcBSel = SrcBSel_EXTOut;
endcase

9.2 应试风格

事先声明,9.2 节内容本人不负责任,因为阅读 9.2 节造成的一切后果,请自行承担!!!

谨慎阅读!!!

算了,奇技淫巧,登不了大雅之堂,不提也罢。


十、思考题

10.1 我们计组课程一本参考书目标题中有 “硬件/软件接口” 接口字样,那么到底什么是 “硬件/软件接口”?(Tips:什么是接口?和我们到现在为止所学的有什么联系?)

我认为接口是解偶联和封装的关键,具体表现是一组需求,在大型项目中,不可能有人了解所有的细节,接口为分工合作提供了必要的支持,管理者通过规定接口,规定了需求和组织方式,这样才是高效的管理学。

对于软硬件接口,我觉得具体是一组约束,比如指令集,在软件方面就是汇编,在硬件方面就是译码,但是他们分别是建立在同一组规范中,再比如说中断,信号是硬件发出的,但是软件有负责了中断的处理。

10.2 BE 部件对所有的外设都是必要的吗?

我不太清楚系统桥的明确定义是什么,但是我觉得一个系统桥上既连接了 DM 这种需要快速交换的部分(甚至现在 DM 的大小和工作风湿,给人感觉想 cache)这是北桥的工作,还连了定时器和外部中断信号(有些缓慢),这是南桥的工作。有点让人摸不着头脑。如果说系统桥的目的是片上通信的话,那么不是必要的。

10.3 请阅读官方提供的定时器源代码,阐述两种中断模式的异同,并分别针对每一种模式绘制状态转移图。

中断模式 0:将计数器写使能变为 0,并且保持中断信号持续有效,直到计数器写使能为 1。该模式通常用于定时中断。

中断模式 1:只保持一周期的中断信号,并重新载入计数器初值,再次倒计时。该模式通常用于产生周期性脉冲。

模式 0:

image-20211214201334968

模式 1:

image-20211214201353733

10.4 请开发一个主程序以及定时器的 exception handler。整个系统完成如下功能:

(1)定时器在主程序中被初始化为模式 0;

(2)定时器倒计数至 0 产生中断;

(3)handler 设置使能 Enable 为 1 从而再次启动定时器的计数器。(2) 及 (3) 被无限重复

(4)主程序在初始化时将定时器初始化为模式 0,设定初值寄存器的初值为某个值,如 100 或 1000。(注意,主程序可能需要涉及对 CP0.SR 的编程,推荐阅读过后文后再进行。)

.text
addi $t0, $t0, 0xfc01
mtc0 $t1, $12 #给SR赋初值,IM全置1允许中断,属于初始化部分

li $s0, 0x7f04
li $s1, 100
sw $s1, 0($s0) #将preset赋成100

li $s0, 0x7f00
li $s1, 9 #即1001
sw $s1, 0($s0) #将Ctrl的IM位置1,说明允许中断,Mode置0,说明是模式0,Enable置1,可以计数

end:
beq $0, $0, end #这个必须要有,因为CPU会把xxxxxx识别成RI,然后就在异常里出不去了
nop

.ktext 0x4180
li $s0, 0x7f00
li $s1, 9 #即1001
sw $s1, 0($s0) #将Ctrl的IM位置1,说明允许中断,Mode置0,说明是模式0,Enable置1,可以计数

ERET:
eret

10.5 请查阅相关资料,说明鼠标和键盘的输入信号是如何被 CPU 知晓的?

键盘和鼠标本质上都是输入设备,其自身的内部有一些微处理器来控制自己与主机之间的信息交互。而在 CPU 看来,外部信号会经过很多层级传入到内部,而内部的信息也会经过很多层级传出至外部。这些层级由外至内大致可以分为主存 ->cache (->bridge)->CPU。

每个设备都需要驱动程序的,只不过驱动程序有的在电脑系统内,有的却需另外按装,比如:无线健鼠套装,驱动程序已经在 USB 头上,我们叫它免驱设备。如果没有这些指定驱动程序的话,电脑是无法识别内容的。


写在后面的话

P7 真的是团队协作啊,所以郑重鸣谢:

  • 代码次次无 bug,环境次次无冲突:救命恩人,硬是把我的 bug 肝出来了
  • ysy 的迷弟:构造测试
  • ysy 的迷妹:构造测试
  • ysy 的迷:肉眼 debug
  • 我要早睡:督促我探究了 wl 的 D 级 eret 问题
  • 小哥哥:肉眼 debug

哄哄闹闹的一个学期都快过完了,之前数据结构助教问我,想去 6 系还是去 21 系,我说想去 21 系,他说是不是害怕 6 系太苦太累太被碾压,我说不是,我贱命一条,不值一提。关键是在 6 系,我没有把握护人周全,后来来了 6 系,确实,当时我第一次听冰哥给我介绍 chgg 的自动测试机的时候,我当时就傻了,这也太厉害了,P6 之前,我每次上机之前都害怕,害怕自己的课下是有 bug 的,但是 P6 真的超级有安全感,因为我都过了 P6 的 290 条强测了。一想到上一届因为有 chgg,那么这一级的同学多有安全感啊,这才是英雄!