Chisel入门教程

原文链接 # 介绍 Chisel(Constructing Hardware In a Scala Embedded Language)是一种嵌入在高级编程语言Scala的硬件构建语言。Chisel实际上只是一些特殊的类定义,预定义对象的集合,使用Scala的用法,所以在写Chisel程序时实际上是在写Scala程序。不过,本文我们并不假设你知道如何去写一个Scala程序。本文会通过一些Chisel的例子来说明某些重要的Scala特征,可以让你只使用本文介绍的东西也能完成一些伟大的硬件设计。 当你越来越有经验,希望自己的代码能够更加简化或提高复用性,你会发现有必要了解Scala语言的潜力。 所以,进一步学习Scala会让你变得更加专业。

Chisel仍处于起步阶段,你可能会遇到一些实现方面的bug,甚至可能会遇到一些概念设计问题。不过,我们正在积极地修改和改进语言,并且对错误报告和建议开放。即使在早期阶段,我们希望Chisel将帮助设计师在构建易于重复使用和维护的设计时更有效率。

Chisel硬件表达

此版本的Chisel只支持二进制逻辑,不支持三态信号。

我们专注于二进制逻辑设计,因为它们构成了实践中的绝大多数设计。我们忽略对当前Chisel语言中的三态逻辑的支持,因为这在工业环境中也很少支持,并且难以在受控硬宏之外可靠地使用。

Chisel数据类型

Chisel数据类型用于指定状态元素中保存的值或wire上传输的值。虽然硬件设计最终操作的是二进制数值向量,但对于值的其他抽象表示具有更清晰的规范,并且能够帮助工具生成更优化的电路。在Chisel中,原始比特集合可以用Bits类型来表示。带符号和无符号整数被认为是定点数的子集,可以用SInt和UInt来表示。带符号定点整数(包括整数)使用二进制补码格式来表示。布尔值可以用Bool类型表示。注意,这些类型与Scala的内建类型不同,例如Int或Boolean。另外,Chisel定义了Bundle用来将值进行集合(类似于其他语言中的struct),还定义了Vec用来对值的集合进行索引。

常量或字面值使用Scala整数或传递给构造函数的字符串表示:

1
2
3
4
5
6
7
8
9
UInt(1)			// decimal 1-bit lit from Scala Int.
UInt("ha") // hexadecimal 4-bit lit from string.
UInt("o12") // octal 4-bit lit from string.
UInt("b1010") // binary 4-bit lit from string.
SInt(5) // signed decimal 4-bit lit from Scala Int.
SInt(-8) // negative decimal 4-bit lit from Scala Int.
UInt(5) // unsigned decimal 3-bit lit from Scala Int.
Bool(true) // Bool lits from Scala lits.
Bool(false)

下划线可以用作长字符串文字中的分隔符,以帮助可读性,但在创建值时会被忽略,例如:

1
UInt("h_dead_beef") // 32-bit lit of type UInt

默认情况下,Chisel编译器将每个常量的大小设置为保存常量所需的最小位数,包括带符号类型的符号位。位宽也可以在字面上明确指定,如下所示:

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

对于UInt类型值,值被零扩展到所需的位宽。对于类型为SInt的文字,该值被符号扩展以填充所需的位宽度。如果给定的位宽太小而不能容纳参数值,则会生成Chisel错误。

组合电路

在Chisel中,电路会被表示为一张节点图。每个节点是具有零个或多个输入并驱动一个输出的硬件运算符。上面介绍的Uint是一种退化类型的节点,它没有输入,并且在其输出上驱动一个恒定的值。创建和连接节点的一种方法是使用字面表达式。例如,我们可以使用以下表达式来表示简单的组合逻辑电路:

1
(a & b) | (~c & d)

语法应该看起来很熟悉,用&和|分别表示按位与和按位或,~表示按位非。a到d表示某些(未指定)宽度的命名导线。
任何简单的表达式都可以直接转换成电路树,在叶子处使用命名的导线和操作符形成内部节点。表达式的电路输出取自树根处的运算符,在本示例中是按位或运算。
简单表达式可以以树的形式构建电路,但是如果想以任意有向非循环图(DAG)的形式构建电路,我们需要描述扇出。在Chisel中,我们通过命名一根wire来表示一个子表达式,这样我们就可以在后续表达式中多次引用。我们通过声明变量来命名Chisel中的wire。例如,考虑如下示例的select表达式,它在后续的多选器描述中可以多次使用:

1
2
val sel = a | b
val out = (sel & in1) | (~sel & in0)

关键字val是Scala的一部分,用于命名具有不会再更改的值的变量。 在上面的例子中它命名了wire类型的sel,保存了第一个按位或运算符的输出,以便输出可在第二个表达式中多次使用。

内建操作符

Chisel定义了一组硬件操作符,如下表所示:

位宽接口

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

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

