Chisel入门(三)------Chisel的基本语法1

0 概述

    前边描述了Chisel工具的安装和SBT的一些最基本用法,然后接下来便主要介绍Chisel3的基本使用语法,其基本和Scala一直,但也有一些所特有的数据类型和内置函数。

    以下内容大部分为The Chisel Book (dtu.dk)Chisel官方教程的相关内容。以便于学习,能力有限,部分描述可能不够准确,如果有问题欢迎大家指出,有条件的话还是推荐大家去阅读英文书籍,讲的很好!

    另外,由于不知名的原因CSDN写的字数过多之后无法正常插入图片,所以会将分成几部分来分享。

1 基本组成部分

1.1 数据类型和常量

    Chisel提供了3种数据类型来描述连接、组合逻辑和寄存器:Bits,UInt和SInt。UInt和SInt是Bits的拓展,并且所有的三个类型都代表bits的一个矢量。UInt表示无符号数,SInt表示有符号数。并且Chisel使用二进制的补码来表示有符号整数。下边是不同类型的定义:

Bits(8.W)
UInt(8.W)
SInt(10.W)

    通过Scala整型定义的常量可以转换为Chisel类型:

0.U    //定义一个UInt常量0
-3.S   //定义一个SInt常量-3

    通过使用Chisel位宽类型,常量可以被定义为指定位宽:

3.U(4.W)    //一个4bit位宽的常数3

    受益于Scala的类型推断,很多地方的类型信息可以省略。类似的也适用于位宽,在很多时候,Chisel会自动推断正确的宽度。所以,Chisel代码比VHDL或Verilog更简单。

    对于以除了10以外为基底表示的常量外,需要定义为字符串,h表示16进制,o是8进制,b是二进制。下边例子为不同基底表示255的方式,且省略了位宽,因为chisel会自动推断最小位宽来表示常量:

“hff”.U          //255的16进制表示
“o377”.U         //255的8进制表示
“b1111_1111”.U   //255的2进制表示

    以上也表示了如何使用下划线来分组表示常量,下划线会被忽略。

    Chisel中表示ASCII码编码格式的文本常量的格式如下:

扫描二维码关注公众号,回复: 15328641 查看本文章
val aChar = 'A'.U

    为了表示逻辑值,Chisel定义了Bool类型,可以表示为true和false,以下代码表示了Bool类型的定义和Bool常量的定义:

Bool()
true.B
false.B

1.2 组合逻辑电路

    Chisel使用布尔操作符来描述组合逻辑电路,以下代码定义了一个对a和b进行与逻辑然后在与c进行或逻辑的电路:

val logic = (a & b) | c

 Chisel的逻辑操作符:

val and = a & b  // bitwise and
val or = a | b   // bitwise or
val xor = a ˆ b  // bitwise xor
val not = ˜a     // bitwise negation

Chisel的算数操作符:

val add = a + b // addition
val sub = a - b // subtraction
val neg = -a    // negate
val mul = a * b // multiplication
val div = a / b // division
val mod = a % b // modulo operation

    加法和减法操作的结果宽度是操作数中的足底啊宽度,乘法操作是两个操作数之和,除法和余数操作时被除数的位宽。

    一个信号可以被先定义为某种类型,然后再给其赋一个值,使用:=

val w = Wire(UInt())
w := a & b

    单个bit的提取方式如下:

val sign = x(31)

    子字段可以从终点到起点提取:

val lowByte = largeWord(7, 0)

        位字段可以使用##操作符或cat操作符拼接:

val word = highByte ## lowByte
val word = Cat(highByt , lowByte)

Chisel定义的硬件操作符:

 Chisel定义的硬件函数,在v上调用:

 1.2.1 多路选择器

    Chisel中提供了一个选择器:

val result = Mux(sel, a, b)

如果sel为真的话,选择a,否则选择b。sel类型为Bool,a和b可以是任意基本类型,但类型需相同。通过逻辑操作、算数操作及多路选择器,每个组合逻辑都能被描述。

