目录

第四章:单周期 CPU
4.1 现在可以公开的情报:一些前置知识
4.2 使用 Logisim 模拟单周期 CPU
4.3 使用 Verilog 模拟单周期 CPU
4.4 本章结语:再见,单周期 CPU !

第五章:五级流水线 CPU
5.1 现在可以公开的情报:一些新的前置知识
5.2 五级流水线 CPU 的基本构造
5.3 阻塞与转发
5.4 使用 Verilog 模拟五级流水线 CPU
5.5 五级流水线 CPU 实战:加指令练习
5.6 五级流水线 CPU 大升级:新指令与新模块
5.7 本章结语:再见,五级流水线 CPU ?

终章:带中断异常的五级流水线 CPU
Final.1 现在可以公开的情报:最后的前置知识
Final.2 CP0 模块的结构与功能
Final.3 中断异常全流程
Final.4 外部设备模拟
Final.5 本章结语:再见,五级流水线 CPU !

第四章:单周期 CPU

4.1 现在可以公开的情报:一些前置知识

终于!我们已经集齐了 Logisim 、Verilog 、MIPS 三大神器,准备开始我们漫长的 CPU 征途了!在我们正式开始之前,我们需要了解一些在上本书中没有学到的前置知识,也算是我们踏上征途前的最后准备吧!

【CPU】

不会有人已经快要动手搭 CPU 了,还不知道什么是 CPU 吧?(笑)没关系,我们赶紧了解一下,现在还来得及!

我们知道,我们编写的 C 语言程序要想能够运行,需要通过编译转换为汇编语言,再将汇编语言转换为机器码,这些流程 —— 都不是 CPU 的工作…… CPU 真正的工作是,将已经全部处理好的机器码作为输入,执行其中的汇编程序,达到修改寄存器和内存中的值的效果。这也正是我们要实现的内容。

事实上,我们一直在使用的 MARS 就是在模拟这个过程。我们在其中编写汇编程序,交给 MARS 来运行,就能够观察到寄存器和内存中发生的变化。在接下来的学习中,我们完全可以参照 MARS 执行指令的流程,从而能够更好地理解 CPU 的运行机制。当你在以后对 CPU 的运行逻辑产生困惑时,不妨回头看一下 MARS 是如何实现这个过程的,相信会对你有所帮助!

【空指令nop】

接下来,我们来认识一条特殊的指令 —— nopnop 指令的机器码是 0x00000000 ,一看就非常不简单。它的作用就是没有任何作用,如果我们查询每条指令的机器码,就会发现 nop 指令实际上等价于 sll $0, $0, 0 指令,确实没有任何作用。

那么你肯定会问,我们为什么需要一条没有任何作用的指令呢?我们先按下不表,到五级流水线 CPU 一章,你或许会再感受到它的存在的。

4.2 使用 Logisim 模拟单周期 CPU

接下来,我们就要正式开始搭建 CPU 了!我们本章的旅程主要分为两个部分:在本节,我们将要认识单周期 CPU 的全部架构,并使用 Logisim 完成一个属于自己的单周期 CPU ,实现 14 条 MIPS 指令:nop addu subu addiu xori lui lw sw beq bne j jal jr jalr ;在下节,我们将要将我们的单周期 CPU 移植到 Verilog 语言,实现两个版本的单周期 CPU。

在正式开始之前,还是要温馨提示一下,虽然本书已经给出了全部的实现细节,但还是希望你能够在看完每节,彻底了解了实现思路之后,挑战在不看本书的情况下自行完成 CPU ,毕竟你也不想查重被抓到小黑屋里接受审判吧只有这样才有自己独立完成一个大工程的成就感嘛!(笑)

【CPU中的存储模块:PC、IM、GRF、DM】

在上一节我们已经知道,CPU 要实现的目标就是正确修改寄存器和内存这些存储模块中的值,所以搞清楚 CPU 中有哪些存储模块,以及如何确保它们的值时刻正确,就是我们搭建 CPU 的第一要务!

于是,我们就要思考一下,CPU 在执行指令的过程中,都会用到哪些存储模块呢?当然了,从大范围上说,其实就是我们刚才提到的寄存器和内存两种。不过我们也可以将它们两个再细分一下,或许对我们接下来的搭建工作更有帮助。

提到寄存器,我们首先能够想到的肯定就是 $0$31 这 32 个寄存器了,我们将它们称为“寄存器堆”(GRF)。在执行绝大多数指令时,我们都需要从这些寄存器中取出值;同样,在很多情况下,我们也需要将计算的结果写入寄存器中。在后面的部分中,我们也将学习如何搭建这个庞大的 GRF 模块。

不过,在我们的 CPU 中,就只有这 32 个寄存器吗?让我们来仔细回忆一下,还记得有一个叫做 PC 的寄存器吗?它代表着当前正在执行指令的地址。在跳转指令的符号语言描述中,我们曾经用到过这个寄存器,在我们的 CPU 中,我们也会将其独立成一个 PC 模块,用于取指令的寻址。

当然,除了 GRF 和 PC 寄存器之外,我们还有一些独立的寄存器,例如 HI LO 寄存器和我们素未谋面的 CP0 寄存器,这些寄存器在我们后续添加相关模块的时候会讲到,在这里就暂且略过了~

接下来,我们再来研究一下内存这个大类。在 CPU 中,我们同样需要开辟一块存储空间来作为内存,这就是 DM 模块。

不过我们不要忘记了,在 MARS 中,内存实际上是被分为两块区域的:地址为 0x000000000x00003000.data 段作为自由存取的空间,而地址高于 0x00003000.text 段作为存储指令机器码的空间。

在我们的 CPU 中,为了便于搭建,我们将这两个部分拆成两个模块,我们将存储指令机器码的 .text 段从 DM 模块中分离出来,单独形成一个 IM 模块。这样两个部分的读写互不干扰,解决了很大一部分的冲突问题。

(顺带一提,我们的 CPU 中这种 IM 和 DM 分离的结构被称之为哈佛结构;相反,类似于 MARS 内存中不分离的结构被称之为冯·诺依曼结构或者普林斯顿结构

到目前为止,我们已经了解了我们这一阶段会用到的所有存储模块。在接下来的工作中,我们要做的就是通过一系列操作实现每一条指令,保证这些存储模块中的值正确无误即可!

【取指令:PC、NPC、IM模块】

如果我们想要让我们的 CPU 要执行一条汇编指令,第一步应该是是什么呢?当然是要正确取出这条指令了!于是,我们就需要搭建一个能够稳定输出当前执行指令的地址的装置 ——

首先,我们使用一个 32 位寄存器作为 PC 寄存器,表示当前指令的地址:

Picture Not Found!

我们知道,每当我们的 CPU 执行完一条指令,切换到下一条指令的时候,PC 寄存器的值都会随之更新到下一条指令的地址。也就是说,每当时钟到达上升沿的时候,我们就需要更新一次 PC 寄存器的值。

以最常见的非跳转指令为例,每当执行到下一条指令时,PC 寄存器的值都会 +4 ,还记得这个知识点吗?这是因为我们的 MIPS 指令都是 32 位,也就是 4 字节的,所以每两条指令之间的地址自然相差 4 。实际上,如果我们把 PC 寄存器的值看作状态机的状态,这就形成了一个非常经典的状态机:

Picture Not Found!

当然了,这个状态机肯定不止现在看上去这么简单。一方面,为了模仿 MARS 中指令在内存中的地址,我们的 CPU 中 PC 寄存器的初始值被要求设定为 0x00003000 ;另一方面,状态转移这一边也不可能只有 +4 一种情况,我们还要针对各种跳转添加不同的情况。看起来还是任重道远啊~

虽然我们确信已经找到了改进的思路,不过页面太小,我们好像实在有点画不下。于是,我们就可以考虑将电路拆成一个个模块,在模块内部实现其中的细节。例如,我们可以将 PC 寄存器整合为 PC 模块,将下方的状态转移部分整合为 NPC 模块,像下面这幅图片一样。你可能会觉得电路更加复杂了,是不是我在里面偷偷加了什么东西?实际上,这个电路和之前的是完全一致的:

Picture Not Found!

(这里为了方便观看,我把三个模块画到了同一张画布中,实际上它们应该在三张不同的画布中,相信聪明的你一定能理解的!)

上面这张图片中还是有很多细节的,以至于我需要单独拿出一大段文字,哪怕面临跑题的风险也要先解释一下:首先我们可以注意到,我在 main 模块中使用了 tunnel 元件连接 clkreset ,在之后的搭建中,我们的电路会越来越复杂,适当使用 tunnel 元件可以让我们的电路看起来更加清爽!

接下来是 main 模块中对 NPC 模块的使用,你可能已经发现了,这里的 NPC 模块的输入端 PC 在模块的右侧,而输出端 NPC 反而在模块的左侧。如果你问我为什么这样设计,当然是为了好看了(笑)!要想实现这个效果也并不困难,只需要在设置模块外观时,将输入端口和输出端口的位置调换就可以了。

最后我还想提醒你注意一下 PC 模块中的 clk 端口。我们可以发现这个接口并没有使用时钟元件作为输入端,而是使用了普通的 input 元件作为了输入端。这是因为它实际上只是模块中的一个接口,真正连接时钟的位置其实在主模块中。如果在 PC 模块中就使用时钟元件作为输入端的话,PC 模块在外观上就会比正常情况少一个 clk 接口,没有办法连接到主模块的时钟元件上了。

说了这么多,我们赶紧把话题拉回来,继续我们对 PC 和 NPC 这组状态机进行改造。首先我们可以为 PC 加上初始值。还记得我们在第一章讲过的双异或法吗?或许时间有些久远了,看看下面这幅图,不知道你能不能回忆起一些往事呢:

Picture Not Found!

接下来就是为 NPC 支持适配跳转指令了。在跳转指令中,绝对跳转指令没有任何跳转条件,实现起来也是非常简单。对于 jrjalr 指令,两条指令均是使用寄存器中 32 位的值作为跳转地址,也就是 NPC 模块的输出;而对于 jjal 两条指令,我们还需要将指令中的 26 位立即数左移 2 位,并与当前 PC 寄存器的值的高 4 位进行拼接,才能得到跳转的地址。

这里我们可以先将上面的 32 位寄存器值和 26 位立即数统统作为 NPC 模块的输入,在后续环节中我们再考虑如何为 NPC 模块准备这些数据。那么到目前为止,我们就将 NPC 模块改造成了这样:

Picture Not Found!

等等,下面这个 NPC_Sel 是哪里来的?定睛一看,原来是控制右侧的多路选择器 MUX 的选项,正是这个接口决定着下一条指令的地址是 PC + 4 ,还是 PC 与 26 位立即数 imm26 的复合,抑或是 GRF 中某个寄存器的值。在不久后我们搭建 Controller 模块时,就能够了解到它的来历了。不过我们现在还是争取来补全 NPC 的最后一块拼图 —— 相对跳转指令吧!

和绝对跳转指令不同的是,相对跳转指令是有条件的。当条件成立时执行跳转,下一条指令的地址变为 PC + 4 + sign_ext(imm16 << 2),当条件不成立时保持 PC + 4 。于是除了 NPC_Sel 这个选项,我们还需要额外引入一个 1 位的判断是否满足条件的接口,祖上传下来的规矩,喜欢把这个接口叫做 zero ,我也不知道为什么。

不过叫什么名字不要紧,最重要的是我接下来的一番话,想当年我就是因为没有参透这句话,差点在 P3 就翻了大车,也让 P3 成为了我唯一一个没有 AK 过的 P ,非常遗憾。这句话就是:当我们添加新的信号接口时,一定要注意新接口的值是否会对已有的指令产生影响! 例如在下面两种 zero 接口的实现中,哪一种实现方法中,zero 的值会对除相对跳转指令的其它指令也产生影响呢?

Picture Not Found!

Picture Not Found!

答案是下面一种,你答对了吗?我们观察两个电路,在上一幅电路图中,当 NPC_Sel 的值不为 3 ,也就是当前指令不是相对跳转指令时,zero 的值无论为多少,都与输出毫无关系;而在下一幅电路图中,我们会发现即使 NPC_Sel 的值为 1 或 2 ,即当前指令为绝对跳转指令时,也需要 zero 的值为 1 才能够正常跳转,否则依然会输出 PC + 4 !

这并不是说后者的实现是错误的,实际上,只要我们在根据指令类型为 zero 赋值时,记得将绝对跳转指令对应的 zero 值无条件赋为 1 ,即可正确实现电路。不过为了保险起见,也是为了我们在上机考试中加指令能够更加顺利,我们在之后的教程中将会使用前者的实现方法。

终于,我们完成了 PC 和 NPC 这组状态机,成功得到了当前指令的地址,在正确取出指令的路上迈出了一大步!接下来,我们来完成 IM 模块,将地址转换为 MIPS 指令的机器码。

我们知道,指令的存储需要用到一大片连续的地址空间,使用最高只有 32 位的寄存器堆可能并不是一个非常好的方法。好在我们还有两个量大管饱的高手能够解决这个问题,那就是 ROM 和 RAM ,在这里我们选择 ROM 作为存储指令机器码的载体。ROM 的特点是只能在程序运行前将内容一次性全部写入,而无法在程序运行过程中进行任何修改。在程序运行过程中,我们当然不希望程序遭到篡改,于是选择 ROM 就再合适不过了!

我们创建一个 IM 模块,在模块中放置一个 ROM 元件。接下来,我们就可以把汇编程序的机器码导入 ROM 中了。如果你忘记了怎么在 MARS 中将汇编程序的机器码导出为 txt 文件,以及如何在 Logisim 中将 txt 文件格式的机器码导入 ROM 中,那么快回头看一看上半本书的内容吧!我会等着你回来的!(笑)

我们观察可以发现,ROM 左侧有一个输入端 Address 端,右侧有一个输出端 Data 端。(下方的 Select 端我们暂时不需要用到)为了使 ROM 的 Data 端能够一次性输出 32 位的指令,我们在左下角的设置中,将 ROM 的 Data Bit Width 改为 32 。

Picture Not Found!

接下来我们来关注 ROM 左侧的 Address 端,Address 端输入的内容就是指令的地址吗?答案显然是否定的。最明显的一个问题,ROM 中指令机器码的首地址是 0 ,而不是 0x3000 ,所以我们在将地址输入 ROM 之前,需要事先减去一个 0x3000

然而这就可以了吗?我们试着给 ROM 的 Address 端赋不同的值作为输入,观察输出的结果:

Picture Not Found!

于是我们能够发现,相邻两条指令之间的输入值并不像指令地址一样相差 4 ,而是相差 1 。所以我们在 -3000 之后,还需要再除以 4 ,也就是右移 2 位。

结束了吗?似乎并没有。当我们满心欢喜以为做好了所有前期工作,准备设置 Address 端的位宽时,却发现我们最大只能设置 24 位的位宽!实际上,Logisim 中 ROM 的存储空间也是有上限的,所以我们只能委曲求全,牺牲一些地址空间,舍去地址中最高的几位了:

Picture Not Found!
(图中截取了地址的第 2 到 17 位,也就是保留了 16 位的 Address 位)

最后,我们把 IM 连接到电路上,就完成了我们 CPU 中取指令的全部内容!

Picture Not Found!

【控制器:Controller模块】

历经了千辛万苦,我们终于取出了要执行的指令。接下来,我们就要进入执行指令的阶段了。那么,我们该如何实现将如此多种指令的执行集成到一个 CPU 上呢?

首先我们肯定知道,每种指令的执行过程都是不同的(这不废话嘛)。即使我们将所有指令的所有共通之处都整合起来,也依然会出现许多差异,这就需要我们的 CPU 根据指令的种类进行分类讨论了。

于是我们就需要发明这样一个模块,它有两个功能:一是根据指令的机器码解析出指令的名称;二是根据解析出的名称为一些存在差异的接口赋值(例如 NPC 模块中的 NPC_Sel ,还记得吗)。那么恭喜你发明了 Controller 模块!

接下来,我们跟着上面的思路,分两步实现 Controller 。首先我们需要根据指令的机器码判断指令的名称。我们知道,对于绝大多数指令来说,通过最高 6 位的 OpCode 和最低 6 位的 Funct 都足以判断指令的类型了(当然像 bltz bgez 这种奇葩除外,不过我们目前可以不考虑这些)。于是我们从指令的机器码中分离出这两个部分,作为 Controller 的输入:

Picture Not Found!

在 Controller 中,我们将 OpCode 和 Funct 与各种指令的值进行比较。需要注意的是,只有当 OpCode 为 0 时,才允许继续比较 Funct ,否则可能会出现某种 I 型或 J 型指令的最低 6 位恰好和某指令的 Funct 相等的情况,导致我们的 CPU 将一条指令同时判定为两种类型的奇葩情况:

Picture Not Found!
nop 指令不需要单独判断,我们将 nop 指令与其它所有的未知指令并入一起,当所有指令的判断全部为 0 时即为这种情况)

我们不难发现,在这种结构下,上面的所有 tunnel 中最多只有 1 个值为 1 ,其余一定都为 0(当然也可能会出现全部为 0 的情况,比如 nop 指令),所以我们可以说这就是一种独热码。

接下来,我们要对每种指令规定每一个接口的值,也就是将这些 1 位的独热码转换为对应接口的输出。例如当指令为 addiu 时,NPC_Sel 接口的值应该等于多少;当指令为 beq 时,NPC_Sel 接口的值又应该等于多少。

或许你已经想到了一种非常简单的方法,那就是使用 Logisim 中自带的功能,将真值表一键转换为电路图。但是实际上我并不推荐这种做法,因为 Logisim 只能保证生成电路的正确性,却没有办法保证它的清晰度和可扩展性。当你紧张地坐在上机考试的考场上,面对着一坨乱七八糟的与或非门,如同不可名状之物缠绕在一起的时候,相信我,你的 SAN 值直接就掉光了!

相反地,我将提出一个更加工整简洁的方法!如果我们想根据不同情况对接口赋值,我们最先想到的一定是使用 MUX 元件。不过,MUX 元件的选择端要求我们输入数字序号,而不是一个一个的独热码,有没有什么元件能够快速解决这个问题呢?

或许你可以试试被尘封许久的优先编码器 Priority Encoder !上半本书中我好像还说过它的坏话,不过现在看来它还是有点用处的。对于独热码来说,独热码最多只有 1 个输入端的值为 1 ,那么优先编码器就会直接输出这个 1 所在的位置,也就实现了将独热码转换为顺序编码!

不过我们还需要注意一个细节,那就是还有可能会出现所有输入端均为 0 的情况,此时优先编码器的输出就会全变成 X 。为了解决这种情况,我们可以使用输出下方另外的一个 1 位输出进行辅助,当输入端全为 0 时输出为 0 ,否则输出为 1 ,搭配 MUX 元件进行选择,就可以实现对 nop 指令以及其它未实现指令的判断了:

Picture Not Found!

将独热码转换为数字之后,我们就可以将其接入 MUX 元件,根据指令在优先编码器左侧的顺序,为每条指令编写接口的值了。这种做法不仅支持无限堆叠接口,在上机时也能够更加轻松地完成加指令的任务:

Picture Not Found!

最后,我们修改 Controller 模块的外观,将输出端的 NPC_Sel 接口与 NPC 模块上的对应接口连接起来就可以了:

Picture Not Found!

Controller 模块作为控制每条指令行为的核心,在接下来加指令的过程中会不断添加接口,我们可以在这里设一个存档点,记得常回家看看~

【R型计算类指令与GRF、ALU模块】

接下来,我们开始为我们的 CPU 加入各种指令,我们从 addusubu 这两条 R 型计算类指令开始。我们分析这两条指令的执行流程,大致需要进行这样的几步操作:

  1. 取出 rs 和 rt 两个寄存器中的值 GPR[rs]GPR[rt]
  2. GPR[rs]GPR[rt] 进行运算;
  3. 将运算结果写入 rd 寄存器中。

其中,第 1 步和第 3 步都需要与寄存器堆 GRF 进行交互。我们来观察交互的需求:第 1 步要求我们同时向 GRF 输入两个 5 位的寄存器编号,并要求寄存器同时输出对应的两个寄存器中 32 位的值。我们将两个输入端记为 A1A2 ,将两个输出端记为 RD1RD2 ;第 3 步要求我们向 GRF 输入一个 5 位的寄存器编号,同时输入一个 32 位的值,要求 GRF 将这个值写入对应的寄存器中。于是我们将这两个输入端分别记为 A3WD

除了上面提到的 4 个输入端口和 2 个输出端口之外,GRF 还应该再设置一些端口,比如控制其中所有寄存器的 clkreset 端口,另外还应该有一个写使能 WE 端口。这个写使能端口的作用是什么呢?这就很考验我们的全局意识了!众所周知,虽然 addu subu 这些指令有向寄存器赋值的环节,然而并不是所有指令都是这样,例如 beq j 等指令,可以说是比比皆是。为了在执行后者这些指令时不把一些奇怪的值写到一些奇怪的寄存器中去,我们就需要为 GRF 加上 WE 这样一个写使能,当执行这些指令时置 0 ,这样就很好地解决了这个问题!

接下来我们来着手搭建一下 GRF 模块。可以预见的是,32 个寄存器必然会导致我们的电路图来到一个极其凶残的超大规模,不过其实都是一些重复性较强的简单工作,只不过是力气和时间的问题。为了让大家能够看清楚,接下来的样例图片中以 4 个寄存器作为例子,大家在搭建的时候自行扩展为 32 个寄存器即可~

首先,我们为我们的小寄存器堆添加时钟信号 clk 、复位信号 reset 和写使能 WE

Picture Not Found!

接下来,我们为小 GRF 加上 A1 A2 两个输入端和 RD1 RD2 两个输出端,使用 MUX 元件就可以轻松完成这个任务:

Picture Not Found!

这个走线实在是说不上优雅……算了无所谓了,我们继续为小 GRF 加上 A3WD 两个输入端,这一次我们使用许久不见的 DMX 元件:

Picture Not Found!

这里有一些小细节,所以我们在这里停顿。最明显的应该就是那根断掉的导线了,这可不是图片加载错误,是因为 0 号寄存器是不能被赋值的,所以我故意掐断了这根导线(笑)。

接下来还有一个细节,在图片中完全体现不出来,但是如果你是我的忠实读者,应该能想起一个非常非常遥远的 callback ,那就是要将 DMX 元件的 Three-state? 属性改为 Yes 。这个知识点在第一章我们初次遇见 DMX 元件的时候就强调过,这里再次提醒一下:DMX 元件在默认情况下,除了 A3 对应端口的输出值为 WD 的值之外,其余所有端口的输出值都为 0 。也就是说,每当时钟上升沿到来时,除了 A3 对应的寄存器会被赋值为 WD 以外,其余所有寄存器都会被赋值为 0 !这显然是错误的,好在使用上述解决方法进行操作之后,就可以正常模拟只为一个寄存器赋值的行为了。

这样,我们就完成了 GRF 模块,现在我们稍作修改一下它的外观,将它添加至主模块中:

Picture Not Found!

这里我们将指令的拆分进一步细化,按照 R 型指令分为了 OpCode 、rs 、rt 、rd 、shamt 和 Funct 六个部分;同时在 Controller 中加入了 GRF 写使能端的对应接口 RegWrite ,如下图所示。其中 0 表示指令执行过程中不会写寄存器,1 表示指令执行过程中会写寄存器:

Picture Not Found!

到目前为止,我们已经几乎完成了 R 型计算类指令三个步骤中的第 1 步和第 3 步,接下来我们来完成第 2 步,也就是对 GRF 输出的 RD1RD2 进行运算,再将结果输入回 WD 中。

不过我们知道,R 型计算类指令的种数相当之多(虽然我们目前并不会实现这么多),直接在主模块中计算的话,电路图就会变得非常杂乱,不如我们再创建一个模块,专门进行这项运算的工作,于是我们又发明了 ALU 模块!

ALU 模块的任务很简单,输入两个值 inputAinputB ,根据指令的类型进行不同的运算,输出一个结果 outputA 。说起来恐怕也就只有一个问题,我们应该如何或许指令的类型呢?当然还要得益于神通广大的 Controller 了!当然了,如果将十几个独热码全部作为 Controller 的输出端口,那可能有点太复杂了,不过我们早已经使用优先编码器把这些独热码转换为了数字,只需要让 Controller 直接输出这些数字,再由 ALU 作为输入就可以了:

Picture Not Found!

有了 type ,我们在编写 ALU 的路上就畅通无阻了,只需要注意将指令种类和代表它们的数字严格对应上就可以了:

Picture Not Found!
(这里当 MUX 的选项为 0 时,因为在 0 号接口没有输入值,所以输出为全 X 。因为 0 号接口对应的指令是 nop 或其它未实现指令,并不会用到 outputA 这个输出值,后续也会有其它指令同样不需要用到这个输出值,例如 beq 等指令,此时输出为全 X 均是正常现象)

最后,我们修改 ALU 模块的外观,放置在主模块中,连好相应的接口。自此,我们的 CPU 终于能够运行指令了!(虽然算上 nop 也只支持区区 3 条指令,不过万事开头难,还是可喜可贺,可喜可贺!)

Picture Not Found!

【I型计算类指令与EXT模块】

刚刚我们稍微体会了一下旗开得胜的喜悦,让我们继续吧!接下来要添加的 3 条新指令是 addiu xori 和马里奥的兄弟 lui

我们首先来思考一下这些 I 型计算类指令与刚才实现的 R 型计算类指令有什么区别。一个区别就是 ALU 的输入端 outputAoutputB 不再是源自 GRF 输出的 RD1RD2 ,而是 RD1 和一个立即数;另外一个区别则要从指令的结构上看出来了,那就是 I 型指令中要被赋值的寄存器,也就是连接 GRF 模块 A3 接口的寄存器,不再是 rd 寄存器,而是 rt 寄存器。

接下来我们来适配一下这两个功能,首先是支持 16 位立即数参与 ALU 计算。看起来只需要将指令的最低 16 位取出来,和 RD2 一起接入 inputB ,再加一个 MUX 元件和 Controller 接口选择即可。不过在此之前我们还是应该注意一下位宽问题,16 位的立即数终究还是和 32 位的 inputB 接口走不到一起,所以我们需要先将 16 位立即数扩展至 32 位。

那这里又有一些说法了,敏锐的你肯定想到了,既然是位扩展,肯定就会有符号扩展和无符号扩展之分。非常巧合的是,我们要新加的 3 个指令里,addiu 指令是符号扩展,xori 指令是无符号扩展,而 lui 指令则是两种扩展方式都可以!(当然是刻意的游戏设计,笑)

针对两种不同的扩展方式,我们可以将其整合为一个新的 EXT 模块,采用新的 Controller 接口进行选择:

Picture Not Found!
(其实我觉得这个 EXT 模块多少有点鸡肋了,不单独设置一个模块也没什么问题)

接下来,我们依旧是在 Controller 中增加接口 EXTop

Picture Not Found!

将 EXT 模块放入主模块中:

Picture Not Found!

接下来,为了节省版面,我们一次性完成两个 Controller 接口的添加:一个是控制 ALU 模块的 inputB 端口是接入 GRF 模块的 RD2 端口,还是接入 EXT 模块的 ext32 端口的 ALUsrc ;另外一个是控制 GRF 的 A3 端口是接入指令的 rt 部分,还是 rd 部分的 RegDst 。我们依旧是现在 Controller 模块中进行添加:

Picture Not Found!

我们在主模块中加入对应的选择:

Picture Not Found!

最后,不要忘了更新 ALU 模块中的计算部分。这里 addu 指令和 addiu 指令的运算是相同的,我们可以在右侧复用这个运算:

Picture Not Found!

这样我们就完成了 I 型计算类指令的追加!在后续的上机考试中,我们也会遇到各种有趣的加指令谜题。虽然谜题千奇百怪,不过只要按照我们刚才加指令的思路去思考,其实也没有看上去那么恐怖嘛!

【内存访存指令与DM模块】

接下来,我们来解决内存访存指令隔壁 lwsw 。不过在此之前,我们需要设计一下内存模块 DM 。

和 IM 模块不同的是,DM 模块不仅要能够读出内存中的内容,还要支持向内存中写入。所以我们采用可读可写的 RAM 元件作为 DM 的主体。仿照 IM 模块中的 ROM 元件,我们将 DM 中的 RAM 元件的地址位宽 Address Bit Width 设置为 16 位,数据位宽 Data Bit Width 设置为 32 位。此外,我们还要按照惯例,将数据接口 Data Interface 设置为 Separate load and store ports :

Picture Not Found!

首先,我们模仿 IM 模块,完成 DM 模块的读内存功能。因为在对内存进行访存的时候,我们的首地址不是 0x3000 而是 0x0000 ,所以自然也不需要在 DM 模块中将地址 -3000 ,而是直接右移两位就可以了:

Picture Not Found!

当然,只有读取作用的 DM 模块实在是太逊了,我们还要为它加上写入的功能,这就需要我们加上一堆接口,例如要写入的数据 WD ,以及时钟接口 clk 、复位接口 reset 。当然了,我们不能忘了写使能接口 WE ,毕竟不是所有的指令都有写内存的需求,在绝大多数指令的情况下,我们需要保证写使能是关闭的,防止将一些不正确的数据不小心漏进内存中:

Picture Not Found!

接下来,我们还是为 DM 模块设置好外观,加入主模块中:

Picture Not Found!

接下来我们考虑一下 DM 模块中 Data WD WE 接口该如何连接。

首先是 Data 接口。我们从内存中取出数据,是为了将其写入寄存器中,所以我们将 Data 接口与 GRF 模块的 WD 接口连接在一起。不过这个接口貌似已经有线路了,所以我们需要加入一个新的选择信号 MemtoReg 进行选择。

接下来是 WD 接口。我们将要输入内存中的数据来自哪里呢?在目前我们将要实现指令里,只有 sw 指令需要写内存,而它要写入的数据则来自 rt 寄存器中的内容。于是我们从 GRF 模块中的 RD2 端口取出 rt 寄存器中的数据之后,不仅需要将其传入 ALU 模块,还需要同时接入 DM 模块的 WD 接口。

最后是 WE 接口,它的值当然就要来自 Controller 模块了!于是,我们可以画出新的主模块电路图:

Picture Not Found!

接下来的操作也不必多说了,想必聪明的你已经轻车熟路了!那就是在 Controller 模块中增加接口:

Picture Not Found!

以及在 ALU 模块中增加相应的计算:

Picture Not Found!

这样,我们就完成了内存访存指令 lwsw

Picture Not Found!

到目前为止,我们已经完成了单周期 CPU 中的所有模块。在接下来加指令的练习中,我们只需要复刻一下之前已经操作过很多次的流程即可。我们可以简单复习一下,大概就是:分析指令的数据通路 -> 增加新的数据通路和选择信号 -> 在 Controller 模块中修改选择信号 -> 在 ALU 模块中加入新的计算。这样,我们就完成了加指令的全部流程!

【相对跳转指令】

刚才我们简单梳理了一下加指令的全部流程,现在我们就用跳转指令来练练手吧!

在相对跳转指令这一部分,我们要加入的是 beqbne 两条指令。我们首先来分析一下这两条指令的数据通路,我们首先需要将 rs 和 rt 输入 GRF 模块,取出 GPR[rs]GPR[rt] 两个值,传入 ALU 模块中。

这一步看起来和我们之前实现过的指令没什么区别,真正的区别在 ALU 模块中。我们并不需要 ALU 模块输出计算后的值,而是输出二者是否相等,也就是决定是否进行跳转的 zero 值,我们将其连接到 NPC 模块的 zero 接口上。配合设定好的 NPC_Sel 值,以及传入的 imm16 ,NPC 模块就可以计算出下一个周期要执行的指令的地址,这样我们就完成了这两条指令的执行。

理清思路后,我们就可以开始着手修改 Controller 模块和 ALU 模块,以及主模块中的数据通路了!

正常加指令的情况下,我们需要在 Controller 模块中加入对新指令 OpCode 和 Funct 的解析,以及为新指令适配已经加入的选择信号。不过这些工作我们在之前已经顺带做过了,而且这两条指令也没有加入新的选择信号,这里就略过修改 Controller 模块这个环节了。

接下来,我们修改 ALU 模块,为其增加新的 zero 接口:

Picture Not Found!

最后,我们在主模块中连接 zero 接口的两端,并向 NPC 模块传入 imm16 ,就完成了 beqbne 两条指令:

Picture Not Found!

就这么简单?就这么简单!有没有稍微感觉到,其实 CPU 并没有我们想象中的那么难?(笑)

【绝对跳转指令】

接下来,我们再挑战一下适配四条绝对跳转指令 j jr jal jalr 。相信刚刚速通了相对跳转指令的你现在一定是满怀信心,跃跃欲试了吧!

我们先速通一下 j 指令和 jr 指令。这两条指令不需要读写寄存器,不需要计算,不需要读写内存,真的是什么都不需要!我们已经保证了 Controller 模块能够解析这两条指令,NPC_Sel 接口额输出的值正确,GRF 模块和 DM 模块的写使能关闭,现在只需要将 NPC 的 imm26GRF 两个接口连好,就直接实现了这两条指令:

Picture Not Found!

接下来我们来重点关注一下 jaljalr 这两条指令。我们首先来看 jal 指令,它其实就是在 j 指令的基础上增加了“将 PC + 4 写入 31 号寄存器中”这条规则。然而我们仔细分析一下就会发现,无论是将 PC + 4 输入 GRF 模块的 WD 接口,还是将 31 输入 GRF 模块的 A3 接口,都需要我们新开一个选择信号才能实现。不过,我相信以我们现在实力,拿下 jal 这个麻烦鬼也依然是游刃有余!

于是,我们在 Controller 模块中编写接口,PCtoReg 接口负责选择 PC + 4 传入 GRF 模块的 WD 接口中;而 Regra 接口负责选择 31 号寄存器作为输入数据的寄存器:

Picture Not Found!

最后,我们在主模块中连接好全部的选择信号,于是我们就完成了 jal 这条指令:

Picture Not Found!
(为了能在 133% 的画面比例下画出 CPU ,真的是把画面运用到极限了(晕倒)我确信有一种优美的画法,但是画面太小,我画不下)

至于 jalr 指令,它是在 jr 指令的基础上加上了“将 PC + 4 写入 rd 寄存器中”这条规则。我们只需在 Controller 模块中设置好相应的选择信号即可(见上上图)。

至此,我们就完成了一个支持 14 种 MIPS 指令,并可轻松拓展到几乎所有常见 MIPS 指令的 CPU !

CPU 搭完真的很兴奋!我猜你大概率是人生中第一次完成这么复杂的数字电路,真的要好好鼓励一下自己,晚上吃点好的吧!

4.3 使用 Verilog 模拟单周期 CPU

接下来,我们试着把我们刚才设计的 CPU 移植到 Verilog 硬件语言上。在第三章我们就已经了解到了一个观点,那就是 Verilog 硬件语言实际上就是在描述电路。所以我们需要做的,其实只不过是将电路图“翻译”成电路,也是非常简单了!事不宜迟,我们趁热打铁,马上开始吧!

我们对 CPU 的移植,从各个模块开始,最终再将所有模块整合成到主模块中。如果你现在还不是特别清楚 ISE 中工程(.xise 文件)与模块(.v 文件)的区别,那么你可能需要稍作停顿,先到上半本书中复习一下,我在这里等着你~

另外,在 Verilog 版本的 CPU 中,课程组为了简便大家的工作量,对复位问题作出了一些细小的改动,我们也随着课程组的脚步一起来改动一下:首先是复位方式从异步复位改为同步复位,前者在 Logisim 中比较容易实现,而后者在 Verilog 中则更加简单,同时也降低了整个电路的复杂度。此外,课程组还保证了在开始执行指令之前,均会将 reset 接口置 1 一次,于是我们的寄存器就不需要使用 initial 块来进行初始化,而只需要在 always 块中写清楚如何重置就可以了。

首先我们从 PC 模块开始翻译。观察下面的图片,我们要实现的接口有:输入接口 input clk input reset input [31:0] NPC ,输出接口有 output [31:0] PC 。另外,我们还需要实现一个寄存器 reg [31:0] regPC ,初始值为 0x00003000 ,在每个时钟上升沿接受 NPC 作为输入,并输出 PC

Picture Not Found!

根据上面的条件,身经百炼的你,一定可以非常容易写出相应的对代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
module PC(
input clk,
input reset,
input [31:0] NPC,
output [31:0] PC
);

