spinalhdl-exercises/Tutorial.md
marton bognar 2256bc29f7 Updates
2025-03-12 13:34:09 +01:00

12 KiB

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.

Table of Contents

[[TOC]]

Setup

Read this to learn how to install all prerequisites and test your setup.

Basic logic

We will introduce the basic concepts of RTL modeling in SpinalHDL by building the same 16-bit counter module we saw during the lectures. As in Verilog, we will create an 8-bit counter module first and use that as the building blocks for a 16-bit counter.

While designing the combinational path in Verilog, we could write 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 Component subclass used to generate them;
  • Top-level signals defined in the constructor of a Component subclass 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
}