FIRRTL Cookbook
Motivation
Now that you are familiar with Scala, let's start carving out some hardware! Chisel stands for Constructing Hardware In a Scala Embedded Language. That means it is a DSL in Scala, allowing you to take advantage of both Scala and Chisel programming within the same code. It is important to understand which code is "Scala" and which code is "Chisel", but we will discuss that more later. For now, think of Chisel and the code in Module 2 as a better way to write Verilog. This module throws an entire Chisel Module
and tester at you. Just get the gist of it for now. You'll see plenty more examples later.
val path = System.getProperty("user.dir") + "/source/load-ivy.sc"
interp.load.module(ammonite.ops.Path(java.nio.file.FileSystems.getDefault().getPath(path)))
As mentioned in the last module, these statements are needed to import Chisel. Run this cell now before running any future code blocks.
import chisel3._
import chisel3.util._
import chisel3.iotesters.{ChiselFlatSpec, Driver, PeekPokeTester}
Your First Module
This section will present your first hardware module, a test case, and how to run it. It will contain many things that you will not understand, and that is OK. We want you to take away the broad strokes, so you can continually return to this complete and working example to reinforce what you've learned.
**Example: A Module**
Like Verilog, we can declare module definitions in Chisel. The following example is a Chisel Module
, Passthrough
, that has one 4-bit input, in
, and one 4-bit output, out
. The module combinationally connects in
and out
, so in
drives out
.
// Chisel Code: Declare a new module definition
class Passthrough extends Module {
val io = IO(new Bundle {
val in = Input(UInt(4.W))
val out = Output(UInt(4.W))
})
io.out := io.in
}
There's a lot here! The following explains how to think of each line in terms of the hardware we are describing.
class Passthrough extends Module {
We declare a new module called Passthrough
. Module
is a built-in Chisel class that all hardware modules must extend.
val io = IO(...)
We declare all our input and output ports in a special io
val
. It must be called io
and be an IO
object or instance, which requires something of the form IO(_instantiated_bundle_)
.
new Bundle {
val in = Input(...)
val out = Output(...)
}
We declare a new hardware struct type (Bundle) that contains some named signals in
and out
with directions Input and Output, respectively.
UInt(4.W)
We declare a signal's hardware type. In this case, it is an unsigned integer of width 4.
io.out := io.in
We connect our input port to our output port, such that io.in
drives io.out
. Note that the :=
operator is a Chisel operator that indicates that the right-hand signal drives the left-hand signal. It is a directioned operator.
The neat thing about hardware construction languages (HCLs) is that we can use the underlying programming language as a scripting language. For example, after declaring our Chisel module, we then use Scala to call the Chisel compiler to translate Chisel Passthrough
into Verilog Passthrough
. This process is called elaboration.
// Scala Code: Elaborate our Chisel design by translating it to Verilog
// Don't worry about understanding this code; it is very complicated Scala
println(getVerilog(new Passthrough))
Note that the Name of our module is cmd<#>WrapperHelperPassthrough
, which is an artifact of running this tutorial in Jupyter. In your normal code, its name should just be Passthrough
. This is an important lesson though - although Chisel does its best to preserve the names of your modules and other hardware components, sometimes it fails to do so.
**Example: A Module Generator**
If we apply what we learned about Scala to this example, we can see that a Chisel module is implemented as a Scala class. Just like any other Scala class, we could make a Chisel module take some construction parameters. In this case, we make a new class PassthroughGenerator
which will accept an integer width
that dictates the widths of its input and output ports:
// Chisel Code, but pass in a parameter to set widths of ports
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
}
// Let's now generate modules with different widths
println(getVerilog(new PassthroughGenerator(10)))
println(getVerilog(new PassthroughGenerator(20)))
Notice that the generated Verilog uses different bitwidths for the input/output depending on the value assigned to the width
parameter. Let's dig into how this works. Because Chisel Modules are normal Scala classes, we can use the power of Scala's class constructors to parameterize the elaboration of our design.
You may notice that this parameterization is enabled by Scala, not Chisel; Chisel has no extra APIs for parameterization, but a designer can simply leverage Scala features to parameterize his/her designs.
Because PassthroughGenerator
no longer describes a single Module, but instead describes a family of modules parameterized by width
, we refer to this Passthrough
as a generator.
Testing Your Hardware
No hardware module or generator should be complete without a tester. Chisel has built-in test features that you will explore throughout this bootcamp. The following example is a Chisel test harness that passes values to an instance of Passthrough
's input port in
, and checks that the same value is seen on the output port out
.
**Example: A Tester**
There is some advanced Scala going on here. However, there is no need for you to understand anything except the poke
and expect
commands. You can think of the rest of the code as simply boilerplate to write these simple tests.
// Scala Code: Calling Driver to instantiate Passthrough + PeekPokeTester and execute the test.
// Don't worry about understanding this code; it is very complicated Scala.
// Think of it more as boilerplate to run a Chisel test.
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!
What's going on? The test accepts a Passthrough
module, assigns values to the module's inputs, and checks its outputs. To set an input, we call poke
. To check an output, we call expect
. If we don't want to compare the output to an expected value (no assertion), we can peek
the output instead.
If all expect
statements are true, then our boilerplate code will return true (see testResult
).
**Exercise: Writing Your Own Testers**
Write and execute two tests, one that tests PassthroughGenerator
for a width of 10 and a second that tests PassthroughGenerator
for a width of 20. Check at least two values for each: zero and the maximum value supported by the specified width. Note that the triple question mark has a special meaning in Scala. You may see it frequently in these bootcamp exercises. Running code with the ???
will produce the NotImplementedError
. Replace ???
with your testers.
val test10result = ???
val test20result = ???
assert((test10result == true) && (test20result == true))
println("SUCCESS!!") // Scala Code: if we get here, our tests passed!
val test10result = Driver(() => new PassthroughGenerator(10)) { c => new PeekPokeTester(c) { poke(c.io.in, 0) expect(c.io.out, 0) poke(c.io.in, 1023) expect(c.io.out, 1023) } } val test20result = Driver(() => new PassthroughGenerator(20)) { c => new PeekPokeTester(c) { poke(c.io.in, 0) expect(c.io.out, 0) poke(c.io.in, 1048575) expect(c.io.out, 1048575) } }
Looking at Generated Verilog/FIRRTL
If you are having trouble understanding the generated hardware and are comfortable with reading structural Verilog and/or FIRRTL (Chisel's IR which is comparable to a synthesis-only subset of Verilog), then you can try looking at the generated Verilog to see the result of Chisel execution.
Here is an example of generating the Verilog (which you've seen already) and the FIRRTL.
// Viewing the Verilog for debugging
println(getVerilog(new Passthrough))
// Viewing the firrtl for debugging
println(getFirrtl(new Passthrough))
Appendix: A Note on "printf" Debugging
Debugging with print statements is not always the best way to debug, but is often an easy first step to see what's going on when something doesn't work the way you expect. Because Chisel generators are programs generating hardware, there are some extra subtleties about printing generator and circuit state. It is important to remember when your print statement executes and what is being printed. The three common scenarios where you might want to print have some important differences:
- Chisel generator prints during circuit generation
- Circuit prints during circuit simulation
- Tester prints during testing
println
is a built-in Scala function that prints to the console. It cannot be used to print during circuit simulation because the generated circuit is FIRRTL or Verilog- not Scala.
The following code block shows different styles of printing.
class PrintingModule extends Module {
val io = IO(new Bundle {
val in = Input(UInt(4.W))
val out = Output(UInt(4.W))
})
io.out := io.in
printf("Print during simulation: Input is %d\n", io.in)
// chisel printf has its own string interpolator too
printf(p"Print during simulation: IO is $io\n")
println(s"Print during generation: Input is ${io.in}")
}
class PrintingModuleTester(c: PrintingModule) extends PeekPokeTester(c) {
poke(c.io.in, 3)
step(5) // circuit will print
println(s"Print during testing: Input is ${peek(c.io.in)}")
}
chisel3.iotesters.Driver( () => new PrintingModule ) { c => new PrintingModuleTester(c) }