reg [31:0] regPC;

always@(posedge clk) begin // 改用同步复位
if (reset) begin // 重置方法
regPC <= 32'h00003000;
end
else begin
regPC <= NPC;
end
end

assign PC = regPC;

endmodule

(这里使用了更适合 Verilog 的为寄存器赋初始值的方法,算是一种信达雅的翻译吧(笑),当然你也可以将 Logisim 中赋初始值的方法直译过来,完全没有问题!)

接下来是 NPC 模块,NPC 模块中没有寄存器,完全是组合逻辑,实在是太爽了:

Picture Not Found!

在组合逻辑的 assign 语句中,我们用三目运算符来代替 MUX 模块,写起来非常方便。在编写代码的过程中,我们可能会遇到很多自行定义的魔数(例如 NPC_Sel 接口的值为 0 、1 、2 、3 时分别表示哪种情况),为了使我们在上机时紧张的环境下看得更加清晰,我们可以采用宏定义或者 parameter 型变量的方法来为魔数取名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
module NPC(
input [31:0] PC,
input [15:0] imm16,
input [25:0] imm26,
input [31:0] GRF,
input [1:0] NPC_Sel,
input zero,
output [31:0] NPC
);

parameter PCPLUS4 = 2'b00,
IMM26 = 2'b01,
GRFconst = 2'b10,
IMM16 = 2'b11;

wire [31:0] sign_ext;
wire [31:0] NPC_PCplus4;
wire [31:0] NPC_imm16;
wire [31:0] NPC_imm26;
wire [31:0] NPC_GRF;

