计算机技术学习札记

Chisel 烹饪指南

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 中可以轻松实现模糊匹配,从而将处理器设计中的「译码」部分做得相当简单。