位宽推测过程会持续到没有位宽改变。 除了通过已知固定数量的右移之外,位宽推测规定了输出位宽度不能小于输入位宽度,因此输出位宽度增长或保持相同。 此外,寄存器的宽度必须由用户明确地或根据复位值或下一个参数的位宽指定。根据这两个要求,我们可以将位宽推测过程将收敛到一个固定点。
> 我们选择的运算符名称受到Scala语言的限制。所以我们必须使用===表示等于判断逻辑和=/=表示不等判断逻辑,这样可以保持原生Scala相关运算符可用。

功能抽象

我们可以定义函数来分解一个重复的逻辑,这样可以在后续设计中重复使用。例如,我们可以包装一个简单的组合逻辑块:

1
2
def clb(a: UInt, b: UInt, c: UInt, d: UInt): UInt = 
(a & b) | (~c & d)

其中clb是表示以a,b,c,d为参数的函数,并返回一个布尔电路的输出。 def关键字是Scala的一部分,表示引入了一个函数定义,每个语句后面跟一个冒号,然后是它的类型,函数返回类型在参数列表之后的冒号之后。(=)符号将函数参数列表与函数定义分隔开。
然后我们就可以在其他的电路中使用了:

1
val out = clb(a,b,c,d)

我们将在后面介绍许多吊炸天的函数使用方法来构造硬件。

Bundles & Vecs

Bundle和Vec是可以允许用户使用其他数据类型来扩展Chisel数据类型集合的类。
Bundle可以将一些不同类型的命名字段组合成一个单元,类似于C语言中的struct。用户可以通过将一个类定义为Bundle的子类来定义自己的bundle:

1
2
3
4
5
6
7
class MyFloat extends Bundle {
val sign = Bool()
val exponent = UInt(width = 8)
val significand = UInt(width = 23)
}
val x = new MyFloat()
val xs = x.sign

scala约定将新类的名称的首字母大写,所以我们建议在Chisel中也遵循这个约定。 UInt构造函数的width命名参数指定类型中的位数。

Vecs用来创建一个可索引的元素向量,其构造如下所示:

1
2
3
4
// Vector of 5 23-bit signed integers.
val myVec = Vec.fill(5){ SInt(width = 23) }
// Connect to one element of vector.
val reg3 = myVec(3)

(注意,我们必须在花括号内指定Vec元素的类型,因为我们必须将位宽参数传递给SInt构造器。)
原始类(SInt,UInt和Bool)加上聚合类(Bundles和Vecs)都继承自一个公共的超类Data。在电路中,每个最终继承自Data的对象都可以表示为一个位向量。
Bundle和Vec可以任意嵌套,从而构建复杂的数据结构:

1
2
3
4
5
6
7
class BigBundle extends Bundle {
// Vector of 5 23-bit signed integers.
val myVec = Vec.fill(5) { SInt(width = 23) }
val flag = Bool()
// Previously defined bundle.
val f = new MyFloat()
}

端口

端口用作硬件组件的接口。一个端口可以是任意的Data对象,但它是具有方向的。
Chisel提供端口构造函数,以允许在构建时给对象添加(输入或输出)。原始的端口构造函数需要将方向作为第一个参数(方向为INPUT或OUTPUT),将位数作为第二个参数(除了始终为1位的布尔值)。
端口的声明如下所示:

1
2
3
4
5
class Decoupled extends Bundle { 
val ready = Bool(OUTPUT)
val data = UInt(INPUT, 32)
val valid = Bool(INPUT)
}

Decoupled被定义后,它就会变成一个新的类型,可以根据需要用于模块接口或命名的wire集合。
对象的方向也可以实例化时确定:

1
2
3
4
5
class ScaleIO extends Bundle {
val in = new MyFloat().asInput
val scale = new MyFloat().asInput
val out = new MyFloat().asOutput
}

asInput和asOutput方法可以强制数据对象的所有模块设置成对应的方向。
通过将方向折叠到对象声明中,Chisel能够提供强大的布线能力,稍后会详细介绍。

Modules

我们现在可以构建电路层次,我们可以从较小的子模块开开始构建更大的模块。例如,我们可以通过将三个2输入多路选择器连接在一起,构建一个4输入多路选择器模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Mux4 extends Module { 
val io = new Bundle {
val in0 = UInt(INPUT, 1)
val in1 = UInt(INPUT, 1)
val in2 = UInt(INPUT, 1)
val in3 = UInt(INPUT, 1)
val sel = UInt(INPUT, 2)
val out = UInt(OUTPUT, 1)
}
val m0 = Module(new Mux2())
m0.io.sel := io.sel(0)
m0.io.in0 := io.in0;
m0.io.in1 := io.in1
val m1 = Module(new Mux2())
m1.io.sel := io.sel(0)
m1.io.in0 := io.in2;
m1.io.in1 := io.in3
val m3 = Module(new Mux2())
m3.io.sel := io.sel(1)
m3.io.in0 := m0.io.out;
m3.io.in1 := m1.io.out
io.out := m3.io.out
}

