原文链接
https://blog.csdn.net/qq_34291505/article/details/87365907
第十七章 Chisel基础——数据类型
为了从Chisel转变成Verilog,语言开发人员开发了一个中间的标准交换格式——Firrtl,它跟Vrilog是同一级别的,两者都比Chisel低一级。编写的Chisel代码首先会经过Firrtl编译器,生成Firrtl代码,也就是一个后缀格式为“.fir”的文件,然后由这个Firrtl文件再去生成对应的Verilog代码。如果读者有兴趣看一看Firrtl的格式,其实与Verilog很接近,只不过是由机器生成的、很死板的代码。Firrtl编译器也并不是只针对Chisel
Verilog的最初目的是用于电路验证,所以它有很多不可综合的语法。Firrtl在转变成Verilog时,只会采用可综合的语法,因此读者完全不用担心用Chisel写出来的电路不可综合。只要能正确生成Verilog,那就能被综合器生成电路
Chisel目前不支持四态逻辑里的x和z,只支持0和1。由于只有芯片对外的IO处才能出现三态门,所以内部设计几乎用不到x和z。而且x和z在设计中会带来危害,忽略掉它们也不影响大多数设计,还简化了模型。当然,如果确实需要,可以通过黑盒语法与外部的Verilog代码互动,也可以在下游工具链里添加四态逻辑
Chisel会对未被驱动的输出型端口和线网进行检测,如果存在,会进行报错
Chisel的代码包并不会像Scala的标准库那样被编译器隐式导入,所以每个Chisel文件都应该在开头至少写一句“import chisel3._”。这个包包含了基本的语法,对于某些高级语法,则可能需要“import chisel3.util._”、“import chisel3.experimental._”、 “import chisel3.testers._”等等
Chisel的数据类型
hisel定义了自己的一套数据类型,读者应该跟Scala的九种基本值类区分开来。而且Chisel也能使用Scala的数据类型,但是Scala的数据类型都是用于参数和内建控制结构,构建硬件电路还是得用Chisel自己的数据类型,在使用时千万不要混
在实际硬件构成里,并不会用到Data,读者也不用关心它的具体实现细节,应该关注Data类的两大子类:聚合类Aggregate和元素类Element
聚合类Aggregate的常用子类是向量类Vec[T]和包裹类Bundle
Vec[T]类用于包含相同的元素,元素类型T可以是任意的Data子类。
Bundle类用于被自定义的类继承,这样自定义的类就能包含任意Data的子类对象,常用于协助构造模块的端口,故而衍生出了一些预定义的端口子类
Element类衍生出了Analog、Bits和Clock三个子类,单例对象DontCare和特质Reset
Analog用于在黑盒中模拟inout端口,目前在实际Chisel里并无其他用途。Bits类的两个子类SInt和UInt是最常用的两个数据类型,它们是用补码表示的有符号整数和无符号整数。不仅用来协助定义端口位宽,还用来进行赋值
FixedPoint类提供的API带有试验性质,而且将来可能会发生改变,所以不常用。Bool类是Chisel自己的布尔类型,区别于Scala的Boolean。Bool类是UInt类的子类
Clock类表示时钟,Chisel里的时钟是专门的一个类型,并不像Verilog里那样是1bit的线网。复位类型Reset也是如此。单例对象DontCare用于赋值给未驱动的端口或线网,防止编译器报错
数据字面量
能够表示具体值的数据类型为UInt、SInt和Bool。实际可综合的电路都是若干个bit,所以只能表示整数,这与Verilog是一致的。要表示浮点数,本质还是用多个bit来构建,而且要遵循IEEE的浮点标准。对于UInt,可以构成任意位宽的线网或寄存器。对于SInt,在Chisel里会按补码解读,转换成Verilog后会使用系统函数$signed,这是可综合的。对于Bool,转换成Verilog后就是1bit的线网或寄存器。
Chisel定义了一系列隐式类:fromBigIntToLiteral、fromtIntToLiteral、fromtLongToLiteral、fromStringToLiteral、fromBooleanToLiteral。回顾前面讲述的隐式类的内容,也就是会有相应的隐式转换。以隐式类fromtIntToLiteral为例,存在一个同名的隐式转换,把相应的Scala的Int对象转换成一个fromtIntToLiteral的对象。而fromtIntToLiteral类有两个方法U和S,分别构造一个等值的UInt对象和SInt对象。再加上Scala的基本值类都是用字面量构造对象,所以要表示一个UInt对象,可以写成“1.U”的格式,这样编译器会插入隐式转换,变成“fromtIntToLiteral(1).U”,进而构造出字面值为“1”的UInt对象。同理,也可以构造SInt。还有相同行为的方法asUInt和asSInt。
1.U // 字面值为“1”的UInt对象
-8.S // 字面值为“-8”的SInt对象
"b0101".U // 字面值为“5”的UInt对象
true.B // 字面值为“true”的Bool对象
数据宽度
Chisel3专门设计了宽度类Width。还有一个隐式类fromIntToWidth,就是把Int对象转换成fromIntToWidth类型的对象,然后通过方法W返回一个Width对象
1.U // 字面值为“1”、宽度为1bit的UInt对象 1.U(32.W) // 字面值为“1”、宽度为32bit的UInt对象
UInt、SInt和Bool都不是抽象类,除了可以通过字面量构造对象以外,也可以直接通过apply工厂方法构造没有字面量的对象
有字面量的数据类型用于赋值、初始化寄存器等操作,而无字面量的数据类型则用于声明端口、构造向量等
类型转换
UInt、SInt和Bool三个类都包含四个方法:asUInt、asSInt、toBool和toBools
toBool会把1bit的“1”转换成Bool类型的true,“0”转换成false。如果位宽超过1bit,则用toBools转换成Bool类型的序列Seq[Bool]
Bool类还有一个方法asClock,把true转换成电压常高的时钟,false转换成电压常低的时钟。Clock类只有一个方法asUInt,转换成对应的0或1
向量
如果需要一个集合类型的数据,除了可以使用Scala内建的数组、列表、集等数据结构外,还可以使用Chisel专属的Vec[T]。T必须是Data的子类,而且每个元素的类型、位宽必须一样。Vec[T]的伴生对象里有一个apply工厂方法,接收两个参数,第一个是Int类型,表示元素的个数,第二个是元素
val myVec = Wire(Vec(3, UInt(32.W)))
val myReg = myVec(0)
还有一个工厂方法VecInit[T],通过接收一个Seq[T]作为参数来构造向量,或者是多个重复参数。不过,这个工厂方法常把有字面值的数据作为参数,用于初始化寄存器组、ROM、RAM等,或者用来构造多个模块。
因为Vec[T]也是一种序列,所以它也定义了诸如map、flatMap、zip、foreach、filter、exists、contains等方法。尽管这些方法应该出现在软件里,但是它们也可以简化硬件逻辑的编写,减少手工代码量。
混合向量
混合向量MixedVec[T]与普通的向量Vec[T]类似,只不过包含的元素可以不全都一样
对于构造Vec[T]和MixedVec[T]的序列,并不一定要逐个手写,可以通过Scala的函数,比如fill、map、flatMap、to、until等来生成
val mixVec = Wire(MixedVec((1 to 10) map { i => UInt(i.W) }))
包裹
抽象类Bundle很像C语言的结构体(struct),用户可以编写一个自定义类来继承自它,然后在自定义的类里包含其它各种Data类型的字段。它可以协助构建线网或寄存器,但是最常见的用途是用于构建一个模块的端口列表,或者一部分端口
class MyModule extends Module {
val io = IO(new Bundle {
val in = Input(UInt(32.W))
val out = Output(UInt(32.W))
})
Chisel的内建操作符
位宽推断
第十八章 Chisel基础——模块与硬件类型
实际的电路应该是由硬件类型的对象构成的,不管是信号的声明,还是用赋值进行信号传递,都是由硬件类型的对象来完成的。数据类型和硬件类型融合在一起,才能构成完整、可运行的组件。
Chisel是如何赋值的
在Chisel里,所有对象都应该由val类型的变量来引用,因为硬件电路的不可变性。
引用的对象很可能需要被重新赋值。例如,输出端口在定义时使用了“=”与端口变量名进行了绑定,那等到驱动该端口时,就需要通过变量名来进行赋值操作,更新数据
Chisel类都定义了方法“:=”,作为等号赋值的代替。所以首次创建变量时用等号初始化,如果变量引用的对象不能立即确定状态或本身就是可变对象,则在后续更新状态时应该用“:=”
val x = Wire(UInt(4.W)) val y = Wire(UInt(4.W)) x := "b1010".U // 向4bit的线网x赋予了无符号数10 y := ~x // 把x按位取反,传递给y
端口
定义端口列表
整个端口列表是由方法“IO[T <: Data](iodef: T)”来定义的,通常其参数是一个Bundle类型的对象,而且引用的字段名称必须是“io”。因为端口存在方向,所以还需要方法“Input[T <: Data](source: T)”和“Output[T <: Data](source: T)”来为每个端口表明具体的方向。
目前Chisel还不支持双向端口inout,只能通过黑盒里的Analog端口来模拟外部Verilog的双向端口
class MyIO extends Bundle { val in = Input(Vec(5, UInt(32.W))) val out = Output(UInt(32.W)) } ...... val io = IO(new MyIO) // 模块的端口列表 ......
翻转端口列表的方向
对于两个相连的模块,可能存在大量同名但方向相反的端口。仅仅为了翻转方向而不得不重写一遍端口显得费时费力,所以Chisel提供了“Flipped[T <: Data](source: T)”方法,可以把参数里所有的输入转输出,输出转输入。如果是黑盒里的Analog端口,则仍是双向的。
class MyIO extends Bundle { val in = Input(Vec(5, UInt(32.W))) val out = Output(UInt(32.W)) } ...... val io = IO(new MyIO) // in是输入,out是输出 ...... val io = IO(Flipped(new MyIO)) // out是输入,in是输出
整体连接
翻转方向的端口列表通常配合整体连接符号“<>”使用。该操作符会把左右两边的端口列表里所有同名的端口进行连接,而且同一级的端口方向必须是输入连输出、输出连输入,父级和子级的端口方向则是输入连输入、输出连输出。
方向必须按这个规则匹配,而且不能存在端口名字、数量、类型不同的情况。这样就省去了大量连线的代码
模块
定义模块
Chisel里面是用一个自定义的类来定义模块的,这个类有以下三个特点:
①继承自Module类。
②有一个抽象字段“io”需要实现,该字段必须引用前面所说的端口对象。
③在类的主构造器里进行内部电路连线。
这样定义的模块会继承一个字段“clock”,类型是Clock,它表示全局时钟,在整个模块内都可见。对于组合逻辑,是用不上它的,而时序逻辑虽然需要这个时钟,但也不用显式声明。还有一个继承的字段“reset”,类型是Reset,表示全局复位信号,在整个模块内可见。对于需要复位的时序元件,也可以不用显式使用该字段。
// mux2.scala package test import chisel3._ 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) }
“new Bundle { ... }”的写法是声明一个匿名类继承自Bundle,然后实例化匿名类。对于短小、简单的端口列表,可以使用这种简便写法。对于大的公用接口,应该单独写成具名的Bundle子类,方便修改。“io.out := ...”其实就是主构造方法的一部分
例化模块
并不是直接用new生成一个实例对象就完成了,还需要再把实例的对象传递给单例对象Module的apply方法。
val m0 = Module(new Mux2)
例化多个模块
对于要多次例化的重复模块,可以利用向量的工厂方法VecInit[T <: Data]。
所以可以把待例化模块的io字段组成一个序列
生成序列的一种方法是调用单例对象Seq里的方法fill,该方法的一个重载版本有两个单参数列表,第一个接收Int类型的对象,表示序列的元素个数,第二个是传名参数,接收序列的元素。
// mux4_2.scala package test import chisel3._ class Mux4_2 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 m = VecInit(Seq.fill(3)(Module(new Mux2).io)) // 例化了三个Mux2,并且参数是端口字段io m(0).sel := io.sel(0) // 模块的端口通过下标索引,并且路径里没有“io” m(0).in0 := io.in0 m(0).in1 := io.in1 m(1).sel := io.sel(0) m(1).in0 := io.in2 m(1).in1 := io.in3 m(2).sel := io.sel(1) m(2).in0 := m(0).out m(2).in1 := m(1).out io.out := m(2).out }
线网
Chisel把线网作为电路的节点,通过工厂方法“Wire[T <: Data](t: T)”来定义。可以对线网进行赋值,也可以连接到其他电路节点,这是组成组合逻辑的基本硬件类型。
val myNode = Wire(UInt(8.W))
myNode := 0.U
因为Scala作为软件语言是顺序执行的,定义具有覆盖性,所以如果对同一个线网多次赋值,则只有最后一次有效
寄存器
如果模块里没有多时钟域的语句块,那么寄存器都是由隐式的全局时钟来控制。对于有复位信号的寄存器,如果不在多时钟域语句块里,则由隐式的全局复位来控制,并且高有效
目前Chisel所有的复位都是同步复位,异步复位功能还在开发中。如果需要异步复位寄存器,则需要通过黑盒引入
有五种内建的寄存器,
第一种是跟随寄存器“RegNext[T <: Data](next: T)”,在每个时钟上升沿,它都会采样一次传入的参数,并且没有复位信号。它的另一个版本的apply工厂方法是“RegNext[T <: Data](next: T, init: T)”,也就是由复位信号控制,当复位信号有效时,复位到指定值,否则就跟随。
第二种是复位到指定值的寄存器“RegInit[T <: Data](init: T)”,参数需要声明位宽,否则就是默认位宽。可以用内建的when语句进行条件赋值。
第三种是普通的寄存器“Reg[T <: Data](t: T)”,它可以在when语句里用全局reset信号进行同步复位(reset信号是Reset类型,要用toBool进行类型转换),也可以进行条件赋值或无条件跟随。参数同样要指定位宽。
第四种是util包里的带一个使能端的寄存器“RegEnable[T <: Data](next: T, init: T, enable: Bool)”,如果不需要复位信号,则第二个参数可以省略给出。
第五种是util包里的移位寄存器“ShiftRegister[T <: Data](in: T, n: Int, resetData: T, en: Bool)”,其中第一个参数in是带移位的数据,第二个参数n是需要延迟的周期数,第三个参数resetData是指定的复位值,可以省略,第四个参数en是使能移位的信号,默认为true.B。
// reg.scala package test import chisel3._ import chisel3.util._ class REG extends Module { val io = IO(new Bundle { val a = Input(UInt(8.W)) val en = Input(Bool()) val c = Output(UInt(1.W)) }) val reg0 = RegNext(io.a) val reg1 = RegNext(io.a, 0.U) val reg2 = RegInit(0.U(8.W)) val reg3 = Reg(UInt(8.W)) val reg4 = Reg(UInt(8.W)) val reg5 = RegEnable(io.a + 1.U, 0.U, io.en) val reg6 = RegEnable(io.a - 1.U, io.en) val reg7 = ShiftRegister(io.a, 3, 0.U, io.en) val reg8 = ShiftRegister(io.a, 3, io.en) reg2 := io.a.andR reg3 := io.a.orR when(reset.toBool) { reg4 := 0.U } .otherwise { reg4 := 1.U } io.c := reg0(0) & reg1(0) & reg2(0) & reg3(0) & reg4(0) & reg5(0) & reg6(0) & reg7(0) & reg8(0) }
寄存器组
如果把子类型Vec[T]作为参数传递进去,就会生成多个位宽相同、行为相同、名字前缀相同的寄存器。同样,寄存器组在Chisel代码里可以通过下标索引。
// reg2.scala package test import chisel3._ import chisel3.util._ class REG2 extends Module { val io = IO(new Bundle { val a = Input(UInt(8.W)) val en = Input(Bool()) val c = Output(UInt(1.W)) }) val reg0 = RegNext(VecInit(io.a, io.a)) val reg1 = RegNext(VecInit(io.a, io.a), VecInit(0.U, 0.U)) val reg2 = RegInit(VecInit(0.U(8.W), 0.U(8.W))) val reg3 = Reg(Vec(2, UInt(8.W))) val reg4 = Reg(Vec(2, UInt(8.W))) val reg5 = RegEnable(VecInit(io.a + 1.U, io.a + 1.U), VecInit(0.U(8.W), 0.U(8.W)), io.en) val reg6 = RegEnable(VecInit(io.a - 1.U, io.a - 1.U), io.en) val reg7 = ShiftRegister(VecInit(io.a, io.a), 3, VecInit(0.U(8.W), 0.U(8.W)), io.en) val reg8 = ShiftRegister(VecInit(io.a, io.a), 3, io.en) reg2(0) := io.a.andR reg2(1) := io.a.andR reg3(0) := io.a.orR reg3(1) := io.a.orR when(reset.toBool) { reg4(0) := 0.U reg4(1) := 0.U } .otherwise { reg4(0) := 1.U reg4(1) := 1.U } io.c := reg0(0)(0) & reg1(0)(0) & reg2(0)(0) & reg3(0)(0) & reg4(0)(0) & reg5(0)(0) & reg6(0)(0) & reg7(0)(0) & reg8(0)(0) & reg0(1)(0) & reg1(1)(0) & reg2(1)(0) & reg3(1)(0) & reg4(1)(0) & reg5(1)(0) & reg6(1)(0) & reg7(1)(0) & reg8(1)(0) }
用when给电路赋值
由于Scala已经占用了“if…else if…else”语法,所以相应的Chisel控制结构改成了when语句,其语法如下:
when用于给带使能信号的寄存器更新数据,组合逻辑不常用。对于有复位信号的寄存器,推荐使用RegInit来声明,这样生成的Verilog会自动根据当前的时钟域来同步复位,尽量不要在when语句里用“reset.toBool”作为复位条件
除了when结构,util包里还有一个与之对偶的结构“unless”,如果unless的判定条件为false.B则一直执行,否则不执行
数据类型与硬件类型的区别
hisel的数据类型,其中常用的就五种:UInt、SInt、Bool、Bundle和Vec[T]。本章介绍了硬件类型,最基本的是IO、Wire和Reg三种,还有指明端口方向的Input、Output和Flipped
在编写Chisel时,要注意哪些地方是数据类型,哪些地方又是硬件类型。这时,静态语言的优势便体现出来了,因为编译器会帮助程序员检查类型是否匹配。如果在需要数据类型的地方出现了硬件类型、在需要硬件类型的地方出现了数据类型
为VecInit专门接收硬件类型的参数来构造硬件向量,给VecInit传入数据类型反而会报错
第十九章 Chisel基础——常用的硬件原语
Chisel在语言库里定义了很多常用的硬件原语,读者可以直接导入相应的包来使用
多路选择器
第一种形式是二输入多路选择器“Mux(sel, in1, in2)”。sel是Bool类型,in1和in2的类型相同,都是Data的任意子类型。当sel为true.B时,返回in1,否则返回in2
所以Mux可以内嵌Mux,Mux(c1, a, Mux(c2, b, Mux(..., default)))
第二种就是针对上述n输入多路选择器的简便写法,形式为“MuxCase(default, Array(c1 -> a, c2 -> b, ...))”,它的展开与嵌套的Mux是一样的。第一个参数是默认情况下返回的结果,第二个参数是一个数组,数组的元素是对偶“(成立条件,被选择的输入)”。MuxCase在chisel3.util包里
第三种是MuxCase的变体,它相当于把MuxCase的成立条件依次换成从0开始的索引值,就好像一个查找表,其形式为“MuxLookup(idx, default, Array(0.U -> a, 1.U -> b, ...))”。它的展开相当于“MuxCase(default, Array((idx === 0.U) -> a, (idx === 1.U) -> b, ...))”。MuxLookup也在chisel3.util包里。
第四种是chisel3.util包里的独热码多路选择器,它的选择信号是一个独热码。如果零个或多个选择信号有效,则行为未定义
val hotValue = Mux1H(Seq( io.selector(0) -> 2.U, io.selector(1) -> 4.U, io.selector(2) -> 8.U, io.selector(4) -> 11.U ))
ROM
可以通过工厂方法“VecInit[T <: Data](elt0: T, elts: T*)”或“VecInit[T <: Data](elts: Seq[T])”来创建一个只读存储器,参数就是ROM里的常量数值,对应的Verilog代码就是给读取ROM的线网或寄存器赋予常量值
// rom.scala package test import chisel3._ class ROM extends Module { val io = IO(new Bundle { val sel = Input(UInt(2.W)) val out = Output(UInt(8.W)) }) val rom = VecInit(1.U, 2.U, 3.U, 4.U) io.out := rom(io.sel) }
RAM
Chisel支持两种类型的RAM。第一种RAM是同步(时序)写,异步(组合逻辑)读,通过工厂方法“Mem[T <: Data](size: Int, t: T)”来构建
val asyncMem = Mem(16, UInt(32.W))
由于现代的FPGA和ASIC技术已经不再支持异步读RAM,所以这种RAM会被综合成寄存器阵列。第二种RAM则是同步(时序)读、写,通过工厂方法“SyncReadMem[T <: Data](size: Int, t: T)”来构建,这种RAM会被综合成实际的SRAM
val syncMem = SyncReadMem(16, UInt(32.W))
写RAM的语法是
when(wr_en) {
mem.write(address, dataIn)
out := DontCare
}
读RAM的语法是
out := mem.read(address, rd_en)
带写掩模的RAM
RAM通常都具备按字节写入的功能,比如数据写入端口的位宽是32bit,那么就应该有4bit的写掩模信号,只有当写掩模比特有效时,对应的字节才会写入。Chisel也具备构建带写掩模的RAM的功能。
而write方法有一个重载版本,就是第三个参数是接收写掩模信号的。当下标为0的写掩模比特是true.B时,最低的那个字节会被写入,依次类推。下面是一个带写掩模的单端口RAM
// maskram.scala package test import chisel3._ import chisel3.util._ class MaskRAM extends Module { val io = IO(new Bundle { val addr = Input(UInt(10.W)) val dataIn = Input(UInt(32.W)) val en = Input(Bool()) val we = Input(UInt(4.W)) val dataOut = Output(UInt(32.W)) }) val dataIn_temp = Wire(Vec(4, UInt(8.W))) val dataOut_temp = Wire(Vec(4, UInt(8.W))) val mask = Wire(Vec(4, Bool())) val syncRAM = SyncReadMem(1024, Vec(4, UInt(8.W))) when(io.en) { syncRAM.write(io.addr, dataIn_temp, mask) dataOut_temp := syncRAM.read(io.addr) } .otherwise { dataOut_temp := DontCare } for(i <- 0 until 4) { dataIn_temp(i) := io.dataIn(8*i+7, 8*i) mask(i) := io.we(i).toBool io.dataOut := Cat(dataOut_temp(3), dataOut_temp(2), dataOut_temp(1), dataOut_temp(0)) } }
从文件读取数据到RAM
在experimental包里有一个单例对象loadMemoryFromFile,它的apply方法可以在Chisel层面上从txt文件读取数据到RAM里、、
MemBase[T]类型的,也就是Mem[T]和SyncReadMem[T]的超类,该参数接收一个自定义的RAM对象。第二个参数是文件的名字及路径,用字符串表示。第三个参数表示读取的方式为十六进制或二进制,默认是MemoryLoadFileType.Hex,也可以改成MemoryLoadFileType.Binary。注意,没有十进制和八进制
计数器
Chisel在util包里定义了一个自增计数器原语Counter,它的工厂方法接收两个参数:第一个参数是Bool类型的使能信号,为true.B时计数器从0开始每个时钟上升沿加1自增,为false.B时则计数器保持不变;第二个参数需要一个Int类型的具体正数,当计数到该值时归零。该方法返回一个二元组,其第一个元素是计数器的计数值,第二个元素是判断计数值是否等于期望值的结果。
// counter.scala
package test
import chisel3._
import chisel3.util._
class MyCounter extends Module {
val io = IO(new Bundle {
val en = Input(Bool())
val out = Output(UInt(8.W))
val valid = Output(Bool())
})
val (a, b) = Counter(io.en, 233)
io.out := a
io.valid := b
}
16位线性反馈移位寄存器
如果要产生伪随机数,可以使用util包里的16位线性反馈移位寄存器原语LFSR16,它接收一个Bool类型的使能信号,用于控制寄存器是否移位,缺省值为true.B。它返回一个UInt(16.W)类型的结果。
// lfsr.scala package test import chisel3._ import chisel3.util._ class LFSR extends Module { val io = IO(new Bundle { val en = Input(Bool()) val out = Output(UInt(16.W)) }) io.out := LFSR16(io.en) }
状态机
Chisel没有直接构建状态机的原语。不过,util包里定义了一个Enum特质及其伴生对象。伴生对象里的apply方法定义如下
def apply(n: Int): List[UInt]
参数n返回对应元素数的List[UInt],每个元素都是不同的,所以可以作为枚举值来使用。最好把枚举状态的变量名也组成一个列表,然后用列表的模式匹配来进行赋值。有了枚举值后,可以通过“switch…is…is”语句来使用
// fsm.scala package test import chisel3._ import chisel3.util._ class DetectTwoOnes extends Module { val io = IO(new Bundle { val in = Input(Bool()) val out = Output(Bool()) }) val sNone :: sOne1 :: sTwo1s :: Nil = Enum(3) val state = RegInit(sNone) io.out := (state === sTwo1s) switch (state) { is (sNone) { when (io.in) { state := sOne1 } } is (sOne1) { when (io.in) { state := sTwo1s } .otherwise { state := sNone } } is (sTwo1s) { when (!io.in) { state := sNone } } } }
枚举状态名的首字母要小写,这样Scala的编译器才能识别成变量模式匹配。
Chisel基础——生成Verilog与基本测试
把一个Chisel模块编译成Verilog代码,并进一步使用Verilator做一些简单的测试
生成Verilog
生成Verilog的程序自然是在主函数里例化待编译的模块,然后运行这个主函数。例化待编译模块需要特殊的方法调用。chisel3包里有一个单例对象Driver,它包含一个方法execute,该方法接收两个参数,第一个参数是命令行传入的实参即字符串数组args,第二个是返回待编译模块的对象的无参函数。运行这个execute方法,就能得到Verilog代码。
接着,读者需要在src/test/scala文件夹下编写对应的主函数文件
// fullAdderGen.scala package test object FullAdderGen extends App { chisel3.Driver.execute(args, () => new FullAdder) }
在这个主函数里,只有一个execute函数的调用,第一个参数固定是“args”,第二个参数则是无参的函数字面量“() => new FullAdder”。因为Chisel的模块本质上还是Scala的class,所以只需用new构造一个对象作为返回结果即可。主函数里可以包括多个execute函数,也可以包含其它代码。还有一点要注意的是,建议把设计文件和主函数放在一个包里,比如这里的“package test”,这样省去了编写路径的麻烦。
要运行这个主函数,需要在build.sbt文件所在的路径下打开终端,然后执行命令
:~/chisel-template$ sbt 'test:runMain test.FullAdderGen'
sbt后面有空格,再后面的内容都是被单引号对或双引号对包起来。其中,test:runMain是让sbt执行主函数的命令,而test.FullAdderGen就是要执行的那个主函数
终端的路径下就会生成三个文件:FullAdder.anno.json、FullAdder.fir和FullAdder.v。
第二个后缀为“.fir”的文件就是对应的Firrtl代码,第三个自然是对应的Verilog文件。
在命令里增加参数
给Firrtl传递参数
命令后面继续增加可选的参数。例如,增加参数“--help”查看帮助菜单
最常用的是参数“-td”,可以在后面指定一个文件夹,这样之前生成的三个文件就在该文件夹里,而不是在当前路径下
给主函数传递参数
Scala的类可以接收参数,自然Chisel的模块也可以接收参数。假设要构建一个n位的加法器,具体位宽不确定,根据需要而定。那么,就可以把端口位宽参数化,例化时传入想要的参数即可。
package test import chisel3._ class Adder(n: Int) extends Module { val io = IO(new Bundle { val a = Input(UInt(n.W)) val b = Input(UInt(n.W)) val s = Output(UInt(n.W)) val cout = Output(UInt(1.W)) }) io.s := (io.a +& io.b)(n-1, 0) io.cout := (io.a +& io.b)(n) } // adderGen.scala package test object AdderGen extends App { chisel3.Driver.execute(args, () => new Adder(args(0).toInt)) }
比如例子中的主函数期望第一个参数即args(0)是一个数字字符串,这样就能通过方法toInt转换成Adder所需的参数。
~/chisel-template$ sbt 'test:runMain test.AdderGen 8 -td ./generated/adder'
编写简单的测试
Chisel的测试有两种,第一种是利用Scala的测试来验证Chisel级别的代码逻辑有没有错误。因为这部分内容比较复杂,而且笔者目前也没有深入学习有关Scala测试的内容,所以这部分内容可有读者自行选择研究。第二种是利用Chisel库里的peek和poke函数,给模块的端口加激励、查看信号值,并交由下游的Verilator来仿真、产生波形。这种方式比较简单,类似于Verilog的testbench,适合小型电路的验证。对于超大型的系统级电路,最好还是生成Verilog,交由成熟的EDA工具,用UVM进行验证。
要编写一个简单的testbench,首先也是定义一个类,这个类的主构造方法接收一个参数,参数类型就是待测模块的类名。其次,这个类继承自PeekPokeTester类,并且把接收的待测模块也传递给此超类。最后,测试类内部有四种方法可用:①“poke(端口,激励值)”方法给相应的端口添加想要的激励值,激励值是Int类型的;②“peek(端口)”方法返回相应的端口的当前值;③“expect(端口,期望值)”方法会对第一个参数(端口)使用peek方法,然后与Int类型的期望值进行对比,如果两者不相等则出错;④“step(n)”方法则让仿真前进n个时钟周期。
package test import scala.util._ import chisel3.iotesters._ class AdderTest(c: Adder) extends PeekPokeTester(c) { val randNum = new Random for(i <- 0 until 10) { val a = randNum.nextInt(256) val b = randNum.nextInt(256) poke(c.io.a, a) poke(c.io.b, b) step(1) expect(c.io.s, (a + b) & 0xff) expect(c.io.cout, ((a + b) & 0x100) >> 8) } }
第一个包scala.util里包含了Scala生成伪随机数的类Random,第二个包chisel3.iotesters包含了测试类PeekPokeTester
运行测试
自然也是通过主函数,但是这次是使用iotesters包里的execute方法。该方法与前面生成Verilog的方法类似,仅仅是多了一个参数列表,多出的第二个参数列表接收一个返回测试类的对象的函数:
// addertest.scala object AdderTestGen extends App { chisel3.iotesters.Driver.execute(args, () => new Adder(8))(c => new AdderTest(c)) }
~/chisel-template$ sbt 'test:runMain test.AdderTestGen -td ./generated/addertest --backend-name verilator'
执行成功后,就能在相应文件夹里看到一个新生成的文件夹,里面是仿真生成的文件。其中,“Adder.vcd”文件就是波形文件,使用GTKWave软件打开就能查看,将相应的端口拖拽到右侧就能显示波形。
第二十一章 Chisel基础——黑盒
例化黑盒
如果定义Dut类时,不是继承自Module,而是继承自BlackBox,则允许只有端口定义,也只需要端口定义。此外,在别的模块里例化黑盒时,编译器不会给黑盒的端口名加上“io_”
// blackbox.scala package test import chisel3._ class Dut extends BlackBox { val io = IO(new Bundle { val a = Input(UInt(32.W)) val clk = Input(Clock()) val reset = Input(Bool()) val b = Output(UInt(4.W)) }) } class UseDut extends Module { val io = IO(new Bundle { val toDut_a = Input(UInt(32.W)) val toDut_b = Output(UInt(4.W)) }) val u0 = Module(new Dut) u0.io.a := io.toDut_a u0.io.clk := clock u0.io.reset := reset io.toDut_b := u0.io.b } object UseDutTest extends App { chisel3.Driver.execute(args, () => new UseDut) }
BlackBox的构造方法可以接收一个Map[String, Param]类型的参数,这会使得例化外部的Verilog模块时具有配置模块的“#(参数配置)”。映射的键固定是字符串类型,它对应Verilog里声明的参数名;映射的值对应传入的配置参数,可以是字符串,也可以是整数和浮点数。虽然值的类型是Param,这是一个Chisel的印章类,但是单例对象chisel3.experimental里定义了相应的隐式转换,可以把BigInt、Int、Long、Double和String转换成对应的Param类型
... import chisel3.experimental._ class Dut extends BlackBox(Map("DATA_WIDTH" -> 32, "MODE" -> "Sequential", "RESET" -> "Asynchronous")) { val io = IO(new Bundle { val a = Input(UInt(32.W)) val clk = Input(Clock()) val reset = Input(Bool()) val b = Output(UInt(4.W)) }) } ..
复制Verilog文件
chisel3.util包里有一个特质HasBlackBoxResource,如果在黑盒类里混入这个特质,并且在src/main/resources文件夹里有对应的Verilog源文件,那么在Chisel转换成Verilog时,就会把Verilog文件一起复制到目标文件夹。
...
import chisel3.util._
class Dut extends BlackBox with HasBlackBoxResource {
val io = IO(new Bundle {
val a = Input(UInt(32.W))
val clk = Input(Clock())
val reset = Input(Bool())
val b = Output(UInt(4.W))
})
setResource("/dut.v")
}
注意,相比一般的黑盒,除了端口列表的声明,还多了一个特质里的setResource方法的调用。方法的入参是Verilog文件的相对地址,即相对src/main/resources的地址
内联Verilog文件
hisel3.util包里还有有一个特质HasBlackBoxInline,混入该特质的黑盒类可以把Verilog代码直接内嵌进去。内嵌的方式是调用特质里的方法“setInline(blackBoxName: String, blackBoxInline: String)”,类似于setResource的用法。这样,目标文件夹里就会生成一个单独的Verilog文件,复制内嵌的代码。该方法适合小型Verilog设计。
inout端口
Chisel目前只支持在黑盒中引入Verilog的inout端口。Bundle中使用 “Analog(位宽)”声明Analog类型的端口,经过编译后变成Verilog的inout端口
模块里的端口可以声明成Analog类型,但只能用于与黑盒连接,不能在Chisel代码中进行读写。
使用前,要先用“chisel3.experimental._”进行导入。
第二十二章 Chisel基础——多时钟域设计
// inout.scala
package test
import chisel3._
import chisel3.util._
import chisel3.experimental._
class InoutIO extends Bundle {
val a = Analog(16.W)
val b = Input(UInt(16.W))
val sel = Input(Bool())
val c = Output(UInt(16.W))
}
class InoutPort extends BlackBox with HasBlackBoxInline {
val io = IO(new InoutIO)
setInline("InoutPort.v",
"""
|module InoutPort( inout [15:0] a,
| input [15:0] b,
| input sel,
| output [15:0] c);
| assign a = sel ? 'bz : b;
| assign c = sel ? a : 'bz;
|endmodule
""".stripMargin)
}
class MakeInout extends Module {
val io = IO(new InoutIO)
val m = Module(new InoutPort)
m.io <> io
}
object InoutGen extends App {
chisel3.Driver.execute(args, () => new MakeInout)
}
第二十二章 Chisel基础——多时钟域设计
数字电路中免不了用到多时钟域设计,尤其是设计异步FIFO这样的同步元件
在Chisel里,则相对复杂一些,因为这与Scala的变量作用域相关,而且时序元件在编译时都是自动地隐式跟随当前时钟域。
没有隐式端口的模块
继承自Module的模块类会获得隐式的全局时钟与同步复位信号,即使在设计中用不上它们也没关系。如果读者确实不喜欢这两个隐式端口,则可以选择继承自RawModule,这样在转换成Verilog时就没有隐式端口。
// module.scala package test import chisel3._ import chisel3.experimental._ class MyModule extends RawModule { val io = IO(new Bundle { val a = Input(UInt(4.W)) val b = Input(UInt(4.W)) val c = Output(UInt(4.W)) }) io.c := io.a & io.b } object ModuleGen extends App { chisel3.Driver.execute(args, () => new MyModule) }
RawModule也可以包含时序逻辑,但要使用多时钟域语法。
定义一个时钟域和复位域
chisel3.core包里有一个单例对象withClockAndReset,其apply方法定义如下:
def apply[T](clock: Clock, reset: Reset)(block: ⇒ T): T
在编写代码时不能写成“import chisel3.core._”,这会扰乱“import chisel3._”的导入内容。正确做法是用“import chisel3.experimental._”导入experimental对象,它里面用同名字段引用了单例对象chisel3.core.withClockAndReset,这样就不需要再导入core包。
class MultiClockModule extends Module { val io = IO(new Bundle { val clockB = Input(Clock()) val resetB = Input(Bool()) val stuff = Input(Bool()) }) // 这个寄存器跟随当前模块的隐式全局时钟clock val regClock1 = RegNext(io.stuff) withClockAndReset(io.clockB, io.resetB) { // 在该花括号内,所有时序元件都跟随时钟io.clockB // 所有寄存器的复位信号都是io.resetB // 这个寄存器跟随io.clockB val regClockB = RegNext(io.stuff) // 还可以例化其它模块 val m = Module(new ChildModule) } // 这个寄存器跟随当前模块的隐式全局时钟clock val regClock2 = RegNext(io.stuff) } ————————————————
因为第二个参数列表只有一个传名参数,所以可以把圆括号写成花括号,这样还有自动的分号推断。再加上传名参数的特性,尽管需要一个无参函数,但是可以省略书写“() =>”
withClockAndReset(io.clockB, io.resetB) {
sentence1
sentence2
...
sentenceN
}
实际上相当于:
withClockAndReset(io.clockB, io.resetB)( () => (sentence1; sentence2; ...; sentenceN) )
读者再仔细看一看apply方法的定义,它的第二个参数是一个函数,同时该函数的返回结果也是整个apply方法的返回结果
class MultiClockModule extends Module { val io = IO(new Bundle { val clockB = Input(Clock()) val resetB = Input(Bool()) val stuff = Input(Bool()) }) val clockB_child = withClockAndReset(io.clockB, io.resetB) { Module(new ChildModule) } clockB_child.io.in := io.stuff }
如果传名参数全都是定义,最后没有表达式用于返回,那么apply的返回结果类型自然就是Unit。
class MultiClockModule extends Module { val io = IO(new Bundle { val clockB = Input(Clock()) val resetB = Input(Bool()) val stuff = Input(Bool()) }) val clockB_child = withClockAndReset(io.clockB, io.resetB) { val m = Module(new ChildModule) } clockB_child.m.io.in := io.stuff }
除了单例对象withClockAndReset,还有单例对象withClock和withReset
使用时钟负沿和低有效的复位信号
可以改变其行为。复位信号比较简单,只需要加上取反符号或逻辑非符号。时钟信号稍微麻烦一些,需要先用asUInt方法把Clock类型转换成UInt类型,再用toBool转换成Bool类型,此时可以加上取反符号或逻辑非符号,最后再用asClock变回Clock类型
// negclkrst.scala package test import chisel3._ import chisel3.experimental._ class NegativeClkRst extends RawModule { val io = IO(new Bundle { val in = Input(UInt(4.W)) val myClk = Input(Clock()) val myRst = Input(Bool()) val out = Output(UInt(4.W)) }) withClockAndReset((~io.myClk.asUInt.toBool).asClock, ~io.myRst) { val temp = RegInit(0.U(4.W)) temp := io.in io.out := temp } } object NegClkRstGen extends App { chisel3.Driver.execute(args, () => new NegativeClkRst) }
示例:异步FIFO
// FIFO.scala package fifo import chisel3._ import chisel3.util._ import chisel3.experimental._ class FIFO(width: Int, depth: Int) extends RawModule { val io = IO(new Bundle { // write-domain val dataIn = Input(UInt(width.W)) val writeEn = Input(Bool()) val writeClk = Input(Clock()) val full = Output(Bool()) // read-domain val dataOut = Output(UInt(width.W)) val readEn = Input(Bool()) val readClk = Input(Clock()) val empty = Output(Bool()) // reset val systemRst = Input(Bool()) }) val ram = SyncReadMem(1 << depth, UInt(width.W)) // 2^depth val writeToReadPtr = Wire(UInt((depth + 1).W)) // to read clock domain val readToWritePtr = Wire(UInt((depth + 1).W)) // to write clock domain // write clock domain withClockAndReset(io.writeClk, io.systemRst) { val binaryWritePtr = RegInit(0.U((depth + 1).W)) val binaryWritePtrNext = Wire(UInt((depth + 1).W)) val grayWritePtr = RegInit(0.U((depth + 1).W)) val grayWritePtrNext = Wire(UInt((depth + 1).W)) val isFull = RegInit(false.B) val fullValue = Wire(Bool()) val grayReadPtrDelay0 = RegNext(readToWritePtr) val grayReadPtrDelay1 = RegNext(grayReadPtrDelay0) binaryWritePtrNext := binaryWritePtr + (io.writeEn && !isFull).asUInt binaryWritePtr := binaryWritePtrNext grayWritePtrNext := (binaryWritePtrNext >> 1) ^ binaryWritePtrNext grayWritePtr := grayWritePtrNext writeToReadPtr := grayWritePtr fullValue := (grayWritePtrNext === Cat(~grayReadPtrDelay1(depth, depth - 1), grayReadPtrDelay1(depth - 2, 0))) isFull := fullValue when(io.writeEn && !isFull) { ram.write(binaryWritePtr(depth - 1, 0), io.dataIn) } io.full := isFull } // read clock domain withClockAndReset(io.readClk, io.systemRst) { val binaryReadPtr = RegInit(0.U((depth + 1).W)) val binaryReadPtrNext = Wire(UInt((depth + 1).W)) val grayReadPtr = RegInit(0.U((depth + 1).W)) val grayReadPtrNext = Wire(UInt((depth + 1).W)) val isEmpty = RegInit(true.B) val emptyValue = Wire(Bool()) val grayWritePtrDelay0 = RegNext(writeToReadPtr) val grayWritePtrDelay1 = RegNext(grayWritePtrDelay0) binaryReadPtrNext := binaryReadPtr + (io.readEn && !isEmpty).asUInt binaryReadPtr := binaryReadPtrNext grayReadPtrNext := (binaryReadPtrNext >> 1) ^ binaryReadPtrNext grayReadPtr := grayReadPtrNext readToWritePtr := grayReadPtr emptyValue := (grayReadPtrNext === grayWritePtrDelay1) isEmpty := emptyValue io.dataOut := ram.read(binaryReadPtr(depth - 1, 0), io.readEn && !isEmpty) io.empty := isEmpty } } object FIFOGen extends App { chisel3.Driver.execute(args, () => new FIFO(args(0).toInt, args(1).toInt)) }
第二十三章 Chisel基础——函数的应用
对于Chisel这样的高级语言,函数的使用更加方便,还能节省不少代码量。不管是用户自己写的函数、Chisel语言库里的函数还是Scala标准库里的函数,都能帮助用户节省构建电路的时间
用函数抽象组合逻辑
与Verilog一样,对于频繁使用的组合逻辑电路,可以定义成Scala的函数形式,然后通过函数调用的方式来使用它。这些函数既可以定义在某个单例对象里,供多个模块重复使用,也可以直接定义在电路模块里。
// function.scala import chisel3._ class UseFunc extends Module { val io = IO(new Bundle { val in = Input(UInt(4.W)) val out1 = Output(Bool()) val out2 = Output(Bool()) }) def clb(a: UInt, b: UInt, c: UInt, d: UInt): UInt = (a & b) | (~c & d) io.out1 := clb(io.in(0), io.in(1), io.in(2), io.in(3)) io.out2 := clb(io.in(0), io.in(2), io.in(3), io.in(1)) }
用工厂方法简化模块的例化
在Scala里,往往在类的伴生对象里定义一个工厂方法,来简化类的实例化。同样,Chisel的模块也是Scala的类,也可以在其伴生对象里定义工厂方法来简化例化、连线模块
// mux4.scala import chisel3._ 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) } object Mux2 { def apply(sel: UInt, in0: UInt, in1: UInt) = { val m = Module(new Mux2) m.io.in0 := in0 m.io.in1 := in1 m.io.sel := sel m.io.out } } class Mux4 extends Module { val io = IO(new Bundle { val sel = Input(UInt(2.W)) 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 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)) }
用Scala的函数简化代码
比如在生成长的序列上,利用Scala的函数就能减少大量的代码。
利用Scala的for、yield组合可以产生相应的判断条件与输出结果的序列,再用zip函数将两个序列组成一个对偶序列,再把对偶序列作为MuxCase的参数,就能用几行代码构造出任意位数的译码器。
// decoder.scala
package decoder
import chisel3._
import chisel3.util._
import chisel3.experimental._
class Decoder(n: Int) extends RawModule {
val io = IO(new Bundle {
val sel = Input(UInt(n.W))
val out = Output(UInt((1 << n).W))
})
val x = for(i <- 0 until (1 << n)) yield io.sel === i.U
val y = for(i <- 0 until (1 << n)) yield 1.U << i
io.out := MuxCase(0.U, x zip y)
}
object DecoderGen extends App {
chisel3.Driver.execute(args, () => new Decoder(args(0).toInt))
}
Chisel的打印函数
printf函数只能在Chisel的模块里使用,并且会转换成Verilog的系统函数“$fwrite”,包含在宏定义块“ `ifndef SYNTHESIS......`endif ”里。通过Verilog的宏定义,可以取消这部分不可综合的代码。因为后导入的chisel3包覆盖了Scala的标准包,所以Scala里的printf函数要写成“Predef.printf”的完整路径形式。
————————————————
Scala风格
Chisel自定义了一个p插值器,该插值器可以对字符串内的一些自定义表达式进行求值、Chiel类型转化成字符串类型等
val myUInt = 33.U // 显示Chisel自定义的类型的数据 printf(p"myUInt = $myUInt") // myUInt = 33 // 显示成十六进制 printf(p"myUInt = 0x${Hexadecimal(myUInt)}") // myUInt = 0x21 // 显示成二进制 printf(p"myUInt = ${Binary(myUInt)}") // myUInt = 100001 // 显示成字符(ASCⅡ码) printf(p"myUInt = ${Character(myUInt)}") // myUInt = !
val myUInt = 33.U// 显示Chisel自定义的类型的数据printf(p"myUInt = $myUInt") // myUInt = 33// 显示成十六进制printf(p"myUInt = 0x${Hexadecimal(myUInt)}") // myUInt = 0x21// 显示成二进制printf(p"myUInt = ${Binary(myUInt)}") // myUInt = 100001// 显示成字符(ASCⅡ码)printf(p"myUInt = ${Character(myUInt)}") // myUInt = !————————————————版权声明:本文为CSDN博主「_iChthyosaur」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。原文链接:https://blog.csdn.net/qq_34291505/article/details/87905379