1.3 寄存器

    Chisel提供了一个D触发器的集合,这个寄存器隐含的连接到了全局时钟并在上升沿触发。当给定一个寄存器初始值时,将使用同步复位的方式连接到一个全局的复位信号。寄存器可以是任意一个表示多bit的Chisel数据类型。下边代码定义了一个同步复位为0的8bit位宽寄存器:

val reg = RegInit(0.U(8.W))

    一个输入可以通过:=操作符直接连接到寄存器来更新操作数,输出寄存器在表达式中可以仅通过名字调用:

reg := d
val q = reg

    寄存器也可以在定义的时候连接到输入:

val nextReg = RegNext(d)

    一个寄存器可以在直接连接到输入的同时并进行初始化值:

val bothReg = RegNext(d, 0.U)

 1.3.1 计数器

    以下代码表示一个计数器,当数到9的时候变为0,从0数到9:

val cntReg = RegInit(0.U(8.W))
cntReg := Mux(cntReg === 9.U, 0.U, cntReg + 1.U)

2.4 使用Bundle和Vec结构

    Chisel提供了两种结构来组合相关信号:1)Bundle和2)Vec。Bundle组合不同类型的信号,Vec组合相同类型的信号,两者创建新的用户自定义的Chisel类型并可以相互嵌套。

1.4.1 Bundle类型

    Chisel中的Bundle组合多个信号。完整的bundle可以看作是一个整体,但也可以通过他们的名字来单独访问,就像C中的struct一样。可以通过定义一个拓展了Bundle的类来定义一个bundle,并且在结构块中通过val列出:

class Channel() extends Bundle {
val data = UInt(32.W)
val valid = Bool()
}

    要使用bundle,可以使用new并把它包裹仅Wire。然后可以通过.来进行访问:

val ch = Wire(new Channel())
ch.data := 123.U
ch.valid := true.B
val b = ch.valid

    点标识是面向对象的常见做法,x.y表示x是一个对象的引用,y是对象的域

val channel = ch

1.4.2 Vec

    Chisel中的Vec代表一系列相同类型的信号,每个元素可以通过索引访问。Vec就像其他编程语言里的数组一样。

    Vec主要由三个用处:1)硬件中的动态寻址,类似于一个多路选择器;2)寄存器文件,包括读功能的复用和生成写是能信号;3)参数化模块的端口数量。对于其他东西的集合,无论是硬件元素还是其他生成数据,最好使用Scala中的Seq。

组合逻辑Vec

    Vec是用过有两个参数的构造函数来创建的:元素的数量和元素的类型。一个组合逻辑Vec需要被包装成Wire:

val v = Wire(Vec(3, UInt(4.W)))

使用索引即可访问单个元素,包装在wire中的向量可以看作是一个多路选择器:

v(0) := 1.U
v(1) := 3.U
v(2) := 5.U
val index = 1.U(2.W)
val a = v(index)

     一个当做多路选择器的例子:

val vec = Wire(Vec(3, UInt(8.W)))
vec(0) := x
vec(1) := y
vec(2) := z
val muxOut = vec(select)

 可以通过VecInit给Vec设定默认值,下边代码默认生成一个三选一选择器。注意第一个常数制定了大小是一个3bit的UInt数据类型,而且VecInt已经返回了Chisel硬件,所以不需要再打包成Wire。

val defVec = VecInit(1.U(3.W), 2.U, 3.U)
when (cond) {
defVec(0) := 4.U
defVec(1) := 5.U
defVec(2) := 6.U
}
val vecOut = defVec(sel)

除了直接给Vec设置常数外,还可以将信号连使用VecInit来初始化Vec

val defVecSig = VecInit(d, e, f)
val vecOutSig = defVecSig(sel)

寄存器Vec

   也可以将Vec打包为一个寄存器类型来定义寄存器阵列:

val regVec = Reg(Vec(3, UInt(8.W)))
val dout = regVec(rdIdx)
regVec(wrIdx) := din

 以上代码的等效电路示意图如下:

     以下代码定义了一个处理器的寄存器文件,为32个32位宽的起存器,如32位的RISC-V:

val registerFile = Reg(Vec(32, UInt(32.W)))

寄存器的访问:

registerFile(index) := dIn
val dOut = registerFile(index)

要初始化寄存器向量,需要将VecInit打包至RegInit:

