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.
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 .
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:
Module
- container for module description rtlBundle
- a data structure in chisel, mainly used to define interfaces.io
- variable for defining portsBool
- data type, simple single bit signalUInt(width: Width)
is an unsigned integer, the constructor accepts the width of the signal as input.RegInit[T <: Data](init: T)
is a register constructor, accepts a reset value at input and has the same data type.<>
Is a universal signal connection operatorwhen(cond: => Bool) { /*...*/ }
- if
analogue in verilogAbout 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.
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:
width: Seq[Int]
- an input parameter for the constructor of the MultiChannelCounter
class MultiChannelCounter
it has the type Seq[Int]
- a sequence with integer elements.Seq
is one of the types of collections in scala with a clearly defined sequence of elements..map
- for all the familiar function on collections, capable of converting one collection to another due to the same operation on each element, in our case, a sequence of integer values turns into a sequence of SimpleCounter
' with the corresponding bit depth.A little bit about chisel:
Vec[T <: Data](gen: T, n: Int): Vec[T]
is a data type chisel, is an analogue of an array.Module[T <: BaseModule](bc: => T): T
is a required wrapper method for instantiated modules.util.Cat[T <: Bits](r: Seq[T]): UInt
- concatenation function, analogue {1'b1, 2'b01, 4'h0}
in verilogPay 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).
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.
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:
Option
- an optional data wrapper in scala which can be either an element of either None
, Option[UInt]
is either Some(UInt(/*...*/))
or None
, useful for parameterizing signals.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.
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.
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
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 multiplexerWe 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.To mix our trait
to a module, you need to meet several conditions:
io
should inherit from wishboneSlave
readMemMap
and writeMemMap
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.
width.indices
- returns an array with indices of elements, i.e. if width.length == 4
then width.indices = {0, 1, 2, 3}
{0, 1, 2, 3}.map(counter.io.getOut)
- gives something like this:{ counter.io.getOut(0), counter.io.getOut(1), /*...*/ }
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:
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 }
regClock
- the register that will be clocked by the standard clock
signal and reset by the standard reset
regClockB
- the same register is clocked, you guessed it, with the io.clockB
signal, but the reset will be used as standard.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 }
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:
DecoupledIO
is a commonly used ready / valid interface.DecoupledIO(UInt(32.W))
- will contain signals:val ready = Input(Bool())
val valid = Output(Bool())
val data = Output(UInt(32.W))
ValidIO
- same as DecoupledIO
only without ready
Modules:
Queue
- the synchronous FIFO module is a very useful thing. The interface looks likeval enq: DecoupledIO[T]
- inverted DecoupledIO
val deq: DecoupledIO[T]
- regular DecoupledIO
val count: UInt
- the amount of data in the queuePipe
- delay module, inserts the n-th number of register slicesArbiter
- arbiter on DecoupledIO
interfaces, has many subspecies of differing arbitrationval in: Vec[DecoupledIO[T]]
- array of input interfacesval out: DecoupledIO[T]
val chosen: UInt
- shows the selected channelAs 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.
It should be mentioned, and the possibility of testing in chisel, at the moment there are two ways to test this:
peekPokeTesters
- purely simulation tests that verify the logic of your designhardwareIOTeseters
is more interesting because With this approach, you will get a generated teset bench with tests that you wrote on chisel, and with verilator you will even get a timeline.
But so far, the testing approach has not been finalized, and the discussion is still underway. In the future, most likely there will be a universal tool, for testing and tests it will also be possible to write on chisel. But while you can look at what is already there and how to use it here .
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.
`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.
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