计算机技术学习札记

TDT4255 笔记


2021 年 12 月 16 日

TDT4255 是挪威科技大学(Norges Teknisk-Naturvitenskapelige Universitet, NTNU)的一个课程的编号。这门课类似于国内高校的“计算机组成原理”课程,其实验部分的任务是使用 Chisel 设计一个 5 级流水线的 RISC-V 32I 指令集的处理器核。由于 RV32I 指令集指令数量少且较为简单(相比 MIPS 所实现的 OpenMIPS 而言),TDT4255 很适合作为一个入门体系结构方向和 Chisel 语言的“敲门砖”。

RISCV-FiveStage 是一个适用于 TDT4255 实验课程的测试平台(test bench,或者说 test harness),它除了内置 Chisel 语言的库之外,还包含了一个 Scala 实现的 RV32I 指令集的虚拟机,因此能够采用 差分测试 的方法直接测试所设计的 CPU 核的工作情况,可以说是极大地降低了入门门槛。

受限于个人水平 (以及你校考试安排的阴间) ,我前后大约花了两周的时间来完成 TDT4255。这篇文章会总结一些在实现处理器核的过程中的问题和技巧。

Chisel 语言相关 #

MuxLookup 的使用 #

在 Verilog 中,用来实现多路选择的 case 语句是只能放在 always 块里面的,但 always 块是不能对“线网”变量进行赋值的(只能对寄存器赋值),这意味着,如果我们想用纯组合逻辑 + “线网”变量实现一个多路选择器,就很可能要面对这样的套娃三目运算符:

追加备注:“线网”变量(即 wire)和“寄存器”变量(即 reg)并不是说最后就一定会综合成线网和寄存器。这二者只是 Verilog 中的语言标记,最终综合时综合器会根据代码的行为来决定到底是真的线网还是真的寄存器的。

assign out = (in == a) ? 2'd0 :
             (in == b) ? 2'd1 :
             (in == c) ? 2'd2 :
                         2'd3 ;

虽然只要缩进妥当,这样的可读性也不会很低。但是总觉得这样写不如使用 case 甚至是连续 if 来的优雅。而在 Chisel 中,除了直接用 switch-is 或者 if 之外,MuxLookup 给了我们一个很棒的解决方案。对于实现上面的功能(三路选择,带有一个默认项),在 Chisel 中直接写成

out := MuxLookup(in, 3.U(2.W), Array(
  a -> 0.U(2.W),
  b -> 1.U(2.W),
  c -> 2.U(2.W)
))

就能实现一样的功能。注意到 MuxLookup 的第三项其实是一个 map,用 -> 符号可以很方便地在 Scala 中构建 map。事实上

a -> b === (a, b) === Tuple2(a, b)

三者意义相同,都是构建二元组。许多个二元组合成的数组就是 map 了。这种用法在 CPU 核的设计中大有裨益。例如 ALU 的设计就可以这样写(假定相关的对象类型都已经定义好了):

result := MuxLookup(op, 0.U(32.W), Array(
  ADD -> a + b,
  SUB -> a - b,
  AND -> a & b,
  OR  -> a | b,
  // ...
  DC  -> 0.U(32.W)
))

是不是很直观?

Chisel 中的面向对象思想 #

Chisel 是在 Scala 之上的,Scala 作为 Java 的某种亲戚自然是面向对象的。 那我们写 Chisel 就得先有个对象,如图:

面向对象编程