val initReg = RegInit(VecInit(0.U(3.W), 1.U, 2.U))
val resetVal = initReg(sel)
initReg(0) := d
initReg(1) := e
initReg(2) := f

    如果想要将一个比较大的寄存器文件全部初始化为相同的值,可以使用Scala中的Seq。VecInit可以用包含Chisel类型的序列来构造,Seq包含一个生成函数fill来初始化序列成一样的值。以下代码构造了一个包含32个32位宽且全部初始化为0的寄存器堆;

val resetRegFile = RegInit(VecInit(Seq.fill(32)(0.U(32.W))))
val rdRegFile = resetRegFile(sel)

组合Bundle和Vec

    我们可以自由的组合bundle和vec,如果创建一个bundle类型的向量,我们需要传递一个向量场的原型。使用上边定义的Channel,可以建立一个channel的向量组:

val vecBundle = Wire(Vec(8, new Channel()))

一个bundle包也可能包含一个向量:

class BundleVec extends Bundle {
val field = UInt(8.W)
val vector = Vec(4,UInt(8.W))
}

当我们需要一个有初始值的包类型寄存器时,首先创建一个Wire类型的包,然后分别设置需要的域,最后将这个包传递给RegInit:

val initVal = Wire(new Channel())
initVal.data := 0.U
initVal.valid := false.B
val channelReg = RegInit(initVal)

    在Chisel3中,部分赋值值不允许的,尽管这个在Chisel2、Verilog和VHDL中是允许的:

val assignWord = Wire(UInt(16.W))
assignWord(7, 0) := lowByte
assignWord(15, 8) := highByte

争议的交点在于这种情况使用包会更好。一种解决该问题的方法是创建一个本地宝,创建一个包类型的Wire,分别连接它的每一个域,然后使用asUInt将其变为UInt类型,并且将和这个值赋给目标UInt。

val assignWord = Wire(UInt(16.W))
class Split extends Bundle {
val high = UInt(8.W)
val low = UInt(8.W)
}
val split = Wire(new Split())
split.low := lowByte
split.high := highByte
assignWord := split.asUInt()

这个方法的缺点在于需要直到包中域的顺序以便于合并到单个向量中去。另一种选择是使用Bool类型去分别连接然后转换为UInt类型:

val vecResult = Wire(Vec(4, Bool()))
// example assignments
vecResult(0) := data(0)
vecResult(1) := data(1)
vecResult(2) := data(2)
vecResult(3) := data(3)
val uintResult = vecResult.asUInt

1.5 Wire、Reg和IO

    UInt,SInt和Bits是Chisel类型,它们本身并不代表硬件。只有将它们包装秤Wire,Reg或IO才能生成硬件。Wire代表组合逻辑,Reg表示D触发器组成的寄存器,IO表示模块的连接。任何Chisel类型都能被打包为Wire,Reg或IO。

    通过将硬件组件分配给Scala的不可变变量可以将其命名:

val number = Wire(UInt())
val reg = Reg(SInt())

之后你可以使用Chisel操作符:=将值或者表达式分配给Wire,Reg或IO。

number := 10.U
reg := value - 3.U

需要注意Scala赋值操作符=和Chisel操作符:=之间的区别。在创建一个硬件对象(并且对其命名)的时候使用Scala的“=”操作符,但是在为现有硬件对象赋值或重赋值时使用Chisel的“:=”操作符。

    组合逻辑可以被条件赋值,但需要在每个条件分支下都被分配。否则将会被描述为一个锁存器,这是Chisel编译所不支持的。所以最好的方法是在创建Wire的时候给赋值一个初值:

val number = WireDefault(10.U(4.W))

    尽管Chisel会自动推断信号或寄存器位宽,但是在创建硬件对象时指定预期位宽是一个很好的实践,在大多数情况下,在复位时给寄存器赋初值也是非常有必要的:

val reg = RegInit(0.S(8.W))

2 搭建过程和测试

    Chisel是使用Scala写的,所以任何支持Scala搭建过程的工具都支持Chisel。一个比较流行的构建Scala的工具是sbt,是Scala Interactive Build Tool的缩写。除了驱动的搭建和测试过程,sbt还需要下载正确的Scala版本和Chisel库。

