前一段时间我和几个同学参加了个比赛,这个比赛是自己设计一个 CPU,在其上设计 SoC,运行操作系统等。是一个系统类的比赛。我主要负责写 CPU 除 Cache 外的部分,我们最终设计了一个有十级流水的双发射顺序执行的 MIPS32 处理器。在比赛方提供的实验板上达到了 123MHz 的主频,同时还具有不错的 IPC。代码现在已经在 GitHub 上开源。

我来说说这个流水线的设计,除去各种数据旁路,流水线的架构大约如下图:

NonTrivialMIPS CPU

这十级流水基本上是从标准的五级流水扩展而来,其中有 3 级的取指,2 级的发射,3 级的访存。其实我们最开始计划的是双发射乱序处理器,并且之前在组成原理课上有一部分顺序双发射的经验,当时有一个很麻烦的地方就是取指阶段,这里需要根据到底发射一条指令还是两条指令控制 PC 的增加,但是在决定下一个 PC 值时所需要的指令还没有取到,这就需要一部分预测和旁路,导致取指阶段很复杂并且容易写错。这次我基本上重写了整个 CPU,在取指阶段增加了一个 FIFO,把取到的指令放入 FIFO。之后在发射的时候从 FIFO 读取指令。这样一定程度上将取指和之后的执行等阶段进行了解耦。

取指有 3 级流水基本上是由于指令 Cache 的需要。由于分支要在较后才能解析,CPU 必然需要一个分支预测器,我实现的是 2bit 的分支预测。假设分支预测完全正确的情况下,可以保证放入 FIFO 的指令流就是执行所需要的指令流(当然这里也处理了延迟槽)。同时,为了支持双发射,我们的指令 Cache 一次可以取出 8 字节的数据,如果其后的 8 字节仍然在同一个 Cache 行内,也会一并取出。这是为了在预测到分支指令的之后能够尽早将延迟槽放入 FIFO 而不需要进行一次多余的取指。也就是,我们的 FIFO 在一个周期最多可以放入 3 条指令。

在之后发射阶段,为了频率的提高,我们将读操作数单独作为一个流水段。虽然这样会造成分支预测失败的损失增大进而减小 IPC,但是和频率的提升相比这部分的损失还是比较小的。双发射基本上是对称的,只要不存在数据冲突和结构冲突(例如两条除法指令或者两条访存指令)都可以同时发射。当然,为了设计简便,我额外增加了一个限制,也就是分支指令和延迟槽必须一起发射。发射之后的读操作数和执行就没有太多技术含量,和通常的五级流水线相同。

我们的异常处理和 CP0 的读写都是在执行之后的一个阶段,也就是访存的第一个阶段进行的。 在这里有一个很严重的问题就是,由于数据 Cache 的流水太长,导致访存相关(即访存后立即使用数据)的暂停很高,这极大影响了性能。为了缓解这个问题,我们允许部分指令的读操作数和执行延迟到访存阶段进行。这样就可以很大地减小访存暂停。当然不是所有指令都能做到这件事,首先这需要满足一些条件。其一就是这类指令不能是访存指令,其二是这类指令不能在执行阶段出现异常(取指的 TLB miss 还是可以的,这在它执行前就可以判断)。当然,这两个条件其实不是很严格,很大一部分程序访存后基本上是跟着一些 ALU 指令或者分支指令。在增加这个优化之后,整个处理器的 IPC 大约有了 15% 的提升,同时频率仍然保持(当然直接改成乱序提升估计更大,但当时时间来不及了)。

其实我们或许还可以进一步优化,对于非分支指令还可以将其延迟到访存的第二个阶段读操作数,这样就完全不会有访存相关。至于分支指令,我们必须要能够在分支预测失败时撤销其后的指令,流水线上能够影响处理器状态的第一个阶段就是访存的第一个阶段,因此分支指令最迟必须要在访存第二个阶段执行完成。不过我并没有实现这个优化。另外还有一个较小的优化,对于存储指令的数据,可以在执行阶段再读取。

除了执行操作系统所需要的指令以外,我们还做了一定的扩展,例如实现了一个 32 位的 FPU 以及一个硬件 AES 加速单元同时移植到 OpenSSL 上。MIPS32 在早期版本对单精度浮点的规范非常奇怪,有两条指令叫做 ldc1 和 sdc1,它允许同时两个 FPU 寄存器和内存交换数据,也就是一次访存的数据宽度是 64 位。我实现的方法是在发射阶段将其拆开成为两条 32 位的访存指令,同时不允许中断打断这两条指令。至于 AES 加速,实际上是在 CP2 上实现的。只用到了 mfc2 和 mtc2 两条指令,AES 单元具有自己的寄存器,通过这两条指令和 CPU 的寄存器进行数据的交互以及控制。

至于操作系统和 SoC 的内容,这就不是我负责的范围了,具体可以看看我们最后的报告. 其实实现的过程中还有一个乱序的版本,不过最后因为频率没法提升最终没有使用。

关于准备比赛过程中更多的一些内容,也可以看 HarryChen 写的回顾