Home Scala Chisel – a (not quite) new approach to digital logic development

Chisel – a (not quite) new approach to digital logic development

by admin

Chisel - a (not quite) new approach to digital logic development

As microelectronics has evolved, rtl designs have gotten bigger and bigger.The reusability of verilogcode has been a pain, even with generate, macros, and system verilog features.Chisel, on the other hand, allows you to apply all the power of object and functional programming to rtl development, which is a rather welcome step that could fill the lungs of ASIC and FPGA developers with fresh air.

This article will give a short overview of the basic functionality and discuss some usecases, also talk aboutthe disadvantages of this language.If the topic is interesting, we will continue in more detailed tutorials.

System requirements

  • scala base level
  • verilog and the basic principles of building digital designs.
  • keep chisel documentation handy

I will try to go over the basics of chisel with simple examples, but ifanything is not clear, you can peep here

As for scala for a quick dive, this one might help cheat sheet

Similar is also available for chisel

The full code of the article (as a scala sbt project) you can be found here

Simple counter

As you can see from the name ‘Constructing Hardware In a scala Embedded Language’ chisel is a hardware description language built on top of scala.

To make a long story short, the hardware graph is built from an rtl description on a chisel and the hardware graph is transformed into an intermediate description in firrtl and then the built-in backend interpreter generates a verilog from the firrtl.

Let’s look at two implementations of a simple counter.

verilog :

module SimpleCounter#(parameter WIDTH = 8)(input clk, input reset, input wire enable, output wire [WIDTH-1:0] out);reg [WIDTH-1:0] counter;assign out= counter;always @(posedge clk)if (reset) begincounter <= {(WIDTH){1'b0}};end else if (enable) begincounter <= counter + 1;endendmodule

chisel :

class SimpleCounter(width: Int = 32) extends Module{val io= IO(new Bundle{val enable= Input(Bool())val out= Output(UInt(width.W))})val counter = RegInit(0.U(width.W))io.out <>counterwhen(io.enable) {counter := counter + 1.U}}

A littlebit about chisel:

  • Module – forrtl container of the module description
  • Bundle – Data structure in a chisel, mostly used to define interfaces.
  • io – variable for ports definition
  • Bool – Data type, simple one bit signal
  • UInt(width: Width) – unsigned integer, the constructor takes the signal width as input.
  • RegInit[T <: Data](init: T) – register constructor, takes a resetvalue as input and has the same data type.
  • <> – universal signal connection operator
  • when(cond: => Bool) { /*...*/ } – analog if in verilog

We will talk about which verilog generates the chisel a bit later. For now, let’s just compare the two designs. As you can see, chisel lacks any reference to signals clk and reset The point is that chisel by default adds these signals to the module. The reset value for the register is counter we pass to the register constructor with the reset RegInit There is support for modules with multiple clock signals in chisel, but we’ll talk about it a bit later too.

The counter is a bit more complicated

Let’s go further and make the task a bit more complicated, for example, let’s make a multi-channel counter with an input parameter as a sequence of digits for each of the channels.

Let’s start now with the version on the chisel

class MultiChannelCounter(width: Seq[Int]= Seq(32, 16, 8, 4)) extends Module {val io = IO(new Bundle {val enable= Input(Vec(width.length, Bool()))val out = Output(UInt(width.sum.W))def getOut(i: Int): UInt = {val right = width.dropRight(width.length - i).sumthis.out(right + width(i) - 1, right)}})val counters: Seq[SimpleCounter] = width.map(x =>Module(new SimpleCounter(x)))io.out <> util.Cat(counters.map(_.io.out))width.indices.foreach { i =>counters(i).io.enable <> io.enable(i)}}

A little bit about scala:

  • width: Seq[Int] – input parameter for class constructor MultiChannelCounter , has the type Seq[Int] – sequence with integer elements.
  • Seq – is one of the collection types in scala with a well-defined sequence of elements.
  • map – is a familiar function on collections, capable of converting one collection into another by performing the same operation on each item, in our case a sequence of integer values is converted into a sequence of SimpleCounter ‘s with the corresponding digit capacity.