2.1 使用sbt搭建项目

    表示Chisel和Chisel测试器的Scala库将在构建过程中从Maven存储库中自动下载。这些库文件有build.sbt所引用。通过使用latest.release来配置build.sbt来表示总是使用最新的Chisel版本。然而,这意味着在每次构建时都需要从Maven库中查找最近新的版本,这需要互联网连接。更好的办法是使用一个指定的Chisel版本和其他所有Scala库。这时候就可以在没有互联网连接的情况下进行硬件设计。

2.1.1 源代码的组织

    sbt继承了Maven自动化构建工具的源文件管理原则。

 根目录下应该还包含build.sbt、Makefile、README和一个LICENSE文件。

    为了使用Chisel中命名空间的功能,你需要声明一个类/模块是在包中定义的,下边例子是声明mypack:

package mypack
import chisel3._
class Abc extends Module {
val io = IO(new Bundle{})
}

为了在不同的地方使用ABC模块,需要导入mypack组件,_表示通配符,表示mypack包下的所有类都被引用:

import mypack._
class AbcUser extends Module {
val io = IO(new Bundle{})
val abc = new Abc()
}

也可以不去引用mypack中的所有类别,而是使用全名mypack.ABC来表示mypack中的ABC模块:

class AbcUser2 extends Module {
val io = IO(new Bundle{})
val abc = new mypack.Abc()
}

也可以只导入一个类并创建它的实例:

import mypack.Abc
class AbcUser3 extends Module {
val io = IO(new Bundle{})
val abc = new Abc()
}

2.1.2 运行sbt

    一个Chisel项目可以通过一个简单的sbt命令来编译和执行:

$ sbt run

这个命令将编译源文件下的所有Chisel代码并所有包含main方法或拓展app的对象的类。如果有多个对象的话,所有的对象将会列出来供进行选择,同样的,你也可以直接将要执行的对象作为参数指定给sbt:

$ sbt "runMain mypacket.MyObject"

    默认情况下,sbt只搜索源代码树下的main方法而不管test部分。要执行ChiselTest的测试,可以运行:

$ sbt test

如果你有一个不遵循ChiselTest约定的测试,它包含了主函数但是被放置在源代码树的测试部分,那么可以使用以下sbt命令来执行测试:

$ sbt "test:runMain mypacket.MyMainTest"

2.1.3 生成Verilog

    为了合成可用于FPGA或ASIC的Chisel代码,需要将Chisel翻译成综合工具可以理解的硬件描述语言。使用Chisel,可以生成一个Verilog描述。

    为了生成Verilog描述,需要使用一个app。extends APP是一个Scala对象,其隐式的生成应用程序启动的主函数。该程序的唯一操作就是创建一个新的Chisel模块(例子中为Hello),然后将其传递给Chisel函数emitVerilog()。下边的代码生成一个Hello.v的Verilog代码:

object Hello extends App {
emitVerilog(new Hello())
}

    使用默认版本的emitVerilog()将把生成的文件放在项目根目录下。要将生成文件放到子文件夹下,需要为emitVerilog()指定选项。推荐指定generated文件夹。构建选项的第二个参数可以设置为一个字符串数组,下边的代码将生成一个Hello.v的Verilog文件并放在generated子文件夹下:

object HelloOption extends App {
emitVerilog(new Hello(), Array("--target -dir",
"generated"))
}

在不需要将Verilog代码写到文件时,可以将Verilog代码作为Scala的字符串保存,并可以通过打印出来进行测试:

object HelloString extends App {
val s = getVerilogString(new Hello())
println(s)
}

2.1.4 工具流程

     数字电路在Hello.scala文件中使用Chisel描述,Scala编译器将这个类和Chisel和Scala库一起编译,并生成可以由Java虚拟机(JVM)执行的Java类文件Hello.class。使用Chisel驱动器执行这个类,将会生成所谓的flexible intermediate representation for RTL(FIRRTL),是一种数字电路的中间表达形式。在这个例子中,指的是Hello.fir,FIRRTL编译器可以在电路上进行转换。

    Treadle是一个可以模拟电路的FIRRTL解释器。联合Chisel测试器,它可以被用来调试和测试Chisel电路。通过断言,我们可以提供测试结果。Treadle也可以生成波形文件,可以用波形查看器查看(如免费的GTKWave或Modelsim)。

    一个FIRRTL变换,Verilog发射器,可以生成可综合的Verilog代码(Hello.v)。电路综合工具(如Intel的Quartus,AMD的Xilinx Vivado,或ASIC工具)可用于综合电路。在FPGA的设计流程中,可以进一步生成比特流文件Hello.bit。

