Chisel(一):初步

推荐阅读官方文档:https://www.chisel-lang.org/chisel3/docs/introduction.html

官方速记手册:chisel_cheatsheet.pdf

官方github:https://github.com/chipsalliance/chisel3

Chisel3.5 API:https://www.chisel-lang.org/api/latest/

官网:https://www.chisel-lang.org/

0 Chisel的常见问题(必读)

(1)Firrtl中间格式: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等语言

(2)Chisel与Firrtl双检查:Scala里的语法,在Chisel里也基本能用,比如Scala的基本值类、内建控制结构、函数抽象、柯里化、模式匹配、隐式参数等等。但是读者要记住这些代码不仅要通过Scala编译器的检查,还需要通过Firrtl编译器的检查

(3)Chisel完全可综合:Verilog的最初目的是用于电路验证,所以它有很多不可综合的语法。Firrtl在转变成Verilog时,只会采用可综合的语法,因此读者完全不用担心用Chisel写出来的电路不可综合。只要能正确生成Verilog,那就能被综合器生成电路

(4)Chisel只支持二值逻辑:Chisel目前只支持0和1不支持四态逻辑里的x和z。由于只有芯片对外的IO处才能出现三态门,所以内部设计几乎用不到x和z。而且x和z在设计中会带来危害,忽略掉它们也不影响大多数设计,还简化了模型。当然,如果确实需要,可以通过黑盒语法与外部的Verilog代码互动,也可以在下游工具链里添加四态逻辑

(5)Chisel检查未驱动:Chisel会对未被驱动的输出型端口和线网进行检测,如果存在,会进行报错。报错选项可以关闭和打开,取决于读者对设计模型的需求。推荐把该选项打开,尽量不要残留无用的声明。

(6)Chisel需手动导环境包:Chisel的代码包并不会像Scala的标准库那样被编译器隐式导入,所以每个Chisel文件都应该在开头都需要导包

  • import chisel3._:必选,包含了基本语法
  • import chisel3.util._:高级语法必选
  • import chisel3.experimental._:高级语法必选
  • import chisel3.testers._:高级语法必选

(7)不同层次打包封装:应该用一个名字有意义的包来打包实现某个功能的文件集。例如,要实现一个自定义的微处理器,则可以把顶层包命名为“mycpu”,进而再划分成“myio”、“mymem”、“mybus”、“myalu”等子包,每个子包里包含相关的源文件。

Chisel现在仍在更新中,很可能会添加新功能或删去老功能。因此,本教程介绍的内容在将来并不一定就正确,读者应该持续关注Chisel3的GitHub的发展动向

1 环境搭建

以下环境搭建基于Ubuntu16.04

请直接参考下方博客

https://blog.csdn.net/qq_39507748/article/details/118003696

1.1 Java安装

根据下面这个博客来的,但在修改环境变量处出现了问题,导致无法进入系统(详情参考本博客《解决Ubuntu修改环境变量后导致登录循环进不去系统的问题》)

https://blog.csdn.net/qq_41204464/article/details/90314834

下载安装包

​ 记得下载Linux64位的官网地址

解压并移动

​ 解压并移动到/usr/lib/jdk目录下

1
2
3
tar -zxvf ./jdk-8u311-linux-x64.tar.gz
mkdir /usr/lib/jdk
mv ./jdk1.8.0_311 /usr/lib/jdk/

修改环境变量

1
sudo gedit /etc/profile

​ 在文件的最后添加:

1
2
3
4
5
6
7
# JDK,TOMCAT,ORACLE
export JAVA_HOME=/home/pu/jdk1.8.0_211
export JRE_HOME=$JAVA_HOME/jre

# two sentence belong may crash your env
# export CLASSPATH=.:$JAVA_HOME/lib:$JRE_HOME/lib
# export PATH=$PATH:$JAVA_HOME/bin

测试环境变量是否出问题

1
source /etc/profile 

​ 如果运行后发现环境变量失效,则修改有问题

1.2 Scala安装

随便从官网找一个安装包https://www.scala-lang.org/download/all.html,我这里用的rpm安装包,省的我再配置环境变量了

安装rpm使用命令:

1
sudo rpm  -ivh ./scala-2.11.8.rpm

