chisel 使用浅测

什么是 chisel

关于“什么是 chisel”这个问题,在网上已经有了详尽的讨论:它是硬件描述语言,它是 scala 的包等等。在我的使用范畴下,我认为它是一个编译器,依托于 scala 包的形式存在,有些类似于 LLVM 以库的形式存在的特点。它具有编译器的特点:源语言是 scala,目标语言是 verilog,此外还具有中间表示 FIRRTL。chisel 的工作过程可以理解为:通过在 scala 代码中调用一些 chisel 包中的类和方法,再运行这段 scala 代码,产生可综合的 verilog。

chisel 的优势

chisel 的敏捷性源于它更高层次的抽象以及 scala 语言本身面向对象、函数式的特点。

丰富的数据类型

下面是 chisel 官网 中数据类型的继承关系图:

比较常用的数据类型有 UIntSIntBoolClockReset。此外还可以通过 VecBundle 将它们聚集起来,Vec 用于聚集相同的类型,而 Bundle 用于聚集不同的类型。

简单的硬件模型

在 chisel 中的硬件模型只有两种:线网 Wire 和寄存器 Reg,并且使用了 Reg 声明的变量最终一定会产生寄存器,而不像 verilog 一样不能确定。

声明一个硬件需要将数据类型和硬件模型结合起来使用:

1
2
val foo = Wire(UInt(32.W))
val bar = Reg(Bool())

foo 描述了一个 32 位宽的线,bar 描述了一个 1 位宽的寄存器。需要注意的是,在 chisel 中的时钟和复位信号是隐含的,Reg 默认使用该寄存器所在模块的同步时钟和复位信号。

自定义类型

在编写 verilog 时,常常因为散乱的接口而难以维护,陷入“设计一小时,模块编码一小时,接线两小时”的窘境,chisel 的自定义类型解决了这一问题。

Bundle 继承

通过继承 Bundle 类,用户可以自定义线束类型,将不同类型的线聚集起来:

1
2
3
4
class MyBundle extends Bundle {
val src = Vec(2, UInt(32.W))
val op = Vec(12, Bool())
}

定义了由两个 32 位操作数 src 和一个 12 位操作码 op 所组成的线束。在模块中,可以使用自定义类型产生硬件并对其进行连接:

1
2
3
4
val myBundle = Wire(new MyBundle)
myBundle.src(0) := 0.U
myBundle.src(1) := 1.U
myBundle.op := "b000100".U

Module 继承

Module 是电路中基本组成单元,chisel 中的模块同样抽象为 IO 接口和内部逻辑,通过继承 Module 可以自定义模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MyModule extends Module {
val io = IO(new Bundle(){
val in = Input(new MyBundle)
val out = Output(new Bundle(){
val res = UInt(32.W)
})
})

io.out.res := Mux1H(Seq(
io.in.op(0) -> io.in.src(0) + io.in.src(1),
io.in.op(1) -> io.in.src(0) - io.in.src(1),
...
))
}

定义了一个使用 12 位独热码作为操作码的 alu 模块。其中值 io 定义了该模块的 IO 接口。为了创建它,定义了一个匿名的 Bundle 对象,其中包含 inout 分别定义输入接口和输出接口。定义 IO 接口中的 3 个方法 IOInputOutput 接受的参数都是 Bundle 的对象,因此在这里可以使用自定义的线束类型。MyModule 中剩余部分是该模块的内部逻辑,这里使用了原语 Mux1H 生成一个独热码选择器。

快速连接模块

chisel 中基本的连接算符是 := ,信号的流动方向是从右向左。chisel 中还提供了一种 element-wise 的连接算符 <>,它可以快速连接线束中的同名元素,并检查其类型是否相同:

1
2
3
4
5
6
7
8
9
10
11
class MyModuleWrap extends Module {
val io = IO(new Bundle(){
val in = Input(new MyBundle)
val out = Output(new Bundle(){
val res = UInt(32.W)
})
})
val myAlu = Module(new MyModule)

myAlu.io<>io
}

强大的语法检查

正如上文所提到的,在 chisel 进行编译时,编译器将检查连接左右硬件的数据类型是否相同,若不同则报错,停止编译。实际上,chisel 编译器还做了更多的事情,能够帮助在编码时就检查出一些设计功能无关的错误,节省开发时间。

参数化模块

