A Style Guide for the Chisel Hardware Construction Language.
Code is meant to be read, not written. You will spend more time searching for bugs, adding features to existing code bases, and trying to learn what other people have done, than you will writing your own code from scratch. Code should strive to be easy to understand and easy to maintain.
As style can be a deeply personal preference, and because Chisel is still a very young language, this guide will eschew making hard edicts on DOs and DONTs. Instead, this guide will strive to provide guidance to newcomers to Chisel through a discussion on best practices.
Chisel is a DSL embedded in Scala. However, it is still a distinct language, and so it may not follow all of Scala's conventions.
For examples of Chisel style, I recommend reading the source code to the Hwacha vector unit, the rocket-chip uncore, and the BOOM out-of-order processor. Although they are all Berkeley-originated projects, each explores some very interesting (and different!) techniques to describing hardware generators.
Feedback requested!
- Spacing
- Naming
- Registers
- Bundles
- Literals
- Parameters
- Ready/Valid Interfaces
- Vector of Modules
- Val versus Var
- Private versus Public
- Imports
- Comments
- Assertions
- Requires
- Additional Best Practices
Spaces, not tabs. Never tabs.
Follow the indent level of the existing code.
Variable names should tend to be descriptive and not overly abbreviated. The smaller the scope (and the more used it is), the more abbreviated the name can be.
Bundles used for I/Os should be named SomethingIO. IO comes last. The name is Camel-cased. Example: (FreeListIO).
Any variable that is used for debugging purposes should be begin with the prefix debug_
(i.e., things that you ideally would not synthesize).
Constants/parameters should be named in all caps, demonstrating their global nature. While most things in Scala are immutable, Chisel isn’t Scala.
Constants should be all uppercase and should be put in a companion object:
object ALU {
val SZ_ALU_FN = 4
FN_ADD = UInt(0, SZ_ALU_FN)
...
}
Or trait (if you want a Module or Bundle to extend the trait):
trait RISCVConstants {
val RD_MSB = 11
val RD_LSB = 7
}
Registers (and their type) should be specified as follows:
Reg(UInt()) // good!
Reg(UInt(8.W)) // also good!
Reg(chiselTypeOf(io.my_signal) // good!
This construct Reg(x)
tells the Reg to be of type x
. It does NOT tell Reg what initial value it should be, nor does it add a Delay
to a signal!
Reg(UInt(0)) // bad! Returns a Reg of type UInt and unknown width.
Reg(UInt(0,15)) // bad! Returns a Reg of type UInt with width 15.
Reg(io.my_signal) // bad. This makes a Reg of type io.my_signal, but the intention is not clear!
// It can be easily misread as Reg(next=io.my_signal).
Registers should be initialized as follows:
RegInit(0.U(15.W)) // good, initialized register to value 0 with width 15
Reg(0.U(15.W)) // WRONG! This will be an elaboration error
Delaying a Node (i.e., piping it into a register) should be performed as follows:
RegNext(io.my_signal) // good
Reg(io.my_signal) // WRONG! Creates a Reg of the same type as io.a, this will be an elaboration error
And commit the following to memory. It's Reg
of Vec
, not Vec
of Reg
, for example
val lotsOfRegs = RegInit(VecInit(10, UInt(32.W)))
Consider providing def
functions in your Bundles. It provides a clearer level of intention to the user of how to interact with the Bundle.
// simplified example
class DecoupledIO extends Bundle {
val ready = Input(Bool())
val valid = Output(Bool())
def fire: Bool = ready && valid
....
In some older chisel code you may see
def fire(dummy: Int = 0): Bool
the dummy argument is no longer necessary
Users of the DecoupledIO can now do something like when(io.deq.fire())
! (note: the dummy: Int = 0
argument must be provided to functions with no arguments placed within Bundles, as Chisel is (currently) unable to differentiate between fields that are wires and fields that are functions with no arguments).
Or this example, which performs a query
against a TLB address translation structure:
class TLBIO extends VMUBundle {
val req = Decoupled(new rocket.TLBReq)
val resp = new rocket.TLBRespNoHitIndex().flip
def query(vpn: UInt, store: Bool): Bool = {
this.req.bits.vpn := vpn
this.req.bits.asid := 0.U
this.req.bits.passthrough := false.B
this.req.bits.instruction := false.B
this.req.bits.store := store
this.req.ready && !this.resp.miss
}
}
The particular example is quite interesting - the query
function provides a clearer interface to the user, it automatically sets up the request signals, and it provides a combinational return value to the caller!
Consider breaking off Conditional I/O fields into a separate Bundles (FreeListRollbackIO and FreeListSingleCycleIO).
Be careful of using Scala Ints to describe Chisel literals. 0xffffffff
is a 32-bit signed integer with value -1, and thus will throw an error when used as UInt(0xffffffff, 32)
. Instead, use Strings to describe large literals:
UInt("hffffffff", 32)
When instantiating an object from another package, explicitly name the arguments:
val s2d = Module(new hardfloat.RecFNToRecFN(inExpWidth = 8, inSigWidth = 24, outExpWidth = 11, outSigWidth = 53))
This safe-guards against the order (or the name) of parameters changing in an external package without your knowledge.
A ready signal denotes a resource is available/is ready to be utilized.
A valid signal denotes something is valid and can commit a state update (it will commit a state update if the corresponding ready signal is high).
Performance tip: a valid signal may often be a late arriving signal. Try to avoid using valid signals to drive datapath logic, and instead use valid signals to gate off state updates.
A valid signal should not depend on the ready signal (unless you really know what you are doing). This hurts the critical path and can create combinational loops if both sides get coupled.
An array of modules can be instantiated as follows:
val my_args = Seq(1,2,3,4)
val exe_units = for (i <- 0 until num_units) yield {
val exe_unit = Module(new AluExeUnit(args = my_args(i)))
// any wiring or other logic can go here
exe_unit
}
You can provide different input parameters to each constructor as required (the above toy example shows different elements of my_args
being provided to each AluExeUnit
).
The disadvantage is you cannot index the collection using Chisel nodes (aka, you can not dynamically index the collection during hardware execution). If you must use a Scala collection (for the first advantage), you can still use dynamic indexing by grabbing a Vec of the IOs:
val exe_units_io = Vec(exe_units.map(_.io))
If you need to index the vector of Modules using a Chisel node, you can also use the following structure:
val table = Vec.fill(num_elements) { Module(new TableElement()).io }
val idx = Wire(UInt())
table(idx).wen := true.B // indexed by a Chisel node!
Note that table
is actually a Vec
of TableElement
I/O
bundles.
Only use val
, unless you are an experienced Chisel programmer. Even then, only use var
in constrained situations (try to abstract it within a function). The use of the var
can make it difficult to reason about your design.
For context, a bit more background is needed. A hardware design described in Chisel is quite literally a Scala program that, when executed, generates a hardware graph composed of Chisel Nodes that is then passed to a back-end which generates a cycle-exact replica in either C++ or Verilog (or whatever other formats supported by the backend).
Thus, val
and var
denote Scala variables (well more exactly, val
is an immutable value and var
is a mutable variable).
val my_node = Wire(UInt())
This is a Scala value called my_node
, which points to a Chisel Node in the hardware graph that is a Wire
of type UInt
. The my_node
value can only ever point to this particular Chisel Node in the graph.
var node_ptr = Wire(UInt())
Uh oh. The Scala variable node_ptr
is pointing to a Chisel node in the graph, but it can later be changed to point to a new Chisel node!
var node_ptr = io.a
node_ptr := true.B
node_ptr = io.b
node_ptr := false.B
In the above (scary!) code,
node_ptr
first points toio.a
,- then uses the Chisel assignment operator
:=
to setio.a
totrue.B
node_ptr
is then changed to point toio.b
- and finally,
io.b
is set tofalse.B
!
We get the following Verilog output:
module Hello(
output io_a,
output io_b
);
assign io_b = 1'h0;
assign io_a = 1'h1;
endmodule
Using var
can make it difficult to reason about the circuit. And be CAREFUL when mixing =
and :=
! The =
is a Scala assignment, and sets a var
variable to point to a new Node in the graph. Meanwhile, :=
is a Chisel assignment and performs a new assignment to the Chisel Node. This distinction is important! For example, Chisel conditional when
statements are for conditionally assigning values to Chisel Nodes - the scala =
operator is invisible to when
statements!!
var my_node = io.a
my_node := true.B // this sets io.a to "true"
my_node = true.B // this sets the Scala variable my_node to point to a Chisel node that is a literal true
Consider the incorrect code below, which tries to mix when
, var
, and =
to perform an OR reduction:
val my_bits = Wire(UInt(n.W))
var temp = false.B
for (i <- 0 until n) {
when (my_bits(i)) {
temp = true.B // wrong! always returns true.
temp := true.B // compiler error!
}
}
For the first statement temp = true.B
, the Scala variable temp
points to the Chisel node true.B
, ignoring the when() statement.
For the second statement temp := true.B
, a Chisel compiler error is thrown because the code is trying to reassign the node false.B
to be true.B
, which is nonsensical.
Conclusion: don't mix when
and var
's!
For completness sake, the proper code for an OR reduction would be my_bits.orR
(and no need to use var or when!).
If you don’t understand why var
and when()
don’t mix, then for the love of god AVOID var
.
There are a few valid uses of var. One would be to generate cascading logic. For example, this locking arbiter from ChiselUtil:
var choose = (n-1).U
for (i <- n-2 to 0 by -1) {
choose = Mux(io.in(i).valid, i.U, choose)
}
chosen := Mux(locked, lockIdx, choose)
After each iteration of the Scala for
loop, choose
is pointing to a new node in the cascading Mux tree.
Another use is forward declaring Modules that are conditionally instantiated later.
var fpu: FPUUnit = null
if (has_fpu) {
fpu = Module(new FPUUnit())
...
Try to avoid wildcard imports. They make code more obfuscated and fragile.
import rocket.{UseFPU, XLen}
import cde.{Parameters, Field}
AVOID using import
statements for bringing in new Module and Bundle definitions. Instead, explicitly invoke the namespace when instantiating the Module or Bundle. It makes the origin of the object clear.
//bad
import rocket._
...
val tlb = Module(new TLB())
//good
val tlb = Module(new rocket.TLB())
There are exceptions to this rule. It is usually best to use the wild card with chisel3, as in
import chisel3._
There are many useful things in chisel such as the support for the .U
and .S
constructs.
It's easier to just make them all available, and it is likely that the import suggestion mechanism in an IDE will not
suggest correctly.
Though you may see
import Chisel
with various endings in some examples (or sometimes suggested by an IDE), we recommend that you not use it. Chisel with a capital 'C' is a backward compatibility mode for Chisel2,
By default, all val
s and def
s are public in Scala.
Label all def
s private if the scope is meant to stay internal to the current object.
This makes intention clearer.
Consider commenting the use of the I/O fields (especially if there are unintuitive timings!). Chisel I/Os aren’t functions - it isn’t obvious how to interface with a Module to other programmers.
class CpuReq extends Bundle {
val addr = UInt(someWidth.W)
val cmd = UInt(someWidth.W)
val data = UInt(someWidth.W) // is sent the cycle after the request is valid
In fact, you may prefer to codify timings in the names of the signals themselves:
val io = new Bundle {
// send read addr on cycle 0, get data out on cycle 2.
val s0_r_idx = Input(UInt(index_sz.W))
val s2_r_out = Output(UInt(fetch_width.W))
If your code required cleverness to write, you should probably describe why it does what it does. The reader is never as smart as the writer. Especially when it’s yourself.
If you solve a bug, strongly contemplate what assert()
could have caught this bug and then add it.
If you are using a one-hot encoding, guard it with asserts! Especially calls to OHToUInt
.
assert(PopCount(updates_oh) == 1.U, "[MyModuleName] ...")
Note which Module the assert resides in when authoring the failure string.
Scala provides a require()
function that will throw a Scala run-time error when compiling your Chisel hardware if the condition is not met.
Use require()
statements to guard against unsupported parameter values in your hardware generators.
Use require()
statements to codify your assumptions in your code (e.g., require(isPow2(num_entries))
for logic that only works when num_entries
is a power of 2).
If you ever write +N
, ask yourself if the number will ever be NonPow2
(and then you should write a require
statement if the logic depends on Pow2
properties!). For example, wrap-around logic will be needed to guard incrementing pointers to queues with NonPow2
number of elements. Just like in software, overflows and array bounds overruns are scary!
Consider restraining any undefined hardware behavior. It makes writing asserts easier. Undefined behavior may provide for a more efficient circuit, but a circuit that works is even more efficient!