ChiselTest
Chisel团队给测试框架做了很多工作,ChiselTest
提供了以下改进:
还有以下计划:
ChiselTest
和iotesters
一样都是从基本操作开始讲起,下面是一个简要的总结,关于旧的iotesters
和新的ChiselTest
中的基本功能特性的对应关系:
iotesters |
ChiselTest |
|
---|---|---|
poke |
poke(c.io.in1, 6) |
c.io.in1.poke(6.U) |
peek |
peek(c.io.out1) |
c.io.out1.peek() |
expect |
expect(c.io.out1, 6) |
c.io.out1.expect(6.U) |
step |
step(1) |
c.io.clock.step(1) |
initiate |
Driver.execute(...) { c => |
test(...) { c => |
下面还是先看之前写的一个简单的pass:
// 通过传入参数指定端口宽度的Chisel代码
class PassthroughGenerator(width: Int) extends Module {
val io = IO(new Bundle {
val in = Input(UInt(width.W))
val out = Output(UInt(width.W))
})
io.out := io.in
}
如果使用旧风格的测试,那么应该是这样的:
val testResult = Driver(() => new Passthrough()) {
c => new PeekPokeTester(c) {
poke(c.io.in, 0) // Set our input to value 0
expect(c.io.out, 0) // Assert that the output correctly has 0
poke(c.io.in, 1) // Set our input to value 1
expect(c.io.out, 1) // Assert that the output correctly has 1
poke(c.io.in, 2) // Set our input to value 2
expect(c.io.out, 2) // Assert that the output correctly has 2
}
}
assert(testResult) // Scala Code: if testResult == false, will throw an error
println("SUCCESS!!") // Scala Code: if we get here, our tests passed!
现在用新风格写(方便起见,这里把测试放在主程序里):
import chisel3._
import chisel3.util._
import chisel3.experimental._
import chisel3.experimental.BundleLiterals._
import chisel3.tester._
import chisel3.tester.RawTester.test
class MyModule(width: Int) extends Module {
val io = IO(new Bundle {
val in = Input(UInt(width.W))
val out = Output(UInt(width.W))
})
io.out := io.in
}
object MyModule extends App {
test(new MyModule(16)) { c =>
c.io.in.poke(0.U) // 输入设为0
c.clock.step(1) // 时钟步进
c.io.out.expect(0.U) // 输出应当为0
c.io.in.poke(1.U) // 输入设为1
c.clock.step(1) // 时钟步进
c.io.out.expect(1.U) // 输出应当为1
c.io.in.poke(2.U) // 输入设为2
c.clock.step(1) // 时钟步进
c.io.out.expect(2.U) // 输出应当为2
}
}
这里测试通过,但是会提示一个警告:
那就在sbt run
之前运行一句set scalacOptions += "-deprecation"
,想看看详情,结果直接就不显示警告了。
以上的例子中,需要注意以下几点。
ChiselTest
的测试方法需要的样板更少,以前的PeekPokeTester
已经内置到进程中了。
poke
和expect
方法现在是每个单独的io
元素的方法,这样可以给测试人员提供重要的提示,来更好地检查类型。peek
和step
操作现在已是io
元素上的方法。
还有个区别是poke
和expect
的值时Chisel字面值。虽然这里的例子很简单,但后面更多高级有趣的例子会展示更强大的检查功能。未来的改进中通过指定Bundle
字面值的能力可以进一步增强这一点。
这一部分会了解tester2
中的一些Decoupled
接口的工具。Decoupled
接收一个Chisel数据类型并给他提供ready
和valid
信号。ChiselTest
提供一些很棒的工具来自动化并可靠地测试这些接口。
QueueModule
传递由ioType
确定类型的数据。在QueueModule
内部有entries
状态元素,这意味着可以在推出数据之前容纳这么多元素。
case class QueueModule[T <: Data](ioType: T, entries: Int) extends MultiIOModule {
val in = IO(Flipped(Decoupled(ioType)))
val out = IO(Decoupled(ioType))
out <> Queue(in, entries)
}
注意这里的case
类修饰符一般是不需要的,例子中的是为了在Jupyter
环境的多个单元格中复用。
enqueueNow
和expectDequeueNow
ChiselTest
有一些内置的方法来处理在IO中有解耦合接口的电路。这里展示怎么向queue
中插入或从queue
中提取值。
方法 | 描述 |
---|---|
enqueueNow |
添加(排队)一个元素到一个Decoupled 输入接口 |
expectDequeueNow |
移出(出列)一个元素自一个Decoupled 输出接口 |
注意,这里需要一些样板如initSource
,setSourceClock
等,以此来确保ready
和valid
字段都在测试开始时正确初始化了。
import chisel3._
import chisel3.util._
import chisel3.experimental._
import chisel3.experimental.BundleLiterals._
import chisel3.tester._
import chisel3.tester.RawTester.test
case class QueueModule[T <: Data](ioType: T, entries: Int) extends MultiIOModule {
val in = IO(Flipped(Decoupled(ioType)))
val out = IO(Decoupled(ioType))
out <> Queue(in, entries)
}
object MyModule extends App {
test(QueueModule(UInt(9.W), entries = 200)) { c =>
c.in.initSource()
c.in.setSourceClock(c.clock)
c.out.initSink()
c.out.setSinkClock(c.clock)
val testVector = Seq.tabulate(200){ i => i.U }
testVector.zip(testVector).foreach { case (in, out) =>
c.in.enqueueNow(in)
c.out.expectDequeueNow(out)
}
}
}
教程这里还是有很多东西没讲清楚的,这里补充一下:
Decoupled(gen: Data)
:给gen
包装一个ready-valid
协议,测试中就是给UInt(9.W)
类型包装了ready
和valid
信号;
Flipped[T <: Data](source:T)
是把参数列表全都翻转过来,即输出变成输入,输入变成输出,比如:
class MyModule[T <: Data](ioType: T) extends MultiIOModule {
val in = IO(Flipped(Decoupled(ioType)))
val out = IO(Decoupled(ioType))
out <> in
}
object MyModule extends App {
println(getVerilogString(new MyModule(UInt(9.W))))
}
输出的Verilog代码如下:
module MyModule(
input clock,
input reset,
output in_ready,
input in_valid,
input [8:0] in_bits,
input out_ready,
output out_valid,
output [8:0] out_bits
);
assign in_ready = out_ready; // @[MyModule.scala 11:7]
assign out_valid = in_valid; // @[MyModule.scala 11:7]
assign out_bits = in_bits; // @[MyModule.scala 11:7]
endmodule
可以看到in
的三个接口分别为output
、input
和input
,与out
是反的。从这里也可以看到,通过Decoupled
包装的ready
信号是输入,valid
是输出。
<>
表示整体连接,这里就是把in
和out
的三个端口分别连接起来,很方便;
Queue(enq:DecoupleIO, entries:Int)
是一个Chisel硬件模块,创建一个entries
个元素的enq
的队列;
enqueueSeq
和expectDequeueSeq
现在介绍两个新方法来以单个操作完成排队和出列操作:
方法 | 描述 |
---|---|
enqueueSeq |
持续从一个Seq 添加元素到Decoupled 输入接口,一次一个,知道序列的元素用完了 |
expectDequeueSeq |
从Decoupled 输出接口移出元素,一次一个,并且和Seq 的下一个元素进行比较 |
下面这个例子还行,但是就像写的那样,enqueueSeq
必须在expectDequeueSeq
开始之前完成,如果testVector
大于队列的深度,那么这个运行就会出问题,因为队列会被填满没法插入新的元素,可以试试失败是啥样的。
import chisel3._
import chisel3.util._
import chisel3.experimental._
import chisel3.experimental.BundleLiterals._
import chisel3.tester._
import chisel3.tester.RawTester.test
case class QueueModule[T <: Data](ioType: T, entries: Int) extends MultiIOModule {
val in = IO(Flipped(Decoupled(ioType)))
val out = IO(Decoupled(ioType))
out <> Queue(in, entries)
}
object MyModule extends App {
test(QueueModule(UInt(9.W), entries = 200)) { c =>
c.in.initSource()
c.in.setSourceClock(c.clock)
c.out.initSink()
c.out.setSinkClock(c.clock)
val testVector = Seq.tabulate(100){ i => i.U }
c.in.enqueueSeq(testVector)
c.out.expectDequeueSeq(testVector)
}
}
现在把entries
改成100,队列改成200试试:
排队排不上,报了个超时错误,下一节会将怎么解决这个问题。
还有需要注意的是,enqueueNow
、enqueueSeq
、expectDequeueNow
和expectDequeueSeq
这些刚看到的函数并不是ChiselTest
中复杂的特殊情况逻辑,相反,他们是ChiselTest
鼓励大家从ChiselTest
原语中构建的,具体怎么使用这些方法可以看这里的定义:chiseltest/TestAdapters.scala at d199c5908828d0be5245f55fce8a872b2afb314e · ucb-bar/chiseltest · GitHub。
ChiselTest
中的fork
和join
这一部分将会介绍怎么同时运行一个单元测试的各个部分,因此首先要介绍两个testers2
的新特性:
方法 | 描述 |
---|---|
fork |
发射一个并发的代码块,额外的forks (分支)可以通过.fork 附加到前一个代码块的结尾来同时执行 |
join |
将多个相关的分支变成中 |
下面的例子有两个分支连在一起,然后join
到一起。在第一个fork
块中enqueueSeq
会继续添加元素直到耗尽,第二个fork
会在数据可用时,在每个时钟周期expectDequeueSeq
。
由fork
创建的线程以确定的顺序运行,主要根据代码指定的顺序执行,并且某些依赖于其他线程的容易出bug的操作会在运行时检查中禁止。
import chisel3._
import chisel3.util._
import chisel3.experimental._
import chisel3.experimental.BundleLiterals._
import chisel3.tester._
import chisel3.tester.RawTester.test
case class QueueModule[T <: Data](ioType: T, entries: Int) extends MultiIOModule {
val in = IO(Flipped(Decoupled(ioType)))
val out = IO(Decoupled(ioType))
out <> Queue(in, entries)
}
object MyModule extends App {
test(QueueModule(UInt(9.W), entries = 200)) { c =>
c.in.initSource()
c.in.setSourceClock(c.clock)
c.out.initSink()
c.out.setSinkClock(c.clock)
val testVector = Seq.tabulate(200){ i => i.U }
fork {
c.in.enqueueSeq(testVector)
}.fork {
c.out.expectDequeueSeq(testVector)
}.join()
}
}
fork
和join
实现GCD这一部分用fork
和join
方法实现GCD(Greatest Common Denominator,最大公约数)的测试。首先定义IO bundle,这里准备添加一点样板来允许使用Bundle
字面值,希望可以支撑支持字面值的代码的自动生成。
// 输入bundle
class GcdInputBundle(val w: Int) extends Bundle {
val value1 = UInt(w.W)
val value2 = UInt(w.W)
}
// 输出bundle
class GcdOutputBundle(val w: Int) extends Bundle {
val value1 = UInt(w.W)
val value2 = UInt(w.W)
val gcd = UInt(w.W)
}
现在来看GCD的Decoupled
版本,这里也可以使用Decoupled
包装器来给两个bundle添加ready
和valid
信号。Flipped
包装器接收一个会被默认创建为输出的Decoupled GcdInputBundle
并将每个字段都转换为相反的方向(递归的),这点在前面的补充中提到过的。Decoupled
的捆绑参数的数据元素放置在顶级字段bits
中。
/**
* 用辗转相减法计算GCD
* 两个寄存器xy中,用大的数减去小的数,小的数和差再存入寄存器,重复此过程直到两个数的差为0
* 此时寄存器x的值即为最大公约数
* 返回一个包,包含两个输入值和他们的GCD
*/
class DecoupledGcd(width: Int) extends MultiIOModule {
val input = IO(Flipped(Decoupled(new GcdInputBundle(width))))
val output = IO(Decoupled(new GcdOutputBundle(width)))
val xInitial = Reg(UInt())
val yInitial = Reg(UInt())
val x = Reg(UInt())
val y = Reg(UInt())
val busy = RegInit(false.B)
val resultValid = RegInit(false.B)
input.ready := ! busy
output.valid := resultValid
output.bits := DontCare // DontCare是一个单例对象,用于赋值给未驱动的端口或线网,防止编译器报错
when(busy) {
// 保证在计算的时候始终是大数减去小数
when(x > y) {
x := x - y
}.otherwise {
y := y - x
}
when(y === 0.U) {
// 当y值为0的时候结束计算
// 如果output已经准备好了,那就把有效的数据发送到output
output.bits.value1 := xInitial
output.bits.value2 := yInitial
output.bits.gcd := x
resultValid := true.B
busy := ! output.ready
}
}.otherwise {
when(input.valid) {
// 有效数据可用且没有正在进行的计算,获取新值并开始
val bundle = input.deq()
x := bundle.value1
y := bundle.value2
xInitial := bundle.value1
yInitial := bundle.value2
busy := true.B
resultValid := false.B
}
}
}
现在这个测试看起来和前面的Queue差不多了,但是还有一些事情要做。因为计算需要多个周期,因此在计算每个GCD是,输入的排队过程会阻塞。不过好消息是这方面的测试和之前的Decoupled
是一样简单且一致的。
这里还需要引入的是Chisel3中的Bundle
字面值符号,看下面这一行:
new GcdInputBundle(16).Lit(_.value1 -> x.U, _.value2 -> y.U)
上面定义的GcdInputBundle
有两个字段value1
和value2
,我们通过先创建一个Bundle
再调用它的.Lit()
方法来创建Bundle
字面值。这个方法接收一个键值对的变量参数列表,这里的键(key,这里是_.value1
)是字段名,值(value,这里是x.U
)是一个Chisel硬件字面值,Scala中的Int
x被转换到Chisel中的UInt
字面值,字段名前的_.
是必要的,不然不知道是这个Bundle
里面的。
这可能不是完美的符号,但是在广泛的开发讨论中,他被视为最小化样板代码和Scala中可用的符号限制之间的最贱平衡。
完整的代码如下:
import chisel3._
import chisel3.util._
import chisel3.experimental._
import chisel3.experimental.BundleLiterals._
import chisel3.tester._
import chisel3.tester.RawTester.test
class GcdInputBundle(val w: Int) extends Bundle {
val value1 = UInt(w.W)
val value2 = UInt(w.W)
}
class GcdOutputBundle(val w: Int) extends Bundle {
val value1 = UInt(w.W)
val value2 = UInt(w.W)
val gcd = UInt(w.W)
}
class DecoupledGcd(width: Int) extends MultiIOModule {
val input = IO(Flipped(Decoupled(new GcdInputBundle(width))))
val output = IO(Decoupled(new GcdOutputBundle(width)))
val xInitial = Reg(UInt())
val yInitial = Reg(UInt())
val x = Reg(UInt())
val y = Reg(UInt())
val busy = RegInit(false.B)
val resultValid = RegInit(false.B)
input.ready := ! busy
output.valid := resultValid
output.bits := DontCare
when(busy) {
when(x > y) {
x := x - y
}.otherwise {
y := y - x
}
when(y === 0.U) {
output.bits.value1 := xInitial
output.bits.value2 := yInitial
output.bits.gcd := x
resultValid := true.B
busy := ! output.ready
}
}.otherwise {
when(input.valid) {
val bundle = input.deq()
x := bundle.value1
y := bundle.value2
xInitial := bundle.value1
yInitial := bundle.value2
busy := true.B
resultValid := false.B
}
}
}
object MyModule extends App {
test(new DecoupledGcd(16)) { dut =>
dut.input.initSource().setSourceClock(dut.clock)
dut.output.initSink().setSinkClock(dut.clock)
val testValues = for { x <- 1 to 10; y <- 1 to 10} yield (x, y)
val inputSeq = testValues.map { case (x, y) =>
(new GcdInputBundle(16)).Lit(_.value1 -> x.U, _.value2 -> y.U)
}
val resultSeq = testValues.map { case (x, y) =>
new GcdOutputBundle(16).Lit(_.value1 -> x.U, _.value2 -> y.U, _.gcd -> BigInt(x).gcd(BigInt(y)).U)
}
fork {
dut.input.enqueueSeq(inputSeq)
}.fork {
for (expected <- resultSeq) {
dut.output.expectDequeue(expected)
dut.clock.step(5) // 在接收到下一输出前等待几个周期来创建backpressure
}
}.join()
}
}
注意以下几点:
这个test
里面的dut
和前面的c
是一样的,代表被测试的对象,即DUT(Device Under Test,被测器件),起什么名字都行;
这里的初始化有两种写法:
dut.input.initSource()
dut.input.setSourceClock(dut.clock)
dut.input.initSource().setSourceClock(dut.clock)
本质上是一样的,都是先初始化然后再设置时钟;
简单来说,上面的过程就是先创建Scala的值,然后转换为Bundle字面值的序列,再作为输入或用于比对的输出。