运行和测试

现在我们已经定义了模块,我们将讨论如何实际运行并测试电路。Chisel代码可以转换为C++或Verilog。 为了编译电路,我们需要调用chiselMain:

1
2
3
4
5
object tutorial {
def main(args: Array[String]) = {
chiselMain(args, () => Module(new Mux2()))
}
}

测试是电路设计的关键部分,因此在Chisel中,我们通过这样一种测试机制:使用Tester类的子类在Scala中提供测试向量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Tester[T <: Module] (val c: T, val isTrace: Boolean = true) {
var t: Int
val rnd: Random
def int(x: Boolean): BigInt
def int(x: Int): BigInt
def int(x: Bits): BigInt
def reset(n: Int = 1)
def step(n: Int): Int
def pokeAt(data: Mem[T], index: Int, x: BigInt)
def poke(data: Bits, x: BigInt)
def poke(data: Aggregate, x: Array[BigInt])
def peekAt(data: Mem[T], index: Int)
def peek(data: Bits): BigInt
def peek(data: Aggregate): Array[BigInt]
def expect (good: Boolean, msg: String): Boolean
def expect (data: Bits, target: BigInt): Boolean
}

它将tester绑定到模块,并允许用户使用给定的调试协议编写测试。用户会用到一下这些:
- poke: 设置输入端口以及状态值
- step: 以一个时间单元执行电路
- peek: 读取端口和状态值
- expect: 比较peek获得的值和期望的值

用户使用如下的方式连接tester和模块:

1
2
3
4
5
object chiselMainTest { 
def apply[T <: Module]
(args: Array[String], comp: () => T)(
tester: T => Tester[T]): T
}

当- -test作为参数传递给chiselMainTest时,tester实例在独立的进程中运行被测器件(DUT),并连接stdin和stdout,这样调试命令可以发送到DUT,响应也可以从DUT接收,如图所示。

举例说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Mux2Tests(c: Mux2) extends Tester(c) { 
val n = pow(2, 3).toInt
for (s <- 0 until 2) {
for (i0 <- 0 until 2) {
for (i1 <- 0 until 2) {
poke(c.io.sel, s)
poke(c.io.in1, i1)
poke(c.io.in0, i0)
step(1)
expect(c.io.out, (if (s == 1) i1 else i0))
}
}
}
}

使用poke将Mux2的每个输入的分别设置为合适的值。对于这个例子,我们通过硬编码输入到一些已知的值并检查输出是否对应于已知的值来测试Mux2。为此,在每次迭代中,我们生成模块输入,让模拟将这些值分配给我们正在测试的器件c的输入,单步运行电路并对比期望值。最后,简单说明一下如何调用测试器:

1
2
3
chiselMainTest(args + "--test", () => Module(new Mux2())){ 
c => new Mux2Tests(c)
}
还有其他的一些命令参数:
> --targetDir 目标路径名前缀
> --genHarness 生成C++文件
> --backend v 生成verilog
> --backend c 生成C++(默认)
> --vcd 开启vcd打印
> --debug 把所有的wire放入class文件

状态元素

Chisel支持的状态元素的最简单形式是上升沿触发寄存器,可以实例化为:

1
val reg = Reg(next = in)

该电路具有输出,该输出是前一个时钟周期的输入信号产生的值。注意,我们不必指定Reg的类型,因为它会在实例化时从输入开始自动推断。在当前版本的Chisel中,时钟和复位是全局信号,在需要时可以隐式包含。
使用寄存器,我们可以快速定义一些有用的电路结构。 例如,当当前值为true且之前的值为false时,上升沿检测器能够获取到布尔信号并输出true,如下所示:

1
def risingedge(x: Bool) = x && !Reg(next = x)

计数器是一个重要的时序电路。 如果想构建一个向上计数器,计数到最大值max后回到零:

1
2
3
4
5
def counter(max: UInt) = {
val x = Reg(init = UInt(0, max.getWidth))
x := Mux(x === max, UInt(0), x + UInt(1))
x
}

计数器复位值为0(宽度大到足以容纳max),当电路的全局复位置位时,寄存器将初始化为该值。
计数器可用于构建很多有用的时序电路。例如,我们可以通过在计数器达到零时输出true来构建脉冲发生器:

1
2
// Produce pulse every n cycles.
def pulse(n: UInt) = counter(n - UInt(1)) === UInt(0)

然后可以通过切换方波发生器脉冲序列,在每个脉冲上的true和false之间切换:

1
2
3
4
5
6
7
8
// Flip internal state when input true.
def toggle(p: Bool) = {
val x = Reg(init = Bool(false))
x := Mux(p, !x, x)
x
}
// Square wave of a given period.
def squareWave(period: UInt) = toggle(pulse(period/2))