安装sbt(官网https://www.scala-sbt.org/download.html):

Scala开发环境,官方名字叫“sbt”,最好选择最新的发布版本的安装,不然你还得蛋疼得安装一个叫Firrtl 的软件。这个需要先下载再安装

1
2
3
4
5
echo "deb https://repo.scala-sbt.org/scalasbt/debian all main" | sudo tee /etc/apt/sources.list.d/sbt.list
echo "deb https://repo.scala-sbt.org/scalasbt/debian /" | sudo tee /etc/apt/sources.list.d/sbt_old.list
curl -sL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x2EE0EA64E40A89B84B2DF73499E82A75642AC823" | sudo apt-key add
sudo apt-get update
sudo apt-get install sbt

sbt安装完检查:

1
2
3
4
sbt
sbt -version
sbt version in this project: 1.6.2
sbt script version: 1.6.2

1.3 安装Verilator

执行命令:

1
2
3
4
sudo apt-get install verilator 

verilator -version
Verilator 3.904 2017-05-30 rev verilator_3_904

1.4 Chisel安装

参考官方文档:

参考指导博客:

方法一:直接git clone template

1
git clone https://github.com/freechipsproject/chisel-template 

1.10 第一个工程

  • 对于小规模电路,可以直接用Chisel写testbench文件,然后联合Verilator生成C++文件来仿真,输出波形图。该方法会在后续章节介绍。
  • 对于大规模电路,Verilator仿真很吃力,建议还是用生成的Verilog文件在专业EDA工具里仿真。当前Chisel不支持UVM,也没有工具支持Chisel,所以尽量用别的工具做测试

2 数据类型

读者在学习本章后,应该理清Chisel数据类型的关系

常用的类型就五种,重点学会这五种即可:

  • UInt
  • SInt
  • Bool
  • Bundle
  • Vec[T]

三种值类:UInt、SInt和Bool的操作符与Verilog差不多,很快就能理解。

2.1 Chisel的数据类型

Chisel定义了自己的一套数据类型,读者应该跟Scala的九种基本值类区分开来。

Scala中数据类型在Chisel用于参数和内建控制结构

Chisel自己的数据类型用于构建硬件电路还是得用

Chisel定义的数据类型如下图所示,其中绿色方块是class红色是object蓝色是trait箭头指向的是超类和混入的特质

Chisel的数据类型关系图

  • Data类:所有数据类型都继承自抽象基类Data,它混入了两个特质HasId和NamedComponent。如果读者查看Chisel3的源代码,就会看到很多参数传递时都用下界表明了是Data的子类。在实际硬件构成里,并不会用到Data,读者也不用关心它的具体实现细节。更多的,应该关注Data类的两大子类:聚合类Aggregate和元素类Element。

  • Aggregate聚合类: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也是如此
    • Reset特质:表示复位,Chisel里的特质是专门的一个特质,并不像Verilog里那样是1bit的线网
    • DontCare单例对象:用于赋值给未驱动的端口或线网,防止编译器报错。

2.2 能表示数据的字面量

字面量(literal):有直接意义的,能直接表示的固定值

BigInt、Int、Long和String四种类型的Scala字面量来构造UInt和SInt,具体分类两类:

  • 数值字面量
  • 字符串字面量

(1)Chisel具体数的数据类型:

能够表示具体值的数据类型为UInt、SInt和Bool。实际可综合的电路都是若干个bit,所以只能表示整数,这与Verilog是一致的。

要表示浮点数,本质还是用多个bit来构建,而且要遵循IEEE的浮点标准。

(2)Chisel中的UInt、SInt、Bool:

  • 对于UInt,可以构成任意位宽的线网或寄存器
  • 对于SInt,在Chisel里会按补码解读,转换成Verilog后会使用系统函数$signed,这是可综合的
  • 对于Bool,转换成Verilog后就是1bit的线网或寄存器

(3)如何用字面量表示数:

要表示值,则必须有相应的字面量。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类型的对象

以上内容总结为:

  • Chisel定义了一系列隐式类,隐式的从四种Scala字面量到Chisel数据类型的转变
    • 隐式类:fromBigIntToLiteral、fromtIntToLiteral、fromtLongToLiteral、fromStringToLiteral、fromBooleanToLiteral
      • 隐式类中存在相应的隐式转换方法,同名方法、U方法、S方法,分别把字面量转为:同款隐式类、UInt、SInt
      • 如果要表示一个UInt对象可以写成:1.U
    • 四种Scala字面量:BigInt、Int、Long和String,对应的表示方法:
      • BigInt、Int、Long:加前缀0x或0X改变进制
      • String:首字母“h”、“o”、“b”来分别表示十六进制、八进制和二进制,并可以用下划线间隔
    • 完整形式举例
      • 表示一个UInt对象可以写成1.U的格式,这样编译器会插入隐式转换,变成fromtIntToLiteral(1).U
  • Scala的Boolean字面量转变方法:
    • 可以通过Boolean类型的字面量true、false,来构造fromBooleanToLiteral类型的对象
    • 然后调用名为BasBool的方法进一步构造Bool类型的对象

一些表示数的例子:

1
2
3
4
5
6
7
1.U                // Int字面值为"1"的UInt对象
0x1.U //Int字面值为16进制1的UInt对象
-8.S // Int字面值为"-8"的SInt对象

"b0101".U // String字面值为"5"的UInt对象

true.B // Boolean字面值为"true"的Bool对象

(4)构造没有字面量的对象

UInt、SInt和Bool都不是抽象类,除了可以通过字面量构造对象以外,也可以直接通过apply工厂方法构造没有字面量的对象

有字面量的数据类型用于赋值、初始化寄存器等操作,而无字面量的数据类型则用于声明端口、构造向量等

2.3 数据宽度

(1)数据的默认宽度:

​ 默认情况下,数据的宽度按字面值取最小,例如字面值为“8”的UInt对象是4位宽,SInt就是5位宽。但是也可以指定宽度。注意,Bool类型固定是1位宽

(2)宽度类Width、宽度隐式类fromIntToWidth

​ Chisel2里,宽度是由Int类型的参数表示的

Chisel3专门设计了宽度类Width,还有一个隐式类fromIntToWidth(把Int对象转换成fromIntToWidth类型的对象),然后通过方法W返回一个Width对象

(3)如何指定数据宽度:

方法U、asUInt、S和asSInt都有一个重载的版本,接收一个Width类型的参数,构造指定宽度的SInt和UInt对象

1
2
3
1.U              // 字面值为“1”、宽度为1bit的UInt对象

1.U(32.W) // 字面值为“1”、宽度为32bit的UInt对象

也可以直接给定:

1
2
3
4
5
UInt("ha", 8) 	// hexadecimal 8-bit lit of type UInt 
UInt("o12", 6) // octal 6-bit lit of type UInt
UInt("b1010", 12) // binary 12-bit lit of type UInt
SInt(5, 7) // signed decimal 7-bit lit of type SInt
UInt(5, 8) // unsigned decimal 8-bit lit of type UInt

(4)UInt、SInt、Bool类中的重载

UInt、SInt和Bool都不是抽象类,除了可以通过字面量构造对象以外,也可以直接通过apply工厂方法构造没有字面量的对象

UInt和SInt的apply方法有两个版本:

  • 一个版本接收Width类型的参数构造指定宽度的对象
  • 另一个则是无参版本构造位宽可自动推断的对象

有字面量的数据类型用于赋值、初始化寄存器等操作,而无字面量的数据类型则用于声明端口、构造向量等。

2.4 数据类型转换asXXX

UInt、SInt和Bool三个类都包含四个方法:asUInt、asSInt、toBool和toBools:

  • asUInt和asSInt分别把字面值按无符号数和有符号数解释,并且位宽不会变化,要注意转换过程中可能发生符号位和数值的变化。例如,3bit的UInt值“b111”,其字面量是“7”,转换成SInt后字面量就变成了“-1”
  • toBool会把1bit的“1”转换成Bool类型的true,“0”转换成false
  • toBools转换成Bool类型的序列Seq[Bool],当位宽超过1bit
  • 另外,Bool类还有一个方法asClock,把true转换成电压常高的时钟,false转换成电压常低的时钟。Clock类只有一个方法asUInt,转换成对应的0或1

2.5 向量Vec[T]

如果需要一个集合类型的数据,除了可以使用Scala内建的数组、列表、集等数据结构外,还可以使用Chisel专属的Vec[T]。T必须是Data的子类,而且每个元素的类型、位宽为最宽的元素位宽。

两种使用apply工厂创建Vec[T]的方法:

  • Vec(n,type)
    • 创建的所有类型一样
  • VecInit(elt0,elts*)
    • 创建的时候可以用字面量/Seq进行初始化值,比较方便,常用于初始化寄存器组、ROM、RAM、一次性构造多模块

使用Vec[T]工厂方法创建向量:

Vec[T]的伴生对象里有一个apply工厂方法,接收两个参数,第一个是Int类型,表示元素的个数,第二个是元素。它属于可索引的序列,下标从0开始

1
2
val myVec = Wire(Vec(3, UInt(32.W)))//Vec(UInt(32.W),UInt(32.W),UInt(32.W))
val myReg = myVec(0)//访问myVec(0)

创建时也可以用Seq初始化如

1
val my_reg = RegInit(Vec(Seq.fill(n)(0.U(32.W))))

使用VecInit工厂方法创建向量:

  • 接收参数为一个Seq[T]或者是多个重复字面量来构造向量,且常有有字面值的数据作为参数用于初始化寄存器组、ROM、RAM等,或者用来构造多个模块

  • 官网api解释

因为Vec[T]也是一种Seq

  • 使用VecInit的工厂方法可以把Seq类型转为Vec[T]如上图

  • Vec[T]定义了诸如map、flatMap、zip、foreach、filter、exists、contains等方法。尽管这些方法应该出现在软件里,但是它们也可以简化硬件逻辑的编写,减少手工代码量。

2.6 混合向量MixedVec[T]

混合向量MixedVec[T]与普通的向量Vec[T]类似,只不过包含的元素可以不全都一样。它的工厂方法是通过重复参数或者序列作为参数来构造的,并且也有一个叫MixedVecInit[T]的单例对象。

对于构造Vec[T]和MixedVec[T]的序列,并不一定要逐个手写,可以通过Scala的函数,比如fill、map、flatMap、to、until等来生成。例如:

1
val mixVec = Wire(MixedVec((1 to 10) map { i => UInt(i.W) }))

2.7 包裹Bundle

抽象类Bundle很像C语言的结构体(struct),用户可以编写一个自定义类来继承自它,然后在自定义的类里包含其它各种Data类型的字段。它可以协助构建线网或寄存器,但是最常见的用途是用于构建一个模块的端口列表,或者一部分端口

1
2
3
4
5
6
7
8
9
10
11
12
//new Bundle{...}定义并例化了一个继承于Bundle的匿名子类
class MyModule extends Module {
val io = IO(new Bundle {
val in = Input(UInt(32.W))
val out = Output(UInt(32.W))
})

//直接定义了一个Bundle的子类
class MyBundle extends Bundle {
val foo = UInt(4.W) // 高位
val bar = UInt(4.W) // 低位
}

(2)Bundle和UInt转换

Bundle->UInt:Bundle可以和UInt进行相互转换。Bundle类有一个方法asUInt,可以把所含的字段拼接成一个UInt数据,并且前面的字段在高位

1
2
3
4
5
6
7
8
9
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

Data->Bundle:有一个隐式类fromBitsable,可以把Data类型的对象转化成该类型,然后通过方法fromBits来接收一个Bits类型的参数来给该对象赋值。不过,该方法在Chisel3中已经被标注为过时,不推荐使用。例如:

1
2
3
4
5
6
7
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

2.8 Chisel的内建操作符

有了数据类型,还需要预定义一些相关的操作符进行基本的操作。下表是Chisel内建的操作符:

Chisel的内建操作符
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另设了这两个新的操作符。按照优先级的判断准则,“===”和“=/=”的优先级以首个字符为“=”来判断,也就是在逻辑操作中,相等性比较的优先级要比与、或、异或都高。

2.9 位宽推断

用户需要设置端口和寄存器的位宽,除非用户手动设置,否则编译器会自动推测wire上的位宽。位宽推测引擎会从节点图的输入端口开始,并根据以下规则集从它们各自的输入位宽度计算节点输出位宽度:

Chisel的位宽判断
操作符位宽
z = x + y  or  z = x +% yw(z) = max(w(x), w(y))
z = x +& yw(z) = max(w(x), w(y)) + 1
z = x - y or z = x -% yw(z) = max(w(x), w(y))
z = x -& yw(z) = max(w(x), w(y)) + 1
z = x & yw(z) = min(w(x), w(y))
z = Mux(c, x, y)w(z) = max(w(x), w(y))
z = w * yw(z) = w(x) + w(y)
z = x << nw(z) = w(x) + maxNum(n)
z = x >> nw(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)

​ 其中例如wz是wire z的位宽,&规则可应用于所有按位逻辑运算

位宽推测过程:

​ 位宽推测过程会持续到没有位宽改变。 除了通过已知固定数量的右移之外,位宽推测规定了输出位宽度不能小于输入位宽度,因此输出位宽度增长或保持相同。 此外,寄存器的宽度必须由用户明确地或根据复位值或下一个参数的位宽指定。根据这两个要求,我们可以将位宽推测过程将收敛到一个固定点。

有关自动截断的问题:

​ 当把一个短位宽的信号值或硬件结构赋值给长位宽的硬件结构时,会自动扩展符号位。但是反过来会报错,并不是像Verilog那样把多余的高位截断,这需要注意(注:最新的chisel3版本已经可以像Verilog一样自动把高位截断了)

3 硬件类型

本章将介绍Chisel里的常用硬件类型、控制语句、如何编写一个基本的模块,对于高级类型,读者可以自行研究。常用的硬件类型包括:

  • IO
  • Module
  • Wire
  • Reg

关于数据类型和硬件类型的关系可参考3.8总结中的内容

3.0 Verilog与Chisel中的硬件类型

Chisel在构建硬件的思路上类似Verilog。在Verilog中,是以“模块(module)”为基本单位组成一个完整的独立功能实体,所以Chisel也是按模块划分的,只不过不是用关键字“module”开头来定义模块,而是Chisel定义模块用一个继承自Module类的自定义class

Verilog与Chisel中的硬件类型:

  • 在Verilog里,模块内部主要有“线网(wire)”和“四态变量(reg)”两种硬件类型,它们用于描述数字电路的组合逻辑和时序逻辑。

  • 在Chisel里,也按这个思路定义了一些硬件类型,包括基本的线网和寄存器,以及一些常用的其它类型。前一章介绍了Chisel的数据类型,这还不够,因为这些数据类型是无法独立工作的。实际的电路应该是由硬件类型的对象构成的,不管是信号的声明,还是用赋值进行信号传递,都是由硬件类型的对象来完成的。数据类型和硬件类型融合在一起,才能构成完整、可运行的组件。比如要声明一个线网,这部分工作由硬件类型来完成;这个线网的位宽是多少、按无符号数还是有符号数解释、是不是向量等等,这些则是由作为参数的数据类型对象来定义的。

3.1 val与赋值

有了硬件类型后,就可以用赋值操作来进行信号的传递或者电路的连接。只有硬件赋值才有意义,单纯的数据对象进行赋值并不会被编译器转换成实际的电路,因为在Verilog里也是对wire、reg类型的硬件进行赋值。那么,赋值操作需要什么样的操作符来完成呢?

在Chisel里,所有对象都应该由val类型的变量来引用,因为硬件电路的不可变性,因此具有以下两种情况:

  • 硬件电路绑定val后不可变:一个变量一旦初始化时绑定了一个对象,就不能再发生更改

  • 对引用对象赋值(驱动)时可变:但引用的对象,很可能需要被重新赋值。例如,输出端口在定义时使用了“=”与端口变量名进行了绑定,那等到驱动该端口时,就需要通过变量名来进行赋值操作,更新数据。很显然,此时“=”已经不可用了,因为变量在声明的时候不是var类型。即使是var类型,这也只是让变量引用新的对象,而不是直接更新原来的可变对象。

如何对引用对象赋值:

​ 为了解决这个问题,几乎所有的Chisel类都定义了方法:=作为等号赋值的代替。所以首次创建变量时用等号初始化,如果变量引用的对象不能立即确定状态或本身就是可变对象,则在后续更新状态时应该用“:=”。从前面讲的操作符优先级来判断,该操作符以等号结尾,而且不是四种逻辑比较符号之一,所以优先级与等号一致,是最低的。例如:

1
2
3
4
5
6
7
val x = Wire(UInt(4.W))

val y = Wire(UInt(4.W))

x := "b1010".U // 驱动4bit的线网x为无符号数10

y := ~x // 驱动线网y为x的按位取反

3.2 端口、自定义接口、模块接口

定义:

  • 端口:任何为其成员分配了方向的数据对象
  • 自定义端口列表:Bundle的子类
  • 模块接口:Module中的io字段,指向一个IO实例实例

各种类型实现概览:

  • 端口/有方向的数据对象:端口类的原始声明为Input[T <: Data](source: T)Output[T <: Data](source: T)注意,端口复制源数据对象的参数,不能是已经被硬件类型包裹的数据类型。目前Chisel还不支持双向端口inout,只能通过黑盒里的Analog端口来模拟外部Verilog的双向端口。

  • 自定义端口列表/Bundle子类:自定义类,父类超类(自定义接口的子类)是Bundle,内部嵌入了分配方向的数据对象

  • 模块接口/io字段:指向一个IO实例,IO类的原始声明为IO[T <: Data](iodef: T)

3.2.1 如何声明一个自定义的端口列表

(1)定义Bundle的子类

Bundle类作为父类,定义子类完成

1
2
3
4
5
6
//一个声明端口的例子
class Decoupled extends Bundle {
val ready = Output(Bool())
val data = Input(UInt(32.W))
val valid = Input(Bool())
}

也可以是Bundle作为超类,扩展子类

1
2
3
4
5
6
7
8
class SimpleLink extends Bundle {
val data = Output(UInt(16.W))
val valid = Output(Bool())
}
//我们可以通过使用bundle继承添加奇偶校验位来扩展SimpleLink
class PLink extends SimpleLink {
val parity = Output(UInt(5.W))
}

(2)端口列表的嵌套

端口列表嵌套单个端口列表:

1
2
3
4
5
class FilterIO extends Bundle {
//在一个新的 FilterIO 包中嵌套两个 pliks 来定义一个过滤器接口:
val x = Flipped(new PLink)//反转的PLink实例
val y = new PLink//PLink实例
}

端口列表嵌套端口列表向量Bundle Vec

通过Vec构造函数,在端口列表中,嵌套端口列表向量形成了更丰富的层次结构接口。

为了创建一个被Uint输入选择的有多组向量输入输出的mux,我们使用了Vec构造方法(For example, in order to create a crossbar with a vector of inputs, producing a vector of outputs, and selected by a UInt input, we utilize the Vec constructor:)

1
2
3
4
5
6
7
import chisel3.util.log2Ceil
class CrossbarIo(n: Int) extends Bundle {
val in = Vec(n, Flipped(new PLink))
val sel = Input(UInt(log2Ceil(n).W))
val out = Vec(n, new PLink)
// Vec 以 size 作为第一个参数,以返回端口的块作为第二个参数。
}

3.2.2 如何定义端口

如何定义端口:使用两个构造函数,参数是数据类型

  • Input[T <: Data](source: T)
  • Output[T <: Data](source: T)

两种定义端口的示例:

  • 声明时指定方向
1
2
3
4
5
class Decoupled extends Bundle {
val ready = Output(Bool())
val data = Input(UInt(32.W))
val valid = Input(Bool())
}
  • 实例化时指定方向
1
2
3
4
5
class ScaleIO extends Bundle {
val in = new MyFloat().asInput
val scale = new MyFloat().asInput
val out = new MyFloat().asOutput
}

asInputasOutput方法强制数据对象的所有模块指向所请求的方向

端口的特性:

  • 驱动特性:端口输入可以驱动内部其它信号,输出可以被其他信号驱动
  • 赋值特性:端口可以直接进行赋值操作,布尔类型的端口还能直接作为使能信号。
  • 硬件类型:端口不需要再使用其它硬件类型来定义,不过要注意从性质上来说它仍然属于组合逻辑的线网

3.2.3 模块接口

定义:模块接口是模块中固定字段io

固定字段io:

  • io名称固定,是Module类的内部成员
  • io指向了一个自定义接口实例,实例由方法IO[T <: Data](iodef: T)生成

通过io调用端口:一旦模块接口定义完成,就可以通过io.端口x使用

模块接口示例:

1
2
3
4
5
6
7
8
9
10
//声明一个端口类
class MyIO extends Bundle {
val in = Input(Vec(5, UInt(32.W)))
val out = Output(UInt(32.W))
}

......
// 模块的端口列表
val io = IO(new MyIO)
......

3.2.4 翻转端口列表的方向

  • 方法“Flipped[T <: Data](source: T)方法
  • 对于两个相连的模块,可能存在大量同名但方向相反的端口。仅仅为了翻转方向而不得不重写一遍端口显得费时费力,所以Chisel提供了“Flipped[T <: Data](source: T)方法,可以把参数里所有的输入转输出,输出转输入。如果是黑盒里的Analog端口,则仍是双向的。例如:
1
2
3
4
5
6
7
8
9
 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是输出

3.2.5 端口的两种连接方式

一旦我们定义了接口,我们就可以通过如下操作符进行连接

(1)MonoConnect.connect, 或:=, 按元素执行单向连接。

请注意,这不是可交换的。在调用这个函数之前,已经确定了一个显式的source和sink

连接操作将在左数据中递归(使用右数据)。如果通过左侧的移动不能与右侧的移动相匹配,则将引发异常。右侧允许有额外的字段。Vec内部元素必须仍然是完全相同的大小

:=的左值与右值:

  • 左值必须是可写的,因此其中之一必须保持:

    • 是内部可写节点(RegWire

    • 是当前模块的输出

    • 是当前模块的子模块的输入

  • 右值必须是可读的,因此其中之一必须保持:

    • 是内部可读节点 ( Reg, Wire, Op)

    • 是字面的

    • 是当前模块的端口还是当前模块的子模块

(2)BiConnect.connect, 或<>, 按元素执行双向连接

请注意,参数是左和右(不是源和接收器),因此目的是使操作具有可交换性。连接操作将递归左下Data(右Data)。如果左侧的移动无法在右侧匹配,或者右侧有额外的字段,则会引发异常

biconnect<>运算符的用法:

  • 将两个模块组合成一个过滤器块,如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class SimpleLink extends Bundle {
val data = Output(UInt(16.W))
val valid = Output(Bool())
}

class PLink extends SimpleLink {
val parity = Output(UInt(5.W))
}

class FilterIO extends Bundle {
val x = Flipped(new PLink)
val y = new PLink
}

class Filter extends Module {
val io = IO(new FilterIO)
// ...
}

class Block extends Module {
val io = IO(new FilterIO)
val f1 = Module(new Filter)
val f2 = Module(new Filter)
f1.io.x <> io.x
f1.io.y <> f2.io.x
f2.io.y <> io.y
}
  • 连接同名的子端口时,对Bundle 的 Scala 类型不需要匹配。如果任一侧缺少一个命名信号,Chisel 将给出错误,如下例所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class NotReallyAFilterIO extends Bundle {
val x = Flipped(new PLink)
val y = new PLink
val z = Output(new Bool())
}
class Block2 extends Module {
val io1 = IO(new FilterIO)
val io2 = IO(Flipped(new NotReallyAFilterIO))

io1 <> io2
}

//执行
ChiselStage.emitVerilog(new Block2)
//执行后发生错误,错误提示如下:
// chisel3.internal.ChiselException: Connection between left (Block2.io1: IO[FilterIO]) ...

注意事项:

  • 双向连接只能与定向元素(如 IO)一起使用,例如不支持连接两条线,因为 Chisel 不一定能自动确定方向。例如,在此处放置两条临时电线并将它们连接起来是行不通的,即使可以从端点知道方向:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class BlockWithTemporaryWires extends Module {
val io = IO(new FilterIO)
val f1 = Module(new Filter)
val f2 = Module(new Filter)
f1.io.x <> io.x
val tmp1 = Wire(new FilterIO)
val tmp2 = Wire(new FilterIO)
f1.io.y <> tmp1
tmp1 <> tmp2
tmp2 <> f2.io.x
f2.io.y <> io.y
}

//执行
ChiselStage.emitVerilog(new BlockWithTemporaryWires)
//执行后发生错误,错误结果提示:
// chisel3.internal.ChiselException: Connection between left (Filter.io.y: IO[PLink]) ...

有关更多详细信息和信息,请参阅深入了解连接运算符

注意:当使用Chisel._(兼容模式) 而不是 时chisel3._:=运算符以类似于 的双向方式工作<>,但不完全相同。

3.3 模块

3.3.1 模块的特性

在Chisel里面是用一个自定义的类来定义模块的,这个类有以下三个特点:

  • 继承自Module类

  • 有一个抽象字段io需要实现,该字段必须引用前面所说的端口对象。

  • 在类的主构造器里进行内部电路连线。因为非字段、非方法的内容都属于主构造方法,所以用操作符“:=”进行的赋值、用“<>”进行的连线或一些控制结构等等,都属于主构造方法。

    • 从Scala的层面来讲,这些代码在实例化时表示如何构造一个对象;

      从Chisel的层面来讲,它们就是在声明如何进行模块内部子电路的连接、信号的传递,类似于Verilog的assign和always语句。实际上这些用赋值表示的电路连接在转换成Verilog时,组合逻辑就是大量的assign语句,时序逻辑就是always语句

自定义类所继承Module类的两个字段clock、reset:

  • clock类型是Clock,它表示全局时钟,在整个模块内都可见。对于组合逻辑,是用不上它的,而时序逻辑虽然需要这个时钟,但也不用显式声明。
  • reset类型是Reset,表示全局复位信号,在整个模块内可见。对于需要复位的时序元件,也可以不用显式使用该字段。如果确实需要用到全局时钟和复位,则可以通过它们的字段名称来使用,但要注意类型是否匹配,经常需要“reset.toBool”这样的语句把Reset类型转换成Bool类型用于控制。隐式的全局时钟和复位端口只有在生成Verilog代码时才能看到。

要编写一个双输入多路选择器,其代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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 := …”其实就是主构造方法的一部分,通过内建操作符和三个输入端口,实现了输出端口的逻辑行为

3.3.2 例化模块

例化模块包括两个步骤:

  • 用new生成一个实例对象就完成了
    • new 自定义模块类
  • 还需要再把实例的对象传递给单例对象Module的apply方法
    • Module(自定义模块对象实例)

​ 这种别扭的语法是Scala的语法限制造成的,就像端口需要写成IO(new Bundle {...}),无符号数要写成UInt(n.W)等等一样。

例如,下面的代码通过例化刚才的双输入多路选择器构建四输入多路选择器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 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
}

3.3.3 一次性例化多个模块

像上个例子中,模块Mux2例化了三次,实际只需要一次性例化三个模块就可以了

例化多个模块的原理

  • 通过向量的工厂方法VecInit[T <: Data]进行多个模块接口的例化
  • VecInit的apply方法的参数
    • 使用序列作为参数(用的多)
      • 使用单例对象Seq里的方法fill进行构造,把多个模块接口整合到序列中
    • 使用重复参数(用的少)

对于要多次例化的重复模块,可以利用向量的工厂方法VecInit[T <: Data]。因为该方法接收的参数类型是Data的子类,而模块的字段io正好是Bundle类型,并且实际的电路连线仅仅只需针对模块的端口,所以可以把待例化模块的io字段组成一个序列,或者按重复参数的方式作为参数传递。

通常使用序列作为参数,这样更节省代码。生成序列的一种方法是调用单例对象Seq里的方法fill,该方法的一个重载版本有两个单参数列表,第一个接收Int类型的对象,表示序列的元素个数,第二个是传名参数,接收序列的元素。

注意:

  • Vec元素是模块接口字段,因此无需经过io引用,并且可直接通过下表索引

因为Vec是一种可索引的序列,所以这种方式例化的多个模块类似于“模块数组”,用下标索引第n个模块。

因为Vec的元素已经是模块的端口字段io,所以要引用例化模块的某个具体端口时,路径里不用再出现“io”。

示例代码如下:的:::

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// mux4_2.scala
package test

import chisel3._
class Mux4_2 extends Module {
//io绑定接口实例
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))
})
//从Module构造出Seq,再由序列构造为向量,并把向量每一个元素(这里是指模块实例)做例化
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
}

