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
)。这个模块的输入输出是这个类的一个常量——一个包含若干线网的「束」——并使用 Module
的 IO()
方法来标记这个线网束为模块的 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 中,裸类型是指诸如 UInt
、Bool
这样的数据类型。硬件上的数据本质都是几根电线,类型只是决定着怎么解读这几根电线上的信号。裸类型是抽象的「类型」。
裸类型通过套上 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 中可以轻松实现模糊匹配,从而将处理器设计中的「译码」部分做得相当简单。