assign sign_ext = {{14{imm16[15]}}, imm16, 2'b00};
assign NPC_PCplus4 = PC + 32'h00000004;
assign NPC_imm16 = PC + 32'h00000004 + sign_ext;
assign NPC_imm26 = {PC[31:28], imm26, 2'b00};
assign NPC_GRF = GRF;

assign NPC = (NPC_Sel == PCPLUS4) ? NPC_PCplus4 :
(NPC_Sel == IMM26) ? NPC_imm26 :
(NPC_Sel == GRFconst) ? NPC_GRF :
(NPC_Sel == IMM16 && zero) ? NPC_imm16 :
NPC_PCplus4; // NPC_Sel == IMM16 && !zero

endmodule

实现了 PC 和 NPC 两个模块,我们就可以使用 IM 模块取出当前指令了:

Picture Not Found!

不过 Verilog 中并没有提供给我们类似于 ROM 和 RAM 这样的元件,但是好在我们可以通过定义寄存器数组 reg [31:0] ROM [0:4095] 的方法来实现指令的存储(不要在意 4096 这个大小,足够大即可,防止把内存撑爆)。其中 ROM 中的每个 32 位寄存器都存放着一条指令,在读取指令时,我们只需要提供数组的下标,即可取出对应位置的整条指令,其实和 Logisim 中地址转换的逻辑是相同的。

另外,我们还需要通过一些手段,将写有机器码的 txt 文件装载到 ROM 中,这时候我们就可以用到上半本书里提到过的 $readmemh("code.txt", ROM, 0, 4095); 语句了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
module IM(
input [31:0] addr,
output [31:0] data
);

reg [31:0] ROM [0:4095];

wire [31:0] ROM_addr;

initial begin
$readmemh("code.txt", ROM, 0, 4095);
end

assign ROM_addr = ((addr - 32'h00003000) >> 2);

// 这里因为地址位数问题会出现强制位数转换,不过问题不大
assign data = ROM[ROM_addr];

endmodule

然后是巨大的寄存器堆 GRF 模块(大图一个屏幕放不下,还是放 4 个寄存器的小图):

Picture Not Found!

我们依然可以通过定义寄存器数组的方式解决,妈妈再也不用担心我的 GRF 太大画不下了!这里在同步复位的时候用到了最基础的 for 循环,正好我们也来回忆一下 for 循环的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
module GRF(
input clk,
input reset,
input WE,
input [4:0] A1,
input [4:0] A2,
input [4:0] A3,
input [31:0] WD,
output [31:0] RD1,
output [31:0] RD2
);

reg [31:0] GRF [0:31];

integer i;

always@(posedge clk) begin
if (reset) begin
for (i = 0; i < 32; i = i + 1) begin
GRF[i] <= 32'h00000000;
end
end
else begin
if (WE) begin
if (A3 != 5'b00000) begin // 注意这个细节
GRF[A3] <= WD;
end
end
end
end

assign RD1 = GRF[A1];
assign RD2 = GRF[A2];

endmodule

接下来是短小精悍的 EXT 模块,我们向其输入一个 16 位的立即数,以及 1 位的扩展方式,EXT 模块输出一个扩展后的 32 位数:

Picture Not Found!

这个模块一定远比你之前做过的 Verilog 练习题简单,所以就不多说了,直接上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module EXT(
input [15:0] imm16,
input EXTop,
output [31:0] ext32
);

wire [31:0] zero_ext;
wire [31:0] sign_ext;

assign zero_ext = {16'h0000, imm16};
assign sign_ext = {{16{imm16[15]}}, imm16};

assign ext32 = (EXTop) ? sign_ext : zero_ext;

endmodule

接下来是 CPU 的心脏(我自封的)—— ALU 模块:

Picture Not Found!

依旧是组合逻辑,道理很简单,不过由于三目运算符串的结尾必须要有一个类似于 else 或者 default 的内容,所以并不支持直接不定义不会用到的选项,此时我们可以将输出设置为一些特殊值,来表示 CPU 可能出现了 bug ,这里选用的是全 0 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
module ALU(
input [31:0] inputA,
input [31:0] inputB,
input [5:0] type,
output [31:0] outputA,
output zero
);

parameter ADDU = 6'b000000,
SUBU = 6'b000001,
ADDIU = 6'b000010,
XORI = 6'b000011,
LUI = 6'b000100,
LW = 6'b000101,
SW = 6'b000110,
BEQ = 6'b000111,
BNE = 6'b001000,
J = 6'b001001,
JAL = 6'b001010,
JR = 6'b001011,
JALR = 6'b001100;

wire [31:0] AaddB;
wire [31:0] AsubB;
wire [31:0] AxorB;
wire [31:0] Bleftshift16;
wire [31:0] none;

assign AaddB = (inputA + inputB);
assign AsubB = (inputA - inputB);
assign AxorB = (inputA ^ inputB);
assign Bleftshift16 = (inputB << 16);
assign none = 32'h00000000;

assign outputA = (type == ADDU) ? AaddB :
(type == SUBU) ? AsubB :
(type == ADDIU) ? AaddB :
(type == XORI) ? AxorB :
(type == LUI) ? Bleftshift16 :
(type == LW) ? AaddB :
(type == SW) ? AaddB : none;

assign zero = ((type == BEQ) && (inputA == inputB)) ? 1'b1 :
((type == BNE) && (inputA != inputB)) ? 1'b1 : 1'b0;

endmodule

再之后是 DM 模块:

Picture Not Found!

我们依旧采用寄存器数组的方式实现 RAM(当然,这里的数组开得也比较小),只需要做一个最简单的寄存器赋值运算即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
module DM(
input clk,
input reset,
input WE,
input [31:0] addr,
input [31:0] WD,
output [31:0] data
);

reg [31:0] RAM [0:3071];

wire [31:0] RAM_addr;

integer i;

assign RAM_addr = (addr >> 2);

always@(posedge clk) begin
if (reset) begin
for (i = 0; i < 3072; i = i + 1) begin
RAM[i] = 32'h00000000;
end
end
else begin
if (WE) begin
RAM[RAM_addr] <= WD;
end
end
end

// 这里依然会出现强制位数转换的问题,不过当然也是问题不大
assign data = RAM[RAM_addr];

endmodule

最后就是 CPU 的大脑(依旧是我自封的)—— Controller 模块:

Picture Not Found!
Picture Not Found!

毕竟是组合逻辑,其实难也难不到哪去,不过复杂度上还是有的,写代码的时候还是要格外细心:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
module Controller(
input [5:0] OpCode,
input [5:0] Funct,
output [1:0] NPC_Sel,
output RegWrite,
output EXTop,
output ALUsrc,
output RegDst,
output MemWrite,
output MemtoReg,
output PCtoReg,
output Regra,
output [5:0] type
);

// 这里的定义一定要和 ALU 中的定义保持一致!
parameter ADDU = 6'b000000,
SUBU = 6'b000001,
ADDIU = 6'b000010,
XORI = 6'b000011,
LUI = 6'b000100,
LW = 6'b000101,
SW = 6'b000110,
BEQ = 6'b000111,
BNE = 6'b001000,
J = 6'b001001,
JAL = 6'b001010,
JR = 6'b001011,
JALR = 6'b001100;

wire addu;
wire subu;
wire addiu;
wire xori;
wire lui;
wire lw;
wire sw;
wire beq;
wire bne;
wire j;
wire jal;
wire jr;
wire jalr;

assign addu = (OpCode == 6'b000000 && Funct == 6'b100001) ? 1'b1 : 1'b0;
assign subu = (OpCode == 6'b000000 && Funct == 6'b100011) ? 1'b1 : 1'b0;
assign addiu = (OpCode == 6'b001001) ? 1'b1 : 1'b0;
assign xori = (OpCode == 6'b001110) ? 1'b1 : 1'b0;
assign lui = (OpCode == 6'b001111) ? 1'b1 : 1'b0;
assign lw = (OpCode == 6'b100011) ? 1'b1 : 1'b0;
assign sw = (OpCode == 6'b101011) ? 1'b1 : 1'b0;
assign beq = (OpCode == 6'b000100) ? 1'b1 : 1'b0;
assign bne = (OpCode == 6'b000101) ? 1'b1 : 1'b0;
assign j = (OpCode == 6'b000010) ? 1'b1 : 1'b0;
assign jal = (OpCode == 6'b000011) ? 1'b1 : 1'b0;
assign jr = (OpCode == 6'b000000 && Funct == 6'b001000) ? 1'b1 : 1'b0;
assign jalr = (OpCode == 6'b000000 && Funct == 6'b001001) ? 1'b1 : 1'b0;

// 这里 NPC_Sel 的值一定要和 NPC 中的定义保持一致!
assign NPC_Sel = (j | jal) ? 2'b01 :
(jr | jalr) ? 2'b10 :
(beq | bne) ? 2'b11 : 2'b00;
assign RegWrite = (addu | subu | addiu | xori | lui | lw | jal | jalr) ? 1'b1 : 1'b0;
assign EXTop = (addiu | lw | sw) ? 1'b1 : 1'b0;
assign ALUsrc = (addiu | xori | lui | lw | sw) ? 1'b1 : 1'b0;
assign RegDst = (addu | subu | jalr) ? 1'b1 : 1'b0;
assign MemWrite = (sw) ? 1'b1 : 1'b0;
assign MemtoReg = (lw) ? 1'b1 : 1'b0;
assign PCtoReg = (jal | jalr) ? 1'b1 : 1'b0;
assign Regra = (jal) ? 1'b1 : 1'b0;
// 这里将全 1 当作错误或未实现指令的输出(包括 nop 指令)
assign type = (addu) ? ADDU :
(subu) ? SUBU :
(addiu) ? ADDIU :
(xori) ? XORI :
(lui) ? LUI :
(lw) ? LW :
(sw) ? SW :
(beq) ? BEQ :
(bne) ? BNE :
(j) ? J :
(jal) ? JAL :
(jr) ? JR :
(jalr) ? JALR : 6'b111111;

endmodule

完成了所有的模块之后,我们就需要将所有模块连接在一起,在主模块上完成我们我们最终的成果:

Picture Not Found!

主模块的线路错综复杂,稍有不慎连错一根就可能导致整个 CPU 直接抽疯。而且 Verilog 也不会像 Logisim 一样直观,接错线并不一定会有特别明显的报错,所以我们还是要拿出 120% 的细心,像拆炸弹一样谨小慎微地连接好我们的电路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
module mips(
input clk,
input reset
);

// PC and NPC
wire [31:0] pc;
wire [31:0] npc;
wire [31:0] pcplus4;

// IM
wire [31:0] Instr;
wire [5:0] OpCode;
wire [4:0] rs;
wire [4:0] rt;
wire [4:0] rd;
wire [4:0] shamt;
wire [5:0] Funct;
wire [15:0] imm16;
wire [25:0] imm26;

// Controller
wire [1:0] NPC_Sel;
wire RegWrite;
wire EXTop;
wire ALUsrc;
wire RegDst;
wire MemWrite;
wire MemtoReg;
wire PCtoReg;
wire Regra;
wire [5:0] type;

// GRF
wire [4:0] A1;
wire [4:0] A2;
wire [4:0] A3;
wire [31:0] WD_Reg;
wire [31:0] RD1;
wire [31:0] RD2;

// EXT
wire [31:0] ext32;

// ALU
wire [31:0] inputA;
wire [31:0] inputB;
wire [31:0] outputA;
wire zero;

// DM
wire [31:0] addr;
wire [31:0] WD_Mem;
wire [31:0] data;

PC PC (.clk(clk), .reset(reset), .NPC(npc), .PC(pc));
NPC NPC (.PC(pc), .NPC_Sel(NPC_Sel), .zero(zero), .imm16(imm16),
.imm26(imm26), .GRF(inputA), .NPC(npc));
IM IM (.addr(pc), .data(Instr));
Controller Controller (.OpCode(OpCode), .Funct(Funct), .RegDst(RegDst),
.ALUsrc(ALUsrc), .MemtoReg(MemtoReg), .RegWrite(RegWrite),
.MemWrite(MemWrite), .NPC_Sel(NPC_Sel), .EXTop(EXTop),
.PCtoReg(PCtoReg), .Regra(Regra), .type(type));
GRF GRF (.clk(clk), .reset(reset), .WE(RegWrite), .A1(A1), .A2(A2),
.A3(A3), .WD(WD_Reg), .RD1(RD1), .RD2(RD2));
EXT EXT (.imm16(imm16), .EXTop(EXTop), .ext32(ext32));
ALU ALU (.inputA(inputA), .inputB(inputB), .type(type), .outputA(outputA),
.zero(zero));
DM DM (.clk(clk), .reset(reset), .WE(MemWrite), .addr(addr), .WD(WD_Mem),
.data(data));

assign pcplus4 = pc + 32'h00000004;

assign OpCode = Instr[31:26];
assign rs = Instr[25:21];
assign rt = Instr[20:16];
assign rd = Instr[15:11];
assign shamt = Instr[10:6];
assign Funct = Instr[5:0];
assign imm16 = Instr[15:0];
assign imm26 = Instr[25:0];

assign A1 = rs;
assign A2 = rt;
assign A3 = (Regra) ? 5'b11111 :
(RegDst) ? rd : rt;
assign WD_Reg = (PCtoReg) ? pcplus4 :
(MemtoReg) ? data : outputA;

assign inputA = RD1;
assign inputB = (ALUsrc) ? ext32 : RD2;

assign addr = outputA;
assign WD_Mem = RD2;

endmodule

至此,我们就将我们的 CPU 成功移植到了 Verilog 上,是不是非常简单?收工收工!

4.4 本章结语:再见,单周期 CPU !

首先还是要恭喜你读到这里,这说明你已经打通了 CPU 的第一关,了解了 CPU 的整体结构和运行逻辑,用两种不同的方法实现了相当复杂的单周期 CPU !

在 Logisim 一节中,我们从实现每条指令的需求出发,设计了一个又一个的模块,了解到如此壮观的 CPU 世界中,每个不起眼的接口、导线都有着不可或缺的意义,等待着执行独属于自己的指令。

在 Verilog 一节中,我们则是按照模块的顺序,将每个模块逐个翻译为 Verilog 语言。这一节并没有写太多,大多数时间都只是在贴代码,不过我觉得这一节的内容确实也不是很难,因为基本没有涉及到任何新知识点,所以也确实没什么可讲的(其实甚至本来并没打算写这节)。如果你在 Verilog 一章的修炼到位的话,这一关确实可以速通了。

不过还是不要小看了 Verilog !因为在此之后,我们就要离开具象的 Logisim 的怀抱,使用抽象的 Verilog 来完成我们剩下的旅途了!接下来,你将面临的是计组实验课程中的究极大魔王 ——

第五章:五级流水线 CPU

5.1 现在可以公开的情报:一些新的前置知识

【单周期与流水线】

在上一章,我们已经实现了单周期 CPU ;在本章,我们要实现的则是流水线 CPU 。那么,究竟什么是单周期 CPU ,什么是流水线 CPU 呢?

打一个比较简单的比方。Kamonto 每天早上上学之前,需要吃早饭、刷牙、洗脸、洗头发、穿衣服。现在有 100 个这样的 Kamonto 要上学,每个 Kamonto 在完成吃早饭、刷牙、洗脸、洗头发、穿衣服这一整套流程之后,下一个 Kamonto 才可以开始吃早饭,进行自己的流程,这样就需要 100 个周期才能完成任务。这就是我们之前完成的单周期 CPU ,只有当一条指令执行完毕后,下一条指令才能开始执行。

不过,我们可以对 Kamonto 家进行一些适当的改造,将家里改为 5 个不同的分区:吃饭区、刷牙区、洗脸区、洗头区、穿衣区。这样,当每个 Kamonto 吃完早饭,开始刷牙的时候,下一个 Kamonto 就可以进入吃饭区开始吃饭,以此类推,可以实现 5 个 Kamonto 同时进行流程的效果,极大地提升了效率,妈妈再也不用担心 Kamonto 上学迟到了!

这就是五级流水线 CPU 的原理。在后面的实现过程中,我们会将原来的单周期 CPU 分为五个部分,当每条指令执行到第二部分时,就放下一条指令进入第一部分,这样就能够极大地提升指令的运行效率,当然也会极大地提升我们实现的难度(笑)

【延迟槽】

接下来我们来认识一个新的奇妙装置 —— 延迟槽。我们打开 MARS ,输入如下的 MIPS 汇编指令:

1
2
3
4
5
beq $0, $0, branch
li $s0, 1
li $s1, 2
branch:
li $s2, 3

我们观察这段指令,它的执行结果应该如何?当然是 $s0$s1 都没有被赋值,而 $s2 被赋值为 3 了!

接下来,我们选中 Settings 选项中的 Delayed branching ,开启延迟槽,再来执行一下刚才的指令,观察执行结果:

Picture Not Found!

我们在 Execute 界面选择单步执行,会发现在执行完 beq $0, $0, branch 指令后,并没有立即发生跳转,而是进入了下一条指令 li $s0, 1(并且变成了诡异的绿色):

Picture Not Found!

执行完 li $s0, 1 之后,跳转发生,正常进入标签 branch 的位置,执行 li $s2, 3 ,程序结束:

Picture Not Found!

我们从右侧的寄存器值中也可以看出,$s0$s2 分别被赋值为了 1 和 3 ,而 $s1 寄存器没有被赋值:

Picture Not Found!

到这里,聪明的你或许已经猜到了延迟槽的作用了,那就是在跳转发生时不立即跳转,而是先执行跳转指令的下一条指令,随后再“延迟”跳转。

(聪明的我猜到聪明的你肯定会灵机一动问出这样一个问题,那如果跳转指令的下一条指令还是跳转指令,会出现怎样的情况呢?答案是这是一种非法行为,不同的 CPU 得出的结果可能不相同,结果是不可预测的~)

在开启延迟槽时,一些指令的执行逻辑也会发生微妙的变化,这种变化主要体现在 J 型指令中:

首先最明显的就是 jal 指令和 jalr 指令的写寄存器值发生了变化。我们来执行下面一段指令:

1
2
3
4
5
jal branch
li $s0, 1
li $s1, 2
branch:
li $s2, 3

运行结果如下:

Picture Not Found!

可以看到,此时 $ra 寄存器中写入的地址是 0x00003008 ,并不是 jal 指令的下一条指令的地址,而是下两条指令的地址,其实也就是延迟槽中指令的下一条指令的地址。

其实也很好理解,因为在跳转之前,延迟槽指令已经执行过一次了,我们当然不希望使用 jr 指令返回之后,延迟槽指令再被执行一次,所以当然要从延迟槽指令的下一条指令开始执行了!

接下来还想介绍一个比较隐蔽的变化。我们知道,在 j 指令和 jal 指令跳转地址的计算中,我们需要使用当前 PC 值的最高 4 位进行补位。实际上,我们无论是用什么软件模拟 CPU ,都很难能够达到这么高的指令地址,所以 PC 值的最高 4 位一般总是 0x0 ,不会出现任何问题。

不过,开启延迟槽恰好会导致 PC 值的计算发生非常微小的变化。这个变化需要我们研读 MIPS 英文指令集才能够发现:

Picture Not Found!

我们关注图中的这两句话:

The remaining upper bits are the corresponding bits of the address of the instruction in the delay slot (not the branch itself).

This definition creates the following boundary case: When the jump instruction is in the last word of a 256MB region, it can branch only to the following 256 MB region containing the branch delay slot.

jal 指令的说明中也有这两句话,这里就不放图片了)

第一句话表明了,当开启延迟槽时,j 指令和 jal 指令的跳转地址计算中,PC 值的最高 4 位来自延迟槽中指令的 PC 值,而非 j 指令和 jal 指令本身的 PC 值。

第二句话指出了可能会出现的一个小问题,我们考虑这样一种情况:当 j 指令或 jal 指令的地址是 0x0ffffffc 时,延迟槽指令的地址就会是 0x10000000 ,两条指令的 PC 值最高 4 位并不相同。我们可能会以为跳转地址的范围应该是 0x000000000x0ffffffc ,但是实际上并非如此,跳转地址的最高 4 位应该从延迟槽指令中取得,也就是 0x100000000x1ffffffc

在接下来要实现的五级流水线 CPU 中,我们需要开启延迟槽,当然这也会为我们的电路带来一些细小的改变。不过可千万不要认为延迟槽是为了增加实现难度而故意设置的挑战,其实恰恰相反,延迟槽是为了方便我们的设计(同时也是为了增加 CPU 效率)才提出的!随着我们深入了解五级流水线的设计,我相信你就能够理解为什么延迟槽是个好东西了!

5.2 五级流水线 CPU 的基本构造

在这一节中,我们将通过 Logisim 中的简化电路来了解五级流水线 CPU 的基本构造。不过我们目前的单周期 CPU 就已经挤成一坨了,为了便于观看,接下来的图片将会省略部分不影响理解的 Controller 的选择接口,以及 clk 和 reset 接口。下面是省略了部分接口的单周期 CPU ,你能根据这幅图片理清指令的运行逻辑吗:

Picture Not Found!

【五级流水线的划分】

在上一节我们了解到,我们需要将指令执行流程的各个步骤分离出来,形成 5 个不同的功能区,于是我们将电路图划分成这样:

Picture Not Found!

看似非常奇葩的划分方法,接下来我们来按顺序分析一下:

从左往右第一个红框是第一个功能区,我们称之为 F 级。F 级包括 PC 、NPC 、IM 三个模块,作用是根据当前的 PC 值取出对应的指令,当然也是每条指令执行的起点。

第二个红框(不包括蓝框中的部分)是第二个功能区,我们称之为 D 级。可以看到它被蓝框抠掉了一大部分,剩下了半个 GRF 模块和一个 EXT 模块。它的作用是根据 A1 A2 接口的寄存器编号,从 GRF 中取出相应的寄存器值 RD1RD2 ;以及将 16 位立即数 imm16 拓展为 32 位,供下个功能区的 ALU 模块使用。

第三个红框和第四个红框分别是第三个功能区和第四个功能区,分别称之为 E 级和 M 级。两个功能区都只有一个模块,E 级的 ALU 模块负责计算,而 M 级的 DM 模块负责读写内存,非常简单朴素的两个功能区(但愿以后还是这样)。

最后就是奇形怪状的蓝框了,它就是第五个功能区,称为 W 级,负责将数据写回 GRF 模块中 A3 对应的寄存器。为了方便观看,我们在接下来的图片中将 W 级从 D 级中分离出来,放置在 M 级的下游:

Picture Not Found!

需要格外注意的是,图中的 GRF 模块看似分裂成了两块,实际上它们还是同一个模块,这里只是为了观看方便而进行的艺术化处理!!!

【流水线寄存器】

目前我们已经将我们的 CPU 分为了五个不同的功能区,然而当前的电路依然只能够满足一条指令在其中执行。为了让五个功能区能够分别执行自己的指令,我们需要在每两个功能区之间加上一个超大型寄存器,负责在每个时钟上升沿将上一级输出的所有内容原封不动流水到下一级,这样就能够实现每级一条指令的需求:

Picture Not Found!

以防你没有理解流水线寄存器的内部结构,其实就是为每根接入的导线用寄存器延迟一下,像下图这样,非常简单:

Picture Not Found!
(这里输入端的个数和位宽都是随便画的,可以根据需要自由调整)

【适配延迟槽、CMP模块】

在本章中,我们为五级流水线 CPU 启用了延迟槽,所以当然也需要做一些适当的改造。

首先,我们需要调整一下 jal 指令和 jalr 指令存储地址的行为。根据 MIPS 指令的定义,在开启延迟槽的情况下,执行 jal 指令和 jalr 指令时不再会将 PC + 4 的值存入寄存器中,而是存入 PC + 8 ,也就是 jal 指令或 jalr 指令后第二条指令的地址。所以我们应该将随指令一起流水的地址值改为 PC + 8 :

Picture Not Found!

接下来,我们来考虑一下跳转地址,也就是 NPC 模块输出值的计算问题。如果延迟槽没有开启,那么当跳转指令处在 F 级的时候,我们就需要计算出下一条要执行的指令的地址,也就是要向 NPC 模块提供所有输入接口( PC imm16 imm26 GRF NPC_Sel zero )的值,这样才能保证我们能够及时取出下一条要执行的指令。

当延迟槽开启时,我们就不需要这么着急了!当 F 级的指令是跳转指令时,由于延迟槽的存在,下一条要执行的指令的地址一定是当前的 PC 值加 4 ,所以我们可以等到跳转指令进入 D 级再来向 NPC 模块提供输入接口的值。

当跳转指令进入 D 级时,我们就需要着手向 NPC 模块提供信息了。从上面的图片中我们可以看出,imm16 imm26GRF 接口的值,正是从 D 级传给 NPC 模块的。需要注意的是,这里所有的值由 D 级向前传递至 F 级的 NPC 模块,都不需要经过流水线寄存器(如上图所示),因为我们要实现的功能就是使 D 级的跳转指令能够实时修改 F 级中 NPC 模块的信息,如果经过流水线寄存器延迟,就没办法在下个时钟上升沿之前改变即将进入 F 级的指令的地址了!

同样的,我们还需要在跳转指令位于 D 级时,向 NPC 模块提供 PC NPC_Selzero 接口的值,不过这几位就有点不听话了,多少都整了点小活,我们挨个来看看怎么回事:

首先是 PC 接口的值,当 D 级的指令是跳转指令时,我们可以从 D 级传回当前跳转指令的 PC 值,用于计算即将跳转到的地址;但在每个周期 F 级也都会传入一个 F 级当前指令的 PC 值,用于计算非跳转情况下下一条指令的地址,也就是 PC + 4 。我们知道,如果 D 级是跳转指令,由于延迟槽的开启,F 级指令的 PC 值一定等于 D 级指令的 PC 值加 4 ,所以我们实际上并不一定需要向 NPC 模块传递 D 级指令的 PC 值,当 D 级指令为跳转指令时,我们只需要将当前 F 级指令传递的 PC 值减去 4 ,即可得到 D 级相对跳转指令的 PC 值了!

(这一段非常拗口,不过还是一定要把其中的逻辑消化到位,务必要分清 F 级 PC 值和 D 级 PC 值的区别,搞清楚每种 NPC 计算方式中,用到的 PC 值是两种中的哪一种!)

下面是 NPC 计算的完整逻辑,可以假设 F 级是任意指令(当然如果 D 级是跳转指令的话,F 级就只能是非跳转指令了),D 级分别是非跳转指令、相对跳转指令、绝对跳转指令等各种情况,根据预期跳转的结果,思考一下 NPC 的计算值为什么是这样的:

1
2
3
4
5
6
7
8
9
10
if D 级为非跳转指令 or D 级为相对跳转指令,但不满足跳转条件 then
NPC = F_PC + 4 // 此时下一条要执行的指令一定是 F 级指令的下一条指令
else if D 级为相对跳转指令,且满足跳转条件 then
// 此时的 D_PC 一定等于 F_PC - 4
NPC = D_PC + 4 + signed_ext(D_imm16 << 2) = F_PC + signed_ext(D_imm16 << 2)
else if D 级为 j 指令或 jal 指令 then
// 注意这里 PC 的最高 4 位来自延迟槽中的指令,即 F_PC
NPC = (F_PC & 0xf0000000) + (D_imm26 << 2)
else if D 级为 jr 指令或 jalr 指令 then
NPC = GRF

接下来是 NPC_Sel 接口的值,这个值需要通过 Controller 模块产生,在后续讲解 Controller 模块时我们再作研究,现在暂且先放过它。我们目前只需要记住 NPC_Sel 接口的值是在 D 级产生的,而不是 F 级就可以了。

最后是 zero 接口的值,这也是一个比较麻烦的问题,因为从上面的图片中我们可以发现,zero 的值是在 E 级的 ALU 模块中产生的。延迟槽的存在让我们可以把 NPC 的计算拖到 D 级,而 zero 竟然想再拖到 E 级,这是万万不可以的,所以我们一定要想个办法,将 zero 的计算提前到 D 级。

这是能够做到的吗?我们思考一下,发现这当然是可以做到的!事实上,在相对跳转指令的跳转条件判断中,我们一般都是使用两个寄存器的值进行比较,而这两个值在 D 级中就已经能够取到了,我们只不过是把 比较 这个过程放在了 E 级的 ALU 模块而已。所以实际上我们只需要将 比较 这个过程从 E 级的 ALU 模块中抽离出来放在 D 级,就完全可以在 D 级就得到 zero 的值了!

于是我们仿照 ALU 模块,在 D 级建立一个相似的模块 CMP ,将 ALU 模块中 zero 的逻辑移至 CMP 模块中。从此,ALU 模块的 zero 接口正式告别了历史舞台:

Picture Not Found!

【Controller模块的改造】

终于,我们要来解决一个你肯定已经发现了很久的问题,那就是刚才我们分级的时候,好像把 Controller 模块给落下了!那么 Controller 模块究竟属于哪一级呢?

我们知道,Controller 模块是区分不同指令的核心,控制着 CPU 中的每一个选择信号,这些信号分散在 CPU 的各级中,在 D 级、E 级、M 级、W 级都会用到( F 级的 NPC_Sel 接口已调任至 D 级,望周知)。

面对这种情况,我们能够很自然地想到一种方法,那就是将 Controller 放在 D 级,在 D 级产生所有选择信号,接下来让这些信号随指令一起流水,像下图这样:

Picture Not Found!

这种做法其实是一种非常主流的做法,这样实现完全没有任何问题。不过我个人觉得流水每个选择信号写起来还是太麻烦了,所以我选择另外一种做法,那就是为每级都设置一个 Controller :

Picture Not Found!
(因为我喜欢这种做法,所以之后的内容中都会以这种架构进行演示)

那么到目前为止,我们就已经完成了所有最基础的五级流水线改造。是的,我们的工作还远远没有结束,甚至可以说是才刚刚开始!接下来的部分才是五级流水线 CPU 的精髓,令往届同学放弃爱情,为之彻底疯狂的 ——

5.3 阻塞与转发

【阻塞的概念】

不知道你是否已经发现了,当前我们的 CPU 还存在着一个极其严重的结构性问题,严重到只用两条 MIPS 指令就会出现 bug ,我们来看下面这个例子:

1
2
addiu $t1, $t0, 1
addiu $t2, $t1, 1

这两条指令正确执行结果应该是 $t1 寄存器被赋值为 1 ,$t2 寄存器被赋值为 2 。可是我们将其输入到我们的 CPU 中,却发现 $t1$t2 两个寄存器的值都是 1 !这是为什么呢?

我们来将两条指令放在五级流水线中观察。当前一条指令处于 D 级时,这条指令能够正确取出 $t0 寄存器的值(为默认值 0 )。但在下一个周期,前一条指令处于 E 级,正在计算 $t1 寄存器的值时,后一条指令就已经到达 D 级,此时前一条指令还没有将计算的结果写回 $t1 寄存器中,于是后一条指令取到的 $t1 寄存器的值仍然是默认值 0 ,这就导致了 $t2 寄存器的值出现计算错误!

当然,这个问题也有一个非常简单粗暴的解决办法。既然问题在于后一条指令读寄存器时,前一条指令还没有来得及写入寄存器的值,那么我们只需要让后一条指令稍作等待,等前一条指令离开 W 级之后,再让后一条指令进入 D 级读取就可以了。这种让后面的指令等待前面的指令提供寄存器值的解决方法,就被称之为“阻塞”。

当然,如果前一条指令继续流水,后一条指令原地不动,那两条指令之间就会出现空档,此时我们可以直接清空两条指令中间的数据,其实也就相当于在两条指令中间插入了空泡指令 nop

周期 F级 D级 E级 M级 W级
T0 addiu addiu
T1 addiu nop addiu
T2 addiu nop nop addiu
T3 addiu nop nop nop addiu
T4 addiu nop nop nop

(这下知道 nop 指令有什么用了吧)

【转发的概念】

然而,如果我们只使用阻塞,每当遇到类似的情况,CPU 都会停摆 3 个周期。实际上,当我们的 CPU 运行真正的汇编程序时,这种连续多条指令使用同一个寄存器的情况是非常常见的,所以只使用阻塞来解决问题还是有点太浪费时间了。那么有没有一种能够让 CPU 不停摆的解决方法呢?答案就是“转发”!

我们还是以之前的两条指令为例,仔细思考一下整个执行过程。前一条指令真的需要到 W 级才能得到将要写入 $t1 寄存器的值吗?实际上并非如此,这个要写入的值实际上在 E 级就已经完成计算,只不过尚未存入 GRF 模块中而已。后一条指令真的需要在 D 级就拿到 $t1 寄存器的值吗?其实也并不需要,只要在 E 级进入 ALU 模块之前拿到 $t1 寄存器的值,就完全来得及正常进行计算。

于是,我们可以建立一条“紧急传送通道”,让前一条指令刚进入 M 级,后一条指令刚进入 E 级时,前一条指令就将计算出来的 outputA 值直接传给后一条指令,作为 inputA 值再次输入 ALU 模块,刚好卡出了一个完美的 timing ,在 CPU 不停摆的情况下解决了问题,这种解决方法就是“转发”:

Picture Not Found!

这里要说明几个细节。首先就是转发的过程肯定是不需要经过流水线寄存器的,我们设计转发的目的就是构建一个最高效的数据通路,当然不会在这种地方浪费一个周期。

另外就是一个经典问题,为什么我们不能在 E 级 ALU 模块计算出数据后就直接转发,而是要等到 M 级才进行转发?我们可以这样理解,在真实的 CPU 中,为了提高 CPU 的运行效率,一个时钟周期的时间大约就等于运行时间最长的一级的运行时间,而数据在模块中的运行时间,几乎占据了该级运行时间的全部,数据在主模块中导线和 MUX 的时间几乎可以忽略不计。所以如果在模块的后面开始转发,就有可能会出现在一个周期中间甚至是末尾的时候数据发生改变的情况,此时转发终点处的数据可能来不及计算完(例如图中的转发终点,新数据还需要再经过一次 ALU 模块进行计算),下个时钟上升沿就已经到来,导致没能够赶上流水,数据出现错误;而只要保证转发起点在任何模块之前,就可以认为是在一个周期的刚开始就进行了转发,于是就不会出现上面那种来不及的情况了。

(实际上这种问题只存在于时钟周期巨短无比的情况下,我们的 CPU 因为时钟周期足够长,根本不可能发生这种情况,不过我们还是要假装自己在设计真正的 CPU ,遵守一下设计规范~)

当然了,我们的 CPU 在实际执行各种指令时,会出现的问题要比刚才的例子复杂得多。理论上来说,距离在三条指令之内,前面的指令要写的寄存器和后面的指令要读的寄存器相同时,都有可能会出现寄存器的冲突问题。最严重的情况下,可能会出现连续四条指令两两之间全部冲突。但是,只要我们使用转发的思想,就能够解决绝大多数的问题!

什么是转发的思想?对于两条可能会发生冲突的指令,我们不再去考虑前一条指令什么时候需要写寄存器,后一条指令什么时候需要读寄存器,而是去考虑前一条指令什么时候产生要写入寄存器的值,以及后一条指令什么时候需要用到寄存器的值。只要这两个时间节点满足一定条件,那么两条指令之间的冲突就是可以用转发来解决的!

【转发的触发条件】

(观前警告:本书中关于转发和阻塞的计算方法,与指导书中的计算方法并不相同,请注意辨别,千万不要学串了!)

接下来我们就来研究一下,当前后两条指令满足什么条件,我们就可以使用转发来解决两者之间的冲突呢?

首先当然需要满足一些基本条件。比如:前一条指令有要写的寄存器;后一条指令有要读的寄存器(如果有两个要读寄存器,分别计算即可);前一条指令要写的寄存器和后一条指令要读的寄存器相同;这个相同的寄存器不能是 0 号寄存器。这些前提条件看似极其显然,但在设计电路时万万不可或缺,否则就会出现胡乱转发的现象!(尤其是 0 号寄存器那条,让我回忆起新主楼通宵 debug 的夜晚,悲)

接下来就是一些奇妙的小计算。我们从直观上来想,如果前一条指令要写入寄存器的值产生得比较早,后一条指令要用到寄存器的值的时间比较晚,那么我们就能够及时地进行转发,卡上这个 timing ;如果前一条指令要写入寄存器的值产生得非常晚,后一条指令要用到寄存器的值的时间又很早,那就有可能赶不上转发,这时候就需要另想别的办法了。

在接下来的计算中,我们令 D 级、E 级、M 级、W 级分别为 0 、1 、2 、3 。

设前一条指令产生要写入寄存器的值的流水线级为 $t$(注意这里指的是可以进行转发的级别),例如 addiu 指令在 M 级可以进行转发,于是 addiu 指令的 $t$ 值为 2 ;

设后一条指令要用到 rs 寄存器的值的流水线级为 $t_{rs}$ ,要用到 rt 寄存器的值的流水线级为 $t_{rt}$ ,例如 addiu 指令在 E 级会用到 rs 寄存器的值,所以 $t_{rs}$ 的值为 1 ,addiu 指令不会用到 rt 寄存器的值(这里指不会读取 rt 寄存器),所以 $t_{rt}$ 的值不存在,不需要参与计算;

设两条指令所在级别的差为 $\Delta t$ ,例如两条相邻指令的 $\Delta t$ 值为 1 。

根据这些值,你能否给出满足转发条件的不等式呢?答案如下:

$$ t_{rs} \geq t - \Delta t,~~~ t_{rt} \geq t - \Delta t $$

其实推导过程也非常简单,看你能不能转过这个弯:对于前一条指令,当它刚好能够产生要写入的寄存器的值时,它所在的流水线级为 $t$ ,此时后一条指令所在的流水线级应该是 $t - \Delta t$ 。当这个流水线级小于等于 $t_{rs}$ 时,就来得及向 rs 寄存器的值进行转发;当这个流水线级小于等于 $t_{rt}$ 时,就来得及向 rt 寄存器的值进行转发。这好像是个洛伦兹变换

然而,在我们的 CPU 实际运行时,处于 D 级到 W 级的四条指令,两两之间都需要计算 $\Delta t$ ,写起来会十分复杂,于是我们将两指令之间的距离 $\Delta t$ 转换为 前一条指令到 D 级的距离 与 后一条指令到 D 级的距离 的差,即 $\Delta t = t_1 - t_2$ ,于是我们将不等式改为以下形式:

$$ t_{rs} - t_2 \geq t - t_1,~~~ t_{rt} - t_2 \geq t - t_1 $$

最后,我们设 $t_{rs} - t_2$ 为 $t_{rsuse}$ ,$t_{rt} - t_2$ 为 $t_{rtuse}$ ,$t - t_1$ 为 $t_{new}$ ,即可得到我们最终的计算公式:

$$ t_{rsuse} \geq t_{new},~~~ t_{rtuse} \geq t_{new} $$

非常简洁,有一种大道至简的美感~

接下来我们来分析一下具体的计算流程。我们的计算过程中最关键的指标是 $t_{rs}$ 、$t_{rt}$ 和 $t$ ,它们都是定值,不随指令的流水发生任何改变。

对于需要进入 CMP 模块进行比较的相对跳转指令,它们在 D 级就需要用到 rs 和 rt 寄存器的值,于是它们的 $t_{rs}$ 和 $t_{rt}$ 值均等于 0 ;对于 jrjalr 这些需要用到 rs 寄存器的值作为跳转地址的指令,它们需要在 D 级向 NPC 模块提供 rs 寄存器的值,于是它们的 $t_{rs}$ 值也为 0 ;对于需要在 E 级进入 ALU 模块计算的指令,参与计算的寄存器的 $t_{rs}$ 或 $t_{rt}$ 值为 1 ;对于 sw 等写内存指令,它们的 rt 寄存器值不参与 ALU 模块的运算,在 M 级存入 DM 模块时才会使用到,于是这些指令的 $t_{rt}$ 值为 2 。

在写寄存器的指令中,写入值由 ALU 模块计算产生的指令,在 M 级能够进行转发,于是它们的 $t$ 值为 2 ;写入值从 DM 模块中读取产生的指令,在 W 级能够进行转发,于是它们的 $t$ 值为 3 ;特殊地jaljalr 指令的写入值 PC + 8 从一开始就已经产生,于是它们的 $t$ 值为 0 。

下面是记录了目前所有指令 $t_{rs}$ 、$t_{rt}$ 和 $t$ 值的表格,你也可以尝试着先自己完成这幅表格,再与答案对照,加深自己的理解:

指令 addu subu addiu xori lui lw sw beq bne j jal jr jalr
$t_{rs}$ 1 1 1 1 X 1 1 0 0 X X 0 0
$t_{rt}$ 1 1 X X X X 2 0 0 X X X X
$t$ 2 2 2 2 2 3 X X X X 0 X 0

( X 表示相应的值不存在)

接下来,我们在计算 $t_{rsuse}$ 、$t_{rtuse}$ 和 $t_{new}$ 值时,只需要将 $t_{rs}$ 、$t_{rt}$ 和 $t$ 值减去指令对应的流水线级即可。为了防止 $t_{rs}$ 、$t_{rt}$ 、$t$ 值出现负数可能带来的莫名其妙的情况,我们规定若计算出的 $t_{rs}$ 、$t_{rt}$ 、$t$ 值小于 0 ,则直接记为 0 。这里我们不对这条规则的正确性作严格推导,因为它的推导需要用到后续阻塞部分的知识,我们目前只需要记住这条规则即可。

举个例子,对于 addiu 指令,它在 D 级的 $t_{rsuse}$ 值为 1 ,$t_{new}$ 值为 2 ;在 E 级的 $t_{rsuse}$ 值为 0 ,$t_{new}$ 值为 1 ;在 M 级的 $t_{rsuse}$ 值为 0 ,$t_{new}$ 值为 0 ;在 W 级的 $t_{rsuse}$ 值为 0 ,$t_{new}$ 值为 0 。整体呈现单调递减的趋势。

本部分的最后一个问题,对于两条处于 D 级到 W 级的指令,只要它们满足寄存器相同等基础条件,以及 $t_{rsuse} \geq t_{new}$ 或 $t_{rtuse} \geq t_{new}$ 的条件,就一定会发生转发吗?

理论上来说,只有当前面的指令的 $t_{new}$ 值等于 0 时,我们才需要进行转发;当前面的指令的 $t_{new}$ 值大于 0 时,这条指令还没有产生要写入的值,无法提供正确的转发值。

不过,在实际编写过程中,我们往往忽略该条件,无论前面的指令的 $t_{new}$ 是否为 0 都进行转发。这是因为,虽然此时的转发值大概率是个错误的值,但我们能保证这个错误的值不会被使用,且最终前面的指令一定能向后面的指令转发正确的值!

这是因为 前面的指令的 $t_{new}$ 值 和 后面的指令的 $t_{rsuse}$ 值(或 $t_{rtuse}$ 值,接下来统称为 $t_{use}$ 值)都在以每周期减 1 的速度减小。因为 $t_{use} \geq t_{new}$ ,所以当 $t_{new}$ 减小为 0 之前,$t_{use}$ 一定大于 0 ,于是就保证了错误的转发值不会被使用;另外,当 $t_{new}$ 减小到 0 时,$t_{use} \geq t_{new}$ 依然成立,所以能够保证最终一定能够进行正确的转发。

【转发的数据通路】

接下来,我们再来分析一下转发的数据通路,也就是来探究一下所有转发情况的起点和终点:

(提前公布一下答案,转发一共有 15 种不同的数据通路,你能将它们全部找到吗?)

首先我们来分析一下转发数据通路的终点。已经掌握了转发思路的你一定能够想到,终点所在的位置必然是需要使用到 rs 或 rt 寄存器的值的模块之前。这样的模块有哪些呢?CMP 模块和 ALU 模块的 inputAinputB 接口可能会用到 rs 和 rt 寄存器的值;当指令向内存中写入数据时,DM 模块的 WD 接口也需要用到 rt 寄存器的值,所以转发数据通路共有 5 种终点。

接下来是转发数据通路的起点。我们需要在每一级的开端,为每一条可能含有写入寄存器的值的导线创建转发数据通路。在之前我们找到了 ALU 模块的 outputA 端口,它的值可以在 M 级作为转发的起点。除此之外还有 GRF 的 WD 端口,即将写入 GRF 的值也可以在 W 级作为转发的起点。

我们已经找到了所有的数据通路了……吗?如果你认为你已经找全了,那你就要失去爱情了!说起为寄存器赋值,我们第一时间想到的就是 GRF -> ALU -> (DM) -> GRF 这条经典的数据通路,不过实际上还有另一条隐藏路线,作为全图中最长的一根导线大隐隐于市,它就是 PC + 8 !这条线路不经过我们刚才分析的任何一个转发数据通路的起点,从 F 级出发直达 W 级,在 jal 指令中为 $ra 寄存器赋值,在 jalr 指令中为 rd 寄存器赋值,所以我们还需要在每一级为这条不起眼的导线加上转发的数据通路!

实际上 PC + 8 的值在 D 级就已经可以转发,不过也没有指令需要在 F 级就用到寄存器的值,所以我们在 D 级可以不设转发通路的起点,不过在 E 级和 M 级都需要设置。事实上,我们在 W 级也可以不需要设置起点,因为此时 PC + 8 的值已经与经典数据通路的值汇合到了一起,不需要单独进行转发了。

综上所述,我们一共找到了 4 个转发数据通路的起点,5 个转发数据通路的终点,除去起点和终点在同一级,或者起点在终点后面的流水线级等不可能出现的情况,一共有下面 15 种转发数据通路:

Picture Not Found!

需要注意的是,我们的 CPU 有可能会出现两条指令同时向同一个寄存器转发的情况,例如下面三条指令:

1
2
3
lw $t1, 0($t0)
addiu $t1, $t0, 1
ori $t2, $t1, 1

lw 指令处于 W 级,addiu 指令处于 M 级,ori 指令处于 E 级时,lw 指令和 addiu 指令会同时向 ori 指令转发 $t1 寄存器的数据。此时,我们需要保证 ori 指令接收到的数据一定要是最新的,也就是 addiu 指令提供的数据。当一个转发数据通路的终点对应多个起点时,我们一定要注意它们之间的优先级关系!

【阻塞的触发条件】

在解决寄存器冲突问题时,我们研究了 $t_{rsuse} \geq t_{new}$ 和 $t_{rtuse} \geq t_{new}$ 的情况,此时可以转发进行处理;但当任何两级之间出现 $t_{rsuse} < t_{new}$ 或 $t_{rtuse} < t_{new}$ 的情况时,我们就无法通过及时转发来保证 CPU 完全不停摆了。为了保证指令执行的绝对正确性,我们只能稍微作出一些牺牲,暂时对我们的 CPU 进行阻塞。

在熟悉了转发条件的计算之后,我们能够非常简单地证明:在某个时刻,若两条指令满足 $t_{use} \geq t_{new}$ 的关系,那么在之后的任何时刻,这两条指令都一定满足 $t_{use} \geq t_{new}$ 的关系。

那么根据这个结论,我们就可以用反证法证明:在某个时刻,若两条指令满足 $t_{use} < t_{new}$ 的关系,则它们从一开始就应该满足 $t_{use} < t_{new}$ 的关系。

能理解其中的数学逻辑吗?没有理解也不要紧,其实我们只需要知道一个事实,那就是:我们只需要在后一条指令位于 D 级时,判断其是否需要阻塞即可!

于是,我们将 D 级指令的 $t_{rsuse}$ 和 $t_{rtuse}$ 分别与 E 级、M 级、W 级指令的 $t_{new}$ 进行比较,若 6 个比较中的任何一个满足 $t_{use} < t_{new}$ ,则需要对 D 级进行一个周期的阻塞。例如下面的两条指令:

1
2
lw $t1, 0($t0)
addiu $t2, $t1, 1

lw 指令运行至 E 级,addiu 指令运行至 D 级时,lw 指令的 $t_{new}$ 值为 2 ,而 addiu 指令的 $t_{rsuse}$ 值为 1 ,于是 $t_{rsuse} < t_{new}$ ,触发阻塞,lw 指令继续流水进入 M 级,而 addiu 指令停止在 D 级不动,在 E 级插入空泡指令 nop

在下一个周期,lw 指令运行至 M 级,addiu 指令依然位于 D 级,此时 lw 指令的 $t_{new}$ 值为 1 ,addiu 指令的 $t_{rsuse}$ 值为 1 ,于是 $t_{rsuse} \geq t_{new}$ ,即可进行转发。

同样是阻塞的方法,这种与转发衔接的阻塞不需要 CPU 停摆 3 个周期才能继续运行,只要阻塞到满足转发条件,直接进行转发即可。刚才的例子中只需要阻塞一个周期,当然也有需要连续阻塞两个周期的情况:

1
2
lw $t1, 0($t0)
beq $t1, $t2, branch

lw 指令运行至 E 级,beq 指令运行至 D 级时,lw 指令的 $t_{new}$ 值为 2 ,而 beq 指令的 $t_{rsuse}$ 值为 0 ,所以需要连续阻塞两个周期,等到 lw 指令进入 W 级,$t_{new}$ 值为 0 时才可以进行转发。

【阻塞的实现细节】

接下来我们来分析一下这种阻塞的具体实现方法。当我们判断出需要将 D 级指令阻塞时,该如何操作各个模块和寄存器,使得能够实现阻塞的效果呢?

首先,我们需要确保阻塞期间的 PC 值不变。为了达到 CPU 停摆的效果,我们应当在下一个始终上升沿禁止 NPC 值写入 PC 模块,使 PC 值不会发生改变。所以我们需要为 PC 模块加入写使能接口 en ,当出现阻塞信号关闭 PC 模块的写使能。

另外,我们其实还需要保证在阻塞期间,NPC 值能够被正常计算。不过因为在阻塞期间,D 级的所有数据以及 F 级的 PC 值本来就不会发生任何变化,所以我们不需要对 NPC 模块进行改动。不过在之后涉及到清除 D 级指令时,我们就需要格外注意这个问题了!

接下来,我们还需要保证 D 级的所有数据不变。和 PC 模块相同,我们只需要为 FDreg 流水线寄存器加入写使能 en ,出现阻塞信号时关闭写使能即可。

然后是为 E 级注入空泡 nop 指令。如果像之前一样为 DEreg 流水线寄存器关闭写使能,这种做法肯定是行不通的,因为此时 E 级指令就会像 D 级指令一样留存在 E 级中。此时 DEreg 流水线寄存器真正需要的,是一个新的清除信号 clear ,当出现阻塞信号时,清除信号 clear 开启,将 DEreg 寄存器中的所有数据全部清空,这样就相当于在 E 级注入了一个新的 nop 指令。

最后是 M 级和 W 级,我们不需要对 EMreg 和 MWreg 两个流水线寄存器做任何更改,让 E 级和 M 级的指令自然流水到下一级即可。

到目前为止,我们终于学完了所有的五级流水线改造,从最基本的结构改造,到寄存器冲突的处理,我们的思维已经走了很远很远。现在是时候运用我们掌握的理论,开始用 Verilog 语言动手实践了!

5.4 使用 Verilog 模拟五级流水线 CPU

在上一节,我们通过简化的 Logisim 电路图,了解了将单周期 CPU 改造成五级流水线 CPU 的设计思路。在这一节,我们就来动手操作,在 Verilog 中实现这个精妙的系统!

首先,我们来实现 FDreg 、DEreg 、EMreg 、MWreg 这四个流水线寄存器,它们的具体实现思路非常简单,不过千万不要大意,一定要将需要流水的所有端口写全,具体可以参照这张完全体的电路图中流水线寄存器两侧的端口:

Picture Not Found!

另外,我们不要忘了阻塞对流水线寄存器的影响,所以我们需要为相应的寄存器加入写使能信号 en 和清空信号 clear(为了增强拓展性,这里将所有的流水线寄存器都加上了这两个信号,虽然在目前的实现中有些信号完全不会发生改变):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
module FDreg(
input clk,
input reset,
input FD_en,
input FD_clear,
input [31:0] F_Instr,
input [31:0] F_PCplus8, // 注意这里是 pcplus8 ,而不再是 pcplus4
input [31:0] F_PC, // 为了拓展性,这里额外再流水一个 PC 值
// 这里寄存器的输出端直接与模块的输出端口相连,为了方便使用了 output reg
output reg [31:0] D_Instr,
output reg [31:0] D_PCplus8,
output reg [31:0] D_PC
);

always@(posedge clk) begin
if (reset | FD_clear) begin
D_Instr <= 32'h00000000;
D_PCplus8 <= 32'h00000000;
D_PC <= 32'h00000000;
end
else begin
if (FD_en) begin
D_Instr <= F_Instr;
D_PCplus8 <= F_PCplus8;
D_PC <= F_PC;
end
end
end

endmodule
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
module DEreg(
input clk,
input reset,
input DE_en,
input DE_clear,
input [31:0] D_Instr,
input [31:0] D_PCplus8,
input [31:0] D_PC,
input [31:0] D_RD1,
input [31:0] D_RD2,
input [4:0] D_A3,
input [31:0] D_ext32,
output reg [31:0] E_Instr,
output reg [31:0] E_PCplus8,
output reg [31:0] E_PC,
output reg [31:0] E_RD1,
output reg [31:0] E_RD2,
output reg [4:0] E_A3,
output reg [31:0] E_ext32
);

always@(posedge clk) begin
if (reset | DE_clear) begin
E_Instr <= 32'h00000000;
E_PCplus8 <= 32'h00000000;
E_PC <= 32'h00000000;
E_RD1 <= 32'h00000000;
E_RD2 <= 32'h00000000;
E_A3 <= 5'b00000;
E_ext32 <= 32'h00000000;
end
else begin
if (DE_en) begin
E_Instr <= D_Instr;
E_PCplus8 <= D_PCplus8;
E_PC <= D_PC;
E_RD1 <= D_RD1;
E_RD2 <= D_RD2;
E_A3 <= D_A3;
E_ext32 <= D_ext32;
end
end
end

endmodule
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
module EMreg(
input clk,
input reset,
input EM_en,
input EM_clear,
input [31:0] E_Instr,
input [31:0] E_PCplus8,
input [31:0] E_PC,
input [31:0] E_outputA,
input [31:0] E_RD2,
input [4:0] E_A3,
output reg [31:0] M_Instr,
output reg [31:0] M_PCplus8,
output reg [31:0] M_PC,
output reg [31:0] M_outputA,
output reg [31:0] M_RD2,
output reg [4:0] M_A3
);

always@(posedge clk) begin
if (reset | EM_clear) begin
M_Instr <= 32'h00000000;
M_PCplus8 <= 32'h00000000;
M_PC <= 32'h00000000;
M_outputA <= 32'h00000000;
M_RD2 <= 32'h00000000;
M_A3 <= 5'b00000;
end
else begin
if (EM_en) begin
M_Instr <= E_Instr;
M_PCplus8 <= E_PCplus8;
M_PC <= E_PC;
M_outputA <= E_outputA;
M_RD2 <= E_RD2;
M_A3 <= E_A3;
end
end
end

endmodule
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
module MWreg(
input clk,
input reset,
input MW_en,
input MW_clear,
input [31:0] M_Instr,
input [31:0] M_PCplus8,
input [31:0] M_PC,
input [4:0] M_A3,
input [31:0] M_outputA,
input [31:0] M_data,
output reg [31:0] W_Instr,
output reg [31:0] W_PCplus8,
output reg [31:0] W_PC,
output reg [4:0] W_A3,
output reg [31:0] W_outputA,
output reg [31:0] W_data
);

always@(posedge clk) begin
if (reset | MW_clear) begin
W_Instr <= 32'h00000000;
W_PCplus8 <= 32'h00000000;
W_PC <= 32'h00000000;
W_A3 <= 5'b00000;
W_outputA <= 32'h00000000;
W_data <= 32'h00000000;
end
else begin
if (MW_en) begin
W_Instr <= M_Instr;
W_PCplus8 <= M_PCplus8;
W_PC <= M_PC;
W_A3 <= M_A3;
W_outputA <= M_outputA;
W_data <= M_data;
end
end
end

endmodule

同样,我们也需要为 PC 模块加入使能信号 en

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
module PC(
input clk,
input reset,
input PC_en,
input [31:0] NPC,
output [31:0] PC
);

reg [31:0] regPC;

always@(posedge clk) begin
if (reset) begin
regPC <= 32'h00003000;
end
else begin
if (PC_en) begin
regPC <= NPC;
end
end
end

assign PC = regPC;

endmodule

接下来,我们对 NPC 模块的计算逻辑进行修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
module NPC(
input [31:0] F_PC, // 为了防止混淆,这里标注出传入的 PC 值为 F 级 PC 值
input [15:0] imm16,
input [25:0] imm26,
input [31:0] GRF,
input [1:0] NPC_Sel,
input zero,
output [31:0] NPC
);

parameter PCPLUS4 = 2'b00,
IMM26 = 2'b01,
GRFconst = 2'b10,
IMM16 = 2'b11;

wire [31:0] sign_ext;
wire [31:0] NPC_PCplus4;
wire [31:0] NPC_imm16;
wire [31:0] NPC_imm26;
wire [31:0] NPC_GRF;

assign sign_ext = {{14{imm16[15]}}, imm16, 2'b00};
assign NPC_PCplus4 = F_PC + 32'h00000004;
assign NPC_imm16 = F_PC + sign_ext; // 注意这里不再有 + 4
assign NPC_imm26 = {F_PC[31:28], imm26, 2'b00};
assign NPC_GRF = GRF;

assign NPC = (NPC_Sel == PCPLUS4) ? NPC_PCplus4 :
(NPC_Sel == IMM26) ? NPC_imm26 :
(NPC_Sel == GRFconst) ? NPC_GRF :
(NPC_Sel == IMM16 && zero) ? NPC_imm16 :
NPC_PCplus4; // NPC_Sel == IMM16 && !zero

endmodule

再接下来,我们将 ALU 模块中 zero 的判断正式移交给 CMP 模块,实现跳转判断的前移:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
module ALU(
input [31:0] inputA,
input [31:0] inputB,
input [5:0] type,
output [31:0] outputA
);

// 这里可以省略一些不需要进入 ALU 计算的指令
parameter ADDU = 6'b000000,
SUBU = 6'b000001,
ADDIU = 6'b000010,
XORI = 6'b000011,
LUI = 6'b000100,
LW = 6'b000101,
SW = 6'b000110;

wire [31:0] AaddB;
wire [31:0] AsubB;
wire [31:0] AxorB;
wire [31:0] Bleftshift16;
wire [31:0] none;

assign AaddB = (inputA + inputB);
assign AsubB = (inputA - inputB);
assign AxorB = (inputA ^ inputB);
assign Bleftshift16 = (inputB << 16);
assign none = 32'h00000000;

assign outputA = (type == ADDU) ? AaddB :
(type == SUBU) ? AsubB :
(type == ADDIU) ? AaddB :
(type == XORI) ? AxorB :
(type == LUI) ? Bleftshift16 :
(type == LW) ? AaddB :
(type == SW) ? AaddB : none;

endmodule
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module CMP(
input [31:0] inputA,
input [31:0] inputB,
input [5:0] type,
output zero
);

// 这里可以省略一些不需要进入 CMP 计算的指令
parameter BEQ = 6'b000111,
BNE = 6'b001000;

assign zero = ((type == BEQ) && (inputA == inputB)) ? 1'b1 :
((type == BNE) && (inputA != inputB)) ? 1'b1 : 1'b0;

endmodule

下一步就是对 Controller 模块的改造了!虽然我们确定了采取 D 级、E 级、M 级、W 级各一个 Controller 模块的策略,但同一种策略也有很多种不同的写法。在这里我们选取一种最便于上机加指令的写法,即 DController 、EController 、MController 、WController 全部引用同一个 Controller ,模块接口和内部结构完全相同,由主模块实现 $t_{rsuse}$ 、$t_{rtuse}$ 和 $t_{new}$ 的计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
module Controller(
input [5:0] OpCode,
input [5:0] Funct,
output [1:0] NPC_Sel,
output RegWrite,
output EXTop,
output ALUsrc,
output RegDst,
output MemWrite,
output MemtoReg,
output PCtoReg,
output Regra,
output [5:0] type,
output [3:0] t_rs,
output [3:0] t_rt,
output [3:0] t
);

// 这里的定义一定要和 ALU 、CMP 中的定义保持一致!
parameter ADDU = 6'b000000,
SUBU = 6'b000001,
ADDIU = 6'b000010,
XORI = 6'b000011,
LUI = 6'b000100,
LW = 6'b000101,
SW = 6'b000110,
BEQ = 6'b000111,
BNE = 6'b001000,
J = 6'b001001,
JAL = 6'b001010,
JR = 6'b001011,
JALR = 6'b001100;

wire addu;
wire subu;
wire addiu;
wire xori;
wire lui;
wire lw;
wire sw;
wire beq;
wire bne;
wire j;
wire jal;
wire jr;
wire jalr;

assign addu = (OpCode == 6'b000000 && Funct == 6'b100001) ? 1'b1 : 1'b0;
assign subu = (OpCode == 6'b000000 && Funct == 6'b100011) ? 1'b1 : 1'b0;
assign addiu = (OpCode == 6'b001001) ? 1'b1 : 1'b0;
assign xori = (OpCode == 6'b001110) ? 1'b1 : 1'b0;
assign lui = (OpCode == 6'b001111) ? 1'b1 : 1'b0;
assign lw = (OpCode == 6'b100011) ? 1'b1 : 1'b0;
assign sw = (OpCode == 6'b101011) ? 1'b1 : 1'b0;
assign beq = (OpCode == 6'b000100) ? 1'b1 : 1'b0;
assign bne = (OpCode == 6'b000101) ? 1'b1 : 1'b0;
assign j = (OpCode == 6'b000010) ? 1'b1 : 1'b0;
assign jal = (OpCode == 6'b000011) ? 1'b1 : 1'b0;
assign jr = (OpCode == 6'b000000 && Funct == 6'b001000) ? 1'b1 : 1'b0;
assign jalr = (OpCode == 6'b000000 && Funct == 6'b001001) ? 1'b1 : 1'b0;

// 这里 NPC_Sel 的值一定要和 NPC 中的定义保持一致!
assign NPC_Sel = (j | jal) ? 2'b01 :
(jr | jalr) ? 2'b10 :
(beq | bne) ? 2'b11 : 2'b00;
assign RegWrite = (addu | subu | addiu | xori | lui | lw | jal | jalr) ? 1'b1 : 1'b0;
assign EXTop = (addiu | lw | sw) ? 1'b1 : 1'b0;
assign ALUsrc = (addiu | xori | lui | lw | sw) ? 1'b1 : 1'b0;
assign RegDst = (addu | subu | jalr) ? 1'b1 : 1'b0;
assign MemWrite = (sw) ? 1'b1 : 1'b0;
assign MemtoReg = (lw) ? 1'b1 : 1'b0;
assign PCtoReg = (jal | jalr) ? 1'b1 : 1'b0;
assign Regra = (jal) ? 1'b1 : 1'b0;
// 这里将全 1 当作错误或未实现指令的输出(包括 nop 指令)
assign type = (addu) ? ADDU :
(subu) ? SUBU :
(addiu) ? ADDIU :
(xori) ? XORI :
(lui) ? LUI :
(lw) ? LW :
(sw) ? SW :
(beq) ? BEQ :
(bne) ? BNE :
(j) ? J :
(jal) ? JAL :
(jr) ? JR :
(jalr) ? JALR : 6'b111111;

// 这里的 t_rs, t_rt 和 t 的定义与之前推导中的定义相同,分别等于 D 级指令的 t_rsuse, t_rtuse 和 t_new
// 这里将全 1 当作该值不存在或未实现指令的输出(包括 nop 指令)
assign t_rs = (addu) ? 4'h1 :
(subu) ? 4'h1 :
(addiu) ? 4'h1 :
(xori) ? 4'h1 :
(lui) ? 4'hf :
(lw) ? 4'h1 :
(sw) ? 4'h1 :
(beq) ? 4'h0 :
(bne) ? 4'h0 :
(j) ? 4'hf :
(jal) ? 4'hf :
(jr) ? 4'h0 :
(jalr) ? 4'h0 : 4'hf;

assign t_rt = (addu) ? 4'h1 :
(subu) ? 4'h1 :
(addiu) ? 4'hf :
(xori) ? 4'hf :
(lui) ? 4'hf :
(lw) ? 4'hf :
(sw) ? 4'h2 :
(beq) ? 4'h0 :
(bne) ? 4'h0 :
(j) ? 4'hf :
(jal) ? 4'hf :
(jr) ? 4'hf :
(jalr) ? 4'hf : 4'hf;

assign t = (addu) ? 4'h2 :
(subu) ? 4'h2 :
(addiu) ? 4'h2 :
(xori) ? 4'h2 :
(lui) ? 4'h2 :
(lw) ? 4'h3 :
(sw) ? 4'hf :
(beq) ? 4'hf :
(bne) ? 4'hf :
(j) ? 4'hf :
(jal) ? 4'h0 :
(jr) ? 4'hf :
(jalr) ? 4'h0 : 4'hf;

endmodule

最后,我们来对主模块进行大刀阔斧的修改!首先,我们将主模块中的所有导线按流水线级进行拆分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
module mips(
input clk,
input reset
);

// F zone ////////////////////////////////
// PC and NPC
wire [31:0] F_PC;
wire [31:0] F_NPC;
wire [31:0] F_PCplus8; // 注意这里变为了 PCplus8

// IM
wire [31:0] F_Instr;

// D zone ////////////////////////////////
// D Controller
wire D_RegDst;
wire [1:0] D_NPC_Sel;
wire D_EXTop;
wire D_Regra;
wire [5:0] D_type;
wire [3:0] D_t_rs;
wire [3:0] D_t_rt;
wire [3:0] D_t;

// D Instruction
// 为了进行转发和阻塞的计算,每一级都需要解析一次指令
wire [31:0] D_Instr;
wire [5:0] D_OpCode;
wire [4:0] D_rs;
wire [4:0] D_rt;
wire [4:0] D_rd;
wire [4:0] D_shamt;
wire [5:0] D_Funct;
wire [15:0] D_imm16;
wire [25:0] D_imm26;

// GRF(R)
wire [4:0] D_A1;
wire [4:0] D_A2;
wire [4:0] D_A3;
wire [31:0] D_RD1;
wire [31:0] D_RD2;

// EXT
wire [31:0] D_ext32;

// CMP
wire D_zero;

// pipeline
// 从 F 级流水进入 D 级的数据
wire [31:0] D_PCplus8;
wire [31:0] D_PC;

// E zone ////////////////////////////////
// E Controller
wire E_ALUsrc;
wire [5:0] E_type;
wire [3:0] E_t_rs;
wire [3:0] E_t_rt;
wire [3:0] E_t;

// E Instruction
wire [31:0] E_Instr;
wire [5:0] E_OpCode;
wire [4:0] E_rs;
wire [4:0] E_rt;
wire [4:0] E_rd;
wire [4:0] E_shamt;
wire [5:0] E_Funct;
wire [15:0] E_imm16;
wire [25:0] E_imm26;

// ALU
wire [31:0] E_inputA;
wire [31:0] E_inputB;
wire [31:0] E_outputA;

// pipeline
// 从 D 级流水进入 E 级的数据
wire [31:0] E_PCplus8;
wire [31:0] E_PC;
wire [31:0] E_RD1;
wire [31:0] E_RD2;
wire [4:0] E_A3;
wire [31:0] E_ext32;

// M zone ////////////////////////////////
// M Controller
wire M_MemWrite;
wire [5:0] M_type;
wire [3:0] M_t_rs;
wire [3:0] M_t_rt;
wire [3:0] M_t;

// M Instruction
wire [31:0] M_Instr;
wire [5:0] M_OpCode;
wire [4:0] M_rs;
wire [4:0] M_rt;
wire [4:0] M_rd;
wire [4:0] M_shamt;
wire [5:0] M_Funct;
wire [15:0] M_imm16;
wire [25:0] M_imm26;

// DM
wire [31:0] M_addr;
wire [31:0] M_WD_Mem;
wire [31:0] M_data;

// pipeline
// 从 E 级流水进入 M 级的数据
wire [31:0] M_PCplus8;
wire [31:0] M_PC;
wire [31:0] M_outputA;
wire [31:0] M_RD2;
wire [4:0] M_A3;

// W zone ////////////////////////////////
// W Controller
wire W_MemtoReg;
wire W_RegWrite;
wire W_PCtoReg;
wire [5:0] W_type;
wire [3:0] W_t_rs;
wire [3:0] W_t_rt;
wire [3:0] W_t;

// W Instruction
wire [31:0] W_Instr;
wire [5:0] W_OpCode;
wire [4:0] W_rs;
wire [4:0] W_rt;
wire [4:0] W_rd;
wire [4:0] W_shamt;
wire [5:0] W_Funct;
wire [15:0] W_imm16;
wire [25:0] W_imm26;

// GRF(W)
wire [4:0] W_A3;
wire [31:0] W_WD_Reg;

// pipeline
// 从 M 级流水进入 W 级的数据
wire [31:0] W_PCplus8;
wire [31:0] W_PC;
wire [31:0] W_outputA;
wire [31:0] W_data;

endmodule

接下来引入转发与阻塞相关的信号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
module mips(
input clk,
input reset
);

// forward ///////////////////////////////
// fixed data
// 转发之后的修正值
wire [31:0] D_fixedRD1;
wire [31:0] D_fixedRD2;
wire [31:0] E_fixedRD1;
wire [31:0] E_fixedRD2;
wire [31:0] M_fixedRD2;

// tuse and tnew
wire [3:0] D_t_rsuse;
wire [3:0] D_t_rtuse;
wire [3:0] D_t_new;

wire [3:0] E_t_rsuse;
wire [3:0] E_t_rtuse;
wire [3:0] E_t_new;

wire [3:0] M_t_rsuse;
wire [3:0] M_t_rtuse;
wire [3:0] M_t_new;

wire [3:0] W_t_rsuse;
wire [3:0] W_t_rtuse;
wire [3:0] W_t_new;

// isPCplus8
// 判断转发的起点是否为 PC + 8
wire E_isPCplus8;
wire M_isPCplus8;

// forward
// 转发数据通路(15条)
wire D_RD1_from_E_PCplus8;
wire D_RD2_from_E_PCplus8;

wire D_RD1_from_M_PCplus8;
wire D_RD2_from_M_PCplus8;
wire E_RD1_from_M_PCplus8;
wire E_RD2_from_M_PCplus8;

wire D_RD1_from_M;
wire D_RD2_from_M;
wire E_RD1_from_M;
wire E_RD2_from_M;

wire D_RD1_from_W;
wire D_RD2_from_W;
wire E_RD1_from_W;
wire E_RD2_from_W;
wire M_RD2_from_W;

// stall /////////////////////////////////
// pipeline reg
// PC 模块和流水线寄存器控制信号
wire PC_en;
wire FD_en;
wire DE_en;
wire EM_en;
wire MW_en;

wire FD_clear;
wire DE_clear;
wire EM_clear;
wire MW_clear;

// stall
// 阻塞发生信号
wire D_stall;

endmodule

接下来导入所有模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
module mips(
input clk,
input reset
);

// F zone ////////////////////////////////
PC PC (.clk(clk), .reset(reset), .PC_en(PC_en), .NPC(F_NPC), .PC(F_PC));
// 这里注意信号处于 F 级还是 D 级
NPC NPC (.F_PC(F_PC), .NPC_Sel(D_NPC_Sel), .zero(D_zero), .imm16(D_imm16),
.imm26(D_imm26), .GRF(D_fixedRD1), .NPC(F_NPC));
IM IM (.addr(F_PC), .data(F_Instr));
FDreg FDreg (.clk(clk), .reset(reset), .FD_en(FD_en), .FD_clear(FD_clear),
.F_Instr(F_Instr), .F_PCplus8(F_PCplus8), .F_PC(F_PC),
.D_Instr(D_Instr), .D_PCplus8(D_PCplus8), .D_PC(D_PC));

// D zone ////////////////////////////////
// 这里注意不要定义一堆 Controller 模块,所有 Controller 模块引用同一个就可以
// 不需要的选择信号可以不连接对应接口
Controller DController (.OpCode(D_OpCode), .Funct(D_Funct), .RegDst(D_RegDst),
.NPC_Sel(D_NPC_Sel), .EXTop(D_EXTop), .Regra(D_Regra),
.type(D_type), .t_rs(D_t_rs), .t_rt(D_t_rt), .t(D_t));
GRF GRF (.clk(clk), .reset(reset), .WE(W_RegWrite), .A1(D_A1), .A2(D_A2),
.A3(W_A3), .WD(W_WD_Reg), .RD1(D_RD1), .RD2(D_RD2));
EXT EXT (.imm16(D_imm16), .EXTop(D_EXTop), .ext32(D_ext32));
// 这里注意连接 inputA 和 inputB 接口的是修正值 D_fixedRD1 和 D_fixedRD2
CMP CMP (.inputA(D_fixedRD1), .inputB(D_fixedRD2), .type(D_type), .zero(D_zero));
// 这里注意连接 D_RD1 和 D_RD2 接口的也是修正值 D_fixedRD1 和 D_fixedRD2
DEreg DEreg (.clk(clk), .reset(reset), .DE_en(DE_en), .DE_clear(DE_clear),
.D_Instr(D_Instr), .D_PCplus8(D_PCplus8), .D_PC(D_PC),
.D_RD1(D_fixedRD1), .D_RD2(D_fixedRD2), .D_A3(D_A3), .D_ext32(D_ext32),
.E_Instr(E_Instr), .E_PCplus8(E_PCplus8), .E_PC(E_PC),
.E_RD1(E_RD1), .E_RD2(E_RD2), .E_A3(E_A3), .E_ext32(E_ext32));

// E zone ////////////////////////////////
Controller EController (.OpCode(E_OpCode), .Funct(E_Funct), .ALUsrc(E_ALUsrc),
.type(E_type), .t_rs(E_t_rs), .t_rt(E_t_rt), .t(E_t));
ALU ALU (.inputA(E_inputA), .inputB(E_inputB), .type(E_type), .outputA(E_outputA));
// 这里注意连接 E_RD2 接口的是修正值 E_fixedRD2
EMreg EMreg (.clk(clk), .reset(reset), .EM_en(EM_en), .EM_clear(EM_clear),
.E_Instr(E_Instr), .E_PCplus8(E_PCplus8), .E_PC(E_PC),
.E_outputA(E_outputA), .E_RD2(E_fixedRD2), .E_A3(E_A3),
.M_Instr(M_Instr), .M_PCplus8(M_PCplus8), .M_PC(M_PC),
.M_outputA(M_outputA), .M_RD2(M_RD2), .M_A3(M_A3));

// M zone ////////////////////////////////
Controller MController (.OpCode(M_OpCode), .Funct(M_Funct), .MemWrite(M_MemWrite),
.type(M_type), .t_rs(M_t_rs), .t_rt(M_t_rt), .t(M_t));
DM DM (.clk(clk), .reset(reset), .WE(M_MemWrite), .addr(M_addr), .WD(M_WD_Mem),
.data(M_data));
MWreg MWreg (.clk(clk), .reset(reset), .MW_en(MW_en), .MW_clear(MW_clear),
.M_Instr(M_Instr), .M_PCplus8(M_PCplus8), .M_PC(M_PC),
.M_A3(M_A3), .M_outputA(M_outputA), .M_data(M_data),
.W_Instr(W_Instr), .W_PCplus8(W_PCplus8), .W_PC(W_PC),
.W_A3(W_A3), .W_outputA(W_outputA), .W_data(W_data));

// W zone ////////////////////////////////
Controller WController (.OpCode(W_OpCode), .Funct(W_Funct), .MemtoReg(W_MemtoReg),
.RegWrite(W_RegWrite), .PCtoReg(W_PCtoReg), .type(W_type),
.t_rs(W_t_rs), .t_rt(W_t_rt), .t(W_t));

endmodule

接下来是一些 wire 型变量的赋值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
module mips(
input clk,
input reset
);

// F zone ////////////////////////////////
assign F_PCplus8 = F_PC + 32'h00000008;

// D zone ////////////////////////////////
assign D_OpCode = D_Instr[31:26];
assign D_rs = D_Instr[25:21];
assign D_rt = D_Instr[20:16];
assign D_rd = D_Instr[15:11];
assign D_shamt = D_Instr[10:6];
assign D_Funct = D_Instr[5:0];
assign D_imm16 = D_Instr[15:0];
assign D_imm26 = D_Instr[25:0];

assign D_A1 = D_rs;
assign D_A2 = D_rt;
assign D_A3 = (D_Regra) ? 5'b11111 :
(D_RegDst) ? D_rd : D_rt;

// E zone ////////////////////////////////
assign E_OpCode = E_Instr[31:26];
assign E_rs = E_Instr[25:21];
assign E_rt = E_Instr[20:16];
assign E_rd = E_Instr[15:11];
assign E_shamt = E_Instr[10:6];
assign E_Funct = E_Instr[5:0];
assign E_imm16 = E_Instr[15:0];
assign E_imm26 = E_Instr[25:0];

// 这里还是要注意将 RD1 和 RD2 替换为修正值 E_fixedRD1 和 E_fixedRD2
assign E_inputA = E_fixedRD1;
assign E_inputB = (E_ALUsrc) ? E_ext32 : E_fixedRD2;

// M zone ////////////////////////////////
assign M_OpCode = M_Instr[31:26];
assign M_rs = M_Instr[25:21];
assign M_rt = M_Instr[20:16];
assign M_rd = M_Instr[15:11];
assign M_shamt = M_Instr[10:6];
assign M_Funct = M_Instr[5:0];
assign M_imm16 = M_Instr[15:0];
assign M_imm26 = M_Instr[25:0];

assign M_addr = M_outputA;
// 这里注意...算了我不说了,你来接
assign M_WD_Mem = M_fixedRD2;

// W zone ////////////////////////////////
assign W_OpCode = W_Instr[31:26];
assign W_rs = W_Instr[25:21];
assign W_rt = W_Instr[20:16];
assign W_rd = W_Instr[15:11];
assign W_shamt = W_Instr[10:6];
assign W_Funct = W_Instr[5:0];
assign W_imm16 = W_Instr[15:0];
assign W_imm26 = W_Instr[25:0];

assign W_WD_Reg = (W_PCtoReg) ? W_PCplus8 :
(W_MemtoReg) ? W_data : W_outputA;

endmodule

最后就是激动人心的转发与阻塞部分了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
module mips(
input clk,
input reset
);

// forward ///////////////////////////////
// fixed data
// 这里一定要注意多个转发同时发生时的优先级,即转发起点 E 级 > M 级 > W 级
assign D_fixedRD1 = (D_RD1_from_E_PCplus8) ? E_PCplus8 :
(D_RD1_from_M_PCplus8) ? M_PCplus8 :
(D_RD1_from_M) ? M_outputA :
(D_RD1_from_W) ? W_WD_Reg : D_RD1;
assign D_fixedRD2 = (D_RD2_from_E_PCplus8) ? E_PCplus8 :
(D_RD2_from_M_PCplus8) ? M_PCplus8 :
(D_RD2_from_M) ? M_outputA :
(D_RD2_from_W) ? W_WD_Reg : D_RD2;
assign E_fixedRD1 = (E_RD1_from_M_PCplus8) ? M_PCplus8 :
(E_RD1_from_M) ? M_outputA :
(E_RD1_from_W) ? W_WD_Reg : E_RD1;
assign E_fixedRD2 = (E_RD2_from_M_PCplus8) ? M_PCplus8 :
(E_RD2_from_M) ? M_outputA :
(E_RD2_from_W) ? W_WD_Reg : E_RD2;
assign M_fixedRD2 = (M_RD2_from_W) ? W_WD_Reg : M_RD2;

// tuse and tnew
assign D_t_rsuse = D_t_rs;
assign D_t_rtuse = D_t_rt;
assign D_t_new = D_t;

assign E_t_rsuse = (E_t_rs == 4'hf) ? 4'hf :
(E_t_rs >= 4'h1) ? (E_t_rs - 4'h1) : 4'h0;
assign E_t_rtuse = (E_t_rt == 4'hf) ? 4'hf :
(E_t_rt >= 4'h1) ? (E_t_rt - 4'h1) : 4'h0;
assign E_t_new = (E_t == 4'hf) ? 4'hf :
(E_t >= 4'h1) ? (E_t - 4'h1) : 4'h0;

assign M_t_rsuse = (M_t_rs == 4'hf) ? 4'hf :
(M_t_rs >= 4'h2) ? (M_t_rs - 4'h2) : 4'h0;
assign M_t_rtuse = (M_t_rt == 4'hf) ? 4'hf :
(M_t_rt >= 4'h2) ? (M_t_rt - 4'h2) : 4'h0;
assign M_t_new = (M_t == 4'hf) ? 4'hf :
(M_t >= 4'h2) ? (M_t - 4'h2) : 4'h0;

assign W_t_rsuse = (W_t_rs == 4'hf) ? 4'hf :
(W_t_rs >= 4'h3) ? (W_t_rs - 4'h3) : 4'h0;
assign W_t_rtuse = (W_t_rt == 4'hf) ? 4'hf :
(W_t_rt >= 4'h3) ? (W_t_rt - 4'h3) : 4'h0;
assign W_t_new = (W_t == 4'hf) ? 4'hf :
(W_t >= 4'h3) ? (W_t - 4'h3) : 4'h0;

// isPCplus8
// 这里直接硬编码了 jal 指令和 jalr 指令的 type
assign E_isPCplus8 = (E_type == 6'b001010) || (E_type == 6'b001100);
assign M_isPCplus8 = (M_type == 6'b001010) || (M_type == 6'b001100);

// forward
// 判断条件:1. 是否为 isPCplus8
// 2. 寄存器编号是否为 0
// 3. 转发起点和终点寄存器是否相同
// 4. t_use 值是否存在
// 5. t_new 值是否存在
// 6. t_use 是否大于等于 t_new
assign D_RD1_from_E_PCplus8 = (E_isPCplus8 && E_A3 != 5'b00000 && D_rs == E_A3 && D_t_rsuse != 4'hf && E_t_new != 4'hf && D_t_rsuse >= E_t_new) ? 1'b1 : 1'b0;
assign D_RD2_from_E_PCplus8 = (E_isPCplus8 && E_A3 != 5'b00000 && D_rt == E_A3 && D_t_rtuse != 4'hf && E_t_new != 4'hf && D_t_rtuse >= E_t_new) ? 1'b1 : 1'b0;

assign D_RD1_from_M_PCplus8 = (M_isPCplus8 && M_A3 != 5'b00000 && D_rs == M_A3 && D_t_rsuse != 4'hf && M_t_new != 4'hf && D_t_rsuse >= M_t_new) ? 1'b1 : 1'b0;
assign D_RD2_from_M_PCplus8 = (M_isPCplus8 && M_A3 != 5'b00000 && D_rt == M_A3 && D_t_rtuse != 4'hf && M_t_new != 4'hf && D_t_rtuse >= M_t_new) ? 1'b1 : 1'b0;
assign E_RD1_from_M_PCplus8 = (M_isPCplus8 && M_A3 != 5'b00000 && E_rs == M_A3 && E_t_rsuse != 4'hf && M_t_new != 4'hf && E_t_rsuse >= M_t_new) ? 1'b1 : 1'b0;
assign E_RD2_from_M_PCplus8 = (M_isPCplus8 && M_A3 != 5'b00000 && E_rt == M_A3 && E_t_rtuse != 4'hf && M_t_new != 4'hf && E_t_rtuse >= M_t_new) ? 1'b1 : 1'b0;

assign D_RD1_from_M = (~M_isPCplus8 && M_A3 != 5'b00000 && D_rs == M_A3 && D_t_rsuse != 4'hf && M_t_new != 4'hf && D_t_rsuse >= M_t_new) ? 1'b1 : 1'b0;
assign D_RD2_from_M = (~M_isPCplus8 && M_A3 != 5'b00000 && D_rt == M_A3 && D_t_rtuse != 4'hf && M_t_new != 4'hf && D_t_rtuse >= M_t_new) ? 1'b1 : 1'b0;
assign E_RD1_from_M = (~M_isPCplus8 && M_A3 != 5'b00000 && E_rs == M_A3 && E_t_rsuse != 4'hf && M_t_new != 4'hf && E_t_rsuse >= M_t_new) ? 1'b1 : 1'b0;
assign E_RD2_from_M = (~M_isPCplus8 && M_A3 != 5'b00000 && E_rt == M_A3 && E_t_rtuse != 4'hf && M_t_new != 4'hf && E_t_rtuse >= M_t_new) ? 1'b1 : 1'b0;

assign D_RD1_from_W = (W_A3 != 5'b00000 && D_rs == W_A3 && D_t_rsuse != 4'hf && W_t_new != 4'hf && D_t_rsuse >= W_t_new) ? 1'b1 : 1'b0;
assign D_RD2_from_W = (W_A3 != 5'b00000 && D_rt == W_A3 && D_t_rtuse != 4'hf && W_t_new != 4'hf && D_t_rtuse >= W_t_new) ? 1'b1 : 1'b0;
assign E_RD1_from_W = (W_A3 != 5'b00000 && E_rs == W_A3 && E_t_rsuse != 4'hf && W_t_new != 4'hf && E_t_rsuse >= W_t_new) ? 1'b1 : 1'b0;
assign E_RD2_from_W = (W_A3 != 5'b00000 && E_rt == W_A3 && E_t_rtuse != 4'hf && W_t_new != 4'hf && E_t_rtuse >= W_t_new) ? 1'b1 : 1'b0;
assign M_RD2_from_W = (W_A3 != 5'b00000 && M_rt == W_A3 && M_t_rtuse != 4'hf && W_t_new != 4'hf && M_t_rtuse >= W_t_new) ? 1'b1 : 1'b0;

// stall /////////////////////////////////
// pipeline reg
assign PC_en = (D_stall) ? 1'b0 : 1'b1;
assign FD_en = (D_stall) ? 1'b0 : 1'b1;
assign DE_en = 1'b1;
assign EM_en = 1'b1;
assign MW_en = 1'b1;

assign FD_clear = 1'b0;
assign DE_clear = (D_stall) ? 1'b1 : 1'b0;
assign EM_clear = 1'b0;
assign MW_clear = 1'b0;

// stall
// 判断条件:1. 寄存器编号是否为 0
// 2. 转发起点和终点寄存器是否相同
// 3. t_use 值是否存在
// 4. t_new 值是否存在
// 5. t_use 是否小于 t_new
assign D_stall = (E_A3 != 5'b00000 && D_rs == E_A3 && D_t_rsuse != 4'hf && E_t_new != 4'hf && D_t_rsuse < E_t_new) ? 1'b1 :
(E_A3 != 5'b00000 && D_rt == E_A3 && D_t_rtuse != 4'hf && E_t_new != 4'hf && D_t_rtuse < E_t_new) ? 1'b1 :
(M_A3 != 5'b00000 && D_rs == M_A3 && D_t_rsuse != 4'hf && M_t_new != 4'hf && D_t_rsuse < M_t_new) ? 1'b1 :
(M_A3 != 5'b00000 && D_rt == M_A3 && D_t_rtuse != 4'hf && M_t_new != 4'hf && D_t_rtuse < M_t_new) ? 1'b1 :
(W_A3 != 5'b00000 && D_rs == W_A3 && D_t_rsuse != 4'hf && W_t_new != 4'hf && D_t_rsuse < W_t_new) ? 1'b1 :
(W_A3 != 5'b00000 && D_rt == W_A3 && D_t_rtuse != 4'hf && W_t_new != 4'hf && D_t_rtuse < W_t_new) ? 1'b1 : 1'b0;

endmodule

终于终于终于!我们在 Verilog 上完成了五级流水线 CPU 的改造!这一次的任务真的说不上很轻松了,还是要稍微休息一下 —— 不过这次要记得回来,因为我们在下一节还要做一些练习,帮助你通过上机实验!

5.5 五级流水线 CPU 实战:加指令练习

这应该是本书中第一次正式拿出整整一节的篇幅讲解上机实验相关内容。大概是因为 P5 和 P6 的上机实验确实非常困难,没有事先的准备几乎一定无法通过,如果没有掌握技巧的话可能会一路挂到底。—— 挂了两回 P5 的 Kamonto 如此说道。

本节中出现的所有指令,有些是 MIPS 指令集里面真实存在的指令,有些是仿照往年助教清奇的思路虚构出来的指令。这些指令都仅用于本节中的练习,不会在后续的 CPU 中再次出现。

【计算类指令:slt】

首先,我们来从一个最简单的计算类指令 slt 开始练习,这是一个存在于 MIPS 指令集中的指令,下面是它的符号语言描述:

Code Block
 if GPR[rs] < GPR[rt] then
    GPR[rd] ← 031 || 1
 else
    GPR[rd]← 032
 endif

接下来,我们通过这条非常简单的指令,感受一下计算类指令的加指令流程。

无论是什么指令,我们加指令的第一步肯定都是在 Controller 中走固定流程。首先,我们根据题目中给出的 OpCode 和 Funct 为其编写解析逻辑:

1
2
3
4
// Controller
parameter SLT = 6'bXXXXXX;
wire slt;
assign slt = (OpCode == 6'bXXXXXX && Funct == 6'bXXXXXX) ? 1'b1 : 1'b0;

(这里的 6'bXXXXXX 根据题目要求和你的实现自行填写即可)

接下来,我们要为新指令适配所有的选择信号。事实上,对于一个普普通通的 R 型计算类指令来说,除了在 ALU 模块中的运算逻辑不同之外,它的其它所有运行逻辑都和 addu subu 等一众指令完全相同,所以我们完全可以仿照这些指令来为其添加选择信号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Controller
assign NPC_Sel = (j | jal) ? 2'b01 :
(jr | jalr) ? 2'b10 :
(beq | bne) ? 2'b11 : 2'b00;
// 只要出现 addu 和 subu ,我们就可以直接无脑跟
assign RegWrite = (addu | subu | slt | addiu | xori | lui | lw | jal | jalr) ? 1'b1 : 1'b0;
assign EXTop = (addiu | lw | sw) ? 1'b1 : 1'b0;
assign ALUsrc = (addiu | xori | lui | lw | sw) ? 1'b1 : 1'b0;
assign RegDst = (addu | subu | slt | jalr) ? 1'b1 : 1'b0;
assign MemWrite = (sw) ? 1'b1 : 1'b0;
assign MemtoReg = (lw) ? 1'b1 : 1'b0;
assign PCtoReg = (jal | jalr) ? 1'b1 : 1'b0;
assign Regra = (jal) ? 1'b1 : 1'b0;
// 不要忘了在 type 处也添加 slt 的信号
assign type = (addu) ? ADDU :
(subu) ? SUBU :
...... :
(slt) ? SLT : 6'b111111;

随后是对 t_rs t_rtt 的修改,我们同样也可以仿照 addu 指令和 subu 指令进行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Controller
// 在 E 级的 ALU 中首次使用 rs 寄存器和 rt 寄存器
assign t_rs = (addu) ? 4'h1 :
(subu) ? 4'h1 :
...... :
(slt) ? 4'h1 : 4'hf;

assign t_rt = (addu) ? 4'h1 :
(subu) ? 4'h1 :
...... :
(slt) ? 4'h1 : 4'hf;

// 在 M 级能够开始转发要写入寄存器的值
assign t = (addu) ? 4'h2 :
(subu) ? 4'h2 :
...... :
(slt) ? 4'h2 : 4'hf;

到这里,聪明的你可能已经发现了,既然上面的步骤对于每条新指令都是必不可少的,那我们其实完全可以在考试前就在代码中准备好新指令的模板,在考试时填入数据就可以了!

这时候有人可能会问,每条指令的名称都各不相同,我们难不成还能在考试之前预测到新指令的名称吗?NONONO ,仔细想想,其实我们并不一定需要使用指令的名称作为变量名,只不过这种写法比较便于维护罢了。既然只是上机时临时加的一条指令,我们想规定它叫什么名字都可以,比如我的新指令总是叫 newb ,没有为什么,就是很 newb ~

(不过我们毕竟还是在书里,接下来的指令还是用指令名作为变量名,看得会更加清晰一点)

下一步就是在 ALU 模块中添加新运算了。这一步往往是计算类指令的难点,其中的计算逻辑可能非常复杂(当然 slt 指令的计算逻辑还算是非常简单)。不过好在只需要在 ALU 模块中完成,不需要处理阻塞和转发的问题,所以在所有类型的加指令问题中,计算类指令可以说是最简单的一种了:

1
2
3
4
5
6
7
8
9
10
// ALU
parameter SLT = 6'bXXXXXX;
wire [31:0] ABslt;
// 这里一定要注意使用 $signed 进行有符号比较!
// 比较结果为真返回 1 ,为假返回 0
assign ABslt = ($signed(inputA) < $signed(inputB));
assign outputA = (type == ADDU) ? AaddB :
(type == SUBU) ? AsubB :
...... :
(type == SLT) ? ABslt : none;

最后,我们再来检查一下,slt 指令不需要跳转,所以不需要修改 NPC 模块和 CMP 模块,同时也不涉及到新的阻塞和转发问题。于是,我们就如此丝滑地完成了一个新指令的添加!可喜可贺,可喜可贺!

【更难的计算类指令:clo】

接下来,我们来挑战一条难度稍高的计算类指令 cloclo 指令也是 MIPS 指令集中存在的一条指令,它的作用是计算 rs 寄存器中的数字有多少个先导 1 ,即从最高位开始有多少个连续的 1 ,下面是它的符号表示:

Code Block
temp ← 32
for i in 31 .. 0
    if GPR[rs]i = 0 then
        temp ← 31 - i
        break
    endif
endfor
GPR[rd] ← temp

可以看到 clo 的符号表示中出现了 for 循环,这让我们隐隐有一丝后背发凉,不过我们还是先完成 Controller 模块的改造,依旧是和 addu subu 指令非常相似(除了 clo 指令不需要用到 rt 寄存器),这里就不细说了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// Controller
parameter CLO = 6'bXXXXXX;
wire clo;
assign clo = (OpCode == 6'bXXXXXX && Funct == 6'bXXXXXX) ? 1'b1 : 1'b0;

assign NPC_Sel = (j | jal) ? 2'b01 :
(jr | jalr) ? 2'b10 :
(beq | bne) ? 2'b11 : 2'b00;
assign RegWrite = (addu | subu | clo | addiu | xori | lui | lw | jal | jalr) ? 1'b1 : 1'b0;
assign EXTop = (addiu | lw | sw) ? 1'b1 : 1'b0;
assign ALUsrc = (addiu | xori | lui | lw | sw) ? 1'b1 : 1'b0;
assign RegDst = (addu | subu | clo | jalr) ? 1'b1 : 1'b0;
assign MemWrite = (sw) ? 1'b1 : 1'b0;
assign MemtoReg = (lw) ? 1'b1 : 1'b0;
assign PCtoReg = (jal | jalr) ? 1'b1 : 1'b0;
assign Regra = (jal) ? 1'b1 : 1'b0;
assign type = (addu) ? ADDU :
(subu) ? SUBU :
...... :
(clo) ? CLO : 6'b111111;

assign t_rs = (addu) ? 4'h1 :
(subu) ? 4'h1 :
...... :
(clo) ? 4'h1 : 4'hf;

// 注意 clo 指令不需要使用 rt 寄存器
assign t_rt = (addu) ? 4'h1 :
(subu) ? 4'h1 :
...... :
(clo) ? 4'hf : 4'hf;

assign t = (addu) ? 4'h2 :
(subu) ? 4'h2 :
...... :
(clo) ? 4'h2 : 4'hf;

接下来就是 ALU 模块的新计算了,尝试一下,你能用 for 循环写出 clo 指令的计算逻辑吗(注意,Verilog 中的 for 循环没有 break 跳出循环的语法):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
reg [31:0] temp;
reg flag;
integer i;

always @(*) begin
temp = 32'h00000020;
flag = 1'b0;
// 注意 Verilog 中没有 i-- 的语法!
for (i = 31; i >= 0; i = i - 1) begin
if (flag == 1'b0 && inputA[i] == 1'b0) begin
temp = 31 - i;
flag = 1'b1;
end
end
end

parameter CLO = 6'bXXXXXX;
assign outputA = (type == ADDU) ? AaddB :
(type == SUBU) ? AsubB :
...... :
(type == CLO) ? temp : none;

由于 Verilog 的 for 循环中不存在 break 语句,所以我们需要对代码进行一些小改造,加入一个 flag 变量,手动实现跳过循环的效果。

如果不会用 Verilog 写怎么办?三步之内,必有解药!这个世界上就不存在不能用组合逻辑改写的 for 循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
wire [31:0] temp;
assign temp = (inputA[31] == 1'b0) ? 32'h00000000 :
(inputA[31] == 1'b1 && inputA[30] == 1'b0) ? 32'h00000001 :
(inputA[30] == 1'b1 && inputA[29] == 1'b0) ? 32'h00000002 :
(inputA[29] == 1'b1 && inputA[28] == 1'b0) ? 32'h00000003 :
(inputA[28] == 1'b1 && inputA[27] == 1'b0) ? 32'h00000004 :
(inputA[27] == 1'b1 && inputA[26] == 1'b0) ? 32'h00000005 :
(inputA[26] == 1'b1 && inputA[25] == 1'b0) ? 32'h00000006 :
(inputA[25] == 1'b1 && inputA[24] == 1'b0) ? 32'h00000007 :
(inputA[24] == 1'b1 && inputA[23] == 1'b0) ? 32'h00000008 :
(inputA[23] == 1'b1 && inputA[22] == 1'b0) ? 32'h00000009 :
(inputA[22] == 1'b1 && inputA[21] == 1'b0) ? 32'h0000000a :
(inputA[21] == 1'b1 && inputA[20] == 1'b0) ? 32'h0000000b :
(inputA[20] == 1'b1 && inputA[19] == 1'b0) ? 32'h0000000c :
(inputA[19] == 1'b1 && inputA[18] == 1'b0) ? 32'h0000000d :
(inputA[18] == 1'b1 && inputA[17] == 1'b0) ? 32'h0000000e :
(inputA[17] == 1'b1 && inputA[16] == 1'b0) ? 32'h0000000f :
(inputA[16] == 1'b1 && inputA[15] == 1'b0) ? 32'h00000010 :
(inputA[15] == 1'b1 && inputA[14] == 1'b0) ? 32'h00000011 :
(inputA[14] == 1'b1 && inputA[13] == 1'b0) ? 32'h00000012 :
(inputA[13] == 1'b1 && inputA[12] == 1'b0) ? 32'h00000013 :
(inputA[12] == 1'b1 && inputA[11] == 1'b0) ? 32'h00000014 :
(inputA[11] == 1'b1 && inputA[10] == 1'b0) ? 32'h00000015 :
(inputA[10] == 1'b1 && inputA[9] == 1'b0) ? 32'h00000016 :
(inputA[9] == 1'b1 && inputA[8] == 1'b0) ? 32'h00000017 :
(inputA[8] == 1'b1 && inputA[7] == 1'b0) ? 32'h00000018 :
(inputA[7] == 1'b1 && inputA[6] == 1'b0) ? 32'h00000019 :
(inputA[6] == 1'b1 && inputA[5] == 1'b0) ? 32'h0000001a :
(inputA[5] == 1'b1 && inputA[4] == 1'b0) ? 32'h0000001b :
(inputA[4] == 1'b1 && inputA[3] == 1'b0) ? 32'h0000001c :
(inputA[3] == 1'b1 && inputA[2] == 1'b0) ? 32'h0000001d :
(inputA[2] == 1'b1 && inputA[1] == 1'b0) ? 32'h0000001e :
(inputA[1] == 1'b1 && inputA[0] == 1'b0) ? 32'h0000001f :
32'h00000020;

(实际上,这道题还有更优雅的位运算组合逻辑写法,有兴趣可以尝试一下)

千万不要嘲笑这种做法,当你被 Verilog 中一堆不存在的语法搞崩溃时,这种方法真的是暴力但好用!

这样,我们就完成了 clo 指令的添加。因为这两条指令都不涉及到额外的阻塞转发的问题,所以和单周期加指令也没什么区别,看起来实在是人畜无害。不过接下来的指令可能就要上一些强度了 ——

【跳过延迟槽:bgtzl】

接下来,我们来实现指令 bgtzlbgtz 指令我们都已经很熟了,就是 branch if greater than zero ,即当 rs 寄存器中的值大于 0 时进行跳转,否则不跳转。我们现在要实现的指令在此基础上多了一个 l ,指的是 likely ,意思是在不跳转的情况下,跳过延迟槽中的指令,从延迟槽指令的下一条指令继续执行。

bgtzl 指令也是 MIPS 指令集中存在的指令,它的符号语言如下:

Code Block
if GPR[rs] > 032 then
    PC ← PC + 4 + sign_extend(offset || 02)
else
    NullifyCurrentInstruction()
endif

题目中并没有给出跳过延迟槽指令的具体实现方法,而是用一个函数草草了事,需要我们自行分析实现的流程。

或许你会想到在执行 bgtzl 指令时,如果不满足跳转条件,则将 NPC 值从 PC + 4 改为 PC + 8 ,然而这种方法真的可行吗?

答案是否定的。因为此时 bgtzl 指令位于 D 级,而延迟槽中的指令已经位于 F 级,所以此时更改 NPC 的值,跳过的并不是延迟槽指令,而是延迟槽指令的下一条指令!

既然此时延迟槽指令已经位于 F 级,我们显然就没办法将其扼杀在 NPC 的摇篮中了,而是应该考虑将 F 级的延迟槽指令清除掉,或者说是替换为空泡指令 nop

聪明的你肯定想到了,仿照阻塞时的行为,对流水线寄存器和 PC 模块进行适当的关闭写使能、清空寄存器,即可实现产生 nop 指令的效果!于是经过一番思考,你得出结论:当 D 级的 bgtzl 指令产生跳过延迟槽信号(假设为 signal 信号)时,我们只需要在下一个时钟上升沿使用 clear 信号清空 FDreg 流水线寄存器即可:

(这里不同于阻塞信号,阻塞信号是插入 nop 指令,所以应该将插入位置之前的指令锁定住,即关闭写使能;这里是将延迟槽指令替换为 nop 指令,所以不需要关闭 PC 模块的写使能)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// CMP
module CMP(
input [31:0] inputA,
input [31:0] inputB,
input [5:0] type,
output zero,
output signal
);

parameter BEQ = 6'b000111,
BNE = 6'b001000,
BGTZL = 6'bXXXXXX;

// 对 bgtzl 使用有符号比较吧!
assign zero = ((type == BEQ) && (inputA == inputB)) ? 1'b1 :
((type == BNE) && (inputA != inputB)) ? 1'b1 :
((type == BGTZL) && ($signed(inputA) > $signed(32'h00000000))) ? 1'b1 : 1'b0;

assign signal = (type == BGTZL) && ($signed(inputA) <= $signed(32'h00000000)) ? 1'b1 : 1'b0;

endmodule
1
2
// mips
CMP CMP (.inputA(D_fixedRD1), .inputB(D_fixedRD2), .type(D_type), .zero(D_zero), .signal(signal));

这样就可以了吗?真的可以了吗?如果是这样的话,你还是太小看五级流水线 CPU 了!给个提示,假如 bgtzl 将要被阻塞在 D 级,同时产生了跳过延迟槽信号,在下个周期会出现什么情况呢?

如果阻塞信号 D_stall 和跳过延迟槽信号 signal 同时产生,那么 FDreg 流水线寄存器的使能端 en 会被关闭,同时清除信号 clear 也会开启。那么这两个信号谁的优先级比较高呢?不幸的是,clear 信号的优先级比较高。所以 FDreg 流水线寄存器会优先清除掉全部的内容,也就是会清除掉 bgtzl 指令本身,完成了一次壮烈的自我毁灭。

于是为了防止这种极其诡异的情况产生,我们规定当阻塞信号 D_stall 开启时,跳过延迟槽信号 signal 必须关闭:

1
2
3
4
// mips
wire fixedsignal;
assign fixedsignal = (signal & (~D_stall));
assign FD_clear = (fixedsignal) ? 1'b1 : 1'b0;

这样,我们就完成了 bgtzl 指令最核心的跳过延迟槽部分!随后,我们只需要补全 Controller 的规定流程即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
parameter BGTZL = 6'bXXXXXX;
wire bgtzl;
assign bgtzl = (OpCode == 6'bXXXXXX) ? 1'b1 : 1'b0;

// 仿照 beq 和 bne 指令即可
assign NPC_Sel = (j | jal) ? 2'b01 :
(jr | jalr) ? 2'b10 :
(beq | bne | bgtzl) ? 2'b11 : 2'b00;
assign RegWrite = (addu | subu | addiu | xori | lui | lw | jal | jalr) ? 1'b1 : 1'b0;
assign EXTop = (addiu | lw | sw) ? 1'b1 : 1'b0;
assign ALUsrc = (addiu | xori | lui | lw | sw) ? 1'b1 : 1'b0;
assign RegDst = (addu | subu | jalr) ? 1'b1 : 1'b0;
assign MemWrite = (sw) ? 1'b1 : 1'b0;
assign MemtoReg = (lw) ? 1'b1 : 1'b0;
assign PCtoReg = (jal | jalr) ? 1'b1 : 1'b0;
assign Regra = (jal) ? 1'b1 : 1'b0;
assign type = (addu) ? ADDU :
(subu) ? SUBU :
...... :
(bgtzl) ? BGTZL : 6'b111111;

assign t_rs = (addu) ? 4'h1 :
...... :
(beq) ? 4'h0 :
...... :
(bgtzl) ? 4'h0 : 4'hf;

// 这里不要忘记了 bgtzl 不需要 rt 寄存器
assign t_rt = (addu) ? 4'h1 :
...... :
(beq) ? 4'h0 :
...... :
(bgtzl) ? 4'hf : 4'hf;

assign t = (addu) ? 4'h2 :
...... :
(beq) ? 4'hf :
...... :
(bgtzl) ? 4'hf : 4'hf;

这样,我们就搞定了 bgtzl 指令!有点上强度了,对不对?

【未定写使能:movz】

写还是不写,这是个问题。—— Kamonto 如是说道。

接下来,我们来看一种从未在课下出现过的奇葩指令 movz :当 rt 寄存器中的值等于 0 时,将 rs 寄存器中的值赋给 rd 寄存器;否则不进行任何赋值操作。

movz 依旧是 MIPS 指令集中的指令,其符号语言如下:

Code Block
if GPR[rt] = 0 then
    GPR[rd] ← GPR[rs]
endif

看似巨简单无比,不过它既然能出现在这里,肯定是有它的原因的(笑)。我们先按照正常流程来走一走,看看在哪一步会出现问题。首先我们修改 Controller 模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Controller
parameter MOVZ = 6'bXXXXXX;
wire movz;
assign movz = (OpCode == 6'bXXXXXX && Funct == 6'bXXXXXX) ? 1'b1 : 1'b0;

assign NPC_Sel = (j | jal) ? 2'b01 :
(jr | jalr) ? 2'b10 :
(beq | bne) ? 2'b11 : 2'b00;
// 这里我们无法判断是否要写寄存器
assign RegWrite = (addu | subu | ??? | addiu | xori | lui | lw | jal | jalr) ? 1'b1 : 1'b0;
assign EXTop = (addiu | lw | sw) ? 1'b1 : 1'b0;
assign ALUsrc = (addiu | xori | lui | lw | sw) ? 1'b1 : 1'b0;
assign RegDst = (addu | subu | movz | jalr) ? 1'b1 : 1'b0;
assign MemWrite = (sw) ? 1'b1 : 1'b0;
assign MemtoReg = (lw) ? 1'b1 : 1'b0;
assign PCtoReg = (jal | jalr) ? 1'b1 : 1'b0;
assign Regra = (jal) ? 1'b1 : 1'b0;
assign type = (addu) ? ADDU :
(subu) ? SUBU :
...... :
(movz) ? MOVZ : 6'b111111;

// 注意这里 GPR[rs] 虽然在 ALU 模块中不需要进行运算,但我们并没有转发到 M 级 RD1(或者说 outputA )的数据通路,所以 t_rs 只能填写 E 级
assign t_rs = (addu) ? 4'h1 :
(subu) ? 4'h1 :
...... :
(movz) ? 4'h1 : 4'hf;

// GPR[rt] 在 E 级的 ALU 模块中进行与 0 的比较,其实在 CMP 模块中比较也可以,但 t_rt 值大一些有利于减少阻塞
assign t_rt = (addu) ? 4'h1 :
(subu) ? 4'h1 :
...... :
(movz) ? 4'h1 : 4'hf;

// 这里我们同样无法判断是否会产生写入寄存器的值
assign t = (addu) ? 4'h2 :
(subu) ? 4'h2 :
...... :
(movz) ? ??? : 4'hf;

可以看到,我们在修改 Controller 模块时遇到了两个问题,RegWrite 信号和 t 值均无法通过指令名直接确定,而是需要我们计算才能够得出,所以我们需要从 ALU 模块中传出一个计算值,动态地修改这两个值。

(关于 t 值是否需要动态修改的问题,如果在不写寄存器的情况下不将 t 值置为 0xf ,那么就有可能会将 GPR[rs] 的值转发给需要读 rd 寄存器的指令,然而我们实际上并不会将 GPR[rs] 写入 rd 寄存器,这就出现了错误,所以必须动态修改 t 值!)

在 ALU 模块中,我们新开一个 1 位的输出信号 signal ,当不满足写入条件时,signal 信号置 1 ,随流水线一直流水到 W 级,直到 movz 指令执行完毕。当 signal 信号为 1 时,当前指令的 t 值将被修改为 0xf ,当指令位于 W 级时,RegWrite 会被关闭。

(这里又涉及到老生常谈的新信号适配之前指令的问题了。如果我们的设计是满足写入条件时,signal 信号置 1 ,那就要为每个写寄存器的指令都加上恒为 1 的 signal 信号,否则之前的其它所有指令都会因为 signal 没有置 1 而被关闭 GRF 模块的写使能!)

于是,我们的 Controller 模块中就可以按满足赋值条件的情况进行设置,当不满足赋值条件时,由 signal 信号进行动态修改:

1
2
3
4
5
6
7
// Controller
assign RegWrite = (addu | subu | movz | addiu | xori | lui | lw | jal | jalr) ? 1'b1 : 1'b0;

assign t = (addu) ? 4'h2 :
(subu) ? 4'h2 :
...... :
(movz) ? 4'h2 : 4'hf;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// ALU
module ALU(
input [31:0] inputA,
input [31:0] inputB,
input [5:0] type,
output [31:0] outputA,
output signal
);

parameter ADDU = 6'b000000,
SUBU = 6'b000001,
......,
MOVZ = 6'bXXXXXX;

......

assign outputA = (type == ADDU) ? AaddB :
(type == SUBU) ? AsubB :
...... :
(type == MOVZ) ? inputA : none;

// movz 指令不满足跳转条件的情况
assign signal = (type == MOVZ && inputB != 32'h00000000) ? 1'b1 : 1'b0;

endmodule
1
2
// mips
ALU ALU (.inputA(E_inputA), .inputB(E_inputB), .type(E_type), .outputA(E_outputA), .signal(signal));

接下来,我们为 EMreg 和 MWreg 流水线寄存器添加 signal 的流水(这一步太简单了,不想写代码了)。

在 W 级,我们修改 GRF 模块的写使能,当 signal 信号为 1 时,RegWrite 关闭:

1
2
3
4
5
6
// mips
wire W_fixedRegWrite;
assign W_fixedRegWrite = (RegWrite & (~W_signal));

GRF GRF (.clk(clk), .reset(reset), .WE(W_fixedRegWrite), .A1(D_A1), .A2(D_A2),
.A3(W_A3), .WD(W_WD_Reg), .RD1(D_RD1), .RD2(D_RD2));

在 E 级、M 级、W 级 t_new 值的计算中,当该级的 signal 为 1 时,t_new 值修改为 0xf

1
2
3
4
5
6
7
8
9
10
11
12
// mips
assign E_t_new = (E_signal) ? 4'hf :
(E_t == 4'hf) ? 4'hf :
(E_t >= 4'h1) ? (E_t - 4'h1) : 4'h0;

assign M_t_new = (M_signal) ? 4'hf :
(M_t == 4'hf) ? 4'hf :
(M_t >= 4'h2) ? (M_t - 4'h2) : 4'h0;

assign W_t_new = (W_signal) ? 4'hf :
(W_t == 4'hf) ? 4'hf :
(W_t >= 4'h3) ? (W_t - 4'h3) : 4'h0;

这样,我们就完成了这个“简单”的指令。是不是非常意外?这就是五级流水线的复杂之处!

偷偷告诉你,其实这个题有一个偷鸡的做法,那就是检测到 signal 之后将 A3 直接改为 0 号寄存器。管你 RegWritet 是多少,0 号寄存器,吸收两米以下的氮磷钾,不写入、不阻塞、不转发!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// mips
wire [4:0] E_fixedA3;
assign E_fixedA3 = signal ? 5'b00000 : E_A3;

EMreg EMreg (.clk(clk), .reset(reset), .EM_en(EM_en), .EM_clear(EM_clear),
.E_Instr(E_Instr), .E_PCplus8(E_PCplus8), .E_PC(E_PC),
.E_outputA(E_outputA), .E_RD2(E_fixedRD2), .E_A3(E_fixedA3),
.M_Instr(M_Instr), .M_PCplus8(M_PCplus8), .M_PC(M_PC),
.M_outputA(M_outputA), .M_RD2(M_RD2), .M_A3(M_A3));

assign D_stall = (E_fixedA3 != 5'b00000 && D_rs == E_fixedA3 && D_t_rsuse != 4'hf && E_t_new != 4'hf && D_t_rsuse < E_t_new) ? 1'b1 :
(E_fixedA3 != 5'b00000 && D_rt == E_fixedA3 && D_t_rtuse != 4'hf && E_t_new != 4'hf && D_t_rtuse < E_t_new) ? 1'b1 :
(M_A3 != 5'b00000 && D_rs == M_A3 && D_t_rsuse != 4'hf && M_t_new != 4'hf && D_t_rsuse < M_t_new) ? 1'b1 :
(M_A3 != 5'b00000 && D_rt == M_A3 && D_t_rtuse != 4'hf && M_t_new != 4'hf && D_t_rtuse < M_t_new) ? 1'b1 :
(W_A3 != 5'b00000 && D_rs == W_A3 && D_t_rsuse != 4'hf && W_t_new != 4'hf && D_t_rsuse < W_t_new) ? 1'b1 :
(W_A3 != 5'b00000 && D_rt == W_A3 && D_t_rtuse != 4'hf && W_t_new != 4'hf && D_t_rtuse < W_t_new) ? 1'b1 : 1'b0;

直接秒了,我还是推荐大家这么做的,非常爽。

实际上,上机考试中更容易出的一种题目是某种跳转指令,如果满足跳转条件,就将 PC + 8 写入 $ra 寄存器中;如果不满足跳转条件,就完全不写。不过我认真的研读了 MIPS 指令集,发现貌似并没有这样的指令,所以选了一个思路相同的指令。

简单讲一下上面题目的思路,在 CMP 模块中输出 signal 信号,如果 signal 信号为 1 ,则将 D_A3 改为 0 号寄存器就可以了~(注意不要忘记填写 isPCplus8 !)

【未定寄存器:lwtxr】

lwtxr 指令是一个完全虚构的指令,虽然这种类型的指令完全不存在于 MIPS 指令集中(大概吧),但却是每次上机几乎都必有的题目。

lwtxr 指令会像 lw 指令一样从内存中取出一个字,但是写入的寄存器有所不同,记取出的字的最低 5 位为 temp ,取 temprt 中的较大者作为写入的寄存器编号。它的符号语言如下:

Code Block
addr ← GPR[base] + sign_ext(offset) 
data ← memory[addr]
temp ← data4:0
target ← (temp > rt) ? temp : rt
GPR[target] ← data

不知道第一位想出这种指令的助教是何等的天才,之前的 movz 指令还是在写和不写之间纠结,现在的 lwtxr 指令已经上升到了根本不知道写哪个寄存器的层次!

既然真的是这样,那问题或许也好办了。如果我完全不知道我要写哪个寄存器,那除非后面一条指令是 j 指令或者 jal 指令,或者要读的寄存器全都是 0 号寄存器的指令,否则只要是需要读寄存器的指令,统统都阻塞在 D 级就好了!等到在 M 级计算出 M_A3 的值,在 M 级再进行转发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// mips
wire [31:0] M_fixedA3;
assign M_fixedA3 = (M_type == LWTXR && M_data[4:0] > M_A3) ? M_data[4:0] : M_A3;

MWreg MWreg (.clk(clk), .reset(reset), .MW_en(MW_en), .MW_clear(MW_clear),
.M_Instr(M_Instr), .M_PCplus8(M_PCplus8), .M_PC(M_PC),
.M_A3(M_fixedA3), .M_outputA(M_outputA), .M_data(M_data),
.W_Instr(W_Instr), .W_PCplus8(W_PCplus8), .W_PC(W_PC),
.W_A3(W_A3), .W_outputA(W_outputA), .W_data(W_data));

// forward
// 新指令不属于 isPCplus8 指令,不需要更改这部分的转发
assign D_RD1_from_E_PCplus8 = (E_isPCplus8 && E_A3 != 5'b00000 && D_rs == E_A3 && D_t_rsuse != 4'hf && E_t_new != 4'hf && D_t_rsuse >= E_t_new) ? 1'b1 : 1'b0;
assign D_RD2_from_E_PCplus8 = (E_isPCplus8 && E_A3 != 5'b00000 && D_rt == E_A3 && D_t_rtuse != 4'hf && E_t_new != 4'hf && D_t_rtuse >= E_t_new) ? 1'b1 : 1'b0;

assign D_RD1_from_M_PCplus8 = (M_isPCplus8 && M_A3 != 5'b00000 && D_rs == M_A3 && D_t_rsuse != 4'hf && M_t_new != 4'hf && D_t_rsuse >= M_t_new) ? 1'b1 : 1'b0;
assign D_RD2_from_M_PCplus8 = (M_isPCplus8 && M_A3 != 5'b00000 && D_rt == M_A3 && D_t_rtuse != 4'hf && M_t_new != 4'hf && D_t_rtuse >= M_t_new) ? 1'b1 : 1'b0;
assign E_RD1_from_M_PCplus8 = (M_isPCplus8 && M_A3 != 5'b00000 && E_rs == M_A3 && E_t_rsuse != 4'hf && M_t_new != 4'hf && E_t_rsuse >= M_t_new) ? 1'b1 : 1'b0;
assign E_RD2_from_M_PCplus8 = (M_isPCplus8 && M_A3 != 5'b00000 && E_rt == M_A3 && E_t_rtuse != 4'hf && M_t_new != 4'hf && E_t_rtuse >= M_t_new) ? 1'b1 : 1'b0;

// 如果 M 级指令是 lwtxr 指令,需要使用修正过后的 A3 值
assign D_RD1_from_M = (~M_isPCplus8 && M_fixedA3 != 5'b00000 && D_rs == M_fixedA3 && D_t_rsuse != 4'hf && M_t_new != 4'hf && D_t_rsuse >= M_t_new) ? 1'b1 : 1'b0;
assign D_RD2_from_M = (~M_isPCplus8 && M_fixedA3 != 5'b00000 && D_rt == M_fixedA3 && D_t_rtuse != 4'hf && M_t_new != 4'hf && D_t_rtuse >= M_t_new) ? 1'b1 : 1'b0;
assign E_RD1_from_M = (~M_isPCplus8 && M_fixedA3 != 5'b00000 && E_rs == M_fixedA3 && E_t_rsuse != 4'hf && M_t_new != 4'hf && E_t_rsuse >= M_t_new) ? 1'b1 : 1'b0;
assign E_RD2_from_M = (~M_isPCplus8 && M_fixedA3 != 5'b00000 && E_rt == M_fixedA3 && E_t_rtuse != 4'hf && M_t_new != 4'hf && E_t_rtuse >= M_t_new) ? 1'b1 : 1'b0;

assign D_RD1_from_W = (W_A3 != 5'b00000 && D_rs == W_A3 && D_t_rsuse != 4'hf && W_t_new != 4'hf && D_t_rsuse >= W_t_new) ? 1'b1 : 1'b0;
assign D_RD2_from_W = (W_A3 != 5'b00000 && D_rt == W_A3 && D_t_rtuse != 4'hf && W_t_new != 4'hf && D_t_rtuse >= W_t_new) ? 1'b1 : 1'b0;
assign E_RD1_from_W = (W_A3 != 5'b00000 && E_rs == W_A3 && E_t_rsuse != 4'hf && W_t_new != 4'hf && E_t_rsuse >= W_t_new) ? 1'b1 : 1'b0;
assign E_RD2_from_W = (W_A3 != 5'b00000 && E_rt == W_A3 && E_t_rtuse != 4'hf && W_t_new != 4'hf && E_t_rtuse >= W_t_new) ? 1'b1 : 1'b0;
assign M_RD2_from_W = (W_A3 != 5'b00000 && M_rt == W_A3 && M_t_rtuse != 4'hf && W_t_new != 4'hf && M_t_rtuse >= W_t_new) ? 1'b1 : 1'b0;

// stall
// 如果 E 级指令是 lwtxr 指令,直接阻塞
// 如果 M 级指令是 lwtxr 指令,需要使用修正过后的 A3 值
assign D_stall = (E_type == LWTXR) ? 1'b1 :
(E_A3 != 5'b00000 && D_rs == E_A3 && D_t_rsuse != 4'hf && E_t_new != 4'hf && D_t_rsuse < E_t_new) ? 1'b1 :
(E_A3 != 5'b00000 && D_rt == E_A3 && D_t_rtuse != 4'hf && E_t_new != 4'hf && D_t_rtuse < E_t_new) ? 1'b1 :
(M_fixedA3 != 5'b00000 && D_rs == M_fixedA3 && D_t_rsuse != 4'hf && M_t_new != 4'hf && D_t_rsuse < M_t_new) ? 1'b1 :
(M_fixedA3 != 5'b00000 && D_rt == M_fixedA3 && D_t_rtuse != 4'hf && M_t_new != 4'hf && D_t_rtuse < M_t_new) ? 1'b1 :
(W_A3 != 5'b00000 && D_rs == W_A3 && D_t_rsuse != 4'hf && W_t_new != 4'hf && D_t_rsuse < W_t_new) ? 1'b1 :
(W_A3 != 5'b00000 && D_rt == W_A3 && D_t_rtuse != 4'hf && W_t_new != 4'hf && D_t_rtuse < W_t_new) ? 1'b1 : 1'b0;

然后恭喜你,你通过了 19 / 20 个点,有一个测试点 TLE 了!这种类型的题目,特点就是疯狂卡 TLE ,只要稍微多阻塞几次,直接自动爆炸!

什么?难道还有更少的阻塞方式吗?这就需要我们仔细看题目了。题目中要求写入的寄存器是 temprt 中的较大值,而 rt 的值是我们一开始就知道的。也就是说,我们虽然不能够精准选出写入的寄存器,但我们能够肯定的是,只要是编号比 rt 更小的寄存器,都不可能会是写入的寄存器!

(当然,也有题目是所有寄存器都可能会写到,那就像上面那样写就可以了,实在不行就加个 D 级指令不读寄存器的情况的特判)

所以我们在 lwtxr 指令位于 E 级时,不能够无脑阻塞,而是只有当 D 级指令要读的寄存器可能是要写的寄存器是,才会进行阻塞,也就是像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
// mips
// stall
// 如果 E 级指令是 lwtxr 指令,则当 D_rs 不是 0 号寄存器,且 D_t_rsuse 存在,且 D_rs 大于等于 E_rt(即 E_A3 )时才阻塞
// 需要注意在 E 级指令不是 lwtxr 指令时,在判断条件前加上 E_type != LWTXR ,避免被上一条判断漏下来的 lwtxr 指令进入接下来的判断!
// 如果 M 级指令是 lwtxr 指令,需要使用修正过后的 A3 值
assign D_stall = (E_type == LWTXR && D_rs != 5'b00000 && D_t_rsuse != 4'hf && D_rs >= E_A3) ? 1'b1 :
(E_type == LWTXR && D_rt != 5'b00000 && D_t_rtuse != 4'hf && D_rt >= E_A3) ? 1'b1 :
(E_type != LWTXR && E_A3 != 5'b00000 && D_rs == E_A3 && D_t_rsuse != 4'hf && E_t_new != 4'hf && D_t_rsuse < E_t_new) ? 1'b1 :
(E_type != LWTXR && E_A3 != 5'b00000 && D_rt == E_A3 && D_t_rtuse != 4'hf && E_t_new != 4'hf && D_t_rtuse < E_t_new) ? 1'b1 :
(M_fixedA3 != 5'b00000 && D_rs == M_fixedA3 && D_t_rsuse != 4'hf && M_t_new != 4'hf && D_t_rsuse < M_t_new) ? 1'b1 :
(M_fixedA3 != 5'b00000 && D_rt == M_fixedA3 && D_t_rtuse != 4'hf && M_t_new != 4'hf && D_t_rtuse < M_t_new) ? 1'b1 :
(W_A3 != 5'b00000 && D_rs == W_A3 && D_t_rsuse != 4'hf && W_t_new != 4'hf && D_t_rsuse < W_t_new) ? 1'b1 :
(W_A3 != 5'b00000 && D_rt == W_A3 && D_t_rtuse != 4'hf && W_t_new != 4'hf && D_t_rtuse < W_t_new) ? 1'b1 : 1'b0;

这样,我们就完成了 lwtxr 指令中最为困难的部分,这也提示我们在上机时遇到同种类型的指令时,一定要思考是否有哪些寄存器一定不会作为被赋值的寄存器,在 E 级阻塞时将它们排除掉即可。

接下来我们就来照例补全一下 lwtxr 指令的 Controller 模块和 ALU 模块吧!不说话,直接放代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Controller
parameter LWTXR = 6'bXXXXXX;
wire lwtxr;
assign lwtxr = (OpCode == 6'bXXXXXX) ? 1'b1 : 1'b0;

// 仿照 lw 指令进行填写即可
assign NPC_Sel = (j | jal) ? 2'b01 :
(jr | jalr) ? 2'b10 :
(beq | bne) ? 2'b11 : 2'b00;
assign RegWrite = (addu | subu | addiu | xori | lui | lw | lwtxr | jal | jalr) ? 1'b1 : 1'b0;
assign EXTop = (addiu | lw | lwtxr | sw) ? 1'b1 : 1'b0;
assign ALUsrc = (addiu | xori | lui | lw | lwtxr | sw) ? 1'b1 : 1'b0;
assign RegDst = (addu | subu | jalr) ? 1'b1 : 1'b0;
assign MemWrite = (sw) ? 1'b1 : 1'b0;
assign MemtoReg = (lw | lwtxr) ? 1'b1 : 1'b0;
assign PCtoReg = (jal | jalr) ? 1'b1 : 1'b0;
assign Regra = (jal) ? 1'b1 : 1'b0;
assign type = (addu) ? ADDU :
(subu) ? SUBU :
...... :
(lwtxr) ? LWTXR : 6'b111111;

assign t_rs = (addu) ? 4'h1 :
...... :
(lw) ? 4'h1 :
...... :
(lwtxr) ? 4'h1 : 4'hf;

assign t_rt = (addu) ? 4'h1 :
...... :
(lw) ? 4'hf :
...... :
(lwtxr) ? 4'hf : 4'hf;

// 这里能够转发要写入的数据的时间是固定的,只不过不知道要写入哪个寄存器而已
assign t = (addu) ? 4'h2 :
...... :
(lw) ? 4'h3 :
...... :
(lwtxr) ? 4'h3 : 4'hf;
1
2
3
4
5
6
7
// ALU
parameter LWTXR = 6'bXXXXXX;
assign outputA = (type == ADDU) ? AaddB :
...... :
(type == LW) ? AaddB :
...... :
(type == LWTXR) ? AaddB : none;

至此,我们就学会了 P5 和 P6 中最常考的几种题型,尤其是后几种题型中对阻塞和转发的改造,在考试的高压环境下很难全部想出来,所以最好多看几遍,理解其中的思路和原理,争取在考到的时候也能够快速写出!

5.6 五级流水线 CPU 大升级:新指令与新模块

在本节,我们将会对我们的五级流水线 CPU 进行一些有趣的小升级,通过加入一些新指令和新模块实现更多的功能!果然还是这种随时可以看到成果的小任务最受大家欢迎了!(打个广告,所以快来选择 OS 的 Shell 挑战性任务吧!)

【实现按半字、字节访存】

在这一部分,我们来加入几条新的指令:sh lh sb lb 指令,用于实现对内存按半字和字节的访存。

我们先来快速过一下加指令的基本流程:首先修改 Controller 模块,解析指令,添加合适的选择信号,设置 t_rs t_rt t 接口的值;接下来是在 CMP 模块和 ALU 模块中加入合适的运算,显然对于这几条指令来说,CMP 模块是不需要改动的,我们只需要修改 ALU 模块即可。

聪明的你一定想到了,上面的所有流程中,除了 Controller 模块中的解析指令以外,其它的所有流程,sh 指令和 sb 指令都与 sw 指令相同,lh 指令和 lb 指令都与 lw 指令相同。这就非常奇怪了,明明是不同的指令,执行流程难不成是相同的吗?

实际上,为了能够区别执行这几条指令,我们需要对 DM 模块进行一些微调,并且在 DM 模块的前后进行一些预处理和再加工。

我们首先来看比较简单的 lh 指令和 lb 指令。我们知道,我们的 DM 模块在取内存时是非常简单粗暴的,将 GPR[rs] + signed_ext(imm16) 直接右移两位,取出相对应的一整个字,也就是 4 个字节。

这种方法对于最低两位必须是 00 的 lw 指令非常好用,然而对于 lh 指令和 lb 指令,DM 模块也只能够取出 要取出的半字或字节 所在的一整个字。例如在下面的地址中:

0x0 - 0x3 0x4 - 0x7 0x8 - 0xb 0xc - 0xf
0x12345678 0x00abcdef 0x00114514 0x01919810

我们使用 lb $t0, 5($0) 指令,想取出地址为 0x5 的 0xcd 字节,将 0x000000cd 赋值给 $t0 寄存器。然而 DM 模块经过运算得出 5 >> 2 的值为 1 ,于是只能取出编号为 1 的字,也就是 0x00abcdef 一整个字,作为输出结果。

所以我们就需要将取出的整个字进行再加工,根据指令类型和地址最低两位,计算出正确的读内存结果。我们设置一个新的模块进行这个操作,取名为 DE 模块:

1
2
3
4
5
6
7
8
module DE(
input [31:0] addr,
input [31:0] data,
input [4:0] type,
output [31:0] fixed_data
);

endmodule

在了解了预期输入和输出之后,DE 模块的逻辑就不难理解了,总结来说可以写成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if 指令为 lw then
fixed_data = data
else if 指令为 lh and addr[1] == 1'b0 then
fixed_data = signed_ext(data[15:0])
else if 指令为 lh and addr[1] == 1'b1 then
fixed_data = signed_ext(data[31:16])
else if 指令为 lb and addr[1:0] == 2'b00 then
fixed_data = signed_ext(data[7:0])
else if 指令为 lb and addr[1:0] == 2'b01 then
fixed_data = signed_ext(data[15:8])
else if 指令为 lb and addr[1:0] == 2'b10 then
fixed_data = signed_ext(data[23:16])
else if 指令为 lb and addr[1:0] == 2'b11 then
fixed_data = signed_ext(data[31:24])

根据这个逻辑,我们就不难写出相应的代码了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
module DE(
input [31:0] addr,
input [31:0] data,
input [5:0] type,
output [31:0] fixed_data
);

// 这里和 Controller 中 type 的定义一致即可
parameter LW = 6'b000101,
LH = 6'b001101,
LB = 6'b001110;

wire [15:0] half_data;
wire [7:0] byte_data;

assign half_data = (type == LH && addr[1] == 1'b0) ? data[15:0] :
(type == LH && addr[1] == 1'b1) ? data[31:16] : 16'h0000;

assign byte_data = (type == LB && addr[1:0] == 2'b00) ? data[7:0] :
(type == LB && addr[1:0] == 2'b01) ? data[15:8] :
(type == LB && addr[1:0] == 2'b10) ? data[23:16] :
(type == LB && addr[1:0] == 2'b11) ? data[31:24] : 8'h00;

assign fixed_data = (type == LW) ? data :
(type == LH) ? {{16{half_data[15]}}, half_data} :
(type == LB) ? {{24{byte_data[7]}}, byte_data} : data;

endmodule

接下来,我们来实现相对复杂一些的 sh 指令和 sb 指令。和之前的问题相同,我们目前的 DM 模块只支持一次性存入一整个字,所以如何向内存中存入一个半字或一个字节,同时又不破坏字内其它部分,就成了一个比较棘手的问题。例如还是之前的地址:

0x0 - 0x3 0x4 - 0x7 0x8 - 0xb 0xc - 0xf
0x12345678 0x00abcdef 0x00114514 0x01919810

假设我想使用 sb $t0, 5($0) 指令将 $t0 寄存器中的值 0x00000012 存入内存中,期望地址为 0x4 - 0x7 的字变为 0x00ab12ef 。但我们的 DM 模块会直接将整个寄存器中的值全部存入编号为 1 的字中,整个字就变成了 0x00000012 ,连最基本的 0x12 字节的位置都错了。我们进行一些预处理,在存入前将数据调整为 0x00001200 ,再存入内存中,虽然 0x12 字节的位置正确了,但 0xab0xef 两个字节却被覆盖掉了,依然没有得到正确结果。

针对这种问题,有一种比较直观的解决方案,那就是先将 0x00abcdef 整个字取出来,与调整过的数据 0x00001200 嵌合成 0x00ab12ef 之后,再将整个字写回内存。不过这种方法实际上问题很大,在一个周期内进行了一次读内存操作,又进行了一次写内存操作,会极大拖慢 CPU 的执行效率。(当然我们实际上不需要考虑 CPU 的效率问题,其实不让这么写的主要原因是后面在做内存外置的时候接口会对不上,笑)

所以我们选取另外一种解决方案,那就是改造 DM 模块的写内存方式。我们将原来的写使能信号 MemWrite 一分为四,为一个字中的每个字节都设置一个写使能信号。在上面的例子中,我们输入预处理后的数据 0x00001200 和写使能信号 0b0010 ,于是 0x00abcdef 中的第 0 、2 、3 字节保持不变,只将 0x00001200 的第 1 个字节写入 0x00abcdef 的第 1 个字节中,得到了 0x00ab12ef 的结果。

首先,我们改造一下 DM 模块,为每个字节添加写使能信号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
module DM(
input clk,
input reset,
// 写使能由 1 位变为 4 位
input [3:0] WE,
input [31:0] Addr,
input [31:0] WD,
output [31:0] Data
);

reg [31:0] RAM [0:3071];

wire [31:0] RAM_Addr;

integer i;

assign RAM_Addr = (Addr >> 2);

always@(posedge clk) begin
if (reset) begin
for (i = 0; i < 3072; i = i + 1) begin
RAM[i] = 32'h00000000;
end
end
else begin
if (WE[0] == 1'b1) begin
RAM[RAM_Addr][7:0] <= WD[7:0];
end
if (WE[1] == 1'b1) begin
RAM[RAM_Addr][15:8] <= WD[15:8];
end
if (WE[2] == 1'b1) begin
RAM[RAM_Addr][23:16] <= WD[23:16];
end
if (WE[3] == 1'b1) begin
RAM[RAM_Addr][31:24] <= WD[31:24];
end
end
end

assign Data = RAM[RAM_Addr];

endmodule

接下来,我们来完成数据的预处理和写使能生成的部分,我们将这一部分也整合成模块,取名为 BE 模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
module BE(
input [31:0] addr,
input [31:0] data,
input [5:0] type,
output [3:0] MemByteWrite,
output [31:0] fixed_data
);

// 依旧是 Controller 中 type 的定义一致即可
parameter SW = 6'b000110,
SH = 6'b001111,
SB = 6'b010000;

assign MemByteWrite = (type == SW) ? 4'b1111 :
(type == SH && addr[1] == 1'b0) ? 4'b0011 :
(type == SH && addr[1] == 1'b1) ? 4'b1100 :
(type == SB && addr[1:0] == 2'b00) ? 4'b0001 :
(type == SB && addr[1:0] == 2'b01) ? 4'b0010 :
(type == SB && addr[1:0] == 2'b10) ? 4'b0100 :
(type == SB && addr[1:0] == 2'b11) ? 4'b1000 : 4'b0000;

assign fixed_data = (type == SW) ? data :
(type == SH && addr[1] == 1'b0) ? {16'h0000, data[15:0]} :
(type == SH && addr[1] == 1'b1) ? {data[15:0], 16'h0000} :
(type == SB && addr[1:0] == 2'b00) ? {24'h000000, data[7:0]} :
(type == SB && addr[1:0] == 2'b01) ? {16'h0000, data[7:0], 8'h00} :
(type == SB && addr[1:0] == 2'b10) ? {8'h00, data[7:0], 16'h0000} :
(type == SB && addr[1:0] == 2'b11) ? {data[7:0], 24'h000000} : data;

endmodule

到这里,我们就已经完成了绝大部分的工作,接下来就是走一下 Controller 模块和 ALU 模块的固定流程了:

(当然了,既然 BE 模块已经能够输出 DM 模块的写使能,那么 MemWrite 信号就不需要 Controller 模块代劳了,从中删去即可)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// Controller
parameter ADDU = 6'b000000,
......,
LW = 6'b000101,
SW = 6'b000110,
......,
LH = 6'b001101,
LB = 6'b001110,
SH = 6'b001111,
SB = 6'b010000;

wire lh;
wire lb;
wire sh;
wire sb;

assign lh = (OpCode == 6'b100001) ? 1'b1 : 1'b0;
assign lb = (OpCode == 6'b100000) ? 1'b1 : 1'b0;
assign sh = (OpCode == 6'b101001) ? 1'b1 : 1'b0;
assign sb = (OpCode == 6'b101000) ? 1'b1 : 1'b0;

// 仿照 lw 和 sw 指令进行填写即可
// 这里注意删掉 MemWrite 选择信号
assign NPC_Sel = (j | jal) ? 2'b01 :
(jr | jalr) ? 2'b10 :
(beq | bne) ? 2'b11 : 2'b00;
assign RegWrite = (addu | subu | addiu | xori | lui | lw | lh | lb | jal | jalr) ? 1'b1 : 1'b0;
assign EXTop = (addiu | lw | lh | lb | sw | sh | sb) ? 1'b1 : 1'b0;
assign ALUsrc = (addiu | xori | lui | lw | lh | lb | sw | sh | sb) ? 1'b1 : 1'b0;
assign RegDst = (addu | subu | jalr) ? 1'b1 : 1'b0;
assign MemtoReg = (lw | lh | lb) ? 1'b1 : 1'b0;
assign PCtoReg = (jal | jalr) ? 1'b1 : 1'b0;
assign Regra = (jal) ? 1'b1 : 1'b0;
assign type = (addu) ? ADDU :
(subu) ? SUBU :
...... :
(lh) ? LH :
(lb) ? LB :
(sh) ? SH :
(sb) ? SB : 6'b111111;

assign t_rs = (addu) ? 4'h1 :
...... :
(lw) ? 4'h1 :
(sw) ? 4'h1 :
...... :
(lh) ? 4'h1 :
(lb) ? 4'h1 :
(sh) ? 4'h1 :
(sb) ? 4'h1 : 4'hf;

assign t_rt = (addu) ? 4'h1 :
...... :
(lw) ? 4'hf :
(sw) ? 4'h2 :
...... :
(lh) ? 4'hf :
(lb) ? 4'hf :
(sh) ? 4'h2 :
(sb) ? 4'h2 : 4'hf;

assign t = (addu) ? 4'h2 :
...... :
(lw) ? 4'h3 :
(sw) ? 4'hf :
...... :
(lh) ? 4'h3 :
(lb) ? 4'h3 :
(sh) ? 4'hf :
(sb) ? 4'hf : 4'hf;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ALU
parameter ADDU = 6'b000000,
......,
LW = 6'b000101,
SW = 6'b000110,
......,
LH = 6'b001101,
LB = 6'b001110,
SH = 6'b001111,
SB = 6'b010000;
assign outputA = (type == ADDU) ? AaddB :
...... :
(type == LW) ? AaddB :
(type == SW) ? AaddB :
...... :
(type == LH) ? AaddB :
(type == LB) ? AaddB :
(type == SH) ? AaddB :
(type == SB) ? AaddB : none;

最后,我们在主模块中声明 DE 和 BE 模块,修改相应接口即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
module mips(
input clk,
input reset
);

// M zone ////////////////////////////////
wire [31:0] M_fixedWD_Mem;
wire [31:0] M_fixeddata;
wire [3:0] M_MemByteWrite;

// M zone ////////////////////////////////
// 删除 Controller 中 MemWrite 的定义
Controller MController (.OpCode(M_OpCode), .Funct(M_Funct), .type(M_type),
.t_rs(M_t_rs), .t_rt(M_t_rt), .t(M_t));
BE BE (.addr(M_addr), .data(M_WD_Mem), .type(M_type), .MemByteWrite(M_MemByteWrite),
.fixed_data(M_fixedWD_Mem));
DM DM (.clk(clk), .reset(reset), .WE(M_MemByteWrite), .addr(M_addr), .WD(M_fixedWD_Mem),
.data(M_data));
DE DE (.addr(M_addr), .data(M_data), .type(M_type), .fixed_data(M_fixeddata));
MWreg MWreg (.clk(clk), .reset(reset), .MW_en(MW_en), .MW_clear(MW_clear),
.M_Instr(M_Instr), .M_PCplus8(M_PCplus8), .M_PC(M_PC),
.M_A3(M_A3), .M_outputA(M_outputA), .M_data(M_fixeddata),
.W_Instr(W_Instr), .W_PCplus8(W_PCplus8), .W_PC(W_PC),
.W_A3(W_A3), .W_outputA(W_outputA), .W_data(W_data));
endmodule

这样,我们就完成了 sh lh sb lb 指令的添加!完成了一个小任务,真的很有成就感!

【内存外置】

其实直到现在,我们的 CPU 的设计都存在着一个很明显的 bug 。如果你理论知识学得还不错的话,可能就会产生这样一个问题:内存是 CPU 的一部分吗?

所以你现在知道了,内存其实并不是 CPU 的一部分。我们之前实现的其实并不只是一个 CPU ,而是 CPU 和内存的结合体。现在,为了维护 CPU 的独立性,我们忍痛割爱,将内存部分从我们的设计中踢出去,只留下一些接口藕断丝连:

1
2
3
4
5
6
7
8
9
10
11
12
module mips(
input clk,
input reset,
input [31:0] i_inst_rdata,
input [31:0] m_data_rdata,
output [31:0] i_inst_addr,
output [31:0] m_data_addr,
output [31:0] m_data_wdata,
output [3:0] m_data_byteen
);

endmodule

我们可以看到主模块中多出了 6 个接口,这些都是曾经连接在 IM 模块和 DM 模块上的导线。在这两个模块被发配至主模块外以后,我们就需要通过 output 端口向两个模块发送数据,从 input 端口接受两个模块传回的信息。

当然,欲练此功,必先 XX ,我们要做的第一步就是删除 IM 模块和 DM 模块:

Picture Not Found!
(当然,不要忘了删除主模块中 IM 模块和 DM 模块的引用~)

接下来,我们将原本接在两个模块接口上的导线,改接到主模块的接口:

IM 模块的改接相对来说比较简单,我们将原本的输入端 addr 改接到 output 端口 i_inst_addr ,将原本的输出端 data 改接到 input 端口 i_inst_rdata 即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module mips(
input clk,
input reset,
input [31:0] i_inst_rdata,
input [31:0] m_data_rdata,
output [31:0] i_inst_addr,
output [31:0] m_data_addr,
output [31:0] m_data_wdata,
output [3:0] m_data_byteen
);

// 注意这里等号两边千万不能写反了,要想清楚数据是由谁传给谁
assign i_inst_addr = F_PC;
assign F_Instr = i_inst_rdata;

endmodule

DM 模块因为有读写两个部分,所以稍显复杂,不过问题不大,我们只要搞清楚新的输入输出端口对应原来 DM 模块的哪个接口,就可以无痛完成改接:原本的地址输入端 addr 改接至 m_data_addr ,写入数据输入端 WD 改接至 m_data_wdata ,写使能输入端 MemByteWrite 改接至 m_data_byteen ,读取数据输出端 data 改接至 m_data_rdata

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
module mips(
input clk,
input reset,
input [31:0] i_inst_rdata,
input [31:0] m_data_rdata,
output [31:0] i_inst_addr,
output [31:0] m_data_addr,
output [31:0] m_data_wdata,
output [3:0] m_data_byteen
);

assign m_data_addr = M_addr;
assign m_data_wdata = M_fixedWD_Mem;
assign m_data_byteen = M_MemByteWrite;
assign M_data = m_data_rdata;

endmodule

这样,我们不费吹灰之力就完成了内存的外置。在之后我们还会遇到更多的外置模块,其实并没有我们想象中的那么复杂,只需要将对应的数据通过输入输出端口进行传输就可以了!

【实现乘除法指令】

接下来等待着我们的,是 P6 的大型规则怪谈 —— 乘除法指令~

我们要实现的乘除法指令一共有 8 条,分别是 mult multu div divu mfhi mflo mthi mtlo 指令。前 4 条指令负责计算乘除法,将结果存入 HI 寄存器和 LO 寄存器中;后 4 条指令负责读取和写入 HI 寄存器和 LO 寄存器。

事实上,如果想直接使用上一节加指令的流程来实现这 8 条指令的话,肯定是不太行的,毕竟我们目前的 CPU 中并没有 HILO 这两个寄存器,所以当然还需要加入一些奇妙的模块才能拿下这几条指令!

于是我们来了解一个新的模块 —— MDU 模块!MDU 模块位于 E 级,和 ALU 模块并列。实际上,MDU 模块有点像 ALU 模块和 GRF 模块的结合体。一方面,它需要像 ALU 磨矿一样输入两个值 inputAinputB 进行计算;另一方面,它又包含两个寄存器 HILO ,所以需要像 GRF 模块一样支持读取和写入这两个寄存器。

那么接下来,我们就来设计一下这个新出炉的 MDU 模块:

首先是计算部分,我们根据不同指令的 type 值,对 inputAinputB 进行不同的运算。和 ALU 指令稍微有些不同的地方是,我们并不需要将运算的结果输出,而是在当前时钟上升沿将结果存入 HILO 寄存器中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
module MDU(
input clk,
input reset,
input [31:0] inputA,
input [31:0] inputB,
input [5:0] type
);

reg [31:0] HI;
reg [31:0] LO;

parameter MULT = 6'b010001,
MULTU = 6'b010010,
DIV = 6'b010011,
DIVU = 6'b010100;

always@(posedge clk) begin
if (reset) begin
HI <= 32'h00000000;
LO <= 32'h00000000;
end
else begin
// 有符号运算一定要记得加 $signed !
if (type == MULT) begin
// 这里是一个很有趣的写法
{HI, LO} <= $signed(inputA) * $signed(inputB);
end
else if (type == MULTU) begin
{HI, LO} <= inputA * inputB;
end
else if (type == DIV) begin
// 这里保证了除数不能为 0
if (inputB != 32'h00000000) begin
HI <= $signed(inputA) % $signed(inputB);
LO <= $signed(inputA) / $signed(inputB);
end
end
else if (type == DIVU) begin
if (inputB != 32'h00000000) begin
HI <= inputA % inputB;
LO <= inputA / inputB;
end
end
end
end

endmodule

接下来是存取部分,和 GRF 略有不同的是,我们一次最多只会读取一个寄存器,而不是 GRF 中的两个;同时,我们也不需要设置一个输入接口判断需要读取或写入的寄存器,从指令名中即可获取到这些信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
module MDU(
input clk,
input reset,
input [31:0] inputA,
input [31:0] inputB,
input [5:0] type,
output [31:0] outputB
);

reg [31:0] HI;
reg [31:0] LO;

parameter MULT = 6'b010001,
MULTU = 6'b010010,
DIV = 6'b010011,
DIVU = 6'b010100,
MFHI = 6'b010101,
MFLO = 6'b010110,
MTHI = 6'b010111,
MTLO = 6'b011000;

always@(posedge clk) begin
if (reset) begin
HI <= 32'h00000000;
LO <= 32'h00000000;
end
else begin
if (type == MTHI) begin
HI <= inputA;
end
else if (type == MTLO) begin
LO <= inputA;
end
else if (type == MULT) begin
{HI, LO} <= $signed(inputA) * $signed(inputB);
end
else if (type == MULTU) begin
{HI, LO} <= inputA * inputB;
end
else if (type == DIV) begin
if (inputB != 32'h00000000) begin
HI <= $signed(inputA) % $signed(inputB);
LO <= $signed(inputA) / $signed(inputB);
end
end
else if (type == DIVU) begin
if (inputB != 32'h00000000) begin
HI <= inputA % inputB;
LO <= inputA / inputB;
end
end
end
end

assign outputB = (type == MFHI) ? HI :
(type == MFLO) ? LO :
32'h00000000;

endmodule

这样,我们就非常轻松地完成了 MDU 模块的设计!接下来当然还是固定流程了,不过因为这些指令和我们之前已经实现的指令都不太相同,所以就需要我们稍微动一动脑了。不过我相信这么简单的小任务,对于聪明的你来说一定不是问题!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
// Controller
parameter ADDU = 6'b000000,
......,
MULT = 6'b010001,
MULTU = 6'b010010,
DIV = 6'b010011,
DIVU = 6'b010100,
MFHI = 6'b010101,
MFLO = 6'b010110,
MTHI = 6'b010111,
MTLO = 6'b011000;

wire mult;
wire multu;
wire div;
wire divu;
wire mfhi;
wire mflo;
wire mthi;
wire mtlo;

assign mult = (OpCode == 6'b000000 && Funct == 6'b011000) ? 1'b1 : 1'b0;
assign multu = (OpCode == 6'b000000 && Funct == 6'b011001) ? 1'b1 : 1'b0;
assign div = (OpCode == 6'b000000 && Funct == 6'b011010) ? 1'b1 : 1'b0;
assign divu = (OpCode == 6'b000000 && Funct == 6'b011011) ? 1'b1 : 1'b0;
assign mfhi = (OpCode == 6'b000000 && Funct == 6'b010000) ? 1'b1 : 1'b0;
assign mflo = (OpCode == 6'b000000 && Funct == 6'b010010) ? 1'b1 : 1'b0;
assign mthi = (OpCode == 6'b000000 && Funct == 6'b010001) ? 1'b1 : 1'b0;
assign mtlo = (OpCode == 6'b000000 && Funct == 6'b010011) ? 1'b1 : 1'b0;

assign NPC_Sel = (j | jal) ? 2'b01 :
(jr | jalr) ? 2'b10 :
(beq | bne) ? 2'b11 : 2'b00;
assign RegWrite = (addu | subu | addiu | xori | lui | lw | lh | lb | jal | jalr | mfhi | mflo) ? 1'b1 : 1'b0;
assign EXTop = (addiu | lw | lh | lb | sw | sh | sb) ? 1'b1 : 1'b0;
assign ALUsrc = (addiu | xori | lui | lw | lh | lb | sw | sh | sb) ? 1'b1 : 1'b0;
assign RegDst = (addu | subu | jalr | mfhi | mflo) ? 1'b1 : 1'b0;
assign MemtoReg = (lw | lh | lb) ? 1'b1 : 1'b0;
assign PCtoReg = (jal | jalr) ? 1'b1 : 1'b0;
assign Regra = (jal) ? 1'b1 : 1'b0;
assign type = (addu) ? ADDU :
(subu) ? SUBU :
...... :
(mult) ? MULT :
(multu) ? MULTU :
(div) ? DIV :
(divu) ? DIVU :
(mfhi) ? MFHI :
(mflo) ? MFLO :
(mthi) ? MTHI :
(mtlo) ? MTLO : 6'b111111;

// mult multu div divu 指令需要在 E 级的 MDU 模块中进行计算
// mthi mtlo 指令需要在 E 级的 MDU 模块中写入 HI LO 寄存器
assign t_rs = (addu) ? 4'h1 :
...... :
(mult) ? 4'h1 :
(multu) ? 4'h1 :
(div) ? 4'h1 :
(divu) ? 4'h1 :
(mfhi) ? 4'hf :
(mflo) ? 4'hf :
(mthi) ? 4'h1 :
(mtlo) ? 4'h1 : 4'hf;

// mult multu div divu 指令需要在 E 级的 MDU 模块中进行计算
assign t_rt = (addu) ? 4'h1 :
...... :
(mult) ? 4'h1 :
(multu) ? 4'h1 :
(div) ? 4'h1 :
(divu) ? 4'h1 :
(mfhi) ? 4'hf :
(mflo) ? 4'hf :
(mthi) ? 4'hf :
(mtlo) ? 4'hf : 4'hf;

// mfhi mflo 从 HI LO 寄存器读出的值在 M 级可以开始转发
assign t = (addu) ? 4'h2 :
...... :
(mult) ? 4'hf :
(multu) ? 4'hf :
(div) ? 4'hf :
(divu) ? 4'hf :
(mfhi) ? 4'h2 :
(mflo) ? 4'h2 :
(mthi) ? 4'hf :
(mtlo) ? 4'hf : 4'hf;

(由于这些指令都不需要进入 ALU 模块,所以我们可以不对 ALU 模块做任何修改)

然而,我们目前的电路还存在着一个非常明显的问题,你发现了吗?那就是我们并没有将 MDU 模块的 outputB 端口接入电路中!所以我们需要在 E 级加入一个新的 Controller 模块的选择端口,通过当前指令的 type 来判断是否使用 outputB 值代替 outputA 值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
module Controller(
input [5:0] OpCode,
input [5:0] Funct,
output [1:0] NPC_Sel,
output RegWrite,
output EXTop,
output ALUsrc,
output RegDst,
output MemWrite,
output MemtoReg,
output PCtoReg,
output Regra,
output mffix,
output [5:0] type,
output [3:0] t_rs,
output [3:0] t_rt,
output [3:0] t
);

assign mffix = (mfhi | mflo) ? 1'b1 : 1'b0;

endmodule

最后,我们将主模块中引用 MDU 模块,并写入 outputAoutputB 合流的逻辑就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// mips
wire E_mffix;
wire [31:0] E_outputB;
wire [31:0] E_fixedoutputA;

assign E_fixedoutputA = mffix ? E_outputB : E_outputA;

// E zone ////////////////////////////////
Controller EController (.OpCode(E_OpCode), .Funct(E_Funct), .ALUsrc(E_ALUsrc),
.mffix(E_mffix), .type(E_type), .t_rs(E_t_rs), .t_rt(E_t_rt),
.t(E_t));
MDU MDU (.clk(clk), .reset(reset), .inputA(E_inputA), .inputB(E_inputB), .type(E_type),
.outputB(E_outputB));
// 这里注意连接 E_outputA 接口的是修正值 E_fixedoutputA
EMreg EMreg (.clk(clk), .reset(reset), .EM_en(EM_en), .EM_clear(EM_clear),
.E_Instr(E_Instr), .E_PCplus8(E_PCplus8), .E_PC(E_PC),
.E_outputA(E_fixedoutputA), .E_RD2(E_fixedRD2), .E_A3(E_A3),
.M_Instr(M_Instr), .M_PCplus8(M_PCplus8), .M_PC(M_PC),
.M_outputA(M_outputA), .M_RD2(M_RD2), .M_A3(M_A3));

这样,我们就完成了 MDU 模块和 8 个乘除法指令!这回是真的超简单了,甚至对于现在这个水平的我们来说有点太简单了,所以我们的课程组为我们设计了一系列规则怪谈,来挑战我们的水平 ——

【使用乘除法指令模拟多周期运算】

在真实的 CPU 中,乘除法的计算是极其缓慢的(相信你也在写程序时听说过把乘除法换成位运算这种操作吧~),所以为了不让乘除法计算指令( mult multu div divu 指令)拖慢 CPU 的最短运行周期,我们不要求这些指令在一个周期内就执行完毕,而是允许它们连续执行好几个周期。

这样做有的好处还是非常明显的,一是我们刚才提过的,CPU 的最短运行周期可以不受影响;二是当乘除法计算指令在 MDU 模块中计算时,虽然当前指令没有执行完毕,但所有不进入 MDU 模块的指令都可以正常流水,而不需要进行任何阻塞。

在乘除法计算的过程中,因为 mult multu div divu 这四条指令不会写入 GRF 中的寄存器,其它不进入 MDU 模块的指令也不会读取 HI LO 寄存器,所以它们之间不存在任何的数据冲突!

当然了,如果在 MDU 模块正在进行乘除法计算的时候出现了新的乘除法指令,如果出现的是 mfhi 指令或 mflo 指令,那就只能将指令阻塞在 D 级,等待计算结果写入 HI 寄存器和 LO 寄存器了;如果出现的是 mult multu div divu 指令,我们可以提前终止当前的运算,毕竟我们接下来要执行的指令会将 HI 寄存器和 LO 寄存器全部覆盖掉。

如果出现的是 mthi 指令或 mtlo 指令,那就有些难搞了,因为它们并不会将 HI 寄存器和 LO 寄存器全部覆盖,所以我们也不能直接终止当前的运算。最简单粗暴的方法当然也是将它们阻塞在 D 级,但如果要追求效率的话,可以为 HI 寄存器和 LO 寄存器分别设置一个 reg 型变量 HIbanLOban ,置 1 时表示禁止写入相应的该寄存器,防止当前正在执行的乘除法运算将已经修改过的寄存器值写回来(当然,当乘除法计算开始时,需要记得将 HIbanLOban 置回 0 )。当 HIbanLOban 的值均为 1 ,即 HI 寄存器和 LO 寄存器全部被 mthi 指令和 mtlo 指令覆写时,我们当然也就可以终止当前的运算了。

为了模拟多周期的运算,课程组为我们设计了一系列规则怪谈,让我们来看看都有什么:

  1. 当 E 级指令是 mult multu div divu 指令时,Controller 模块需要输出 1 个周期的 1 位 start 信号,表示乘除法计算开始;
  2. start 信号结束后,如果之前的计算指令是 mult multu 指令,MDU 模块需要连续输出 5 个周期的 busy 信号,表示正在计算 HI 寄存器和 LO 寄存器的值;如果之前的计算指令是 div divu 指令,则需要连续输出 10 个周期的 busy 信号;
  3. start 信号或 busy 信号为 1 时,不进入 MDU 模块的指令正常流水,进入 MDU 模块的所有指令均阻塞在 D 级;
  4. 为了降低实现难度,保证 MDU 模块在进行乘除法计算时只会出现 mfhi mflo 两种乘除法指令(所以不需要考虑提前终止运算或禁止写入寄存器的情况,这也呼应了第 3 条规则,无脑阻塞就可以了)。

虽然规则很复杂,不过要实现的内容却并不是很多,说白了只有 3 项:start 信号、busy 信号,以及无脑阻塞!

在 Controller 模块中,我们加入新的选择信号 start ,并将其输入 MDU 模块中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Controller
module Controller(
input [5:0] OpCode,
input [5:0] Funct,
output [1:0] NPC_Sel,
output RegWrite,
output EXTop,
output ALUsrc,
output RegDst,
output MemWrite,
output MemtoReg,
output PCtoReg,
output Regra,
output mffix,
output start,
output [5:0] type,
output [3:0] t_rs,
output [3:0] t_rt,
output [3:0] t
);

assign start = (mult | multu | div | divu) ? 1'b1 : 1'b0;

endmodule
1
2
3
4
5
6
7
8
9
// mips
wire E_start;

// E zone ////////////////////////////////
Controller EController (.OpCode(E_OpCode), .Funct(E_Funct), .ALUsrc(E_ALUsrc),
.mffix(E_mffix), .start(E_start), .type(E_type), .t_rs(E_t_rs),
.t_rt(E_t_rt), .t(E_t));
MDU MDU (.clk(clk), .reset(reset), .inputA(E_inputA), .inputB(E_inputB), .type(E_type),
.start(E_start), .outputB(E_outputB));

在 MDU 模块中,我们根据输入的 start 信号触发 busy 信号。在时钟上升沿,如果 start 信号为 1 ,则将 reg 型变量 count 置为 5 或 10 ,并且每周期递减,若 count 值不为 0 ,则输出 busy 信号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
module MDU(
input clk,
input reset,
input [31:0] inputA,
input [31:0] inputB,
input [4:0] type,
input start,
output [31:0] outputB,
output busy
);

reg [31:0] HI;
reg [31:0] LO;
reg [3:0] count;

parameter MULT = 6'b010001,
MULTU = 6'b010010,
DIV = 6'b010011,
DIVU = 6'b010100,
MFHI = 6'b010101,
MFLO = 6'b010110,
MTHI = 6'b010111,
MTLO = 6'b011000;

always@(posedge clk) begin
if (reset) begin
HI <= 32'h00000000;
LO <= 32'h00000000;
count <= 4'h0;
end
else begin
if (type == MTHI) begin
HI <= inputA;
end
else if (type == MTLO) begin
LO <= inputA;
end
else if (type == MULT) begin
{HI, LO} <= $signed(inputA) * $signed(inputB);
end
else if (type == MULTU) begin
{HI, LO} <= inputA * inputB;
end
else if (type == DIV) begin
if (inputB != 32'h00000000) begin
HI <= $signed(inputA) % $signed(inputB);
LO <= $signed(inputA) / $signed(inputB);
end
end
else if (type == DIVU) begin
if (inputB != 32'h00000000) begin
HI <= inputA % inputB;
LO <= inputA / inputB;
end
end

if (start) begin
if (type == MULT || type == MULTU) begin
count <= 4'h5;
end
else if (type == DIV || type == DIVU) begin
count <= 4'ha;
end
end

if (count != 4'h0) begin
count <= count - 4'h1;
end
end
end

assign outputB = (type == MFHI) ? HI :
(type == MFLO) ? LO :
32'h00000000;

assign busy = (count != 4'h0) ? 1'b1 : 1'b0;

endmodule

最后,我们在主模块中修改 MDU 模块的引用,并加入一条新的阻塞条件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// mips
wire E_busy;
wire D_ismult;

// E zone ////////////////////////////////
MDU MDU (.clk(clk), .reset(reset), .inputA(E_inputA), .inputB(E_inputB), .type(E_type),
.start(E_start), .outputB(E_outputB), .busy(E_busy));

// stall ////////////////////////////////
// 判断 D 级指令是否为 8 条乘除法指令,这里对 D_type 采用了硬编码
assign D_ismult = (D_type == 6'b010001 || D_type == 6'b010010 || D_type == 6'b010011 || D_type == 6'b010100 ||
D_type == 6'b010101 || D_type == 6'b010110 || D_type == 6'b010111 || D_type == 6'b011000) ? 1'b1 : 1'b0;

// 当 D 级指令是 8 条乘除法指令,且 start 信号或 busy 信号为 1 时,直接阻塞
assign D_stall = (E_A3 != 5'b00000 && D_rs == E_A3 && D_t_rsuse != 4'hf && E_t_new != 4'hf && D_t_rsuse < E_t_new) ? 1'b1 :
(E_A3 != 5'b00000 && D_rt == E_A3 && D_t_rtuse != 4'hf && E_t_new != 4'hf && D_t_rtuse < E_t_new) ? 1'b1 :
(M_A3 != 5'b00000 && D_rs == M_A3 && D_t_rsuse != 4'hf && M_t_new != 4'hf && D_t_rsuse < M_t_new) ? 1'b1 :
(M_A3 != 5'b00000 && D_rt == M_A3 && D_t_rtuse != 4'hf && M_t_new != 4'hf && D_t_rtuse < M_t_new) ? 1'b1 :
(W_A3 != 5'b00000 && D_rs == W_A3 && D_t_rsuse != 4'hf && W_t_new != 4'hf && D_t_rsuse < W_t_new) ? 1'b1 :
(W_A3 != 5'b00000 && D_rt == W_A3 && D_t_rtuse != 4'hf && W_t_new != 4'hf && D_t_rtuse < W_t_new) ? 1'b1 :
(D_ismult & (E_start | E_busy)) ? 1'b1 : 1'b0;

这样,我们就实现了完全体的乘除法指令!恭喜你完成了本书中最困难的第五章!

5.7 本章结语:再见,五级流水线 CPU ?

可能你会发现本节标题竟然是个问号,当然再见是不可能再见的,接下来我们还要再和五级流水线 CPU 缠斗一章(笑)。

能够看到这里,说明你已经闯过了本书中最凶险的一片领域,想必也花费了不少的时间和精力吧!想当年,我在这一章的内容中花费了 4 周,因为我 P5 挂了两次(悲);今年写这本书的时候,我也是花费了很长的一段时间,总是想在这么难的一章中把每个细节讲到透彻。你我都已经尽了非常大的努力了!

如果你已经挂过几回了,千万不要灰心!要相信,即使是一次又一次的挫败,你的能力也是在不断积累的。能够一路闯到这里,现在的你已经变得比之前任何时候的你都更加强大。休息一下,然后重整旗鼓,去面对最后的挑战吧!

如果你已经被迫止步于此,也不要为此过于悲伤,毕竟理论课的成绩才占计组课程的大头,我们还有着翻盘的机会,从现在开始准备起来吧!计组课程虽然是一门大课重课,然而研究的方向更偏向于硬件,可能会和很多同学感兴趣和擅长的领域并不重合,不要因为第一门专业课的失手就选择放弃,相信你能够找到自己热爱的领域,在亲手辛勤浇灌的土地上闪闪发光!

终章:带中断异常的五级流水线 CPU

Final.1 现在可以公开的情报:最后的前置知识

已经没什么好害怕了,让我们一同来面对最后的前置知识吧!

【用户态与内核态】

Kamonto 辛苦工作了十年,终于开了一家餐厅。大家都喜欢来 Kamonto 的餐厅,不仅是因为餐厅的大厨做饭好吃,更是因为 Kamonto 经常不在店里,这个时候大家想做什么就可以做什么!

一开始,大家按照菜单点餐,自觉付款,一切井然有序,Kamonto 欣慰。

直到有一天,Kamonto 回到店里,发现顾客在菜单上添了一道菜,名字叫“烫烫烫的锟斤拷”,Kamonto 不解。

过了几天,Kamonto 回到店里,发现顾客把招牌菜蟹 X 堡的核心秘方送给了隔壁的 P 老板,Kamonto 伤心。

又过了几天,Kamonto 回到店里,发现自己的店铺被顾客卖掉了,并解雇了所有的员工,Kamonto 绝望。

这个故事启示我们,我们不应该让顾客获得所有操作的权限,比如修改菜单、读取秘方、贩卖店铺。

这个道理同样适用于操作系统。在设计操作系统时,我们也需要为不同的操作设置不同的权限。我们可以允许用户编写的程序进行一些基本操作,例如进行计算,或者在一定范围内进行跳转等等;然而我们不应该让用户编写的程序进行一些更深层次的操作,几行代码下去直接把 C 盘删了,这你受得了吗。

所以我们需要为操作系统设置两个不同的权限状态:用户态和内核态。我们规定用户编写的程序只能进行用户态操作,内核态操作只能由操作系统自行执行(除非用户使用 syscall ,当然也只能进行一些有限的操作),这样就能够防止用户对操作系统造成一些危险的修改,有效地防止了电脑变板砖的大部分情况。

在程序执行时,如果需要临时将用户态切换为内核态,CPU 会先保存当前执行的现场(相当于设置了一个存档),并将 PC 切换到内核指令对应的地址,我们称这个过程为“陷入内核”;在执行完内核指令后,CPU 会读取之前保存的现场信息,回到用户态中原来的位置继续执行(相当于读取之前的存档),我们称这个过程为“返回现场”。

【内部异常】

在本章中,我们的 CPU 要执行的指令,不一定都是正确的指令,而是有可能会出现一些错误,例如取指令时地址没有对齐、取出的指令无法解析、对内存进行访存时地址没有对齐、计算加减法时发生位溢出等等。在接下来的内容中,我们统一将这种情况称之为“内部异常”。

当发生内部异常时,操作系统可能会直接产生崩溃。不过我们肯定不希望操作系统因为一点小小的错误就直接崩溃,毕竟你也不想因为在计算器里输入了一个除以 0 ,电脑就直接蓝瓶了吧!所以我们需要操作系统能够识别出一些错误,并进行适当的处理,使得程序能够自动地平稳结束,不至于导致操作系统直接崩溃;或者在进行一些简单的修复后,能够使程序继续执行。

我们知道,如果一条指令能够自动结束程序,那这已经不是一般的指令了,必须陷入内核!在操作系统这门课程的上机考试中,我们还会遇到这样的操作 —— 当某些指令出现一些预料之内的小错误的时候,操作系统能够直接修改指令的内容,将其微调为正确的指令,再回到原来的位置继续执行。当然了,修改指令内容这个操作显然也不是用户态应该有的权限,同样也需要陷入内核才能够实现。

在我们接下来要实现的内容中,我们要模拟的就是后者这种情况:我们的 CPU 需要在识别到错误指令时跳转到内核态,执行内核中的指令;在内核指令执行完后,再回到跳转前的位置继续执行。

不过,我们这门课程毕竟是计算机组成,而不是操作系统,所以我们并不需要真的去处理这些内部异常,我们要跳转到的“内核”中的指令其实也根本没有处理内部异常的功能。我们的课程只要求我们做到能够正确识别内部异常,能够正确跳转到内核地址,并能够正确从内核中跳转回原来的位置,这样就可以了,不需要自己去实现内核指令。

【外部中断】

我们在使用计算机时,一定会用到一些外部设备,例如键盘、鼠标、麦克风、扬声器等等。通过这些设备,我们就能够实现对 CPU 的实时控制。

当我们按下键盘按键时,作为外部设备的键盘就会向 CPU 输入一个中断信号,告诉 CPU 先暂停执行手中的工作,优先处理键盘的输入操作。外部设备的 I/O 也是一个内核态操作,所以 CPU 在接到中断信号后需要陷入内核,在处理完外部设备的输入后再返回现场继续工作。

以上就是外部设备 I/O 的大致流程。在接下来的内容中,我们将这种情况称为“外部中断”,将内部异常与外部中断合称为“中断异常”。当然了,外部设备的 I/O 是一个比较复杂的过程,也分为多种类型,然而这就是理论课的知识了,在这里我们就不再展开谈了。

我们需要知道的是,在这个过程中,陷入内核的操作并不是由 CPU 内部的指令引起的主动操作,而是由外部设备发出的中断信号导致的被动操作。也就是说,对于 CPU 来说,外部中断可能在任何一个时刻产生,完全无法预知。所以我们接下来需要模拟的就是:在任意一个时钟上升沿,无论当前在执行的是什么指令,只要收到外部中断,我们的 CPU 都需要立即陷入内核,并在执行完内核指令之后返回现场

【syscall 指令】

在学习 MIPS 编程时,你肯定使用过 syscall 指令进行输入、输出,以及退出程序,不过你可能并不是特别了解什么叫 syscall ,为什么一条 syscall 指令能够进行各种各样的操作,在这里我们就来说文解字一下。

实际上,除了内部异常和外部中断外,我们在编写程序的时候可以主动要求 CPU 在某个位置陷入内核,实现一些只有内核态才能够实现的操作,比如输入或输出数据、读写文件,以及退出程序等。

在 C 语言中,这些操作可能是 scanf() printf() fscanf() fprintf() exit() 这些函数,这些函数看似各不相同,但在编译为汇编语言后,就会有同一条汇编指令浮现出来,那就是 syscall 指令。

syscall 指令是用户通过自行编写程序或指令使操作系统进入内核态的唯一方式,所有需要进入内核态的函数,都需要使用 syscall 指令才能够实现。syscall 指令除了能够引导操作系统进入内核态外,本身并没有任何作用,也不包含将要进行的内核操作的信息。在进入内核态后,操作系统需要查找系统调用编号,在 MIPS 中也就是我们事先写入的 $v0 寄存器的值,根据这个值再进行不同的内核操作。

在指导书中,课程组将 syscall 指令作为了内部异常的一种,随内部异常一起处理,所以我们约定当接下来的内容中提到“内部异常”时,不仅表示指令本身产生错误的情况,还包括指令是 syscall 指令的情况。既然我们在接下来将 syscall 指令视为一种内部异常,那么我们只需要在识别异常时能够识别出 syscall 指令,并为其分配异常码就可以了。如上文所说,syscall 指令不会对任何寄存器和内存产生任何影响(和 nop 指令没区别),只不过会产生一个异常信号,导致 CPU 陷入内核而已。

Final.2 CP0 模块的结构与功能

为了能够实现中断异常的处理,我们需要设置一个新模块 CP0 。在动手操作之前,我们来想象一下这个新模块需要完成哪些工作:

首先肯定要判断是否需要进行中断异常的处理。当需要进行处理时,CP0 模块还需要向其它各个模块发送信号,告诉它们现在需要停止手中的一切工作,去执行内核中的处理指令。

在内核中的处理指令执行完成之后,我们的 CPU 还需要返回到陷入内核时的地址,恢复陷入内核前的一切状态,仿佛无事发生。

貌似 CP0 模块大概也就只有这些事情要做,看起来还挺简单的,那就来继续研究一下它的具体结构吧!

【SR 寄存器】

和 MDU 模块一样,CP0 模块中也自带一些寄存器,毕竟要保存陷入内核时的“犯罪现场”嘛!CP0 模块中自带 4 个寄存器:12 号寄存器 SR 、13 号寄存器 Cause 、14 号寄存器 EPC ,以及 15 号寄存器 PRId 。这几个寄存器各有各自的作用,我们依次来了解一下:

SR 寄存器像是一个总电闸,用于判断是否允许某种中断异常发生,它的结构如下:

位数 31 - 16 15 - 10 9 - 2 1 0
名称 IM EXL IE

(没有名称的位数表示没有实际作用,无论值是多少都没有影响)
(为了防止名称太多记不下来,我们在后续使用 SR_IM SR_EXL SR_IE 这种写法,用来强调寄存器的名称)

SR_EXL 只有 1 位,但却是电闸中的总闸,是所有中断异常的总开关。当 SR_EXL1'b0 时,表示允许进行中断异常的处理;反之表示不进行任何中断异常的处理。

SR_IE 同样只有 1 位,不过法力相对比较小,只能控制是否处理外部中断。当 SR_IE1'b1 时,表示允许进行外部中断的处理;反之表示不对任何外部中断进行处理。

SR_IM 则一共有 6 位,表示的是每个外部中断的分开关。在接下来的设计中,我们会遇到 6 个外部设备,每个设备都有可能发出中断信号,SR_IM 的 6 位就分别表示是否处理相应的外部设备产生的外部中断。在每一位中,1'b1 表示允许处理相应外部设备的外部中断;反之表示不处理该外部设备的外部中断。例如,若 SR_IM 的值为 6'b110101 ,则代表有 4 个外部设备产生的外部中断是被允许处理的,有 2 个是不作处理的。

(这里要注意 SR_EXLSR_IE SR_IM 的含义是完全相反的,前者是 1'b0 表示允许,后者是 1'b1 表示允许,我也不知道为什么这么设计)

在 CP0 模块中定义 SR 寄存器时,我们可以使用宏定义表示寄存器中的各个部分,在单独调用某个部分而不是调用整个寄存器时能够更加直观:

1
2
3
4
5
6
// CP0
reg [31:0] SR;

`define SR_IM SR[15:10]
`define SR_EXL SR[1]
`define SR_IE SR[0]

【Cause 寄存器】

Cause 寄存器,顾名思义就是存储陷入内核原因的寄存器。冤有头债有主,我们需要保存为什么陷入了内核,到底是因为指令出现了错误(具体到出现了哪种错误),还是因为调用了 syscall 指令,还是因为外部设备发出了中断信号,都是 Cause 寄存器需要记录的信息。它的结构是这样的:

位数 31 30 - 16 15 - 10 9 - 7 6 - 2 1 - 0
名称 BD IP ExcCode

Cause_ExcCode 共有 5 位,用来在内部异常发生时,记录内部异常的类型。在后面我们会实现各种内部异常的识别,每种异常都有一个对应的 ExcCode 值,例如未知指令的 ExcCode 值为 5'b01010 ,计算时发生溢出的 ExcCode 值为 5'b01100 ,在这里就不一一介绍了,等到我们实现内部异常的识别时再全部列出来好了。

Cause_IP 共有 6 位,用来在外部中断发生时,记录产生外部中断的外部设备。和 SR_IM 相同,Cause_IP 的每一位也分别代表一个外部设备,1'b1 代表相应的外部设备产生了外部中断信号,反之则代表该设备没有产生外部信号。例如 6'b110101 代表时钟上升沿时有 4 个外部设备产生了外部中断信号,有 2 个外部设备没有产生外部中断信号。

Cause_BD 是一个非常抽象的 1 位信号,看似不知道为什么要存储这种东西,但实际上却有着非常大的作用。Cause_BD 中存储的内容是:在陷入内核时,当前指令是否为延迟槽指令。其中 1'b1 代表当前指令位于延迟槽中;反之代表当前指令不位于延迟槽中。

确实很奇怪,我们为什么要存储这种信息呢?这就要从执行完内核指令返回原处时说起了。如果在当前指令为延迟槽指令时陷入内核,在恢复时自然就会从这条延迟槽指令开始重新执行。聪明的你肯定想到了,从这里开始执行的话好像会出现一些问题 —— 那就是 CPU 不会记得它的上一条指令是跳转指令,即将发生跳转,而是会直接去按顺序执行它的下一条指令!

所以为了解决这个问题,我们需要记录陷入内核时,当前的指令是否位于延迟槽中。如果 Cause_BD 等于 1'b1 ,那么在内核指令执行结束,返回现场的时候,我们需要从陷入时指令的上一条指令,也就是前面的跳转指令开始重新执行,这样才能够正确实现原来的跳转。

这里需要注意的是,即使是不满足跳转条件的条件跳转指令,我们仍然认为它的下一条指令是延迟槽指令,如果在此时陷入内核,依然需要将 Cause_BD 置 1 。这是因为在内核中,我们可能会进行一些奇妙的小操作(比如修改寄存器的值),使得返回现场时,反而满足跳转指令的条件了。所以我们依然需要从上一条跳转指令开始执行,才能正确实现功能!

和 SR 寄存器一样,我们可以使用下面的方法定义 Cause 寄存器:

1
2
3
4
5
6
// CP0
reg [31:0] Cause;

`define Cause_BD Cause[31]
`define Cause_IP Cause[15:10]
`define Cause_ExcCode Cause[6:2]

【EPC 寄存器和 PRId 寄存器】

EPC 寄存器就比较简单了,也没有分成各个部分,整个就是一个 CPU 的地址,用于记录执行完内核指令返回现场时,要返回的地址。也就是当 Cause_BD1'b0 时,EPC 的值等于陷入内核时指令的地址;当 Cause_BD1'b1 时,EPC 的值等于陷入内核时指令的上一条指令的地址。

PRId 寄存器就更简单了,它其实就是独属于我们自己的 CPU 防伪认证码。这个寄存器不能使用 mtc0 指令从外部写入,但是可以使用 mfc0 指令读取到寄存器中(这两条指令我们后续会讲到,其实你现在肯定也能猜出它们的含义了吧!),所以可以作为我们 CPU 的唯一标识符。我们事先在其中写死一个 32 位的标识符(这里写什么都可以,不会通不过评测,所以可以放一些自己的彩蛋),如果你将自己的 CPU 做成了实体,别人用了你的 CPU ,只要读出的 PRId 数据是你写入的数据,就能证明这是你创造的 CPU ,非常有趣!

【输入、输出、修改 CP0 寄存器】

接下来,我们来了解一下 CP0 模块的输入端口。除了常规的 clk 端口和 reset 端口外,CP0 模块还需要根据输入端口的信息计算当前周期是否需要陷入内核,并将当前的一些状态信息记录在寄存器中。

首先,我们需要 CP0 模块能够从外部读取当前指令的内部异常信息 ExcCodeExcCode 等于 5'b00000 表示当前指令没有内部异常)、6 台外部设备的外部中断信号情况 HWInt 、当前指令的 PC 值 VPC 以及当前指令是否位于延迟槽 BD 。根据这些输入值,再加上 SR 寄存器中允许或禁止的信息,我们就可以用下面的式子判断出是否需要陷入内核了:

1
assign Req = (~`SR_EXL && ((`SR_IE && (`SR_IM & HWInt)) || (ExcCode)));

这个式子中的 Req 就代表着我们的 CPU 在下一周期是否需要陷入内核。看起来相当复杂,但其实是一个非常简单的逻辑:SR_EXL 作为陷入内核的总开关,我们首先要保证 SR_EXL 的值为 0 。接下来分为两种情况,若 ExcCode 不为 0 ,即当前指令存在内部异常,则 Req 直接成立;否则查看是否有能够执行的外部中断。查看是否有能够执行的外部中断,首先要保证外部中断总开关 SR_IE 为 1 ,接下来要保证存在一台外部设备,它在 SR_IM 中的相应位为 1 ,同时在 HWInt 中的相应位也为 1 ,这就是上面式子中的按位与运算 SR_IM & HWInt ,我们只需观察这个按位与运算的结果是否等于 0 ,就可以判断出当前是否有能够执行的外部中断了!

在获取到 Req 的值之后,我们就可以使用 Req 在陷入内核时作出适当的修改了!在 CP0 模块内部,当陷入内核时我们都需要进行什么样的修改呢?

其实在 CP0 模块内部,我们能做的其实无外乎就是修改 CP0 中的几个寄存器的值。当处于时钟上升沿且 Req1'b1 时,我们将刚才传入 CP0 模块中的信息写入 Cause 寄存器和 EPC 寄存器中,具体写法如下:

1
2
3
4
5
6
7
8
9
// CP0
always@(posedge clk) begin
if (Req) begin
`Cause_BD <= BD;
`Cause_ExcCode <= (~`SR_EXL && `SR_IE && (`SR_IM & HWInt)) ? 5'b00000 : ExcCode;
EPC <= (BD) ? (VPC - 32'h00000004) : VPC;
end
`Cause_IP <= HWInt;
end

聪明的你肯定发现了,这里的写法还是有些门道的!Cause_BDEPC 的赋值尚且能够理解,可是 Cause_ExcCode 这一坨是怎么回事?Cause_IP 写到外面去了(这可不是我笔误哈)又是怎么一回事?

我们重点来谈谈 Cause_ExcCode 的问题,其实从上面的赋值中我们不难看出,三目运算符的判断条件 (~SR_EXL && SR_IE && (SR_IM & HWInt)) 其实就是当前有能够执行的外部中断的的意思。所以整个赋值语句的含义其实是,当外部中断和内部异常同时发生时,认为导致陷入内核的是外部中断,所以将 ExcCode 写为表示没有内部异常的 5'b00000

这样做其实也比较符合实际,当外部中断和内部异常同时产生时,我们肯定要优先处理外部中断。我们处理完外部中断,恢复现场之后肯定要重新执行这条指令,到时候再处理内部异常也完全来得及。因为内部异常无论什么时候,只要不进行处理,它永远都在那里;而外部中断就不一样了,如果我们先处理内部异常,等到回到现场的时候,外部中断早就不知道跑哪里去了。

接下来是 Cause_IP 的问题,从结果上来看,我们不需要在陷入内核时才写入当前外部设备的 HWInt 值,而是在每个周期都写入;同时我们也不需要只写入允许处理的位数,而是无论外部设备有没有被 ban 掉,都一股脑地写入 Cause_IP 中。不要问我为什么,我真不知道(叹气)。

除此之外,我们还需要对 SR 寄存器进行一处修改,那就是当陷入内核时,我们需要关闭中断异常的总闸 SR_EXL ,也就是在执行内核指令时禁止一切中断异常的处理;当恢复现场时,我们需要重新开启 SR_EXL ,恢复对中断异常的处理。

我们仔细想一想,目前我们好像只能够通过 Req 得出何时陷入内核,貌似并不能得知何时恢复现场,所以我们还需要加入一个新的输入端口 EXLclr ,当 EXLclr1'b1 时,就代表着恢复现场。我们目前不需要考虑 EXLclr 是如何计算出来的,只需要学会使用它就可以了。

这样,我们就得到了新版的更新 CP0 模块中的寄存器的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
// CP0
always@(posedge clk) begin
if (Req) begin
`SR_EXL <= 1'b1; // 陷入内核时关闭 SR_EXL
`Cause_BD <= BD;
`Cause_ExcCode <= (~`SR_EXL && `SR_IE && (`SR_IM & HWInt)) ? 5'b00000 : ExcCode;
EPC <= (BD) ? (VPC - 32'h00000004) : VPC;
end
if (EXLclr) begin
`SR_EXL <= 1'b0; // 恢复现场时开启 SR_EXL
end
`Cause_IP <= HWInt;
end

最后,我们还要再加上一个输出端口 EPCout ,用于实时输出 EPC 寄存器的值。为什么要输出这个值呢?我们来思考一下这个场景:当我们的 CPU 陷入内核时,我们只需要将 NPC 值置为内核指令的固定的起始地址( 0x00004180 )即可;然而在返回现场时,我们则需要使用 EPC 寄存器中保存的地址作为 NPC 值,这样才能够返回到正确的地址。于是我们需要像 imm16 imm26 GRF 这些输入端口一样,将 EPC 值实时传入 NPC 模块中,才能实现返回现场的功能:

1
assign EPCout = EPC;

【CP0 模块的位置】

目前为止,我们已经设计完了整个 CP0 模块,是时候把它加入到主模块中了!那么,现在有一个问题是,我们应该将这个模块放在哪个流水线级呢?其实现在讨论这个问题还为时尚早,因为分析问题用到的论据我们都还没有了解到;不过后续的设计就会开始与 CP0 模块的位置强相关了,所以我们还是提前研究一下这个问题,即使暂时没搞清楚也不要紧,当你学完下一节之后,可以回到这里看看,或许会有些新的收获:

首先,CP0 模块肯定要能够接收到所有可能会出现的内部异常的 ExcCode 值,在后续我们会了解到,F 级、D 级和 E 级都会负责识别一部分的内部异常,所以我们至少要把 CP0 模块放在 E 级,而不能放在 F 级和 D 级。

其次,CP0 模块在识别到中断异常时,需要及时阻止此时指令的执行,禁止指令写入任何存储空间,也就是 MDU 寄存器、CP0 寄存器、GRF 寄存器和内存,所以为了避免指令已经写入了存储空间,造成无法挽回的结果,我们需要让 CP0 模块越靠前越好。

综上所述,根据目前的内部异常识别要求,CP0 模块的最佳位置就是 —— E 级!

不过在接下来的内容中,我们的 CP0 模块将会写在 M 级……并非是因为个人喜好,而是我担心上机考试时会出现只有 M 级才能判断出的新内部异常,比如从内存中取出的值有什么问题这样,那 E 级派就只能下周再见了(叹气)。

此外,将 CP0 模块设置在 M 级,也并非完全不能解决阻止指令写入 MDU 寄存器的问题,在后面我们会了解到一种非常有趣的回溯方法,可以先小小期待一下!

【宏观 PC】

接下来,我们再认识一个新概念,那就是宏观 PC !我们知道,目前真正的 CPU 都是依靠多级流水线操作。在同一个时刻,CPU 中执行着许多条指令,这些指令自然都有着不同的 PC 值。不过,如果我们去向 CPU 询问当前正在执行的指令的 PC 值,得到的肯定是一个确切的答复,例如 0x00003008 ,而不会是正在执行的所有指令的一堆 PC 值。

这是因为当我们在询问 CPU 当前的 PC 值时,CPU 只选择了流水线中的一个固定的位置进行观测,就像只观测河流的一个截面一样。例如在我们每次询问时,CPU 都回答当前 D 级的 PC 值,那么 D 级就是 CPU 选取的固定位置,D 级的 PC 就是我们能够从外界看到的 PC ,于是我们将此时的 D 级 PC 称为宏观 PC 。

接下来,我们也要为我们的 CPU 选取一个固定的流水线级,作为宏观 PC 位于的流水线级。

当然,这个流水线级可不是乱选的,它的选取要满足一个要求,那就是:在使用流水线 CPU 执行指令时,从外界观看宏观 PC 出现的顺序,要和使用单周期 CPU 中执行相同指令时 PC 值出现的顺序是相同的!(这里只要求 PC 值出现的顺序相同,并不要求出现的周期数相同)

那么,在 CP0 模块位于 M 级的情况下,宏观 PC 位于的流水线级似乎也就唯一确定了。假设在单周期 CPU 中,我们读入某条指令,当 CPU 发现这条指令出现了内部异常,或者此时发生了外部中断,会在下一个周期跳转到内核指令的地址;在五级流水线 CPU 中,我们也同样需要先读入指令一个周期,如果此时出现了中断异常,那么就在下一个周期跳转至内核指令的地址。

根据上述理论,如果我们将观测宏观 PC 的流水线级设置在 W 级,那么就无法读到出现中断异常的那条指令的 PC 值;反过来,如果我们将观测宏观 PC 的流水线级设置在 F 级、D 级或 E 级,那么就会读到出现中断异常的那条指令后面几条指令的 PC 的值,这是本来不应该出现的。所以根据排除法,我们就能够得出一个结论:宏观 PC 要等于 CP0 模块所在流水线级的 PC 值!

【mfc0 指令、mtc0 指令】

刚才我们提到了两条新的指令:mfc0 指令和 mtc0 指令,不知道你有没有想起 4 位故人 —— 没错,和你想象中的一样,CP0 模块和 MDU 模块一样,都支持使用指令读取和写入其中的寄存器。

相信对于已经走到这里的你,仿照 MDU 模块加上两条读写 CP0 寄存器的新指令应该完全是小菜一碟了!不过在开动之前,还是有相当一些细节需要注意的,希望你一定不要错过:

首先是两条指令的结构,不同于之前的指令使用 OpCode 或者OpCode 和 Funct 来解析指令的类型,mfc0mtc0 两条指令是通过 OpCode 和 rs 两个部分解析指令的。两条指令的 OpCode 均为 6'b010000 ,区别在于 mfc0 指令的 rs 值固定为 5'b00000mtc0 指令的 rs 值固定为 5'b00100 。这就需要我们向 Controller 模块中额外传入当前指令的 rs 值,作为解析指令的依据。

(其实这两条指令的 rt 和 rd 值表示的含义也挺奇葩的,在动手之前请仔细看十遍甚至九遍,到底是读取哪个寄存器,写入哪个寄存器!笑)

接下来是 mtc0 指令的赋值问题。我们只允许使用 mtc0 指令写入 SR 和 EPC 两个寄存器,即修改中断异常的使能和返回现场时的返回地址,而不允许使用 mtc0 指令写入 Cause 和 PRId 两个寄存器。当 mtc0 指令的 rd 值为 13 或 15 时,我们不需要进行写入操作,让两个寄存器保持原值即可。

此外,在为 SR 寄存器赋值时,我们还会遇到这样一个问题:对于未实现的位数,也就是 SR 寄存器的 31 - 16 位和 9 - 2 位,我们是否应该将要写入数据的相应位写到这些未实现位中呢?理论上来说应该是无所谓的,但根据我本人上机考试的经验来说,我考的那一场 P7 如果不对未实现位赋值就会 WA(这可能是个 bug ,不知道数据组助教会不会修),所以还是建议直接将整个 32 位数据全部写入 SR 寄存器中!(其实如果这里出问题的话,报错信息会非常明显,所以考试的时候可以根据实际情况灵活调整一下~)

在了解了这些前置知识后,我们就可以开始实现两条新指令了!在 CP0 模块中,我们只需要根据写使能 WE 和寄存器编号 addr 判断将输入数据 WD 写入哪个寄存器,再根据寄存器编号 addr 将对应寄存器的值输出到 data 端口即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// CP0
always@(posedge clk) begin
if (WE) begin
if (addr == 5'b01100) begin
SR <= WD;
end
else if (addr == 5'b01110) begin
EPC <= WD;
end
end
end

assign data = (addr == 5'b01100) ? SR :
(addr == 5'b01101) ? Cause :
(addr == 5'b01110) ? EPC :
(addr == 5'b01111) ? PRId : 32'h00000000;

接下来就是加指令的固定流程了。我们在 Controller 模块中添加两条指令的相关信息。这一次,我们还需要加上两个新的选择信号,分别是 CP0 的写使能 cpzWrite ,和将 CP0 模块输出的结果与 ALU 模块输出的结果合流的 cpzfix

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// Controller
module Controller(
input [5:0] OpCode,
input [5:0] Funct,
input [4:0] rs,
output [1:0] NPC_Sel,
output RegWrite,
output EXTop,
output ALUsrc,
output RegDst,
output MemWrite,
output MemtoReg,
output PCtoReg,
output Regra,
output mffix,
output start,
output cpzfix,
output cpzWrite,
output [5:0] type,
output [3:0] t_rs,
output [3:0] t_rt,
output [3:0] t
);

parameter ADDU = 6'b000000,
......,
MFCZ = 6'b011001,
MTCZ = 6'b011010;

wire mfcz;
wire mtcz;

assign mfcz = (OpCode == 6'b010000 && rs == 5'b00000) ? 1'b1 : 1'b0;
assign mtcz = (OpCode == 6'b010000 && rs == 5'b00100) ? 1'b1 : 1'b0;

assign NPC_Sel = (j | jal) ? 2'b01 :
(jr | jalr) ? 2'b10 :
(beq | bne) ? 2'b11 : 2'b00;
assign RegWrite = (addu | subu | addiu | xori | lui | lw | lh | lb | jal | jalr | mfhi | mflo | mfcz) ? 1'b1 : 1'b0;
assign EXTop = (addiu | lw | lh | lb | sw | sh | sb) ? 1'b1 : 1'b0;
assign ALUsrc = (addiu | xori | lui | lw | lh | lb | sw | sh | sb) ? 1'b1 : 1'b0;
assign RegDst = (addu | subu | jalr | mfhi | mflo) ? 1'b1 : 1'b0;
assign MemtoReg = (lw | lh | lb) ? 1'b1 : 1'b0;
assign PCtoReg = (jal | jalr) ? 1'b1 : 1'b0;
assign Regra = (jal) ? 1'b1 : 1'b0;
assign mffix = (mfhi | mflo) ? 1'b1 : 1'b0;
assign start = (mult | multu | div | divu) ? 1'b1 : 1'b0;
assign cpzfix = (mfcz) ? 1'b1 : 1'b0;
assign cpzWrite = (mtcz) ? 1'b1 : 1'b0;
assign type = (addu) ? ADDU :
(subu) ? SUBU :
...... :
(mfcz) ? MFCZ :
(mtcz) ? MTCZ : 6'b111111;

// mfc0 mtc0 指令均无需使用 rs 寄存器
assign t_rs = (addu) ? 4'h1 :
...... :
(mfcz) ? 4'hf :
(mtcz) ? 4'hf : 4'hf;

// mfc0 指令不需要使用 rt 寄存器中的值,mtc0 指令需要在 M 级获得 rt 寄存器中的值
assign t_rt = (addu) ? 4'h1 :
...... :
(mfcz) ? 4'hf :
(mtcz) ? 4'h2 : 4'hf;

// mfc0 指令从 CP0 模块读取的值在 W 级可以开始转发,mtc0 指令不需要写 GRF 模块中的寄存器
assign t = (addu) ? 4'h2 :
...... :
(mfcz) ? 4'h3 :
(mtcz) ? 4'hf : 4'hf;

在主模块中,我们根据 cpzfix 信号将 CP0 模块的输出与 ALU 模块的输出合流:

1
2
3
4
5
6
7
8
9
10
11
12
// mips
wire [31:0] M_fixedoutputA;
assign M_fixedoutputA = (M_cpzfix) ? M_outputC : M_outputA;

Controller MController (.OpCode(M_OpCode), .Funct(M_Funct), .cpzfix(M_cpzfix), .cpzWrite(M_cpzWrite),
.rs(M_rs), .type(M_type), .t_rs(M_t_rs), .t_rt(M_t_rt), .t(M_t));

MWreg MWreg (.clk(clk), .reset(reset), .MW_en(MW_en), .MW_clear(MW_clear),
.M_Instr(M_Instr), .M_PCplus8(M_PCplus8), .M_PC(M_PC),
.M_A3(M_A3), .M_outputA(M_fixedoutputA), .M_data(M_data),
.W_Instr(W_Instr), .W_PCplus8(W_PCplus8), .W_PC(W_PC),
.W_A3(W_A3), .W_outputA(W_outputA), .W_data(W_data));

这样,我们就完成了 mfc0mtc0 两条指令的添加,是不是非常简单?最后,贴一下整个 CP0 模块的代码。还是那句话,仅供参考,切勿抄袭哟 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
module CP0(
input clk,
input reset,

input WE,
input [4:0] addr,
input [31:0] WD,
output [31:0] data,

input [31:0] VPC,
input BD,
input [4:0] ExcCode,
input [5:0] HWInt,
input EXLclr,
output [31:0] EPCout,
output Req
);

reg [31:0] SR;
reg [31:0] Cause;
reg [31:0] EPC;
reg [31:0] PRId;

`define SR_IM SR[15:10]
`define SR_EXL SR[1]
`define SR_IE SR[0]
`define Cause_BD Cause[31]
`define Cause_IP Cause[15:10]
`define Cause_ExcCode Cause[6:2]

always@(posedge clk) begin
if (reset) begin
SR <= 32'h00000000;
Cause <= 32'h00000000;
EPC <= 32'h00000000;
PRId <= 32'hb87860c0;
end
else begin
if (WE & ~Req) begin
if (addr == 5'b01100) begin
SR <= WD;
end
else if (addr == 5'b01110) begin
EPC <= WD;
end
end
if (Req) begin
`SR_EXL <= 1'b1;
`Cause_BD <= BD;
`Cause_ExcCode <= (~`SR_EXL && `SR_IE && (`SR_IM & HWInt)) ? 5'b00000 : ExcCode;
EPC <= (BD) ? (VPC - 32'h00000004) : VPC;
end
if (EXLclr) begin
`SR_EXL <= 1'b0;
end
`Cause_IP <= HWInt;
end
end

assign data = (addr == 5'b01100) ? SR :
(addr == 5'b01101) ? Cause :
(addr == 5'b01110) ? EPC :
(addr == 5'b01111) ? PRId : 32'h00000000;

assign EPCout = EPC;

assign Req = (~`SR_EXL && ((`SR_IE && (`SR_IM & HWInt)) || (ExcCode)));

endmodule

Final.3 中断异常全流程

在这一节,我们将从一次中断异常的整个流程的角度,来完善我们的 CPU 对中断异常的处理。话不多说,让我们发起最后的冲锋吧!

【内部异常的识别】

在这一部分,我们的目标只有一个,那就是向 CP0 模块输入正确的 ExcCode 值!首先,我们来了解一下可能会出现的内部异常:

内部异常类型 ExcCode
外部中断 0
PC 值对齐错误 1
PC 值范围错误 1
syscall 指令 2
未知指令 3
lw lh 指令读内存地址对齐错误 4
lw lh lb 指令读内存地址范围错误 4
lw lh lb 指令计算读内存地址时发生溢出 4
sw sh 指令写内存地址对齐错误 5
sw sh sb 指令写内存地址范围错误 5
sw sh sb 指令计算写内存地址时发生溢出 5
add sub addi 指令计算时发生溢出 6

(当同一条指令出现多个内部异常时,ExcCode 较小的内部异常优先级更高)

提前警告:这里的 ExcCode 值和指导书肯定对不上,直接 copy 有风险哦~

我们可以发现,能够检测出每种内部异常的流水线级各不相同,能够检测出每种内部异常的模块也各不相同。所以这些不同流水线级的不同模块都需要输出一个相应的 ExcCode ,我们需要把它们合并在一起,再输入 CP0 模块,这样就完成了这一部分的任务!

首先我们来看 ExcCode 为 0 的外部中断。这就很奇怪了,为什么外部中断会是内部异常呢?翻译翻译,什么叫 ExcCode 为 0 的外部中断?我们知道,其实在没有任何内部异常的情况下,ExcCode 本身就为 0 。这句话的意思其实就是,当出现外部中断时,外部中断的优先级要高于内部异常,此时需要将 ExcCode 置 0 ,也就是我们在 CP0 模块中已经实现过的内容,所以这一条我们就略过,不需要再实现了~

接下来是 PC 值相关的内部异常,也就是取指令过程中的内部异常。我们只需要在取指令前检查 F_PC 的范围和对齐是否正确即可。

实际上,即使我们使用错误的 PC 值,依然能够在内存中取出一条指令,当然这是一种未定义行为,就像在酒吧点炒饭一样,我们无法预测从内存中取出的指令是什么。为了规避胡乱取出的指令的风险,我们还需要在 PC 值异常的情况下,将取出的指令更改为空泡指令,防止出现不可预测的错误:

1
2
3
4
5
6
7
8
// mips
assign F_ExcCode = ((F_PC[1:0] != 2'b00) || !(F_PC >= 32'h00003000 && F_PC < 32'h00007000)) ? 5'b00001 : 5'b00000;

assign F_fixedInstr = (F_ExcCode) ? 32'h00000000 : F_Instr;

FDreg FDreg (.clk(clk), .reset(reset), .FD_en(FD_en), .FD_clear(FD_clear),
.F_Instr(F_fixedInstr), .F_PCplus8(F_PCplus8), .F_PC(F_PC),
.D_Instr(D_Instr), .D_PCplus8(D_PCplus8), .D_PC(D_PC));

接下来是 D 级的内部异常。在 D 级的 Controller 模块中,我们会对指令进行解析,判断指令的类型。在这一步中,我们就能够判断出当前指令是不是 syscall 指令,以及是否是无法识别类型的未知指令。

D 级的异常识别看起来也比较简单,不过其实还是暗藏了一些玄机,那就是我们一直忽略的 nop 指令!显然,无论是内存中本来就有的 nop 指令,还是清空流水线寄存器之后出现的空泡指令,都不应该被识别为内部异常。然而在我们之前的实现中,我们都没有在 Controller 模块中加入过 nop 指令,每当这条指令出现的时候,Controller 模块都是将其视作无法识别的指令,当然因为 nop 指令不进行任何写入的操作,所以也不会出现任何问题。现在,我们是时候还给 nop 指令一个名分了:

1
2
3
4
5
6
7
8
9
10
// Controller
assign nop = (Instr == 32'h00000000) ? 1'b1 : 1'b0;
assign type = (addu) ? ADDU :
(subu) ? SUBU :
...... :
(nop) ? NOP : 6'b111111;

// type 全 1 代表未实现或未知指令
assign ExcCode = (type == SYSCALL) ? 5'b00010 :
(type == 6'b111111) ? 5'b00011 : 5'b00000;

接下来是包揽了内部异常半壁江山的 E 级内部异常:如你所见,在 ALU 模块的加持下,余下的所有内部异常全部都可以在 E 级被识别出来。我们可以将它们大致分为 3 类:地址对齐错误、地址范围错误,以及加减法溢出错误。

首先是最简单的地址对齐错误,我们只需要在当前指令为 lw 指令或 sw 指令时,判断加法计算结果 outputA 的最低两位是否均为 0 ,以及在当前指令为 lh 指令或 sh 指令时,判断 outputA 的最低一位是否为 0 即可:

1
2
3
4
5
// ALU
assign ExcCode = (type == LW && AaddB[1:0] != 2'b00) ? 5'b00100 :
(type == LH && AaddB[0] != 1'b0) ? 5'b00100 :
(type == SW && AaddB[1:0] != 2'b00) ? 5'b00101 :
(type == SH && AaddB[0] != 1'b0) ? 5'b00101 : 5'b00000;

接下来是地址范围错误。这里要注意的是,除了指导书中给出的内存访问范围 0x000000000x00003000 ,我们的访存指令还可以访问 0x00007fxx 的一些地址,这些地址的含义我们后续会了解到,这里只需要确认一下它们的范围即可。具体地来说,lw 指令能够额外访问 0x00007f00 0x00007f04 0x00007f08 0x00007f10 0x00007f14 0x00007f18 这 6 个地址;sw 指令能够额外访问 0x00007f00 0x00007f04 0x00007f10 0x00007f14 这 4 个地址;除此之外,所有的访存指令都都能额外访问 [0x00007f20, 0x00007f24) 范围内的地址(当然要在满足对齐要求的前提下):

1
2
3
4
5
6
7
8
9
10
11
12
// ALU
assign ExcCode = (type == LW && !((AaddB >= 32'h00000000 && AaddB < 32'h00003000) || (AaddB >= 32'h00007f00 && AaddB < 32'h00007f0c) ||
(AaddB >= 32'h00007f10 && AaddB < 32'h00007f1c) || (AaddB >= 32'h00007f20 && AaddB < 32'h00007f24))) ? 5'b00100 :

((type == LH || type == LB) && !((AaddB >= 32'h00000000 && AaddB < 32'h00003000) ||
(AaddB >= 32'h00007f20 && AaddB < 32'h00007f24))) ? 5'b00100 :

(type == SW && !((AaddB >= 32'h00000000 && AaddB < 32'h00003000) || (AaddB >= 32'h00007f00 && AaddB < 32'h00007f08) ||
(AaddB >= 32'h00007f10 && AaddB < 32'h00007f18) || (AaddB >= 32'h00007f20 && AaddB < 32'h00007f24))) ? 5'b00101 :

((type == SH || type == SB) && !((AaddB >= 32'h00000000 && AaddB < 32'h00003000) ||
(AaddB >= 32'h00007f20 && AaddB < 32'h00007f24))) ? 5'b00101 :5'b00000;

最后是加减法溢出错误,还记得如何判断加减法溢出吗?虽然你肯定学过,但我还是假装你忘了,带你复习一下:首先把相加或相减的两个数都有符号扩展到 33 位,接下来进行加法或减法运算,得到一个 33 位的结果。如果结果的最高两位数字相同,则没有发生溢出;反之则发生了溢出。掌握了溢出的基本原理,判断这种内部异常当然就是手到擒来了!

1
2
3
4
5
6
7
8
9
10
// ALU
assign add_overflow_temp = {inputA[31], inputA} + {inputB[31], inputB};
assign sub_overflow_temp = {inputA[31], inputA} - {inputB[31], inputB};
assign overflow = ((type == ADD || type == ADDI || type == LW || type == LH || type == LB ||
type == SW || type == SH || type == SB) && (add_overflow_temp[32] != add_overflow_temp[31])) ? 1'b1 :
((type == SUB) && (sub_overflow_temp[32] != sub_overflow_temp[31])) ? 1'b1 : 1'b0;

assign ExcCode = ((type == LW || type == LH || type == LB) && overflow) ? 5'b00100 :
((type == SW || type == SH || type == SB) && overflow) ? 5'b00101 :
((type == ADD || type == SUB || type == ADDI) && overflow) ? 5'b00110 : 5'b00000;

到目前为止,我们已经判断出了所有的内部异常,在 F 级、D 级、E 级都分别得到了一个 ExcCode 。接下来,我们来将这些 ExcCode 合并在一起,在 M 级形成一个最终的 ExcCode ,传入 CP0 模块即可。

我们以 D 级为例,在 D 级会出现 Controller 模块输出的 D_ExcCode ,以及 FDreg 流水线寄存器输出的来自 F 级的 FD_ExcCode ;我们需要将二者合并为一个 D_fixedExcCode 。考虑二者的优先级,从 F 级流水而来的 FD_ExcCode 的优先级要高于 D 级 Controller 模块的 D_ExcCode ,于是我们有如下实现方法:

1
2
3
4
5
6
7
8
// mips
// F_ExcCode -> FD_ExcCode
assign D_fixedExcCode = (FD_ExcCode != 5'b00000) ? FD_ExcCode : D_ExcCode;
// D_fixedExcCode -> DE_ExcCode
assign E_fixedExcCode = (DE_ExcCode != 5'b00000) ? DE_ExcCode : E_ExcCode;
// E_fixedExcCode -> EM_ExcCode
assign M_fixedExcCode = EM_ExcCode;
// M_fixedExcCode -> (CP0)ExcCode

【外部中断的处理】

在 CP0 认识模块的过程中,我们了解到 CP0 的外部中断信号是由一个 6 位的输入端口 HWInt 得到的,HWInt 的 6 位分别代表着 6 台外部设备,某一位置 1 就代表相应的外部设备发出了中断信号。

那么这个输入 CP0 模块的 HWInt 值从何而来呢?实际上 HWInt 值来自比主模块更加遥远的远方,这个值在主模块中依然依靠一个叫 HWInt 的输入端口输入进来,我们暂时不需要关心它是如何产生的,可以将它看作是一个 6 位的随机数(当然大多数时候应该是 6'b000000 ,要不然 CPU 一直陷入也不用跑了)。

此外,HWInt 还有一个特殊之处,那就是它不并不是从 F 级进入,随流水线流水至 M 级,而是从主模块的输入端口直接连接 M 级的 CP0 模块!

根据 HWInt 值随机产生和直连 CP0 模块这两个特点,你能够发现其中的可怕之处吗?想象你是一条指令,没有任何内部异常,当你悠闲地流水到 M 级的时候,突然天上劈下来一道闪电 HWInt ,正好把你击中,烤得你外焦里嫩,于是只能被送往内核就医(悲)。

当然,你不是一条悠闲的指令,你是一个熬夜设计 CPU 的苦 B 大学生。对于你来说,外部中断就像是一把达摩克利斯之剑,你永远不知道这把剑在哪个周期掉下来,又会劈中哪条指令。被劈中的可能是任何一条指令,甚至有可能是 —— 清空流水线寄存器产生的空泡指令。

再想象一下这个场景:当阻塞产生时,我们会清空 E 级的所有内容,形成一条空泡指令。当这条空泡指令运行到 M 级时,突然产生了一个外部中断。于是我们向 CP0 模块写入相关信息,并陷入内核。在内核中执行了一些指令之后,我们返回现场,结果发现:我们要返回到哪里呢?

坏了!我们在清空寄存器时,一不小心把所有内容都清空了,这就导致我们在外部中断产生时,向 CP0 模块写入的 ExcCode VPC BD 全部都是 0 ,导致我们返回现场的时候找不到返回的地址了!

为了解决这个问题,我们在使用 resetclear 清空流水线寄存器的时候,就不能无脑清空所有的信息了,而是应该适当让一些信息流进流水线寄存器,也就是 PC 值和 BD 值。ExcCode 其实清空也可以,因为是为了解决外部中断击中空泡指令,而外部中断时 ExcCode 在 CP0 模块中本来也会被清零,所以可以不流水:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// DEreg
always@(posedge clk) begin
if (reset | DE_clear) begin
E_Instr <= 32'h00000000;
E_PCplus8 <= 32'h00000000;
E_PC <= reset ? 32'h00003000 : D_PC;
E_RD1 <= 32'h00000000;
E_RD2 <= 32'h00000000;
E_A3 <= 5'b00000;
E_ext32 <= 32'h00000000;
E_ExcCode <= 5'b00000;
E_BD <= reset ? 1'b0 : D_BD;
end
else begin
......
end
end

(其它模块的操作完全相同,这里就不水篇幅了~)

在后续,我们还会遇到其它清空流水线寄存器的情况。每当出现这种情况的时候,我们都要思考一下,为了能够应对外部中断击中空泡指令,我们该如何对空泡指令进行流水。

【陷入内核】

目前,我们已经成功将内部异常信号 ExcCode 和外部中断信号 HWInt 送入了 CP0 模块,在陷入内核之前,我们还需要做最后的准备。为了能够正确返回现场,我们还需要向 CP0 模块提供当前现场的一些信息用于记录,也就是当前的 PC 值 VPC 以及判断当前指令是否在延迟槽中的 BD

首先是 VPC 值,这个值当然就是此时的 M_PC 值了,我们直接拿来用即可,so easy !

接下来是 BD 值,这是一个新出现的信息,需要我们精心准备一下。既然是要判断当前指令是否在延迟槽中,那其实就是要判断当前指令的上一条指令是不是跳转指令了!不过,我们从之前的内容中也大致可以看出,BD 值并不是在 M 级直接产生的,而是随流水线从 F 级流入 M 级的。我们为什么不能在中断异常产生时直接检测 W 级的指令是否是跳转指令呢?

其实问题的答案往往出现在题面上,在上面的问题中,我们把“ M 级指令的上一条指令”偷换成了“ W 级指令” ,那么问题来了,W 级的指令一定是 M 级指令的上一条指令吗?这就要提到阻塞这种情况了!

我们想象一下,如果 M 级指令曾经被阻塞过,此时 W 级的指令就只剩下一条空泡,真正的前一条指令早已经功成身退,完全消失不见了!所以我们还是要在阻塞没有发生之前,也就是指令位于 F 级,前一条指令位于 D 级的时候,就生成 BD 信号,再一步一步随流水流到 M 级:

1
2
3
// mips
assign D_isjump = (D_type == BEQ || D_type == BNE || D_type == J || D_type == JAL || D_type == JR || D_type == JALR);
assign F_BDIn = (D_isjump) ? 1'b1 : 1'b0;

在将内部异常信号 ExcCode 和外部中断信号 HWInt 送入 CP0 模块,并向 CP0 模块写入陷入现场的信息后,我们就可以开始陷入内核了!

陷入内核的过程,其实就是立即停止出现中断异常的指令,以及后续所有指令的执行,从陷入的内核地址开始重新执行。具体来说,我们要做的一共有三件事:跳转到内核地址、清空所有流水线寄存器,以及你可能没想到的 —— 阻止现有指令写 MDU 寄存器、CP0 寄存器和内存!而在背后操纵着一切的,其实只有一个小小的 Req 信号。

跳转到内核地址还是比较简单的,你可能已经轻车熟路了,只需要将 Req 信号传入 NPC 模块,当 Req 为 1 时,将输出的 NPC 值设置为内核地址 0x00004180 即可:

1
2
3
4
5
6
7
8
9
// NPC
assign NPC_Req = 32'h00004180;
// 这里注意 Req 的优先级要高于普通跳转
assign NPC = (Req) ? NPC_Req :
(NPC_Sel == PCPLUS4) ? NPC_PCplus4 :
(NPC_Sel == IMM26) ? NPC_imm26 :
(NPC_Sel == GRFconst) ? NPC_GRF :
(NPC_Sel == IMM16 && zero) ? NPC_imm16 :
NPC_PCplus4; // NPC_Sel == IMM16 && !zero

不过这里还有一个非常大的坑点,那就是 NPC 模块能够正确输出 0x00004180 ,不代表这个地址能够进入 PC 模块!这是为什么呢?试想一下下面这种情况:

Req 信号产生的周期,NPC 模块会正确输出 0x00004180 ,这本身没有任何问题;然而在同一周期的时候,假如位于 D 级的指令是 beq 指令,位于 E 级的指令是 addu 指令,非常不巧的是,两条指令之前恰好又有数据冲突,那么按理来说,我们的 CPU 在下个周期就会发生阻塞。

于是,当下个周期的时钟上升沿到来时,虽然 beq 指令和 addu 指令被消灭了,但是它们的阻塞信号 D_stall 却留了下来,作用在了 PC 模块、FDreg 流水线寄存器和 DEreg 流水线寄存器上。其实对于两个流水线寄存器来说还好,我们马上就会了解到,Req 指令能够清空流水线寄存器,这个操作的优先级非常高,仅次于 reset 操作,所以会覆盖掉 FDreg 流水线寄存器的关闭使能操作,也会覆盖掉 DEreg 流水线寄存器的清空操作。

然而对于 PC 模块,我们就需要多加注意了!当阻塞信号 D_stallReq 信号同时产生时,我们需要禁止阻塞信号 D_stall 关闭 PC 模块的写使能,否则就会导致 NPC 模块输出的 0x00004180 无法被 CPU 模块读入,出现无法陷入内核的情况!所以我们需要像下面这样调整 PC 模块的写使能条件,这样就解决了这个非常隐蔽的问题:

1
2
// mips
assign PC_en = (D_stall && ~Req) ? 1'b0 : 1'b1;

接下来是清空所有流水线寄存器,我们直接将 Req 输入所有的流水线寄存器,和 resetclear 信号并列。这个时候我们就需要警觉起来了!那就是我们不能清空所有信息,依然要考虑为流水线寄存器中的 PCBD 赋值!

对于 Req 的清空来说,PC 值应该转移到了内核地址,所以我们将所有流水线寄存器的 PC 值设置为 0x00004180 ;内核地址对应的指令当然也不可能是延迟槽内的指令,所以我们将所有流水线寄存器的 BD 值设置为 0 。

另外,我们还需要考虑一下 Reqreset clear 之间的优先级关系。reset 的优先级当然最高,当它出现的时候,一切都将回归原始,不复存在;而 Req 的优先级又应该高于 clear ,当陷入内核的时候,所有指令都被消灭,自然也不需要考虑阻塞的问题了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// DEreg
always@(posedge clk) begin
if (reset | Req | DE_clear) begin
E_Instr <= 32'h00000000;
E_PCplus8 <= 32'h00000000;
E_PC <= reset ? 32'h00003000 : Req ? 32'h00004180 : D_PC;
E_RD1 <= 32'h00000000;
E_RD2 <= 32'h00000000;
E_A3 <= 5'b00000;
E_ext32 <= 32'h00000000;
E_ExcCode <= 5'b00000;
E_BD <= reset ? 1'b0 : Req ? 1'b0 : D_BD;
end
else begin
......
end
end

(其它模块依旧是同理,这里也不全写出来了)

最后是一个很容易被忽略的问题,那就是我们需要防止 M 级指令,以及尚未到达 M 级的指令,产生任何的执行效果,也就是写寄存器( CP0 寄存器、MDU 寄存器)和内存。

我们禁止这些指令写寄存器和内存,原因也是比较显然的,毕竟在 M 级检测到中断异常的时候,我们要立即停止这些指令的执行,转而陷入内核。不过也不需要着急,在返回现场的时候,这些指令会再次进入流水线,再次尝试执行。

(所以这里也就能解释我们为什么不禁止写 GRF 模块中的寄存器了,因为这个操作发生在 W 级。当 M 级检测到中断异常时,W 级指令不会被清空,需要正常执行。想象一下如果 W 级指令被清空的话,返回现场的时候还会再执行这条指令吗?当然也不会了)

我们首先来看 CP0 寄存器和内存,如果我们想防止将要被清除的指令对它们造成影响,只需要在 M 级检测到中断异常的时候,关闭它们的写使能即可。

对于 BE 模块,我们只需要向其中传入 Req 信号,当 Req 信号为 1 时,我们需要将每个字节的写使能都置为 0 :

1
2
3
4
5
6
7
8
9
// BE
assign MemByteWrite = (Req) ? 4'b0000 :
(type == SW) ? 4'b1111 :
(type == SH && addr[1] == 1'b0) ? 4'b0011 :
(type == SH && addr[1] == 1'b1) ? 4'b1100 :
(type == SB && addr[1:0] == 2'b00) ? 4'b0001 :
(type == SB && addr[1:0] == 2'b01) ? 4'b0010 :
(type == SB && addr[1:0] == 2'b10) ? 4'b0100 :
(type == SB && addr[1:0] == 2'b11) ? 4'b1000 : 4'b0000;

对于 CP0 模块,我们直接原汤化原食,当 Req 为 1 时,禁止 mtc0 指令的写入:

1
2
3
4
5
6
7
8
9
10
11
// CP0
always@(posedge clk) begin
if (WE & ~Req) begin
if (addr == 5'b01100) begin
SR <= WD;
end
else if (addr == 5'b01110) begin
EPC <= WD;
end
end
end

接下来是 MDU 模块,这里可就不只是关闭写使能这么简单了,因为从理论上来说,我们要阻止的不仅是当前位于 E 级的指令写入 HILO 两个寄存器,还有当前位于 M 级的指令在上个周期的写入!

这就很难办了,我们在之前就了解到,外部中断信号是没有办法提前知道的。假如有一条 mthi 指令在 M 级收到了外部中断,此时它已经写完了 HI 寄存器,我们完全来不及在写入之前就通过关闭写使能及时阻止,所以我们需要实现 HI LO 寄存器的撤回操作,去拯救那个还没有被外部中断欺骗的 MDU 模块!

实际上,有很多种手段都能够实现撤回操作,你可以脑洞大开随意想象。不过我们这里使用一种最简单的方法,不是在外部中断发生时检测位于 M 级的指令是否写过 HI LO 寄存器,如果写过 HI LO 寄存器则撤回这次写入操作;而是在外部中断发生时直接逆转时空,将 HI LO 寄存器回溯到一个周期前,也就是 外部中断发生时位于 W 级的指令 刚离开 MDU 模块时的状态。

为了实现这种效果,我们在 MDU 模块中设置两个新寄存器 lastHIlastLO ,在每个时钟上升沿,我们都将当前 HI LO 寄存器中的值写入 lastHI lastLO 寄存器中,无论 HI LO 寄存器的值是否发生改变。因为我们始终要记得,我们要实现的是无条件的回溯,与 HI LO 寄存器是否被写入没有任何关系!

这样,当在 M 级检测到外部中断时,我们就可以直接将 lastHI lastLO 寄存器中的值写回 HI LO 寄存器,当然同时也要禁止此时位于 E 级的指令的写入,这样就实现了整个回溯的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
module MDU(
input clk,
input reset,
input [31:0] inputA,
input [31:0] inputB,
input [4:0] type,
input start,
input Req,
output [31:0] outputB,
output busy
);

reg [31:0] HI;
reg [31:0] LO;
reg [31:0] lastHI;
reg [31:0] lastLO;
reg [3:0] count;

......

always@(posedge clk) begin
if (reset) begin
HI <= 32'h00000000;
LO <= 32'h00000000;
lastHI <= 32'h00000000;
lastLO <= 32'h00000000;
count <= 4'h0;
end
else if (~Req) begin
......
end
else begin
HI <= lastHI;
LO <= lastLO;
end
lastHI <= HI;
lastLO <= LO;
end

......

endmodule

好了,看到这里,我们可以把整个回溯流程删掉了(笑)!因为我们的指导书要求,在 CP0 模块位于 M 级的这种设计下,当出现中断异常时,我们不需要考虑此时位于 M 级的指令对 MDU 模块造成的修改,只需要对此时位于 E 级的指令关闭 MDU 模块的写使能即可(也就是只保留 else if (~Req) 那行即可)。但是说实话,我对这种纯粹为了降低难度而放弃正确性的要求并不是特别满意(纯粹个人想法),It’s not professional and it’s not ethical !

这样,我们就实现了整个陷入内核的过程。由于我们的 CPU 并不能真的实现内核级的操作,所以内核中的指令其实和普通指令一样,并没有什么特别(当然其实也并没有处理中断异常的能力,这个在之前也提到过)。我们就跳过这段无聊的过程,直接来到最后的返回现场。

【返回现场】

当我们执行完内核中的指令之后,会使用一个名为 eret 的新指令返回现场。eret 其实只是一个很普通的指令,千万不要把它想得太复杂。实际上 eret 指令的行为非常接近于 j 指令,都是进行无条件跳转,只不过 eret 指令不需要指定跳转的地址,跳转的地址只能是 CP0 模块中的 EPC ;另外,eret 还有一个重要的特性,那就是执行的时候需要 跳 过 延 迟 槽 ~

在 Controller 模块中的那些陈腔滥调我们就直接跳过了,参照 j 指令来写即可,我们还是来重点关注一下上面提到的新特性吧!

这一次,我们又遇到了跳过延迟槽这个老朋友,还记得我们之前是怎么处理的吗?在跳转指令位于 D 级时,从 CMP 模块输出信号,在下个时钟上升沿,通过清空 FDreg 流水线寄存器的方式清空原来在 F 级的指令。

想起来了吗?好,我们不用这种做法!(笑)这种做法对于 eret 指令来说还是太麻烦了,毕竟还要清空寄存器,又要考虑那个老生常谈的问题,是吧?

所以说,过去跳过延迟槽的方式都弱爆了!这一次,我们选择直接将 F 级的指令换成 EPC 地址对应的指令!当 eret 指令出现在 D 级的时候,我们直接将当前的 PC 值换成 EPC 值;非常巧妙的是,由于此时 D 级的 eret 指令不是之前的任何一种跳转指令,所以此时 NPC 模块的输出值直接就等于 F_PC + 4 ,也就是 EPC + 4 ;这样就形成了完美衔接,只需要非常微小的改动即可完成跳过延迟槽的实现:

1
2
3
4
5
6
7
8
9
// mips
// 由于 F_PC 使用的场合过多,为了避免漏改,我们直接将 PC 模块的输出端改为 F_oldPC ,修正值保持 F_PC 不变
wire D_eret;
wire [31:0] F_oldPC;

assign D_eret = (D_type == ERET) ? 1'b1 : 1'b0;
assign F_PC = D_eret ? EPC : F_oldPC;

PC PC (.clk(clk), .reset(reset), .PC_en(PC_en), .NPC(F_NPC), .PC(F_oldPC));

还有一件事情不要忘了,当 eret 指令流水到 M 级的时候,我们需要将其作为 EXLclr 传入 CP0 模块中,用于将 SR_EXL 的值置 0 。

1
2
3
4
5
6
7
// mips
wire M_eret;

assign M_eret = (M_type == ERET) ? 1'b1 : 1'b0;

CP0 CP0 (.clk(clk), .reset(reset), .WE(M_cpzWrite), .addr(M_rd), .WD(M_fixedRD2), .data(M_outputC),
.VPC(M_PC), .BD(M_BD), .ExcCode(M_fixedExcCode), .HWInt(HWInt), .EXLclr(M_eret), .EPCout(EPC), .Req(Req));

结束了吗?并非如此。我们目前的设计中还存在最后一个 bug 。如果你能够在没有任何提示的情况下发现这个 bug ,那么你对五级流水线的掌握一定达到了逆天的程度!

我们思考这样一个场景,如果 eret 指令的前一条或前两条指令是 mtc0 指令,且这条 mtc0 指令要写 EPC 寄存器,会发生什么情况?

是不是惊出一身冷汗来了?因为 EPC 寄存器还没写入 CP0 模块中,所以跳转的地址会发生错误!啊呀,骇死我力!

要想解决这个问题,我们看来是要增加新的转发通路了。不过毕竟是到最后了,就不要考虑太多了,根据往年经验来看,这里直接暴力阻塞一下就可以:

1
2
3
4
5
6
7
8
9
10
11
// mips
// rd == 5'b01110 即写 CP0 模块的 14 号寄存器(EPC 寄存器)
assign D_stall = (E_A3 != 5'b00000 && D_rs == E_A3 && D_t_rsuse != 4'hf && E_t_new != 4'hf && D_t_rsuse < E_t_new) ? 1'b1 :
(E_A3 != 5'b00000 && D_rt == E_A3 && D_t_rtuse != 4'hf && E_t_new != 4'hf && D_t_rtuse < E_t_new) ? 1'b1 :
(M_A3 != 5'b00000 && D_rs == M_A3 && D_t_rsuse != 4'hf && M_t_new != 4'hf && D_t_rsuse < M_t_new) ? 1'b1 :
(M_A3 != 5'b00000 && D_rt == M_A3 && D_t_rtuse != 4'hf && M_t_new != 4'hf && D_t_rtuse < M_t_new) ? 1'b1 :
(W_A3 != 5'b00000 && D_rs == W_A3 && D_t_rsuse != 4'hf && W_t_new != 4'hf && D_t_rsuse < W_t_new) ? 1'b1 :
(W_A3 != 5'b00000 && D_rt == W_A3 && D_t_rtuse != 4'hf && W_t_new != 4'hf && D_t_rtuse < W_t_new) ? 1'b1 :
(D_ismult & (E_start | E_busy)) ? 1'b1 :
(D_eret && E_type == MTCZ && E_rd == 5'b01110) ? 1'b1 :
(D_eret && M_type == MTCZ && M_rd == 5'b01110) ? 1'b1 : 1'b0;

到这里,我们终于完成了所有中断异常的处理。恭喜你已经完成了绝大部分的内容,让我们最后休息一下,向 Boss 发起最后一击!

Final.4 外部设备模拟

海的那边是什么?

在最后一节,我们将要离开我们从零构建起的主模块,探索主模块外面更广阔的世界。在那里,我们所有的未解之谜,随机劈下的闪电 HWInt 是如何产生的,地址超出 0x00003000 时访存指令究竟访问了哪里,都将得到一一揭晓。

【Timer 模块导读】

离开主模块的第一站,我们遇到了一位新朋友 —— Timer 模块,用于实现外部中断信号的模拟。在之前了解外部中断时,我们暂且认为外部中断信号 HWInt 是随机生成的,其实也是有迹可循,例如 Timer 模块的输出 IRQ 就是 HWInt 的其中一位。接下来我们就来了解一下这一位外部中断信号是如何生成的。

Timer 模块由课程组提供,不需要我们自己编写。为了避免 Timer 模块的实现方式发生改动,下面贴一下 Timer 模块除注释外的全部内容。如果发生了影响输出值的改动,请联系我,我将重新编写这一部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
`timescale 1ns / 1ps
`define IDLE 2'b00
`define LOAD 2'b01
`define CNT 2'b10
`define INT 2'b11

`define ctrl mem[0]
`define preset mem[1]
`define count mem[2]

module TC(
input clk,
input reset,
input [31:2] Addr,
input WE,
input [31:0] Din,
output [31:0] Dout,
output IRQ
);

reg [1:0] state;
reg [31:0] mem [2:0];

reg _IRQ;
assign IRQ = `ctrl[3] & _IRQ;

assign Dout = mem[Addr[3:2]];

wire [31:0] load = Addr[3:2] == 0 ? {28'h0, Din[3:0]} : Din;

integer i;
always @(posedge clk) begin
if(reset) begin
state <= 0;
for(i = 0; i < 3; i = i+1) mem[i] <= 0;
_IRQ <= 0;
end
else if(WE) begin
mem[Addr[3:2]] <= load;
end
else begin
case(state)
`IDLE : if(`ctrl[0]) begin
state <= `LOAD;
_IRQ <= 1'b0;
end
`LOAD : begin
`count <= `preset;
state <= `CNT;
end
`CNT :
if(`ctrl[0]) begin
if(`count > 1) `count <= `count-1;
else begin
`count <= 0;
state <= `INT;
_IRQ <= 1'b1;
end
end
else state <= `IDLE;
default : begin
if(`ctrl[2:1] == 2'b00) `ctrl[0] <= 1'b0;
else _IRQ <= 1'b0;
state <= `IDLE;
end
endcase
end
end

endmodule

我们首先来观察一下 Timer 模块中的寄存器:state mem_IRQ

显然 Timer 模块就是一个大型的有限状态机,state 就表示状态机的状态,一共有 4 种,分别是 0 号状态 IDLE(初始状态)、1 号状态 LOAD 、2 号状态 CNT ,以及 3 号状态 INT

mem 是一个 32 位的 reg 型数组,其中共有 3 个 reg 型变量,分别是 0 号寄存器 ctrl 、1 号寄存器 preset ,以及 2 号寄存器 count 。在这三个寄存器中,ctrl 寄存器负责控制状态机的各种行为,第 3 位是 Timer 中断信号的使能,第 2 位和第 1 位用于区分状态机的两种运行模式,第 0 位用于状态机的开启与关闭,其它未实现位永远为 0 ;preset 寄存器用于写入 Timer 中断信号产生前总共需要的倒计时;count 寄存器用于进行 Timer 中断信号产生前的倒计时。

_IRQ 寄存器是状态机的输出,也就是即将发出的 Timer 中断信号,不过真正的输出值 IRQ 还需要将 _IRQ 的值与 ctrl 寄存器的第 3 位进行与运算,也就是需要经过 Timer 中断信号的使能才能得到。

接下来是 Timer 模块的输入和输出。实际上,在 reset 复位后所有寄存器都被清零,包括 ctrl 寄存器的第 3 位,所以此时使能处于关闭状态,无法输出 Timer 中断信号,所以需要我们向 ctrl 寄存器赋值,手动开启 Timer 模块。

事实上,我们可以交互的寄存器,就是 mem 中的 3 个寄存器:ctrl presetcount 。我们观察 Addr WE Din Dout 这几个端口,和已经被开除的 DM 模块的端口非常相像,我们可以对照着进行理解。

在进行写入时,我们给出地址 Addr(这里 Addr 的第 3 位和第 2 位代表 mem 中寄存器的编号,另外,count 寄存器只能读取,不能写入),要写入的数据 Din ,以及写使能 WE ,就能够向寄存器中写入值了!顺带一提,我们根据上面的代码可以发现,在写使能 WE 开启,即向寄存器写入值时,状态机会暂停运作一个周期。

此外,我们也能读取这 3 个寄存器的值,只需要提供地址 Addr ,就可以从 Dout 端口读出相应的寄存器值了。

至于在 CPU 中,我们该使用什么样的指令,实现 Timer 模块中 3 个寄存器的写入与读取呢?我们在之后的系统桥中将会了解到,这里就先按下不表。

最后,我们来研究一下 Timer 模块中状态机的状态转移逻辑。Timer 中的状态机有两种不同的运行模式,分别对应 ctrl[2:1] == 2'b00ctrl[2:1] == 2'b01 ,我们就叫两种模式为 Mode 0 和 Mode 1 好了!

下面是 Mode 0 的状态转移图,我们根据这幅图来探究一下这种模式下的转移逻辑:

Picture Not Found!

我们从初始状态 IDLE 开始分析。可以看到,当 ctrl[0] 被置为 1 时,状态机开始启动,进入 LOAD 状态,同时将 _IRQ 寄存器复位为 0 。

LOAD 状态实际上只是一个过渡状态,状态机只会在这里停留 1 个周期,下一个状态直接无条件转移到 CNT 状态,同时将 preset 寄存器的值赋给 count 寄存器,开启倒计时。

接下来是 CNT 状态,当 count > 1 时,状态机会一直停留在 CNT 状态,直到 count == 1 ,才会转移到 INT 状态。在这个过程中,状态机在 CNT 状态刚好停留了 preset 个周期。在转移过程中,_IRQ 被置为 1 ,Timer 中断开启。在 CNT 状态下,我们还可以随时通过将 ctrl[0] 置 0 的方法复位状态机,使状态机直接回到 IDLE 状态。

最后的 INT 状态也同样是一个过渡状态,状态机依然只会在这里停留 1 个周期,随即转移到 IDLE 状态,同时将 ctrl[0] 置为 0 。也就是说,在转移回 IDLE 状态后,状态机会自动进行循环,停留在 IDLE 状态,直到我们再次将 ctrl[0] 置为 1 ,状态机才会再次启动。需要注意的是,状态机在 IDLE 状态循环的时候,_IRQ 依然为 1 ,即 Timer 中断始终处于开启状态,直到下次转移到 LOAD 状态时,Timer 中断才会关闭。

接下来我们来分析 Mode 1 的情况,还是先给出状态转移图:

Picture Not Found!

如果比对两张状态转移图,我们就能够发现,其实两种模式唯一的区别就是 INT 状态转移到 IDLE 状态时的操作。在 Mode 1 中,转移的操作为 _IRQ = 0 ,也就是只有状态机处于 INT 状态的 1 个周期内,Timer 中断是开启的,其余时间 Timer 状态都会关闭。

此外,Mode 1 并不会在 INT 状态转移到 IDLE 状态时将 ctrl[0] 置 0 ,于是状态机在回到 IDLE 状态时,只会在 IDLE 状态停留 1 周期,之后就会转移到 LOAD 状态。也就是说,除非我们手动将 ctrl[0] 置 0 ,否则状态机会一直循环下去。

分析了两种不同模式状态转移的全过程,我们就可以推测出这两种模式各自的用途了!Mode 0 用于在倒计时结束后,产生持续稳定的 Timer 中断信号;而 Mode 1 用于每个一段固定的时间,产生 1 个周期的 Timer 中断信号。

【系统桥】

恭喜你历尽千辛万苦,终于拿到了 CO 大陆的地图,在下面这幅图中,你将会看到 CO 大陆的全貌:

Picture Not Found!

图中的白色方框 mips 是我们目前的主模块吗?其实并不是。我们目前的主模块其实是图中黄色的 CPU 模块,黄色方块以外的部分都是我们尚未探索过的领域。

从这幅世界大地图中,我们能够得知一个不得不接受的现实,我们一直以来以为是整个世界的“主模块”,实际上只不过是真正的主模块 mips 的一小部分而已,于是我们为这个曾经的主模块起一个新的名字,叫作 CPU 模块。

新的主模块 mips 中一共有 4 个模块,除了我们最熟悉的 CPU 模块以外,还有一对完全相同的模块 Timer0 和 Timer1 ,以及一个名叫系统桥的模块 Bridge 。

Timer0 模块和 Timer1 模块我们也已经比较熟悉了,那么 Bridge 模块究竟是干什么用的呢?从图中我们也能略微猜出一些。我们发现,CPU 模块,Timer0 模块,Timer1 模块,以及它们外层的 mips 模块,都无法直接进行沟通,而是要通过 Bridge 模块作为中转站,实现互相的连接。

(从图中我们也能看出,CPU 模块、Timer0 模块、Timer1 模块和 Bridge 模块都隶属于 mips 模块,它们 4 个模块之间并没有隶属的关系,所以 CPU 模块、Timer0 模块、Timer1 模块与 Bridge 模块沟通时,一定会经过 mips 模块,也就是图中箭头中间穿过的白色部分。在我们的实现中,mips 模块确实需要为 3 个模块与 Bridge 模块之间的沟通牵线搭桥,也就是在 mips 中定义一个 wire 类型的变量连接在两个模块的端口之间,但是 mips 模块并不会窃听两个模块之间传递的信息,也就是不会使用这个 wire 类型变量的数据)

如此以来,读指令的操作变为了从 CPU 模块向 Bridge 模块传递指令的地址,再由 Bridge 模块传递至 mips 模块,mips 模块再将地址传至 testbench 中的内存中。在 testbench 的内存中取出指令之后,我们再将指令内容沿着 testbench -> mips -> Bridge -> CPU 的方向传回 CPU 。读写内存的操作也是完全相同。

之前我们提到了读写 Timer0 模块和 Timer1 模块中的 ctrl 等寄存器,同样是需要经过系统桥,沿着 CPU -> Bridge -> Timer0 / Timer1 ,Timer0 / Timer1 -> Bridge -> CPU 的方向传递信息。

不过话说回来,如果系统桥只是原封不动地交接信息的话,未免有些太鸡肋了吧!这就要提到系统桥的另外一个作用,那就是为不同的地址分配不同的外部设备!

虽然这本书就快要结束了,但是我们依然有两个问题悬而未决,一个是 lw lh lb sw sh sb 这些指令读写的 0x00007fxx 的地址是什么意思,另外一个就是我们应该如何读写 Timer0 模块和 Timer1 模块中的寄存器。在最后,我们就一次性把这两个问题解决掉!

我们在 CPU 中执行访存指令时,除了正常会读到的 0x000000000x00003000 这个范围外,还可能会出现 0x00007fxx 这样的地址。这些地址访问的其实并不是内存,而是外部设备中的存储空间,例如 Timer0 模块和 Timer1 模块中的寄存器,以及 testbench 中控制 interrupt 信号输出的部分。

而系统桥的作用之一,正是根据地址的不同范围,将传递的信息分发给不同的模块:

Picture Not Found!

(颈椎病治疗时间~)

上图中列出了部分数据的分发路径(这里省略了向 Timer0 模块的分发),除了黑色的一对一分发之外,每一种颜色都对应着一个数据的分发。

图中最关键的数据就是红色的 m_data_addr 数据,它就是访存指令给出的地址。当 CPU 模块的 m_data_addr0x000000000x00003000 的范围时,所有的数据都会被分发至 testbench 中的内存中。根据图片中的分发路径,此时 CPU 模块中的 m_data_addr 会成为 testbench 中的 m_data_addrm_data_byteen 会成为 testbench 中的 m_data_byteenm_data_wdata 会成为 testbench 中的 m_data_wdata ;同时 testbench 中的 m_data_rdata 会写入 CPU 模块中的 m_data_rdata

对于所有未被分发的外部设备,此时 Timer0 模块和 Timer1 模块的 Addr WE Din 接口被写入全 0 ,Dout 接口的输出值不会被采用;testbench 的 m_int_addrm_int_byteen 接口同样被写入全 0 。

当 CPU 模块的 m_data_addr0x00007f000x00007f10 的范围时,所有的数据都会被分发到 Timer0 模块中。根据图片中的分发路径,此时 CPU 模块中的 m_data_addr 会成为 Timer0 模块中的 Addrm_data_byteen 会(在所有位数按位与之后)成为 Timer0 模块中的 WEm_data_wdata 会成为 Timer0 模块中的 Din ;同时 Timer0 模块中的 Dout 会写入 CPU 模块中的 m_data_rdata

对于所有未被分发的外部设备,我们依旧是写入全 0 ,或者不采用其输出值,后面就不赘述了。

当 CPU 模块的 m_data_addr0x00007f100x00007f20 的范围时,所有的数据都会被分发到 Timer1 模块中。根据图片中的分发路径,此时 CPU 模块中的 m_data_addr 会成为 Timer1 模块中的 Addrm_data_byteen 会(在所有位数按位与之后)成为 Timer1 模块中的 WEm_data_wdata 会成为 Timer1 模块中的 Din ;同时 Timer1 模块中的 Dout 会写入 CPU 模块中的 m_data_rdata 。(水字数这一块)

当 CPU 模块的 m_data_addr0x00007f200x00007f30 的范围时,所有的数据都会被分发到 testbench 中 interrupt 的计算逻辑中。根据图片中的分发路径,此时 CPU 模块中的 m_data_addr 会成为 testbench 中的 m_int_addrm_data_byteen 会成为 testbench 中的 m_int_byteen 。特殊的是,在此时 CPU 模块中 m_data_wdata 的值不会被使用,同时 CPU 模块中的 m_data_rdata 也会固定被写入全 0 。

(这里简单介绍一下 testbench 中 interrupt 的计算逻辑,这是一个手动生成外部中断信号的结构,我们在 testbench 中指定一个宏观 PC ,它就能够在这个宏观 PC 的当前周期开始不断输出 interrupt ,作为 HWInt 的一位输入 CPU 模块中,在下个周期的时钟上升沿产生外部中断。而我们输入的 m_data_addrm_data_byteen 是用来取消这个不断输出的 interrupt 的)

最后,我们还要了解一下比较特殊的 HWInt 信号。从图中蓝色的分发路径中我们能够看出,它的来源有 Timer0 模块和 Timer1 模块输出的 IRQ 值,同时也有 testbench 输出的 interrupt 值。不过 HWInt 并不是根据地址分选得到,而是我全都要!无论是两个 IRQ 值,还是 interrupt 值,它们全都是 HWInt 的天使,所以 HWInt 的值等于 {3'b000, mips_interrupt, timer1_IRQ, timer0_IRQ}(是的,其实所谓 6 个外部设备实际上只有 3 个哈哈):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
// mips(不是原来的那个,这回是新的主模块)
module mips(
input clk,
input reset,

input [31:0] i_inst_rdata,
output [31:0] i_inst_addr,

output [31:0] macroscopic_pc,
input interrupt,
output [31:0] m_int_addr,
output [3:0] m_int_byteen,

output [31:0] m_data_addr,
output [3:0] m_data_byteen,
output [31:0] m_data_wdata,
input [31:0] m_data_rdata
);

// CPU and Bridge
wire [31:0] CPU_i_inst_addr;
wire [31:0] CPU_i_inst_rdata;

wire [31:0] CPU_macroscopic_pc;
wire [5:0] CPU_HWInt;

wire [31:0] CPU_m_data_addr;
wire [3:0] CPU_m_data_byteen;
wire [31:0] CPU_m_data_wdata;
wire [31:0] CPU_m_data_rdata;

// Timer0 and Bridge
wire [31:2] t0_Addr;
wire t0_WE;
wire [31:0] t0_Din;
wire [31:0] t0_Dout;
wire t0_IRQ;

// Timer1 and Bridge
wire [31:2] t1_Addr;
wire t1_WE;
wire [31:0] t1_Din;
wire [31:0] t1_Dout;
wire t1_IRQ;

CPU CPU (
.clk(clk),
.reset(reset),

.i_inst_addr(CPU_i_inst_addr),
.i_inst_rdata(CPU_i_inst_rdata),

.macroscopic_pc(CPU_macroscopic_pc),
.HWInt(CPU_HWInt),

.m_data_addr(CPU_m_data_addr),
.m_data_byteen(CPU_m_data_byteen),
.m_data_wdata(CPU_m_data_wdata),
.m_data_rdata(CPU_m_data_rdata)
);

Bridge Bridge (
// attach CPU
.CPU_i_inst_addr(CPU_i_inst_addr),
.CPU_i_inst_rdata(CPU_i_inst_rdata),

.CPU_macroscopic_pc(CPU_macroscopic_pc),
.CPU_HWInt(CPU_HWInt),

.CPU_m_data_addr(CPU_m_data_addr),
.CPU_m_data_byteen(CPU_m_data_byteen),
.CPU_m_data_wdata(CPU_m_data_wdata),
.CPU_m_data_rdata(CPU_m_data_rdata),

// attach mips
.mips_i_inst_addr(i_inst_addr),
.mips_i_inst_rdata(i_inst_rdata),

.mips_macroscopic_pc(macroscopic_pc),
.mips_interrupt(interrupt),
.mips_m_int_addr(m_int_addr),
.mips_m_int_byteen(m_int_byteen),

.mips_m_data_addr(m_data_addr),
.mips_m_data_byteen(m_data_byteen),
.mips_m_data_wdata(m_data_wdata),
.mips_m_data_rdata(m_data_rdata),

// attach Timer0
.timer0_Addr(t0_Addr),
.timer0_WE(t0_WE),
.timer0_Din(t0_Din),
.timer0_Dout(t0_Dout),
.timer0_IRQ(t0_IRQ),

// attach Timer1
.timer1_Addr(t1_Addr),
.timer1_WE(t1_WE),
.timer1_Din(t1_Din),
.timer1_Dout(t1_Dout),
.timer1_IRQ(t1_IRQ)
);

TC Timer0 (
.clk(clk),
.reset(reset),
.Addr(t0_Addr),
.WE(t0_WE),
.Din(t0_Din),
.Dout(t0_Dout),
.IRQ(t0_IRQ)
);

TC Timer1 (
.clk(clk),
.reset(reset),
.Addr(t1_Addr),
.WE(t1_WE),
.Din(t1_Din),
.Dout(t1_Dout),
.IRQ(t1_IRQ)
);

endmodule
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
// Bridge
module Bridge(
// attach CPU
input [31:0] CPU_i_inst_addr,
output [31:0] CPU_i_inst_rdata,

input [31:0] CPU_macroscopic_pc,
output [5:0] CPU_HWInt,

input [31:0] CPU_m_data_addr,
input [3:0] CPU_m_data_byteen,
input [31:0] CPU_m_data_wdata,
output [31:0] CPU_m_data_rdata,

// attach mips
output [31:0] mips_i_inst_addr,
input [31:0] mips_i_inst_rdata,

output [31:0] mips_macroscopic_pc,
input mips_interrupt,
output [31:0] mips_m_int_addr,
output [3:0] mips_m_int_byteen,

output [31:0] mips_m_data_addr,
output [3:0] mips_m_data_byteen,
output [31:0] mips_m_data_wdata,
input [31:0] mips_m_data_rdata,

// attach Timer0
output [31:2] timer0_Addr,
output timer0_WE,
output [31:0] timer0_Din,
input [31:0] timer0_Dout,
input timer0_IRQ,

// attach Timer1
output [31:2] timer1_Addr,
output timer1_WE,
output [31:0] timer1_Din,
input [31:0] timer1_Dout,
input timer1_IRQ
);

wire isDM;
wire isTimer0;
wire isTimer1;
wire isInterrupt;

assign isDM = (CPU_m_data_addr >= 32'h00000000 && CPU_m_data_addr < 32'h00003000) ? 1'b1 : 1'b0;
assign isTimer0 = (CPU_m_data_addr >= 32'h00007f00 && CPU_m_data_addr < 32'h00007f0c) ? 1'b1 : 1'b0;
assign isTimer1 = (CPU_m_data_addr >= 32'h00007f10 && CPU_m_data_addr < 32'h00007f1c) ? 1'b1 : 1'b0;
assign isInterrupt = (CPU_m_data_addr >= 32'h00007f20 && CPU_m_data_addr < 32'h00007f24) ? 1'b1 : 1'b0;

// 构造 HWInt
assign CPU_HWInt = {3'b0, mips_interrupt, timer1_IRQ, timer0_IRQ};

// mips and CPU
assign mips_i_inst_addr = CPU_i_inst_addr;
assign CPU_i_inst_rdata = mips_i_inst_rdata;

assign mips_macroscopic_pc = CPU_macroscopic_pc;
assign mips_m_int_addr = CPU_m_data_addr;
assign mips_m_int_byteen = (isDM) ? 4'h0 :
(isTimer0) ? 4'h0 :
(isTimer1) ? 4'h0 :
(isInterrupt) ? CPU_m_data_byteen :
4'h0;

assign mips_m_data_addr = CPU_m_data_addr;
assign mips_m_data_byteen = (isDM) ? CPU_m_data_byteen :
(isTimer0) ? 4'h0 :
(isTimer1) ? 4'h0 :
(isInterrupt) ? 4'h0 :
4'h0;
assign mips_m_data_wdata = CPU_m_data_wdata;

// Timer0 and CPU
assign timer0_Addr = CPU_m_data_addr[31:2];
assign timer0_WE = (isDM) ? 1'b0 :
(isTimer0) ? (&CPU_m_data_byteen) :
(isTimer1) ? 1'b0 :
(isInterrupt) ? 1'b0 :
1'b0;
assign timer0_Din = CPU_m_data_wdata;

// Timer1 and CPU
// &a 的意思是将 a 的所有位按位与起来,输出 1 位结果
assign timer1_Addr = CPU_m_data_addr[31:2];
assign timer1_WE = (isDM) ? 1'b0 :
(isTimer0) ? 1'b0 :
(isTimer1) ? (&CPU_m_data_byteen) :
(isInterrupt) ? 1'b0 :
1'b0;
assign timer1_Din = CPU_m_data_wdata;

// all and CPU
assign CPU_m_data_rdata = (isDM) ? mips_m_data_rdata :
(isTimer0) ? timer0_Dout :
(isTimer1) ? timer1_Dout :
(isInterrupt) ? 32'h00000000 :
32'h00000000;

endmodule

这样,我们就完成了全部的 CO 课程!果然,奇迹和魔法都是存在的!

Final.5 本章结语:再见,五级流水线 CPU !

终于走到了这一步了吗?在 P7 这只最终 Boss 被打败之后,你的心情如何?回头看去,我们已经走了这么远,哪怕是上半段旅途的终点,都已经显得那么遥远。

不管怎么说,总之恭喜你,也恭喜我,完成了人生中一个如此艰巨的任务,拼尽全力摘下了 CO 的王冠,领略到了旅途终点绝美的风景。

勇者击败了巨龙,接下来的故事,就是荣归故里,在一片欢呼与喝彩声中,接受象征着荣耀的表彰。现在,请好好享受这段难忘的时光吧!

在一段平静的生活之后,勇者也会再次迎来新的挑战,踏上新的旅程。只不过,新的旅程中或许不再有我的陪伴。不过没有关系!击败过巨龙的你,一定会比之前的任何时候都更加强大,最终成长为一名智慧、坚韧的战士!

Kamonto 与你同在!

后记

最近实在太忙,个人感受和致谢部分过后再写,可能是第 15 周编译期末考试结束之后吧,如果支持我的话,记得回来看我煽情这段哈哈哈

大家对本书有勘误,或者有自己的见解,都可以向我反馈,都会上致谢名单哦~

致谢