3.4 线网

定义线网的方法:Chisel把线网作为电路的节点,通过工厂方法来定义

  • Wire[T <: Data](t: T)

线网的赋值、连接:

  • 可以对线网进行赋值,也可以连接到其他电路节点,这是组成组合逻辑的基本硬件类型。例如:
1
2
val myNode = Wire(UInt(8.W))
myNode := 0.U
  • 对线网的多次驱动是可覆盖的:
1
2
3
4
val myNode = Wire(UInt(8.W))
myNode := 10.U
myNode := 0.U
//最终myNode表现为0,10被覆盖

3.5 寄存器

寄存器特性:

  • 寄存器是时序逻辑的基本硬件类型,它们都是由当前时钟域的时钟上升沿触发的。
  • 如果模块里没有多时钟域的语句块,那么寄存器都是由隐式的全局时钟来控制。对于有复位信号的寄存器,如果不在多时钟域语句块里,则由隐式的全局复位来控制,并且高有效。
  • 目前Chisel所有的复位都是同步复位,异步复位功能还在开发中。如果需要异步复位寄存器,则需要通过黑盒引入。

五种内建的寄存器:init复位、next输入、t位宽、enable是能

  • 跟随寄存器RegNext[T <: Data](next: T)RegNext[T <: Data](next: T, init: 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。

假如有如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 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
}
//regx(n) n表示寄存器位数
io.c := reg0(0) & reg1(0) & reg2(0) & reg3(0) & reg4(0) & reg5(0) & reg6(0) & reg7(0) & reg8(0)
}

对应生成的主要Verilog代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
// 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

3.6 寄存器组

五种内建寄存器的工厂方法,它们的参数可以是任何Data的子类型。如果把子类型Vec[T]作为参数传递进去(vec[T]可通过VecInit创建),就会生成多个位宽相同、行为相同、名字前缀相同的寄存器。同样,寄存器组在Chisel代码里可以通过下标索引

假如有如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 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代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
// 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

3.7 用when给电路赋值(if else if else)

在Verilog里,可以使用“if…else if…else”这样的条件选择语句来方便地构建电路的逻辑。由于Scala已经占用了“if…else if…else”语法,所以相应的Chisel控制结构改成了when语句,其语法如下:

1
2
3
4
5
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作为复位条件

unless结构:

  • 除了when结构,util包里还有一个与之对偶的结构unless如果unless的判定条件为false.B则一直执行,否则不执行:
1
2
3
import chisel3.util._

unless (condition) { definition }

3.8 总结:数据类型与硬件类型的区别