A littlebit about chisel:

  • Vec[T <: Data](gen: T, n: Int): Vec[T] – chisel data type, is analogous to array.
  • Module[T <: BaseModule](bc: => T): T – Mandatory wrapper method for instantiated modules.
  • util.Cat[T <: Bits](r: Seq[T]): UInt – concatenation function, analogous to {1'b1, 2'b01, 4'h0} in verilog

Let’s look at the ports :
enable – deployed already in Vec[Bool] *, roughly speaking, into an array of single-bit signals, one for each channel, you could also make UInt(width.length.W)
out – expanded to the sum of the widths of all our channels.

Variable counters is an array of our counters. Connect enable signal of each counter to the corresponding input port, and all the signals out combine into one using the built-in util.cat function and throw it through to the output.

Let’s also note the function getOut(i: Int) – this function calculates and returns the range of bits in the out for i ‘s channel. It will be very useful for further work with such a counter. There is no way to implement something like this in verilog

* Vec not to be confused with Vector , the first is an array in chisel, the second is a collection in scala.

Let’s now try to write this module in verilog, even in systemVerilog for convenience.

After some thought I came up with this way (it’s probably not the only right and best way, but you can always suggest your own way in the comments).

verilog

module MultiChannelCounter #(parameter TOTAL = 4, parameter integer WIDTH_SEQ [TOTAL] = {32, 16, 8, 4})(clk, reset, enable, out);localparam OUT_WIDTH = get_sum(TOTAL, WIDTH_SEQ);input clk;input reset;input wire [TOTAL - 1 : 0] enable;output wire [OUT_WIDTH - 1 :0] out;genvar j;generatefor(j = 0; j < TOTAL; j = j + 1) begin : counter_generationlocalparam OUT_INDEX = get_sum(j, WIDTH_SEQ);SimpleCounter #( WIDTH_SEQ[j] ) SimpleCounter_unit (.clk(clk), .reset(reset), .enable(enable[j]), .out(out[OUT_INDEX + WIDTH_SEQ[j] - 1: OUT_INDEX]));endendgeneratefunction automatic integer get_sum;input integer array_width;input integer array [TOTAL];integer counter = 0;integer i;beginfor(i = 0; i < array_width; i = i + 1)counter = counter + array[i];get_sum = counter;endendfunctionendmodule

This already looks a lot more impressive. But what if we go further and bolt on the popular wishbone interface with case-based access.

Bundle interfaces

Wishbone is a small bus like AMBA APB, used mostly for open-source ip cores.

A little more on the wiki : https://ru.wikipedia.org/wiki/Wishbone

Since chisel gives us containers of data like Bundle it makes sense to wrap the bus in such a container that you can use it in any chisel project.

class wishboneMasterSignals(addrWidth: Int = 32, dataWidth: Int = 32, gotTag: Boolean = false)extends Bundle {val adr = Output(UInt(addrWidth.W))val dat_master = Output(UInt(dataWidth.W))val dat_slave = Input(UInt(dataWidth.W))val stb = Output(Bool())val we = Output(Bool())val cyc = Output(Bool())val sel = Output(UInt((dataWidth / 8).W))val ack_master = Output(Bool())val ack_slave = Input(Bool())val tag_master: Option[UInt]= if(gotTag) Some(Output(Bool())) else Noneval tag_slave: Option[UInt] = if(gotTag) Some(Input(Bool())) else Nonedef wbTransaction: Bool = cyc stbdef wbWrite: Bool = wbTransaction wedef wbRead: Bool = wbTransaction !weoverride def cloneType: wishboneMasterSignals.this.type =new wishboneMasterSignals(addrWidth, dataWidth, gotTag).asInstanceOf[this.type]}

A little bit about scala:

  • Option – optional data wrapper in scala which can be either an element or None , Option[UInt] – is either Some(UInt(/*...*/)) or None , useful for parameterization of signals.

Nothing out of the ordinary. Just a description of the interface on the master side, except for a few signals and methods :

tag_master and tag_slave – are optional general purpose signals in the wishbone protocol, they will appear if the parameter gotTag will be equal to true

wbTransaction , wbWrite , wbRead – functions to simplify the bus operation.

cloneType – Mandatory cloneType method for all parametrized [T<: Bundle] classes

But we also need a slave interface, so let’s see how we can implement it.

class wishboneSlave(addrWidth: Int = 32, dataWidth: Int = 32, tagWidht: Int = 0)extends Bundle {val wb = Flipped(new wishboneMasterSignals(addrWidth , dataWidth, tagWidht))override def cloneType: wishboneSlave.this.type =new wishboneSlave(addrWidth, dataWidth, tagWidht).asInstanceOf[this.type]}

Method Flipped , as you might guess from the name, flips the interface, and now our master interface has turned into a slave, let’s add the same class but for the master.

class wishboneMaster(addrWidth: Int = 32, dataWidth: Int = 32, tagWidht: Int = 0)extends Bundle {val wb = new wishboneMasterSignals(addrWidth , dataWidth, tagWidht)override def cloneType: wishboneMaster.this.type =new wishboneMaster(addrWidth, dataWidth, tagWidht).asInstanceOf[this.type]}

So that’s it, the interface is ready.But before we write the handler, let’s see how we can use these interfaces if we want to make a switch or something with more interfaces.

class WishboneCrossbarIo(n: Int, addrWidth: Int, dataWidth: Int)extends Bundle {val slaves = Vec(n, new wishboneSlave(addrWidth, dataWidth, 0))val master = new wishboneMaster(addrWidth, dataWidth, 0)}class WBCrossBar extends Module {val io= IO(new WishboneCrossbarIo(1, 32, 32))io.master <>io.slaves(0)// ...}

This is a small blueprint for a switch. It is convenient to declare an interface like Vec[wishboneSlave] and you can connect the interfaces with the same operator <> Quite useful chisel chips when it comes to controlling a large set of signals.

Universal bus controller

As said before about the power of functional and object programming, let’s try to apply it. Next we will talk about the implementation of a universal bus controller in the form of trait , this will be a kind of mixin for any module with a bus wishboneSlave for the module, you just need to define the memory card and mixin trait – controller to it when generating.

Implementation

For those who are still enthusiastic

Let’s move on to the implementation of the handler. It will be simple and immediately respond to single transactions, in caseof a dropout from the address pool it will give zero.

Let’s break it down piece by piece :

  • each transaction must be answered with alege

    val io : wishboneSlave= /*... */val wb_ack = RegInit(false.B)when(io.wb.wbTransaction){wb_ack := true.B}.otherwise {wb_ack := false.B}wb_ack <> io.wb.ack_slave

  • Read response with data
    val wb_dat = RegInit(0.U(io.wb.dat_slave.getWidth.W))// getWidth resets the bitwidthwhen(io.wb.wbRead) {wb_dat := MuxCase(default = 0.U, Seq((io.wb.addr === ADDR_1) -> data_1, (io.wb.addr === ADDR_3) -> data_2, (io.wb.addr === ADDR_3) -> data_2))}wb_dat <> io.wb.dat_slave

  • MuxCase[T <: Data] (default: T, mapping: Seq[(Bool, T)]) T – embedded cobin type scheme case in verilog*.

What it would look like in verilog approximately:

always @(posedge clock)if(reset)wb_dat_o <= 0;else if(wb_read)case (wb_adr_i)`ADDR_1 : wb_dat_o <= data_1;`ADDR_2 : wb_dat_o <= data_2;`ADDR_3 : wb_dat_o <= data_3;default : wb_dat_o <= 0;endcase}

*Actually in this case it’s a little hack for the sake of parameterizability, there is a standard construction in chisel which is better to use if, write something simpler.

switch(x) {is(value1) {// ...}is(value2) {// ...}}

And the entry

when(io.wb.wbWrite) {data_4 := Mux(io.wb.addr === ADDR_4, io.wb.dat_master, data_4)}

  • Mux[T <: Data](cond: Bool, con: T, alt: T): T – normal multiplexer

We add something like this to our multichannel counter, put registers to control channels and we’re done. But here you get to the WB universal bus controller which will take this kind of memory map:

val readMemMap= Map(ADDR_1 -> DATA_1, ADDR_2 -> DATA_2/*...*/)val writeMemMap= Map(ADDR_1 -> DATA_1, ADDR_2 -> DATA_2/*...*/)

For a task like this we can use trait – something like mixins in Sala. The main task will be to bring readMemMap: [Int, Data]. to the form Seq(condition -> data) and it would also be nice if you could pass the base address and data array

val readMemMap= Map(ADDR_1_BASE -> DATA_SEQ, ADDR_2 -> DATA_2/*...*/)

What will unfold with something like this, where WB_DAT_WIDTH is the width of the data in bytes

val readMemMap = Map(ADDR_1_BASE + 0 * (WB_DAT_WIDHT)-> DATA_SEQ_0, ADDR_1_BASE + 1 * (WB_DAT_WIDHT)-> DATA_SEQ_1, ADDR_1_BASE + 2 * (WB_DAT_WIDHT)-> DATA_SEQ_2, ADDR_1_BASE + 3 * (WB_DAT_WIDHT)-> DATA_SEQ_3/*...*/ADDR_2 -> DATA_2/*...*/)

To implement this, let’s write a converter function from Map[Int, Any] in Seq[(Bool, UInt)] You’ll have to use scala pattern mathcing.

def parseMemMap(memMap: Map[Int, Any]): Seq[(Bool, UInt)] = memMap.flatMap { case(addr, data) =>data match {case a: UInt => Seq((io.wb.adr === addr.U) -> a)case a: Seq[UInt] => a.map(x => (io.wb.adr === (addr + io.wb.dat_slave.getWidth / 8).U) -> x)case _ => throw new Exception("WRONG MEM MAP!!!")}}.toSeq

Finally, our traitwill look like this :

traitwishboneSlaveDriver{val io : wishboneSlaveval readMemMap: Map[Int, Any]val writeMemMap: Map[Int, Any]val parsedReadMap: Seq[(Bool, UInt)] = parseMemMap(readMemMap)val parsedWriteMap: Seq[(Bool, UInt)] = parseMemMap(writeMemMap)val wb_ack = RegInit(false.B)val wb_dat = RegInit(0.U(io.wb.dat_slave.getWidth.W))when(io.wb.wbTransaction) {wb_ack := true.B}.otherwise {wb_ack := false.B}when(io.wb.wbRead) {wb_dat := MuxCase(default = 0.U, parsedReadMap)}when(io.wb.wbWrite) {parsedWriteMap.foreach { case(addrMatched, data) =>data := Mux(addrMatched, io.wb.dat_master, data)}}wb_dat <> io.wb.dat_slavewb_ack <> io.wb.ack_slavedef parseMemMap(memMap: Map[Int, Any]): Seq[(Bool, UInt)] = { /*...*/}}

A little about scala :

  • io , readMemMap, writeMemMap – abstract fields of our trait ‘a, which must be defined in the class into which we are going to mix it.

How to use it

To mix our trait to a module, several conditions must be met :

  • io must be inherited from the class wishboneSlave
  • two memory cards must be declared readMemMap and writeMemMap

class WishboneMultiChannelCounter extends Module {val BASE = 0x11A00000val OUT = 0x00000100val S_EN = 0x00000200val H_EN = 0x00000300val wbAddrWidth = 32val wbDataWidth = 32val wbTagWidth = 0val width = Seq(32, 16, 8, 4)val io = IO(new wishboneSlave(wbAddrWidth, wbDataWidth, wbTagWidth) {val hardwareEnable: Vec[Bool] = Input(Vec(width.length, Bool()))})val counter = Module(new MultiChannelCounter(width))val softwareEnable= RegInit(0.U(width.length.W))width.indices.foreach(i => counter.io.enable(i) := io.hardwareEnable(i) softwareEnable(i))val readMemMap = Map(BASE + OUT -> width.indices.map(counter.io.getOut), BASE + S_EN -> softwareEnable, BASE + H_EN -> io.hardwareEnable.asUInt)val writeMemMap = Map(BASE + S_EN -> softwareEnable)}

Let’s create a register softwareEnable it is ‘and’ added to the input signal hardwareEnable and goes into enable counter[MultiChannelCounter]

Declare two read and write memory cards : readMemMapwriteMemMap for more details about the structure, see the chapter above.
In the read-map, we pass the counter value of each channel*, softwareEnable and hardwareEnable And we give only softwareEnable register.

* width.indices.map(counter.io.getOut) – strange construction, let’s break it down piece by piece.

  • width.indices – will return an array with element indices, i.e. if width.length == 4 then width.indices = {0, 1, 2, 3}
  • {0, 1, 2, 3}.map(counter.io.getOut) – gives approximately the following :
    { counter.io.getOut(0), counter.io.getOut(1), /*...*/ }

Now for any module on the chisel with we can declare read and write memory cards and just connect our universal bus controller when generating, something like this :

class wishbone_multicahnnel_counter extends WishboneMultiChannelCounter with wishboneSlaveDriverobject countersDriver extends App {Driver.execute(Array("-td", "./src/generated"), () =>new wishbone_multicahnnel_counter)}

wishboneSlaveDriver – is exactly the trait mix we described under the spoiler.

Of course, this variant of the universal controller is far from being final, but it is rather raw. Its main purpose is to demonstrate one of possible approaches to rtl development on chisel. With all possibilities of scala there could be many more such approaches, so every developer has his own field for creativity. But there is not much to inspire from, except :

  • the native chisel library utils, about which a little further, there you can look at the inheritance of modules and interfaces
  • https://github.com/freechipsproject/rocket-chip – The risc-v kernel is entirely implemented in chisel, provided you know scala very well, but for newbies without half a liter you will take a very long time to understand as there is no official documentation about the internal structure of the project.

MultiClockDomain

What if we want to manually control the clockand resetsignals in a chisel.Until recently this was not possible, but with a recent release there is support for withClock {} , withReset {} and withClockAndReset {} Let’s look at an example :

class DoubleClockModule extends Module {val io = IO(new Bundle {val clockB = Input(Clock())val in = Input(Bool())val out = Output(Bool())val outB = Output(Bool())})val regClock= RegNext(io.in, false.B)regClock <> io.outval regClockB= withClock(io.clockB) {RegNext(io.in, false.B)}regClockB <> io.outB}

  • regClock – register which will be clocked by the standard signal clock and will be resetby the standard reset
  • regClockB – the same register is clocked by, you guessed it, the io.clockB , but the reset will be used as standard.

If, on the other hand, we want to remove the standard signals clock and reset completely, then you can use the experimental fixture so far – RawModule (module without standard clock and reset signals, everything will have to be controlled manually). Example :

class MultiClockModule extends RawModule {val io = IO(new Bundle {val clockA = Input(Clock())val clockB = Input(Clock())val resetA = Input(Bool())val resetB = Input(Bool())val in = Input(Bool())val outA = Output(Bool())val outB = Output(Bool())})val regClockA = withClockAndReset(io.clockA, io.resetA) {RegNext(io.in, false.B)}regClockA <> io.outAval regClockB = withClockAndReset (io.clockB, io.resetB) {RegNext(io.in, false.B)}regClockB <> io.outB}

Utils library

The goodies of chisel don’t end there. Its creators took the trouble to write a small but very useful library of small interfaces, modules and functions. Strangely enough the wiki doesn’t have a description of the library, but you can check the cheat sheet which is linked at the beginning of the wiki (the last two sections there)

Interfaces :

  • DecoupledIO – common ready/valid interface used frequently.
    DecoupledIO(UInt(32.W)) – will contain signals :
    val ready= Input(Bool())
    val valid = Output(Bool())
    val data = Output(UInt(32.W))
  • ValidIO – the same as DecoupledIO only without ready

Modules :

  • Queue – synchronous FIFO module is very useful thing the interface looks like
    val enq: DecoupledIO[T] – inverted DecoupledIO
    val deq: DecoupledIO[T] – normal DecoupledIO
    val count: UInt – amount of data in the queue
  • Pipe – delay module, inserts nth number of register slices
  • Arbiter – arbiter on DecoupledIO interfaces, has many subspecies differing in the type of arbitration
    val in: Vec[DecoupledIO[T]] – array of input interfaces
    val out: DecoupledIO[T]
    val chosen: UInt – shows the selected channel

As far as you can understand from the discussion on github – in the global plans is a significant expansion of this library modules: such as asynchronous FIFO, LSFSR, frequency dividers, PLL patterns for FPGA; various interfaces; controllers for them and much more.

Chisel io-testers

It is worth mentioning the possibility of testing in chisel, at the moment there are two ways to test this :

  • peekPokeTesters – pure simulation tests which test the logic of your design
  • hardwareIOTeseters – this is more interesting because with this approach you get a generated teset bench with the tests you wrote in chisel, and if you have a verilator you even get a timeline diagram.

    But for now, the approach to testing has not been finalized and discussion is still going on. In the future there will most likely be a universal testing tool and tests can also be written in chisel. But for now you can look at what you already have and how use here

Disadvantages of chisel

I can’t say that chisel is a universal tool and that everyone should use it. Like probably all projects under development, it has some disadvantages that are worth mentioning to complete the picture.

The first and perhaps the most important disadvantage is the lack of asynchronous resets. It’s quite a big one, but it can be solved in several ways, and one of them is scripts on top of verilog that turn synchronous resets into asynchronous resets. This is easy to do because all the constructs in the generated verilogwith always are fairly single-use.

The second disadvantage is, according to many people, the unreadability of the generated verilog and as a consequence making debugging more difficult. But let’s look at the generated code from the example with the simple counter

generated verilog

`ifdef RANDOMIZE_GARBAGE_ASSIGN`define RANDOMIZE`endif`ifdef RANDOMIZE_INVALID_ASSIGN`define RANDOMIZE`endif`ifdef RANDOMIZE_REG_INIT`define RANDOMIZE`endif`ifdef RANDOMIZE_MEM_INIT`define RANDOMIZE`endifmodule SimpleCounter(input clock, input reset, input io_enable, output [7:0] io_out);reg [7:0] counter;reg [31:0] _RAND_0;wire [8:0] _T_7;wire [7:0] _T_8;wire [7:0] _GEN_0;assign _T_7 = counter + 8'h1;assign _T_8 = _T_7[7:0];assign _GEN_0 = io_enable ? _T_8 : counter;assign io_out = counter;`ifdef RANDOMIZEinteger initvar;initial begin`ifndef verilator#0.002 begin end`endif`ifdef RANDOMIZE_REG_INIT_RAND_0 = {1{$random}};counter = _RAND_0[7:0];`endif // RANDOMIZE_REG_INITend`endif // RANDOMIZEalways @(posedge clock) beginif (reset) begincounter <= 8'h0;end else beginif (io_enable) begincounter <= _T_8;endendendendmodule

At first glance the generated verilog may be repulsive, even in a medium-sized design, but let’s break it down a bit.

  • RANDOMIZE defines (may be useful when testing with chisel-testers) – generally useless, but not really an obstacle
  • As we see the name of our ports and the register are preserved
  • _GEN_0 is useless for us, but necessary for the firrtl interpreter to generate the verilog. We don’t pay any attention to it either.
  • This leaves _T_7 and _T_8, all combinational logic in the generated verilog will be represented step by step as _T variables.

The most important thing is that all the ports, registers, wires needed for debugging retain their names from the chisel. And if you look not only at the verilog but also at the chisel you will soon find debugging just as easy as with pure verilog.

Conclusion

In today’s reality RTL development whether asic or fpga outside of academic environment, has long gone from using only pure handwritten verilog code to some varieties of generation scripts, be it a small tcl script or a whole IDE with lots of features.

Chisel, on the other hand, is a logical development of languages for developing and testing digital logic. Although at this stage it is far from perfect, but already able to provide features for the sake of which you can put up with its shortcomings. It is important that the project is alive and growing, and there is a high probability that in the foreseeable future these flaws will be very few and the functionality will be very much.

You may also like