📜 ⬆️ ⬇️

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


With the development of microelectronics, rtl designs have become more and more. Reusability of the verilog code delivers a lot of inconvenience, even using generate, macros and system verilog chips. Chisel, on the other hand, makes it possible to apply all the power of object and functional programming to the development of rtl, which is quite a welcome step that can fill the ASIC and FPGA developers with fresh air.


This article will give a brief overview of the main functionality and consider some use cases, we will also talk about the shortcomings of this language. In the future, if the topic is interesting, we will continue the article in more detailed tutorials.


System requirements



I will try to make out the basics of chisel with simple examples, but if something is not clear, you can peek here .


As for scala for quick immersion, this cheat list can help.


Similar is for chisel .


The full article code (in the form of a scala sbt project) can be found here .


Simple counter


As can be understood from the name 'Constructing Hardware In a scala' Embedded Language 'chisel is a hardware description language built on over scala.


Briefly about how everything works, then: the hardware graph is built from rtl of the description on chisel, which, in turn, turns into an intermediate description in the firrtl language, and after the embedded backend the interpreter generates from firrtl verilog.


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) begin counter <= {(WIDTH){1'b0}}; end else if (enable) begin counter <= counter + 1; end endmodule 

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 <> counter when(io.enable) { counter := counter + 1.U } } 

A little bit about chisel:



About what verilog generates chisel talk a little later. Now just compare these two designs. As you can see, in chisel there is no mention of the clk and reset signals. The point is that chisel adds these signals to the module by default. The reset value for the counter register is RegInit register constructor with the RegInit reset. Support for modules with multiple clock signals in chisel is, but about it, too, a little later.


The counter is a bit more complicated


Let's go ahead and complicate the task a bit, for example, let's make a multi-channel counter with an input parameter in the form of a sequence of digits for each channel.


Let's start now with the version on 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).sum this.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:



A little bit about chisel:



Pay attention to the ports:
enable - turned around already in Vec[Bool] *, roughly speaking, into an array of one-bit signals, one for each channel, it was possible to do UInt(width.length.W) .
out - expanded to the sum of the widths of all our channels.


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


Note also the getOut(i: Int) function — this function calculates and returns the range of bits in the out signal for the i'th channel. It will be very useful in further work with such a counter. Implement something similar in verilog will not work


* Vec not confused with Vector , the first is an array of data in chisel, the second is the collection in scala.


Let's now try to write this module on verilog, for convenience even on systemVerilog.


After sitting after thinking, I came to this option (most likely it is not the only correct and most optimal, but you can always offer your implementation 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; generate for(j = 0; j < TOTAL; j = j + 1) begin : counter_generation localparam 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]) ); end endgenerate function automatic integer get_sum; input integer array_width; input integer array [TOTAL]; integer counter = 0; integer i; begin for(i = 0; i < array_width; i = i + 1) counter = counter + array[i]; get_sum = counter; end endfunction endmodule 

It looks much more impressive already. But what if, we go ahead and attach to this popular wishbone interface with register access.


Bundle interfaces


Wishbone is a small bus like AMBA APB, used mainly for ip kernels with open source.


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


Since chisel provides us with data containers of the Bundle type, it makes sense to wrap the tire in a container that can later be used in any projects on chisel.


 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 None val tag_slave: Option[UInt] = if(gotTag) Some(Input(Bool())) else None def wbTransaction: Bool = cyc && stb def wbWrite: Bool = wbTransaction && we def wbRead: Bool = wbTransaction && !we override def cloneType: wishboneMasterSignals.this.type = new wishboneMasterSignals(addrWidth, dataWidth, gotTag).asInstanceOf[this.type] } 

A little bit about scala:



It seems nothing unusual. A simple description of the interface from the wizard, with the exception of a few signals and methods:


tag_master and tag_slave are optional general-purpose signals in the wishbone protocol; in our case they will appear if the gotTag parameter is true .


wbTransaction , wbWrite , wbRead - functions to simplify the work with the bus.


cloneType is a required type cloning method for all parameterized [T <: Bundle] classes


But we also need a slave interface, let's see how it can be implemented.


 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] } 

The Flipped method, as it was possible to guess from the name, turns the interface over, and now our master interface has become a slave, we will 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] } 

Well, that's all, the interface is ready. But before writing a handler, let's see how you can use these interfaces if we need to make a switch or something with a large set of wishbone 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 preparation for the switch. It is convenient to declare an interface of type Vec[wishboneSlave] , and you can connect interfaces using the same <> operator. Chisel chips are quite useful when it comes to managing a large set of signals.


Universal Bus Controller


As mentioned earlier about functional and object programming, let's try to apply it. Then we will discuss the implementation of the wishbone universal bus controller in the form of a trait , it will be some mixin for any module with the wishboneSlave bus, for the module you only need to define a memory card and mix the trait controller to it when it is generated.


Implementation


For those who are still full of enthusiasm

Let us turn to the implementation of the handler. It will be simple and immediately respond to single transactions, in case of dropping out of the address pool, issue zero.


Disassemble in parts:


  • each transaction needs to be answered acknowlege


     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 reply data
     val wb_dat = RegInit(0.U(io.wb.dat_slave.getWidth.W)) // getWidth   when(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 is a built-in kobination scheme of the case type in verilog *.