前一章介绍了Chisel的数据类型,其中常用的就五种:UInt、SInt、Bool、Bundle和Vec[T]。本章介绍了硬件类型,最基本的是IO、Wire和Reg三种,还有指明端口方向的Input、Output和Flipped。Module是沿袭了Verilog用模块构建电路的规则,不仅让熟悉Verilog/VHDL的工程师方便理解,也便于从Chisel转化成Verilog代码。

数据类型必须配合硬件类型才能使用,它不能独立存在,因为编译器只会把硬件类型生成对应的Verilog代码。从语法规则上来讲,这两种类型也有很大的区别,编译器会对数据类型和硬件类型加以区分。尽管从Scala的角度来看,硬件类型对应的工厂方法仅仅是“封装”了一遍作为入参的数据类型,其返回结果没变,比如Wire的工厂方法定义为:

1
def apply[T <: Data](t: T)(implicit sourceInfo: SourceInfo, compileOptions: CompileOptions): T

可以看到,入参t的类型与返回结果的类型是一样的,但是还有配置编译器的隐式参数,很可能区别就源自这里。

但是从Chisel编译器的角度来看,这两者就是不一样。换句话说,硬件类型就好像在数据类型上“包裹了一层外衣(英文原文用单词binding来形容)”。比如,线网“Wire(UInt(8.W))”就像给数据类型“UInt(8.W)”包上了一个“Wire( )”。所以,在编写Chisel时,要注意哪些地方是数据类型,哪些地方又是硬件类型。这时,静态语言的优势便体现出来了,因为编译器会帮助程序员检查类型是否匹配。如果在需要数据类型的地方出现了硬件类型、在需要硬件类型的地方出现了数据类型,那么就会引发错误。程序员只需要按照错误信息去修改相应的代码,而不需要人工逐个检查。

例如,在前面介绍寄存器组的时候,示例代码里的一句是这样的:

1
val reg0 = RegNext(VecInit(io.a, io.a)) 

读者可能会好奇为什么不写成如下形式:

1
val reg0 = RegNext(Vec(2, io.a)) 

如果改成这样,那么编译器就会发出如下错误:

1
[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——chiselTypeOfT <: Data: T,其作用就是把硬件类型的“封皮”去掉,变成纯粹的数据类型。因此,读者可能会期望如下代码成功:

1
val reg0 = RegNext(Vec(2, chiselTypeOf(io.a)))  

但是编译器仍然发出了错误信息:

1
[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库里定义的常用原语,有了这些原语就能更快速地构建电路,而不需要只用这些基本类型来搭积木。

4 常用的硬件原语

前两章介绍了基本的数据类型和硬件类型,已经足够编写基本的小规模电路。至于要如何生成Verilog,会在后续章节讲解。如果要编写大型电路,当然也可以一砖一瓦地搭建,但是费时费力,完全体现不出软件语言的优势。Chisel在语言库里定义了很多常用的硬件原语,读者可以直接导入相应的包来使用。让编译器多干活,让程序员少费力,本章介绍的常用原语有:

  • 多路选择器
  • ROM
  • RAM
  • 带写掩膜的RAM
  • 从文件读取到RAM
  • 计数器
  • 16位线性反馈移位寄存器
  • 状态机(不是原语)

本章介绍了Chisel内建的常用原语,还有更多原语可以使用,比如Bundle衍生的几种端口类,读者可以通过查询API或源码来进一步了解。

4.1 多路选择器

因为多路选择器是一个很常用的电路模块,所以Chisel内建了几种多路选择器

  • 二输入多路选择器(Mux),形式为Mux(sel, in1, in2):sel是Bool类型,in1和in2的类型相同,都是Data的任意子类型。当sel为true.B时,返回in1,否则返回in2

    • 因为Mux仅仅是把一个输入返回,所以Mux可以内嵌Mux,构成n输入多路选择器(嵌套Mux),类似于嵌套的三元操作符。其形式为Mux(c1, a, Mux(c2, b, Mux(..., default)))
  • n输入多路选择器的简便写法(MuxCase),MuxCase在chisel3.util包里,形式为MuxCase(default, Array(c1 -> a, c2 -> b, ...)),它的展开与嵌套的Mux是一样的:第一个参数是默认情况下返回的结果,第二个参数是一个数组,数组的元素是对偶(成立条件,被选择的输入)

  • 查找表(MuxLookup),第三种是MuxCase的变体也在chisel3.util包里,它相当于把MuxCase的成立条件依次换成从0开始的索引值像一个查找表,其形式为MuxLookup(idx, default, Array(0.U -> a, 1.U -> b, ...))。它的展开相当于MuxCase(default, Array((idx === 0.U) -> a, (idx === 1.U) -> b, ...))
  • 独热码多路选择器(Mux1H):第四种是chisel3.util包里的,它的选择信号是一个独热码。如果零个或多个选择信号有效,则行为未定义。其形式如下
1
2
3
4
5
6
val hotValue = Mux1H(Seq(
io.selector(0) -> 2.U,
io.selector(1) -> 4.U,
io.selector(2) -> 8.U,
io.selector(4) -> 11.U
))

Chisel与Verilog带来的思考:

  • 内建的多路选择器会转换成Verilog的三元操作符? :,这对于构建组合逻辑而言是完全足够的,而且更推荐这种做法,
  • when语句常用于给寄存器赋值,而很少用来给线网赋值

  • always的缺点与为什么要使用assign:读者可能习惯用always语句块来编写电路,但这存在一些问题:

    • always既可以综合出时序逻辑又能综合出组合逻辑,导致reg变量存在二义性,常常使得新手误解reg就是寄存器
    • if…else if…else不能传播控制变量的未知态x(某些EDA工具可以),使得仿真阶段无法发现一些错误,但是assign语句会在控制变量为x时也输出x
    • 工业级的Verilog,都是用assign语句来构建电路。时序逻辑也是通过例化触发器模块来完成的,相应的端口都是由assign来驱动,而且触发器会使用SystemVerilog的断言来寻找always语句里的x和z整个设计应该尽量避免使用always语句。

4.2 ROM

可以用VecInit的工厂方法来创建一个只读存储器

  • VecInit的两种形式(前面提到过):

    • VecInit[T <: Data](elt0: T, elts: T*):elt0和elts类型/字面量保持一致即可
    • VecInit[T <: Data](elts: Seq[T]):从seq序列中创建
  • 参数就是ROM里的常量数值,对应的Verilog代码就是给读取ROM的线网或寄存器赋予常量值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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]索引需要注意的内容:

在这个例子里需要提的一点是,Vec[T]类的apply方法不仅可以接收Int类型的索引值,另一个重载版本还能接收UInt类型的索引值。所以对于承担地址、计数器等功能的部件,可以直接作为由Vec[T]构造的元素的索引参数,比如这个例子中根据sel端口的值来选择相应地址的ROM值。

4.3 RAM

Chisel支持两种类型的RAM:

  • 第一种RAM是同步(时序)写,异步(组合逻辑)读,通过工厂方法Mem[T <: Data](size: Int, t: T)来构建。例如:

    1
    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的读地址会被地址寄存器寄存一次。例如:

    1
    val syncMem = SyncReadMem(16, UInt(32.W))

写RAM的语法是:

1
2
3
4
when(wr_en) {
mem.write(address, dataIn)
out := DontCare
}
  • 其中DontCare告诉Chisel的未连接线网检测机制,写入RAM时读端口的行为无需关心(说人话就是out悬空的时候的一种表达方式)

读RAM的语法是:

1
out := mem.read(address, rd_en)

读、写使能信号都可以省略

综合器综合出的SRAM可能是寄存器阵列:

​ 要综合出实际的SRAM,读者最好了解自己的综合器是如何推断的,按照综合器的推断规则来编写模块的端口定义、时钟域划分、读写使能的行为等等,否则就可能综合出寄存器阵列而不是SRAM。以Vivado 2018.3为例,下面的单端口SRAM代码经过综合后会映射到FPGA上实际的BRAM资源,而不是寄存器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 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)//写RAM
io.dataOut := DontCare//输出悬空
} .otherwise {
io.dataOut := syncRAM.read(io.addr)//读RAM到输出
}
} .otherwise {
io.dataOut := DontCare//输出悬空
}
}

​ 下面是Vivado综合后的部分截图,可以看到确实变成了实际的BRAM:

Vivado综合出来的单端口BRAM

双端BRAM的Verilog->Chisel->Verilog后无法识别出SRAM:

​ Vivado的BRAM最多支持真·双端口,按照对应的Verilog模板逆向编写Chisel,然后用编译器把Chisel转换成Verilog。但此时编译器生成的Verilog代码并不能被Vivado的综合器识别出来。

原因在于SyncReadMem生成的Verilog代码是用一级寄存器保存输入的读地址,然后用读地址寄存器去异步读取RAM的数据,而Vivado的综合器识别不出这种模式的RAM。读者必须手动修改成用一级寄存器保存异步读取的数据而不是读地址,然后把读数据寄存器的内容用assign语句赋值给读数据端口,这样才能被识别成真·双端口BRAM。尚不清楚其它综合器是否有这个问题。经过咨询SiFive的工作人员,对方答复因为当前转换的代码把延迟放在地址一侧,所以流水线的节拍设计也是根据这个来的。考虑到贸然修改SyncReadMem的行为,可能会潜在地影响其它用户对流水线的设计,故而没有修改计划。如果确实需要自定义的、对综合器友好的Verilog代码,可以使用黑盒功能替代,或者给Firrtl编译器传入参数,改用自定义脚本来编译Chisel。

4.4 带写掩模的RAM

写掩膜:

​ RAM通常都具备按字节写入的功能,比如数据写入端口的位宽是32bit,那么就应该有32b(4bit)的写掩模信号,只有当写掩模比特有效时,对应的字节才会写入。Chisel也具备构建带写掩模的RAM的功能

Chisel中的实现:

  • 当构建RAM的数据类型为Vec[T]时,就会推断出该RAM具有写掩模
1
val ram = SyncReadMem(size, Vec(Num, Type(Width)))
  • 需要定义一个Seq[Bool]类型的写掩模信号序列的元素个数为数据写入端口的位宽除以字节宽度
1
val mask = Wire(Vec(DataWidth/ByteWitdth, Bool()))
  • write方法有一个重载版本,就是第三个参数是接收写掩模信号的。当下标为0的写掩模比特是true.B时,最低的那个字节会被写入,依次类推
1
2
val dataIn_temp = Wire(和定义ram时的Type(Width)一样)
ram.write(addr, dataIn_temp, mask)

是一个带写掩模的单端口RAM:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 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:

​ 读、写端口和写掩模可以不用定义成一个UInt,也可以是Vec[UInt],这样定义只是为了让模块对外只有一个读端口、一个写端口和一个写掩模端口

使用Vec[T]数据类型定义RAM的效果:

​ 注意,编译器会把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

4.5 从文件读取数据到RAM

在experimental包里有一个单例对象loadMemoryFromFile,它的apply方法可以在Chisel层面上从txt文件读取数据到RAM里。

导入方式:

1
import chisel3.util.experimental.loadMemoryFromFile

其定义如下所示:

1
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/b

​ 该方法其实就是调用Verilog的系统函数$readmemh”和“$readmemb

文件路径:

​ 该方法其实就是调用Verilog的系统函数$readmemh”和“$readmemb所以要注意文件路径的书写和数据的格式都要按照Verilog的要求书写。最好把数据文件放在resources文件夹里。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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)) //定义了一个asyncMem
io.value := memory.read(io.address)
loadMemoryFromFile(memory, "~/chisel-workspace/chisel-template/mem.txt")
}

那么就会得到两个Verilog文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 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代码,从文件读取数据

4.6 计数器

计数器也是一个常用的硬件电路。Chisel在util包里定义了一个自增计数器原语Counter

导入方式:

1
import chisel3.util._

