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定义了不同的硬件操作符,如下表所示:
Chisel有一个很大的特点就是变量宽度推测,wire的宽度是可以推测出来的,除了右移操作外,宽度推测机制得到的输出wire的宽度始终大于或者等于输入wire的宽度。按照如下规则推测:
函数
我们可以定义函数来分解一个重复的逻辑,这样可以在后续设计中重复使用。
eg. def clb(a: UInt, b: UInt, c: UInt, d: UInt): UInt = (a & b) | (~c & d)
其中clb是表示以a,b,c,d为参数的函数,并返回一个布尔电路的输出。def关键字是Scala的一部分,表示引入了一个函数定义,每个语句后面跟一个冒号,然后是它的类型,函数返回类型在参数列表之后的冒号之后。(=)符号将函数参数列表与函数定义分隔开。
然后我们就可以在其他的电路中使用了:val out = clb(a,b,c,d)
端口
端口用作硬件组件的接口。一个端口可以是任意的Data对象,但它是具有方向的。 Chisel提供端口构造函数,以允许在构建时给对象添加(输入或输出)。原始的端口构造函数需要将方向作为第一个参数(方向为INPUT或OUTPUT),将位数作为第二个参数(除了始终为1位的布尔值)。
eg. 端口声明如下所示
class Decoupled extends Bundle {
val ready = Bool(OUTPUT)
val data = UInt(INPUT, 32)
val valid = Bool(INPUT)
}
模块
Module用class来定义,继承Module。继承自 Module。 包含一个用函数 IO() 包裹的接口,并且保存在一个名为 io 的端口域内。在它的构造器内连接它所有的子电路。
例如,考虑把你自己的双输入多路选择器定义成一个模块:
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 & io.in1) | (~io.sel & io.in0)
}
模块的连线接口是一组端口绑裹在一起形成的Bundle。接入模块的接口被命名成 io 的域。对这个Mux2 模块而言,io 是包含四个域的一个绑裹,每一个域都是这个多路选择器的一个端口。
这里用在定义主体里的赋值操作符 := 是 Chisel 定义的特殊的操作符,连接右手边电路的输出到左手边电路的输入。
模块的工厂方法
与 Scala 一样, Chisel 也可以在模块(类)的伴生对象里定义 apply 工厂方法,该方法实例化一个模块,并连接到传入的参数。这样,其他模块在实例化该模块时,只需写成“模块名 (连接端口)”的形式来调用 apply 方法即可。如下所示,重写 Mux4 模块:
状态元素
val r = Reg(clonedUpdateData)
when (enable)
{ r := updateData }
RegNext(updateData,resetData,enable):
这个相比于上边的也就多了一个复位时候的值。
val r = RegInit(resetData)
when (enable) { r := updateData }
en:使能信号,为true的时候移位
if (n != 0) {
RegEnable(apply(in, n-1, en), en)
} else {
in
}
ShiftRegister(in ,n ,resetData,en)
resetData :复位的时候为每个寄存器赋初值
if (n != 0) {
RegEnable(apply(in, n-1, resetData, en), resetData, en)
} else {
in
}
条件更新
有限状态机
下面给出了一个稍微复杂点儿的 FSM 例子,这是为自动售货机如何收钱而设计的一个电路:
class VendingMachine extends Module {
val io = IO(new Bundle {
val nickel = Input(Bool())
val dime = Input(Bool())
val valid = Output(Bool()) })
val s_idle :: s_5 :: s_10 :: s_15 :: s_ok :: Nil =
Enum(5)
val state = Reg(init = s_idle)
when (state === s_idle) {
when (io.nickel) { state := s_5 }
when (io.dime) { state := s_10 }
}
when (state === s_5) {
when (io.nickel) { state := s_10 }
when (io.dime) { state := s_15 }
}
when (state === s_10) {
when (io.nickel) { state := s_15 }
when (io.dime) { state := s_ok }
}
when (state === s_15) {
when (io.nickel) { state := s_ok }
when (io.dime) { state := s_ok }
}
when (state === s_ok) {
state := s_idle
}
io.valid := (state === s_ok)
}
下面再给出用 switch 语句设计的自动售货机的
FSM:
class VendingMachine extends Module {
val io = IO(new Bundle {
val nickel = Input(Bool())
val dime = Input(Bool())
val valid = Output(Bool())
})
val s_idle :: s_5 :: s_10 :: s_15 :: s_ok :: Nil =
Enum(5)
val state = Reg(init = s_idle)
switch (state) {
is (s_idle) {
when (io.nickel) { state := s_5 }
when (io.dime) { state := s_10 }
}
is (s_5) {
when (io.nickel) { state := s_10 }
when (io.dime) { state := s_15 }
}
is (s_10) {
when (io.nickel) { state := s_15 }
when (io.dime) { state := s_ok }
}
is (s_15) {
when (io.nickel) { state := s_ok }
when (io.dime) { state := s_ok }
}
is (s_ok) {
state := s_idle
}
}
io.valid := (state === s_ok)
}
存储器
Chisel 提供了创建只读存储器和可读写存储器的机制。
只读存储器 (ROM)
可以使用 Vec 类型来定义只读存储器,如示例:
Vec(inits: Seq[T])
Vec(elt0: T, elts: T*)
或者,可以通过一个被初始化的向量来创建一个只读存储器,形式为:
要么把一个元素为 Data 类型的序列作为参数,要么直接用若干个 Data 类型的元素作为参数。如:
用户可以创建一个小 ROM,初始化成1, 2, 4, 8,并且用一个计数器作为地址生成器来遍历它所有的值,如下例所示:
val m = Vec(Array(1.U, 2.U, 4.U, 8.U))
val r = m(counter(UInt(m.length.W)))
我们可以 ROM 创建一个有 n 个入口的 sine 函数的查找表,如下例所示:
def sinTable (amp: Double, n: Int) = {
val times =Range(0, n, 1).map(i => (i2Pi)/(n.toDouble-1) - Pi)
val inits =times.map(t => SInt(round(amp * sin(t)), width = 32))
Vec(inits)
}
def sinWave (amp: Double, n: Int) =sinTable(amp, n)(counter(UInt(n.W))
这里的 amp 用来缩放 ROM 中保存的定点数值。
可读写存储器 (Mem)
Chisel 支持两种类型的 RAM。
第一种 RAM 是同步(时序)写,异步(组合逻辑)读,用语法“Mem(数量, UInt(位宽))”来构建。如:
由于现代的 FPGA 和 ASIC 技术已经不再支持异步读 RAM,所以这种 RAM 会被综合成寄存器阵列。
第二种 RAM 则是同步(时序)读、写,用语法“SyncReadMem(数量, UInt(位宽))”来构建,这种 RAM 会被综合成实际的 SRAM。如:
写 RAM 的语法是“when(wr_en) { mem.write(address, dataIn); 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 的方法。首先最重要的,接口类允许用户用有效的形式化的方法抓住那些能够设计一次复用多次的接口,鼓励他们复用设计。再者,接口支持在生产者和消费者模块之间使用成批连接,允许用户大幅节省连线语句。最后,用户可以在管理大接口变动的时候只在一处做更改,在增加减少接口子域的时候减少了更改的位置数量。
函数式的模块创建
为模块构造设计一个函数式的接口也是非常有用的。例如,我们可以设计一个构造器,它以多路选择器模块作为输入,以更复杂的多路选择器模块作为输出:
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 & io.in1) | (~io.sel & io.in0)
}
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))
})
val m0 = Module(new Mux2())
m0.io.sel := io.sel(0)
m0.io.in0 := io.in0; 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
}
① “poke(端口,激励值)”方法给相应的端口添加想要的激励值;
② “peek(端口)”方法返回相应的端口的当前值;
③ “expect(端口,期望值)”方法会对第一个参数(端口)使用 peek 方法,然后与期望值进行对比,如果两者不相等则出错;
④ “step(n)”方法则让仿真前进 n 个时钟周期。
生成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”