由于所有类型的定义都是基于 scala 的类,因此可以使用类的初始化参数对模块进行参数化定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class MyBundle(xLen:Int = 32) extends Bundle {
val src = Vec(2, UInt(xLen.W))
val op = Vec(12, Bool())
}

class MyModule(xLen:Int = 32) extends Module {
val io = IO(new Bundle(){
val in = Input(new MyBundle(xLen))
val out = Output(new Bundle(){
val res = UInt(xLen.W)
})
})

io.out.res := Mux1H(Seq(
io.in.op(0) -> io.in.src(0) + io.in.src(1),
io.in.op(1) -> io.in.src(0) - io.in.src(1),
...
))
}

在实例化模块时,可以通过改变传入的参数定制不同的模块:

1
2
val myModule1 = Module(new MyModule(16))
val myModule2 = Module(new MyModule(32))

scala 集合类型和函数式编程

集合类型和函数式编程都是 scala 的语言特性,由于 chisel 是 scala 的包,因此可以使用这些特性用于构造硬件生成器。

比如说对于设计 CPU 核而言,一个明显的集合是指令集,在 chisel 中,可以将所有指令放到一个查找表中,利用 scala 的 ListLookUp 方法生成译码逻辑:

  1. 首先,构造查找表,它使用一个指令作为索引,查找结果是控制信号:
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
abstract trait DecodeConstants {
object CtrlIdx extends scala.Enumeration {
val selSrc0, selSrc1, selImm, selWnum, fuType, fuOp, rfWen, illegal = Value
}

def default: List[UInt] = // no instruction
// SelSrc(0)
// | SelSrc(1)
// | | SelImm
// | | | SelWnum
// | | | | FuType
// | | | | | FuOp
// | | | | | | rfWen
// | | | | | | | illegal
List(rs, rt, nan, dzero, alu, sll, f, t)
val table: Array[(BitPat, List[UInt])]
}

object MIPS32I extends DecodeConstants {
val table: Array[(BitPat, List[UInt])] = Array(
SLL -> rTypeRAlu(sll),
SRL -> rTypeRAlu(srl),
SRA -> rTypeRAlu(sra),

JR -> rTypeJr(jumpr),

ADDU -> rTypeRAlu(add),
SUBU -> rTypeRAlu(sub),

AND -> rTypeRAlu(and),
OR -> rTypeRAlu(or),
XOR -> rTypeRAlu(xor),
NOR -> rTypeRAlu(nor),
SLT -> rTypeRAlu(slt),
SLTU -> rTypeRAlu(sltu),

JAL -> jTypeJal(jump),

BEQ -> iTypeBr(beq),
BNE -> iTypeBr(bne),

ADDIU -> iTypeUAlu(add),

LUI -> iTypeUAlu(lui),

LW -> iTypeLd(lw),

SW -> iTypeSt(sw)
)
}

table 定义了所需要的查找表,它的表项是一系列键-值对,键-值对在 scala 中使用 -> 表示,左边是键,右边是值。在查找表中,所有键的类型都是 BitPat,它是 chisel 中的一种用于做二进制串模式匹配的类型;所有值的类型都是 List[UInt],即由 UInt 对象组成的列表,用于表示某条指令对应的控制信号的具体取值。

  1. 定义控制信号的线束:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class ControlSignals(xLen:Int = 32) extends Bundle {
val selSrc0 = UInt()
val selSrc1 = UInt()
val selImm = UInt()
val selWnum = UInt()
val fuType = UInt()
val fuOp = UInt()
val rfWen = Bool()
val illegal = Bool()

def signals = Array(
MIPS32I.CtrlIdx.selSrc0 -> selSrc0,
MIPS32I.CtrlIdx.selSrc1 -> selSrc1,
MIPS32I.CtrlIdx.selImm -> selImm,
MIPS32I.CtrlIdx.selWnum -> selWnum,
MIPS32I.CtrlIdx.fuType -> fuType,
MIPS32I.CtrlIdx.fuOp -> fuOp,
MIPS32I.CtrlIdx.rfWen -> rfWen,
MIPS32I.CtrlIdx.illegal -> illegal
)
}

这里没有指定信号的位宽,在编译时 chisel 将自动推导出它们的宽度。另外,定义了 signal 方法,它返回一个 Array,存储这些信号在查找结果中的索引和具体信号值的键-值对,方便在模块中对其进行迭代。

  1. 定义译码模块:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Instruction(xLen:Int = 32) extends Bundle {
val instr = UInt(xLen.W)
val pc = UInt(xLen.W)
}