转发声明

纯组合电路在节点之间不存在周期,如果检测到这样的周期,则Chisel将报告错误。因为它们不具有周期,所以可以总是以前馈方式构建组合电路,通过添加一些输入从已经定义的节点导出的新节点。时序电路在节点之间具有反馈,因此有时需要在生成节点被定义之前输出。因为Scala顺序执行程序语句,所以我们允许数据节点作为wire来提供节点声明,这样可以立即被使用,但其输入将稍后设置。如下例所示,在简单的CPU中,我们需要定义pcPlus4和brTarget的线,以便在定义之前引用它们:

1
2
3
4
5
6
7
val pcPlus4 = UInt()
val brTarget = UInt()
val pcNext = Mux(io.ctrl.pcSel, brTarget, pcPlus4)
val pcReg = Reg(next = pcNext, init = UInt(0, 32))
pcPlus4 := pcReg + UInt(4)
...
brTarget := addOut

接线操作符:=用于在pcReg和addOut定义后连接。

条件更新

在前面使用到寄存器的示例中,我们简单地将组合逻辑块连接到寄存器的输入。当描述状态元素的操作时,指定何时将发生寄存器更新并且用几个单独的语句指明这些更新。Chisel以when的形式提供条件更新规则,以支持这种顺序逻辑描述的风格。例如,

1
2
3
4
val r = Reg(init = UInt(0, 16)) 
when (cond) {
r := r + UInt(1)
}

其中只有在cond为真时,才在当前时钟周期的结尾更新寄存器r。when的参数是返回Bool值。后面的更新块只能包含使用赋值运算符:=,简单表达式和用val定义的命名引线的更新语句。
在条件更新序列中,条件为真的最近条件更新优先。 例如:

1
2
when (c1) { r := UInt(1) } 
when (c2) { r := UInt(2) }

上述表达式会根据以下真值表更新r:

c1 c2 r
0 0 r r 不变
0 1 2
1 0 1
1 1 2 c2优先于c1
下图显示了如何将每个条件更新视为在寄存器的输入之前插入mux,根据when选择更新表达式或之前的输入。编译器会把初始化值置于链的开头,以便如果在一个时钟周期内没有条件更新激活,则寄存器的加载使能将被置为无效,寄存器值就不会改变。

Chisel为条件更新的其他常见形式提供了一些语法糖。除非结构与when相同,但否定其条件。也就是说,

1
2
3
unless (c) { body }
// the same as
when (!c) { body }

更新块可以操作多个目标寄存器,在不同更新块中的也允许存在寄存器的不同重叠子集。每个寄存器只受其出现的条件的影响。组合电路(更新Wire)也是可能的。注意,所有组合电路需要默认值。例如:

1
2
3
r := SInt(3); s := SInt(3)
when (c1) { r := SInt(1); s := SInt(1) }
when (c2) { r := SInt(2) }

上述语句会根据如下真值表更新r和s:

c1 c2 r s
0 0 3 3
0 1 2 3
1 0 1 1
1 1 2 1

条件更新结构可以嵌套,任何给定块在所有外嵌套条件的联合下才能执行。例如,

1
2
3
when (a) { when (b) { body } }
// the same as
when (a && b) { body }

条件可以使用when,.elsewhen,.otherwise来链式表达,对应于Scala中的if, else if, else。例如:

1
2
3
4
5
6
7
when (c1) { u1 } 
.elsewhen (c2) { u2 }
.otherwise { ud }
// the same as
when (c1) { u1 }
when (!c1 && c2) { u2 }
when (!(c1 || c2)) { ud }

我们再介绍关于用于条件更新的switch语句,其涉及针对公共密钥的一系列比较。例如,

1
2
3
4
5
6
7
switch(idx) { 
is(v1) { u1 }
is(v2) { u2 }
}
// the same sa
when (idx === v1) { u1 }
.elsewhen (idx === v2) { u2 }

Chisel还允许Wire,即一些组合逻辑的输出,成为条件性更新语句的目标,以允许逐步构建复杂的组合逻辑表达式。Chisel不允许不指定组合输出,并且如果组合输出未遇到无条件更新,则报告错误。

有限状态机

在数字设计中有限状态机(FSM)是时序电路常用的类型。简单FSM的例子就是奇偶校验生成器:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Parity extends Module { 
val io = new Bundle {
val in = Bool(dir = INPUT)
val out = Bool(dir = OUTPUT)
}
val s_even :: s_odd :: Nil = Enum(UInt(), 2)
val state = Reg(init = s_even)
when (io.in) {
when (state === s_even) { state := s_odd }
when (state === s_odd) { state := s_even }
}
io.out := (state === s_odd)
}