How about would look like in verilog:


  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 } 

* In general, in this case it is a small hack for the sake of parameterization, chisel has a standard design that is better to use if you write something more simple.


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

Well, write


  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 - the usual multiplexer

We embed something similar to our multi-channel counter, hang the registers for channel management and the trick is done. But here it is already within reach of the universal WB bus controller to which we will transmit a memory card of this type:


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

For such a task, trait will help us — something like the mixins in Sala. The main task will be to readMemMap: [Int, Data] to the form Seq( -> ) , and it would be nice if you could transfer the base address and the data array inside the memory card


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

What will be revealed with in something like, 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, we write a function converter from Map[Int, Any] to Seq[(Bool, UInt)] . We'll have to use the 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 treyt will look like this:


 trait wishboneSlaveDriver { val io : wishboneSlave val 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_slave wb_ack <> io.wb.ack_slave def parseMemMap(memMap: Map[Int, Any]): Seq[(Bool, UInt)] = { /*...*/} } 

A little bit about scala:


  • io , readMemMap, writeMemMap are the abstract fields of our trait 'a that should be defined in the class in which we will knead it.

How to use it


To mix our trait to a module, you need to meet several conditions:



 class WishboneMultiChannelCounter extends Module { val BASE = 0x11A00000 val OUT = 0x00000100 val S_EN = 0x00000200 val H_EN = 0x00000300 val wbAddrWidth = 32 val wbDataWidth = 32 val wbTagWidth = 0 val 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 ) } 

We create the softwareEnable register and add it to the hardwareEnable signal and go to enable counter[MultiChannelCounter] .


We declare two memory cards for reading and writing: readMemMap writeMemMap , for more information about the structure, you can see the chapter above.
We transfer the value of each channel’s *, softwareEnable and hardwareEnable counter to the reading memory card. And on record we give only softwareEnable register.


* width.indices.map(counter.io.getOut) - a strange construction, we will sort it in parts.



Now, for any module on chisel with, we can declare memory cards for reading and writing and simply connect our universal wishbone bus controller when generating something like this:


 class wishbone_multicahnnel_counter extends WishboneMultiChannelCounter with wishboneSlaveDriver object 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 version of the universal controller is far from final, but rather, on the contrary, crude. His main goal is to demonstrate one of the possible approaches to the development of rtl on chisel. With all the possibilities of scala, such approaches can be much more, so that each developer has his own field of creativity. It’s true that there’s nowhere else to get inspired, except:



MultiClockDomain


What if we want to manually manage the clock and reset signals in the chisel. Until recently, it was impossible to do this, but with one of the latest releases appeared support 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.out val regClockB = withClock(io.clockB) { RegNext(io.in, false.B) } regClockB <> io.outB } 


If we want to remove the standard clock and reset signals completely, then we can use an experimental feature - RawModule (the module without standard clocking and reset signals, you have to manually manage everything). 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.outA val regClockB = withClockAndReset (io.clockB, io.resetB) { RegNext(io.in, false.B) } regClockB <> io.outB } 

Utils library


This does not end pleasant chisel bonuses. Its creators worked and wrote a small but very useful library of small, interfaces, modules, functions. Oddly enough, there is no library description on the wiki, but you can see the cheat list for which it is at the very beginning (the last two sections are there)


Interfaces:



Modules:



As far as can be understood from the discussion on github, the global plans have a significant expansion of this library: modules such as asynchronous FIFO, LSFSR, frequency dividers, PLL templates for FPGA; various interfaces; controllers for them and much more.


Chisel io-teseters


It should be mentioned, and the possibility of testing in chisel, at the moment there are two ways to test this:



Disadvantages chisel


This is not to say that chisel is a universal tool, and that everyone should go for it. Like all projects at the development stage, it has its drawbacks, which are worth mentioning for completeness.


The first and perhaps the most important drawback is the lack of asynchronous dumps. It is quite significant, but it can be solved in several ways, and one of them is scripts over verilog, which turn a synchronous reset into asynchronous. This is easy to do, because All constructions in the generated verilog with always quite uniform.


The second drawback is, according to many, in the unreadability of the generated verilog and, as a consequence, the complexity of debugging. But let's take a look at the generated code from the example with a 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 `endif module 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 RANDOMIZE integer 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_INIT end `endif // RANDOMIZE always @(posedge clock) begin if (reset) begin counter <= 8'h0; end else begin if (io_enable) begin counter <= _T_8; end end end endmodule 

At first glance, the generated verilog can push away, even in a medium-sized design, but let's see a little.



Most importantly, all the ports, registers, and wires needed for debugging retain their names from chisel. And if you look not only at verilog but also at chisel, then soon the debugging process will go as easily as with pure verilog.


Conclusion


In modern realities, the development of RTL, whether asic or fpga outside the academic environment, has long since gone from using only pure handwritten verilog code to various kinds of generation scripts, be it a small tcl script or a whole IDE with lots of possibilities.


Chisel, in turn, is a logical development of languages ​​for developing and testing digital logic. Suppose that at this stage it is far from perfect, but it is already able to provide opportunities for the sake of which one can put up with its shortcomings. , .


')

Source: https://habr.com/ru/post/419413/


All Articles