Counter工厂方法接收两个参数的版本:

  • 第一个参数是Bool类型的使能信号,为true.B时计数器从0开始每个时钟上升沿加1自增,为false.B时则计数器保持不变
  • 第二个参数需要一个Int类型的具体正数
  • 当计数到该值时归零。该方法返回一个二元组,其第一个元素是计数器的计数值,第二个元素是判断计数值是否等于期望值的结果。

Counter工厂方法接收一个参数的重载版本没有使能信号

有如下示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 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代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 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

4.7 16位线性反馈移位寄存器

如果要产生伪随机数,可以使用util包里的16位线性反馈移位寄存器原语LFSR16

导入方式:

1
import chisel3.util._

其定义如下所示:

  • LFSR16接收一个Bool类型的使能信号,用于控制寄存器是否移位,缺省值为true.B
  • 返回一个UInt(16.W)类型的结果

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 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

4.8 用于初始化状态位的不重复枚举

状态机也是常用电路,但是Chisel没有直接构建状态机的原语

不过,util包里定义了一个Enum特质及其伴生对象

导入方式:

1
import chisel3.util._

伴生对象里的apply方法定义如下:

1
def apply(n: Int): List[UInt]
  • 根据参数n返回对应元素数的List[UInt],每个元素都是不同的,所以可以作为枚举值来使用
  • 最好把枚举状态的变量名也组成一个列表,然后用列表的模式匹配来进行赋值。有了枚举值后,可以通过switch…is…is语句来使用。其中,switch里是相应的状态寄存器,而每个is分支的后面则是枚举值及相应的定义

例如检测持续时间超过两个时钟周期的高电平:

注意,枚举状态名的首字母要小写,这样Scala的编译器才能识别成变量模式匹配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 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())
})
//sNone :: sOne1 :: sTwo1s :: Nil等价于List(sNone, sOne1, sTwo1s)
//下面语句的主要作用使用过Enum的伴生对象的apply方法对上面三个val赋初值
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
}
}
}
}

它生成的Verilog为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 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

5 生成Verilog与基本测试

经过前三章的内容,读者已经了解了如何使用Chisel构建一个基本的模块。

本章的内容就是在此基础上,把一个Chisel模块编译成Verilog代码,并进一步使用Verilator做一些简单的测试。

5.1 生成Verilog

Scala程序的入口是主函数。所以,生成Verilog的程序自然是在主函数里例化待编译的模块,然后运行这个主函数。例化待编译模块需要特殊的方法调用。chisel3包里有一个单例对象Driver,它包含一个方法execute,该方法接收两个参数,第一个参数是命令行传入的实参即字符串数组args,第二个是返回待编译模块的对象的无参函数。运行这个execute方法,就能得到Verilog代码

  • Scala主函数作为入口

    • Scala主函数内待例化的verilog模块
      • 例化时使用到chisel3包里的单例对象Driver的execute方法得到verilog代码
        • execute接收两个参数:arg1为命令行传入的字符串数组args;arg2为返回待编译模块对象的无参函数

假设在src/main/scala文件夹下有一个全加器的Chisel设计代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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文件夹下编写对应的主函数文件,如下所示:

1
2
3
4
5
6
7
// fullAdderGen.scala
package test

object FullAdderGen extends App {
chisel3.Driver.execute(args, () => new FullAdder)//只有一个execute函数调用
//还可以继续写execute
}

test主函数分析:

  • 主函数:在这个主函数里,只有一个execute函数的调用,主函数里可以包括多个execute函数,也可以包含其它代码。
  • execute参数:
    • 第一个参数固定是args
    • 第二个参数则是无参的函数字面量() => new FullAdder,因为Chisel的模块本质上还是Scala的class**,所以只需用new构造一个对象作为返回结果即可
  • 还有一点要注意的是,建议把设计文件和主函数放在一个包里,比如这里的“package test”,这样省去了编写路径的麻烦。
  • 要运行这个主函数,需要在build.sbt文件所在的路径下打开终端,然后执行命令:
1
esperanto@ubuntu:~/chisel-template$ sbt 'test:runMain test.FullAdderGen'

sbt命令与结果解析

  • sbt命令:sbt后面有空格,再后面的内容都是被单引号对或双引号对包起来
  • sbt执行主函数的命令:test:runMain是让sbt执行主函数的固定命令
  • 主函数:test.FullAdderGen就是要执行的那个主函数
    • test为主函数的package
    • 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文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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代码,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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代码非常接近。这种代码风格虽然不方便人工阅读,但是适合语法分析脚本使用

5.2 在命令里增加参数

5.2.1 给Firrtl传递参数

在运行主函数时,可以在刚才的命令后面继续增加可选的参数。例如,增加参数--help查看帮助菜单,运行命令:

1
esperanto@ubuntu:~/chisel-template$ sbt 'test:runMain test.FullAdderGen --help'

可以得到如下帮助信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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,可以在后面指定一个文件夹,这样之前生成的三个文件就在该文件夹里,而不是在当前路径下。其格式如下:

1
esperanto@ubuntu:~/chisel-template$ sbt 'test:runMain test.FullAdderGen -td ./generated/fulladder' 

5.2.2 给主函数传递参数

Scala的类可以接收参数,自然Chisel的模块也可以接收参数。假设要构建一个n位的加法器,具体位宽不确定,根据需要而定。那么,就可以把端口位宽参数化,例化时传入想要的参数即可。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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)
}
1
2
3
4
5
6
// adderGen.scala
package test

object AdderGen extends App {
chisel3.Driver.execute(args, () => new Adder(args(0).toInt))
}
  • 模块Adder的主构造方法接收一个Int类型的参数n,然后用n去定义端口位宽

  • 主函数在例化这个模块时,就要给出相应的参数

  • sbt命令可接受若干独立参数:前面的帮助菜单里显示,在运行sbt命令时,可以传入若干个独立的参数和运行Scala的主函数一样,这些命令行的参数也可以由字符串数组args通过下标来索引。从要运行的主函数后面开始,后面的内容都是按空格划分、从下标0开始的args的元素。比如例子中的主函数期望第一个参数即args(0)是一个数字字符串,这样就能通过方法toInt转换成Adder所需的参数。

sbt编译时:

1
esperanto@ubuntu:~/chisel-template$  sbt 'test:runMain test.AdderGen 8 -td ./generated/adder'
  • test.AdderGen 8:把参数8传入package.class,即传入主函数

可以在相应的文件夹下得到如下Verilog代码,其中位宽的确是8位的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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

5.3 编写简单的测试

5.3.1 testbench

Chisel的测试有两种:

  • 第一种:Scala验证Chisel。是利用Scala的测试来验证Chisel级别的代码逻辑有没有错误。因为这部分内容比较复杂,而且笔者目前也没有深入学习有关Scala测试的内容,所以这部分内容可有读者自行选择研究
  • 第二种:Chisel的peek和poke加激励,Verilator跑仿真。是利用Chisel库里的peek和poke函数,给模块的端口加激励、查看信号值,并交由下游的Verilator来仿真、产生波形。这种方式比较简单,类似于Verilog的testbench,适合小型电路的验证。
  • 第三种:Chisel生成Verilog交给成熟的EDA工具验证。对于超大型的系统级电路,最好还是生成Verilog,交由成熟的EDA工具,用UVM进行验证

如何编写一个testbench:

  • 定义一个类作为testbench:这个类接受参数为dut_class_name继承于PeekPokeTester的类,同时把dut_class_name也传递给PeekPokeTeste。

要编写一个简单的testbench,首先也是定义一个类,这个类的主构造方法接收一个参数,参数类型就是待测模块的类名。因为模块也是一个类,从Scala的角度来看,一个类就是定义了一种类型。其次,这个类继承自PeekPokeTester类,并且把接收的待测模块也传递给此超类。

注: 关于测试包的说明

现在已经推了chiseltest包,可以用其进行测试,写peekpoke更简洁而且数据类型也更严谨了。之后,会再研究一下!!!下面先简单看下使用chiseltest和使用iotesters在代码风格上的不同:

img

可以看出,这些方法已经变成了每个IO的属性!!!

5.3.2 使用chisel3.iotesters

要编写一个简单的testbench,首先也是定义一个类,这个类的主构造方法接收一个参数,参数类型就是待测模块的类名。因为模块也是一个类,从Scala的角度来看,一个类就是定义了一种类型。

其次,这个类继承自chisel3.iotesters.PeekPokeTester类,并且把接收的待测模块也传递给此超类。最后,测试类内部有四种方法可用:

最后,测试类内部有四种方法可用:

  • “poke(端口,激励值)”方法给相应的端口添加想要的激励值,激励值是Int类型的
  • “peek(端口)”方法返回相应的端口的当前值;
  • “expect(端口,期望值)”方法会对第一个参数(端口)使用peek方法,然后与Int类型的期望值进行对比,如果两者不相等则出错;
  • “step(n)”方法则让仿真前进n个时钟周期。

测试文件可以使用高级语法:

​ 因为测试模块只用于仿真,无需转成Verilog,所以类似for、do…while、to、until、map等Scala高级语法都可以使用,帮助测试代码更加简洁有效。

如下所示是一个对前一例中的8位加法器的testbench:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 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

5.3.3 使用chiseltest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// addertest.scala
//包定义
package test

//特质ChiselScalatestTester在该包下,并且定义了需要使用的test方法
import chiseltest._
//FlatSpec在该包下
import org.scalatest._

class AdderTest extends FlatSpec with ChiselScalatestTester{
behavior of "MyModule"
// test class body here
it should "do something" in {
//test case body here
test(new Adder(8)) { c =>
//test body here
val randNum = new Random
for(i <- 0 until 10) {
val a = randNum.nextInt(256)
val b = randNum.nextInt(256)
c.io.a.poke(a.U)
c.io.b.poke(b.U)
c.clock.step(1)
c.io.s.expect(((a + b) & 0xff).U)
c.io.cout.expect((((a + b) & 0x100) >> 8).U)
}
}
}
}

5.3.4 原始文档中使用的iotester

如下所示是一个对前一例中的8位加法器的testbench:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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。

5.4 运行测试

5.4.1 运行chisel3.iotesters写的测试

要运行测试,自然也是通过主函数,但是这次是使用chisel3.iotesters包里的execute方法

该方法与前面生成Verilog的方法类似,仅仅是多了一个参数列表,多出的第二个参数列表接收一个返回测试类的对象的函数

1
2
3
4
5
6
7
// addertest.scala
object AdderTestGen extends App {
chisel3.iotesters.Driver.execute(args, () => new Adder(8))(c => new AdderTest(c))
//与前面verilog生成对比
//chisel3.iotesters.Driver.execute(args, () => new Adder(8))(c => new AdderTest(c)) //testbench
//chisel3.Driver.execute(args, () => new Adder(8)) //compile verilog
}

运行如下命令(需要安装verilator):

1
sbt 'test:runMain test.AdderTestGen -td ./generated/addertest --backend-name verilator' 

仿真文件目录:

​ 执行成功后,就能在相应文件夹里看到一个新生成的文件夹,里面是仿真生成的文件。波形文件为Adder.vcd文件,使用GTKWave软件打开就能查看,将相应的端口拖拽到右侧就能显示波形

终端查看仿真信息:

​ 如果只想在终端查看仿真运行的信息,则执行命令:

1
sbt 'test:runMain test.AdderTestGen -td ./generated/addertest --is-verbose' 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
[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

5.4.2 运行chiseltest写的测试

运行命令:

​ 在使用chiseltest的代码中,如果AluTester继承了FlatSpec,那么即使定义成了class(正常情况下,在scala中主函数必须定义在object中),该段代码还是可以运行,只不过运行方式和之前不一样了,写法如下:

1
sbt 'testOnly test.AdderTest '

如果想要生成vcd文件,写法如下:

1
sbt 'testOnly test.AdderTest -- -DwriteVcd=1'

终端运行查看仿真:

  • 注意此时不能使用test:runMain来运行了,因为只有定义成object才可以这样被运行,class不行。但是如果还想这样运行,那就必须改成如下形式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package test
//特质ChiselScalatestTester在该包下,并且定义了需要使用的test方法
//peek poke等方法也定义在了该包下,并且定义了隐式转换,所以我们可以直接.xxxx调用
import chiseltest._
import chiseltest.RawTester.test

object AdderTest extends App{

//test case body here
test(new Adder(8)) { c =>
//test body here
......

}
}
  • 然后在命令行执行以下命令即可:
1
sbt 'test:runMain test.AdderTest ' 
  • 注意,不能简单的只将class修改为object,然后再混入App。因为这样虽然不报错,但是好像不工作。如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package test
//特质ChiselScalatestTester在该包下,并且定义了需要使用的test方法
//peek poke等方法也定义在了该包下,并且定义了隐式转换,所以我们可以直接.xxxx调用
import chiseltest._
//FlatSpec在该包下
import org.scalatest._

object AdderTest extends FlatSpec with ChiselScalatestTester with App{
behavior of "MyModule"
// test class body here
it should "do something" in {
//test case body here
test(new Adder(8)) { c =>
//test body here
......
}
}
}

5.4.3 原始文档中的运行测试

要运行测试,自然也是通过主函数,但是这次是使用iotesters包里的execute方法。该方法与前面生成Verilog的方法类似,仅仅是多了一个参数列表,多出的第二个参数列表接收一个返回测试类的对象的函数:

1
2
3
4
// addertest.scala
object AdderTestGen extends App {
chisel3.iotesters.Driver.execute(args, () => new Adder(8))(c => new AdderTest(c))
}

运行如下命令:

1
esperanto@ubuntu:~/chisel-template$  sbt 'test:runMain test.AdderTestGen -td ./generated/addertest --backend-name verilator' 

执行成功后,就能在相应文件夹里看到一个新生成的文件夹,里面是仿真生成的文件。其中,“Adder.vcd”文件就是波形文件,使用GTKWave软件打开就能查看,将相应的端口拖拽到右侧就能显示波形。

如果只想在终端查看仿真运行的信息,则执行命令:

1
esperanto@ubuntu:~/chisel-template$  sbt 'test:runMain test.AdderTestGen -td ./generated/addertest --is-verbose' 

那么终端就会显示如下信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
[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

5.5 总结

本章介绍了从Chisel转换成Verilog、测试设计的基本方法。因为Chisel还在更新中,这些方法也是从Chisel2里保留下来的。将来也许会有更便捷的方式,读者可以留意

6 黑盒BlackBox

因为Chisel的功能相对Verilog来说还不完善,所以设计人员在当前版本下无法实现的功能,就需要用Verilog来实现。在这种情况下,可以使用Chisel的BlackBox功能

黑盒的作用:

​ 它的作用就是向Chisel代码提供了用Verilog设计的电路的接口,使得Chisel层面的代码可以通过模块的端口来进行交互

本章介绍了三种黑盒的用法,其目的在于通过外部的Verilog文件来补充Chisel还没有的功能。除此之外,由于还没有EDA工具直接支持Chisel,比如在开发FPGA项目时,要例化Xilinx或Altera的IP,就需要用到黑盒。

本章提到的类、特质、方法:

  • 例化黑盒时用到的类与方法:

    • BlackBox类:作为父类

    • Map[String, Param]类:传入BlackBox类作为参数配置

    资源文件中带有 Verilog 的黑盒

    • HasBlackBoxResoure特质(chisel3.util包)
      • 方法setResoure(“path”)
    • HasBlackBoxPath特质
      • 方法addPath(“path”)
    • HasBlackBoxInline特质
      • 方法setInline(blackBoxName: String, blackBoxInline: String)
    • 字符串类的方法:stripMargin

    inout端口

    • 类Analog(chisel3.experimental._包)

6.1 例化黑盒

注意本节只讲如何例化黑盒,并未提及如何与verilog模块连接,黑盒是指一个内部表达与verilog相同的模块

(1)verilog的模块参数声明在chisel中造成的问题

​ 如果读者尝试在Chisel的模块里例化另一个模块,然后生成Verilog代码,就会发现端口名字里多了“io_”这样的字眼。很显然,这是因为Chisel要求模块的端口都是由字段“io”来引用的,语法分析脚本在生成Verilog代码时会保留这个端口名前缀。

  • 假设有一个外部的Verilog模块,它的端口列表声明如下:
1
2
//一个外部的verilog模块
module Dut ( input [31: 0] a, input clk, input reset, output [3: 0] b );
  • 按照Verilog的语法,它的例化代码应该是这样的:
1
2
3
4
//上述模块在verilog中例化的语法
Dut u0 ( .a(u0_a), .clk(u0_clk), .reset(u0_reset), .b(u0_b) );
//例化时的名字和连接的线网名是可以任意的
//模块名“Dut”和端口名“.a”、“.clk”、“.reset”、 “.b”是固定的。
  • 把这个Verilog模块声明成普通的Chisel模块,然后直接例化使用,那么例化的Verilog代码就会变成:
1
2
3
//上述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”。
  • 产生的问题:
    • 原来verilog的模块参数,经过chisel再编译为verilog后,从“.a”,变成了“.io_a”
    • 这样做首先在Chisel层面上就不会成功,因为Chisel的编译器不允许模块内部连线为空,不能只有端口声明而没有内部连线的模块

(2)如何使用黑盒生成verilog风格模块

​ 如果定义Dut类时,不是继承自Module,而是继承自BlackBox,则允许只有端口定义,也只需要端口定义。此外,在别的模块里例化黑盒时,编译器不会给黑盒的端口名加上“io_”,连接的线网名变成引用黑盒的变量名与黑盒端口名的组合。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 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代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 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工具使用。

(3)黑盒如何表示parameter

​ BlackBox的构造方法可以接收一个Map[String, Param]类型的参数,这会使得例化外部的Verilog模块时具有配置模块的“#(参数配置)”

​ 映射的键固定是字符串类型,它对应Verilog里声明的参数名;映射的值对应传入的配置参数,可以是字符串,也可以是整数和浮点数。虽然值的类型是Param,这是一个Chisel的印章类,但是单例对象chisel3.experimental里定义了相应的隐式转换,可以把BigInt、Int、Long、Double和String转换成对应的Param类型。例如把上例修改成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
...
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就变成了:

1
2
3
4
5
6
7
8
...
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,就是用黑盒导入异步寄存器,供内部代码使用

6.2 资源文件中带有 Verilog 的黑盒

(1)方法一:HasBlackBoxResource

特质HasBlackBoxResource

  • 所属包:chisel3.util

  • 作用:如果在黑盒类里混入这个特质,并且在src/main/resources文件夹里有对应的Verilog源文件,那么在Chisel转换成Verilog时,就会把Verilog文件一起复制到目标文件夹。

方法setResource

  • 所属特质:HasBlackBoxResource
  • 入参:Verilog文件的相对地址,即相对src/main/resources的地址
  • 作用:标注verilog文件地址

注意,相比一般的黑盒,除了端口列表的声明,还多了一个特质里的setResource方法的调用。方法的入参是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
...
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")
}
...

(2)方式二:HasBlackBoxPath

特质HasBlackBoxPath

  • 包:chisel3.util
  • 作用:chisel3.util包里还有一个特质HasBlackBoxPath,如果在黑盒类里混入这个特质,并且在任意文件夹(也即此时Verilog文件不再必须放入src/main/resources路径下)里有对应的Verilog源文件,那么在Chisel转换成Verilog时,就会把Verilog文件一起复制到目标文件夹。注意此时的路径是相对于工程的路径,我们需要提供相对路径或者全路径

方法addPath:

  • addPath(“工程的绝对路径/绝对路径”)

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import chisel3.util._

class Dut extends BlackBox with HasBlackBoxPath{
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))
})

addPath("./src/main/scala/dut.v")

//或者addPath("D:/chisel-examples/src/main/scala/dut.v")
}

(3)内嵌verilog:HasBlackBoxInline

特质HasBlackBoxInline

  • 包:chisel.util
  • 作用:混入该特质的黑盒类可以把Verilog代码直接内嵌进去。

方法:setInline(blackBoxName: String, blackBoxInline: String)

  • 作用:内嵌的方式是调用特质里的方法“setInline(blackBoxName: String, blackBoxInline: String)”,类似于setResource的用法。这样,目标文件夹里就会生成一个单独的Verilog文件,复制内嵌的代码。该方法适合小型Verilog设计。

字符串类的方法:stripMargin

  • 作用:方法stripMargin用于消除竖线左侧的空格

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
...
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代码。

6.3 inout端口

类Analog:

  • 所属包:chisel3.experimental._
  • 作用:声明一个inout类型的端口

引入inout端口的流程:

  • 定义一个内部是Analog(位宽)的Bundle子类,做自定义端口列表
  • 需要引入inout的模块设置为黑盒
  • 在黑盒模块内进行接口的例化

引入的inout需要注意的内容:

​ Chisel目前只支持在黑盒中引入Verilog的inout端口。Bundle中使用 “Analog(位宽)”声明Analog类型的端口,经过编译后变成Verilog的inout端口。模块里的端口可以声明成Analog类型,但只能用于与黑盒连接,不能在Chisel代码中进行读写。因为是双向端口,所以不需要用Input或Output指明方向,但是可以用Flipped来翻转,也就不会影响整个Bundle的翻转使用前,要先用“chisel3.experimental._”进行导入

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 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为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 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

7 多时钟域设计

在数字电路中免不了用到多时钟域设计,尤其是设计异步FIFO这样的同步元件。

  • 在Verilog里,多时钟域的设计很简单,只需声明多个时钟端口,然后不同的always语句块根据需要选择不同的时钟作为敏感变量即可
  • 在Chisel,则相对复杂一些,因为这与Scala的变量作用域相关,而且时序元件在编译时都是自动地隐式跟随当前时钟域。

本章将介绍多时钟域设计的语法,这其实很简单。

7.1 原始模块RawModule(没有隐式端口的模块)

  • RawModule

继承自Module的模块类会获得隐式的全局时钟与同步复位信号,即使在设计中用不上它们也没关系。如果读者确实不喜欢这两个隐式端口,则可以选择继承自RawModule,这样在转换成Verilog时就没有隐式端口。它是单例对象chisel3.experimental里定义的类型,也就是UserModule类的别名。

这样的模块一般用于纯组合逻辑。在类内顶层不能出现使用时钟的相关操作,比如定义寄存器,否则会报错没有隐式端口。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 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代码为:

1
2
3
4
5
6
7
8
// 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也可以包含时序逻辑,但要使用多时钟域语法

7.2 定义一个时钟域和复位域

  • withClockAndReset

chisel3.core包里有一个单例对象withClockAndReset,其apply方法定义如下:

1
def apply[T](clock: Clock, reset: Reset)(block: ⇒ T): T
  • 方法的作用:就是创建一个新的时钟和复位域,作用范围仅限于它的传名参数的内部。新的时钟和复位信号就是第一个参数列表的两个参数。
  • 注意,在编写代码时不能写成import chisel3.core._,这会扰乱import chisel3._的导入内容。正确做法是用import chisel3.experimental._导入experimental对象,它里面用同名字段引用了单例对象chisel3.core.withClockAndReset这样就不需要再导入core

(1)两种定义写法

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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)
}

​ 因为第二个参数列表只有一个传名参数,所以可以把圆括号写成花括号,这样还有自动的分号推断。再加上传名参数的特性,尽管需要一个无参函数,但是可以省略书写“() =>”。所以,

1
2
3
4
5
6
withClockAndReset(io.clockB, io.resetB) {
sentence1
sentence2
...
sentenceN
}

实际上相当于:

1
withClockAndReset(io.clockB, io.resetB)( () => (sentence1; sentence2; ...; sentenceN) )

