Chisel
Scala
Digital Design
Programming
For Chisel and Scala
Case Study - RISC-V CPU Design
一開始在學 Chisel,不管是課程的範例或是 The Chisel Book 上面的範例,都是用 chisel.util.Enum
這個方式來宣告 states,但後來看 Chisel 官方文件的時候,發現都是用下一小節提到的 ChiselEnum
的方法,目前還不太清楚差別在哪,個人認為應該 chisel.util.Enum
是比較舊的寫法 (The Chisel Book 2ed 就出現了),而 ChiselNnum
是目前官方文件上的推薦用法,所以應該是比較新的寫法,但還不太確定就是了。
峻豪
我覺得 chisel.util.Enum
應該就類似 C/C++ 的 Enum,他就是單純把你宣告的變數名稱 encode 成對應的整數,所以 chisel.util.Enum
沒有辦法被放進 IO/Bundle 裡面,但是 ChiselEnum
就可以,彈性比較大,可以讓 code 的可讀性更高。
Use val ... = Enum()
import chisel3._
import chisel3.util._
class SimpleFsm extends Module {
val io = IO(new Bundle{
val badEvent = Input(Bool ())
val clear = Input(Bool ())
val ringBell = Output(Bool ())
})
// The three states
val green :: orange :: red :: Nil = Enum (3)
// The state register
val stateReg = RegInit(green)
// Next state logic
switch (stateReg) {
is (green) {
when(io.badEvent ) {
stateReg := orange
}
}
is (orange) {
when(io.badEvent ) {
stateReg := red
} .elsewhen(io.clear) {
stateReg := green
}
}
is (red) {
when (io.clear) {
stateReg := green
}
}
}
// Output logic
io. ringBell := stateReg === red
}
Use ChiselEnum
import chisel3._
import chisel3.util.{switch, is}
import chisel3.experimental.ChiselEnum
object DetectTwoOnes {
object State extends ChiselEnum {
val sNone, sOne1, sTwo1s = Value
}
}
/* This FSM detects two 1's one after the other */
class DetectTwoOnes extends Module {
import DetectTwoOnes.State
import DetectTwoOnes.State._
val io = IO(new Bundle {
val in = Input(Bool())
val out = Output(Bool())
val state = Output(State())
})
val state = RegInit(sNone)
io.out := (state === sTwo1s)
io.state := state
switch (state) {
is (sNone) {
when (io.in) {
state := sOne1
}
}
is (sOne1) {
when (io.in) {
state := sTwo1s
} .otherwise {
state := sNone
}
}
is (sTwo1s) {
when (!io.in) {
state := sNone
}
}
}
}
Note: the is statement can take multiple conditions e.g. is (sTwo1s, sOne1) { … }.
Chisel
ChiselEnum
引述官方的文件敘述
The ChiselEnum type can be used to reduce the chance of error when encoding mux selectors, opcodes, and functional unit operations. In contrast with Chisel.util.Enum, ChiselEnum are subclasses of Data, which means that they can be used to define fields in Bundles, including in IOs.
Chisel
從上面敘述最後一段看起來,ChiselEnum
應該是一個可以取代 Chisel.util.Enum
的存在,因為它的限制更少 (e.g. 可以包在 Bundle, IO 裡面)
我認為現在看到 ChiselEnum
很好用的地方應該在於定義 Opcode 或是 Mux Seletor,以下有兩個 example,一個是針對 MUX,一個是針對 RISC-V OPCODE
因為 ChiselEnum
是 Data
的 subclass,所以可以被直接包在 Bundle 內,利用這樣的方式來定義 OPCODE 我覺得會比之前利用 val
的方式來定義還要來得容易看懂電路描述的邏輯
Example of Mux
// package CPUTypes {
object AluMux1Sel extends ChiselEnum {
val selectRS1, selectPC = Value
}
// We can see the mapping by printing each Value
AluMux1Sel.all.foreach(println)
// AluMux1Sel(0=selectRS1)
// AluMux1Sel(1=selectPC)
class AluMux1Bundle extends Bundle {
val aluMux1Sel = Input(AluMux1Sel())
val rs1Out = Input(Bits(32.W))
val pcOut = Input(Bits(32.W))
val aluMux1Out = Output(Bits(32.W))
}
class AluMux1File extends Module {
val io = IO(new AluMux1Bundle)
// Default value for aluMux1Out
io.aluMux1Out := 0.U
switch (io.aluMux1Sel) {
is (selectRS1) {
io.aluMux1Out := io.rs1Out
}
is (selectPC) {
io.aluMux1Out := io.pcOut
}
}
}
Example of Defining RV32I Opcode
object Opcode extends ChiselEnum {
val load = Value(0x03.U) // i "load" -> 000_0011
val imm = Value(0x13.U) // i "imm" -> 001_0011
val auipc = Value(0x17.U) // u "auipc" -> 001_0111
val store = Value(0x23.U) // s "store" -> 010_0011
val reg = Value(0x33.U) // r "reg" -> 011_0011
val lui = Value(0x37.U) // u "lui" -> 011_0111
val br = Value(0x63.U) // b "br" -> 110_0011
val jalr = Value(0x67.U) // i "jalr" -> 110_0111
val jal = Value(0x6F.U) // j "jal" -> 110_1111
}
object BranchFunct3 extends ChiselEnum {
val beq, bne = Value
val blt = Value(4.U)
val bge, bltu, bgeu = Value
}
// We can see the mapping by printing each Value
BranchFunct3.all.foreach(println)
// BranchFunct3(0=beq)
// BranchFunct3(1=bne)
// BranchFunct3(4=blt)
// BranchFunct3(5=bge)
// BranchFunct3(6=bltu)
// BranchFunct3(7=bgeu)
在 Chisel 中,常用的電路行為可以直接定義成一個 function,以下引述自文件
We can define functions to factor out a repeated piece of logic that we later reuse multiple times in a design.
Chisel
Example
def clb(a: UInt, b: UInt, c: UInt, d: UInt): UInt =
(a & b) | (~c & d)
在 scala 中,函數的最後一個 expression 會被 return(這是 Functional Programming 的特性),所以呼叫這個 clb()
function 時,就會 return (a & b) | (~c & d)
的結果。
val out = clb(a,b,c,d)
在 scala 中,object 本來就有一個 pre-existing 的 function(method) 叫做 apply()
,當呼叫一個定義好的 object 時,會一併呼叫 apply()
,所以可以透過這個方法,來把一個普通的 Circuit Module 變成 Functional Module (我自己的理解是讓這個 module 可以像是 function 一樣被呼叫,然後有 return value)。以下引述自官方文件
Objects in Scala have a pre-existing creation function (method) called apply. When an object is used as value in an expression (which basically means that the constructor was called), this method determines the returned value. When dealing with hardware modules, one would expect the module output to be representative of the hardware module’s functionality. Therefore, we would sometimes like the module output to be the value returned when using the object as a value in an expression. Since hardware modules are represented as Scala objects, this can be done by defining the object’s apply method to return the module’s output. This can be referred to as creating a functional interface for module construction.
Chisel
Mux2
,並且定義 Mux2
object 的 apply()
function
import chisel3._
class Mux2 extends Module {
val io = IO(new Bundle {
val sel = Input(Bool())
val in0 = Input(UInt())
val in1 = Input(UInt())
val out = Output(UInt())
})
io.out := Mux(io.sel, io.in0, io.in1)
}
object Mux2 {
def apply(sel: UInt, in0: UInt, in1: UInt) = {
val m = Module(new Mux2)
m.io.in0 := in0
m.io.in1 := in1
m.io.sel := sel
m.io.out
}
}
Mux4
class Mux4 extends Module {
val io = IO(new Bundle {
val in0 = Input(UInt(1.W))
val in1 = Input(UInt(1.W))
val in2 = Input(UInt(1.W))
val in3 = Input(UInt(1.W))
val sel = Input(UInt(2.W))
val out = Output(UInt(1.W))
})
val m0 = Module(new Mux2)
m0.io.sel := io.sel(0)
m0.io.in0 := io.in0
m0.io.in1 := io.in1
val m1 = Module(new Mux2)
m1.io.sel := io.sel(0)
m1.io.in0 := io.in2
m1.io.in1 := io.in3
val m3 = Module(new Mux2)
m3.io.sel := io.sel(1)
m3.io.in0 := m0.io.out
m3.io.in1 := m1.io.out
io.out := m3.io.out
}
Mux4
,可以讓 code 簡潔很多
class Mux4 extends Module {
val io = IO(new Bundle {
val in0 = Input(UInt(1.W))
val in1 = Input(UInt(1.W))
val in2 = Input(UInt(1.W))
val in3 = Input(UInt(1.W))
val sel = Input(UInt(2.W))
val out = Output(UInt(1.W))
})
io.out := Mux2(io.sel(1),
Mux2(io.sel(0), io.in0, io.in1),
Mux2(io.sel(0), io.in2, io.in3))
}
以下引述自官方文件
Chisel now supports a DontCare element, which may be connected to an output signal, indicating that that signal is intentionally not driven. Unless a signal is driven by hardware or connected to a DontCare, Firrtl will complain with a “not fully initialized” error.
Chisel
以前的 FIRRTL Compiler 不支援沒有被驅動的 output signal/wires,所以一旦有 output signal 或是 wires 沒有被初始化,就會出現 “not fully initialized” error,但是後來的 Chisel 支援了 DontCare
這個關鍵字,可以用在那些不會被驅動的信號,讓 compiler 不會出現錯誤。
Example 1
class Out extends Bundle {
val debug = Bool()
val debugOption = Bool()
}
val io = new Bundle { val out = new Out }
// assign DontCare to io.out.debutOption
io.out.debug := true.B
io.out.debugOption := DontCare
Example 2 - Use bunk connection
import chisel3._
class ModWithVec extends Module {
// ...
val nElements = 5
val io = IO(new Bundle {
val outs = Output(Vec(nElements, Bool()))
})
io.outs <> DontCare
// ...
}
class TrivialInterface extends Bundle {
val in = Input(Bool())
val out = Output(Bool())
}
class ModWithTrivalInterface extends Module {
// ...
val io = IO(new TrivialInterface)
io <> DontCare
// ...
}
以下引述自官方文件
Chisel contains two connection operators,
:=
and<>
. This document provides a deeper explanation of the differences of the two and when to use one or the other. The differences are demonstrated with experiments using Scastie examples which useDecoupledIO
.
Chisel
直接說結論
<>
is Commutative:=
means assign ALL LHS signals from the RHS, regardless of the direction on the LHS.:=
to assign DontCare to Wires<>
or :=
to assign DontCare to directioned things (IOs<>
works between things with at least one known flow (An IO or child’s IO).<>
and :=
connect signals by field name.實驗的過程可以參考:[Deep Dive into Connection Operators](https://www.chisel-lang.org/chisel3/docs/explanations/connection-operators.html
在 Chisel 中,有一種特殊的用法可以讓 Bundle 可以被參數化,通常是用在 bit width 的參數化。
abstract class myBundle(param: MyParam) extends GenericParameterizedBundle(param)
class myBundleA(param: MyParam) extends myBundle(param){
val data = UInt(param.width.W)
}
在 Chisel 中,可以利用一些語法,配合 scala 的特性,來讓一個 Reg 包含不同的 data type,以下舉兩個例子,並且同時示範如何做 Partially reset(initialization)。
Example 1
透過 Bundle + .Lit()
的語法來初始化 aggregate register
import chisel3._
import chisel3.experimental.BundleLiterals._
class MyBundle extends Bundle {
val foo = UInt(8.W)
val bar = UInt(8.W)
}
class MyModule extends Module {
// Only .foo will be reset, .bar will have no reset value
val reg = RegInit((new MyBundle).Lit(_.foo -> 123.U))
}
Example 2
透過 Wire 和 initial value 的方式
class MyModule2 extends Module {
val reg = RegInit({
// The wire could be constructed before the reg rather than in the RegInit scope,
// but this style has nice lexical scoping behavior, keeping the Wire private
val init = Wire(new MyBundle)
init := DontCare // No fields will be reset
init.foo := 123.U // Last connect override, .foo is reset
init
})
}
Example 3
另外一種寫法,單純透過 Wire 的宣告
val regWithValidBit = RegInit({
val init = Wire(Valid(UInt(bits.W)))
init := DontCare
init // return
})
有時候一個 module 中的某些 IO ports 可能是為了 debug 用的,當 debug 完成之後,我們就不會想要在 verilog code 中生成這些 ports,如何在 Chisel 中表達這些 optional IO ports?以下舉一個例子
Example
利用傳入 Boolean
參數,來決定某個 ports 是否要被生成,利用 if (param) Some_port else None
這個語法
import chisel3._
class ModuleWithOptionalIOs(flag: Boolean) extends Module {
val io = IO(new Bundle {
val in = Input(UInt(12.W))
val out = Output(UInt(12.W))
val out2 = if (flag) Some(Output(UInt(12.W))) else None
})
io.out := io.in
if (flag) {
io.out2.get := io.in
}
}
特別注意上面的 io.out2.get
這個用法,這個語法代表,如果 io.out2
有被定義的話,那麼 get()
就會 return io.out2
,然後就可以進行 assgin,反之,get()
會 return None
。這方面的 API 是關於 scala 的 Options,詳細可以參考 Scala - Options
Example - if an entire IO
is optional
import chisel3._
class ModuleWithOptionalIO(flag: Boolean) extends Module {
val in = if (flag) Some(IO(Input(Bool()))) else None
val out = IO(Output(Bool()))
out := in.getOrElse(false.B)
}
上面的 getOrElse(false.B)
代表如果 io.in
有被定義的話,那就會傳回 io.in
,反之,就會傳回 false.B
,所以括號內的 value 代表 default value。
有些 ports 可能包含雙向的 signals,像是被 Decoupled()
包起來的 port,有時候我們可能會遇到下面的使用場景
import chisel3.util.Decoupled
class BadRegConnect extends Module {
val io = IO(new Bundle {
val enq = Decoupled(UInt(8.W))
})
val monitor = Reg(chiselTypeOf(io.enq))
monitor := io.enq
}
但是這樣做,FIRRTL Compiler 會報錯
ChiselStage.emitVerilog(new BadRegConnect)
// firrtl.passes.CheckHighFormLike$RegWithFlipException: @[cookbook.md 715:20]: [module BadRegConnect] Register monitor cannot be a bundle type with flips.
因此,可以透過 chiselTypeOf()
這個 API 解決這個問題,僅僅拿到 signal 的 type 就好,不要管它的方向
import chisel3.util.Decoupled
class CoercedRegConnect extends Module {
val io = IO(new Bundle {
val enq = Flipped(Decoupled(UInt(8.W)))
})
// Make a Reg which contains all of the bundle's signals, regardless of their directionality
val monitor = Reg(Output(chiselTypeOf(io.enq)))
// Even though io.enq is bidirectional, := will drive all fields of monitor with the fields of io.enq
monitor := io.enq
}
之前有一個比較舊的框架叫做 PeekPokeTester
,但後來 UC Berkeley 又推出一個新的框架來取代這個舊框架,叫做 ChiselTest
。兩著的用法雖然類似,但是 ChiselTest
是基於 ScalaTest
的一個測試框架,所以可以利用很多 ScalaTest
的強大功能,因此還是推薦使用 ChiselTest
而非 PeekPokeTester
。
這篇筆記中,我們會先簡單介紹 ScalaTest
的用法,再帶入 ChiselTest
的用法。以下引述自 The Chisel Book
ChiselTest is the new standard testing tool for Chisel modules based on the ScalaTest tool for Scala and Java, which we can use to run Chisel tests.
The Chisel Book
chiseltest now provides a compatibility layer that makes it possible to re-use old PeekPokeTester based tests with little to no changes to the code.
FIRRTL/Chisel
以下是 ScalaTest
的簡單用法,你可以寫一段類似敘述或是 spec 的語句來描述目前這個 test 要做哪些事情,應該要有哪些行為產生,像是下面的 "Integers" should "add" in {...}
,而花括號 {...}
裡面則為 test codes。
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)
}
}
而要執行 test script,則可以直接在 terminal 輸入 $ sbt test
,他會執行全部的測試檔案,如果不想要執行全部的測試的話,則可以輸入像是 $ sbt "testOnly ExampleTest"
來執行單一測試。
而上面的測試的輸出結果如下
[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
使用 ChiselTest
和使用 ScalaTest
的方式差不多,只是多了一些由 Chisel 提供的 API 而已。以下為一個簡單的例子
import chisel3._
import chiseltest ._
import org. scalatest .flatspec. AnyFlatSpec
// define module
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))
})
io.out := io.a & io.b
}
// define testbench
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.peek ().toString)
dut.io.a.poke (3.U)
dut.io.b.poke (2.U)
dut.clock.step ()
println("Result is: " + dut.io.out.peek ().toString)
}
}
}
Run your testbench
# run all testbenches
$ sbt test
# run a specific testbench
$ sbt "testOnly SimpleTest"
simulation results in terminal
...
Result is: UInt<2>(0)
Result is: UInt<2>(2)
[info] SimpleTest:
[info] DUT
[info] - should pass
[info] ScalaTest
...
在上面的例子,我們是用 dut.io.poke()
加上 toString()
的方式去得到 port value,還有另外一種方式,是利用 expect()
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)
}
}
}
Testing result in terminal
[info] SimpleTestExpect:
[info] DUT
[info] - should pass
[info] ScalaTest
[info] Run completed in 1 second, 85 milliseconds.
[info] Total number of tests run: 1
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
如果要產生 .vcd
檔案,基本上有兩種方式可以選擇。
Option 1 - Add commands in terminal
加上 -DwriteVcd=1
sbt "testOnly SimpleTest -- -DwriteVcd=1"
這種方式會一次產生所有 test cases 的波形,如果不想要一次產生所有波形可以使用 option 2。
Option 2 - Use ChiselTest
API
利用 withAnnotations (Seq( WriteVcdAnnotation ))
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()
}
}
}
printf
Debugging在 debug 的時候也可以利用 printf()
的方式印出想要看到的 values,而 printf()
可以寫在 module declaration 內的任何地方,他會在每次 rising edge 的時候把東西印出來。下面引述自官方 doc
The printing happens at the rising edge of the clock. A printf statement can be inserted just anywhere in the module definition, as shown in the printf debugging version of the DUT.
The Chisel Book
而 printf()
的寫法又有分兩種 style,一種是 Scala Style,一種是 C Style。其中 Scala Style 感覺很類似 Python 裡面的 f string。
// Chisel provides a custom string interpolator p"..."
val myUInt = 33.U
printf(p"myUInt = $myUInt") // myUInt = 33
// Does not interpolate the second string
val myUInt = 33.U
printf("my normal string" + p"myUInt = $myUInt")
// simple formatting
val myUInt = 33.U
// Hexadecimal
printf(p"myUInt = 0x${Hexadecimal(myUInt)}") // myUInt = 0x21 // myUInt = 0x21
// Binary
printf(p"myUInt = ${Binary(myUInt)}") // myUInt = 100001 // myUInt = 100001
// Character
printf(p"myUInt = ${Character(myUInt)}") // myUInt = !
val myUInt = 32.U
printf("myUInt = %d", myUInt) // myUInt = 32
在 Chisel 的子計畫中,有一個叫做 diagrammer 的 project,他是基於 GraphViz
這個繪圖框架,可以根據 .fir
檔案自動生成電路相關的 block diagram 或是整個 module 的 hierarchy diagram。
先安裝 diagrammer
Clone repo
$ git clone https://github.com/freechipsproject/diagrammer
$ cd diagrammer
如果沒有安裝 GraphViz
,則需要一併安裝,因為 diagrammer
是基於 GraphViz
的 tool
# under linux
$ sudo apt-get install graphviz
# check
$ dot -V
Hwo to use
Step#1: 你需要先生成電路的 .fir
檔案
Setp#2: 來移動到 diagrammer
的資料夾下面,利用 diagram.sh
生成 .svg
檔案
$ ./diagram.sh -i path/to/your/fir_file
如果你沒有指定路徑的話,那預設會在 diagrammer
的資料夾底下,所以這個 tool 有提供一些 options 可以使用,像是利用 -t
指定生成圖檔要放在哪個路徑之下。
open
, set to empty to tell it not to do open