(上图来源:面向对象编程 - 廖雪峰的官方网站

在 RISCV-FiveStage 这个框架中,很多地方都体现了面向对象的思想。例如,32 位长的指令被包装成一种类 Instruction(定义在 TopLevelSignals.scala 中)。这种类定义了许多方法,这些方法在我们编写解码模块时变得十分方便。例如 immediateIType() 方法的作用,是将一个指令对象的第 31 至 20 位有符号拓展成 32 位。这正对应着取出指令中的 I 型立即数的操作。又例如 registerRd() 方法能将指令对象的第 11 到 7 位取出,这正是取出指令中 Rd 寄存器的地址。

又如,在实现指令类型的判断时,原先在 Verilog 中我们会使用一系列的 if 或者 case 判断一条指令的 op1op2 等序列,来判定指令所属的类型,进而进行后续操作,就像《自己动手写 CPU》中的那样。但在 Chisel 中,借助 BitPat 我们能够直接使用类似于

BitPat("b?????????????????000?????1100011")

的语句直接“规定”一类指令的特征。

这些面向对象的“函数”或者说“方法”并不会在电路中被综合。在综合(乃至翻译成 Verilog 代码)时它们会被转换成低阶的死操作,但它们能极大地加快设计流程,提升开发体验。这也是 Chisel 的一个巨大优势。

CPU 核相关 #

与内存无关的 RAW 冲突 #

RAW 冲突(Read After Write),指的是有两条指令,其中前一条的写入寄存器和后一条的操作数寄存器相同。由于五级流水线的特性,每条指令只有在“回写”阶段才会将计算结果写回寄存器堆,如果这两条指令相隔比较近,可能出现后一条指令在读寄存器(“译码”阶段)的时候,前一条指令的结果还没有回写,导致后一条指令取到的操作数仍然是旧值。对于与内存无关的 RAW 冲突,会发生在相邻、相隔 1 条指令和相隔 2 条指令的情况。

大体来说,解决各种 RAW 问题都无非暂停流水线、数据前推和编译器优化几种策略。编译器优化并不是我们这里的问题,而对于与内存无关的 RAW 冲突,若选择暂停流水线对效率影响比较大,因此我选择了和 OpenMIPS 一样的“数据前推”策略。考虑到前一指令实际上的运算结果早在“执行”阶段就已经产生,只需要在“执行”、“访存”阶段提前将计算完成的值送回“译码”模块,由“译码”模块进行判断是否取用,同时对寄存器堆进行改写,使得当读取地址和写入地址相同时,直接将待写入值送出就可以了。

内存相关的 RAW 冲突 #

对于 LW 指令,由于它的“运算”结果只有在经历“访存”模块的下一个周期(即“回写”)才能得到,故没有办法进行数据前推。对于这条指令,只能选择暂停流水线的方式来解决冲突。

我处理暂停流水线的思路是,由“译码”模块进行判断:“译码”模块总是记录着上两条实际执行的指令的相关信息。当“译码”模块得到一条新的指令时,都去检查它是否和记忆中的上两条指令是否存在 LW 冲突。如果存在这样的冲突,就缓存当前这条指令,通知“取指”模块暂停取指令,并且向后续各模块实际发送一条空指令(称为“空泡”)。这样等到下一个时钟周期,再检查是否存在冲突(这时,之前执行的空泡也计入上两条历史记录)。当冲突解除时,先将缓存的原指令向后发送执行,再继续取指,流水线重新正常动作。

开发相关 #

自己写测试 #

RISCV-FiveStage 内置了一系列汇编文件用于进行测试。除了测试 ADDIADD 等基本算术指令的一些 .s 文件外,剩下的都是 C 语言程序编译成的 .s 文件,解决的是诸如图像卷积、斐波那契数列等实际问题。这些汇编文件并不是很适合碳基生物直接进行阅读,因此用它们进行调试难度比较大。

我在完成 TDT4255 的最后几天,总是无法正常通过这些汇编文件中的某几个——当我的处理器运行这些程序时会进入死循环。由于死循环数量达到 15000 轮,波形和 VM 动作记录都是一坨 💩,不便于调试。

这一切的解决直到我自己写了一个简单的 .s 文件来运行。这个文件长这样:

main:
    addi x31, x0, 422
    addi x30, x0, 913
    bne x31, x30, chen
chen:
    add x31, x31, x31
    bgeu x31, x30, love
love:
    addi x29, zero, 2003
    addi x28, zero, 2003
    beq x29, x28, jia
jia:
    lw x27, 0x4(x0)
    lw x26, 0x8(x0)
    bne x27, x26, yi
yi:
    addi x25, x0, 1314
    nop
    nop
    done
#memset 0x4, 19
#memset 0x8, 18

将这个文件送入处理器运行,在 bne x27, x26, yi 这一句之后,addi x25, x0, 1314 这条指令被“吃”掉了。通过查波形发现,PC 直接指向了它后面的那条 NOP 指令。排查代码发现,我在处理跳转指令时存在一个 PC 错位的致命 bug。这个 bug 在使用 RISCV-FiveStage 中给出的一大堆汇编文件中都幸运地没有被撞到,另一方面,那些进入死循环的代码则正是由于这个 bug 导致 PC 无法正常指向需要的位置造成的。修正了这个 bug 之后,我成功通过了所有的测试。

感谢队长,不然我真不会自己动手写汇编文件来测试的(汇编 0 基础啊我)

(也许)未完待续 #