Chisel的基本概念
Chisel硬件表达 Chisel只支持二进制逻辑,不支持三态信号。
Chisel数据类型和数据字面量
-
数据类型用于指定状态元素中保存的值或wire上传输的值。 Chisel 所有的数据类型都是 Data 类的子类,所有最终继承自 Data 类的对象都可以在实际硬件中表示成一个 bit 向量。
-
常用的数据类型有: Bits,表示一行 bit 的集合; UInt,表示无符号整数; SInt,用补码表示有符号整数,它和 UInt 都是 FixedPoint 的子类; Bool,表示一个布尔值;还有其他 Data 的子类是这些常用类的超类,但是不用于构建电路,而是为了定义这些类.
-
Bundle 和 Vec 用于表示上述类型的集合,其中 Bundle 常用于构建模块的 I/O,而 Vec 常用于构建重复单元如多根线网、多个例化的模块、寄存器组等。
-
Bundle和Vec是可以允许用户使用其他数据类型来扩展Chisel数据类型集合的类。绑裹类型把若干命名的可以是不同类型的的域集合在一起变成一个连贯清晰的单元,这非常像 C 语言中的 struct。用户通过从 Bundle 衍生一个子类就可以定义自己的绑裹类型Bundle用class来定义,用户可以通过将一个类定义为Bundle的子类来定义自己的bundle。
-
Chisel 内建基础类和聚合类不需要使用 new 关键字,但是用户自定义的数据类型就必须使用。使用 Scala apply 构造器,用户自定义的数据类型也可以省略 new 关键字.
-
绑裹类型和向量类型可以任意相互嵌套构造复杂的数据结构。
Chisel数据字面量
-
Chisel的数据字面量可以通过 Scala 的 Int 和 String 类型隐式转换得到, Scala 的 Int 默认是十进制,以 0x 开头是十六进制,这会相应地转化成 Chisel的十进制和十六进制的数值字面量。字符串以“b”开头会被转化成 Chisel 的二进制,相应的,以“o”开头对应八进制,以“h”开头对应十六进制。常量和字面数值表示成 Scala 整数或者附有对应类型构造器的字符串:
1.U // decimal 1-bit lit from Scala Int. “ha”.U // hexadecimal 4-bit lit from string. “o12”.U // octal 4-bit lit from string. “b1010”.U // binary 4-bit lit from string. 5.S // signed decimal 4-bit lit from Scala Int. -8.S // negative decimal 4-bit lit from Scala Int. 5.U // unsigned decimal 3-bit lit from Scala Int. true.B // Bool lits from Scala lits. false.B
-
Chisel编译器默认使用最小的位宽来保存常量,有符号类型会包括一个符号比特。字面数字也可以显式地指明位宽,如下所示:
“ha”.U(8.W) // hexadecimal 8-bit lit of type UInt “o12”.U(6.W) // octal 6-bit lit of type UInt “b1010”.U(12.W) // binary 12-bit lit of type UInt 5.S(7.W) // signed decimal 7-bit lit of type SInt 5.U(8.W) // unsigned decimal 8-bit lit of type UInt
-
UInt 类型的字面常量的数值用零扩展到目标位宽。SInt 类型的字面常量的数值用符号为扩展到目标位宽。如果给出的位宽太小不能保存参量数值,Chisel会报错
组合电路
- 在Chisel中,电路会被表示为一张节点图。每个节点是具有零个或多个输入并驱动一个输出的硬件运算符。
- Uint是一种退化类型的节点,它没有输入,并且在其输出上驱动一个恒定的值。创建和连接节点的一种方法是使用字面表达式。 eg. (a out := DontCare }”,其中DontCare 告诉 Chisel 的未连接线网检测机制,写入 RAM 时读端口的行为无需关心。读 RAM的语法是“out := mem.read(address, rd_en)”。读、写使能信号都可以省略,而且多条读、写语句会生成多个读写端口。如下面的双端口读写 RAM: 单个读端口、单个写端口的 SRAM 可以如下设计:
val ram1r1w =Mem(1024, UInt(32.W)) val reg_raddr = Reg(UInt()) when (wen) { ram1r1w(waddr) := wdata } when (ren) { reg_raddr := raddr } val rdata = ram1r1w(reg_raddr)
如果读取和写入被放在在同一个 when 语句中,并且它们的条件是互斥的,那么就可以推断这是一个单端口 SRAM。
val ram1p = Mem(1024, UInt(32.W)) val reg_raddr = Reg(UInt()) when (wen) { ram1p(waddr) := wdata } .elsewhen (ren) { reg_raddr := raddr } val rdata = ram1p(reg_raddr)
接口和成批连接 对于复杂的电路模块来说,定义和例化接口类是非常有效的设计模块 IO 的方法。首先最重要的,接口类允许用户用有效的形式化的方法抓住那些能够设计一次复用多次的接口,鼓励他们复用设计。再者,接口支持在生产者和消费者模块之间使用成批连接,允许用户大幅节省连线语句。最后,用户可以在管理大接口变动的时候只在一处做更改,在增加减少接口子域的时候减少了更改的位置数量。
- 端口:子类和嵌套 如我们之前讨论过的,用户可以从 Bundle 继承子类来定义自己的接口。例如,用户可以像这样定一个简单的链路来传输握手数据: class SimpleLink extends Bundle { val data = Output(UInt(16.W)) val valid = Output(Bool()) } 我们下面可以用继承的方法增加奇偶位来扩展 SimpleLink: class PLink extends SimpleLink { val parity = Output(UInt(5.W)) } 一般来说,用户可以用继承的方法组织出层级化的接口。 现在,只要把两个 PLink 套叠在一起形成新的绑裹FilterIO,我们就可以定义过滤器接口了: class FilterIO extends Bundle { val x = new PLink().flip val y = new PLink() } 这里 flip 逐级递归地反转一个绑裹内部成员的“性别”,把输入变成输出,输出变成输入。 现在我们可以从模块继承一个过滤器类来定义过滤器了: class Filter extends Module { val io = IO(new FilterIO()) … }
- 向量绑裹 元素的向量超过单个元素,可以形成更加丰富的接口层级。例如,根据一个 UInt 输入从一组输入生成一组输入的交换器可以通过 Vec 构造器实现: class CrossbarIo(n: Int) extends Bundle { val in = Vec(n, new PLink().flip()) val sel = Input(UInt(sizeof(n).W)) val out = Vec(n, new PLink()) } 这里 Vec 的第一个参数规定了数量,第二个参数是能产生端口的语句块。
- 成批连接 现在我们来用两个过滤来组成一个过滤器丛,如下例所示: class Block extends Module { val io = IO(new FilterIO()) val f1 = Module(new Filter()) val f2 = Module(new Filter()) f1.io.x io.x f1.io.y f2.io.x f2.io.y io.y } 这里 就是成批连接的操作符,它可以把两个子模块的相对方向的端口连接起来,也可以把父模块和子模块的相同方向的端口连接起来。成批连接能够把相同名字的子端口相互连接起来。当所有的连接都成功之后,电路就开始详细展开了。如果有端口被连接了多个端口,Chisel 就会报告警告给用户。
函数式的模块创建 为模块构造设计一个函数式的接口也是非常有用的。例如,我们可以设计一个构造器,它以多路选择器模块作为输入,以更复杂的多路选择器模块作为输出:
object Mux2 { def apply (sel: UInt, in0: UInt, in1: UInt) = { val m = new Mux2() m.io.in0 := in0 m.io.in1 := in1 m.io.sel := sel m.io.out } }
这里 object Mux2 是在模块类 Mux2 的基础上创建的一个 Scala 单例对象,apply 定义了一个方法可以创建一个 Mux2 的实例。有了这个 Mux2 创建函数,Mux4 的设计规范就显著的简单了。
class Mux4 extends Module { val io = IO(new Bundle { val in0 = Input(UInt(1.W)) val in1 = Input(UInt(1.W)) val in2 = Input(UInt(1.W)) val in3 = Input(UInt(1.W)) val sel = Input(UInt(2.W)) val out = Output(UInt(1.W)) }) io.out := Mux2(io.sel(1), Mux2(io.sel(0), io.in0, io.in1), Mux2(io.sel(0), io.in2, io.in3)) }
对比:
class Mux2 extends Module { val io = IO(new Bundle{ val sel = Input(UInt(1.W)) val in0 = Input(UInt(1.W)) val in1 = Input(UInt(1.W)) val out = Output(UInt(1.W)) }) io.out := (io.sel m0.io.in1 := io.in1 val m1 = Module(new Mux2()) m1.io.sel := io.sel(0) m1.io.in0 := io.in2; m1.io.in1 := io.in3 val m3 = Module(new Mux2()) m3.io.sel := io.sel(1) m3.io.in0 := m0.io.out; m3.io.in1 := m1.io.out io.out := m3.io.out }
- Chisel3 支持多时钟域设计,除了当前模块隐式的全局时钟和复位信号,还可以在 IO 里面定义新的时钟与复位信号,然后传递给多时钟域构造方法。当然,为了安全地跨越时钟域,应该正确使用同步逻辑例如异步 FIFO。 时钟域内的定义只对包含它的模块可见。
- 如果想构造多个时钟与复位信号,则可以用 withClockAndReset 方法,需要“importchisel3.experimental._”导入。该方法接收两个参数,一个时钟信号一个复位信号,花括号内定义使用传入的时钟与复位信号的电路。如:如果只想要多个时钟域,复位信号使用当前模块统一的隐式 reset,则可以用 withClock方法,它只接受一个输入时钟作为参数。如:
- 如果只想要多个时钟域,复位信号使用当前模块统一的隐式 reset,则可以用 withClock方法,它只接受一个输入时钟作为参数。如:
- Chisel 可以方便地对小型电路编写测试,但对于大型电路,由于 Chisel 本身还是受限于Scala,所以测试性能不佳,而且目前没有 UVM 的支持。对于大型电路,建议转成Verilog文件用 EDA 工具做 UVM 或者用 Verilator 把 Verilog 转成 C++来仿真。
- 要编写仿真文件,首先要用“import chisel3.iotesters._”导入相应文件。然后,因为测试模块也是一个模块,所以测试文件应该也定义成一个 class,但是这个 class 不像普通模块一样继承自 Module,而是继承自 PeekPokeTester。另外,测试类接收一个参数,该参数类型就是待测模块的 class 名称(类型),而且这个参数还要传递给 PeekPokeTester 的构造器。如: 这表明测试模块叫 Tester,继承自 PeekPokeTester。参数是待测模块 Test,并且 PeekPokeTester的构造器也需要这个参数。
- 有四个方法可供测试使用:
① “poke(端口,激励值)”方法给相应的端口添加想要的激励值; ② “peek(端口)”方法返回相应的端口的当前值; ③ “expect(端口,期望值)”方法会对第一个参数(端口)使用 peek 方法,然后与期望值进行对比,如果两者不相等则出错; ④ “step(n)”方法则让仿真前进 n 个时钟周期。
- 因为测试文件只用于仿真, 无需转成 Verilog, 所以类似 for、 do…while、 to、 until、 map等 Scala 高级语法都可以尽情使用,帮助测试代码更加简洁有效。如下所示是一个对 Test 模块进行简单测试的代码:
-
生成Verilog文件 Scala 的程序也是从主函数开始运行的,所以 Chisel 把相应电路模块的 class 生成 Verilog文件的命令也是放在主函数里。如果没有测试文件,不需要测试结果,只要电路的 Verilog文件,则主函数编写如下:
object AddMain extends App{ chisel3.Driver.execute(args,() => new Add) }
其中, execute 函数的第一个参数接收命令行给出的参数,第二个参数是一个无参的函 数字面量,该函数字面量返回一个想要生成 Verilog 的电路模块(class)的实例,所以可以写成“new ClassName”。 运行时,在终端输入:
sbt”test:runMain add.AddMain”
终端当前的路径应该和提供的 build.sbt 文件一致,那么该命令运行成功后,会在当前路 径生成想要的 firrtl 文件和 Verilog 文件。 如果想指定文件存放路径,则可以给主函数传入参数,这些参数由 args 接收: sbt”test:runMain add.AddMain” –target-dir ./result/add
-
如果有测试文件,要生成波形文件,则主函数这样编写:
object AddMain extends App { iotesters.Driver.execute(args, () => new Add) (c => new AddTester©) }
与之前不一样的是,首先要导入“import chisel3.iotesters._”。其次使用的 execute 函数不一样(在 iotesters 包里),该函数有第二个参数列表,需要传入一个函数字面量,这个函数字面量返回测试模块(class)的实例。
运行这个主函数可以执行命令:
sbt”test:runMain add.AddMain -–target-dir ./result/add –backend-name verilator”
该命令需要安装 Verilator,运行成功后会有一个“.vcd”文件,用 GTKWave 打开查看 波形。如果只想在终端查看仿真运行的信息,则执行: sbt”test:runMain add.AddMain –is-verbose”