这结合了Scala的柯里化、传名参数和单参数列表的语法特性,让DSL语言的自定义方法看上去就跟内建的while、for、if等结构一样自然,所以Scala很适合构建DSL语言。

(2)withClockAndReset第二个函数参数的返回值

  • 读者再仔细看一看apply方法的定义,它的第二个参数是一个函数,同时该函数的返回结果也是整个apply方法的返回结果。

最后一个表达式有返回值时:

​ 在独立时钟域的定义里,最后一个表达式的结果会被当作函数的返回结果可以用一个变量来引用这个返回结果,这样在独立时钟域的定义外也能使用

例如引用最后返回的模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
class MultiClockModule extends Module {
val io = IO(new Bundle {undefined
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来引用
}

clockB_child.io.in := io.stuff
}

如果传名参数全都是定义时:返回Unit,外部无法访问独立时钟内任何内容

​ 最后没有表达式用于返回,那么apply的返回结果类型自然就是Unit此时,外部不能访问独立时钟域里的任何内容。

例如把上个例子改成如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class MultiClockModule extends Module {
val io = IO(new Bundle {undefined
val clockB = Input(Clock())
val resetB = Input(Bool())
val stuff = Input(Bool())
})
//pre code
// val clockB_child = withClockAndReset(io.clockB, io.resetB) {
// Module(new ChildModule)
// }

val clockB_child = withClockAndReset(io.clockB, io.resetB) {
val m = Module(new ChildModule)//只是个定义
//
}

clockB_child.m.io.in := io.stuff
}

​ 现在,被例化的模块不是作为返回结果,而是变成了变量m的引用对象,故而传名参数是只有定义、没有有用的返回值的空函数。如果编译这个模块,就会得到“没有相关成员”的错误信息:

1
2
3
[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] ^

(3)如何让独立时钟域变量与外界交互

如果独立时钟域有多个变量要与外部交互,则应该在模块内部的顶层定义全局的线网,让所有时钟域都能访问。

(4)withClock和withRest

​ 除了单例对象withClockAndReset,还有单例对象withClock和withReset,分别用于构建只有独立时钟和只有独立复位信号的作用域,三者的语法是一样的。

7.3 使用时钟负沿和低有效的复位信号

​ 默认情况下,声明的时序元件都是以时钟的正沿和高有效的复位信号作为敏感变量,但是在多时钟域的语法里,可以改变其行为。

  • 复位信号比较简单:~io.rst
    • 只需要加上取反符号或逻辑非符号。
  • 时钟信号稍微麻烦一些:(~io.clk.asUint.toBoot).asClock
    • 需要先用asUInt方法把Clock类型转换成UInt类型,
      • 再用toBool转换成Bool类型,此时可以加上取反符号或逻辑非符号,
        • 最后再用asClock变回Clock类型。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 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主要是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 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

7.4 示例:异步FIFO

​ 在跨时钟域设计中,经常需要使用异步FIFO来同步不同时钟域的数据传输。下面是笔者自己编写的一个异步FIFO例子,数据位宽和深度都是参数化的,读、写地址指针的交互采用格雷码和两级寄存器采样,以便改善亚稳态。通过在Vivado 2018.3里综合后,可以得到以BRAM为存储器的FIFO。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// 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))
}

7.5 总结

​ 本章介绍了如何用Chisel设计多时钟域电路,重点是学会apply方法的使用,以及对第二个参数列表的理解。要注意独立时钟域里只有最后的表达式能被作为返回值给变量引用,并被外部访问,其它的定义都是对外不可见的

8 函数的应用

函数是编程语言的常用语法,即使是Verilog这样的硬件描述语言,也会用函数来构建组合逻辑。对于Chisel这样的高级语言,函数的使用更加方便,还能节省不少代码量。

Chisel函数都是Scala的函数,种类有以下三种:

  • 自定义的函数
  • Chisel语言库中的函数
  • Scala标准库中的函数

不管是用户自己写的函数、Chisel语言库里的函数还是Scala标准库里的函数,都能帮助用户节省构建电路的时间。

本章提到的类、特质、方法:

  • RawModule类
    • experimental包
  • withClockAndReset单例对象
    • chisel3.core包
      • 导入时请导入experiment中的同名对象,而不是直接导入core包
    • apply方法用于创建新的时钟域
  • withClock、withReset

8.1 组合逻辑函数

直接定义就行:

