Skip to content

Writing Scala Testbenches

Jim Lawson edited this page Sep 28, 2017 · 8 revisions

Chisel's Scala based testbench is the first line of defense against simple bugs in your design. The Scala testbench uses several unique Chisel constructs to perform this. To see how this works, let's first explore a simple example.

Scala Testbench Example

Below is the ByteSelector.scala module definition from the previous tutorial and the corresponding Chisel test harness.

package examples

import chisel3._

class ByteSelector extends Module {
  val io = IO(new Bundle {
    val in     = Input(UInt(32.W))
    val offset = Input(UInt(2.W))
    val out    = Output(UInt(8.W))
  })
  io.out := 0.U(8.W)
  when (io.offset === 0.U) {
    io.out := io.in(7,0)
  } .elsewhen (io.offset === 1.U) {
    io.out := io.in(15,8)
  } .elsewhen (io.offset === 2.U) {
    io.out := io.in(23,16)
  } .otherwise {
    io.out := io.in(31,24)
  }
}

class ByteSelectorTests(c: ByteSelector) 
    extends Tester(c) {
  val test_in = 12345678
  for (t <- 0 until 4) {
    poke(c.io.in,     test_in)
    poke(c.io.offset, t)
    step(1)
    expect(c.io.out, (test_in >> (t * 8)) & 0xFF)
  }
}

In the test harness ByteSelectorTests we see that the test portion is written in Scala with some Chisel constructs inside a Tester class definition. The device under test is passed to us as a parameter c.

In the for loop, the assignments for each input of the ByteSelector is set to the appropriate values using poke. For this particular example, we are testing the ByteSelector by hardcoding the input to some known value and checking if each of the 4 offsets returns the appropriate byte. To do this, on each iteration we generate appropriate inputs to the module and tell the simulation to assign this value to the input of the device we are testing c:

val test_in = 12345678
for (t <- 0 until 4) {
  // set in of the DUT to be some known word
  poke(c.io.in,     test_in)
  // set the offset of the DUT
  poke(c.io.offset, t)
  ...
}

Next we step the circuit. We next advance the simulation by calling the step function. This effectively advances the simulation one clock cycle in the presence of sequential logic.

step(1)

Finally, we check for expected outputs. In this case, we check the expected output of ByteSelector as follows:

expect(c.io.out, (test_in >> (t * 8)) & 0xFF)

This defines the reference output expected for this particular cycle of the simulation. Since the circuit we are testing is purely combinational, we expected that the output we define appears on any advancement of the simulation. The expect function will record either true or false after checking if the output generates the expected reference output. The results of successive expect's are anded into a Tester field called ok which starts out as true. The value of the ok field determines the success or failure of the tester execution.

Actually expect is defined in terms of peek roughly as follows:

def expect (data: Bits, expected: BigInt) = 
  ok = peek(data) == expected && ok

where peek gets the value of a signal from the DUT.

Simulation Debug Output

Now suppose we run the testbench for the ByteSelector defined previously. To do this, run ./run-examples.sh ByteSelector --is-verbose from the tutorials directory. We've added the is-verbose flag to get the actual sequence of peeks and pokes used during the test.

When we run the testbench, we will notice that the simulation produces debug output every time the step function is called. Each of these calls gives the state of the inputs and outputs to the ByteSelector and whether the check between the reference output and expected output matched as shown below:

Starting tutorial ByteSelector
[info] [0.006] Elaborating design...
[info] [0.201] Done elaborating.
Total FIRRTL Compile Time: 363.1 ms
Total FIRRTL Compile Time: 56.7 ms
End of dependency graph
Circuit state created
[info] [0.001] SEED 1505836830809
[info] [0.003]   POKE io_in <- 12345678
[info] [0.004]   POKE io_offset <- 0
[info] [0.004] STEP 0 -> 1
[info] [0.006] EXPECT AT 1   io_out got 78 expected 78 PASS
[info] [0.007]   POKE io_in <- 12345678
[info] [0.007]   POKE io_offset <- 1
[info] [0.007] STEP 1 -> 2
[info] [0.009] EXPECT AT 2   io_out got 97 expected 97 PASS
[info] [0.009]   POKE io_in <- 12345678
[info] [0.010]   POKE io_offset <- 2
[info] [0.010] STEP 2 -> 3
[info] [0.012] EXPECT AT 3   io_out got 188 expected 188 PASS
[info] [0.012]   POKE io_in <- 12345678
[info] [0.012]   POKE io_offset <- 3
[info] [0.012] STEP 3 -> 4
[info] [0.014] EXPECT AT 4   io_out got 0 expected 0 PASS
test ByteSelector Success: 4 tests passed in 9 cycles taking 0.028873 seconds
[info] [0.015] RAN 4 CYCLES PASSED
Tutorials passing: 1

Also notice that there is a final pass assertion "PASSED" at the end which corresponds to the allGood at the very end of the testbench. In this case, we know that the test passed since the allGood assertion resulted in a "PASSED". In the event of a failure, the assertion would result in a "FAILED" output message here.

General Testbench

In general, the scala testbench should have the following rough structure:

  • Set inputs using poke
  • Advance simulation using step
  • Check expected values using expect (and/or peek)
  • Repeat until all appropriate test cases verified

For sequential modules we may want to delay the output definition to the appropriate time as the step function implicitly advances the clock one period in the simulation. Unlike Verilog, you do not need to explicitly specify the timing advances of the simulation; Chisel will take care of these details for you.

Max2 Testbench

In this assignment, write a tester for the Max2 circuit (found in $TUT_DIR/src/main/scala/problems/Max2.scala ):

class Max2 extends Module {
  val io = IO(new Bundle {
    val in0 = Input(UInt(8.W))
    val in1 = Input(UInt(8.W))
    val out = Output(UInt(8.W))
  })
  io.out := Mux(io.in0 > io.in0, io.in0, io.in1)
}

by filling in the following tester (found in $TUT_DIR/src/test/scala/problems/Max2Tests.scala ):

class Max2Tests(c: Max2) extends PeekPokeTester(c) {
  for (i <- 0 until 10) {

    // Implement below ----------

    poke(c.io.in0, 0)
    poke(c.io.in1, 0)
    step(1)
    expect(c.io.out, 1)

    // Implement above ----------
  }
}

using random integers generated as follows:

// returns random int in 0..lim-1
val in0 = rnd.nextInt(lim) 

Run

./run-problem.sh Max2

until the circuit passes your tests.

Limitations of the Testbench

The Chisel testbench works well for simple tests and small numbers of simulation iterations. However, for larger test cases, the Chisel testbench quickly becomes more complicated and slower simply due to the inefficiency of the infrastructure. For these larger and more complex test cases, we recommend using the C++ emulator or Verilog test harnesses which run faster and can handle more rigorous test cases.

Prev (Instantiating Modules) Next (Creating Your Own Project)