计算机技术学习札记

Chisel 烹饪指南.md


2022 年 8 月 28 日

Chisel 是一门新型的硬件描述语言(HDL),「寄生」于 Scala 之上。与传统的 HDL 如 Verilog 或者 VHDL 相比,Chisel 提供了面向对象的敏捷开发体验。在这个快节奏高效率的时代,将 Chisel 应用于芯片设计等领域有着不少的优势。

目前,中文互联网上 Chisel 的资料还相对比较少,因此很多使用技巧还需要自己摸索。本文是我在使用 Chisel 进行数字电路开发时总结的一些经验。

他山之石 #

比较成体系的 Chisel 教程文章,CSDN 上 _iChthyosaur 的 教程系列 是质量较高的。Chisel 的官方文档 则适用于作为开发时参考。

此外中文互联网上还有许多零散的 Chisel 相关文章可供我们查阅;国外一些以 Chisel 为数字电路课程语言的大学的课件 / 讲义也是不可多得的参考资料。

原理相关 #

Chisel 做了什么?Chisel 虽说已经可以算作一门 HDL,但它其实是寄生在 Scala 上的一个库——一个自己定义了整套数字逻辑所需的元件的库。例如,一个 32 位全加器的 Chisel 代码如下:

class Adder extends Module {
  val io = IO(new Bundle {
    val a = Input(UInt(32.W))
    val b = Input(UInt(32.W))
    val out = Output(UInt(32.W))
  })
  
  out := a + b
}

在 Chisel 中,一个模块(Verilog 中的 module)被表示为一个继承了 Module 的类(Scala 中的 class)。这个模块的输入输出是这个类的一个常量——一个包含若干线网的「束」——并使用 ModuleIO() 方法来标记这个线网束为模块的 IO。模块的内部定义,则写在这个类的构造方法(Scala 中,一个 class 内部直接写的东西就是它的构造方法)中。总而言之,通过写一个特殊的类,我们就相当于在 Verilog 中写了一个模块。

显然,这样的东西并不能生成实际的硬件,这个类是一个软件的类,与硬件没有什么关系。Chisel 要做的,是把这个类变成相应功能的 Verilog。Chisel 不仅定义了数字逻辑所用的各种元件,还定义了它们生成 Verilog 的「模板」。通过调用 Chisel 提供的方法,我们就可以将一个模块 class 变成 Verilog。

chisel3.Driver.execute(args, () => new Adder)

将上面的代码作为主函数或作为测试激励编译运行,就能完成生成 Verilog 的操作。在执行过程中,会有两次代码语法的检查:

  • 第一次是 Scala 编译器将 Scala 代码编译时的语法检查。这将保证我们的代码是符合 Scala 代码规范的。
  • 第二次是编译成功后,运行时由 Chisel 库进行的检查。这将保证我们设计的电路是合理的、能够被综合器综合的。

当这个 Scala 程序正常运行结束后,我们就可以查看生成的 Verilog 代码了——尽管这代码的可读性并不高,里面充满了金属味。将这样的 Verilog 代码送入综合器综合,便能得到对应功能的电路。

裸类型与硬件类型 #

在 Chisel 中,裸类型是指诸如 UIntBool 这样的数据类型。硬件上的数据本质都是几根电线,类型只是决定着怎么解读这几根电线上的信号。裸类型是抽象的「类型」。

裸类型通过套上 IO()Wire() 等就能变成硬件类型,那样它就是实际的硬件了。例如,UInt(32.W) 只是表示「32 位的无符号整数」,Wire(UInt(32.W)) 则表示「承载着 32 位无符号整数的位宽为 32 位的线网」。显然,我们可以驱动一个硬件类型(的「变量」,实际是一个电路元件),但不能驱动一个裸类型。

Scala 类型与 Chisel 类型 #

Chisel 寄生在 Scala 之上,因此 Chisel 代码中就会同时出现 Scala 的类型和 Chisel 的类型。Chisel 自己定义的类型都是硬件相关的,上面提到的裸类型和硬件类型都是 Chisel 的类型;而 Scala 的类型则是软件的东西,如同 Verilog 中的 genvar 一样,是用来生成电路时的辅助工具。

一些 Scala 类型和 Chisel 类型之间很有迷惑性,如 Seq()(Scala 的数组类型)和 Vec()(Chisel 的硬件向量)。

「多驱动」 #

Verilog 是不允许多驱动的,毕竟用不同的信号接到同一根线上会有无法确定的行为。但 Chisel 是允许多次对同一线网或寄存器驱动的。在 Chisel 中,后来的驱动信号会覆盖之前的信号。例如:

signal := false.B

when (condition) {
  signal := true.B
}

则会在 condition 为真时,让 siganl 为高电平,反之为低电平。它生成的 Verilog 代码中是会为这样的情况带上多路选择器的。

同理还有 WireInit()。它会定义一个线网的同时给线网驱动一个初值——如果之后线网没有被其他的信号驱动,就用这个值驱动它;否则就用其他的(新)值。RegInit() 不同于此——它是在 reset 信号拉高时给寄存器重置一个特定的初值。

寄存器堆、带初值的寄存器堆与 Block RAM #

Chisel 提供了 Mem()SyncReadMem() 两种存储器实现。它们都是同步写入(在时钟上升沿到来时采样数据并写入)的,但 Mem() 是异步读取(任意时候给出一个索引,就立即给出对应存储单元的值)的,而 SyncReadMem() 是同步读取的(在时钟上升沿到来时采样索引,然后给出对应存储单元的值)。

实现到 FPGA 上,Mem() 会被综合成寄存器堆,而 SyncReadMem() 可能被综合成寄存器堆、Distributed RAM(使用 LUT 实现的 RAM)或 Block RAM(专门的 RAM)。

无论是 Mem() 实现的寄存器堆还是 SyncReadMem() 实现的存储器,它们都不确定在上电重置后的初值。如果需要行为确定的存储器,可以考虑下面的方案:

  • 寄存器堆可以用 val mem = RegInit(VecInit(Seq.fill(数量)(初值))) 来实现。这相当于人为定义了一个向量,每个元素都是用 RegInit() 确定初值的寄存器。
  • Block RAM 建议使用 IP 核,然后使用 Chisel 的黑盒来使用。

Scala 花活和 Chisel 妙手 #

我们可以把 Scala 语言本身的各种奇技淫巧运用在 Chisel 电路设计中。例如,找出一组信号中是否有 1

val hit = signals.reduce(_ | _)

并且找出它的索引

val index = signals.zipWithIndex().map(signal => Mux(signal._2, signal._1.U, 0.U).reduce(_ + _))

此外,充分利用 MuxLookUp() 可以在很多使用避免大面积 switch-is 或者 when-otherwise,从而提升代码的可读性。

再如,使用 BitPat() 在 Chisel 中可以轻松实现模糊匹配,从而将处理器设计中的「译码」部分做得相当简单。