1
def 函数名(参数1:参数1的类型,.....):返回类型={内部语句}`

适用范围:频繁使用的组合逻辑电路,通过函数调用的方式来使用它。

定义区域:这些函数既可以定义在某个单例对象里,供多个模块重复使用,也可以直接定义在电路模块里

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 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))
}

8.2 用工厂方法简化模块的例化

简而言之:

​ 和Scala一样,直接在类中重载apply方法,调用时直接使用类名就能调用

在Scala里,往往在类的伴生对象里定义一个工厂方法,来简化类的实例化。同样,Chisel的模块也是Scala的类,也可以在其伴生对象里定义工厂方法来简化例化、连线模块

例如用双输入多路选择器构建四输入多路选择器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 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))
}

8.3 用Scala的函数简化代码(使用Scala中的for)

Scala的函数也能在Chisel里使用,只要能通过Firrtl编译器的检查。

比如在生成长的序列上,利用Scala的函数就能减少大量的代码

任意位数译码器使用Scala函数的优化写法:

​ 假设要构建一个译码器,在Verilog里需要写条case语句,当n很大时就会使代码显得冗长而枯燥。

利用Scala函数的优化:

  • 使用Scala的for、yield组合可以产生相应的判断条件与输出结果的序列,
  • 再用zip函数将两个序列组成一个对偶序列
  • 再把对偶序列作为MuxCase的参数

​ 最终就能用几行代码构造出任意位数的译码器。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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))
})
//使用Scala的for
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位译码器。

8.4 Chisel的打印函数

Chisel提供了一个“ printf ”函数来打印信息,用于电路调试。它有Scala和C两种风格。

需要导入完整包信息Predef.printf

printf显示时机:

  • 当用Verilator生成波形时,每个时钟周期都会在屏幕上显示一次。
  • 如果在when语句块里,只有条件成立时才运行。隐式的全局复位信号也不会触发。

printf函数只能在Chisel的模块里使用,并且会转换成Verilog的系统函数“$fwrite”,包含在宏定义块“ `ifndef SYNTHESIS......`endif 里。通过Verilog的宏定义,可以取消这部分不可综合的代码。因为后导入的chisel3包覆盖了Scala的标准包,所以Scala里的printf函数要写成“Predef.printf”的完整路径形式。

8.4.1 Scala风格(p插值器)

该风格类似于Scala的字符串插值器

Chisel自定义了一个p插值器,该插值器可以对字符串内的一些自定义表达式进行求值、Chiel类型转化成字符串类型等。

① 简单格式

1
2
3
4
5
6
7
8
9
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 = !

② 聚合数据类型

1
2
3
4
5
6
7
8
9
10
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方法。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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方法。因此,最终的打印信息如下:

1
2
3
4
5
Message:
valid : v
addr : 0x00001234
length : 10
data : 0x00000000deadbeef

8.4.2 C风格

Chisel的printf也支持C的部分格式控制符和转义字符。如下所示:

1
2
val myUInt = 32.U
printf("myUInt = %d", myUInt) // myUInt = 32

8.5 Chisel的对数与对数取整

对数单例对象的apply方法:Log2,返回值为该参数的以2为底的幂次

  • Log2(Bits类型的参数)
  • Log2(Bits类型的参数,Int类型的返回值的位宽)

包:chisel3.util

​ 在二进制运算里,求以2为底的对数也是常用的运算。

chisel3.util包里有一个单例对象Log2,它的一个apply方法接收一个Bits类型的参数,计算并返回该参数值以2为底的幂次。返回类型是UInt类型,并且是向下截断的。另一个apply的重载版本可以接受第二个Int类型的参数,用于指定返回结果的位宽。例如:

1
2
3
4
5
Log2(8.U)  // 等于3.U

Log2(13.U) // 等于3.U(向下截断)

Log2(myUIntWire) // 动态求值

对数取整单例对象的apply方法:

  • log2Ceil
  • log2Floor
  • log2Up
  • log2Down

参数与返回值:参数都是Int和BigInt类型,返回结果都是Int类型

包:chisel3.util

chisel3.util包里还有四个单例对象:log2Ceil、log2Floor、log2Up和log2Down,它们的apply方法的参数都是Int和BigInt类型,返回结果都是Int类型。log2Ceil是把结果向上舍入,log2Floor则向下舍入。log2Up和log2Down不仅分别把结果向上、向下舍入,而且结果最小为1。

判断该整数是不是2的n次幂:isPow2

包:chisel3.util

​ 参数:Int和BigInt类型的参数

单例对象isPow2的apply方法接收Int和BigInt类型的参数,判断该整数是不是2的n次幂,返回Boolean类型的结果。

8.6 与硬件相关的函数

8.6.1 位旋转Reverse

chisel3.util包里还有一些常用的操作硬件的函数,比如单例对象Reverse的apply方法可以把一个UInt类型的对象进行旋转,返回一个对应的UInt值。在转换成Verilog时,都是通过拼接完成的组合逻辑。例如:

1
2
3
4
5
Reverse("b1101".U)  // 等于"b1011".U

Reverse("b1101".U(8.W)) // 等于"b10110000".U

Reverse(myUIntWire) // 动态旋转

8.6.2 位拼接Cat

单例对象Cat有两个apply方法,分别接收一个Bits类型的序列和Bits类型的重复参数,将它们拼接成一个UInt数。前面的参数在高位。例如:

1
2
3
4
5
6
7
Cat("b101".U, "b11".U)  // 等于"b10111".U

Cat(myUIntWire0, myUIntWire1) // 动态拼接

Cat(Seq("b101".U, "b11".U)) // 等于"b10111".U

Cat(mySeqOfBits) // 动态拼接

8.6.3 1计数器PopCount

单例对象PopCount有两个apply方法:

  • 接受一个Bits类型的参数

  • 接受一个Bool类型的序列

计算参数里“1”或“true.B”的个数,返回对应的UInt值。例如:

1
2
3
4
5
6
7
8
9
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) // 动态计数

8.6.4 独热码转换器OHToUInt、UIntToOH

单例对象OHToUInt的apply方法可以接收:

  • 一个Bits类型的独热码参数

  • Bool序列类型的独热码参数

​ 计算独热码里的“1”在第几位(从0开始),返回对应的UInt值。如果不是独热码,则行为不确定。例如:

1
2
3
OHToUInt("b1000".U)  // 等于3.U

OHToUInt("b1000_0000".U) // 等于7.U

还有一个行为相反的单例对象UIntToOH,它的apply方法是根据输入的UInt类型参数,返回对应位置的独热码,独热码也是UInt类型。例如:

1
2
3
UIntToOH(3.U)  // 等于"b1000".U

UIntToOH(7.U) // 等于"b1000_0000".U

8.6.5 无关位 BitPat、bitPatToUInt、dontCare

Verilog里可以用问号表示无关位,那么用case语句进行比较时就不会关心这些位。

Chisel里有对应的BitPat类,可以指定无关位。

  • 在其伴生对象里,一个apply方法可以接收一个字符串来构造BitPat对象,字符串里用问号表示无关位。例如:
1
2
3
4
5
"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类型的参数,构造等值位宽的全部无关位。例如:

1
val myDontCare = BitPat.dontCare(4)  // 等于BitPat("b????") 

8.6.6 查找表Lookup、ListLookup

BitPat通常配合两种查找表使用:

一种是单例对象Lookup,返回结果是一个T类型的Bits

  • 定义:
1
def apply[T <: Bits](addr: UInt, default: T, mapping: Seq[(BitPat, T)]): T 
  • 作用:addr会与每个BitPat进行比较,如果相等,就返回对应的值,否则就返回default

第二种是单例对象ListLookup,它的apply方法与上面的类似,区别在于返回结果是一个T类型的列表:

1
defapply[T <: Data](addr: UInt, default: List[T], mapping: Array[(BitPat, List[T])]): List[T] 

这两种查找表的常用场景是构造CPU的控制器,因为CPU指令里有很多无关位,所以根据输入的指令(即addr)与预先定义好的带无关位的指令进行匹配,就能得到相应的控制信号。

8.7 总结

在编写代码时,虽然是构造硬件,但是语言特性和编译器允许读者灵活使用高级函数。要做到熟能生巧,就应该多阅读、多练习

9 其他

本章讲解的内容比较繁杂,没有一个统一的中心思想。这些问题与实际编程没有太大关系,但是读者需要稍微留意。

总的来说,本章内容是编写Chisel时的常见问题汇总最常出现的错误就是“not fully initialized”,读者应该根据提示信息查看设计中是否有情况没覆盖全的组合逻辑

9.1 模块的动态命名desiredName

  • 作用:chisel模块转为verilog模块时,模块/黑盒名称用的是别名

  • 使用方法:重写desiredName即可

Chisel可以动态定义模块的名字,也就是转成Verilog时的模块名不使用定义的类名,而是使用重写的desiredName方法的返回字符串。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
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为:

1
2
3
4
5
6
7
8
9
10
11
12
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

9.2 可选的端口(Scala中的if和Boolean)

定义:Chisel通过引入Scala的Boolean参数可选值以及if语句可以创建出可选的端口

使用方法:在例化该模块时可以通过控制Boolean入参来生成不同的端口。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
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方法把端口解放出来。这里也体现了可选值语法的便利性。

9.3 生成正确的块内信号名

一般情况下,在when、withClockAndReset等语句块里定义的信号(线网和寄存器),转换成Verilog时不会生成正确的变量名。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 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) { //when
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为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// TestMod.v
module TestMod(
input clock,
input reset,
input io_a,
output [3:0] io_b
);
reg [3:0] _T;//innerReg变成了_T
wire [3:0] _T_2;//变成了_T
assign _T_2 = _T + 4'h1;
assign io_b = io_a ? _T : 4'ha;
always @(posedge clock) begin
if (reset) begin
_T <= 4'h5;//innerReg变成了_T
end else begin
_T <= _T_2;
end
end
endmodule
  • 注意看,when语句块里声明的寄存器innerReg,被命名成了“_T”。

如果想让名字正确,则需要在build.sbt文件里加上:

1
addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.0" cross CrossVersion.full) 

同时,设计代码里需要加上传递给Firrtl的注解

1
2
3
4
5
6
7
...
import chisel3._
import chisel3.experimental.chiselName

@chiselName
class TestMod extends Module {
...

这样,对应的Verilog文件就有了正确的寄存器名字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 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

9.4 拆包(强制类型转换,给拼接变量赋值)

Verilog使用大括号进行拼接

在Verilog中,左侧的赋值对象可以是一个拼接起多个变量的值,例如:

1
2
3
4
5
wire [1:0] a;
wire [3:0] b;
wire [2:0] c;
wire [8:0] z = [...];
assign {a, b, c} = z;

在Chisel里不能直接这么赋值。类似可对线网通过强制类型转换为Bundle实现,做法如下:

  • 是先定义一个a、b、c组成的Bundle,高位定义在前面,
    • 然后创建线网z,线网z可以被直接赋值,
      • z初始化为线网后,z再调用方法asTypeOf,该方法接收一个Data类型的参数,可以把调用对象强制转换成参数的类型并返回,在这里也就是把a、b、c组成的Bundle作为参数

注意,返回结果是一个新对象,并没有直接修改调用对象z。强制转换必须保证不会出错。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//① 定义一个Bundle
class MyBundle extends Bundle {
val a = UInt(2.W)
val b = UInt(4.W)
val c = UInt(3.W)
}
//② 创建线网z,并对z进行赋值
val z = Wire(UInt(9.W))
z := ...
//③调用asTypeOf,强制转换类型为MyBundle
val unpacked = z.asTypeOf(new MyBundle)//注意是返回一个新类,z没有发生改变
unpacked.a
unpacked.b
unpacked.c

9.5 按位赋值

Verilog可以直接给向量的某几位赋值。

同样,Chisel受限于Scala,不支持直接给Bits类型的某几位赋值。子字赋值的可行办法是:

  • 先调用Bits类型的toBools方法。该方法根据调用对象的0、1排列返回一个相应的Seq[Bool]类型的结果,并且低位在序列里的下标更小,比如第0位的下标就是0、第n位的下标就是n,
    • 然后用这个Seq[Bool]对象配合VecInit构成一个向量
      • 此时就可以给单个比特赋值
1
2
3
4
简而言之:
Bits类--Bits.toBools方法-->Seq[Bool]类型的返回值--
-->VecInit(Seq[Bool])---->转换为一个Vec[T]--
-->Vec[0],Vec[1],Vec[2]....按位赋值

注意,必须都是Bool类型,要注意赋值前是否需要类型转换。子字赋值完成后,Bool向量再调用asUInt、asSInt方法转换回来。

例如:

1
2
3
4
5
6
7
8
9
10
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
}

9.6 对参数化的Bundle进行复制

因为Chisel是基于Scala和JVM的,所以当一个Bundle类的对象用于创建线网、IO等操作时,它并不是把自己作为参数,而是交出自己的一个复制对象,也就是说编译器需要知道如何来创建当前Bundle对象的复制对象

如何复制有参数的Bundle对象:cloneType与chiselTypeOf:

  • Chisel提供了一个内部的API函数cloneType,任何继承自Data的Chisel对象,要复制自身时,都是由cloneType负责返回该对象的复制对象。它对应的用户API则是chiselTypeOf。

  • 当自定义的Bundle的主构造方法没有参数时,Chisel会自动推断出如何构造Bundle对象的复制,原因很简单,因为构造一个新的复制对象不需要任何参数,仅仅使用关键字new就行了。

  • 如果自定义的Bundle带有参数列表,那么Chisel就无法推断了,因为传递进去的参数可以是任意的,并不一定就是完全地复制。此时需要用户自己重写Bundle类的cloneType方法,其形式为:
1
override def cloneType = (new CustomBundle(arguments)).asInstanceOf[this.type]

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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]//保证返回对象的类型与this对象是一样的
}

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的作用,再来看一个“别扭”的例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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如下:
1
2
3
4
5
6
7
8
9
10
11
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。

9.7 Chisel泛型(同Scala)

Chisel本质上还是Scala,所以Chisel的泛型就是使用Scala的泛型语法,这使得电路参数化更加方便。

无论是Chisel的函数还是模块,都可以用类型参数和上、下界来泛化。在例化模块时,传入不同类型的参数,就可能会产生不同的电路,而无需编写额外的代码,当然前提是逻辑、类型必须正确。

要熟练使用泛型比较麻烦,所需素材很多,这里就不再介绍。读者可以通过阅读Chisel的源码来学习它是如何进行泛型的。

9.8 未驱动的线网DontCare、检查机制explicitInvalidate

Chisel自动检测:

Chisel的Invalidate API支持检测未驱动的输出型IO以及定义不完整的Wire定义,在编译成firrtl时会产生“not fully initialized”错误。换句话说,就是组合逻辑的真值表不完整,不能综合出完整的电路

DontCare作用:

如果确实需要不被驱动的线网,则可以赋给一个DontCare对象,这会告诉Firrtl编译器,该线网故意不被驱动。转换成的Verilog会赋予该信号全0值,甚至把逻辑全部优化掉,所以谨慎使用。例如:

1
2
3
4
val io = IO(new Bundle {
val outs = Output(Vec(10, Bool()))
})
io.outs <> DontCare

设置检查机制成都:

  • 检查机制是由CompileOptions.explicitInvalidate控制的
    • 如果把它设置成true就是严格模式(执行检查),设置成false就是不严格模式(不执行检查)。

检查机制的设置方法1

  • 开关方法有两种,第一种是定义一个抽象的模块类,由抽象类设置,其余模块都继承自这个抽象类例如:
1
2
3
4
5
// 严格
abstract class ExplicitInvalidateModule extends Module()(chisel3.core.ExplicitCompileOptions.NotStrict.copy(explicitInvalidate = true))

// 不严格
abstract class ImplicitInvalidateModule extends Module()(chisel3.core.ExplicitCompileOptions.Strict.copy(explicitInvalidate = false))

检查机制的设置方法2

  • 第二种方法是在每个模块里重写compileOptions字段,由该字段设置编译选项。例如:
1
2
3
4
5
6
7
8
9
10
11
// 严格
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)
...
}

10 隐式参数的应用

用Chisel编写的CPU,比如Rocket-Chip、RISCV-Mini等,都有一个特点,就是可以用一个配置文件来裁剪电路。这利用了Scala的模式匹配、样例类、偏函数、可选值、隐式定义等语法。本章内容就是来为读者详细解释它的工作机制。

10.1 相关定义

要理解隐式参数是如何配置电路的,应该先了解与配置相关的定义。在阅读代码之前,为了能快速读懂、深入理解,读者最好复习一下模式匹配和隐式定义两章的内容

下面是来自于开源处理器RISCV-Mini的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// 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)
}
}

10.2 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修饰的抽象类,不能被继承。

10.3 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,即把可选值解开并返回。

10.4 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的可选值字段。这与推测内容相吻合。

10.5 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方法。

10.6 MiniConfig类

前面讲解的内容相当于类库里预先定义好的内容。要配置自定义的电路,还需要一个自定义的类。比如,处理器RISCV-Mini就定义了下面的MiniConfig类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 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。

10.7 MiniConfig的运行原理

我们来看Tile模块的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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模块为例:

看到,Core模块确实需要接收一个隐式的Parameters类型的参数。

再来看Core混入的特质CoreParams:

1
2
3
4
abstract trait CoreParams {
implicit val p: Parameters
val xlen = p(XLEN)
}

这个特质有未实现的抽象成员,即隐式参数p。抽象成员需要子类给出具体的实现,这里也就是Core模块接收的MiniConfig实例。

那么“val xlen = p(XLEN)”意味着什么呢?我们知道,p是一个MiniConfig的实例对象,它继承了超类View的apply方法。查看apply的定义,也就是调用了:

1
final def apply[T](pname: Field[T]): T = apply(pname, this)

1
2
3
4
5
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被定义为:

1
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方法,也就是:

1
2
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类:

1
protected[config] def chain[T](site: View, tail: View, pname: Field[T]) = p.chain(site, tail, pname) 

注意这里的p是用MiniConfig传递给超类的函数f构造的PartialParameters对象,不是MiniConfig对象自己。即:

1
val out = (new PartialParameters((site, here, up) => {…})).chain(p, new TerminalView, XLEN)”。

再来看PartialParameters类的chain方法的具体行为:

1
2
3
4
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的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(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]的第一个辅助构造函数:

1
def this() = this(None) 

导致XLEN.default = None。这使得“val out = None”,apply方法的require产生异常报错,并打印信息“Key XLEN is not defined in Parameters”。注意字符串插值会把${pname}求值成XLEN。

再来看Core模块里的CoreIO:

1
2
3
4
5
6
7
8
9
10
11
12
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语句,原理一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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
......

10.8 总结:如何自定义参数

首先要导入第一点给出的文件,其次是像定义MiniConfig那样定义自己的参数类,然后实例化参数类,并用隐式参数传递给相应的模块。模块在定义时,记得要留好隐式参数列表。

如果当前作用域有隐式的参数类对象,那么用“val xxx = p(XXX)”参数化的字段就能根据隐式对象求得具体的值。改变隐式对象的内容,就能动态地定义像位宽这样的关键字段。这样裁剪设计时,只需要修改自定义参数类的偏函数,而不需要每个地方都去更改。

11 后记

终于学完了,教程写的太难看了,累死我了,以后有用的文档都会挂在文章开头,有缘再见


96733090_p3

96733090_p4