class ControlIO(xLen:Int = 32) extends Bundle {
val in = Input(new Instruction(xLen))
val out = Output(new ControlSignals(xLen))
}

class Control(xLen:Int = 32) extends Module {
val io = IO(new ControlIO(xLen))

val ctrlSignals = ListLookup(io.in.instr, MIPS32I.default, MIPS32I.table)
io.out.signals.map(x => x._2 := ctrlSignals(x._1.id))
}

译码模块 Control 的输入是一条指令及其对应的 pc,输出是控制信号。该模块的巧妙之处在于控制信号的生成。

首先,使用 ListLookup 方法对之前构建的查找表进行查找,查找的结果是一个 UInt 组成的 List,将其存储在 ctrlSignals 中。

然后调用 Arrayio.out.signals 方法返回一个 Array)的 map 方法进行迭代。该方法传入了一个匿名函数 x => x._2 := ctrlSignals(x._1.id),它将 Array中的每一项取出,使用该项的键索引查找结果,然后将其连接到对应的值上。最终的效果是将查找结果的每一项都连接到了对应的控制信号上。

scala 泛型

由于 scala 泛型的存在,可以对电路进行更高层次的抽象。

比如说对于流水线寄存器而言,可以将其抽象为:

  1. IO 接口:
名称 方向 说明
io.in.data.valid input 输入数据有效
io.in.data.bits input 输入数据
io.in.stall input 流水级暂停
io.in.flush input 流水级冲刷
io.out.data.valid output 输出数据有效
io.out.data.bits outptu 输出数据
  1. 内部逻辑(伪代码):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class StageReg extends Module {
val io = IO(new StageRegIO)

val validReg = Reg(Bool())
val dataReg = Reg(DataType())

when(reset.asBool || io.in.flush) {
validReg := 0.U
} .elsewhen(!io.in.stall) {
validReg := io.in.data.valid
}

when (!io.in.stall) {
dataReg := io.in.data.bits
}

io.out.data.valid := validReg
io.out.data.bits := dataReg
}

在实际使用中,IO 接口和内部逻辑都是不变的,发生变化的是输入数据的类型 DataType,因此可以对于输入的数据类型使用泛型:

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
class StageRegIn[T<:Bundle](xLen:Int = 32, dataType: T) extends Bundle {
val stall = Input(Bool())
val flush = Input(Bool())
val data = Flipped(Valid(dataType))
}

class StageRegOut[T<:Bundle](xLen:Int = 32, dataType: T) extends Bundle {
val data = Valid(dataType)
}

class StageRegIO[T<:Bundle](xLen:Int = 32, dataType: T) extends Bundle {
val in = new StageRegIn(xLen, dataType)
val out = new StageRegOut(xLen, dataType)
}

class StageReg[T<:Bundle](xLen:Int = 32, dataType: T) extends Module {
val io = IO(new StageRegIO(xLen, dataType))

val validReg = Reg(Bool())
val dataReg = Reg(dataType)

when(reset.asBool || io.in.flush) {
validReg := 0.U
} .elsewhen(!io.in.stall) {
validReg := io.in.data.valid
}

when (!io.in.stall) {
dataReg := io.in.data.bits
}

io.out.data.valid := validReg
io.out.data.bits := dataReg
}

在实例化一个流水级寄存器时,可以传入自定义类型以定制寄存器的输入输出数据类型:

1
val myStageReg = Module(new StageReg(32, new MyBundle))

chisel 的劣势

学习成本高

chisel 是基于 scala 开发的包,因此使用 chisel 需要先学习 scala。相比于 C 而言,scala 是一门完全不同的语言,其中面向对象、函数式编程的特性是 C 所不具备的,需要从头学习。之所以需要学习这些高级特性,是因为只有使用了这些特性才能充分利用 chisel 的优势,否则 chisel 的使用体验和 verilog 差不多。

对工业级开发流程支持尚不完善

目前,我只把 chisel 当作一个工具,用于使用更少、更易于维护的代码生成 verilog,然后将 verilog 导入 vivado 工程,作为 SOC 的一部分进行仿真、综合、实现、比特流生成和上板。虽然 chisel 中包含测试模块,但是它完全不支持对于 IP 核的调用。

参考资料