2.2 使用Chisel测试

    硬件设计的测试通常称为testbench,测试平台例化被测设计(DUT),驱动输入端口,观察输出端口,并与预期值进行比较。Chisel在chiseltest包中提提供了测试功能。

    Scala的一个有点是它可以使用Scala的全部功能来编写测试平台。例如可以在软件仿真器中编写硬件的预期功能,然后对硬件仿真和软件仿真进行比较。这个方法在测试处理器实现时是非常方便的。

2.2.1 Scala测试

    Scala测试时Scala(和Java)的测试工具,Chisel测试时Scala测试的拓展。所以,首先研究一个简单的Scala测试例子。要想使用它,需要在build.sbt中包含如下语句:

libraryDependencies += "org.scalatest" %% "scalatest" %
"3.1.4" % "test"

测试通常在src/test/scaala中找到,整个测试套件可以通过以下方式运行:

$ sbt test

一个测试Scala整数加法和乘法的最小测试如下:

import org.scalatest._
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class ExampleTest extends AnyFlatSpec with Matchers {
"Integers" should "add" in {
val i = 2
val j = 3
i + j should be (5)
}
"Integers" should "multiply" in {
val a = 3
val b = 4
a * b should be (12)
}
}

Scala测试使得简单的单元测试读起来像可执行规范一样。上面的实例包含两个测试,测试运行的输出将重复规范,并显示两个测试都通过了:

[info] ExampleTest:
[info] Integers
[info] - should add
[info] Integers
[info] - should multiply
[info] ScalaTest
[info] Run completed in 119 milliseconds.
[info] Total number of tests run: 2
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 2, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[info] Passed: Total 2, Failed 0, Errors 0, Passed 2

sbt test会执行所有的有效测试,这对于回归测试是很有用的。然而,如果你只想运行一个单独的测试,可以这样做:

$ sbt "testOnly ExampleTest"

2.2.2 Chisel测试

    ChiselTest是一个基于Scala和Java的ScalaTest工具的Chisel模块标准执行工具,可用于运行Chisel测试。要使用它,使用以下代码在build.sbt包含chiseltest库:

libraryDependencies += "edu.berkeley.cs" %% "chiseltest" %
"0.5.6"

以这种方式包含ChiselTest将自动的包含ScalaTest的必要版本。所以就不再需要包含ScalaTest了。另外要使用ChiselTest还需要导入以下包:

import chisel3._
import chiseltest._
import org.scalatest.flatspec.AnyFlatSpec

一个Chisel设计的例子如下:

class DeviceUnderTest extends Module {
val io = IO(new Bundle {
val a = Input(UInt(2.W))
val b = Input(UInt(2.W))
val out = Output(UInt(2.W))
val equ = Output(Bool())
})
io.out := io.a & io.b
io.equ := io.a === io.b
}

DUT的测试平台拓展了AnyFlatSpec和ChiselScalatestTester,它在Scala测试中提供了Chisel测试功能。调用test()方法时,将DUT作为参数,将测试代码作为函数字面。

class SimpleTest extends AnyFlatSpec with
ChiselScalatestTester {
"DUT" should "pass" in {
test(new DeviceUnderTest) { dut =>
dut.io.a.poke(0.U)
dut.io.b.poke(1.U)
dut.clock.step()
println("Result is: " + dut.io.out.peekInt())
dut.io.a.poke(3.U)
dut.io.b.poke(2.U)
dut.clock.step()
println("Result is: " + dut.io.out.peekInt())
}
}
}

