| Exercises | ||
| Images | ||
| SpinalTest | ||
| .gitignore | ||
| README.md | ||
[[TOC]]
Introduction
This exercise session introduces SpinalHDL, a Scala-based Hardware Description Language (HDL). SpinalHDL is implemented as a Scala library that generates RTL in the form of either Verilog or VHDL code. It is also capable of running, and interacting with, a Verilog simulation from Scala.
SpinalHDL is a RTL code generator rather than a higher-level HDL. Of course, the full power of Scala can be used to build abstractions on top of the basic RTL components but only by combining those basic components. Having a good understanding of RTL is therefore imperative before using SpinalHDL.
This text explains the basic concepts of SpinalHDL before introducing the exercises. However, it is not meant to be a comprehensive description of all its features. For that, we refer to the official documentation.
Setup
Read this to learn how to install all prerequisites and test your setup.
Basic logic
We will introduce the basic concepts of RTL modelling in SpinalHDL by building the same 16-bit timer module we built in Verilog during the lectures. As in Verilog, we will create an 8-bit timer module first and use that as the building blocks for a 16-bit timer.
While designing the combinational path in Verilog, we ended-up with the following code (ignoring the declaration of all signals that are read):
reg [7:0] t2;
always @* begin
if (rst) t2 = 0;
else if (enable) t2 = counter + 1;
else t2 = counter ;
end
In SpinalHDL, the same logic could be modelled as follows:
val t2 = UInt(8 bits)
when (rst) {
t2 := 0
} elsewhen (enable) {
t2 := counter + 1
} otherwise {
t2 := counter
}
Structurally, this looks very similar to the Verilog code. The most important
difference is the lack of an equivalent of Verilog's always block. When a
value is assigned to a variable, SpinalHDL uses its type annotations to figure
out if it should create combinational or synchronous logic. In this case, since
t2 is not declared as a register, combinational logic will automatically be
created that is equivalent to the Verilog code above.
While in Verilog we only specify the bit width of signals, SpinalHDL has
stricter
types. The
most important simple types are Bool (single-bit signal), Bits (bit-vector),
UInt/SInt (multi-bit signal supporting unsigned/signed arithmetic
operations). The multi-bit types take their bit width as a constructor argument,
which is specified as a positive integer followed by the keyword bits.
Signals can freely be "cast" between types using the methods asBits, asUInt,
asSInt, and asBools. The latter method converts a multi-bit signal to a
Vec[Bool] (see here).
SpinalHDL uses the := operator to connect signals. For most arithmetic
operations, the same operators as in Verilog or Scala are used (e.g., +,
&&,...). The two exceptions are tests for equality (===) and inequality
(=/=) because using the standard operators (== and !=) would clash with
the use of those operators in Scala's class hierarchy.
Because Scala already has keywords named if and else, SpinalHDL introduces
the keywords when (if in Verilog), elsewhen (else if), and otherwise
(else). Besides their names, they are syntactically similar as normal
conditional execution in Scala.
For storing the value in a register, we initially had the following Verilog code:
reg [7:0] counter;
always @(posedge clk) begin
counter <= t2;
end
which can be coded like this in SpinalHDL:
val counter = Reg(UInt(8 bits))
counter := t2
Next to its type (UInt), the counter signal is explicitly annotated to be a
register
(Reg).
Since SpinalHDL knows that counter is a register, we do not have specify any
clock edge sensitivity like we have to in Verilog. Note that there is no mention
of any clock signal at all. By default, SpinalHDL uses a global "clock domain"
(which contains, among others, a clock signal) to drive all registers in the
design. This means that in most designs it is not necessary to ever explicitly
refer to a clock.
The 8-bit counter design with all logic combined looked like this in Verilog:
reg [7:0] counter;
always @(posedge clk) begin
if (rst) counter <= 0;
else if (enable) counter <= counter + 1;
end
which is concisely specified as follows in SpinalHDL:
val counter = Reg(UInt(8 bits)).init(0)
when (enable) {
counter := counter + 1
}
The most important difference with Verilog is that we don't have to explicitly
deal with the reset signal. Like the clock signal, an implicit reset signal is
contained within the global clock domain. A reset value can be specified for a
register by calling its init method.
Advanced muxing
We ran into trouble in Verilog when trying to model a 3-input mux like this:
reg o;
always @* begin
if (s == 0) o = i0;
else if (s == 1) o = i1;
else if (s == 2) o = i2;
end
Here, Verilog would silently create a latch for o.
If we model the same logic in SpinalHDL:
val o = Bool
when (s === 0) {
o := i0
} elsewhen (s === 1) {
o := i1
} elsewhen (s === 2) {
o := i2
}
we get a very useful error (SpinalHDL checks for many common design errors:
LATCH DETECTED from the combinatorial signal (toplevel/o : Bool), defined at ...
The solution is the same as in Verilog: make sure a value is assigned to o under all conditions.
Like in Verilog, later assignments override earlier ones so we could do something like this:
val o = Bool
o := False // or o.assignDontCare()
...
SpinalHDL also supports a switch construct:
switch (s) {
is (0) { o := i0 }
is (1) { o := i1 }
is (2) { o := i0 }
default { o := False }
}
and a more explicit mux method:
o := s.mux(
0 -> i1,
1 -> i1,
2 -> i2,
default -> False
)
Modules
The full 8-bit counter module looked as follows in Verilog:
module counter8(
input wire clk,
input wire rst,
input wire enable,
output reg[7:0] counter
);
always @(posedge clk) begin
if (rst) counter <= 0;
else if (enable) counter <= counter + 1;
end
endmodule
In SpinalHDL, modules are created by wrapping logic in the constructor of a
class that inherits from Component:
class Counter8 extends Component {
val enable = in(Bool)
val counter = out(Reg(UInt(8 bits)).init(0))
when (enable) {
counter := counter + 1
}
}
I/O ports are created by wrapping signal types with the in or out
annotations. Signals that are not annotated as I/O are local signals which
cannot be accessed from outside the surrounding module. Note again that the
clk and rst signals are not visible since they are part of the implicit
global clock domain.
Modules can be instantiated by creating objects of their classes. I/O ports are then connected by simply assigning to or reading from them:
class Counter16 extends Component {
val enable = in(Bool)
val counter = out(UInt(16 bits))
val lsb = new Counter8
lsb.enable := enable
val msb = new Counter8
msb.enable := enable && (lsb.counter === 0xff);
counter := msb.counter @@ lsb.counter
}
Workflow
SpinalHDL is implemented as a Scala library and all "keywords" discussed so for are, in fact, not keywords but simply methods defined by this library. When running a Scala program using SpinalHDL, an internal RTL representation is created that corresponds to the logic defined by the library calls. This RTL can then be converted to Verilog or VHDL:
object Generate {
def main(args: Array[String]): Unit = {
SpinalVerilog(new Counter16)
}
}
Here, the instantiation of the Counter16 module builds an internal
representation of all RTL for this module which is then converted to Verilog
by passing the instance to the SpinalVerilog method. When running this main
method, a file called Counter16.v will be created that contains the generated
Verilog code.
SpinalHDL also supports running Verilog simulations directly from Scala. This is implemented by first generating Verilog and then simulating this code using Verilator. While the simulation is running, it is possible to access Verilog signals from Scala which allows, for example, writing test cases in Scala. For this exercise session, all necessary simulation code is provided.
The temporary files and results of the simulation are stored in the
simWorkspace directory. When a top-level component called Foo is simulated,
the following files are created (among others):
Foo/test.vcd: VCD file resulting from the simulation;Foo/rtl/Foo.v: Verilog code used for the simulation.
Naming
Since SpinalHDL generates Verilog or runs simulations using a Verilog simulator, it is important that modules and signals defined in Scala are properly named in Verilog. For the most common cases, SpinalHDL is able to automatically figure out good names to use in Verilog using the following rules:
- Modules get the same name as the
Componentsubclass used to generate them; - Top-level signals defined in the constructor of a
Componentsubclass get the same name as the Scala instance variable used to store them.
In both cases, SpinalHDL will resolve naming conflicts with Verilog keywords by appending a number to the name. It is recommended to avoid using Verilog keywords for modules or signal names.
In more advanced use cases where signals are not necessarily stored in top-level
Scala instance variables, SpinalHDL will not be able to automatically select a
good name. Instead, it will generate a name of the form _zz_n_ where n is
a natural number. Such signals are not dumped to theVCD file during simulation.
In these cases, the setName method can be used to manually select a name.
Aggregate data types
SpinalHDL offers an array-like data structure called
Vec
that holds a fixed number of signals of the same type. It implements Scala's
IndexedSeq trait. The main advantage of using Vec over one of Scala's
built-in collections is that SpinalHDL's naming algorithm recognizes it. It
creates a signal for each element that is named by appending its index to the
name of the Vec.
Multiple signals of different types can be bundled in a struct-like data
structure using the
Bundle
base class. This is mainly useful to define complex buses in which multiple
signals are always used together. For example, an SPI
bus could be defined
as follows:
class SpiBus(numSlaves: Int) extends Bundle {
val sclk = Bool
val mosi = Bool
val miso = Bool
val ss = Bits(numSlaves bits)
}
Buses often have an I/O direction and what is an output on one side of the bus,
is an input on the other side. The two sides of a bus are called master and
slave. To handle this, SpinalHDL defines the IMasterSlave trait. When
implementing this trait, the asMaster method must be implemented which should
set the I/O direction of all signals when the bus is used as a master (when used
as a slave, SpinalHDL automatically flips the direction of all signals):
class SpiBus(numSlaves: Int) extends Bundle with IMasterSlave {
...
override def asMaster(): Unit = {
out(sclk, mosi, ss)
in(ss)
}
}
SpinalHDL also supports an "auto-connect" operator (<>) that automatically
connects all signals of a bus to the corresponding signals of another bus
(taking their directions into account):
class SpiMaster extends Component {
val bus = master(new SpiBus)
...
}
class SpiSlave extends Component {
val bus = slave(new SpiBus)
...
}
class Soc extends Component {
val spiMaster = new SpiMaster
val spiSlave = new SpiSlave
spiMaster.bus <> spiSlave.bus
}
Exercise: popcnt
For the first exercise, the popcnt module discussed during the Verilog lecture
should be implemented in SpinalHDL. The end goal is to implement a class that is
configurable in the number of bits of the input and in whether the operation
should be implemented in multiple cycles. The logic should be implemented in
Popcnt.scala and the simulation can be run using the PopcntSim object in
Top.scala.
The Popcnt component has the following constructor parameters:
width: the bit width of the input;multiCycle: boolean indicating whether the logic should be implemented in multiple cycles or asynchronous.
The following I/O ports should be defined by the Popcnt component:
start(input): asserted when the operation should start. Not needed for the asynchronous logic but should still be defined;value(input): input to the operation;count(output): result of the operation;ready(output): asserted when the operation is ready. Not needed for the asynchronous logic but should still be defined;
It is recommended to implement the logic incrementally:
- Implement the logic for a 4-bit asynchronous circuit. Beware that even though the operation should be implemented using purely combinational logic, the result should still be stored in a register. Also, the simulation will fail with an obscure error message if you try to run it before defining a register in your implementation;
- Generalize this logic to support arbitrary bit widths. Hint: one way to do this is to use a fold operation on the bits of the input;
- Add the logic for the multi-cycle implementation.
Exercise: a configurable shift register
In this exercise, you will be implementing a component that manages shift registers. This component provides the following method to dynamically add a shift register:
def addReg(name: String, width: BitCount, delay: Int): (Bits, Bits)
This methods adds a new shift register to the component containing delay
width-bit registers in cascade. The name argument can be used to generate
readable names for all created I/O ports and registers. The return value of this
method is a 2-tuple where the first element is an input connected to the first
register in the cascade and the second element an output connected to the last.
The method getReg should return the same tuple.
As an example, the image below shows the configuration of the component after calling
addReg("foo", 8 bits, 3)|
In this case, the tuple returned would be (foo_in, foo_out).
SpinalHDL globally keeps track of the "current component". Whenever a new
instance is created of a Component subclass, the current component is set to
this instance. When logic is created, it is added to the current component. This
means that if logic is created inside the addReg method, it will be added to
the component that called this method, which is not what we want. To solve this,
SpinalHDL offers the rework method which can be called on a Component to add
logic to it after its creation:
class ExtendableComponent extends Component {
def addLogic(): Unit = {
rework {
// Add logic here
}
}
}
The logic for this exercise should be implemented in ShiftReg.scala and the
simulation can be run using the ShiftRegSim object in Top.scala. The
simulation adds two registers (see method createShiftReg) and runs for 10
cycles, setting the input of the first register to increasing values starting at
0, and the input of the second register to decreasing values starting at 10.