其中Enum(Uint(), 2)生成两个UInt数。当io.in为true时更新状态。需要注意的是,FSM的所有机制都建立在寄存器,线和条件更新的基础上。
下面是一个复杂的FSM例子,这是一个自动售货机接收货币的电路:

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
class VendingMachine extends Module {
val io = new Bundle {
val nickel = Bool(dir = INPUT)
val dime = Bool(dir = INPUT)
val valid = Bool(dir = OUTPUT)
}
val s_idle :: s_5 :: s_10 :: s_15 :: s_ok :: Nil = Enum(UInt(), 5)
val state = Reg(init = s_idle)
when (state === s_idle) {
when (io.nickel) { state := s_5 }
when (io.dime) { state := s_10 }
}
when (state === s_5) {
when (io.nickel) { state := s_10 }
when (io.dime) { state := s_15 }
}
when (state === s_10) {
when (io.nickel) { state := s_15 }
when (io.dime) { state := s_ok }
}
when (state === s_15) {
when (io.nickel) { state := s_ok }
when (io.dime) { state := s_ok }
}
when (state === s_ok) {
state := s_idle
}
io.valid := (state === s_ok)
}

下面是一个使用switch语句定义的售货机FSM:

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 VendingMachine extends Module {
val io = new Bundle {
val nickle = Bool(dir = INPUT)
val dime = Bool(dir = INPUT)
val valid = Bool(dir = OUTPUT)
}
val s_idle :: s_5 :: s_10 :: s_15 :: s_ok :: Nil = Enum(UInt(), 5)
val state = Reg(init = s_idle)
switch (state) {
is (s_idle) {
when (io.nickel) { state := s_5 }
when (io.dime) { state := s_10 }
}
is (s_5) {
when (io.nickel) { state := s_10 }
when (io.dime) { state := s_15 }
}
is (s_10) {
when (io.nickel) { state := s_15 }
when (io.dime) { state := s_ok }
}
is (s_ok) {
state := s_idle
}
}
io.valid := (state === s_ok)
}
# 内存 Chisel提供了创建只读和读/写存储器的功能。

ROM

用户可以使用Vec定义ROM:

1
2
Vec(inits: Seq[T])
Vec(elt0: T, elts: T*)

其中inits是初始化ROM的初始Data序列。例如,用户可以创建一个初始化为1,2,4,8的小型ROM,并使用计数器作为地址生成器循环访问所有值,如下所示:

1
2
val m = Vec(Array(UInt(1), UInt(2), UInt(4), UInt(8))) 
val r = m(counter(UInt(m.length)))

我们可以使用如下初始化的ROM创建n值正弦查找表:

