参考博文:https://blog.csdn.net/qq_34291505/article/details/86744581
第十六章 Chisel入门——搭建开发环境
用于编写Chisel的Scala内容已经全部讲完了,下面就可以正式进入Chisel的学习之旅了。有兴趣的读者也可以自行深入研究Scala的其它方面,不管是日后学习、工作,或是研究Chisel发布的新版本,都会有不少的帮助。
在学习Chisel之前,自然是要先讲解如何搭建开发环境。因为目前还没有Windows系统的开发环境,所以读者最好有一个Linux系统的虚拟机,或者Mac OS的电脑。在这里,笔者以使用广泛的Ubuntu 16.04作为开发平台进行讲解。
一、Chisel的安装步骤
首先自然是要保证已经安装好了Scala。对于如何安装Scala,这里就不再赘述,可以参考第二章。接下来,执行以下安装步骤:
①安装Sbt。以下所有安装都只需要默认版本,通过命令安装即可。如果需要特定的版本,读者可以自行下载安装包安装。打开终端,执行命令:
esperanto@ubuntu:~$ sudo apt-get install sbt
等待安装完成后,可以用命令查看sbt的版本:
esperanto@ubuntu:~$ sbt sbtVersion
[info] Loading project definition from /home/esperanto/project
[info] Set current project to esperanto (in build file:/home/esperanto/)
[info] 1.2.6
②安装Git,系统可能已经自带了。执行命令:
esperanto@ubuntu:~$ sudo apt-get install git
esperanto@ubuntu:~$ git --version
git version 2.7.4
③安装Verilator。执行命令:
esperanto@ubuntu:~$ sudo apt-get install verilator
esperanto@ubuntu:~$ verilator -version
Verilator 3.904 2017-05-30 rev verilator_3_904
④从GitHub上克隆一个chisel-template文件夹。在想要安装chisel的目录下执行命令:
esperanto@ubuntu:~$ git clone https://github.com/freechipsproject/chisel-template
这个文件夹就是一个工程文件,它已经自带了Chisel 3.1.3。如果读者不想使用新版本的Chisel,那么在这个工程文件下编写代码也已经足够了。如果想用更新的版本,则继续下面的步骤。
⑤安装Firrtl。在想要安装chisel的目录下执行命令:
esperanto@ubuntu:~$ git clone https://github.com/freechipsproject/firrtl.git && cd firrtl
克隆完成后,cd命令会把终端路径切换到firrtl文件夹下,在该路径下执行编译命令:
esperanto@ubuntu:~/firrtl$ sbt compile
编译完成后,执行测试命令:
esperanto@ubuntu:~/firrtl$ sbt test
测试通过后,执行汇编命令:
esperanto@ubuntu:~/firrtl$ sbt assembly
汇编完成后,推送到本地缓存:
esperanto@ubuntu:~/firrtl$ sbt publishLocal
测试步骤容易出错,多半是虚拟机的网络不好导致克隆下来的文件有缺失。如果上述步骤都正确完成了,则可以查看firrtl的版本:
esperanto@ubuntu:~/firrtl$ cd ~/.ivy2/local/edu.berkeley.cs/ && ls
chisel3_2.12 firrtl_2.12
⑥安装chisel3,步骤与firrtl类似。在想要安装chisel的目录下执行以下命令:
esperanto@ubuntu:~$ git clone https://github.com/freechipsproject/chisel3.git && cd chisel3
esperanto@ubuntu:~/chisel3$ sbt compile
esperanto@ubuntu:~/chisel3$ sbt test
esperanto@ubuntu:~/chisel3$ sbt publishLocal
esperanto@ubuntu:~/chisel3$ cd ~/.ivy2/local/edu.berkeley.cs/ && ls
如果所有命令都成功,就能看见chisel3的缓存。
⑦仍然把第④步克隆的chisel-template文件夹作为工程文件,但是里面的build.sbt文件改成以下内容:
// build.sbt
def scalacOptionsVersion(scalaVersion: String): Seq[String] = {
Seq() ++ {
// If we\'re building with Scala > 2.11, enable the compile option
// switch to support our anonymous Bundle definitions:
// https://github.com/scala/bug/issues/10047
CrossVersion.partialVersion(scalaVersion) match {
case Some((2, scalaMajor: Long)) if scalaMajor < 12 => Seq()
case _ => Seq("-Xsource:2.11")
}
}
}
def javacOptionsVersion(scalaVersion: String): Seq[String] = {
Seq() ++ {
// Scala 2.12 requires Java 8. We continue to generate
// Java 7 compatible code for Scala 2.11
// for compatibility with old clients.
CrossVersion.partialVersion(scalaVersion) match {
case Some((2, scalaMajor: Long)) if scalaMajor < 12 =>
Seq("-source", "1.7", "-target", "1.7")
case _ =>
Seq("-source", "1.8", "-target", "1.8")
}
}
}
name := "MyChisel"
version := "3.2-SNAPSHOT"
scalaVersion := "2.12.6"
crossScalaVersions := Seq("2.11.12", "2.12.4")
resolvers += "My Maven" at "https://raw.githubusercontent.com/sequencer/m2_repository/master"
// bug fix from https://github.com/freechipsproject/chisel3/wiki/release-notes-17-09-14
scalacOptions ++= Seq("-Xsource:2.11")
libraryDependencies += "edu.berkeley.cs" %% "chisel3" % "3.2-SNAPSHOT"
libraryDependencies += "edu.berkeley.cs" %% "chisel-iotesters" % "1.2.+"
libraryDependencies += "edu.berkeley.cs" %% "chisel-dot-visualizer" % "0.1-SNAPSHOT"
libraryDependencies += "edu.berkeley.cs" %% "rocketchip" % "1.2"
scalacOptions ++= scalacOptionsVersion(scalaVersion.value)
javacOptions ++= javacOptionsVersion(scalaVersion.value)
二、Chisel的使用方法
克隆的chisel-template文件夹可以修改成想要的名字。该文件夹下的src文件夹用于存放工程的源代码。src文件夹下又有main和test两个文件夹,其中main用于存放Chisel的设计部分,test用于存放对应的测试文件和生成电路的主函数。main文件夹下又有resources和scala两个文件夹,其中scala文件夹里可以创建自定义的工程文件进行编写,也可以继续创建多个文件夹来按模块存储不同功能的设计文件,而resources文件夹用于存放与Chisel互动的Verilog等外部文件。test文件下只有一个scala文件夹,在scala文件夹里创建自定义的测试文件和主函数文件。
在编写代码时,可用的编辑器可以选择自带的gedit。笔者习惯使用Visual Studio Code,因为可以一边写代码一边使用集成终端,而且应用商店里可以下载到Chisel语法的扩展应用。
也可以使用IDE工具IntelliJ IDEA。首先需要安装好Scala插件,然后在开始界面选择导入工程,并把chisel-template指定为工程文件夹,在下一个页面选择“Import project from external model”并从列表里选择Sbt为外部模型,最后确定即可。
三、编写一个简单的电路
在chisel-template/src/main/scala文件夹里创建一个文件,命名为AND.scala,输入以下内容并保存:
// AND.scala
package test
import chisel3._
import chisel3.experimental._
class AND extends RawModule {
val io = IO(new Bundle {
val a = Input(UInt(1.W))
val b = Input(UInt(1.W))
val c = Output(UInt(1.W))
})
io.c := io.a & io.b
}
在chisel-template/src/test/scala文件夹里创建一个文件,命名为ANDtest.scala,输入以下内容并保存:
// ANDtest.scala
package test
import chisel3._
object testMain extends App {
Driver.execute(args, () => new AND)
}
在chisel-template文件夹下(与文件build.sbt同一路径)打开终端,执行命令:
esperanto@ubuntu:~/chisel-template$ sbt "test:runMain test.testMain --target-dir generated/and"
当最后输出success时,就会在当前路径生成一个generated文件夹,里面有一个and文件夹。and文件夹里包含了三个最终输出的文件,打开其中的AND.v文件,可以看到一个与门的Verilog代码:
module AND(
input io_a,
input io_b,
output io_c
);
assign io_c = io_a & io_b;
endmodule
对于小规模电路,可以直接用Chisel写testbench文件,然后联合Verilator生成C++文件来仿真,输出波形图。该方法会在后续章节介绍。对于大规模电路,Verilator仿真很吃力,建议还是用生成的Verilog文件在专业EDA工具里仿真。当前Chisel不支持UVM,也没有工具支持Chisel,所以尽量用别的工具做测试。
四、总结
本章介绍了Chisel开发环境的搭建,搭建完毕后就可以用Chisel代码生成电路了。后续章节将逐步讲解Chisel的语法,由于内容较为分散,不能很快就完成模块级的讲解,所以前面的内容无法及时运行验证,读者只需要理解文中提供的示例即可。
第十七章 Chisel基础——数据类型
一、Chisel的常见问题
在学习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的数据类型
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的内建操作符
有了数据类型,还需要预定义一些相关的操作符进行基本的操作。下表是Chisel内建的操作符:
这里要注意的一点是相等性比较的两个符号是“===”和“=/=”。因为“==”和“!=”已经被Scala占用,所以Chisel另设了这两个新的操作符。按照优先级的判断准则,“===”和“=/=”的优先级以首个字符为“=”来判断,也就是在逻辑操作中,相等性比较的优先级要比与、或、异或都高。
十、位宽推断
某些操作符会发生位宽的改变,这些返回的结果会生成一个自动推断的位宽。如下表所示:
当把一个短位宽的信号值或硬件结构赋值给长位宽的硬件结构时,会自动扩展符号位。但是反过来会报错,并不是像Verilog那样把多余的高位截断,这需要注意(注:最新的chisel3版本已经可以像Verilog一样自动把高位截断了)。
十一、总结
读者在学习本章后,应该理清Chisel数据类型的关系。常用的类型就五种:UInt、SInt、Bool、Bundle和Vec[T],所以重点学会这五种即可。有关三种值类UInt、SInt和Bool的操作符与Verilog差不多,很快就能理解。
第十八章 Chisel基础——模块与硬件类型
Chisel在构建硬件的思路上类似Verilog。在Verilog中,是以“模块(module)”为基本单位组成一个完整的独立功能实体,所以Chisel也是按模块划分的,只不过不是用关键字“module”开头来定义模块,而是用一个继承自Module类的自定义class。
在Verilog里,模块内部主要有“线网(wire)”和“四态变量(reg)”两种硬件类型,它们用于描述数字电路的组合逻辑和时序逻辑。在Chisel里,也按这个思路定义了一些硬件类型,包括基本的线网和寄存器,以及一些常用的其它类型。前一章介绍了Chisel的数据类型,这还不够,因为这些数据类型是无法独立工作的。实际的电路应该是由硬件类型的对象构成的,不管是信号的声明,还是用赋值进行信号传递,都是由硬件类型的对象来完成的。数据类型和硬件类型融合在一起,才能构成完整、可运行的组件。比如要声明一个线网,这部分工作由硬件类型来完成;这个线网的位宽是多少、按无符号数还是有符号数解释、是不是向量等等,这些则是由作为参数的数据类型对象来定义的。
本章将介绍Chisel里的常用硬件类型以及如何编写一个基本的模块,对于高级类型,读者可以自行研究。这些类型的语法很简单,都是由定义在单例对象里的apply工厂方法来完成。字面的名字已经把硬件含义表明得很清楚,至于它们的具体实现是什么,读者可以不用关心。
一、Chisel是如何赋值的
有了硬件类型后,就可以用赋值操作来进行信号的传递或者电路的连接。只有硬件赋值才有意义,单纯的数据对象进行赋值并不会被编译器转换成实际的电路,因为在Verilog里也是对wire、reg类型的硬件进行赋值。那么,赋值操作需要什么样的操作符来完成呢?
在Chisel里,所有对象都应该由val类型的变量来引用,因为硬件电路的不可变性。因此,一个变量一旦初始化时绑定了一个对象,就不能再发生更改。但是,引用的对象很可能需要被重新赋值。例如,输出端口在定义时使用了“=”与端口变量名进行了绑定,那等到驱动该端口时,就需要通过变量名来进行赋值操作,更新数据。很显然,此时“=”已经不可用了,因为变量在声明的时候不是var类型。即使是var类型,这也只是让变量引用新的对象,而不是直接更新原来的可变对象。
为了解决这个问题,几乎所有的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)”来为每个端口表明具体的方向。注意,“Input[T <: Data](source: T)”和“Output[T <: Data](source: T)”仅仅是复制它们的参数,所以不能是已经被硬件类型包裹的数据类型。目前Chisel还不支持双向端口inout,只能通过黑盒里的Analog端口来模拟外部Verilog的双向端口。
一旦端口列表定义完成,就可以通过“io.xxx”来使用。输入可以驱动内部其它信号,输出可以被其他信号驱动。可以直接进行赋值操作,布尔类型的端口还能直接作为使能信号。端口不需要再使用其它硬件类型来定义,不过要注意从性质上来说它仍然属于组合逻辑的线网。例如:
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是输出
Ⅲ、整体连接
翻转方向的端口列表通常配合整体连接符号“<>”使用。该操作符会把左右两边的端口列表里所有同名的端口进行连接,而且同一级的端口方向必须是输入连输出、输出连输入,父级和子级的端口方向则是输入连输入、输出连输出。注意,方向必须按这个规则匹配,而且不能存在端口名字、数量、类型不同的情况。这样就省去了大量连线的代码。例如:
class MyIO extends Bundle {
val in = Input(Vec(5, UInt(32.W)))
val out = Output(UInt(32.W))
}
......
val io = IO(new Bundle {
val x = new MyIO
val y = Flipped(new MyIO)
})
io.x <> io.y // 相当于 io.y.in := io.x.in; io.x.out := io.y.out
......
三、模块
Ⅰ、定义模块
在Chisel里面是用一个自定义的类来定义模块的,这个类有以下三个特点:①继承自Module类。②有一个抽象字段“io”需要实现,该字段必须引用前面所说的端口对象。③在类的主构造器里进行内部电路连线。因为非字段、非方法的内容都属于主构造方法,所以用操作符“:=”进行的赋值、用“<>”进行的连线或一些控制结构等等,都属于主构造方法。从Scala的层面来讲,这些代码在实例化时表示如何构造一个对象;从Chisel的层面来讲,它们就是在声明如何进行模块内部子电路的连接、信号的传递,类似于Verilog的assign和always语句。实际上这些用赋值表示的电路连接在转换成Verilog时,组合逻辑就是大量的assign语句,时序逻辑就是always语句。
还有一点需要注意,这样定义的模块会继承一个字段“clock”,类型是Clock,它表示全局时钟,在整个模块内都可见。对于组合逻辑,是用不上它的,而时序逻辑虽然需要这个时钟,但也不用显式声明。还有一个继承的字段“reset”,类型是Reset,表示全局复位信号,在整个模块内可见。对于需要复位的时序元件,也可以不用显式使用该字段。如果确实需要用到全局时钟和复位,则可以通过它们的字段名称来使用,但要注意类型是否匹配,经常需要“reset.toBool”这样的语句把Reset类型转换成Bool类型用于控制。隐式的全局时钟和复位端口只有在生成Verilog代码时才能看到。
要编写一个双输入多路选择器,其代码如下所示:
// 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方法。这种别扭的语法是Scala的语法限制造成的,就像端口需要写成“IO(new Bundle {...})”,无符号数要写成“UInt(n.W)”等等一样。例如,下面的代码通过例化刚才的双输入多路选择器构建四输入多路选择器:
// mux4.scala
package test
import chisel3._
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 m2 = Module(new Mux2)
m2.io.sel := io.sel(1)
m2.io.in0 := m0.io.out
m2.io.in1 := m1.io.out
io.out := m2.io.out
}
Ⅲ、例化多个模块
像上个例子中,模块Mux2例化了三次,实际只需要一次性例化三个模块就可以了。对于要多次例化的重复模块,可以利用向量的工厂方法VecInit[T <: Data]。因为该方法接收的参数类型是Data的子类,而模块的字段io正好是Bundle类型,并且实际的电路连线仅仅只需针对模块的端口,所以可以把待例化模块的io字段组成一个序列,或者按重复参数的方式作为参数传递。通常使用序列作为参数,这样更节省代码。生成序列的一种方法是调用单例对象Seq里的方法fill,该方法的一个重载版本有两个单参数列表,第一个接收Int类型的对象,表示序列的元素个数,第二个是传名参数,接收序列的元素。
因为Vec是一种可索引的序列,所以这种方式例化的多个模块类似于“模块数组”,用下标索引第n个模块。另外,因为Vec的元素已经是模块的端口字段io,所以要引用例化模块的某个具体端口时,路径里不用再出现“io”。例如:
// 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作为软件语言是顺序执行的,定义具有覆盖性,所以如果对同一个线网多次赋值,则只有最后一次有效。例如下面的代码与上面的例子是等效的:
val myNode = Wire(UInt(8.W))
myNode := 10.U
myNode := 0.U
五、寄存器
寄存器是时序逻辑的基本硬件类型,它们都是由当前时钟域的时钟上升沿触发的。如果模块里没有多时钟域的语句块,那么寄存器都是由隐式的全局时钟来控制。对于有复位信号的寄存器,如果不在多时钟域语句块里,则由隐式的全局复位来控制,并且高有效。目前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)
}
对应生成的主要Verilog代码为:
// REG.v
module REG(
input clock,
input reset,
input [7:0] io_a,
input io_en,
output io_c
);
reg [7:0] reg0;
reg [7:0] reg1;
reg [7:0] reg2;
reg [7:0] reg3;
reg [7:0] reg4;
wire [7:0] _T_1;
reg [7:0] reg5;
wire [8:0] _T_2;
wire [8:0] _T_3;
wire [7:0] _T_4;
reg [7:0] reg6;
reg [7:0] _T_5;
reg [7:0] _T_6;
reg [7:0] reg7;
reg [7:0] _T_7;
reg [7:0] _T_8;
reg [7:0] reg8;
wire [7:0] _T_9;
wire _T_10;
wire _T_11;
wire _GEN_8;
wire _T_13;
wire _T_14;
wire _T_15;
wire _T_16;
wire _T_17;
wire _T_18;
wire _T_19;
wire _T_20;
wire _T_21;
wire _T_22;
wire _T_23;
wire _T_24;
wire _T_25;
wire _T_26;
wire _T_27;
wire _T_28;
assign _T_1 = io_a + 8\'h1;
assign _T_2 = io_a - 8\'h1;
assign _T_3 = $unsigned(_T_2);
assign _T_4 = _T_3[7:0];
assign _T_9 = ~ io_a;
assign _T_10 = _T_9 == 8\'h0;
assign _T_11 = io_a != 8\'h0;
assign _GEN_8 = reset ? 1\'h0 : 1\'h1;
assign _T_13 = reg0[0];
assign _T_14 = reg1[0];
assign _T_15 = _T_13 & _T_14;
assign _T_16 = reg2[0];
assign _T_17 = _T_15 & _T_16;
assign _T_18 = reg3[0];
assign _T_19 = _T_17 & _T_18;
assign _T_20 = reg4[0];
assign _T_21 = _T_19 & _T_20;
assign _T_22 = reg5[0];
assign _T_23 = _T_21 & _T_22;
assign _T_24 = reg6[0];
assign _T_25 = _T_23 & _T_24;
assign _T_26 = reg7[0];
assign _T_27 = _T_25 & _T_26;
assign _T_28 = reg8[0];
assign io_c = _T_27 & _T_28;
always @(posedge clock) begin
reg0 <= io_a;
if (reset) begin
reg1 <= 8\'h0;
end else begin
reg1 <= io_a;
end
if (reset) begin
reg2 <= 8\'h0;
end else begin
reg2 <= {{7\'d0}, _T_10};
end
reg3 <= {{7\'d0}, _T_11};
reg4 <= {{7\'d0}, _GEN_8};
if (reset) begin
reg5 <= 8\'h0;
end else begin
if (io_en) begin
reg5 <= _T_1;
end
end
if (io_en) begin
reg6 <= _T_4;
end
if (reset) begin
_T_5 <= 8\'h0;
end else begin
if (io_en) begin
_T_5 <= io_a;
end
end
if (reset) begin
_T_6 <= 8\'h0;
end else begin
if (io_en) begin
_T_6 <= _T_5;
end
end
if (reset) begin
reg7 <= 8\'h0;
end else begin
if (io_en) begin
reg7 <= _T_6;
end
end
if (io_en) begin
_T_7 <= io_a;
end
if (io_en) begin
_T_8 <= _T_7;
end
if (io_en) begin
reg8 <= _T_8;
end
end
endmodule
六、寄存器组
上述构造寄存器的工厂方法,它们的参数可以是任何Data的子类型。如果把子类型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)
}
对应的主要Verilog代码为:
// REG2.v
module REG2(
input clock,
input reset,
input [7:0] io_a,
input io_en,
output io_c
);
reg [7:0] reg0_0;
reg [7:0] reg0_1;
reg [7:0] reg1_0;
reg [7:0] reg1_1;
reg [7:0] reg2_0;
reg [7:0] reg2_1;
reg [7:0] reg3_0;
reg [7:0] reg3_1;
reg [7:0] reg4_0;
reg [7:0] reg4_1;
wire [7:0] _T_5;
reg [7:0] reg5_0;
reg [7:0] reg5_1;
wire [8:0] _T_10;
wire [8:0] _T_11;
wire [7:0] _T_12;
reg [7:0] reg6_0;
reg [7:0] reg6_1;
reg [7:0] _T_19_0;
reg [7:0] _T_19_1;
reg [7:0] _T_20_0;
reg [7:0] _T_20_1;
reg [7:0] reg7_0;
reg [7:0] reg7_1;
reg [7:0] _T_22_0;
reg [7:0] _T_22_1;
reg [7:0] _T_23_0;
reg [7:0] _T_23_1;
reg [7:0] reg8_0;
reg [7:0] reg8_1;
wire [7:0] _T_24;
wire _T_25;
wire _T_28;
wire _GEN_16;
wire _T_31;
wire _T_32;
wire _T_33;
wire _T_34;
wire _T_35;
wire _T_36;
wire _T_37;
wire _T_38;
wire _T_39;
wire _T_40;
wire _T_41;
wire _T_42;
wire _T_43;
wire _T_44;
wire _T_45;
wire _T_46;
wire _T_47;
wire _T_48;
wire _T_49;
wire _T_50;
wire _T_51;
wire _T_52;
wire _T_53;
wire _T_54;
wire _T_55;
wire _T_56;
wire _T_57;
wire _T_58;
wire _T_59;
wire _T_60;
wire _T_61;
wire _T_62;
wire _T_63;
wire _T_64;
assign _T_5 = io_a + 8\'h1;
assign _T_10 = io_a - 8\'h1;
assign _T_11 = $unsigned(_T_10);
assign _T_12 = _T_11[7:0];
assign _T_24 = ~ io_a;
assign _T_25 = _T_24 == 8\'h0;
assign _T_28 = io_a != 8\'h0;
assign _GEN_16 = reset ? 1\'h0 : 1\'h1;
assign _T_31 = reg0_0[0];
assign _T_32 = reg1_0[0];
assign _T_33 = _T_31 & _T_32;
assign _T_34 = reg2_0[0];
assign _T_35 = _T_33 & _T_34;
assign _T_36 = reg3_0[0];
assign _T_37 = _T_35 & _T_36;
assign _T_38 = reg4_0[0];
assign _T_39 = _T_37 & _T_38;
assign _T_40 = reg5_0[0];
assign _T_41 = _T_39 & _T_40;
assign _T_42 = reg6_0[0];
assign _T_43 = _T_41 & _T_42;
assign _T_44 = reg7_0[0];
assign _T_45 = _T_43 & _T_44;
assign _T_46 = reg8_0[0];
assign _T_47 = _T_45 & _T_46;
assign _T_48 = reg0_1[0];
assign _T_49 = _T_47 & _T_48;
assign _T_50 = reg1_1[0];
assign _T_51 = _T_49 & _T_50;
assign _T_52 = reg2_1[0];
assign _T_53 = _T_51 & _T_52;
assign _T_54 = reg3_1[0];
assign _T_55 = _T_53 & _T_54;
assign _T_56 = reg4_1[0];
assign _T_57 = _T_55 & _T_56;
assign _T_58 = reg5_1[0];
assign _T_59 = _T_57 & _T_58;
assign _T_60 = reg6_1[0];
assign _T_61 = _T_59 & _T_60;
assign _T_62 = reg7_1[0];
assign _T_63 = _T_61 & _T_62;
assign _T_64 = reg8_1[0];
assign io_c = _T_63 & _T_64;
always @(posedge clock) begin
reg0_0 <= io_a;
reg0_1 <= io_a;
if (reset) begin
reg1_0 <= 8\'h0;
end else begin
reg1_0 <= io_a;
end
if (reset) begin
reg1_1 <= 8\'h0;
end else begin
reg1_1 <= io_a;
end
if (reset) begin
reg2_0 <= 8\'h0;
end else begin
reg2_0 <= {{7\'d0}, _T_25};
end
if (reset) begin
reg2_1 <= 8\'h0;
end else begin
reg2_1 <= {{7\'d0}, _T_25};
end
reg3_0 <= {{7\'d0}, _T_28};
reg3_1 <= {{7\'d0}, _T_28};
reg4_0 <= {{7\'d0}, _GEN_16};
reg4_1 <= {{7\'d0}, _GEN_16};
if (reset) begin
reg5_0 <= 8\'h0;
end else begin
if (io_en) begin
reg5_0 <= _T_5;
end
end
if (reset) begin
reg5_1 <= 8\'h0;
end else begin
if (io_en) begin
reg5_1 <= _T_5;
end
end
if (io_en) begin
reg6_0 <= _T_12;
end
if (io_en) begin
reg6_1 <= _T_12;
end
if (reset) begin
_T_19_0 <= 8\'h0;
end else begin
if (io_en) begin
_T_19_0 <= io_a;
end
end
if (reset) begin
_T_19_1 <= 8\'h0;
end else begin
if (io_en) begin
_T_19_1 <= io_a;
end
end
if (reset) begin
_T_20_0 <= 8\'h0;
end else begin
if (io_en) begin
_T_20_0 <= _T_19_0;
end
end
if (reset) begin
_T_20_1 <= 8\'h0;
end else begin
if (io_en) begin
_T_20_1 <= _T_19_1;
end
end
if (reset) begin
reg7_0 <= 8\'h0;
end else begin
if (io_en) begin
reg7_0 <= _T_20_0;
end
end
if (reset) begin
reg7_1 <= 8\'h0;
end else begin
if (io_en) begin
reg7_1 <= _T_20_1;
end
end
if (io_en) begin
_T_22_0 <= io_a;
end
if (io_en) begin
_T_22_1 <= io_a;
end
if (io_en) begin
_T_23_0 <= _T_22_0;
end
if (io_en) begin
_T_23_1 <= _T_22_1;
end
if (io_en) begin
reg8_0 <= _T_23_0;
end
if (io_en) begin
reg8_1 <= _T_23_1;
end
end
endmodule
七、用when给电路赋值
在Verilog里,可以使用“if...else if...else”这样的条件选择语句来方便地构建电路的逻辑。由于Scala已经占用了“if…else if…else”语法,所以相应的Chisel控制结构改成了when语句,其语法如下:
when (condition 1) { definition 1 }
.elsewhen (condition 2) { definition 2 }
...
.elsewhen (condition N) { definition N }
.otherwise { default behavior }
注意,“.elsewhen”和“.otherwise”的开头有两个句点。所有的判断条件都是返回Bool类型的传名参数,不要和Scala的Boolean类型混淆,也不存在Boolean和Bool之间的相互转换。对于UInt、SInt和Reset类型,可以用方法toBool转换成Bool类型来作为判断条件。
when语句不仅可以给线网赋值,还可以给寄存器赋值,但是要注意构建组合逻辑时不能缺失“.otherwise”分支。通常,when用于给带使能信号的寄存器更新数据,组合逻辑不常用。对于有复位信号的寄存器,推荐使用RegInit来声明,这样生成的Verilog会自动根据当前的时钟域来同步复位,尽量不要在when语句里用“reset.toBool”作为复位条件。
除了when结构,util包里还有一个与之对偶的结构“unless”,如果unless的判定条件为false.B则一直执行,否则不执行:
import chisel3.util._
unless (condition) { definition }
八、总结:数据类型与硬件类型的区别
前一章介绍了Chisel的数据类型,其中常用的就五种:UInt、SInt、Bool、Bundle和Vec[T]。本章介绍了硬件类型,最基本的是IO、Wire和Reg三种,还有指明端口方向的Input、Output和Flipped。Module是沿袭了Verilog用模块构建电路的规则,不仅让熟悉Verilog/VHDL的工程师方便理解,也便于从Chisel转化成Verilog代码。
数据类型必须配合硬件类型才能使用,它不能独立存在,因为编译器只会把硬件类型生成对应的Verilog代码。从语法规则上来讲,这两种类型也有很大的区别,编译器会对数据类型和硬件类型加以区分。尽管从Scala的角度来看,硬件类型对应的工厂方法仅仅是“封装”了一遍作为入参的数据类型,其返回结果没变,比如Wire的工厂方法定义为:
def apply[T <: Data](t: T)(implicit sourceInfo: SourceInfo, compileOptions: CompileOptions): T
可以看到,入参t的类型与返回结果的类型是一样的,但是还有配置编译器的隐式参数,很可能区别就源自这里。
但是从Chisel编译器的角度来看,这两者就是不一样。换句话说,硬件类型就好像在数据类型上“包裹了一层外衣(英文原文用单词binding来形容)”。比如,线网“Wire(UInt(8.W))”就像给数据类型“UInt(8.W)”包上了一个“Wire( )”。所以,在编写Chisel时,要注意哪些地方是数据类型,哪些地方又是硬件类型。这时,静态语言的优势便体现出来了,因为编译器会帮助程序员检查类型是否匹配。如果在需要数据类型的地方出现了硬件类型、在需要硬件类型的地方出现了数据类型,那么就会引发错误。程序员只需要按照错误信息去修改相应的代码,而不需要人工逐个检查。
例如,在前面介绍寄存器组的时候,示例代码里的一句是这样的:
val reg0 = RegNext(VecInit(io.a, io.a))
读者可能会好奇为什么不写成如下形式:
val reg0 = RegNext(Vec(2, io.a))
如果改成这样,那么编译器就会发出如下错误:
[error] chisel3.core.Binding$ExpectedChiselTypeException: vec type \'chisel3.core.UInt@6147b2fd\' must be a Chisel type, not hardware
这是因为方法Vec期望第二个参数是数据类型,这样它才能推断出返回的Vec[T]是数据类型。但实际的“io.a”是经过Input封装过的硬件类型,导致Vec[T]变成了硬件类型,所以发生了类型匹配错误。错误信息里也明确指示了,“Chisel type”指的就是数据类型,“hardware”指的就是硬件类型,而vec的类型应该是“Chisel type”,不应该变成硬件。
Chisel提供了一个用户API——chiselTypeOf[T <: Data](target: T): T,其作用就是把硬件类型的“封皮”去掉,变成纯粹的数据类型。因此,读者可能会期望如下代码成功:
val reg0 = RegNext(Vec(2, chiselTypeOf(io.a)))
但是编译器仍然发出了错误信息:
[error] chisel3.core.Binding$ExpectedHardwareException: reg next \'Vec(chisel3.core.UInt@65b0972a, chisel3.core.UInt@25155aa4)\' must be hardware, not a bare Chisel type. Perhaps you forgot to wrap it in Wire(_) or IO(_)?
只不过,这次是RegNext出错了。chiselTypeOf确实把硬件类型变成了数据类型,所以Vec[T]的检查通过了。但RegNext是实打实的硬件——寄存器,它也需要根据入参来推断返回结果的类型,所以传入一个数据类型Vec[T]就引发了错误。错误信息还额外提示程序员,是否忘记了用Wire(_)或IO(_)来包裹裸露的数据类型。甚至是带有字面量的数据类型,比如“0.U(8.W)”这样的对象,也被当作是硬件类型。
综合考虑这两种错误,只有写成“val reg0 = RegNext(VecInit(io.a, io.a))”合适,因为VecInit专门接收硬件类型的参数来构造硬件向量,给VecInit传入数据类型反而会报错,尽管它的返回类型也是Vec[T]。另外,Reg(_)的参数是数据类型,不是硬件类型,所以示例代码中它的参数是Vec,而别的参数都是VecInit。
有了基本的数据类型和硬件类型后,就已经可以编写绝大多数组合逻辑与时序逻辑电路。下一章将介绍Chisel库里定义的常用原语,有了这些原语就能更快速地构建电路,而不需要只用这些基本类型来搭积木。
第十九章 Chisel基础——常用的硬件原语
前两章介绍了基本的数据类型和硬件类型,已经足够编写基本的小规模电路。至于要如何生成Verilog,会在后续章节讲解。如果要编写大型电路,当然也可以一砖一瓦地搭建,但是费时费力,完全体现不出软件语言的优势。Chisel在语言库里定义了很多常用的硬件原语,读者可以直接导入相应的包来使用。让编译器多干活,让程序员少费力。
一、多路选择器
因为多路选择器是一个很常用的电路模块,所以Chisel内建了几种多路选择器。第一种形式是二输入多路选择器“Mux(sel, in1, in2)”。sel是Bool类型,in1和in2的类型相同,都是Data的任意子类型。当sel为true.B时,返回in1,否则返回in2。
因为Mux仅仅是把一个输入返回,所以Mux可以内嵌Mux,构成n输入多路选择器,类似于嵌套的三元操作符。其形式为“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
))
内建的多路选择器会转换成Verilog的三元操作符“? :”,这对于构建组合逻辑而言是完全足够的,而且更推荐这种做法,所以when语句常用于给寄存器赋值,而很少用来给线网赋值。读者可能习惯用always语句块来编写电路,但这存在一些问题:首先,always既可以综合出时序逻辑又能综合出组合逻辑,导致reg变量存在二义性,常常使得新手误解reg就是寄存器;其次,if...else if...else不能传播控制变量的未知态x(某些EDA工具可以),使得仿真阶段无法发现一些错误,但是assign语句会在控制变量为x时也输出x。工业级的Verilog,都是用assign语句来构建电路。时序逻辑也是通过例化触发器模块来完成的,相应的端口都是由assign来驱动,而且触发器会使用 SystemVerilog的断言来寻找always语句里的x和z。整个设计应该尽量避免使用always语句。
二、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)
}
对应的Verilog为:
// ROM.v
module ROM(
input clock,
input reset,
input [1:0] io_sel,
output [7:0] io_out
);
wire [2:0] _GEN_1;
wire [2:0] _GEN_2;
wire [2:0] _GEN_3;
assign _GEN_1 = 2\'h1 == io_sel ? 3\'h2 : 3\'h1;
assign _GEN_2 = 2\'h2 == io_sel ? 3\'h3 : _GEN_1;
assign _GEN_3 = 2\'h3 == io_sel ? 3\'h4 : _GEN_2;
assign io_out = {{5\'d0}, _GEN_3};
endmodule
在这个例子里需要提的一点是,Vec[T]类的apply方法不仅可以接收Int类型的索引值,另一个重载版本还能接收UInt类型的索引值。所以对于承担地址、计数器等功能的部件,可以直接作为由Vec[T]构造的元素的索引参数,比如这个例子中根据sel端口的值来选择相应地址的ROM值。
三、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。在Verilog代码上,这两种RAM都是由reg类型的变量来表示的,区别在于第二种RAM的读地址会被地址寄存器寄存一次。例如:
val syncMem = SyncReadMem(16, UInt(32.W))
写RAM的语法是:
when(wr_en) {
mem.write(address, dataIn)
out := DontCare
}
其中DontCare告诉Chisel的未连接线网检测机制,写入RAM时读端口的行为无需关心。
读RAM的语法是:
out := mem.read(address, rd_en)
读、写使能信号都可以省略。
要综合出实际的SRAM,读者最好了解自己的综合器是如何推断的,按照综合器的推断规则来编写模块的端口定义、时钟域划分、读写使能的行为等等,否则就可能综合出寄存器阵列而不是SRAM。以Vivado 2018.3为例,下面的单端口SRAM代码经过综合后会映射到FPGA上实际的BRAM资源,而不是寄存器:
// ram.scala
package test
import chisel3._
class SinglePortRAM 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(Bool())
val dataOut = Output(UInt(32.W))
})
val syncRAM = SyncReadMem(1024, UInt(32.W))
when(io.en) {
when(io.we) {
syncRAM.write(io.addr, io.dataIn)
io.dataOut := DontCare
} .otherwise {
io.dataOut := syncRAM.read(io.addr)
}
} .otherwise {
io.dataOut := DontCare
}
}
下面是Vivado综合后的部分截图,可以看到确实变成了实际的BRAM:
Vivado的BRAM最多支持真·双端口,按照对应的Verilog模板逆向编写Chisel,然后用编译器把Chisel转换成Verilog。但此时编译器生成的Verilog代码并不能被Vivado的综合器识别出来。原因在于SyncReadMem生成的Verilog代码是用一级寄存器保存输入的读地址,然后用读地址寄存器去异步读取RAM的数据,而Vivado的综合器识别不出这种模式的RAM。读者必须手动修改成用一级寄存器保存异步读取的数据而不是读地址,然后把读数据寄存器的内容用assign语句赋值给读数据端口,这样才能被识别成真·双端口BRAM。尚不清楚其它综合器是否有这个问题。经过咨询SiFive的工作人员,对方答复因为当前转换的代码把延迟放在地址一侧,所以流水线的节拍设计也是根据这个来的。考虑到贸然修改SyncReadMem的行为,可能会潜在地影响其它用户对流水线的设计,故而没有修改计划。如果确实需要自定义的、对综合器友好的Verilog代码,可以使用黑盒功能替代,或者给Firrtl编译器传入参数,改用自定义脚本来编译Chisel。
四、带写掩模的RAM
RAM通常都具备按字节写入的功能,比如数据写入端口的位宽是32bit,那么就应该有4bit的写掩模信号,只有当写掩模比特有效时,对应的字节才会写入。Chisel也具备构建带写掩模的RAM的功能。
当构建RAM的数据类型为Vec[T]时,就会推断出该RAM具有写掩模。此时,需要定义一个Seq[Bool]类型的写掩模信号,序列的元素个数为数据写入端口的位宽除以字节宽度。而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))
}
}
读、写端口和写掩模可以不用定义成一个UInt,也可以是Vec[UInt],这样定义只是为了让模块对外只有一个读端口、一个写端口和一个写掩模端口。注意,编译器会把Vec[T]的元素逐个展开,而不是合并成压缩数组的形式。也正是如此,上述代码对应的Verilog中,把RAM主体定义成了“reg [7:0] syncRAM_0 [0:1023]”、“reg [7:0] syncRAM_1 [0:1023]”、“reg [7:0] syncRAM_2 [0:1023]”和“reg [7:0] syncRAM_3 [0:1023]”,而不是一个“reg [31:0] syncRAM [0:1023]”。这样,Vivado综合出来的电路是四小块BRAM,而不是一大块BRAM。
五、从文件读取数据到RAM
在experimental包里有一个单例对象loadMemoryFromFile,它的apply方法可以在Chisel层面上从txt文件读取数据到RAM里。其定义如下所示:
def apply[T <: Data](memory: MemBase[T], fileName: String, hexOrBinary: FileType = MemoryLoadFileType.Hex): Unit
第一个参数是MemBase[T]类型的,也就是Mem[T]和SyncReadMem[T]的超类,该参数接收一个自定义的RAM对象。第二个参数是文件的名字及路径,用字符串表示。第三个参数表示读取的方式为十六进制或二进制,默认是MemoryLoadFileType.Hex,也可以改成MemoryLoadFileType.Binary。注意,没有十进制和八进制。
该方法其实就是调用Verilog的系统函数“$readmemh”和“$readmemb”,所以要注意文件路径的书写和数据的格式都要按照Verilog的要求书写。最好把数据文件放在resources文件夹里。例如:
// loadmem.scala
package test
import chisel3._
import chisel3.util.experimental.loadMemoryFromFile
class LoadMem extends Module {
val io = IO(new Bundle {
val address = Input(UInt(3.W))
val value = Output(UInt(8.W))
})
val memory = Mem(8, UInt(8.W))
io.value := memory.read(io.address)
loadMemoryFromFile(memory, "~/chisel-workspace/chisel-template/mem.txt")
}
那么就会得到两个Verilog文件:
// LoadMem.v
module LoadMem(
input clock,
input reset,
input [2:0] io_address,
output [7:0] io_value
);
reg [7:0] memory [0:7];
wire [7:0] memory__T_data;
wire [2:0] memory__T_addr;
assign memory__T_addr = io_address;
assign memory__T_data = memory[memory__T_addr];
assign io_value = memory__T_data;
endmodule
// LoadMem.LoadMem.memory.v
module BindsTo_0_LoadMem(
input clock,
input reset,
input [2:0] io_address,
output [7:0] io_value
);
initial begin
$readmemh("~/chisel-workspace/chisel-template/mem.txt", LoadMem.memory);
end
endmodule
在用Verilator仿真时,它会识别这个Chisel代码,从文件读取数据。
六、计数器
计数器也是一个常用的硬件电路。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
}
它生成的主要Verilog代码为:
// MyCounter.v
module MyCounter(
input clock,
input reset,
input io_en,
output [7:0] io_out,
output io_valid
);
reg [7:0] value;
wire _T;
wire [7:0] _T_2;
assign _T = value == 8\'he8;
assign _T_2 = value + 8\'h1;
assign io_out = value;
assign io_valid = io_en & _T;
always @(posedge clock) begin
if (reset) begin
value <= 8\'h0;
end else begin
if (io_en) begin
if (_T) begin
value <= 8\'h0;
end else begin
value <= _T_2;
end
end
end
end
endmodule
七、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)
}
它生成的主要Verilog代码为:
// LFSR.v
module LFSR(
input clock,
input reset,
input io_en,
output [15:0] io_out
);
reg [15:0] _T;
wire _T_1;
wire _T_2;
wire _T_3;
wire _T_4;
wire _T_5;
wire _T_6;
wire _T_7;
wire [14:0] _T_8;
wire [15:0] _T_9;
assign _T_1 = _T[0];
assign _T_2 = _T[2];
assign _T_3 = _T_1 ^ _T_2;
assign _T_4 = _T[3];
assign _T_5 = _T_3 ^ _T_4;
assign _T_6 = _T[5];
assign _T_7 = _T_5 ^ _T_6;
assign _T_8 = _T[15:1];
assign _T_9 = {_T_7,_T_8};
assign io_out = _T;
always @(posedge clock) begin
if (reset) begin
_T <= 16\'h1;
end else begin
if (io_en) begin
_T <= _T_9;
end
end
end
endmodule
八、状态机
状态机也是常用电路,但是Chisel没有直接构建状态机的原语。不过,util包里定义了一个Enum特质及其伴生对象。伴生对象里的apply方法定义如下:
def apply(n: Int): List[UInt]
它会根据参数n返回对应元素数的List[UInt],每个元素都是不同的,所以可以作为枚举值来使用。最好把枚举状态的变量名也组成一个列表,然后用列表的模式匹配来进行赋值。有了枚举值后,可以通过“switch…is…is”语句来使用。其中,switch里是相应的状态寄存器,而每个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的编译器才能识别成变量模式匹配。它生成的Verilog为:
// DetectTwoOnes.v
module DetectTwoOnes(
input clock,
input reset,
input io_in,
output io_out
);
reg [1:0] state;
wire _T_1;
wire _T_2;
wire _T_3;
wire _T_4;
assign _T_1 = 2\'h0 == state;
assign _T_2 = 2\'h1 == state;
assign _T_3 = 2\'h2 == state;
assign _T_4 = io_in == 1\'h0;
assign io_out = state == 2\'h2;
always @(posedge clock) begin
if (reset) begin
state <= 2\'h0;
end else begin
if (_T_1) begin
if (io_in) begin
state <= 2\'h1;
end
end else begin
if (_T_2) begin
if (io_in) begin
state <= 2\'h2;
end else begin
state <= 2\'h0;
end
end else begin
if (_T_3) begin
if (_T_4) begin
state <= 2\'h0;
end
end
end
end
end
end
endmodule
九、总结
本章介绍了Chisel内建的常用原语,还有更多原语可以使用,比如Bundle衍生的几种端口类,读者可以通过查询API或源码来进一步了解。
第二十章 Chisel基础——生成Verilog与基本测试
经过前三章的内容,读者已经了解了如何使用Chisel构建一个基本的模块。本章的内容就是在此基础上,把一个Chisel模块编译成Verilog代码,并进一步使用Verilator做一些简单的测试。
一、生成Verilog
前面介绍Scala的内容里说过,Scala程序的入口是主函数。所以,生成Verilog的程序自然是在主函数里例化待编译的模块,然后运行这个主函数。例化待编译模块需要特殊的方法调用。chisel3包里有一个单例对象Driver,它包含一个方法execute,该方法接收两个参数,第一个参数是命令行传入的实参即字符串数组args,第二个是返回待编译模块的对象的无参函数。运行这个execute方法,就能得到Verilog代码。
假设在src/main/scala文件夹下有一个全加器的Chisel设计代码,如下所示:
// fulladder.scala
package test
import chisel3._
class FullAdder extends Module {
val io = IO(new Bundle {
val a = Input(UInt(1.W))
val b = Input(UInt(1.W))
val cin = Input(UInt(1.W))
val s = Output(UInt(1.W))
val cout = Output(UInt(1.W))
})
io.s := io.a ^ io.b ^ io.cin
io.cout := (io.a & io.b) | ((io.a | io.b) & io.cin)
}
接着,读者需要在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文件所在的路径下打开终端,然后执行命令:
esperanto@ubuntu:~/chisel-template$ sbt \'test:runMain test.FullAdderGen\'
注意,sbt后面有空格,再后面的内容都是被单引号对或双引号对包起来。其中,test:runMain是让sbt执行主函数的命令,而test.FullAdderGen就是要执行的那个主函数。
如果设计文件没有错误,那么最后就会看到“[success] Total time: 6 s, completed Feb 22, 2019 4:45:31 PM”这样的信息。此时,终端的路径下就会生成三个文件:FullAdder.anno.json、FullAdder.fir和FullAdder.v。
第一个文件用于记录传递给Firrtl编译器的Scala注解,读者可以不用关心。第二个后缀为“.fir”的文件就是对应的Firrtl代码,第三个自然是对应的Verilog文件。
首先查看最关心的Verilog文件,内容如下:
// FullAdder.v
module FullAdder(
input clock,
input reset,
input io_a,
input io_b,
input io_cin,
output io_s,
output io_cout
);
wire _T; // @[fulladder.scala 14:16]
wire _T_2; // @[fulladder.scala 15:20]
wire _T_3; // @[fulladder.scala 15:37]
wire _T_4; // @[fulladder.scala 15:45]
assign _T = io_a ^ io_b; // @[fulladder.scala 14:16]
assign _T_2 = io_a & io_b; // @[fulladder.scala 15:20]
assign _T_3 = io_a | io_b; // @[fulladder.scala 15:37]
assign _T_4 = _T_3 & io_cin; // @[fulladder.scala 15:45]
assign io_s = _T ^ io_cin; // @[fulladder.scala 14:8]
assign io_cout = _T_2 | _T_4; // @[fulladder.scala 15:11]
endmodule
可以看到,代码逻辑与想要表达的意思完全一致,而且对应的代码都用注释标明了来自于Chisel源文件的哪里。但由于这是通过语法分析的脚本代码得到的,所以看上去显得很笨拙、僵硬,生成了大量无用的中间变量声明。对于下游的综合器而言是一个负担,可能会影响综合器的优化。而且在进行仿真时,要理解这些中间变量也很麻烦。对后端人员来说,这也是让人头疼的问题。
接着再看一看Firrtl代码,内容如下:
// FullAdder.fir
;buildInfoPackage: chisel3, version: 3.2-SNAPSHOT, scalaVersion: 2.12.6, sbtVersion: 1.1.1
circuit FullAdder :
module FullAdder :
input clock : Clock
input reset : UInt<1>
output io : {flip a : UInt<1>, flip b : UInt<1>, flip cin : UInt<1>, s : UInt<1>, cout : UInt<1>}
node _T = xor(io.a, io.b) @[fulladder.scala 14:16]
node _T_1 = xor(_T, io.cin) @[fulladder.scala 14:23]
io.s <= _T_1 @[fulladder.scala 14:8]
node _T_2 = and(io.a, io.b) @[fulladder.scala 15:20]
node _T_3 = or(io.a, io.b) @[fulladder.scala 15:37]
node _T_4 = and(_T_3, io.cin) @[fulladder.scala 15:45]
node _T_5 = or(_T_2, _T_4) @[fulladder.scala 15:28]
io.cout <= _T_5 @[fulladder.scala 15:11]
可以看到,Firrtl代码与它生成的Verilog代码非常接近。这种代码风格虽然不方便人工阅读,但是适合语法分析脚本使用。
二、在命令里增加参数
Ⅰ、给Firrtl传递参数
在运行主函数时,可以在刚才的命令后面继续增加可选的参数。例如,增加参数“--help”查看帮助菜单,运行命令:
esperanto@ubuntu:~/chisel-template$ sbt \'test:runMain test.FullAdderGen --help\'
可以得到如下帮助信息:
common options
-tn, --top-name <top-level-circuit-name>
This options defines the top level circuit, defaults to dut when possible
-td, --target-dir <target-directory>
This options defines a work directory for intermediate files, default is .
-ll, --log-level <Error|Warn|Info|Debug|Trace>
This options defines a work directory for intermediate files, default is .
-cll, --class-log-level <FullClassName:[Error|Warn|Info|Debug|Trace]>[,...]
This options defines a work directory for intermediate files, default is .
-ltf, --log-to-file default logs to stdout, this flags writes to topName.log or firrtl.log if no topName
-lcn, --log-class-names shows class names and log level in logging output, useful for target --class-log-level
--help prints this usage text
<arg>... optional unbounded args
chisel3 options
-chnrf, --no-run-firrtl Stop after chisel emits chirrtl file
firrtl options
-i, --input-file <firrtl-source>
use this to override the default input file name , default is empty
-o, --output-file <output>
use this to override the default output file name, default is empty
-faf, --annotation-file <input-anno-file>
Used to specify annotation files (can appear multiple times)
-foaf, --output-annotation-file <output-anno-file>
use this to set the annotation output file
-X, --compiler <high|middle|low|verilog|sverilog>
compiler to use, default is verilog
--info-mode <ignore|use|gen|append>
specifies the source info handling, default is append
-fct, --custom-transforms <package>.<class>
runs these custom transforms during compilation.
-fil, --inline <circuit>[.<module>[.<instance>]][,..],
Inline one or more module (comma separated, no spaces) module looks like "MyModule" or "MyModule.myinstance
-firw, --infer-rw Enable readwrite port inference for the target circuit
-frsq, --repl-seq-mem -c:<circuit>:-i:<filename>:-o:<filename>
Replace sequential memories with blackboxes + configuration file
-clks, --list-clocks -c:<circuit>:-m:<module>:-o:<filename>
List which signal drives each clock of every descendent of specified module
-fsm, --split-modules Emit each module to its own file in the target directory.
--no-check-comb-loops Do NOT check for combinational loops (not recommended)
--no-dce Do NOT run dead code elimination
例如,最常用的是参数“-td”,可以在后面指定一个文件夹,这样之前生成的三个文件就在该文件夹里,而不是在当前路径下。其格式如下:
esperanto@ubuntu:~/chisel-template$ sbt \'test:runMain test.FullAdderGen -td ./generated/fulladder\'
Ⅱ、给主函数传递参数
Scala的类可以接收参数,自然Chisel的模块也可以接收参数。假设要构建一个n位的加法器,具体位宽不确定,根据需要而定。那么,就可以把端口位宽参数化,例化时传入想要的参数即可。例如:
// adder.scala
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))
}
在这里,模块Adder的主构造方法接收一个Int类型的参数n,然后用n去定义端口位宽。主函数在例化这个模块时,就要给出相应的参数。前面的帮助菜单里显示,在运行sbt命令时,可以传入若干个独立的参数。和运行Scala的主函数一样,这些命令行的参数也可以由字符串数组args通过下标来索引。从要运行的主函数后面开始,后面的内容都是按空格划分、从下标0开始的args的元素。比如例子中的主函数期望第一个参数即args(0)是一个数字字符串,这样就能通过方法toInt转换成Adder所需的参数。
执行如下命令:
esperanto@ubuntu:~/chisel-template$ sbt \'test:runMain test.AdderGen 8 -td ./generated/adder\'
可以在相应的文件夹下得到如下Verilog代码,其中位宽的确是8位的:
// Adder.v
module Adder(
input clock,
input reset,
input [7:0] io_a,
input [7:0] io_b,
output [7:0] io_s,
output io_cout
);
wire [8:0] _T;
assign _T = io_a + io_b;
assign io_s = _T[7:0];
assign io_cout = _T[8];
endmodule
三、编写简单的测试
Chisel的测试有两种,第一种是利用Scala的测试来验证Chisel级别的代码逻辑有没有错误。因为这部分内容比较复杂,而且笔者目前也没有深入学习有关Scala测试的内容,所以这部分内容可有读者自行选择研究。第二种是利用Chisel库里的peek和poke函数,给模块的端口加激励、查看信号值,并交由下游的Verilator来仿真、产生波形。这种方式比较简单,类似于Verilog的testbench,适合小型电路的验证。对于超大型的系统级电路,最好还是生成Verilog,交由成熟的EDA工具,用UVM进行验证。
要编写一个简单的testbench,首先也是定义一个类,这个类的主构造方法接收一个参数,参数类型就是待测模块的类名。因为模块也是一个类,从Scala的角度来看,一个类就是定义了一种类型。其次,这个类继承自PeekPokeTester类,并且把接收的待测模块也传递给此超类。最后,测试类内部有四种方法可用:①“poke(端口,激励值)”方法给相应的端口添加想要的激励值,激励值是Int类型的;②“peek(端口)”方法返回相应的端口的当前值;③“expect(端口,期望值)”方法会对第一个参数(端口)使用peek方法,然后与Int类型的期望值进行对比,如果两者不相等则出错;④“step(n)”方法则让仿真前进n个时钟周期。
因为测试模块只用于仿真,无需转成Verilog,所以类似for、do…while、to、until、map等Scala高级语法都可以使用,帮助测试代码更加简洁有效。
如下所示是一个对前一例中的8位加法器的testbench:
// addertest.scala
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))
}
运行如下命令:
esperanto@ubuntu:~/chisel-template$ sbt \'test:runMain test.AdderTestGen -td ./generated/addertest --backend-name verilator\'
执行成功后,就能在相应文件夹里看到一个新生成的文件夹,里面是仿真生成的文件。其中,“Adder.vcd”文件就是波形文件,使用GTKWave软件打开就能查看,将相应的端口拖拽到右侧就能显示波形。
如果只想在终端查看仿真运行的信息,则执行命令:
esperanto@ubuntu:~/chisel-template$ sbt \'test:runMain test.AdderTestGen -td ./generated/addertest --is-verbose\'
那么终端就会显示如下信息:
[info] [0.002] SEED 1550906002475
[info] [0.005] POKE io_a <- 184
[info] [0.006] POKE io_b <- 142
[info] [0.006] STEP 0 -> 1
[info] [0.007] EXPECT AT 1 io_s got 70 expected 70 PASS
[info] [0.008] EXPECT AT 1 io_cout got 1 expected 1 PASS
[info] [0.008] POKE io_a <- 114
[info] [0.009] POKE io_b <- 231
[info] [0.009] STEP 1 -> 2
[info] [0.009] EXPECT AT 2 io_s got 89 expected 89 PASS
[info] [0.009] EXPECT AT 2 io_cout got 1 expected 1 PASS
[info] [0.010] POKE io_a <- 183
[info] [0.010] POKE io_b <- 168
[info] [0.010] STEP 2 -> 3
[info] [0.011] EXPECT AT 3 io_s got 95 expected 95 PASS
[info] [0.011] EXPECT AT 3 io_cout got 1 expected 1 PASS
[info] [0.012] POKE io_a <- 223
[info] [0.012] POKE io_b <- 106
[info] [0.012] STEP 3 -> 4
[info] [0.012] EXPECT AT 4 io_s got 73 expected 73 PASS
[info] [0.013] EXPECT AT 4 io_cout got 1 expected 1 PASS
[info] [0.013] POKE io_a <- 12
[info] [0.013] POKE io_b <- 182
[info] [0.013] STEP 4 -> 5
[info] [0.014] EXPECT AT 5 io_s got 194 expected 194 PASS
[info] [0.014] EXPECT AT 5 io_cout got 0 expected 0 PASS
[info] [0.014] POKE io_a <- 52
[info] [0.014] POKE io_b <- 41
[info] [0.015] STEP 5 -> 6
[info] [0.015] EXPECT AT 6 io_s got 93 expected 93 PASS
[info] [0.016] EXPECT AT 6 io_cout got 0 expected 0 PASS
[info] [0.016] POKE io_a <- 187
[info] [0.017] POKE io_b <- 60
[info] [0.017] STEP 6 -> 7
[info] [0.017] EXPECT AT 7 io_s got 247 expected 247 PASS
[info] [0.018] EXPECT AT 7 io_cout got 0 expected 0 PASS
[info] [0.018] POKE io_a <- 218
[info] [0.019] POKE io_b <- 203
[info] [0.019] STEP 7 -> 8
[info] [0.019] EXPECT AT 8 io_s got 165 expected 165 PASS
[info] [0.020] EXPECT AT 8 io_cout got 1 expected 1 PASS
[info] [0.020] POKE io_a <- 123
[info] [0.021] POKE io_b <- 115
[info] [0.021] STEP 8 -> 9
[info] [0.021] EXPECT AT 9 io_s got 238 expected 238 PASS
[info] [0.022] EXPECT AT 9 io_cout got 0 expected 0 PASS
[info] [0.022] POKE io_a <- 17
[info] [0.022] POKE io_b <- 197
[info] [0.023] STEP 9 -> 10
[info] [0.023] EXPECT AT 10 io_s got 214 expected 214 PASS
[info] [0.024] EXPECT AT 10 io_cout got 0 expected 0 PASS
test Adder Success: 20 tests passed in 15 cycles in 0.047415 seconds 316.36 Hz
[info] [0.025] RAN 10 CYCLES PASSED
[success] Total time: 7 s, completed Feb 23, 2019 3:13:26 PM
五、总结
本章介绍了从Chisel转换成Verilog、测试设计的基本方法。因为Chisel还在更新中,这些方法也是从Chisel2里保留下来的。将来也许会有更便捷的方式,读者可以留意。
第二十一章 Chisel基础——黑盒
因为Chisel的功能相对Verilog来说还不完善,所以设计人员在当前版本下无法实现的功能,就需要用Verilog来实现。在这种情况下,可以使用Chisel的BlackBox功能,它的作用就是向Chisel代码提供了用Verilog设计的电路的接口,使得Chisel层面的代码可以通过模块的端口来进行交互。
一、例化黑盒
如果读者尝试在Chisel的模块里例化另一个模块,然后生成Verilog代码,就会发现端口名字里多了“io_”这样的字眼。很显然,这是因为Chisel要求模块的端口都是由字段“io”来引用的,语法分析脚本在生成Verilog代码时会保留这个端口名前缀。
假设有一个外部的Verilog模块,它的端口列表声明如下:
module Dut ( input [31: 0] a, input clk, input reset, output [3: 0] b );
按照Verilog的语法,它的例化代码应该是这样的:
Dut u0 ( .a(u0_a), .clk(u0_clk), .reset(u0_reset), .b(u0_b) );
其中,例化时的名字和连接的线网名是可以任意的,但是模块名“Dut”和端口名“.a”、“.clk”、“.reset”、 “.b”是固定的。
倘若把这个Verilog模块声明成普通的Chisel模块,然后直接例化使用,那么例化的Verilog代码就会变成:
Dut u0 ( .io_a(io_u0_a), .io_clk(io_u0_clk), .io_reset(io_u0_reset), .io_b(io_u0_b) );
也就是说,本来应该是“.a”,变成了“.io_a”。当然,这样做首先在Chisel层面上就不会成功,因为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)
}
它对应生成的Verilog代码为:
// UseDut.v
module UseDut(
input clock,
input reset,
input [31:0] io_toDut_a,
output [3:0] io_toDut_b
);
wire [31:0] u0_a; // @[blackbox.scala 20:18]
wire u0_clk; // @[blackbox.scala 20:18]
wire u0_reset; // @[blackbox.scala 20:18]
wire [3:0] u0_b; // @[blackbox.scala 20:18]
Dut u0 ( // @[blackbox.scala 20:18]
.a(u0_a),
.clk(u0_clk),
.reset(u0_reset),
.b(u0_b)
);
assign io_toDut_b = u0_b; // @[blackbox.scala 25:14]
assign u0_a = io_toDut_a; // @[blackbox.scala 22:11]
assign u0_clk = clock; // @[blackbox.scala 23:13]
assign u0_reset = reset; // @[blackbox.scala 24:15]
endmodule
可以看到,例化黑盒生成的Verilog代码,完全符合Verilog例化模块的语法规则。通过黑盒导入Verilog模块的端口列表给Chisel模块使用,然后把Chisel代码转换成Verilog,把它与导入的Verilog一同传递给EDA工具使用。
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就变成了:
...
Dut #(.DATA_WIDTH(32), .MODE("Sequential"), .RESET("Asynchronous")) u0 ( // @[blackbox.scala 23:18]
.a(u0_a),
.clk(u0_clk),
.reset(u0_reset),
.b(u0_b)
);
...
通过这种方式,借助Verilog把Chisel的功能暂时补齐了。比如UCB发布的Rocket-Chip,就是用黑盒导入异步寄存器,供内部代码使用。
二、复制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文件
chisel3.util包里还有有一个特质HasBlackBoxInline,混入该特质的黑盒类可以把Verilog代码直接内嵌进去。内嵌的方式是调用特质里的方法“setInline(blackBoxName: String, blackBoxInline: String)”,类似于setResource的用法。这样,目标文件夹里就会生成一个单独的Verilog文件,复制内嵌的代码。该方法适合小型Verilog设计。例如:
...
import chisel3.util._
class Dut extends BlackBox with HasBlackBoxInline {
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))
})
setInline("dut.v",
"""
|module dut(input [31:0] a,
| input clk,
| input reset,
| output [3:0] b);
|
| reg [3:0] b_temp;
|
| always @ (posedge clk, negedge reset)
| if(!reset)
| b_temp <= \'b0;
| else if(a == \'b0)
| b_temp <= b_temp + 1\'b1
|
| assign b = b_temp;
|endmodule
""".stripMargin)
}
...
字符串中的“ | ”表示文件的边界,比如Scala的解释器在换行后的开头就是一根竖线,方法stripMargin用于消除竖线左侧的空格。
调用这个黑盒的模块在转换成Verilog后,目标文件夹里会生成一个“dut.v”文件,内容就是内嵌的Verilog代码。
四、inout端口
Chisel目前只支持在黑盒中引入Verilog的inout端口。Bundle中使用 “Analog(位宽)”声明Analog类型的端口,经过编译后变成Verilog的inout端口。模块里的端口可以声明成Analog类型,但只能用于与黑盒连接,不能在Chisel代码中进行读写。因为是双向端口,所以不需要用Input或Output指明方向,但是可以用Flipped来翻转,也就不会影响整个Bundle的翻转。使用前,要先用“chisel3.experimental._”进行导入。
例如:
// 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)
}
对应的Verilog为:
// MakeInout.v
module MakeInout(
input clock,
input reset,
inout [15:0] io_a,
input [15:0] io_b,
input io_sel,
output [15:0] io_c
);
wire [15:0] m_b; // @[inout.scala 32:17]
wire m_sel; // @[inout.scala 32:17]
wire [15:0] m_c; // @[inout.scala 32:17]
InoutPort m ( // @[inout.scala 32:17]
.a(io_a),
.b(m_b),
.sel(m_sel),
.c(m_c)
);
assign io_c = m_c; // @[inout.scala 34:8]
assign m_b = io_b; // @[inout.scala 34:8]
assign m_sel = io_sel; // @[inout.scala 34:8]
endmodule
五、总结
本章介绍了三种黑盒的用法,其目的在于通过外部的Verilog文件来补充Chisel还没有的功能。除此之外,由于还没有EDA工具直接支持Chisel,比如在开发FPGA项目时,要例化Xilinx或Altera的IP,就需要用到黑盒。
第二十二章 Chisel基础——函数的应用
函数是编程语言的常用语法,即使是Verilog这样的硬件描述语言,也会用函数来构建组合逻辑。对于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的函数也能在Chisel里使用,只要能通过Firrtl编译器的检查。比如在生成长的序列上,利用Scala的函数就能减少大量的代码。假设要构建一个译码器,在Verilog里需要写条case语句,当n很大时就会使代码显得冗长而枯燥。利用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))
}
只需要输入参数n,就能立即生成对应的n位译码器。
四、Chisel的打印函数
Chisel提供了一个“ printf ”函数来打印信息,用于电路调试。它有Scala和C两种风格。当用Verilator生成波形时,每个时钟周期都会在屏幕上显示一次。如果在when语句块里,只有条件成立时才运行。隐式的全局复位信号也不会触发。
printf函数只能在Chisel的模块里使用,并且会转换成Verilog的系统函数“$fwrite”,包含在宏定义块“ `ifndef SYNTHESIS......`endif ”里。通过Verilog的宏定义,可以取消这部分不可综合的代码。因为后导入的chisel3包覆盖了Scala的标准包,所以Scala里的printf函数要写成“Predef.printf”的完整路径形式。
Ⅰ、Scala风格
该风格类似于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 myVec = Vec(5.U, 10.U, 13.U)
printf(p"myVec = $myVec") // myVec = Vec(5, 10, 13)
val myBundle = Wire(new Bundle {
val foo = UInt()
val bar = UInt()
})
myBundle.foo := 3.U
myBundle.bar := 11.U
printf(p"myBundle = $myBundle") // myBundle = Bundle(a -> 3, b -> 11)
③自定义打印信息
对于自定义的Bundle类型,可以重写toPrintable方法来定制打印内容。当自定义的Bundle配合其他硬件类型例如Wire构成具体的硬件,并且被赋值后,可以用p插值器来求值该硬件,此时就会调用重写的toPrintable方法。例如:
class Message extends Bundle {
val valid = Bool()
val addr = UInt(32.W)
val length = UInt(4.W)
val data = UInt(64.W)
override def toPrintable: Printable = {
val char = Mux(valid, \'v\'.U, \'-\'.U)
p"Message:\n" +
p" valid : ${Character(char)}\n" +
p" addr : 0x${Hexadecimal(addr)}\n" +
p" length : $length\n" +
p" data : 0x${Hexadecimal(data)}\n"
}
}
val myMessage = Wire(new Message)
myMessage.valid := true.B
myMessage.addr := "h1234".U
myMessage.length := 10.U
myMessage.data := "hdeadbeef".U
printf(p"$myMessage")
注意,重写的toPrintable方法的返回类型固定是Printable,这是因为p插值器的返回类型就是Printable,并且Printable类里定义了一个方法“+”用于将多个字符串拼接起来。在最后一个语句里,p插值器会求值myMessage,这就会调用Message类的toPrintable方法。因此,最终的打印信息如下:
Message:
valid : v
addr : 0x00001234
length : 10
data : 0x00000000deadbeef
Ⅱ、C风格
Chisel的printf也支持C的部分格式控制符和转义字符。如下所示:
val myUInt = 32.U
printf("myUInt = %d", myUInt) // myUInt = 32
五、Chisel的对数函数
在二进制运算里,求以2为底的对数也是常用的运算。
chisel3.util包里有一个单例对象Log2,它的一个apply方法接收一个Bits类型的参数,计算并返回该参数值以2为底的幂次。返回类型是UInt类型,并且是向下截断的。另一个apply的重载版本可以接受第二个Int类型的参数,用于指定返回结果的位宽。例如:
Log2(8.U) // 等于3.U
Log2(13.U) // 等于3.U(向下截断)
Log2(myUIntWire) // 动态求值
chisel3.util包里还有四个单例对象:log2Ceil、log2Floor、log2Up和log2Down,它们的apply方法的参数都是Int和BigInt类型,返回结果都是Int类型。log2Ceil是把结果向上舍入,log2Floor则向下舍入。log2Up和log2Down不仅分别把结果向上、向下舍入,而且结果最小为1。
单例对象isPow2的apply方法接收Int和BigInt类型的参数,判断该整数是不是2的n次幂,返回Boolean类型的结果。
六、与硬件相关的函数
Ⅰ、位旋转
chisel3.util包里还有一些常用的操作硬件的函数,比如单例对象Reverse的apply方法可以把一个UInt类型的对象进行旋转,返回一个对应的UInt值。在转换成Verilog时,都是通过拼接完成的组合逻辑。例如:
Reverse("b1101".U) // 等于"b1011".U
Reverse("b1101".U(8.W)) // 等于"b10110000".U
Reverse(myUIntWire) // 动态旋转
Ⅱ、位拼接
单例对象Cat有两个apply方法,分别接收一个Bits类型的序列和Bits类型的重复参数,将它们拼接成一个UInt数。前面的参数在高位。例如:
Cat("b101".U, "b11".U) // 等于"b10111".U
Cat(myUIntWire0, myUIntWire1) // 动态拼接
Cat(Seq("b101".U, "b11".U)) // 等于"b10111".U
Cat(mySeqOfBits) // 动态拼接
Ⅲ、1计数器
单例对象PopCount有两个apply方法,分别接收一个Bits类型的参数和Bool类型的序列,计算参数里“1”或“true.B”的个数,返回对应的UInt值。例如:
PopCount(Seq(true.B, false.B, true.B, true.B)) // 等于3.U
PopCount(Seq(false.B, false.B, true.B, false.B)) // 等于1.U
PopCount("b1011".U) // 等于3.U
PopCount("b0010".U) // 等于1.U
PopCount(myUIntWire) // 动态计数
Ⅳ、独热码转换器
单例对象OHToUInt的apply方法可以接收一个Bits类型或Bool序列类型的独热码参数,计算独热码里的“1”在第几位(从0开始),返回对应的UInt值。如果不是独热码,则行为不确定。例如:
OHToUInt("b1000".U) // 等于3.U
OHToUInt("b1000_0000".U) // 等于7.U
还有一个行为相反的单例对象UIntToOH,它的apply方法是根据输入的UInt类型参数,返回对应位置的独热码,独热码也是UInt类型。例如:
UIntToOH(3.U) // 等于"b1000".U
UIntToOH(7.U) // 等于"b1000_0000".U
Ⅴ、无关位
Verilog里可以用问号表示无关位,那么用case语句进行比较时就不会关心这些位。Chisel里有对应的BitPat类,可以指定无关位。在其伴生对象里,一个apply方法可以接收一个字符串来构造BitPat对象,字符串里用问号表示无关位。例如:
"b10101".U === BitPat("b101??") // 等于true.B
"b10111".U === BitPat("b101??") // 等于true.B
"b10001".U === BitPat("b101??") // 等于false.B
另一个apply方法则用UInt类型的参数来构造BitPat对象,UInt参数必须是字面量。这允许把UInt类型用在期望BitPat的地方,当用BitPat定义接口又并非所有情况要用到无关位时,该方法就很有用。
另外,bitPatToUInt方法可以把一个BitPat对象转换成UInt对象,但是BitPat对象不能包含无关位。
dontCare方法接收一个Int类型的参数,构造等值位宽的全部无关位。例如:
val myDontCare = BitPat.dontCare(4) // 等于BitPat("b????")
Ⅵ、查找表
BitPat通常配合两种查找表使用。一种是单例对象Lookup,其apply方法定义为:
def apply[T <: Bits](addr: UInt, default: T, mapping: Seq[(BitPat, T)]): T
参数addr会与每个BitPat进行比较,如果相等,就返回对应的值,否则就返回default。
第二种是单例对象ListLookup,它的apply方法与上面的类似,区别在于返回结果是一个T类型的列表:
defapply[T <: Data](addr: UInt, default: List[T], mapping: Array[(BitPat, List[T])]): List[T]
这两种查找表的常用场景是构造CPU的控制器,因为CPU指令里有很多无关位,所以根据输入的指令(即addr)与预先定义好的带无关位的指令进行匹配,就能得到相应的控制信号。
七、总结
在编写代码时,虽然是构造硬件,但是语言特性和编译器允许读者灵活使用高级函数。要做到熟能生巧,就应该多阅读、多练习。
第二十三章 Chisel基础——多时钟域设计
在数字电路中免不了用到多时钟域设计,尤其是设计异步FIFO这样的同步元件。在Verilog里,多时钟域的设计很简单,只需声明多个时钟端口,然后不同的always语句块根据需要选择不同的时钟作为敏感变量即可。在Chisel里,则相对复杂一些,因为这与Scala的变量作用域相关,而且时序元件在编译时都是自动地隐式跟随当前时钟域。本章将介绍多时钟域设计的语法,这其实很简单。
一、没有隐式端口的模块
继承自Module的模块类会获得隐式的全局时钟与同步复位信号,即使在设计中用不上它们也没关系。如果读者确实不喜欢这两个隐式端口,则可以选择继承自RawModule,这样在转换成Verilog时就没有隐式端口。它是单例对象chisel3.experimental里定义的类型,也就是UserModule类的别名。
这样的模块一般用于纯组合逻辑。在类内顶层不能出现使用时钟的相关操作,比如定义寄存器,否则会报错没有隐式端口。例如:
// 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)
}
它生成的Verilog代码为:
// MyModule.v
module MyModule(
input [3:0] io_a,
input [3:0] io_b,
output [3:0] io_c
);
assign io_c = io_a & io_b; // @[module.scala 13:8]
endmodule
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) )
这结合了Scala的柯里化、传名参数和单参数列表的语法特性,让DSL语言的自定义方法看上去就跟内建的while、for、if等结构一样自然,所以Scala很适合构建DSL语言。
读者再仔细看一看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
}
现在,被例化的模块不是作为返回结果,而是变成了变量m的引用对象,故而传名参数是只有定义、没有有用的返回值的空函数。如果编译这个模块,就会得到“没有相关成员”的错误信息:
[error] /home/esperanto/chisel-template/src/main/scala/module.scala:42:16: value m is not a member of Unit
[error] clockB_child.m.io.in := io.stuff
[error] ^
如果独立时钟域有多个变量要与外部交互,则应该在模块内部的顶层定义全局的线网,让所有时钟域都能访问。
除了单例对象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)
}
它生成的Verilog主要是:
// NegativeClkRst.v
module NegativeClkRst(
input [3:0] io_in,
input io_myClk,
input io_myRst,
output [3:0] io_out
);
wire _T; // @[negclkrst.scala 14:32]
wire _T_2; // @[negclkrst.scala 14:22]
wire _T_3; // @[negclkrst.scala 14:47]
wire _T_4; // @[negclkrst.scala 14:56]
reg [3:0] _T_5; // @[negclkrst.scala 15:23]
assign _T = $unsigned(io_myClk); // @[negclkrst.scala 14:32]
assign _T_2 = ~ _T; // @[negclkrst.scala 14:22]
assign _T_3 = _T_2; // @[negclkrst.scala 14:47]
assign _T_4 = ~ io_myRst; // @[negclkrst.scala 14:56]
assign io_out = _T_5; // @[negclkrst.scala 17:12]
always @(posedge _T_3) begin
if (_T_4) begin
_T_5 <= 4\'h0;
end else begin
_T_5 <= io_in;
end
end
endmodule
四、示例:异步FIFO
在跨时钟域设计中,经常需要使用异步FIFO来同步不同时钟域的数据传输。下面是笔者自己编写的一个异步FIFO例子,数据位宽和深度都是参数化的,读、写地址指针的交互采用格雷码和两级寄存器采样,以便改善亚稳态。通过在Vivado 2018.3里综合后,可以得到以BRAM为存储器的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设计多时钟域电路,重点是学会apply方法的使用,以及对第二个参数列表的理解。要注意独立时钟域里只有最后的表达式能被作为返回值给变量引用,并被外部访问,其它的定义都是对外不可见的。
第二十四章 Chisel基础——其它议题
本章讲解的内容比较繁杂,没有一个统一的中心思想。这些问题与实际编程没有太大关系,但是读者需要稍微留意。
一、动态命名模块
Chisel可以动态定义模块的名字,也就是转成Verilog时的模块名不使用定义的类名,而是使用重写的desiredName方法的返回字符串。模块和黑盒都适用。例如:
class Coffee extends BlackBox {
val io = IO(new Bundle {
val I = Input(UInt(32.W))
val O = Output(UInt(32.W))
})
override def desiredName = "Tea"
}
class Salt extends Module {
val io = IO(new Bundle {})
val drink = Module(new Coffee)
override def desiredName = "SodiumMonochloride"
}
对应的Verilog为:
module SodiumMonochloride(
input clock,
input reset
);
wire [31:0] drink_O;
wire [31:0] drink_I;
Tea drink (
.O(drink_O),
.I(drink_I)
);
assign drink_I = 32\'h0;
endmodule
二、动态修改端口
Chisel通过引入Scala的Boolean参数、可选值以及if语句可以创建出可选的端口,在例化该模块时可以通过控制Boolean入参来生成不同的端口。例如:
class ModuleWithOptionalIOs(flag: Boolean) extends Module {
val io = IO(new Bundle {
val in = Input(UInt(12.W))
val out = Output(UInt(12.W))
val out2 = if (flag) Some(Output(UInt(12.W))) else None
})
io.out := io.in
if(flag) {
io.out2.get := io.in
}
}
注意,端口应该包装成可选值,这样不需要端口时就能用对象None代替,编译出来的Verilog就不会生成这个端口。在给可选端口赋值时,应该先用可选值的get方法把端口解放出来。这里也体现了可选值语法的便利性。
三、生成正确的块内信号名
一般情况下,在when、withClockAndReset等语句块里定义的信号(线网和寄存器),转换成Verilog时不会生成正确的变量名。例如:
// name.scala
package test
import chisel3._
class TestMod extends Module {
val io = IO(new Bundle {
val a = Input(Bool())
val b = Output(UInt(4.W))
})
when (io.a) {
val innerReg = RegInit(5.U(4.W))
innerReg := innerReg + 1.U
io.b := innerReg
} .otherwise {
io.b := 10.U
}
}
object NameGen extends App {
chisel3.Driver.execute(args, () => new TestMod)
}
它对应生成的Verilog为:
// TestMod.v
module TestMod(
input clock,
input reset,
input io_a,
output [3:0] io_b
);
reg [3:0] _T;
wire [3:0] _T_2;
assign _T_2 = _T + 4\'h1;
assign io_b = io_a ? _T : 4\'ha;
always @(posedge clock) begin
if (reset) begin
_T <= 4\'h5;
end else begin
_T <= _T_2;
end
end
endmodule
注意看,when语句块里声明的寄存器innerReg,被命名成了“_T”。
如果想让名字正确,则需要在build.sbt文件里加上:
addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.0" cross CrossVersion.full)
同时,设计代码里需要加上传递给Firrtl的注解:
...
import chisel3._
import chisel3.experimental.chiselName
@chiselName
class TestMod extends Module {
...
这样,对应的Verilog文件就有了正确的寄存器名字:
// TestMod.v
module TestMod(
input clock,
input reset,
input io_a,
output [3:0] io_b
);
reg [3:0] innerReg;
wire [3:0] _T_1;
assign _T_1 = innerReg + 4\'h1;
assign io_b = io_a ? innerReg : 4\'ha;
always @(posedge clock) begin
if (reset) begin
innerReg <= 4\'h5;
end else begin
innerReg <= _T_1;
end
end
endmodule
四、拆包一个值(给拼接变量赋值)
在Verilog中,左侧的赋值对象可以是一个拼接起多个变量的值,例如:
wire [1:0] a;
wire [3:0] b;
wire [2:0] c;
wire [8:0] z = [...];
assign {a, b, c} = z;
在Chisel里不能直接这么赋值。最简单的做法是先定义一个a、b、c组成的Bundle,高位定义在前面,然后创建线网z。线网z可以被直接赋值,被赋值后,z再调用方法asTypeOf。该方法接收一个Data类型的参数,可以把调用对象强制转换成参数的类型并返回,在这里也就是把a、b、c组成的Bundle作为参数。注意,返回结果是一个新对象,并没有直接修改调用对象z。强制转换必须保证不会出错。例如:
class MyBundle extends Bundle {
val a = UInt(2.W)
val b = UInt(4.W)
val c = UInt(3.W)
}
val z = Wire(UInt(9.W))
z := ...
val unpacked = z.asTypeOf(new MyBundle)
unpacked.a
unpacked.b
unpacked.c
五、子字赋值
在Verilog中,可以直接给向量的某几位赋值。同样,Chisel受限于Scala,不支持直接给Bits类型的某几位赋值。子字赋值的可行办法是先调用Bits类型的toBools方法。该方法根据调用对象的0、1排列返回一个相应的Seq[Bool]类型的结果,并且低位在序列里的下标更小,比如第0位的下标就是0、第n位的下标就是n。然后用这个Seq[Bool]对象配合VecInit构成一个向量,此时就可以给单个比特赋值。注意,必须都是Bool类型,要注意赋值前是否需要类型转换。子字赋值完成后,Bool向量再调用asUInt、asSInt方法转换回来。例如:
class TestModule extends Module {
val io = IO(new Bundle {
val in = Input(UInt(10.W))
val bit = Input(Bool())
val out = Output(UInt(10.W))
})
val bools = VecInit(io.in.toBools)
bools(0) := io.bit
io.out := bools.asUInt
}
六、参数化的Bundle
因为Chisel是基于Scala和JVM的,所以当一个Bundle类的对象用于创建线网、IO等操作时,它并不是把自己作为参数,而是交出自己的一个复制对象,也就是说编译器需要知道如何来创建当前Bundle对象的复制对象。Chisel提供了一个内部的API函数cloneType,任何继承自Data的Chisel对象,要复制自身时,都是由cloneType负责返回该对象的复制对象。它对应的用户API则是chiselTypeOf。
当自定义的Bundle的主构造方法没有参数时,Chisel会自动推断出如何构造Bundle对象的复制,原因很简单,因为构造一个新的复制对象不需要任何参数,仅仅使用关键字new就行了。但是,如果自定义的Bundle带有参数列表,那么Chisel就无法推断了,因为传递进去的参数可以是任意的,并不一定就是完全地复制。此时需要用户自己重写Bundle类的cloneType方法,其形式为:
override def cloneType = (new CustomBundle(arguments)).asInstanceOf[this.type]
例如:
class ExampleBundle(a: Int, b: Int) extends Bundle {
val foo = UInt(a.W)
val bar = UInt(b.W)
override def cloneType = (new ExampleBundle(a, b)).asInstanceOf[this.type]
}
class ExampleBundleModule(btype: ExampleBundle) extends Module {
val io = IO(new Bundle {
val out = Output(UInt(32.W))
val b = Input(chiselTypeOf(btype))
})
io.out := io.b.foo + io.b.bar
}
class Top extends Module {
val io = IO(new Bundle {
val out = Output(UInt(32.W))
val in = Input(UInt(17.W))
})
val x = Wire(new ExampleBundle(31, 17))
x := DontCare
val m = Module(new ExampleBundleModule(x))
m.io.b.foo := io.in
m.io.b.bar := io.in
io.out := m.io.out
}
例子中的ExampleBundle有两个参数,编译器无法在复制它的对象时推断出这两个参数是什么,所以重写的cloneType方法需要用户手动将两个参数传入,而且用asInstanceOf[this.type]保证返回对象的类型与this对象是一样的。
如果没有这个重写的cloneType的方法,编译器会提示把ExampleBundle的参数变成固定的和可获取的,以便cloneType方法能被自动推断,即非参数化Bundle不需要重写该方法。此外,变量x必须要用Wire包住ExampleBundle的对象,否则x在传递给ExampleBundleModule时,编译器会提示应该传入一个硬件而不是裸露的Chisel类型,并询问是否遗漏了Wire(_)或IO(_)。与之相反,“Input(chiselTypeOf(btype))”中的chiselTypeOf方法也必不可少,因为此时传入的btype是一个硬件,编译器会提示Input的参数应该是Chisel类型而不是硬件,需要使用方法chiselTypeOf解除包住ExampleBundle对象的Wire。
这个例子中,cloneType在构造复制对象时,仅仅是传递了对应的参数,这就会构造一个一模一样的新对象。为了进一步说明cloneType的作用,再来看一个“别扭”的例子:
class TestBundle(a: Int, b: Int) extends Bundle {
val A = UInt(a.W)
val B = UInt(b.W)
override def cloneType = (new TestBundle(5*b, a+1)).asInstanceOf[this.type]
}
class TestModule extends Module {
val io = IO(new Bundle {
val x = Input(UInt(10.W))
val y = Input(UInt(5.W))
val out = Output(new TestBundle(10, 5))
})
io.out.A := io.x
io.out.B := io.y
}
这里,cloneType在构造复制对象前,先把形参a、b做了一些算术操作,再传递给TestBundle的主构造方法使用。按常规思路,代码“Output(new TestBundle(10, 5))”应该构造两个输出端口:10bit的A和5bit的B。但实际生成的Verilog如下:
module TestModule(
input clock,
input reset,
input [9:0] io_x,
input [4:0] io_y,
output [24:0] io_out_A,
output [10:0] io_out_B
);
assign io_out_A = {{15\'d0}, io_x};
assign io_out_B = {{6\'d0}, io_y};
endmodule
也就是说,“Output(new TestBundle(10, 5))”的真正形式应该是“Output((new TestBundle(10, 5)).cloneType)”,即Output的真正参数是对象TestBundle(10, 5)的cloneType方法构造出来的对象。而cloneType方法是用实参“5 * 5(b)”和“10(a) + 1”来分别赋予形参a和b,因此得出A的实际位宽是25bit,B的实际位宽是11bit。
七、Chisel泛型
Chisel本质上还是Scala,所以Chisel的泛型就是使用Scala的泛型语法,这使得电路参数化更加方便。无论是Chisel的函数还是模块,都可以用类型参数和上、下界来泛化。在例化模块时,传入不同类型的参数,就可能会产生不同的电路,而无需编写额外的代码,当然前提是逻辑、类型必须正确。
要熟练使用泛型比较麻烦,所需素材很多,这里就不再介绍。读者可以通过阅读Chisel的源码来学习它是如何进行泛型的。
八、未驱动的线网
Chisel的Invalidate API支持检测未驱动的输出型IO以及定义不完整的Wire定义,在编译成firrtl时会产生“not fully initialized”错误。换句话说,就是组合逻辑的真值表不完整,不能综合出完整的电路。如果确实需要不被驱动的线网,则可以赋给一个DontCare对象,这会告诉Firrtl编译器,该线网故意不被驱动。转换成的Verilog会赋予该信号全0值,甚至把逻辑全部优化掉,所以谨慎使用。例如:
val io = IO(new Bundle {
val outs = Output(Vec(10, Bool()))
})
io.outs <> DontCare
检查机制是由CompileOptions.explicitInvalidate控制的,如果把它设置成true就是严格模式(执行检查),设置成false就是不严格模式(不执行检查)。开关方法有两种,第一种是定义一个抽象的模块类,由抽象类设置,其余模块都继承自这个抽象类。例如:
// 严格
abstract class ExplicitInvalidateModule extends Module()(chisel3.core.ExplicitCompileOptions.NotStrict.copy(explicitInvalidate = true))
// 不严格
abstract class ImplicitInvalidateModule extends Module()(chisel3.core.ExplicitCompileOptions.Strict.copy(explicitInvalidate = false))
第二种方法是在每个模块里重写compileOptions字段,由该字段设置编译选项。例如:
// 严格
class MyModule extends Module {
override val compileOptions = chisel3.core.ExplicitCompileOptions.NotStrict.copy(explicitInvalidate = true)
...
}
// 不严格
class MyModule extends Module {
override val compileOptions = chisel3.core.ExplicitCompileOptions.Strict.copy(explicitInvalidate = false)
...
}
九、总结
本章内容是编写Chisel时的常见问题汇总。最常出现的错误就是“not fully initialized”,读者应该根据提示信息查看设计中是否有情况没覆盖全的组合逻辑。
第二十五章 Chisel进阶——隐式参数的应用
用Chisel编写的CPU,比如Rocket-Chip、RISCV-Mini等,都有一个特点,就是可以用一个配置文件来裁剪电路。这利用了Scala的模式匹配、样例类、偏函数、可选值、隐式定义等语法。本章内容就是来为读者详细解释它的工作机制。
一、相关定义
要理解隐式参数是如何配置电路的,应该先了解与配置相关的定义。在阅读代码之前,为了能快速读懂、深入理解,读者最好复习一下模式匹配和隐式定义两章的内容。
下面是来自于开源处理器RISCV-Mini的代码:
// Config.scala
// See LICENSE.SiFive for license details.
package freechips.rocketchip.config
abstract class Field[T] private (val default: Option[T])
{
def this() = this(None)
def this(default: T) = this(Some(default))
}
abstract class View {
final def apply[T](pname: Field[T]): T = apply(pname, this)
final def apply[T](pname: Field[T], site: View): T = {
val out = find(pname, site)
require (out.isDefined, s"Key ${pname} is not defined in Parameters")
out.get
}
final def lift[T](pname: Field[T]): Option[T] = lift(pname, this)
final def lift[T](pname: Field[T], site: View): Option[T] = find(pname, site).map(_.asInstanceOf[T])
protected[config] def find[T](pname: Field[T], site: View): Option[T]
}
abstract class Parameters extends View {
final def ++ (x: Parameters): Parameters =
new ChainParameters(this, x)
final def alter(f: (View, View, View) => PartialFunction[Any,Any]): Parameters =
Parameters(f) ++ this
final def alterPartial(f: PartialFunction[Any,Any]): Parameters =
Parameters((_,_,_) => f) ++ this
final def alterMap(m: Map[Any,Any]): Parameters =
new MapParameters(m) ++ this
protected[config] def chain[T](site: View, tail: View, pname: Field[T]): Option[T]
protected[config] def find[T](pname: Field[T], site: View) = chain(site, new TerminalView, pname)
}
object Parameters {
def empty: Parameters = new EmptyParameters
def apply(f: (View, View, View) => PartialFunction[Any,Any]): Parameters = new PartialParameters(f)
}
class Config(p: Parameters) extends Parameters {
def this(f: (View, View, View) => PartialFunction[Any,Any]) = this(Parameters(f))
protected[config] def chain[T](site: View, tail: View, pname: Field[T]) = p.chain(site, tail, pname)
override def toString = this.getClass.getSimpleName
def toInstance = this
}
// Internal implementation:
private class TerminalView extends View {
def find[T](pname: Field[T], site: View): Option[T] = pname.default
}
private class ChainView(head: Parameters, tail: View) extends View {
def find[T](pname: Field[T], site: View) = head.chain(site, tail, pname)
}
private class ChainParameters(x: Parameters, y: Parameters) extends Parameters {
def chain[T](site: View, tail: View, pname: Field[T]) = x.chain(site, new ChainView(y, tail), pname)
}
private class EmptyParameters extends Parameters {
def chain[T](site: View, tail: View, pname: Field[T]) = tail.find(pname, site)
}
private class PartialParameters(f: (View, View, View) => PartialFunction[Any,Any]) extends Parameters {
protected[config] def chain[T](site: View, tail: View, pname: Field[T]) = {
val g = f(site, this, tail)
if (g.isDefinedAt(pname)) Some(g.apply(pname).asInstanceOf[T]) else tail.find(pname, site)
}
}
private class MapParameters(map: Map[Any, Any]) extends Parameters {
protected[config] def chain[T](site: View, tail: View, pname: Field[T]) = {
val g = map.get(pname)
if (g.isDefined) Some(g.get.asInstanceOf[T]) else tail.find(pname, site)
}
}
二、Field[T]类
位置:6-10行
抽象类Field[T]是一个类型构造器,它需要根据类型参数T来生成不同的类型。而T取决于传入的参数——可选值default:Option[T]的类型。例如,如果传入一个Some(10),那么所有的T都可以确定为Int。
Field[T]只有一个公有val字段,即主构造方法的参数default:Option[T]。此外,主构造方法是私有的,外部只能访问两个公有的辅助构造方法“def this()”和“def this(default: T)”。第一个辅助构造方法不接收参数,所以会构造一个可选值字段是None的对象;第二个辅助构造方法接受一个T类型的参数,然后把参数打包成可选值Some(default): Option[T],并把它赋给对象的可选值字段。
事实上,Field[T]是抽象的,我们并不能通过“new Field(参数)”来构造一个对象,所以它只能用于继承给子类、子对象或子特质。之所以定义抽象类Field[T],是为了后面构造出它的样例子对象,并把这些样例对象用于偏函数。例如,构造一个“case object isInt extends Field[Int]”,然后把样例对象isInt用于偏函数“case isInt => …”。
为什么要把isInt构造成Field[Int]类型,而不是直接的Int类型呢?首先,我们想要偏函数的参数是一个常量,这样才能构成常量模式的模式匹配,一个常量模式控制一条配置选项。所以,要么定义一个样例对象,要么定义一个普通的Int对象比如1。这里我们选择定义样例对象,因为不仅会有Int类型,还可能有其他的自定义类型,它们可能是抽象的,无法直接创建实例对象。而且,用一个普通的Int对象来做模式匹配,会显得不那么独一无二。为了方便统一,全部构造成Field[T]类型的样例对象。例如,“case object isA extends Field[A]”、“case object isB extends Field[B]”等等。
其次,为什么要引入Field[Int]而不是“case object isInt extends Int”呢?因为Scala的基本类型Int、Float、Double、Boolean等都是final修饰的抽象类,不能被继承。
三、View类
位置:12-24行
我们只需要关心抽象类View的两个apply方法。其中第一个apply方法只是调用了第二个apply方法,重点在第二个apply方法。第二个apply方法调用了View的find方法,而find方法是抽象的,目前只知道它的返回结果是一个可选值。View的子类应该实现这个find方法,并且find方法会影响apply方法。如果不同的子类实现了不同行为的find方法,那么apply方法可能也会有不同的行为。
我们可以大致推测一下,参数pname的类型是Field[T],那么很有可能是一个样例对象。而find方法应该就是在参数site里面找到是否包含pname,如果包含就返回一个可选值,否则就返回None。根据require函数可以印证这一点:如果site里面没有pname,那么结果out就是None,out.isDefined就是false,require函数产生异常,并输出字符串“Key ${pname} is not defined in Parameters”,即找不到pname;反之,out.isDefined就是true,require函数通过,不会输出字符串,并执行后面的out.get,即把可选值解开并返回。
四、Parameters类及伴生对象
位置:26-46行
抽象类Parameters是View的子类,它的确实现了find方法,但是又引入了抽象的chain方法,所以我们只需要关心Parameters的子类是如何实现chain方法的。另外四个方法不是重点,但是大致可以推测出来是在把两个Parameters类的对象拼接起来。
此外,出现了新的类TerminalView(位置58-60行)。TerminalView类也是View的子类,它也实现了find方法,只不过是直接返回pname的可选值字段。可以做如下推测:Parameters类的find方法给chain方法传递了三个参数——site、一个TerminalView实例对象和pname,它既可以在site里寻找是否包含pname,也可以用TerminalView的find方法直接返回pname。
Parameters类的伴生对象里定义了一个apply工厂方法,该方法构造了一个PartialParameters对象(位置74-79行)。
首先,PartialParameters类是Parameters的子类,所以工厂方法的返回类型可以是Parameters但实际返回结果是一个子类对象。
其次,工厂方法的入参f是一个理解难点。f的类型是一个函数,这个函数有三个View类型的入参,然后返回一个偏函数,即f是一个返回偏函数的函数。根据偏函数的介绍内容,我们可以推测出f返回的偏函数应该是一系列的case语句,用于模式匹配。
接着,前面说过,我们只需要关心Parameters的子类是如何实现chain方法的,而子类PartialParameters则实现了chain方法的一个版本。这个chain方法首先把PartialParameters的构造参数f返回的偏函数用g来引用,也就是说,g现在就是那个偏函数。至于f的三个入参site、this和tail则不是重点。然后,g.isDefinedAt(pname)表示在偏函数的可行域里寻找是否包含pname,如果有的话,则执行相应的case语句;否则,就用参数tail的find方法。结合代码定义,参数tail其实就是TerminalView的实例对象,它的find方法就是直接返回pname的可选值字段。这与推测内容相吻合。
五、Config类
位置:48-54行
首先,Config类也是Parameters的子类。它可以通过主构造方法接收一个Parameters类型的实例对象来构造一个Config类型的实例对象,或者通过辅助构造方法接收一个函数f来间接构造一个Config类型的实例对象。观察这个辅助构造方法,它其实先调用了Parameters的工厂方法,也就是利用函数f先构造了一个PartialParameters类型的对象(是Parameters的子类型),再用这个PartialParameters类型的对象去运行主构造方法。
其次,我们仍然需要知道chain方法是如何实现的。这里,Config的chain方法是由构造时的参数p: Parameters决定的。如果一个Config的对象是用辅助构造方法和函数f构造的,那么参数p就是一个PartialParameters的对象,构造出来的Config对象的chain方法实际上运行的是PartialParameters的chain方法。
六、MiniConfig类
前面讲解的内容相当于类库里预先定义好的内容。要配置自定义的电路,还需要一个自定义的类。比如,处理器RISCV-Mini就定义了下面的MiniConfig类:
// See LICENSE for license details.
package mini
import chisel3.Module
import freechips.rocketchip.config.{Parameters, Config}
import junctions._
class MiniConfig extends Config((site, here, up) => {
// Core
case XLEN => 32
case Trace => true
case BuildALU => (p: Parameters) => Module(new ALUArea()(p))
case BuildImmGen => (p: Parameters) => Module(new ImmGenWire()(p))
case BuildBrCond => (p: Parameters) => Module(new BrCondArea()(p))
// Cache
case NWays => 1 // TODO: set-associative
case NSets => 256
case CacheBlockBytes => 4 * (here(XLEN) >> 3) // 4 x 32 bits = 16B
// NastiIO
case NastiKey => new NastiParameters(
idBits = 5,
dataBits = 64,
addrBits = here(XLEN))
}
)
MiniConfig类是Config的子类,其实它没有添加任何定义,只是给超类Config传递了所需要的构造参数。第五点讲了,Config有两种构造方法,这里是用了给定函数f的方法。那么函数f是什么呢?函数f的类型是“(View, View, View) => PartialFunction[Any,Any]”,这里给出的三个View类型入参是site、here和up。我们目前只知道site、here和up是View类型的对象,具体是什么,还无法确定,也无需关心。重点在于返回的偏函数是什么。偏函数是用花括号包起来的9个case语句,这呼应了我们前面讲过的用case语句组构造偏函数。我们可以推测case后面的XLEN、Trace等,就是一系列的Filed[T]类型的样例对象,也就是第二点推测的。
那么如何利用MiniConfig类呢?我们可以推测这个类包含了riscv-mini核全部的配置信息,然后看看处理器RISCV-Mini的顶层文件是如何描述的:
val params = (new MiniConfig).toInstance
val chirrtl = firrtl.Parser.parse(chisel3.Driver.emit(() => new Tile(params)))
这里,也就是直接构造了一个MiniConfig的实例,并把它传递给了需要它的顶层模块Tile。
七、MiniConfig的运行原理
我们来看Tile模块的定义:
class Tile(tileParams: Parameters) extends Module with TileBase {
implicit val p = tileParams
val io = IO(new TileIO)
val core = Module(new Core)
val icache = Module(new Cache)
val dcache = Module(new Cache)
val arb = Module(new MemArbiter)
io.host <> core.io.host
core.io.icache <> icache.io.cpu
core.io.dcache <> dcache.io.cpu
arb.io.icache <> icache.io.nasti
arb.io.dcache <> dcache.io.nasti
io.nasti <> arb.io.nasti
}
首先,Tile模块需要一个Parameters类型的参数,我们给了一个MiniConfig的实例,而MiniConfig继承自Config,Config继承自Parameters,所以这是合法的。
然后,Tile模块把入参赋给了隐式变量p。参考隐式定义的内容,这个隐式变量会被编译器传递给当前层次所有未显式给出的隐式参数。查看其他代码的定义,也就是后面实例化的TileIO、Core、Cache和MemArbiter需要隐式参数。由于没有显式给出隐式参数,那么它们都会接收这个隐式变量p,即MiniConfig实例。
以Core模块为例:
class Core(implicit val p: Parameters) extends Module with CoreParams {
val io = IO(new CoreIO)
val dpath = Module(new Datapath)
val ctrl = Module(new Control)
io.host <> dpath.io.host
dpath.io.icache <> io.icache
dpath.io.dcache <> io.dcache
dpath.io.ctrl <> ctrl.io
}
可以看到,Core模块确实需要接收一个隐式的Parameters类型的参数。
再来看Core混入的特质CoreParams:
abstract trait CoreParams {
implicit val p: Parameters
val xlen = p(XLEN)
}
这个特质有未实现的抽象成员,即隐式参数p。抽象成员需要子类给出具体的实现,这里也就是Core模块接收的MiniConfig实例。
那么“val xlen = p(XLEN)”意味着什么呢?我们知道,p是一个MiniConfig的实例对象,它继承了超类View的apply方法。查看apply的定义,也就是调用了:
final def apply[T](pname: Field[T]): T = apply(pname, this)
和
final def apply[T](pname: Field[T], site: View): T = {
val out = find(pname, site)
require (out.isDefined, s"Key ${pname} is not defined in Parameters")
out.get
}
而XLEN被定义为:
case object XLEN extends Field[Int]
前面推测了XLEN是Field[T]类型的样例对象。现在看到定义,确实如此。
即“val xlen = p(XLEN)”相当于“val xlen = p.apply(XLEN, p)”。这里的this也就是把对象p自己传入。紧接着,apply方法需要调用find方法,即“val out = find(XLEN, p)”。而MiniConfig继承了Parameters的find和chain方法,也就是:
protected[config] def chain[T](site: View, tail: View, pname: Field[T]): Option[T]
protected[config] def find[T](pname: Field[T], site: View) = chain(site, new TerminalView, pname)
而chain方法继承自Config类:
protected[config] def chain[T](site: View, tail: View, pname: Field[T]) = p.chain(site, tail, pname)
注意这里的p是用MiniConfig传递给超类的函数f构造的PartialParameters对象,不是MiniConfig对象自己。即:“val out = (new PartialParameters((site, here, up) => {…})).chain(p, new TerminalView, XLEN)”。
再来看PartialParameters类的chain方法的具体行为:
protected[config] def chain[T](site: View, tail: View, pname: Field[T]) = {
val g = f(site, this, tail)
if (g.isDefinedAt(pname)) Some(g.apply(pname).asInstanceOf[T]) else tail.find(pname, site)
}
注意,这里的f就是PartialParameters的构造参数,也就是MiniConfig传递给超类Config的函数:
(site, here, up) => {
// Core
case XLEN => 32
case Trace => true
case BuildALU => (p: Parameters) => Module(new ALUArea()(p))
case BuildImmGen => (p: Parameters) => Module(new ImmGenWire()(p))
case BuildBrCond => (p: Parameters) => Module(new BrCondArea()(p))
// Cache
case NWays => 1 // TODO: set-associative
case NSets => 256
case CacheBlockBytes => 4 * (here(XLEN) >> 3) // 4 x 32 bits = 16B
// NastiIO
case NastiKey => new NastiParameters(
idBits = 5,
dataBits = 64,
addrBits = here(XLEN))
}
至此,我们就可以确定site = p(MiniConfig对象自己),here = new PartialParameters((site, here, up) => {…})(注意这里的this应该是chain的调用对象),up = new TerminalView。
而g就是由花括号里的9个case语句组成的偏函数。那么g.isDefinedAt(XLEN)就是true,最终chain返回的结果就是“Some(g.apply(XLEN).asInstanceOf[Int])”即可选值Some(32),注意XLEN是Field[Int]类型的,确定了T是Int。
得到了“val out = Some(32)”后,apply方法的require就能通过,同时返回结果“out.get”即32。最终,“val xlen = p(XLEN)”相当于“val xlen = 32”。也就是说,在混入特质CoreParams的地方,如果有一个隐式Parameters变量是MiniConfig的对象,就会得到一个名为“xlen”的val字段,它的值是32。
关于“here(XLEN)”,因为here已经确定是由f构成的PartialParameters对象,那么套用前述过程,其实也是返回32。
假设偏函数的可行域内没有XLEN,那么chain就会执行“(new TerminalView).find(XLEN, p)”,也就是返回XLEN.default。因为XLEN在定义时没给超类Filed[Int]传递参数,所以会调用Filed[T]的第一个辅助构造函数:
def this() = this(None)
导致XLEN.default = None。这使得“val out = None”,apply方法的require产生异常报错,并打印信息“Key XLEN is not defined in Parameters”。注意字符串插值会把${pname}求值成XLEN。
再来看Core模块里的CoreIO:
abstract class CoreBundle(implicit val p: Parameters) extends Bundle with CoreParams
class HostIO(implicit p: Parameters) extends CoreBundle()(p) {
val fromhost = Flipped(Valid(UInt(xlen.W)))
val tohost = Output(UInt(xlen.W))
}
class CoreIO(implicit p: Parameters) extends CoreBundle()(p) {
val host = new HostIO
val icache = Flipped((new CacheIO))
val dcache = Flipped((new CacheIO))
}
抽象类CoreBundle混入了特质CoreParams,并接收HostIO传来的隐式参数——MiniConfig的对象(HostIO来自于CoreIO ,CoreIO来自于Core,Core来自于Tile),所以HostIO有了字段“val xlen = 32”,它定义的端口位宽也就是32位的了。
对于偏函数其他的case语句,原理一样:
case object Trace extends Field[Boolean]
case object BuildALU extends Field[Parameters => ALU]
case object BuildImmGen extends Field[Parameters => ImmGen]
case object BuildBrCond extends Field[Parameters => BrCond]
case object NWays extends Field[Int]
case object NSets extends Field[Int]
case object CacheBlockBytes extends Field[Int]
case object NastiKey extends Field[NastiParameters]
case class NastiParameters(dataBits: Int, addrBits: Int, idBits: Int)
if (p(Trace)) {
printf("PC: %x, INST: %x, REG[%d] <- %x\n", ew_pc, ew_inst,
Mux(regFile.io.wen, wb_rd_addr, 0.U),
Mux(regFile.io.wen, regFile.io.wdata, 0.U))
}
val alu = p(BuildALU)(p)
val immGen = p(BuildImmGen)(p)
val brCond = p(BuildBrCond)(p)
val nWays = p(NWays) // Not used...
val nSets = p(NSets)
val bBytes = p(CacheBlockBytes)
val nastiExternal = p(NastiKey)
val nastiXDataBits = nastiExternal.dataBits
val nastiWStrobeBits = nastiXDataBits / 8
val nastiXAddrBits = nastiExternal.addrBits
val nastiWIdBits = nastiExternal.idBits
val nastiRIdBits = nastiExternal.idBits
......
八、总结:如何自定义参数
首先要导入第一点给出的文件,其次是像定义MiniConfig那样定义自己的参数类,然后实例化参数类,并用隐式参数传递给相应的模块。模块在定义时,记得要留好隐式参数列表。
如果当前作用域有隐式的参数类对象,那么用“val xxx = p(XXX)”参数化的字段就能根据隐式对象求得具体的值。改变隐式对象的内容,就能动态地定义像位宽这样的关键字段。这样裁剪设计时,只需要修改自定义参数类的偏函数,而不需要每个地方都去更改。