DUT的输入输出端口将由dut.io访问。你可以通过poke一个端口来设置值,它将输入端口的Chisel类型的值作为参数。可以通过调用peekInt()或peekBoolean()来读取输出端口,这将返回一个Scala类型的返回值。测试器通过dut.clock.step()可以将仿真提前一个时钟周期。要想提前多个时钟周期,可以为step()提供一个参数。可以使用println()来打印输出的值。

    运行测试:

$ sbt "testOnly SimpleTest"

将看到结果打印在了终端:

...
Result is: 0
Result is: 2
[info] SimpleTest:
[info] DUT
[info] - should pass
...

除了手动检查打印输出外(这是一个很好的办法),还可以通过在输出端口上调用expect(value)并将期望值作为参数的方式表达我们的期望:

class SimpleTestExpect extends AnyFlatSpec with
ChiselScalatestTester {
"DUT" should "pass" in {
test(new DeviceUnderTest) { dut =>
dut.io.a.poke(0.U)
dut.io.b.poke(1.U)
dut.clock.step()
dut.io.out.expect(0.U)
dut.io.a.poke(3.U)
dut.io.b.poke(2.U)
dut.clock.step()
dut.io.out.expect(2.U)
}
}
}

    这种测试不会打印任何消息,但是如果通过了所有测试就表明是正确的:

    peek()函数返回的值是Chisel类型,需要经过转换后才能用作Scala类型。为了简化在Scala环境中使用测试值,ChiselTest支持peekInt()和peekBoolean()。下边的测试使用peekInt()读取输出,并返回一个用于assert()语句的Scala整数值。类似的,我们可以将equ的输出变为Scala的布尔值,直接用于assert语句。

class SimpleTestPeek extends AnyFlatSpec with
ChiselScalatestTester {
"DUT" should "pass" in {
test(new DeviceUnderTest) { dut =>
dut.io.a.poke(0.U)
dut.io.b.poke(1.U)
dut.clock.step()
dut.io.out.expect(0.U)
val res = dut.io.out.peekInt()
assert(res == 0)
val equ = dut.io.equ.peekBoolean()
assert(!equ)
}
}
}

2.2.3 波形

    要生成测试波形,可以像测试传递指令“writeVcd=1”,如以下sbt命令:

sbt "testOnly SimpleTest -- -DwriteVcd=1"

然后可以使用GTKWAVE或modelsim打开。

    此外,波形的生成也可以用过writeVcdAnnotation()注释传递给test()函数来启动:

class WaveformTest extends AnyFlatSpec with
ChiselScalatestTester {
"Waveform" should "pass" in {
test(new DeviceUnderTest)
.withAnnotations(Seq(WriteVcdAnnotation)) { dut =>
dut.io.a.poke(0.U)
dut.io.b.poke(0.U)
dut.clock.step()
dut.io.a.poke(1.U)
dut.io.b.poke(0.U)
dut.clock.step()
dut.io.a.poke(0.U)
dut.io.b.poke(1.U)
dut.clock.step()
dut.io.a.poke(1.U)
dut.io.b.poke(1.U)
dut.clock.step()
}
}
}

    显式的枚举所有可能输入值是不值得的。所以可以使用Scala代码来驱动DUT。下边测试美居乐所有可能的2输入2bit位宽的输入信号值:

class WaveformCounterTest extends AnyFlatSpec with
ChiselScalatestTester {
"WaveformCounter" should "pass" in {
test(new DeviceUnderTest)
.withAnnotations(Seq(WriteVcdAnnotation)) { dut =>
for (a <- 0 until 4) {
for (b <- 0 until 4) {
dut.io.a.poke(a.U)
dut.io.b.poke(b.U)
dut.clock.step()
}
}
}
}
}

可以执行:

$ sbt "testOnly WaveformCounterTest"

2.2.4 printf调试

    printf语句可插入模块定义的任何位置,如DUT的printf调试版本:

class DeviceUnderTestPrintf extends Module {
val io = IO(new Bundle {
val a = Input(UInt(2.W))
val b = Input(UInt(2.W))
val out = Output(UInt(2.W))
})
io.out := io.a & io.b
printf("dut: %d %d %d\n", io.a, io.b, io.out)
}

猜你喜欢

转载自blog.csdn.net/qq_38798111/article/details/129355526