1
2
3
4
5
6
7
def sinTable (amp: Double, n: Int) = { 
val times = Range(0, n, 1).map(i => (i*2*Pi)/(n.toDouble-1) - Pi)
val inits = times.map(t => SInt(round(amp * sin(t)), width = 32))
Vec(inits)
}
def sinWave (amp: Double, n: Int) =
sinTable(amp, n)(counter(UInt(n))

其中amp用于缩放存储在ROM中的固定点值。

Mem

存储器在Chisel中被给予特殊处理,因为存储器的硬件实现具有许多变化,例如,FPGA存储器与ASIC存储实例化的结果完全不同。Chisel定义了一个内存抽象,可以映射到简单的Verilog行为描述,也可以映射到从代工厂或IP厂商提供的外部内存生成器获得的内存模块实例。
Chisel通过Mem结构可以支持随机存取存储器。写入Mems是正边沿触发,读取是组合或正边沿触发。

1
2
3
4
5
6
7
8
object Mem {
def apply[T <: Data](type: T, depth: Int,
seqRead: Boolean = false): Mem
}
class Mem[T <: Data](type: T, depth: Int, seqRead: Boolean = false)
extends Updateable {
def apply(idx: UInt): T
}

通过使用UInt索引创建到Mems的端口。具有一个写入端口和两个组合读取端口的32-entry的寄存器堆可以如下表示:

1
2
3
4
val rf = Mem(UInt(width = 64), 32) 
when (wen) { rf(waddr) := wdata }
val dout1 = rf(waddr1)
val dout2 = rf(waddr2)

如果设置了可选参数seqRead,当读地址为Reg时,Chisel将尝试推断顺序读端口。单读端口,单写端口SRAM可以描述如下:

1
2
3
4
5
val ram1r1w = Mem(UInt(width = 32), 1024, seqRead = true)
val reg_raddr = Reg(UInt())
when (wen) { ram1r1w(waddr) := wdata }
when (ren) { reg_raddr := raddr }
val rdata = ram1r1w(reg_raddr)

单端口SRAM可以在读和写条件在链中相同时相互排斥时推断:

1
2
3
4
5
val ram1p = Mem(UInt(width = 32), 1024, seqRead = true) 
val reg_raddr = Reg(UInt())
when (wen) { ram1p(waddr) := wdata }
.elsewhen (ren) { reg_raddr := raddr }
val rdata = ram1p(reg_raddr)

如果相同的Mem地址在相同的时钟沿上被写入和顺序读取,或者如果顺序读取使能被清除,则读取数据为未定义。
Mem还支持subword写入的写掩码。如果相应的屏蔽位置1,则写入给定位。

1
2
3
4
5
6
val ram = Mem(UInt(width = 32), 256)
when (wen) { ram.write(waddr, wdata, wmask) }
class FilterIO extends Bundle {
val x = new PLink().flip
val y = new PLink()
}

其中flip递归地改变Bundle的“性别”,将输入改变为输出和输出。
我们现在可以通过定义一个过滤器类扩展模块来定义一个过滤器:

1
2
3
class Filter extends Module { val io = new FilterIO()
...
}
其中io包含了FilterIO。

Bundle 向量

除了单个元素,元素向量可以形成更丰富的分层接口。例如,创建具有输入向量的交叉开关,产生输出向量,并通过UInt输入选择,我们可以使用Vec构造函数:

1
2
3
4
5
class CrossbarIo(n: Int) extends Bundle {
val in = Vec.fill(n){ new PLink().flip() }
val sel = UInt(INPUT, sizeof(n))
val out = Vec.fill(n){ new PLink() }
}

其中Vec用第一个参获取大小,区块返回一个端口作为第二个参数。

批量连接

我们现在可以将两个过滤器组成一个过滤器块,如下所示:

1
2
3
4
5
6
7
8
class Block extends Module { 
val 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
}

其中<>批量连接同级模块之间的相反接口或父/子模块之间的相同接口。批量连接将相同名称的叶子端口彼此连接。在所有连接完成后,Chisel警告用户端口是否只有一个到它们的连接。

接口视图

考虑一个由控制逻辑和数据通路子模块以及主机和存储器接口组成的简单CPU,如图所示。在这个CPU中,我们可以看到控制逻辑和数据通路每个只连接到指令和数据存储器接口的一部分。Chisel允许用户通过部分实现接口来实现这一点。用户首先定义到ROM和Mem的完整接口,如下:
1
2
3
4
5
6
7
8
9
class RomIo extends Bundle { 
val isVal = Bool(INPUT)
val raddr = UInt(INPUT, 32)
val rdata = UInt(OUTPUT, 32)
}
class RamIo extends RomIo {
val isWr = Bool(INPUT)
val wdata = UInt(INPUT, 32)
}

现在控制逻辑可以根据这些接口构建接口:

1
2
3
4
5
class CpathIo extends Bundle { 
val imem = RomIo().flip()
val dmem = RamIo().flip()
...
}

而且控制和数据通路模块可以通过部分地分配来给这个接口来构建,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Cpath extends Module { 
val io = new CpathIo();
...
io.imem.isVal := ...;
io.dmem.isVal := ...;
io.dmem.isWr := ...;
...
}
class Dpath extends Module {
val io = new DpathIo();
...
io.imem.raddr := ...;
io.dmem.raddr := ...;
io.dmem.wdata := ...;
...
}

我们现在可以使用批量连接来连接CPU,就像使用其他bundle一样:

1
2
3
4
5
6
7
8
9
10
11
12
class Cpu extends Module {
val io = new CpuIo()
val c = Module(new CtlPath())
val d = Module(new DatPath())
c.io.ctl <> d.io.ctl
c.io.dat <> d.io.dat
c.io.imem <> io.imem
d.io.imem <> io.imem
c.io.dmem <> io.dmem
d.io.dmem <> io.dmem
d.io.host <> io.host
}

模块的功能创建

制造用于模块构造的功能接口也是有用的。例如,我们可以构建一个构造函数,它将多路复用器输入作为参数,并返回多路复用器输出:

1
2
3
4
5
6
7
8
9
object Mux2 {
def apply (sel: UInt, in0: UInt, in1: UInt) = {
val m = new Mux2()
m.io.in0 := in0
m.io.in1 := in1
m.io.sel := sel
m.io.out
}
}

其中对象Mux2在Mux2模块类中创建一个Scala单例对象,并且apply定义了创建Mux2实例的方法。有了这个Mux2创建功能,Mux4的规格现在明显更简单。

1
2
3
4
5
6
7
8
9
10
11
class Mux4 extends Module { 
val io = new Bundle {
val in0 = UInt(INPUT, 1)
val in1 = UInt(INPUT, 1)
val in2 = UInt(INPUT, 1)
val in3 = UInt(INPUT, 1)
val sel = UInt(INPUT, 2)
val out = UInt(OUTPUT, 1)
}
io.out := Mux2(io.sel(1), Mux2(io.sel(0), io.in0, io.in1), Mux2(io.sel(0), io.in2, io.in3))
}

Chisel提供MuxCase,其本质上是一个n-way Mux。

1
MuxCase(default, Array(c1 -> a, c2 -> b, ...))

其中每个条件/值在Scala数组中表示为元组,并且其中MuxCase可以转换为以下Mux表达式:

1
Mux(c1, a, Mux(c2, b, Mux(..., default)))

Chisel还提供MuxLookup,其本质是一个n-way的可索引多路选择器:

1
2
MuxLookup(idx, default,
Array(UInt(0) -> a, UInt(1) -> b, ...))

这可以用MuxCase来重写:

1
MuxCase(default,Array((idx === UInt(0)) -> a,(idx === UInt(1)) -> b, ...))

多态性和参数化

Scala是一种强类型语言,使用参数化类型来指定通用函数和类。 在本节中,我们展示了Chisel用户如何使用参数化类来定义自己的可重用函数和类。

参数化函数

前面我们在Bool上定义了Mux2,但现在我们展示如何定义一个通用的多路复用器功能。我们使用一个布尔条件和con和alt参数(对应于then和else表达式)来定义一个T类型的函数:

1
def Mux[T <: Bits](c: Bool, con: T, alt: T): T { ... }

其中T需要是Bits的子类。Scala确保在Mux的每个使用中,它可以找到实际的con和alt参数类型的公共超类,否则会导致Scala编译类型错误。例如,

1
Mux(c, UInt(10), UInt(11))

这会产生一个UInt线,因为con和alt参数都是UInt类型。
我们现在提出一个更高级的参数化函数的例子,用于定义一个内积FIR数字滤波器,通常用于Chisel Num。 内积FIR滤波器可以在数学上定义为:

其中x是输入,w是权重向量。在Chisel中,这可以定义为:

1
2
3
4
5
def delays[T <: Data](x: T, n: Int): List[T] =
if (n <= 1) List(x) else x :: Delays(RegNext(x), n-1)

def FIR[T <: Data with Num[T]](ws: Seq[T], x: T): T =
(ws, Delays(x, ws.length)).zipped.map( _ * _ ).reduce( _ + _ )

其中延迟产生其输入的增量递增延迟的列表,并且reduce构造给出二进制组合器函数f的简化电路。在这种情况下,reduce创建一个求和电路。最后,FIR函数被限制为处理类型Num的输入,其中定义了Chisel乘法和加法。

参数化类

与参数化函数一样,我们也可以参数化类,使它们可重用程度更高。例如,我们可以将Filter类概括为可以使用任何类型的链接。我们可以通过参数化FilterIO类和定义构造函数采取零参数类型构造函数来做到这点,如下所示:

1
2
3
4
class FilterIO[T <: Data](type: T) extends Bundle { 
val x = type.asInput.flip
val y = type.asOutput
}

我们现在可以通过定义一个模块类来定义Filter,该模块类也接收一个链接类型构造函数参数,并将其传递给FilterIO接口构造器:

1
2
3
4
class Filter[T <: Data](type: T) extends Module { 
val io = new FilterIO(type)
...
}

我们现在可以定义一个基于PLant的过滤器,如下所示:

1
val f = Module(new Filter(new PLink()))

其中大括号{}表示零参数函数(也称为thunk),在这种情况下创建链接类型。
通用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
class DataBundle extends Bundle { 
val A = UInt(width = 32)
val B = UInt(width = 32)
}
object FifoDemo {
def apply () = new Fifo(new DataBundle, 32)
}

class Fifo[T <: Data] (type: T, n: Int) extends Module {
val io = new Bundle {
val enq_val = Bool(INPUT)
val enq_rdy = Bool(OUTPUT)
val deq_val = Bool(OUTPUT)
val deq_rdy = Bool(INPUT)
val enq_dat = type.asInput
val deq_dat = type.asOutput
}
val enq_ptr = Reg(init = UInt(0, sizeof(n)))
val deq_ptr = Reg(init = UInt(0, sizeof(n)))
val is_full = Reg(init = Bool(false))
val do_enq = io.enq_rdy && io.enq_val
val do_deq = io.enq_rdy && io.deq_val
val is_empty = !is_full && (enq_ptr === deq_ptr)
val deq_ptr_inc = deq_ptr + UInt(1)
val enq_ptr_inc = enq_ptr + UInt(1)
val is_full_next = Mux(do_enq && ~do_deq && (enq_ptr_inc === deq_ptr), Bool(true), Mux(do_deq && is_full, Bool(false), is_full))
enq_ptr := Mux(do_enq, enq_ptr_inc, enq_ptr)
deq_ptr := Mux(do_deq, deq_ptr_inc, deq_ptr)
is_full := is_full_next
val ram = Mem(n)
when (do_enq) {
ram(enq_ptr) := io.enq_dat
}
io.enq_rdy := !is_full
io.deq_val := !is_empty
ram(deq_ptr) <> io.deq_dat
}

也可以定义成通用解耦接口:

1
2
3
4
5
class DecoupledIO[T <: Data](data: T) extends Bundle {
val ready = Bool(INPUT)
val valid = Bool(OUTPUT)
val bits = data.clone.asOutput
}

然后可以使用该模板向任何信号集添加握手:

1
2
class DecoupledDemo
extends DecoupledIO()( new DataBundle )

FIFO的接口也可以如下进行简化:

1
2
3
4
5
6
7
class Fifo[T <: Data] (data: T, n: Int) extends Module {
val io = new Bundle {
val enq = new DecoupledIO( data ).flip()
val deq = new DecoupledIO( data )
}
...
}

多时钟域

Chisel 2.0介绍了对多时钟域的支持。

创建时钟域

为了使用多个时钟域,用户必须创建多个时钟。 在Chisel中,时钟是使用复位信号参数创建的第一级节点,定义如下:

1
2
3
class Clock (reset: Bool) extends Node { 
def reset: Bool // returns reset pin
}

在Chisel中有一个内置的隐式时钟,状态元素默认使用:

1
var implicitClock = new Clock( implicitReset )

状态元素和模块的时钟可以使用名为clock的附加命名参数来定义:

1
2
3
Reg(... clock: Clock = implicitClock) 
Mem(... clock: Clock = implicitClock)
Module(... clock: Clock = implicitClock)

交叉时钟域

有两种方式可以定义电路在时钟域之间发送数据。第一种也是最原始的方式就是使用由两个寄存器组成的同步器电路,如下所示:

1
2
3
4
5
6
7
// signalA is in clock domain clockA,
// want a version in clockB as signalB
val s1 = Reg(init = UInt(0), clock = clockB)
val s2 = Reg(init = UInt(0), clock = clockB)
s1 := signalA
s2 := s1;
signalB := s2

由于亚稳性问题,该技术只限于在域之间传递一位数据。
在域之间发送数据的第二种和更一般的方式是通过使用异步fifo:

1
class AsyncFifo[T<:Data](gen: T, entries: Int, enq_clk: Clock, deq_clock:Clock) extends Module

当通过指定标准fifo参数和两个时钟,然后使用标准解耦就绪/有效信号从时钟域clockA到clockB获取一个版本的signalA时:

1
2
3
4
5
val fifo = new AsyncFifo(Uint(width = 32), 2, clockA, clockB)
fifo.io..enq.bits := signalA
signalB := fifo.io.deq.bits
fifo.io.enq.valid := condA
fifo.io.deq.ready := condB

后端特定的多个时钟域

每个Chisel后端需要用户以后端特定的方式设置和控制多个时钟。为了展示如何驱动一个多时钟设计,考虑这样一个硬件示例,其中两个模块使用Async-Fifo进行通信,每个模块在不同的时钟:fastClock和slowClock。

C++

在C++后端,对于每个时钟i有:
- size_t clk.len域表示时钟i的周期
- clock_lo_i,clock_hi_i - int reset() 函数,可以保证clock_lo和clock_hi的所有函数被立刻调用
- int clock(reset)函数,计算最小增量,调用合适的clock_lo和clock_hi,并返回使用的最小增量。

为了设置一个C++模拟器,用户需要:
- 将所有周期字段初始化为期望的周期
- 将所有计数字段初始化为期望的相位
- 调用reset - 重复调用时钟来单步模拟

以下是slowClock / fastClock的main函数C++示例:

1
2
3
4
5
6
7
8
9
10
11
12
int main(int argc, char** argv) { 
ClkDomainTest_t dut; dut.init(1);
dut.clk = 2;
dut.clk_cnt = 1;
dut.fastClock = 4;
dut.fastClock_cnt = 0;
dut.slowClock = 6;
dut.slowClock_cnt = 0;
for (int i = 0; i < 12; i ++)
dut.reset();
for (int i = 0; i < 96; i ++)
dut.clock(LIT<1>(0)); }

Verilog

在Verilog中:
- Chisel为每个时钟/复位创建一个新端口,
- Chisel将所有的时钟连到顶层模块
- 用户必须要为每个时钟i创建一个always块时钟驱动

以下是驱动slowClock / fastClock电路的顶层Verilog示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
module emulator;
reg fastClock = 0, slowClock = 0, resetFast = 1, resetSlow = 1;
wire [31:0] add, mul, test;
always #2 fastClock = ~fastClock;
always #4 slowClock = ~slowClock;
initial begin
# 8
resetFast = 0;
resetSlow = 0;
#400
$finish;
end
ClkDomainTest dut (
.fastClock(fastClock),
.slowClock(slowClock),
.io_resetFast(resetFast),
.io_resetSlow(resetSlow),
.io_add(add),
.io_mul(mul),
.io_test(test));
endmodule

(完)