在学习Chisel前,应该熟悉一些常见问题,这些问题在编写Chisel的任何时候都应该牢记。
①Chisel是寄宿在Scala里的语言,所以它本质还是Scala。为了从Chisel转变成Verilog,语言开发人员开发了一个中间的标准交换格式——Firrtl,它跟Vrilog是同一级别的,两者都比Chisel低一级。编写的Chisel代码首先会经过Firrtl编译器,生成Firrtl代码,也就是一个后缀格式为“.fir”的文件,然后由这个Firrtl文件再去生成对应的Verilog代码。如果读者有兴趣看一看Firrtl的格式,其实与Verilog很接近,只不过是由机器生成的、很死板的代码。Firrtl编译器也并不是只针对Chisel,有兴趣和能力的读者也可以开发针对Java、Python、C++等语言的Firrtl编译器。因为Firrtl只是一种标准的中间媒介,如何从一端到另一端,完全是自定义的。另外,Firrtl也并不仅仅是生成Verilog,同样可以开发工具生成VHDL、SystemVerilog等语言。
②Scala里的语法,在Chisel里也基本能用,比如Scala的基本值类、内建控制结构、函数抽象、柯里化、模式匹配、隐式参数等等。但是读者要记住这些代码不仅要通过Scala编译器的检查,还需要通过Firrtl编译器的检查。
③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._”等等。
⑦应该用一个名字有意义的包来打包实现某个功能的文件集。例如,要实现一个自定义的微处理器,则可以把顶层包命名为“mycpu”,进而再划分成“myio”、“mymem”、“mybus”、“myalu”等子包,每个子包里包含相关的源文件。
⑧Chisel现在仍在更新中,很可能会添加新功能或删去老功能。因此,本教程介绍的内容在将来并不一定就正确,读者应该持续关注Chisel3的GitHub的发展动向。
Chisel定义了自己的一套数据类型,读者应该跟Scala的九种基本值类区分开来。而且Chisel也能使用Scala的数据类型,但是Scala的数据类型都是用于参数和内建控制结构,构建硬件电路还是得用Chisel自己的数据类型,在使用时千万不要混淆。当前Chisel定义的数据类型如下图所示,其中绿色方块是class,红色是object,蓝色是trait,箭头指向的是超类和混入的特质:
所有数据类型都继承自抽象基类Data,它混入了两个特质HasId和NamedComponent。如果读者查看Chisel3的源代码,就会看到很多参数传递时都用下界表明了是Data的子类。在实际硬件构成里,并不会用到Data,读者也不用关心它的具体实现细节。更多的,应该关注Data类的两大子类:聚合类Aggregate和元素类Element。
聚合类Aggregate的常用子类是向量类Vec[T]和包裹类Bundle。Vec[T]类用于包含相同的元素,元素类型T可以是任意的Data子类。因为Vec[T]混入了特质IndexedSeq[T],所以向量的元素能从下标0开始索引访问。Bundle类用于被自定义的类继承,这样自定义的类就能包含任意Data的子类对象,常用于协助构造模块的端口,故而衍生出了一些预定义的端口子类。混合向量类MixedVec[T]是Chisel3.2以上版本添加的语法,它与Vec[T]的不同在于可以包含不同类型的元素。
Element类衍生出了Analog、Bits和Clock三个子类,单例对象DontCare和特质Reset。Analog用于在黑盒中模拟inout端口,目前在实际Chisel里并无其他用途。Bits类的两个子类SInt和UInt是最常用的两个数据类型,它们是用补码表示的有符号整数和无符号整数。不仅用来协助定义端口位宽,还用来进行赋值。FixedPoint类提供的API带有试验性质,而且将来可能会发生改变,所以不常用。Bool类是Chisel自己的布尔类型,区别于Scala的Boolean。Bool类是UInt类的子类,因为它可以看成是1bit的UInt,而且它被混入Reset特质,因为复位信号都是用Bool类型的线网或寄存器使能的。此外,Bits类混入了特质ToBoolable,也就是说FixedPoint、SInt和UInt都能转换成多bit的Bool类型。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。
从几个隐式类的名字就可以看出,可以通过BigInt、Int、Long和String四种类型的Scala字面量来构造UInt和SInt。按Scala的语法,其中BigInt、Int、Long三种类型默认是十进制的,但可以加前缀“0x”或“0X”变成十六进制。对于字符串类型的字面量,Chisel编译器默认也是十进制的,但是可以加上首字母“h”、“o”、“b”来分别表示十六进制、八进制和二进制。此外,字符串字面量可以用下划线间隔。
可以通过Boolean类型的字面量——true和false——来构造fromBooleanToLiteral类型的对象,然后调用名为B和asBool的方法进一步构造Bool类型的对象。例如:
1.U // 字面值为“1”的UInt对象
-8.S // 字面值为“-8”的SInt对象
"b0101".U // 字面值为“5”的UInt对象
true.B // 字面值为“true”的Bool对象
默认情况下,数据的宽度按字面值取最小,例如字面值为“8”的UInt对象是4位宽,SInt就是5位宽。但是也可以指定宽度。在Chisel2里,宽度是由Int类型的参数表示的,而Chisel3专门设计了宽度类Width。还有一个隐式类fromIntToWidth,就是把Int对象转换成fromIntToWidth类型的对象,然后通过方法W返回一个Width对象。方法U、asUInt、S和asSInt都有一个重载的版本,接收一个Width类型的参数,构造指定宽度的SInt和UInt对象。注意,Bool类型固定是1位宽。例如:
1.U // 字面值为“1”、宽度为1bit的UInt对象
1.U(32.W) // 字面值为“1”、宽度为32bit的UInt对象
UInt、SInt和Bool都不是抽象类,除了可以通过字面量构造对象以外,也可以直接通过apply工厂方法构造没有字面量的对象。UInt和SInt的apply方法有两个版本,一个版本接收Width类型的参数构造指定宽度的对象,另一个则是无参版本构造位宽可自动推断的对象。有字面量的数据类型用于赋值、初始化寄存器等操作,而无字面量的数据类型则用于声明端口、构造向量等。
UInt、SInt和Bool三个类都包含四个方法:asUInt、asSInt、toBool和toBools。其中asUInt和asSInt分别把字面值按无符号数和有符号数解释,并且位宽不会变化,要注意转换过程中可能发生符号位和数值的变化。例如,3bit的UInt值“b111”,其字面量是“7”,转换成SInt后字面量就变成了“-1”。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类型,表示元素的个数,第二个是元素。它属于可索引的序列,下标从0开始。例如:
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]类似,只不过包含的元素可以不全都一样。它的工厂方法是通过重复参数或者序列作为参数来构造的,并且也有一个叫MixedVecInit[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))
})
Bundle可以和UInt进行相互转换。Bundle类有一个方法asUInt,可以把所含的字段拼接成一个UInt数据,并且前面的字段在高位。例如:
class MyBundle extends Bundle {
val foo = UInt(4.W) // 高位
val bar = UInt(4.W) // 低位
}val bundle = Wire(new MyBundle)
bundle.foo := 0xc.U
bundle.bar := 0x3.U
val uint = bundle.asUInt // 12*16 + 3 = 195
有一个隐式类fromBitsable,可以把Data类型的对象转化成该类型,然后通过方法fromBits来接收一个Bits类型的参数来给该对象赋值。不过,该方法在Chisel3中已经被标注为过时,不推荐使用。例如:
class MyBundle extends Bundle {
val foo = UInt(4.W) // 高位
val bar = UInt(4.W) // 低位
}val uint = 0xb4.U
val bundle = Wire(new MyBundle).fromBits(uint) // foo = 11, bar = 4
有了数据类型,还需要预定义一些相关的操作符进行基本的操作。下表是Chisel内建的操作符:
操作符 | 释义 |
---|---|
位操作符 | 作用类型: SInt, UInt, Bool |
val invertedX = ~x | 位取反 |
val hiBits = x & "h_ffff_0000".U | 位与 |
val flagsOut = flagsIn | overflow | 位或 |
val flagsOut = flagsIn ^ toggle | 位异或 |
缩减位操作符 | 作用类型: SInt, UInt 返回类型: Bool |
val allSet = x.andR | 缩减与 |
val anySet = x.orR | 缩减或 |
val parity = x.xorR | 缩减异或 |
相等性比较符 | 作用类型: SInt, UInt, Bool 返回类型: Bool |
val equ = x === y | 相等 |
val neq = x =/= y | 不相等 |
移位操作符 | 作用类型: SInt, UInt |
val twoToTheX = 1.S << x | 逻辑左移 |
val hiBits = 16.U >> x | 右移(UInt逻辑右移,SInt算术右移) |
部分位操作符 | 作用类型: SInt, UInt, Bool |
val xLSB = x(0) | 抽取1bit,最低位下标0,最高位下标n-1 |
val xTopNibble = x(15, 12) | 抽取多个bit,左边是高位,右边是低位 |
val usDebt = Fill(3, "hA".U) | 拼接一个UInt类型的数据多次(位于util包) |
val float = Cat(sign, exponent, mantissa) | 拼接多个bit,左边的参数是高位(位于util包) |
逻辑操作符 | 作用类型: Bool |
val sleep = !busy | 逻辑非 |
val hit = tagMatch && valid | 逻辑与 |
val stall = src1busy || src2busy | 逻辑或 |
val out = Mux(sel, inTrue, inFalse) | 双输入多路选择器,sel是Bool类型 |
算术操作符 | 作用类型: SInt, UInt |
val sum = a + b or val sum = a +% b | 加法(不进行宽度扩展) |
val sum = a +& b | 加法(扩展一位进位位) |
val diff = a - b or val diff = a -% b | 减法(不进行宽度扩展) |
val diff = a -& b | 减法(扩展一位进位位) |
val prod = a * b | 乘法 |
val div = a / b | 除法 |
val mod = a % b | 求余数 |
算术比较符 | 作用类型: SInt, UInt 返回类型: Bool |
val gt = a > b | 大于 |
val gte = a >= b | 大于等于 |
val lt = a < b | 小于 |
val lte = a <= b | 小于等于 |
这里要注意的一点是相等性比较的两个符号是“===”和“=/=”。因为“==”和“!=”已经被Scala占用,所以Chisel另设了这两个新的操作符。按照优先级的判断准则,“===”和“=/=”的优先级以首个字符为“=”来判断,也就是在逻辑操作中,相等性比较的优先级要比与、或、异或都高。
某些操作符会发生位宽的改变,这些返回的结果会生成一个自动推断的位宽。如下表所示:
操作符 | 位宽 |
---|---|
z = x + y or z = x +% y | w(z) = max(w(x), w(y)) |
z = x +& y | w(z) = max(w(x), w(y)) + 1 |
z = x - y or z = x -% y | w(z) = max(w(x), w(y)) |
z = x -& y | w(z) = max(w(x), w(y)) + 1 |
z = x & y | w(z) = min(w(x), w(y)) |
z = Mux(c, x, y) | w(z) = max(w(x), w(y)) |
z = w * y | w(z) = w(x) + w(y) |
z = x << n | w(z) = w(x) + maxNum(n) |
z = x >> n | w(z) = w(x) - minNum(n) |
z = Cat(x, y) | w(z) = w(x) + w(y) |
z = Fill(n, x) | w(z) = w(x) * maxNum(n) |
当把一个短位宽的信号值或硬件结构赋值给长位宽的硬件结构时,会自动扩展符号位。但是反过来会报错,并不是像Verilog那样把多余的高位截断,这需要注意(注:最新的chisel3版本已经可以像Verilog一样自动把高位截断了)。
读者在学习本章后,应该理清Chisel数据类型的关系。常用的类型就五种:UInt、SInt、Bool、Bundle和Vec[T],所以重点学会这五种即可。有关三种值类UInt、SInt和Bool的操作符与Verilog差不多,很快就能理解。