Spade
Spade is a Rust-inspired hardware description language.
For a gentle introduction to Spade, there is a (work in progress) tutorial that starts with the hello-world of hardware - blinky.
If you want to jump straight into something more complex, there is a chapter on implementing a ws2128 RGB LED driver in Spade.
If you are more interested in a reference of all constructs in the language, see the language reference
If you have any questions or would like to discuss Spade with others, feel free to join the Discord community or Matrix channel.
Spade is a work in progress language and so is this documentation. Writing documentation is hard since it is hard to know what details are obvious and what things need more explanation. To make the documentation better, your feedback is invaluable so if you notice anything that is unclear or needs more explanation, please, reach out either via a gitlab issue or on Discord
Chapters
Installation
Before installing locally, there is a "playground" available at ▶️ play.spade-lang.org which you can use to play around with the language. The first few chapters of the book use that, so if you want to follow along with the tutorial, you can skip this chapter until prompted to install Spade locally.
At the moment, Spade works best on Linux systems, though macOS also works quite well with only a few minor issues1. Windows is not supported for now, though it should be usable in WSL.
In order to install Spade, you need the Rust toolchain, specifically cargo
.
If you don't have it installed, you can install it with https://rustup.rs/.
Simply run the command there, make sure its binaries are in your PATH
and
then run rustup toolchain install stable
Unless you have specific needs, you should install the Spade compiler via its build tool Swim. Swim then manages the compiler version on a per-project basis. To install Swim, run
cargo install --git https://gitlab.com/spade-lang/swim
If you are on arch-linux you can also install the swim-git
package from the
aur https://aur.archlinux.org/packages/swim-git
You should now be able to create a swim project using swim init hello_world
Synthesis Tools and Simulators
Spade compiles to Verilog code which is simulated and synthesised (compiled to hardware) by other tools. In particular cocotb for simulation, and yosys+nextpnr for synthesis.
Automated install
The easiest way to install those tools is via Swim. This is done by running
swim install-tools
which downloads https://github.com/YosysHQ/oss-cad-suite-build into
~/.local/share/swim
. If it is installed, swim will always use the cad-suite tools instead of system tools.
NOTE: If you need to uninstall those tools, remove
~/.local/share/swim/bin
Manual install
You can also install the tools manually. Refer to the individual installation instructions in that case. The tools you need are:
- For simulation:
- Python3.8 or later
- Simulation via Icarus Verilog (using cocotb): https://github.com/steveicarus/iverilog
- Simulation via Verilator: https://github.com/verilator/verilator
- For building for hardware
- Synthesis: https://yosyshq.net/yosys/
- Place and route: https://github.com/YosysHQ/nextpnr. Build
nextpnr-ice40
ornextpnr-ecp5
depending on your target FPGA - Bitstream generation and upload. Depends on your target FPGA
If you're just starting out, you probably don't need all of these. Start of by simulating your designs using
cocotb
andicarus
, then you can move on to testing in hardware usingyosys
andnextpnr
.If your simulations are too slow, you can try
verilator
Next steps
Now, move on to setting up your editor to work with Spade.
Editor Setup
Before installing locally, there is a "playground" available at ▶️ play.spade-lang.org which you can use to play around with the language. The first few chapters of the book use that, so if you want to follow along with the tutorial, you can skip this chapter until prompted to install Spade locally.
There are a variety of third-party plugins integrating Spade in different editors.
Vim
If you use neovim with lazy.nvim, you can use spade.nvim, which is maintained by Ethan at that GitHub repository. This plugin sets up syntax highlighting and LSP automatically.
Otherwise, you can use https://gitlab.com/spade-lang/spade-vim, following the instructions at that repository for manual setup.
Vscode
Emacs
Other Editors
Made a plugin for your favorite editor? Submit a merge request to add it to this list!
Blinky
The traditional program to start learning any language is "hello, world!". However, printing a string in hardware is a complex task, so the "hello, world!" in hardware is usually blinking an LED.
This chapter has two versions:
- Blinky (for hardware people) for people who have some experience with digital hardware and want to learn Spade. This version focuses on the syntax of the language and makes comparisons to Verilog and VHDL, but assumes some familiarity with things like registers.
- Blinky (for software people) for people who are used to software development but are new to hardware. This version puts less emphasis on the syntax of the language, and more on the basic hardware it is describing.
Blinky (for software people)
This chapter will show the very basics of Spade and is aimed at people who are familiar with software development but are new to hardware. If you come here with some experience in hardware design with VHDL or Verilog, the Blinky (for hardware people) chapter is probably more useful.
Before blinking an LED, we can try turning on or off an LED. We can do this as
entity blinky() -> bool {
true
}
To Rust users, this will likely feel very familiar. To those familiar with
other languages, the last value at the end of a block is "returned", so this is
an entity
which returns true
.
If we connect the output signal of this to an LED, it would turn on. If you're curious, you can try it ▶️ on the playground
This isn't particularly interesting though, so let's do something more
interesting. Blinking an LED is the typical "Hello, World" in hardware, but
even that requires some complexity so we will build up to it. Let's first start
by making the LED turn off while we hold down a button, which first requires
taking a btn
as an input:
entity blinky(btn: bool) -> bool {
and then changing the output to !btn
entity blinky(btn: bool) -> bool {
!btn
}
If you ▶️ try this, you can see that if you press the button, the LED turns off, and if you release it, it will turn on again. Here we're just simulating the resulting hardware, but if we connected this up to real hardware, it would also work!
If you think about this for a while, you may start wondering when this gets
"evaluated". In software, this "function" would be called once, giving it the value of
the button and generating a single result. But this somehow reacts to inputs!
While Spade, and many HDLs for that matter may look like software, it is
important to note that we are not describing instructions for some processor
to execute, we are describing hardware to be built on a chip. The code we
wrote says "connect input btn
to an inverter, whose output in turn should be
connected to the output of the module which we externally connect to an LED.
If we want to approximate the behaviour from a software perspective, we can view the progrmaming model of Spade either as continuously re-evaluating every value in the design, or as re-evaluating every value when the values it depends on changes.
At this point, we can start thinking about actually making an LED blink. In software we'd probably accomplish this by writing something along the lines of
def main():
led_on = Talse
while True:
led_on = not led_on;
set_led(led_on);
sleep(0.5);
However, because we are describing hardware, not software we can't really "loop". Every expression we write will correspond to some physical block of hardware, rather than instructions that get executed.
A Detour Over Software
Before talking about how we would make a LED blink in hardware and Spade, it is helpful to talk about how we might write a software function to to "blink" an LED if we can't have loops inside our function. Remember that we can view our execution model as constantly re-evaluating our function to get its new values, roughly
def blinky():
return True
while True:
print(blinky())
On the surface, it might seem very difficult to make this thing blink, but if we had some way to maintain state between calls of the function. In software, we can achieve this by using a global variable for the state of the LED
LED_ON = False
def blinky():
global LED_ON
LED_ON = not LED_ON
return LED_ON
while True:
print(blinky())
If we run this program, we'll now get alternating True
and False
True
False
True
False
...
There are some problems with this though, our value is "blinking" far too fast for us to see it blinking. If this were hardware, the LED would just look dim as opposed to clearly switching between on and off, we need to regulate it somehow. A quick way to do this would be to just call our function less often, for example, once per second. As we'll see, this is something we can kind of do in hardware, so let's try it!
import time
while True:
start = time.time()
print(blinky())
end = time.time()
# We want each iteration to take 0.5 seconds
# so we get a blinking frequency of 1 hz.
# To avoid drifting if `blinky` ends up taking
# a long time, we'll compute how long the evaluation
# took and subtract that from the period
time.sleep(0.5 - (end - start))
That works, but has a major problem: now we cannot do anything more often than
once per second, so if our program was to do more things than blinking an LED,
we're probably screwed. To solve this, we can reduce the sleep time to
something faster, but which we can still manage without having end-start
become larger than the period. Being conservative, we'll aim for a frequency of 1 KHz
import time
while True:
start = time.time()
print(blinky())
end = time.time()
time.sleep(0.001 - (end - start))
If we just run our blinky now, we're back to it blinking faster than we can see, so we'll need to adjust it to compute how long it has been running and toggling the LED accordingly
COUNTER = 0
def blinky():
global COUNTER
if COUNTER == 1000:
COUNTER = 0
else:
COUNTER = COUNTER + 1
# The LED should be on in the second counter interval
return COUNTER > 500
import time
while True:
start = time.time()
print(blinky())
end = time.time()
time.sleep(0.001 - (end - start))
Back To Hardware
At this point, you have got a sense of a (pretty cursed) programming model that approximates hardware pretty well, so we can get back to writing hardware.
Almost all primitive hardware blocks are pure (or combinatorial as it is known in hardware). They take their inputs and produce an output. This includes arithmetic operators, comparators, logic gates and "if expressions" (multiplexers). Using these to build up any form of state, like our counter, will be very difficult. Luckily there is a special kind of hardware unit called a flip_flop which can remember a single bit value. These come in several flavours and by far the most common is the D-flipflop which has a signature that is roughly
entity dff(clk: clock, new_value: bool) -> bool
Its behaviour when the clock signal (clk
) is unchanged is to simply remember
its current value. Flip flops become much more interesting when we start
toggling the clock. Whenever the clk
signal changes from 0 to 1, it will
replace its currently stored value with the value that is on its new_value
input.
Hardware is often shown graphically, and a dff
is usually drawn like this:
Using this, we can build our initial very fast blinking circuit like this:
entity blinky_dff(clk: clock) -> bool {
decl led_on;
let led_on = inst dff(clk, !led_on);
led_on
}
Don't worry too much about the syntax here, we define led_on
as a dff
whose new value is !led_on
. When the clk
goes from 0 to 1, the dff
will
take the value that is on its input (!led_on
) and set it as its internal
value, which makes the LED blink. This might be easier to understand graphically:
We can also visualize the value of the signals in the circuit over time, which looks roughly like
As soon as the clock switches from 0 to 1, the value of led_on
switches to
new_value
. This in turn makes the output of the inverter change to the
inverse which is now the "new new_value
". Then nothing happens until the
clock toggles again at which point the cycle repeats.
At this point, you should be wondering what the initial state of the register is as right now it only depends on itself. While it is possible to specify initial values in registers in FPGAs, that's not possible when building dedicated hardware, so the proper approach is to use a third input to the DFF that we left out for now: the reset. It takes a bool
which tells the flip flop to reset its current value if 1, and a value to reset to. Again, looking at the signature, this would be roughly
entity dff(clk: clock, rst_trigger: bool, initial_value: bool, new_value: bool) -> bool
When rst
is true, the internal value of the dff will get set to initial_value
.
Visualized as signal values over time, this looks like:
The clk
and rst_trigger
signal are typically fed to our hardware
externally. The clock is as you may expect from reading clock signal
specifications on hardware, quite fast. Not quite the 3-5 GHz that you may
expect from a top of the line processor, but usually between 10 500 MHz in
FPGAs. This means that we need to pull the same trick we did in our software
model to make the blinking visible: maintain a counter of the current time and
use that to derive if the led should be on or not.
Our counter needs to be quite big to count on human time scales with a 10 Mhz
clock, so building a counter from individual bool
s with explicit dff
s for
each of them is infeasible. Therefore, we almost always use "registers" for our state. These are just banks of dff
with a shared clock and reset.
Additionally, using our dff
entity isn't super ergonomic since it requires that decl
keyword, so Spade has dedicated syntax for registers. It looks like this
reg(clk) value: uint<8> reset(rst: reset_value) = new_value
which, admittedly is quite dense syntax. It helps to break it down in pieces though
reg(clk)
specifies that this is a register that is clocked byclk
.1value
is the name of the variable that will hold the current register value: uint<8>
specifies the type of the register, in this case an 8 bit unsigned value. In most cases, the type of variables can be inferred, so this can be left outreset(rst: reset_value)
says that the register should be set back toreset_value
whenrst
is true. If the register does not depend on itself, it can be omitted
Blinky, Finally
We finally have all the background we need to drumroll 🥁 blink an LED! The code to do so looks like this
entity blinky(clk: clock, rst: bool) -> bool {
let duration = 100_000_000;
reg(clk) count: uint<28> reset(rst: 0) = if count == duration {
0
} else {
trunc(count + 1)
};
count > duration / 2
}
Looking at the python code we wrote before, we can see some similarities. Our
global count
has been replaced with a reg
. reg
has a special scoping rule
that allows it to depend on its own value, unlike normal let
bindings which
are used to define other values. The new value of the register is given in
terms of its current value. If it is duration
, it is set to 0, otherwise it
is set to count + 1
.
trunc
is needed since Spade prevents you from overflows and underflows by
extending signals when they have the potential to overflow. count + 1
can
require one more bit than count
, so you need to explicitly convert the value
down to 28
bits. trunc
is short for "truncate" which is the hardware way of
saying "throwing away bits".
Those unfamiliar with Rust or other functional languages may be a bit surprised that the if
isn't written as
if count == duration {
count = 0
} else {
count = trunc(count + 1)
}
This is because spade is expression based -- conditional return values instead of having side effects. This is because in hardware, we can't really re-assign a value conditionally, the input to the "new value" field of the register is a single signal, so all variables in Spade are immutable.
If you are used to C or C++, you can view if
expressions as better ternary
operators (cond ? on_true : on_false
), and python users may view them as the
on_true if cond else false
construct.
Play around
At this point it might be fun to play a little bit with the language, you could try modifying the code to:
- Add an additional input to the
entity
calledbtn
which can be used to pause the counter - Use
btn
to invert the blink pattern
You can try the code directly in your browser at ▶️ play.spade-lang.org
Technically, there are a whole family, but in practice we almost always use registers built from D-flip flops. 1: Most of the time when starting out you'll just have one clock, but as you build bigger systems, you'll eventually need multiple clocks
Blinky (for hardware people)
This chapter will show the very basics of Spade and is aimed at people who are already familiar with basic digital hardware and want to learn the language. If you come here as a software developer, the Blinky (for software people) chapter is probably more approachable.
A blinky circuit in Spade is written as
entity blinky(clk: clock, rst: bool) -> bool {
let duration = 100_000_000;
reg(clk) count: uint<28> reset(rst: 0) = if count == duration {
0
} else {
trunc(count + 1)
};
count > duration / 2
}
The first line defines a "unit" 1 called blinky
which takes a clock and a
reset signal and returns (->
) a bool
which will be true
when the blinking LED
should be on.
This highlights an important difference between Spade and traditional HDLs: most2 units in Spade take
a number of input signals and produces an output signal instead of operating
on a set of input or output ports.
In general, Spade units are much more "linear" than their VHDL and Verilog
counterparts - Variables can only be read after their definition (unless
pre-declared using decl
) and units do not mix inputs with output.
The first line in the body of the entity uses let
to define a new variable called duration
whose
value is the number of clock cycles in a blink period, here we assume a 100
MHz clock.
Spade is a statically typed language so duration
will have a fixed type
known at compile time, however, the compiler uses type inference to infer the
types of variables where possible.
In this case, the duration
variable is compared to count
on the next line
which forces its type to be the same as count
, i.e. uint<28>
and the compiler
will ensure that the value fits in the inferred type's range.
If needed, the type of a variable can be specified explicitly using let duration: uint<28> = ...
.
The next few lines are a reg
statement which is used to declare a register. The syntax
for these can be hard to take in at first, but it helps to break it up into pieces:
reg(clk)
specifies which clock is used to clock this registercount
is the name of the variable which will hold the register value: uint<28>
specifies the type of the register. Normally this can be omitted but in this case the compiler is unable to infer the size without it sincecount
only refers to itself andduration
.reset(rst: 0)
says that the register should be reset back to0
wheneverrst
is asserted. At the moment, this is always done using an asynchronous reset.
Finally, the statement is ended with an =
sign followed by an expression
that gives the new value of the register as a "function" of its previous value. Here, the register is set back to 0
if it has reached the duration
, otherwise it is incremented by 1
.
A significant difference between Spade and most other HDLs here is that its
semantics are not "imperative". We do not write
if count == duration {
count = 0
} else {
count = trunc(count + 1)
}
which is conceptually hard to map to hardware, instead the if
construct returns a value
which is assigned to the register's new value.
This is much closer to the multiplexers that will be generated here than the
imperative description is, and prevents bugs if one for example, forgets to
give count
a value in the else
branch.
The trunc
function call in the else branch is another effect of Spade's type
system. The type system is designed to prevent accidental destruction of
information.
Since a + 1
can require one more bit than a
itself, the type
of count + 1
is uint<28+1>
, which cannot be implicitly converted to a
uint<28>
. The trunc
function explicitly truncates the result back to fit in
the register's value.
The final line count > duration / 2
is what sets the output of the unit.
Whenever count
is greater than half the duration of the counter, its output
will be true
.
The final expression in a unit is its return value which may feel unfamiliar
at first, but eventually feels quite natural, especially when combined with
other block-based constructs. For example, the same thing is true in
if-expressions. The 0
and trunc(count + 1)
are the final expressions in
the blocks, and therefore their "return" values.
A note on division: You may question the use of
/
in the above example since division is usually a very expensive operation in hardware. However, divisions by powers of two are cheap, so spade explicitly allows those. If the code was changed to/ 3
, you would get a compiler error telling you about the performance implication and telling you to explicitly use combinational division if you are OK with the performance.error: Division can only be performed on powers of two ┌─ src/blinky.spade:10:24 │ 10 │ count > duration / 3 │ ^ Division by non-power-of-two value │ = help: Non-power-of-two division is generally slow and should usually be done over multiple cycles. = If you are sure you want to divide by 3, use `std::ops::comb_div` │ 10 │ count > duration `std::ops::comb_div` 3 │ ~~~~~~~~~~~~~~~~~~~~
Play around
If you want to play around with the language at this point, you can try to modify the code to do some of these things:
- Add an additional input to the
entity
calledbtn
which can be used to pause the counter - Use
btn
to invert the blink pattern
You can try the code directly in your browser at ▶️ play.spade-lang.org
A "unit" in spade is similar to entity
in VHDL and module
in Verilog.
The input -> output
flow is not always well suited to hardware, in those cases, ports may be used.
Units
The basic building blocks of a Spade project are units. A unit takes a set of input signals, "processes" them, and usually produces a resulting output signal. We already saw an example of a unit in the blinky chapter, but here we will go into them in a bit more detail.
The basic syntax for defining all three is the same for all three though. They
start with fn
, entity
or pipeline
depending on their "flavor" which we
will talk about soon, then the name of the unit is specified. The unit inputs
are specified inside ()
with each argument on the form name: type
. The
output of the unit is specified after the parameter list as -> type
, and
finally the body of the unit is specified.
As an example, the blinky from the previous chapter has the following definition
entity blinky(clk: clock, rst: bool) -> bool
which means it
- is an
entity
- called
blinky
- which takes 2 inputs:
clk
with typeclock
andrst
with typebool
- returns a
bool
At this point you are probably wondering why we keep calling them "units" when they are defined as entity
. The reason for this is that units come in three "flavors": function
, entity
and pipeline
. While they all take inputs and produce outputs, their semantics are somewhat different
-
Entities are the most general units, but as we will see, they also come with the fewest guarantees. If you need registers but don't want to use a pipeline, you should use an
entity
. -
Functions are a special case of entities which don't allow registers or instantiation of non-functions. This means that they cannot contain any state, which in hardware terms means they are combinational, and in software terms means they are pure. While any function can be written as an entity, it is good practice to use functions whenever possible as it tells readers of the code that the unit is non-stateful.
-
Pipelines are a special unit which, as the name implies, is used when building pipelines. You will learn more about these in a later chapter.
In general, you should prefer to use function
and pipeline
where possible,
and only resort to entity
in cases where you both need state, and when the
hardware you are building is not pipeline-like, for example our blinky module.
Instantiating Units
Units are not very useful if they cannot be instantiated. Functions are
instantiated using the same syntax as function calls in C-like software
languages: function_name(parameter1, parameter2)
.
Entities on the other hand need the inst
keyword before the instantiation,
for example inst entityoname(parameter1, parameter2)
.
This is done to alert you as a writer of the code, and future readers of the
code that the unit you are instantiating can have underlying state.
If you do not see inst
, you know that that is a function and therefore is
pure which allows you to make more assumptions about the behaviour of your
circuit without having to read through the source code of what you are
instantiating.
Finally, when instantiating pipelines, you specify the pipeline depth after
the inst
, so inst(10)
. This will be described in more detail later.
Passing arguments
Of course, most functions need their arguments specified, and there are two ways to pass arguments to units in Spade: by position or by name.
Positional arguments work like they do in most languages: the first value passed is matched with the first argument, the second with the second and so on. It is the syntax we have seen so far.
Named arguments have a $
sign before the argument list and allow you specify
the name of each argument along with the value it should receive as arg: value
.
As an example, if we want to instantiate the following entity
entity some_entity(x: uint<8>, y: uint<8>) -> uint<8> // ...
with x=10
and y=15
we can do so with positional arguments as
inst some_entity(10, 15)
or using named arguments
inst some_entity$(x: 10, y: 15)
// or
inst some_entity$(y: 15, x: 10)
In many cases when specifying arguments by name, you have a variable where you
want to do your instantiation that has the same name as the argument you want
to pass it to. You could of course specify arg: arg
, but Spade also allows
you to use a short-hand syntax and only specify arg
in this case.
Continuing with our example, function, yet another way to instantiate it is therefore:
let x = 10;
let y = 15;
inst some_entity$(x, y)
You can even mix and match shorthand names with long names, which is especially useful
if you have signals with common names such as clk
and rst
:
entity do_something(clk: clock, rst: bool) -> uint<8> {
let x = 10;
inst takes_clk_rst$(clk, rst, x, y: trunc(x + 5))
}
However, note that you cannot mix positional and non-positional arguments!
Which style to use depends on your application and code, you should strive for the variant that gives the most readable code. Sometimes that means you pass arguments by position because the order is obvious while other times, you opt to pass arguments by name because your unit takes too many signals to keep track of their positions.
For Software People: Instantiation vs calling
Instantiation is similar in behaviour to "calling" in software terms, but because we are building hardware, we cannot simply "transfer control flow" to another function. Instead, we copy the hardware inside the function to our "chip" and connect its inputs and outputs as appropriate.
As an example, if we define the following functions
fn add(a: uint<16>, b: uint<16>) -> uint<16> {
trunc(a + b)
}
fn mul(a: uint<16>, b: uint<16>) -> uint<16> {
trunc(a * b)
}
fn sel(a: uint<16>, b: uint<16>, cond: bool) -> uint<16> {
if cond {a} else {b}
}
which generate the following hardware
and then use them as part of a bigger function:
fn mul_or_add(a: uint<16>, b: uint<16>, multiply: bool) -> uint<16> {
sel(add(a, b), mul(a, b), multiply)
}
it generates this hardware:
This is important to keep in mind as a very important metric for resource usage in hardware is the area of the chip being used. In software, an expensive function only used very rarely is relatively cheap since the time taken for the program to run is the main cost. However, in hardware, as soon as a unit is instantiated, you pay the cost upfront, regardless of it is used millions of times per second or just once over the lifetime of the chip.
In addition, it is important to keep in mind how much area each function and operator uses. In the graphics drawn now, the multiplier looks as big as the adder, but in practice, the size of the adder grows as \(O(n)\) in the number of bits, while the multiplier grows as \(O(n^2)\). In FPGAs, things are even trickier as they have built in multipliers. While you have spare multipliers, they are free in terms of other resources, but they themselves are finite. The resource usage of different units is generally something you will learn over time.
Naming conventions
While not strictly required, unit names are usually written using
snake_case
, so are variable names. User defined types use on PascalCase
while constant values use SCREAMING_SNAKE_CASE
Exercises
Modify the blinky code from the previous chapter to do the following
- Break the check for
count > (duration / 2)
into a function- Call with named arguments
- And positional arguments
- Break the counter logic out into its own
unit
- Should it be an entity or function?
Brief intro to generic parameters
We will discuss the type system in more detail later, but you will most likely come across a few generic functions before then, so here is a quick introduction.
In the functions we have seen so far, the type of the arguments has been
specified explicitly, for example, sel
in the example above takes two
uint<16>
and a bool
. However, this is quite restrictive, we may want sel
to operate on other sized integers, or other types entirely. There is nothing in
that function that requires 16-bit unsigned integers.
We can redefine sel
to make the values it selects "generic" as follows:
fn sel<T>(a: T, b: T, cond: bool) -> T {
if cond {a} else {b}
}
which defines a new local type T
that can be substituted for any other type in the
implementation, as long as that same type is used everywhere T
is.
We can now instantiate sel
with different types
let x_16: uint<16> = 10;
let y_16 = 10u16; // You can specify the type of integers using `u<size>` or `i<size>`
let max_16: uint<16> = sel(x_16, y_16, x_16 > y_16);
let (x_32, y_32) = (0, 0);
let selected: int<32> = sel(x_32, y_32, select_x);
In some cases, the typeinference is unable to infer the generic parameters of
an instance which you can resolve by specifying them using the "turbofish"1
syntax (::<>
). Like function arguments, type parameters can be specified positionally or by name using ::<>
or ::$<>
:
// We don't have enough information about what type the integers have here, we'll
// get a compiler error
let selected = sel(10, 20, select_10);
// Turbofish solves that
let selected = sel::<uint<8>>(10, 20, select_10);
let selected = sel::$<T: uint<8>>(10, 20, select_10);
Simulation and Testing
At this point, we're going to move away from the playground and install Spade locally so we can run a "real" flow. If you haven't already, go back and read the installation to install Swim, Spade and a simulator. You can skip synthesis tools for now.
Because it is time-consuming and difficult to debug hardware, most hardware projects use simulation to speed up the development process and ease debugging.
Tests are not written in Spade itself, instead they are written in Python using cocotb or C++ in Verilator. Cocotb is easier to set up and nicer to use but can be quite slow.
If you haven't already, install the tools by following the installation instructions
Cocotb
Any Python files in the test
directory will be run with cocotb. The first line must be a
comment on the form
# top = <path to unit under test>
where the path is relative to your project. I.e. if you have a unit called
top
in main.spade
, this will be main::top
Tests are asynchronous functions annotated with @cocotb.test()
, they take a single
input which is the design under test.
When working with Spade, you generally want to be able to use Spade values rather than
pure bit vectors. To do so, import SpadeExt
from spade
and instantiate SpadeExt
class, passing the dut
to hits constructor.
You can then access the inputs of your unit using .i.<input name>
and the
output using .o
. If the output of the unit is a struct, you can refer to
individual fields using .o.<field name>
As an example, consider this unit which computes a+b
and a*b
with a latency of
one cycle:
struct Output {
sum: int<9>,
product: int<16>
}
pipeline(1) add_mul(clk: clock, a: int<8>, b: int<8>) -> Output {
let result = Output$(
sum: a+b,
product: a*b
);
reg;
result
}
A test bench for this module looks like this (this assumes that the Spade code
is in src/cocotb_sample.spade
. If this is not the case, adjust the # top=cocotb_sample::add_mul
part to reflect your module name):
#top = cocotb_sample::add_mul
import cocotb
from spade import SpadeExt
from cocotb.clock import Clock
from cocotb.triggers import FallingEdge
@cocotb.test()
async def test(dut):
s = SpadeExt(dut) # Wrap the dut in the Spade wrapper
# To access unmangled signals as cocotb values (without the spade wrapping) use
# <signal_name>_i
# For cocotb functions like the clock generator, we need a cocotb value
clk = dut.clk_i
await cocotb.start(Clock(
clk,
period=10,
units='ns'
).start())
await FallingEdge(clk)
s.i.a = "2"
s.i.b = "3"
await FallingEdge(clk)
s.o.sum.assert_eq("5")
s.o.product.assert_eq("6")
s.i.a = "3"
s.i.b = "2"
await FallingEdge(clk)
s.o.sum.assert_eq("5")
s.o.product.assert_eq("6")
s.i.a = "0"
s.i.b = "1"
await FallingEdge(clk)
s.o.sum.assert_eq("1")
s.o.product.assert_eq("0")
You can then run the test using swim test
or swim t
. If you want to run all
tests in a specific file, run swim test <pattern>
. All files which contain the
pattern will be run, for example, swim test abc
will run tests in both abc.py
and cdeabc.py
.
You can also run individual tests using -t <test name>
though here, the name has
to be exactly the name of the test, not a pattern
For more information on the cocotb api, refer to its documentation
Viewing the waveform
One cool thing about HDL simulation is that it tracks how all signals trace over time, allowing you to see exactly how your circuit behaves. These traces are called waveforms, and stored in a file which you can view with a waveform viewer.
Once all tests are run, Swim will print the result of each test, along with a .vcd file in which the waveform is stored:
...
ok test/cocotb_sample.py 0/1 failed
🭼 test ok [build/cocotb_sample_test/cocotb_sample.vcd]
For waveform viewers, we recommend https://surfer-project.org/ which was developed specifically for the Spade project, but you can also use GtkWave if you are already familiar with it.
Once the waveform viewer is installed, you can simply run surfer build/cocotb_sample_test/cocotb_sample.vcd
or gtkwave build/cocotb_sample_test/cocotb_sample.vcd
.
If you have a relatively modern terminal, Swim also supports clickable links for opening wave files for each test, but it needs an initial automated setup. Just run
swim setup-links
. Now Swim will print two clickable links, one for surfer and one for gtkwave:ok test/cocotb_sample.py 0/1 failed 🭼 test ok [build/cocotb_sample_test/cocotb_sample.vcd ([🏄] [🌊])]
Surfer also supports translation from the raw bit patterns to Spade types which makes debugging much easier. By far the easiest way to get this is with the clickable links mentioned above, but you can also run Surfer with this manually by running
surfer <path-to-vcd> --spade-state build/state.ron --spade-top path::to::top::module
Tips and Tricks
For now, &mut
wires can not be read by the Spade API in cocotb. Similarly,
tests can not be run on generic units. Therefore, it is often a good idea to
define a wrapper function, typically called a harness
around your units which
use advanced features.
Verilator
C++ source files with the .cpp
extension in the test
directory will be
simulated using Verilator. Like cocotb, these files consist of a set of test
cases, but the test cases are defined using macros rather than attributes.
The Spade path to the top module is specified using a comment, i.e.
// top = path::to::module
where the path is relative to the current project.
After that, the Verilog name of the top module must be specified using a
#define
. Unless you know the details of how Verilator name mangling works,
you almost certainly want to specify #[no_mangle]
on your unit under test.
The last thing you need to do before defining your tests is to include <verilator_util.hpp>
.
After all your test have been defined, end test file with MAIN
which defines
a main function which is compatible with Swim.
// top=main::main
#define TOP main
#include <verilator_util.hpp>
TEST_CASE(it_works, {
// Your test code here
return 0;
})
MAIN
Accessing inputs and outputs
Like the cocotb API, there is a wrapper around Spade types to allow easier interactions with your design.
As an example, consider testing the following code
struct SubStruct {
b: int<10>,
c: int<5>,
}
struct SampleOutput {
a: Option<int<20>>,
sub: SubStruct
}
#[no_mangle]
fn sample(a: Option<int<20>>, b: int<10>, c: int<5>) -> SampleOutput {
SampleOutput(a, SubStruct(b, c))
}
In the TEST_CASE
macro, you have access to two variables: dut
and s
.
dut
is the raw verilator interface around your module which. s
is the Spade
wrapper which has a field i
for its inputs and o
for its output.
You can set the value of inputs to your module with s.i->input_name = "<spade expression>"
, for example:
s.i->a = "Some(5)";
s.i->b = "10";
s.i->c = "5";
Similarly, you can compare the output to a spade expression
using s.o == "<spade expression>"
If your unit under test returns a struct, you can also access its fields and sub-fields
as fields on the output struct, for example s.o->field->subfield == "<spade expression>"
.
Finally, to assert that an output value is what you expect, you can use the ASSERT_EQ
macro
which takes s.o
or a subfield, and compares it against a Spade expression. The advantage
of using this macro over a C++ assert is that you get a diff print, both with the Spade value
and the underlying bits.
For example, tests the fields in our example look like:
ASSERT_EQ(s.o, "SampleOutput$(a: Some(5), sub: SubStruct$(b: 10, c: 5))");
ASSERT_EQ(s.o->a, "Some(5)");
ASSERT_EQ(s.o->sub, "SubStruct$(b: 10, c: 5)");
ASSERT_EQ(s.o->sub->b, "10");
ASSERT_EQ(s.o->sub->c, "5");
Clock generation
Clocks need to be ticked manually in verilator. The spade clock type does not
allow direct assignment, so the clock needs to be accessed via the Verilator
dut
. Spade mangles inputs names as <name>_i
, so if you want to set clk
,
you would set dut->clk_i
. Or you can mark the clock input with #[no_mangle]
The following code will tick the clock once
dut->clk_i = 1;
ctx->timeInc(1);
dut->eval();
dut->clk_i = 0;
ctx->timeInc(1);
dut->eval();
Since this is so common, it is helpful to define a macro for it:
#define TICK \
dut->clk_i = 1; \
ctx->timeInc(1); \
dut->eval(); \
dut->clk_i = 0; \
ctx->timeInc(1); \
dut->eval();
which can then be used like this:
s.i->a = "5";
s.i->b = "10";
TICK;
ASSERT_EQ(s.o, "15");
Alternative test directory
If desired, you can change the name of the test directory by specifying the new name
in swim.toml
as follows
[simulation]
testbench_dir = "not/test"
Unless you have good reason to do this, it is better to leave the default directory.
Ports and wires
If you prefer documentation in video form there is a talk available on this topic.
Note that the syntax of
&mut
has changed toinv &
since that talk
Units in Spade, unlike most HDLs are similar to functions in programming languages in the sense that the receive a set of values, and their output is another set of values. For example, a function that adds 2 numbers is written as
fn add(x: uint<8>, y: uint<8>) -> uint<9> {
x + y
}
This makes sense for a lot of hardware where there is a clear flow of values from inputs to outputs, but this is not always the case. wires and ports are a language feature that helps deal with these cases.
To understand wires and ports, it helps to look at a motivating example. If you're building a project consisting of 2 modules that communicate with each other via some other module, such as a memory, you want your hardware to look something like this:
Without using ports, you'd have to write the signature of this hierarchy as
pipeline(1) mem(clk: clock, addr1: uint<16>, addr2: uint<16>) -> (T, T)
pipeline(4) mod1(clk: clock, inputs: I, data: T) -> (uint<16>, O)
pipeline(3) mod2(clk: clock, inputs: I, data: T) -> (uint<16>, O)
entity top(clk: clock) {
decl memout1, memout2;
let (addr1, mod1_out) = inst(4) mod1(clk, I(), memout1);
let (addr2, mod2_out) = inst(3) mod2(clk, I(), memout2);
let (memout1, memout2) = inst(1) mem(clk, addr1, addr2);
}
Writing it like this is tedious, and more importantly, error-prone as there is no way to communicate which signals correspond to each other. One might assume that the left output of the memory result is the data corresponding to address 1, but there is nothing to enforce this.
In addition, the pipelines internally have to prevent the addresses and returned data from being pipelined:
pipeline(4) mod1(clk: clock, inputs: I, mem_out: T) -> (uint<16>, O) {
'start
reg;
// ...
reg;
'mem_read
let mem_addr = inst mem_ctrl();
reg;
let result = inst compute(stage(start).mem_out);
reg;
(stage(mem_read).mem_addr, result)
}
This is another pain point and more importantly a source of errors. Graphically, the structure is more like the following which is as hard to follow as the code that describes it:
Wires
The solution to the pipelining problem is a new type called a wire denoted
by &
. Wires, unlike values are not delayed in pipelines and can intuitively
be viewed as representing physical wires connecting modules rather than values
to be computed on.
To "read" the value of a wire, the *
operator is used and to turn a value
into a wire, &
is used.
With this change, the pipeline example can be rewritten as
pipeline(4) mod1(clk: clock, inputs: I, mem_out: &T) -> (&uint<16>, &O) {
reg;
// ...
reg;
let mem_addr = &inst mem_ctrl();
reg;
let result = inst compute(*mem_out);
reg;
(mem_addr, &result)
}
For now, it is not possible to return a compound type with both wires and tuples, which is why the output of the module was changed to
&O
.
Inverted wires
There is still at least one big problem with the current structure: returning addresses as outputs and taking values as inputs is problematic as there is no clear link between input and output, and the return value of a unit ends up being a mix of both control signals like addresses, and values computed by the unit.
The solution to this problem is inverted wires, denoted inv &
. These wires
flow the opposite way to the normal flow of values. A unit which accepts an
inverted wire as an input is able to set the value of that wire. A unit which
returns an inverted wire is able to read the value that was set by the "other
end"
Inverted wires are created using the port
expression which returns (T, inv T)
let (read_side, write_side) = port;
The set
statement is used to give set the value of an inverted wire. For example
set adder_out = a + b;
Rewriting the pipeline once again using inverted wires results in
pipeline(4) mod1(clk: clock, inputs: I, mem_addr: inv &uint<16>, mem_out: &T) -> O {
reg;
// ...
reg;
set mem_addr = inst mem_ctrl();
reg;
let result = inst compute(*mem_out);
reg;
result
}
The code can be made even neater by grouping all the memory signals together into a tuple:
pipeline(4) mod1(clk: clock, inputs: I, mem: (inv &uint<16>, &T)) -> O {
reg;
// ...
reg;
set mem#0 = inst mem_ctrl();
reg;
let result = inst compute(*mem#1);
reg;
result
}
Wires are passed around as if they were values, so our memory can now return all its signals, both inputs and outputs. As an example, to convert from a memory that does not use ports to one that does, we can write:
// A mockup memory which takes 2 addresses and returns two values.
pipeline(1) fake_memory(clk: clock, addrs: [uint<16>; 2]) -> [T;2]
pipeline(1) mem(clk: clock) -> ((inv &uint<16>, &T), (inv &uint<16>, &T)) {
let (addr1_read, addr1) = port;
let (addr2_read, addr2) = port;
let [out1, out2] = inst(1) fake_memory(clk, [*addr1_read, *addr2_read]);
reg;
((addr1, &out1), (addr2, &out2))
}
This finally allows us to write a neat top module for our running example:
entity top(clk: clock) {
let (m1, m2) = inst(1) mem(clk);
let out1 = inst(4) mod1(clk, I(), m1);
let out2 = inst(4) mod2(clk, I(), m2);
// ...
}
Ports
It is often desirable to define struct
s of related wires, for example the
wires we've used in the memory interface.
We can wrap them all in tuples like
we did above it is often desirable to give things names with struct
s.
To put wires in structs, we need to define them as struct port
which tells the compiler
that the struct is of port kind which is a broader concept than just struct port
.
In fact, wries, their inversions, compound types of wires like tuples and even clocks are all
ports as opposed to values as discussed previously.
Most of the time, what is and what is not a port is unimportant, but they have two important
properties:
- Ports are not pipelined.
- Generic arguments cannot be ports.
We can define a struct port
for our memory example as
struct port MemoryPort<T> {
addr: inv &uint<16>,
// A practical memory will usually also have a write value:
write: inv &Option<T>,
read: &T,
}
inv
for real
The inv
type is not only used to invert wires, it can be used to invert whole ports.
Effectively this flips the direction of all wires in the port.
This is very useful if there is no "owner" of a particular port as is the case with the
memory example.
We could tweak our memory example to use an inverted port by making the memory module also
accept the port as an (inverted) input.
pipeline(1) mem<T>(clk: clock, p1: inv MemoryPort<T>, p2: inv MemoryPort<T>) {
let [out1, out2] = inst(1) fake_memory(clk, [*p1.addr, *p2.addr]);
reg;
set p1.read = out1;
set p2.read = out2;
}
entity top(clk: clock) {
let (m1, m1_inv) = port;
let (m2, m2_inv) = port;
let _ = inst(1) mem::<uint<32>>(clk, m1_inv, m2_inv);
let out1 = inst(4) mod1(clk, I(), m1);
let out2 = inst(3) mod2(clk, I(), m2);
// ...
}
Inverted wires must be set
It is important that a circuit which uses inveted wires has a well defined value for all wires. In practice this means that a wire can only be assigned to exactly once, which is enforced by the compiler.
In practice this means that if you create an inv &
wire, or receive one as
an argument you must either set the value, or hand it off to a sub-unit you
instantiate.
For example, if we make an error while writing the top module in our running
example and accidentally pass m1
to both mod1
and mod2
entity top(clk: clock) {
let (m1, m2) = inst(1) mem(clk);
let out1 = inst(4) mod1(clk, I(), m1);
let out2 = inst(4) mod2(clk, I(), m1);
// ^^ Should be m2
}
We get a compilation error:
error: Use of consumed resource
┌─ src/wires.spade:234:39
│
3 │ let out1 = inst(4) mod1(clk, I(), m1);
│ -- Previously used here
4 │ let out2 = inst(3) mod2(clk, I(), m1);
│ ^^ Use of consumed resource
Similarly, if we don't give m2
a value by removing the last line, we get another error
error: swim_test_project::wires::m9::m2.addr is unused
┌─ src/wires.spade:231:10
│
231 │ let (m2, m2_inv) = port;
│ ^^ swim_test_project::wires::m9::m2.addr is unused
│
= note: swim_test_project::wires::m9::m2.addr is a inv & value which must be set
Conditional assignment
Since Spade is expression based, setting the value of an inv &
wire inside an if branch is not supported. For example, you may be tempted to write a multiplexer as
entity mux(sel: bool, on_false: bool, on_true: bool, out: inv &T) {
if sel {
set out = on_true
} else {
set out = on_false;
}
}
However, this will result in a multiply used resource error.
The correct way to write this is instead
entity mux<T>(sel: bool, on_false: T, on_true: T, out: inv &T) {
set out = if sel {on_true} else {on_false};
}
NOTE This mux is only written like this to showcase how mutable wires are used A better way to write a mux is
entity mux<T>(sel: bool, on_false: T, on_true: T) -> T { if sel {on_true} else {on_false} }
Interfacing with Verilog
It is often desirable to interface with existing Verilog, either instantiating
a Verilog module inside a Spade project, or including a Spade module as a
component of a larger Verilog project. Both are quite easy to do as long as you
have no generics on the Spade side, and no parameter
s on the Verilog side.
Generics and parameters may be supported in the future.
Instantiating a Verilog module
If you have a Verilog module that you want to instantiate from Spade, you need
to add a stub for it in your Spade project. This is done by defining a
function, entity or pipeline1 but using __builtin__
instead of the body
of the unit. For example,
struct Output {
valid: bool,
value: int<16>
}
entity external_module(clk: clock, x: int<8>) -> Output __builtin__
While this works, Spade will "mangle" names to avoid namespace collisions and collisions with keywords, so this would in practice look for a module like
module \your_project::your_file::external_module(
input clk_i,
input[7:0] x_i,
output[16:0] output__
);
Changing your module to follow this signature would work, but is not very convenient, the more convenient thing is to add #[no_mangle]
to both the module and its inputs in order to use the raw names instead of the mangled names:
#[no_mangle]
entity external_module(
#[no_mangle] clk: clock,
#[no_mangle] x: int<8>
) -> Output __builtin__
Now, the resulting Verilog signature is
module external_module(
input clk_i,
input[7:0] x_i,
output[16:0] output__
);
As you can see it still has a single output__
which is both inconvenient if
you can't change the signature, and annoying since you need to know how Spade
packs struct
s in order to generate the correct signals. Spade currently does
not even define the packing of the structs, so we need to do something about
this. The solution is to use mutable wires to generate Verilog output
s
Changing our module to
#[no_mangle]
entity external_module(
#[no_mangle] clk: clock,
#[no_mangle] x: int<8>
#[mo_mangle] output_valid: &mut bool,
#[no_mangle] output_value: int<16>,
) __builtin__
results in
module external_module(
input clk_i,
input[7:0] x_i,
output output_valid,
output[15:0] output_value
);
which is a normal looking Verilog signature.
One downside of this however, is that the interface to this module isn't very Spadey, so typically you will want to define a wrapper around the external module that provides a more Spade-like interface
use std::ports::new_mut_wire;
use std::ports::read_mut_wire;
// Put the wrapper inside a `mod` to allow defining a spade-native unit of the same name.
mod extern {
#[no_mangle]
entity external_module(
#[no_mangle] clk: clock,
#[no_mangle] x: int<8>
#[mo_mangle] output_valid: &mut bool,
#[no_mangle] output_value: int<16>,
) __builtin__
}
struct Output {
valid: bool,
value: int<16>
}
entity external_module(clk: clock, x: int<8>) -> Output {
let valid = inst new_mut_wire();
let value = inst new_mut_wire();
let _ = inst extern::external_module$(clk, x, output_valid: valid, output_value: value);
Output {
valid: inst read_mut_wire(valid),
value: inst read_mut_wire(value),
}
}
With this, we have the best of both worlds. A canonical spade-entity on the Spade side, and a canonical Verilog module on the other.
Finally, to use the Verilog module in a Spade project, the Verilog file containing the implementation must be specified in swim.toml
under extra_verilog
at the root, or extra_verilog
in the synthesis section. This takes a list of globs that get synthesized with the rest of the project.
See the documentation for units for more
details. Most of the time, you probably want to use entity
for external
Verilog.
Instantiating Spade in a Verilog project
Instantiating Spade in a larger Verilog project is similar to going the other
way around as just described. Mark the Spade unit you want to expose as
#[no_mangle]
on both the unit itself and its inputs. Prefer using &mut
instead of returning output values, as that results in a more Verilog-friendly
interface.
To get the Verilog code, run swim build
, which will generate build/spade.sv
which contains all the Verilog code for the Spade project, including your
exposed module.
Ws2812b Example
This chapter will guide you through how to build a spade library for the ws2812b RGB led and should serve as a practical example for "real world" Spade usage.
This assumes a bit of familiarity with basic spade concepts, and is written primarily with software people in mind, as such more weight will be put on the FPGA specifics than on spade syntax and concepts.
The chapter starts off with a discussion on how to create a spade project and how that project is laid out. After that, we will discuss the interfaces we want to and need to use, i.e. how to talk to the LEDs, and how to make the driver interface nice to use for other spade code. Finally, we'll go over the implementation of the actual driver.
Creating a Project.
It is strongly advised to use the Swim build tool to write Spade projects. It manages rebuilding the spade compiler, including the standard library and dependencies, testing and synthesis etc.
If you haven't installed swim already, do so by following the installation instructions.
After you have swim installed, we should create a new project. The easiest way
to do this is to run swim init --board <fpga name>
. To get a list of the boards
we currently have templates for, run
swim init --list-boards
which should give you something like
Cloning into '/tmp/swim-templates'...
remote: Enumerating objects: 135, done.
remote: Counting objects: 100% (50/50), done.
remote: Compressing objects: 100% (49/49), done.
remote: Total 135 (delta 11), reused 0 (delta 0), pack-reused 85
Receiving objects: 100% (135/135), 30,73 KiB | 30,73 MiB/s, done.
Resolving deltas: 100% (37/37), done.
[INFO] Available boards:
ecpix5
go-board
icesugar-nano
tinyfpga-bx
ulx3s_85k
If your FPGA board is not on the list, you can also set up your project manually, but that's out of scope for this guide. Have a look at the templates repository for inspiration.
For this project, the exact board isn't super important. I like my ecpix5
so I will use that.
Create the project using
swim init --board ecpix5 ws2812b
Note that it is likely that this project, being a library to drive specific hardware should be a library, not a standalone project, it is still useful to initialise it targeting a specific FPGA board in order to test in hardware it later.
Basic project layout
Inside the newly created directory we find the following files:
- ecpix5.lpf
- openocd-ecpix5.cfg
- src
- main.spade
- top.v
- swim.toml
FPGA specific files
The ecpix5.lpf
file is a pin mapping file which tells the synthesis tool what physical
pins correspond to the inputs and outputs from our top module.
If you are using a ice40 based FPGA, this file is instead a
pcf
file which has the same purpose but different syntax.
We'll get back to this file when it is time to test on hardware
The openocd-ecpix5.cfg
file is a file needed to program the FPGA. It is
specific to the ecpix5 programmer and you don't really have to care what it
does or why it is needed.
Since spade is a very work in progress language with breaking changes being very
common, it's easiest to have each project depend on a specific git version of
the compiler. This is handled by swim
, which will track a specific compiler
version for us.1 The first time we build the
project using swim
, it will download and compile the compiler.
Since the compilation process takes quite a while the first time you run it, now is a good time to call
swim build
The src
directory contains our spade source code. Each file is given a unique
namespace based on the name, so anything you define inside main.spade
will be under
the namespace ws2812b::main::<unit name>
.
Finally, there is the swim.toml
file which describes our project
name = "ws2812b"
[synthesis]
top = "top"
command = "synth_ecp5"
[board]
name = "ecpix-5"
pin_file = "ecpix5.lpf"
config_file = "openocd-ecpix5.cfg"
The name
is, as you might expect, the name of your project. If another
project depends on your project, this is the namespace at which your project
will reside.
The synthesis, pnr, upload, and packing fields tell swim what tools to call to
synthesise the project and upload it to the board. Most things can be ignored
here, but the top
field is worth knowing about, as that
is how you specify the top module (roughly equivalent to main
in most software languages).
You can read more about this in the swim README.
Basic swim usage
Swim has several subcommands which you can use. These commands call their prerequisites so you only have to call the one you actually want to run. I.e. you don't have to call swim build
before swim test
.
swim build
Compiles your spade code to verilog. The output ends up in build/spade.sv
swim synth
, swim pnr
Call the synthesis tool and place and route tool respectively.
swim upload
Build the project and upload it to the board
swim test
Run simulation to test your code. Note that by default, your project does not contain any test benches, so this will complain. We'll write some later in the guide.
Aliases
Most of these commands have aliases that you can use to be lazy and avoid typing.
b
:build
syn
:synthesise
u
:upload
t
,sim
:test
In the next section, we will start discussing how to talk to the LEDs.
LED protocol overview
Now that we are familiar with the project layout, we can start writing the driver for our LEDs. To do so, a good place to start is the datasheet. By reading it we can find out how the protocol works:
The LEDs are chained together, with us talking to the data in pin on the first LED in the chain, and it relaying messages to the rest of the chain automatically.
Data transmission consists of 3 symbols:
- 0 code
- 1 code
- RET code
Each LED has 24 bit color, 8 bits per channel and the transmission order is GRB 1 with the most significant bit first. The first 24 bits of color control the first LED, the next 24 the second and so on, until the RET code is sent at which point data transmission re-starts from the beginning
Because apparently standard color orders like RGB is too mainstream
As a more graphical example, a transmission of the color information for a sequence of three LEDs look like this:
| G7..0 | R7..0 | B7..0 | G7..0 | R7..0 | B7..0 | G7..0 | R7..0 | B7..0 | RET |...
|< LED 1 >|< LED 2 >|< LED 2 >| |< ...
Each color segment is a sequence of 1
or 0
codes depending on the desired
color for that led and color channel.
We should also have a look at the waveform of the 0, 1 and RET codes which look like this (see the datasheet for prettier figures):
0 code
------+
|
+-----------
| T0H | T0L |
I.e. a signal that is High for T0H
units of time, followed by Low for T0L
units of time
1 code
----------+
|
+-------
| T1H | T1L |
I.e. a signal that is High for T1H
units of time, followed by Low for T1L
units of time. It is very similar to the 0 code, but for the 1 code, the high
duration is longer than the low duration.
RET code
The RET code is just a Low signal which lasts for Tret
units of time.
NOTE: The datasheet usually refers to this signal as
reset
and the timing asTreset
. In order to make the rest of this text less confusing, we use the nameret
throughout, as we already have a FPGA reset signal in our design which has different purposes.
Durations
We'll leave the durations of the signals for now and get back to them when we start implementing things. If you're curious already, have a look at the datasheet.
With the discussion of the external protocol out of the way, the next section will discuss our internal protocol, i.e. what interface we expose to users of our driver.
Driver interface
Now that we know how we should talk to the LEDs, we should also consider how we want the interface to our library to work. Here we have a few options with various trade-offs.
Passing an array around
The most familiar coming from a software world might be for the library to take a copy or a reference to an array containing the values to set the LEDs to. However, this is quite a difficult interface to implement in an FPGA. If we were to copy the LED values, we would need 24 bits per LED to be connected between the driver and user. Those bits would need individual wires, so the number of wires would quickly grow very large.
This interface would look something like
entity ws2812<#N>(clk: clock, rst: bool, to_write: [Color; N]) -> bool {
// ...
}
"Function" to write single LED
Another option we might be tempted to try is to have an interface where you
"call" a function to set a specific LED. This is difficult to do in practice
however. In spade, one does not "call" a function, instead you instantiate a
block of hardware. One might work around that by passing something like an
Option<(Index, Color)>
to the driver, which updates the specified LED.
However, this is still not without flaws. First, we can't update a single LED, we need to send colors to all the LEDs before it too, so we'd need to store what the color of the other LEDs are. Second, it takes time to transmit the control signals, so one couldn't send new colors at any rate, the module must be ready to transmit before receiving the next command. This is technically solvable, but there are better options for this particular interface.
Letting the driver read from memory
Passing a reference is slightly more doable in an FPGA. Here, we might give the LED driver a read port to a memory from which it can read the color values at its own pace. This is certainly an option for us to use here, though spade currently doesn't have great support for passing read ports to memories around. Until that is mitigated, we'll look for other options
This might look something like this, but the MemoryPort is not currently supported in spade
entity ws2812<#N>(clk: clock, rst: bool, mem: MemoryPort<Color>, start_addr: int<20>) -> bool { // ... }
For those unfamiliar, the
#NumLjds
syntax means that the entity is generic over an integer calledNumLeds
.In current spade, one would have to write it as
struct Ws2812Out { signal: bool, read_addr: int<20>, } entity ws2812<#NumLeds>(clk: clock, rst: bool, memory_out: Color, start_addr: int<20>) -> Ws2812Out { // ... }
which decouples the
read_addr
frommemory_out
, and does not make clear the read delay between them.
Driver owned memory
Another, more spade- and FPGA friendly option is to have the driver itself own a memory where it stores the colors to write, and expose a write port to that memory for instanciators to write new values. This might look as follows:
entity ws2812<#NumLeds>(clk: clock, rst: bool, write_cmd: Option<int<20, Color>>) -> bool {
// ...
}
Just in time output
Finally, an interface which might be unfamiliar coming from the software world is to have the user generate the color on the fly, i.e. the user provides a translation from LED index to LED color. This is quite a nice setup as it doesn't intrinsically require any memory; if color selection is simple, it can be made on the fly. This interface is best demonstrated graphically
Control
signals
|
v
+---------------+
| State machine |
+---------------+
|
v
+-------------------+
| User provided |
| color translation |
+-------------------+
|
v
+------------------+
| Output generator |
+------------------+
|
V
LED
Signals
Here, as driver implementors we are responsible for providing the state
machine, whose output would be some signal which says "Right now, we should
emit byte B
of the color for LED N
". We'll represent it by an enum
The color translator translates that into a known color, and the output generator generates the signals that actually drive the led.
In some sense, this interface is the most general. Both the driver owned memory version, as well as the memory read port version can be implemented by plugging the read logic into the translation part. For that reason, we will implement this interface first.
With all that discussion on interfaces out of the way, it is finally time to start implementing things. The next section will introduce the finite state machine, a real work horse in any spade project.
State Machine
Now it is finally time to write some code. The swim template project contains
some example code in main.spade
, feel free to run swim upload
to test it if
you'd like. However, for this project, we won't need any of it, so once you are
done playing around with it, remove all code from main.spade
.
We'll start off by writing the state machine that generates the drive signals for the rest of the circuit. Before we do that though, it is a good idea to think about the input and output signals we want.
For simplicity, the state machine will not take any input control signals, it will start running as soon as the reset signal is turned off, and write data as fast as possible until the end of time.
Output type
The output is a bit more interesting. As stated before, we want the Finite State Machine (FSM) to emit information about what we are currently drawing.
For those unfamiliar, a Finite State Machine is less scary than the words make it seem. It is a way to do computation by describing a series of states and how and when to change between the states.
For example, if we want to build a circuit to toggle an LED whenever a short pulse arrives 1, our FSM would consist of two states: On and Off. If no pulse arrives, the current state remains. If the pulse arrives, we transition from the current state to the opposite state.
It is usually convenient to look at small FSMs graphically, the following figure shows the states and transitions of the pulse example
1perhaps a pulse from a button, though some extra circuitry would be needed to turn the "short" pulse of a human pressing the button into a pulse that is "short" for an electronics circuit :)
Before we discuss our state machine further, we should consider what output we want it to generate. Initially, we might do something like this:
enum OutputControl<#IndexWidth> {
/// Currently emitting the RET signal
Ret,
/// Currently emitting the specified bit of the color for LED `index`
Led{index: int<IndexWidth>, bit: int<6>}
}
We make this enum generic over the width of the led indices to not waste bits and allow an arbitrary number of LEDs
The
bit
field is anint<6>
because we want to be able to express0..24
. If spade had better unsigned support, we'd be able to useuint<5>
:)
This enum has a few issues though, so let's make some improvements.
First, the data coming out of the color translation block will end up being quite similar to this enum, so we can use generics to share some code. The user will translate the index into a color, so we will allow arbitrary payload instead of that index
enum OutputControl<T> {
/// Currently emitting the Ret signal
Ret,
/// Currently emitting the specified bit of the color for LED `index`
Led{payload: T, bit: int<6>}
}
This is enough information to write the color translator, but to generate the
output, it would be nice to have some more information. Specifically, because
this is a time based interface, we could more easily generate the output
waveforms if we knew how long we've been emitting the current bit. Let's add that
to the enum
enum OutputControl<T> {
/// Currently emitting the RET signal
Ret,
/// Currently emitting the specified bit of the color for LED `index`
Led{payload: T, bit: int<6>, duration: int<12>}
}
If you are curious, the width of the duration field is 12 to support a counter counting from 0 to 1250. This was chosen because the total duration of a data bit is 1.25 microseconds, which takes 1250 clock cycles at 1 GHz, and we are unlikely to run our FPGA above that frequency. Better generics over clock frequencies is something that might happen down the line
State machine entity
We can finally stop talking about interfaces and write some actual code. Let's
start off writing an entity where we can put the logic to generate the
OutputControl
enum. This entity will be generic over the index width as
discussed previously, and will take a number of LEDs to control as a normal
parameter.
In practice, it would be a lot nicer to set the number of LEDs at compile time too, but spade generics are not quite there yet.
entity state_gen<#IndexWidth>(
clk: clock,
rst: bool,
num_leds: int<IndexWidth>
) -> OutputControl<int<IndexWidth>> {
// TODO
}
Let's work on that // TODO
next. Recall that when working in spade, we always
describe the behaviour of our circuit between one clock cycle and the next.
However, we want to implement an interface that is time dependent, so we
need to do some thinking. In a high level language, we'd want to do something like
#![allow(unused_variables)] fn main() { while true { for t in 0..ret_duration { output = Ret; wait_clock_cycle; } for i in 0..num_leds { for bit in 0..24 { for t in 0..bit_duration { output = Led(i, bit, t) wait_clock_cycle; } } } } }
Unfortunately, loops are out of reach, so we will need to encode this logic in some other way. Most of the time, this is done by writing a state machine. The exact method is somewhat situation dependent and takes some practice. To be successful at this task, we have two basic constrains: we need enough information to know what state to jump to at all times, and we need enough information to know what output to generate. In spade, we'll almost always represent the states with an enum
enum State {
// TODO
}
The RET signal
Let's start off with the first for loop to generate the RET signal. We need to keep track of how long we've been in RET, so we know when to jump over to the output generation loop. A good starting point is therefore a state, with a duration
enum State {
Ret{duration: int<17>},
// ...
}
The state_gen
will need an instance of the state enum, which is updated at
every clock cycle. A perfect use for the reg
statement and match
expression
entity state_gen<#IndexWidth>(
clk: clock,
rst: bool,
num_leds: int<IndexWidth>
) -> OutputControl<int<IndexWidth>> {
reg(clk) state reset(rst: State::Ret(0)) = match state {
// Compute next state here
};
}
What happened here? We have a register called state
which we update by
checking the state in the current clock cycle, to build a circuit that gives
the state in the next. Since state depends on itself, it needs to be reset back
to an initial value when the FPGA, which is why write reset(rst: State::Ret(0))
. This will make the circuit send the RET signal to the LEDs
when starting up, then operate as normal. We could have started emitting LED
values too, but this makes the description easier and gives the LEDs a few
microseconds to get up and running during power up.
How do we compute the next state in the Ret state? That depends on how long we have been in Ret already. If that time is longer than the minimum time in Ret, we can start emitting LED data, otherwise we'll stay in the Ret state. We'll write this logic as
entity state_gen<#IndexWidth>(
clk: clock,
rst: bool,
num_leds: int<IndexWidth>
) -> OutputControl<int<IndexWidth>> {
reg(clk) state reset(rst: State::Ret(0)) = match state {
State::Ret(duration) => {
if duration >= Tret {
// First LED state
}
else {
State::Ret(trunc(duration + 1))
}
},
// ...
};
}
You may be curious why we need
trunc
there. That's because spade does not implicitly cast away overflow.duration+1
is 1 bit larger thanduration
if it overflows. To make it fit back into our state, we truncate the result of the addition, since we know that we have chosen duration to be large enough for it not to be an issue.
Timing
The keen eyed might have noticed Tret
there. What is its value? It represents
the minimum time that we should emit the ret
signal, but duration
is in
clock cycles. Eventually, Spade might support being generic over clock cycles
and allow you to reason about time natively. For now, we need to compute how
many clock cycles Tret
is manually. This of course depends on the clock
frequency, a value which varies between FPGAs. At the time of writing this
guide, out of the boards that swim currently supports natively, there are 4
different clock frequencies, so we probably want to be generic over it in order
to write a library.
Since we'll need a few more time dependent parameters down the line, we'll
define a Timing
struct which we pass to the modules, which contains the
relevant timings. We might write something like this
struct Timing {
Tret: int<17>,
T0h: int<12>,
T0l: int<12>,
T1h: int<12>,
T1l: int<12>,
bit_time: int<12>,
}
However, now the user needs to know what those implementation dependent times are, which probably requires going to the data sheet. To make their life simpler, let's change it to
struct Timing {
// 280 microseconds
us280: int<17>,
// 0.4 microseconds
us0_4: int<12>,
// 0.8 microseconds
us0_8: int<12>,
// 0.45 microseconds
us0_45: int<12>,
// 0.85 microseconds
us0_85: int<12>,
// 1.25 microseconds
us1_25: int<12>,
}
and update our entity
entity state_gen<#IndexWidth>(
clk: clock,
rst: bool,
num_leds: int<IndexWidth>
t: Timing,
) -> OutputControl<int<IndexWidth>> {
let t_ret = t.us280;
reg(clk) state reset(rst: State::Ret(0)) = match state {
State::Ret(duration) => {
if duration >= t_ret {
// First LED state
}
else {
State::Ret(trunc(duration + 1))
}
},
// TODO: next states
};
// TODO: Output
}
NOTE: If you've been following along with a datasheet, for example the first result on duck duck go, or the first result from google you may be confused by why we use 280 microseconds and not 50. It turns out that the manufacturers of the LEDs updated the protocol at some point without updating model numbers or datasheets. this took quite a few hours of debugging when the code worked on old LEDs, but not a new strip.
Bit signals
To generate the bit signals, i.e. the nested for loop in the example above, we need to keep track of 3 things: which LED we're working on, which bit on that LED we're working on, and how long we've been in that state. Essentially 1 variable per loop level. We'll extend the state enum to fit:
enum State<#IndexWidth> {
Ret{duration: int<17>},
Led{idx: int<IndexWidth>, bit: int<6>, duration: int<12>}
}
How do we want the logic to work? At the "innermost level", if we aren't done
emitting the current bit
, we increase the duration by 1. If the duration
reaches the bit time, we move on to the next bit, and if we are done with all
bits, we move on to the next LED. Finally, if we reached the last LED, we'll go
back to the RET state.
In spade, we'll write that as
entity state_gen<#IndexWidth>(
clk: clock,
rst: bool,
num_leds: int<IndexWidth>,
t: Timing,
) -> OutputControl<int<IndexWidth>> {
let t_ret = t.us280;
let t_bit = t.us1_25;
reg(clk) state reset(rst: State::Ret(0)) = match state {
State::Ret(duration) => {
if duration >= t_ret {
State::Led(0, 0, 0)
}
else {
State::Ret(trunc(duration + 1))
}
},
State::Led$(idx, bit, duration) => {
if duration == t_bit {
if bit == 23 {
if idx == trunc(num_leds-1) {
State::Ret(0)
}
else {
State::Led$(idx: trunc(idx+1), bit: 0, duration: 0)
}
}
else {
State::Led$(idx, bit: trunc(bit+1), duration: 0)
}
}
else {
State::Led$(idx, bit, duration: trunc(duration + 1))
}
},
};
// TODO: Output
}
Spade supports passing arguments to units both by position, i.e. argument 1 is passed to parameter 1, 2 to 2 and so on, and also by name. To specify parameters by name, the calling parenthesis are preceded by
$
, i.e.State::Led$(idx, bit, duration: trunc(duration + 1))
says to pass the variable calledidx
to the parameteridx
,bit
tobit
, andtrunc(duration + 1)
toduration
. This works the same way as the rust struct initialisation syntax
Finally, generating the output signal can be done by another match statement. Since State
and OutputControl
are very similar in this case, the resulting match statement is not very complex:
match state {
State::Ret(_) => OutputControl::Ret(),
State::Led$(idx, bit, duration) => OutputControl::Led$(payload: idx, bit, duration)
}
Putting it all together, we end up with the following code:
enum OutputControl<T> {
/// Currently emitting the RET signal
Ret,
/// Currently emitting the specified bit of the color for LED `index`
Led{payload: T, bit: int<6>, duration: int<12>}
}
struct Timing {
// 50 microseconds
us280: int<17>,
// 0.4 microseconds
us0_4: int<12>,
// 0.8 microseconds
us0_8: int<12>,
// 0.45 microseconds
us0_45: int<12>,
// 0.85 microseconds
us0_85: int<12>,
// 1.25 microseconds
us1_25: int<12>,
}
enum State<#IndexWidth> {
Ret{duration: int<17>},
Led{idx: int<IndexWidth>, bit: int<6>, duration: int<12>}
}
entity state_gen<#IndexWidth>(
clk: clock,
rst: bool,
num_leds: int<IndexWidth>,
t: Timing,
) -> OutputControl<int<IndexWidth>> {
let t_ret = t.us280;
let t_bit = t.us1_25;
reg(clk) state reset(rst: State::Ret(0)) = match state {
State::Ret(duration) => {
if duration >= t_ret {
State::Led(0, 0, 0)
}
else {
State::Ret(trunc(duration + 1))
}
},
State::Led$(idx, bit, duration) => {
if duration == t_bit {
if bit == 23 {
if idx == trunc(num_leds-1) {
State::Ret(0)
}
else {
State::Led$(idx: trunc(idx+1), bit: 0, duration: 0)
}
}
else {
State::Led$(idx, bit: trunc(bit+1), duration: 0)
}
}
else {
State::Led$(idx, bit, duration: trunc(duration + 1))
}
}
};
match state {
State::Ret(_) => OutputControl::Ret(),
State::Led$(idx, bit, duration) => OutputControl::Led$(payload: idx, bit, duration)
}
}
While we hope that the above code will work on the first try, that is rarely the case in practice. The next section will discuss how we can test our design
Testing the state machine
Just like software, testing our code is vital. Unlike software however, we don't have (easy) access to fancy tools like debuggers, printfs or error messages when we run on hardware. Therefore, we usually simulate FPGA designs to make sure they work in simulation in order to avoid painful debugging in hardware.
Currently, writing simulation code in spade is not possible, as the things you want to do in a simulator are quite different to describing hardware. Instead, tests are written in python using the cocotb testing framework.
If you haven't already, refer to the installation instructions to see how to install cocotb.
Setting up tests
To do our testing, we need to do a tiny bit more setup in swim.
To do testing, we need to tell swim where we put our test benches. To do so,
create a directory called test
Then edit swim.toml
adding a simulation
section like so:
[simulation]
testbench_dir = "test"
Inside the test
folder we put our test benches in python files. Let's create
our first one by creating test/state_gen.py
. Each spade test file must start
with a comment telling swim which unit is to be tested, the "top module", like
so. The path must be a fully namespaced name, and since our module resides in
main.spade
, it will be main::state_gen
# top=main::state_gen
We'll also add an empty test to that file like this:
# top=main::state_gen
import cocotb
from spade import SpadeExt
@cocotb.test()
async def normal_operation(dut):
s = SpadeExt(dut)
Each test is annotated by @cocotb.test()
and is an async python function
which takes a single parameter dut
, the Design Under Test.
Running swim test
(or swim t
for the lazy :)) presents us with the following error1
Error:
0: In {tb}
1: main::state_gen is generic which is currently unsupported in test benches
Which is a limitation of the spade python interface. To test our module, we'll need to create a dummy entity without any generic parameters, for now we'll use one with 10 LEDs.
entity state_gen_10(clk: clock, rst: bool, t: Timing) -> OutputControl<int<5>> {
inst state_gen(clk, rst, 10, t)
}
After updating the top to # top=main::state_gen_10
we can swim test
again and we
should see a nice PASS
(along with some other output which we'll ignore for now)
[INFO] Building spade compiler
Finished release [optimized] target(s) in 0.04s
[INFO] Built spade compiler
[INFO] build/spade.sv is up to date
[INFO] Building spade-python
Finished release [optimized] target(s) in 0.04s
[INFO] Built spade-python
-.--ns INFO cocotb.gpi ..mbed/gpi_embed.cpp:109 in set_program_name_in_venv Using Python virtual environment interpreter at /home/frans/Documents/spade/ws2812-spade/build/.env/bin/python
-.--ns INFO cocotb.gpi ../gpi/GpiCommon.cpp:99 in gpi_print_registered_impl VPI registered
/home/frans/Documents/spade/ws2812-spade/build/spade.sv:177: Warning: Calling system function $value$plusargs() as a task.
/home/frans/Documents/spade/ws2812-spade/build/spade.sv:177: The functions return value will be ignored.
/home/frans/Documents/spade/ws2812-spade/build/spade.sv:111: Warning: Calling system function $value$plusargs() as a task.
/home/frans/Documents/spade/ws2812-spade/build/spade.sv:111: The functions return value will be ignored.
0.00ns INFO Running on Icarus Verilog version 11.0 (stable)
0.00ns INFO Running tests with cocotb v1.6.2 from /home/frans/Documents/spade/ws2812-spade/build/.env/lib/python3.10/site-packages/cocotb
0.00ns INFO Seeding Python random module with 1657993983
0.00ns WARNING Pytest not found, assertion rewriting will not occur
0.00ns INFO Found test state_gen.normal_operation
0.00ns INFO running normal_operation (1/1)
state_gen.py.vcd
VCD info: dumpfile state_gen.py.vcd opened for output.
0.00ns INFO normal_operation passed
0.00ns INFO **************************************************************************************
** TEST STATUS SIM TIME (ns) REAL TIME (s) RATIO (ns/s) **
**************************************************************************************
** state_gen.normal_operation PASS 0.00 0.00 4.45 **
**************************************************************************************
** TESTS=1 PASS=1 FAIL=0 SKIP=0 0.00 0.00 0.26 **
**************************************************************************************
[INFO] Building vcd translator
Finished release [optimized] target(s) in 0.04s
[INFO] Built vcd translator
[INFO] Translating types in "./build/state_gen/state_gen.py.vcd"
[INFO] Translated VCD: ./build/state_gen/state_gen.py.translated.vcd
test/state_gen.py: PASS
The first time you run swim test
, it will set up a
python environment with the required libraries which requires compiling a
separate part of the spade compiler. Don't be alarmed at the time swim test
takes, or the amount of output the first time you run it in a new
project.
Writing Some Tests
Now we are ready to actually test our module. All spade test functions start
with s = SpadeExt(dut)
which creates a nice spade interface around the cocotb
functions.
Since our entity is clocked, we need to generate a clock for it. This is done by starting a cocotb clock task like this. The exact clock frequency is not really important here, it only decides the mapping between simulation time and real time.
# import the clock generator
from cocotb.clock import Clock
# ...
clk = dut.clk_i
await cocotb.start(Clock(clk, 1, units='ns').start())
The design also takes a reset signal which we need to set to get our initial state defined. If we forget to do this, most of the signals will be undefined.
We can access the input ports of our design using s.i.<input_name>
and give
them values by assigning strings containing spade expressions to them. The following code sets the reset signal to true:
s.i.rst = "true"
The
s.i
interface does not work well with cocotb built in functions likeClock
You can access the raw verilog input ports on the dut viadut.<name>_i
as above which is nice if you want to pass them to special cocotb functions likeClock
. However, most of the time you should use the spade interface since that doesn't require you to know the spade internal representation of types.
For our design to start running, we need to take it out of reset again, you might
think that we can just add another line s.i.rst = false
. However, this would
give the design no time to see the change in reset. Instead, we need to let the
simulation step forward a bit. The easiest way to do that is to let it step forward
one clock cycle, which we do by waiting until the next time the clock goes from 1 to 0
# import trigger
from cocotb.triggers import FallingEdge
# ...
await FallingEdge(clk)
s.i.rst = "false"
This will create a waveform that looks like this
---+ +---+
clk: | | |
+---+ +---...
---+
rst: |
+-----------...
In order to let the circuit catch up to the fact that the reset has been turned off, we'll advance the simulation another tiny time step (1 picosecond):
# import timer
from cocotb.triggers import Timer
# ...
await Timer(1, units='ps')
You can find more things to wait for in the cocotb documentation for triggers.
Now we can do our first test, ensuring that the initial output of the circuit is RET. We can access the output of our dut with s.o
, and run assertions on it like this:
s.o.assert_eq("OutputControl::Ret()")
If you return a struct from a unit, you can access them as normal python fields on the
s.o
field. For examples.o.x.y.assert_eq(...)
Our test file now looks like this:
# top=main::state_gen_10
from spade import SpadeExt
import cocotb
from cocotb.clock import Clock
from cocotb.triggers import FallingEdge, Timer
@cocotb.test()
async def normal_operation(dut):
s = SpadeExt(dut)
clk = dut.clk_i
await cocotb.start(Clock(clk, 1, units='ns').start())
s.i.rst = "true"
await FallingEdge(clk)
s.i.rst = "false"
await Timer(1, units='ps')
s.o.assert_eq("OutputControl::Ret()")
and calling swim test
should tell us that all our assertions passed.
A failing test
Next, we may want to ensure that we output Ret
in the next clock cycle as well. So, we'll advance the clock and assert that.
Changes to our state happens on the rising edge of clocks, so I prefer to do my assertions on the falling edge. That way I don't have to worry about if values have or have not changed right at the RisingEdge.
await FallingEdge(clk)
s.o.assert_eq("OutputControl::Ret()")
Calling swim test
results in the following output:
...
VCD info: dumpfile state_gen.py.vcd opened for output.
1.50ns INFO normal_operation failed
Traceback (most recent call last):
File "/home/frans/Documents/spade/ws2812-spade/build/state_gen/state_gen.py", line 22, in normal_operation
s.o.assert_eq("OutputControl::Ret()")
File "/home/frans/Documents/spade/ws2812-spade/spade/spade-python/spade/__init__.py", line 75, in assert_eq
assert False, message
AssertionError:
Assertion failed
expected: OutputControl::Ret()
got: UNDEF
verilog (0XXXXXXXXXXXXXXXXXXXXXXX != xxxxxxxxxxxxxxxxxxxxxxxx)
...
[INFO] Building vcd translator
Finished release [optimized] target(s) in 0.04s
[INFO] Built vcd translator
[INFO] Translating types in "build/state_gen/state_gen.vcd"
[INFO] Translated VCD: build/state_gen/state_gen.translated.vcd
test/state_gen.py: FAIL [build/state_gen/state_gen.translated.vcd]
Failed test cases:
normal_operation
Oh no, something went wrong, why? To debug our tests, the best method by far is to look at the wave dump. It contains the value of all the signals in the design over time and can give plenty of debug information. To see it, we need to install a vcd viewer, and the defacto standard is gtkwave.
Swim translates Verilog values in the wave dump back into spade files and stores the result in a new vcd file which is printed along with the failing tests:
test/state_gen.py: FAIL [build/state_gen/state_gen.translated.vcd]
Let's open build/state_gen/state_gen.translated.vcd
in gtkwave:
gtkwave build/state_gen/state_gen.translated.vcd
This should open a window that looks something like this:
The black portion shows the value of the signals we select over time. The left
pane contains a list of the units in our design, in this case
e_proj_main_state_gen_10
. This is the verilog name of our module
main::state_gen_10
. If you select it, the signal list below will be populated
by all the values in that module. In this case, it is just a wrapper around the
actual design state_gen
, so expand the module and select the submodule. This should give you a lot more signals
Now we have lots of signals to play with! Broadly, we can group them into
several categories. Some signals start with p_
. These contain the spade value
of the corresponding signal, for example p_clk_n...
is a spade value, and
clk_n...
is the raw verilog bits.
Names on the form _e_<numbers>
and p_e_<numbers>
are subexpressions that
are not named in the spade program. Unless you're debugging the compiler, you
can ignore those.
Names on the form <name>_n<numbers>
and p_<name>_n<numbers>
are values which
are named in your spade code. These are the values you will actually want to look at
Finally, there are some signals called <name>_i
. These are input input
values. The spade translation does not translate those, so it is better to look
at the corresponding <name>_n<numbers>
signals.
To add a signal to the waveform window, double click it. To debug this value,
we'll want to look at a few signals clk
, rst
, state
and t
,
so go ahead and add those to the wave view
Here we get quite a bit of information. We see that our state is defined until
the first clock cycle after reset. We also see that all fields of t
, our
timing struct is "HIGHIMP". The name is a bit confusing, but this is caused by
us forgetting to set that parameter.
Going back to the spade code, to compute the next state in Ret
, we check if
the duration
we've been in Ret
so far is greater than t_ret
. However, we
haven't set t
, so we are essentially comparing duration
to t_ret
, which
is undefined, resulting in another undefined value.
Spade tries to do its best to avoid undefined values, it is certainly harder to write undefined values in spade than in verilog. However, when forgetting to specify inputs, and when working with memories, they can pop up.
Let's specify the timings to fix this issue. Again, exact timing here isn't important, we'll set some values that make testing possible:
s.i.t = """Timing$(
us280: 28,
us0_4: 4,
us0_8: 8,
us0_45: 4,
us0_85: 8,
us1_25: 12,
)"""
await FallingEdge(clk)
s.o.assert_eq("OutputControl::Ret()")
With that change, our assertions pass.
More tests
Now we can get to testing the rest of the design. Since our state space is quite small in this case, we can ensure that all state transitions happen as they should. Since this is python, we can write things like loop, helper functions etc.
First, let's ensure that we stay in Ret
for the specified amount of time,
i.e. us280
clock cycles:
s.i.t = """Timing$(
us280: 28,
us0_4: 4,
us0_8: 8,
us0_45: 4,
us0_85: 8,
us1_25: 12,
)"""
for i in range(0, 28):
await FallingEdge(clk)
s.o.assert_eq("OutputControl::Ret()")
After that, we should be emitting the value of the first LED. Here we can write a function to check a whole LED output, since we'll do that quite a few times
async def check_led(clk, s, index):
# Each bit of the LED should be emitted
for b in range(0, 24):
# And each duration from 0 to us1_25 in each bit
# For simulation performance, we'll just check the first and last bit explicitly
await FallingEdge(clk)
s.o.assert_eq(f"OutputControl::Led$(payload: {index}, bit: {b}, duration: 0)")
for d in range(0, 5):
await FallingEdge(clk)
s.o.assert_eq(f"OutputControl::Led$(payload: {index}, bit: {b}, duration: 5)")
We can now test all our LEDs by calling it in a loop, and finally ensure that we go back to the ret state at the right time. The final test bench looks like this:
# top=main::state_gen_10
from spade import SpadeExt
import cocotb
from cocotb.clock import Clock
from cocotb.triggers import FallingEdge, Timer
async def check_led(clk, s, index):
# Each bit of the LED should be emitted
for b in range(0, 24):
# And each duration from 0 to us1_25 in each bit
# For simulation performance, we'll just check the first and last bit explicitly
await FallingEdge(clk)
s.o.assert_eq(f"OutputControl::Led$(payload: {index}, bit: {b}, duration: 0)")
for d in range(0, 5):
await FallingEdge(clk)
s.o.assert_eq(f"OutputControl::Led$(payload: {index}, bit: {b}, duration: 5)")
@cocotb.test()
async def normal_operation(dut):
s = SpadeExt(dut)
clk = dut.clk_i
await cocotb.start(Clock(clk, 1, units='ns').start())
s.i.rst = "true"
await FallingEdge(clk)
s.i.rst = "false"
await Timer(1, units='ps')
s.o.assert_eq("OutputControl::Ret()")
s.i.t = """Timing$(
us280: 28,
us0_4: 4,
us0_8: 8,
us0_45: 4,
us0_85: 8,
us1_25: 12,
)"""
for i in range(0, 28):
await FallingEdge(clk)
s.o.assert_eq("OutputControl::Ret()")
# Check all 10 leds
for i in range(0, 10):
await check_led(clk, s, i)
# Ensure we get back to the ret state
await FallingEdge(clk)
s.o.assert_eq("OutputControl::Ret()")
Running it gives us another assertion error:
VCD info: dumpfile state_gen.py.vcd opened for output.
28.50ns INFO normal_operation failed
Traceback (most recent call last):
File "/home/frans/Documents/spade/ws2812-spade/build/state_gen/state_gen.py", line 44, in normal_operation
await check_led(clk, s, i)
File "/home/frans/Documents/spade/ws2812-spade/build/state_gen/state_gen.py", line 13, in check_led
s.o.assert_eq(f"OutputControl::Led$(payload: {index}, bit: {b}, duration: {d})")
File "/home/frans/Documents/spade/ws2812-spade/spade/spade-python/spade/__init__.py", line 75, in assert_eq
assert False, message
AssertionError:
Assertion failed
expected: OutputControl::Led$(payload: 0, bit: 1, duration: 0)
got: proj::main::OutputControl::Led(0,0,12)
verilog (100000000001000000000000 != 100000000000000000001100)
28.50ns INFO **************************************************************************************
** TEST STATUS SIM TIME (ns) REAL TIME (s) RATIO (ns/s) **
**************************************************************************************
** state_gen.normal_operation FAIL 28.50 0.06 461.47 **
**************************************************************************************
** TESTS=1 PASS=0 FAIL=1 SKIP=0 28.50 0.07 432.88 **
**************************************************************************************
[INFO] Building vcd translator
Finished release [optimized] target(s) in 0.04s
[INFO] Built vcd translator
[INFO] Translating types in "./build/state_gen/state_gen.py.vcd"
[INFO] Translated VCD: ./build/state_gen/state_gen.py.translated.vcd
test/state_gen.py: FAIL [./build/state_gen/state_gen.py.translated.vcd]
Failed test cases:
normal_operation
Try to see if you can figure out what happened. Looking at the waves can be helpful, but in this case it might be enough to look at what states it transitioned to.
If you can't figure it out, jump to the next section for the answer
Output Generation
First of all, the cause of the bug mentioned in the end of the last chapter was an incorrect equality check of the duration when transitioning between states. It should be
if duration == trunc(t_bit-1) {
instead of
if duration == t_bit {
Now that our state machine works, we have done most of the heavy lifting. We still need to translate our control signal into an actual LED output, which is what we'll work on next.
Since this will require no internal state, and is fairly simple logic we'll represent
it as a function. We'll also represent color as a struct with r
, g
and b
values. The output is a single bool
, the actual control signal to be passed to
the LEDs.
struct Color {
r: int<8>,
g: int<8>,
b: int<8>
}
fn output_gen(control: OutputControl<Color>, t: Timing) -> bool {
// TODO
}
The ret output is easy, it is simply a low signal. The 0 and 1 signals are a
bit more complex. The output should be 1 initially, and then transition to 0 at
t0l
or t1l
depending on if the current bit is a 0 or a 1.
The OutputControl::Led
has information about which of the 24 bits should be
emitted. To translate that into a bit value, we'll concatenate the color
channels, and "index" the correct bit. (Currently, Spade does not support bit indexing,
so we'll extract the bits using shifts and masks instead)
This logic can be written as follows:
struct Color {
r: int<8>,
g: int<8>,
b: int<8>
}
fn output_gen(control: OutputControl<Color>, t: Timing) -> bool {
let t0h = t.us0_4;
let t1h = t.us0_8;
match control {
OutputControl::Ret => false,
OutputControl::Led$(payload: color, bit, duration) => {
let color_concat = (color.g `concat` color.r `concat` color.b);
let val = ((color_concat >> sext((23-bit))) & 1) == 1;
let step_time = if val {t1h} else {t0h};
if duration > step_time {
false
}
else {
true
}
}
}
}
Testing
Again, it is good practice to test the module. Testing it is very similar to the state machine, except here we don't have a clock. Instead, we'll set a signal value, advance the simulation by a tiny time step, and assert the output. Here is an example of the test bench. Feel free to extend it with more tests that you think are reasonable. Here it might also be helpful to define some helper functions which check that a specific input gives a specific waveform, for example.
# top=main::output_gen
from spade import *
@cocotb.test()
async def ret_works(dut):
s = SpadeExt(dut)
s.i.t = """Timing$(
us280: 2800,
us0_4: 40,
us0_8: 80,
us0_45: 45,
us0_85: 85,
us1_25: 125,
)"""
s.i.control = "OutputControl::Ret()"
await Timer(1, units='ps')
s.o.assert_eq("false")
@cocotb.test()
async def one_at_bit_0(dut):
s = SpadeExt(dut)
s.i.t = """Timing$(
us280: 2800,
us0_4: 40,
us0_8: 80,
us0_45: 45,
us0_85: 85,
us1_25: 125,
)"""
# Sending 1 @ bit 0, time 0
s.i.control = "OutputControl::Led(Color$(g: 0b1000_0000, r: 0, b: 0), 0, 0)"
await Timer(1, units='ps')
s.o.assert_eq("true")
# Sending 1 @ bit 0, time 40
s.i.control = "OutputControl::Led(Color$(g: 0b1000_0000, r: 0, b: 0), 0, 40)"
await Timer(1, units='ps')
s.o.assert_eq("true")
# Sending 1 @ bit 0, time 80
s.i.control = "OutputControl::Led(Color$(g: 0b1000_0000, r: 0, b: 0), 0, 80)"
await Timer(1, units='ps')
s.o.assert_eq("true")
# Sending 1 @ bit 0, time 81
s.i.control = "OutputControl::Led(Color$(g: 0b1000_0000, r: 0, b: 0), 0, 81)"
await Timer(1, units='ps')
s.o.assert_eq("false")
If you want to see a more fleshed out test test, have a look at https://gitlab.com/TheZoq2/ws2812-spade/-/blob/e3ede5d50abf176f0ea5f0dcf6bfdcfb8b2228d8/test/output_gen.py.
With our tests now passing, we can finally run the code in hardware, which we will discuss in the next and final section of this chapter.
Testing in hardware
We are finally at a point where we think the code is correct, and all the pieces are implemented. It's time to test it on hardware.
To do so, we need to set up a demo entity which instantiates the state and
output generators, and selects a nice color for them. We'll do this in a
separate file, called src/hw_test.spade
use lib::main::state_gen;
use lib::main::output_gen;
use lib::main::OutputControl;
use lib::main::Timing;
use lib::main::Color;
#[no_mangle]
entity demo(#[no_mangle] clk: clock, #[no_mangle] pmod0: &mut int<6>) {
reg(clk) rst initial(true) = false;
// Our target FPGA, the ecpix5 has a 100 MHz clock.
let t = Timing$(
us280: 28000,
us0_4: 40,
us0_8: 80,
us0_45: 45,
us0_85: 85,
us1_25: 125,
);
let ctrl: OutputControl<int<4>> = inst state_gen(clk, rst, 4, t);
reg(clk) timer: int<32> reset(rst: 0) = if timer > 100_000_000 {
0
}
else {
trunc(timer+1)
};
reg(clk) offset: int<2> reset(rst: 0) = if timer == 0 {
trunc(offset+1)
}
else {
offset
};
let brightness = 64;
let colors = [
Color(brightness, 0, 0),
Color(0, brightness, 0),
Color(0, 0, brightness),
Color(0, brightness, brightness),
];
let with_color = match ctrl {
OutputControl::Ret => OutputControl::Ret(),
OutputControl::Led$(payload: led_num, bit, duration) => {
let led_num = trunc(led_num + sext(offset));
OutputControl::Led$(payload: colors[led_num], bit, duration)
},
};
let pin = output_gen(with_color, t);
set pmod0 = if pin {0b1} else {0};
}
There is not much going on here. Since we're in a different file, we need to
include the stuff defined in the other file. lib
refers to the library we are
currently building, and since our code is in main.spade
, the items are put in
the main
namespace
Since our top module, demo
, is going to connect to the external world,
we'll mark both it and its parameters as #[no_mangle]
. This tells the spade
compiler to name things exactly what they are called in
the Spade code. The downside of this is that we might collide with Verilog
keywords, and the module demo
will not have a namespaced name.
For the output, we also use a &mut int<6>
. &mut
is a mutable wire, i.e. a
wire where we can set a value using set
. It is an int<6>
because the IO
port pmod0
on the ecpix5
board we've been using as an example is 6 bits
wide. The physical pins pmod0
is mapped to is specified in the lpf
file.
The line reg(clk) rst initial(true) = false;
generates a reset signal that is
active the first clock cycle after the FPGA has started.
To generate the output, we create our timing struct, this time with correct timings for the 100 MHz FPGA we're targeting. We use an array to look up color values for each of the LEDs we're going to test, and output those signals.
Then we instantiate everything, and finally set the output pin to the resulting value. Here the LED strip is connected to the first pin of pmod0
We also need to tell the synthesis tool what entity should be our top module; to do so, change the synthesis.top
value in swim.toml
to demo
[synthesis]
top = "demo"
With all that done, we can run swim upload
, and look at our new RGB LEDs.
The pattern is static and boring at the moment, so this is a great opportunity to play around a bit and make the LEDs do something more interesting!
All the code for this project can be found at https://gitlab.com/TheZoq2/ws2812-spade
Language Reference
This chapter is a reference for individual features in the language.
Items
Anything that appears at the top level of a Spade file is an item. This includes units, types and (sub)modules etc..
As a user, you will rarely encounter the term Item
, though it might appear in
parser errors if you write something unexpected at the top level of a file.
Units
Units are the basic building blocks of a Spade project, they correspond to modules in Verilog, and entities in VHDL. Units come in three flavors: functions, pipelines and entities.
Functions
Functions are combinational circuits (or pure, in software terms), that is they have no internal state, and can not read or set mutable wires.
Pipelines
Pipelines have a specified delay between input and output, and have explicit staging statements.
Entities
Finally, entities are the most general units, they can have state, and the input-output delay is arbitrary. They therefore have roughly the same programming model as VHDL and Verilog.
Type Declarations
Struct
struct
declaration include a name, optional generic arguments and a list of
fields. The fields in turn have a name and a type which may use the generic
arguments.
struct Empty {}
struct NonGeneric {
field_a: int<8>,
field_b: bool
}
struct Generic<T> {
field_a: T,
field_b: bool
}
Enum
enum
declarations also include a name and optional generic arguments. Their body
consists of a list of variants. Each variant in turn has a name, and an optional
list of fields
enum Enum {
EmptyVariant,
OneField{val: int<8>}
TwoFields{val1: bool, val2: bool}
}
enum GenericEnum<T> {
EmptyVariant,
OneField{val: T}
}
Statements
The body of any unit, or block is a list of statements followed by a resulting expression. Statements can declare things local to the block and contain expressions to be evaluated
Let bindings
Let bindings bind a pattern to a value.
Those not used to bindings and patterns can view a let
binding as assigning a value
to a variable.
The pattern has to be an irrefutable pattern
If the type specification is omitted, the type is inferred.
Syntax
let
pattern [:
type specification]=
expression;
Examples
Binding a value to a variable
let a = some_value;
Binding a value to the result of an if expression
let a = if x {0} else {2};
Unpacking a tuple
let (a, b) = some_value;
Unpacking a struct with an explicit type signature
let Struct$(a, b): Struct<int<8>> = some_value;
Registers
Free-standing (i.e. non-pipelining registers) are defined using reg(clk) ...
The register definition is quite complex and includes
- The clock signal which triggers an update
- A pattern to bind the current value of the register to. It must be irrefutable
- An optional type specification. Like let bindings, the type is inferred if the type signature is omitted
- An optional reset consisting of a reset trigger and a reset value.
Whenever the reset trigger is
true
the value of the register is asynchronously set to the reset value1 - An expression which gives new value
On the rising edge of the clock signal, the value of the register is updated to the value of the new value. The new value expression can include variables from the register itself.
Syntax
reg(
clock: expression)
pattern [:
type specification] [reset(
reset trigger: expression:
reset value expression)
]=
new value: expression;
Examples
A register which counts from -128 to 127 (Note that because no initial value is specified, this will be undefined in simulation):
reg(clk) value: int<8> = trunc(value + 1);
A register which counts from 0 to 200 (inclusive) and is reset to 0 by rst
:
reg(clk) value reset(rst: 0) =
if value == 200 {
0
} else {
trunc(value + 1)
};
Pipeline stage markers
Stage markers (reg;
) are used in pipelines to specify where pipeline registers should be inserted.
After a reg
statement, all variables above the statement will be put in registers and any reference
to those variables refer to the registered version.
Syntax
reg;
reg *
integer;
reg[
expression];
Repeated
In cases where more than one stage should be inserted without any new statements in between, there is a shorthand syntax:
reg * n`
where n
is an integer. This is compiled into n
simple reg statements, i.e.
reg * 3;
is the same as
reg;
reg;
reg;
Conditioned
A condition for the registers to accept values can also be specified in square brackets
reg[condition]
The semantics of this are explained in the section on dynamic pipelines
Pipeline stage labels
Pipeline stages can be given names to refer to them from other stages. This is done using 'name
.
'first
let x = ...;
reg;
To refer to a named stage, use a []
Set
Set the value of a mutable wire to the specified value.
set wire = value;
Set statements can only appear at the top block of a unit. This might be surprising as you would expect to be able to write
#![allow(unused_variables)] fn main() { if condition { set wire = value; } }
However, this is not well-defined in hardware because the wire needs some value, but no value is specified if condition does not hold. This particular point isn't true if an else branch is also specified, but the exact hardware that gets generated from imperative code like this is not obvious, particularly with more nesting.
Therefore, if you want to write
if condition {
set wire = on_true;
} else {
set wire = on_false
}
you should move the set
statement outside to make it unconditional, i.e.
set wire =
if condition {
on_true
} else {
on_false
}
Syntax
set
expression=
expression;
Assert
Takes a boolean condition and evaluates it, raising a runtime error in
simulation if it ever evaluates to false
. In synthesis, this is ignored
assert this_should_be_0 == 0;
NOTE: Assert statements are currently not supported for synthesis with Verilator, only with Icarus.
Comptime
TODO
Expressions
An expression is anything that has a value. Like most languages this includes things like integers literals, instantiations and operators. However, unlike the languages you may be used to, almost everything in Spade is an expression and has a value, for example if-expression and match-blocks.
This means, among other things, that you can assign the 'result' of an
if
-expression to a variable:
let a = if choose_b {
b
}
else {
c
};
Blocks
A block is an expression which can contain sub-statements.
They are delimited by {}
, contain zero or more statements
and usually end with an expression for the whole block's value.
let a = {
let partial_result = ...; // Bind a partial result to a variable
// 'return' the result of compute_on as the result of the block
compute_on(partial_result)
}
Variables defined inside blocks are only visible in the block. For example, you cannot use partial_result
outside the block above.
Blocks are required in places like bodies of if
-expressions and functions,
but can be used in any place where an expression is
expected.
if
-expressions
Syntax
if
expression blockelse
block
The if
-expression looks a lot like an if-statement in languages you may be
used to, but unlike most languages where if
is used to conditionally do
something, in Spade, it is used to select values.
For example, the following function returns a
if select_a
is true, otherwise it returns b
.
fn select(select_a: bool, a: int<8>, b: int<8>) -> int<8> {
if select_a {
a
} else {
b
}
}
This code makes heavy use of blocks. The body of the function, as well as each if-branch is a block.
In traditional hardware description languages, this would instead look like
fn select(select_a: bool, a: int<8>, b: int<8>) -> int<8> {
var result;
if select_a {
result = a;
} else {
result = b;
}
return result
}
but the Spade version is much closer to the actual hardware that is generated. Hardware in general does not support conditional execution, it will evaluate both branches and select the result.
match
-expression
Syntax
match
expression{
pattern=>
expression,
...}
The match
-expression is used to select a value based on the value of a single
expression. It is similar to case
statements in many languages, but supports
pattern
-matching which allows you to bind sub-values to variables. Typically, match
statements are used on enum
values:
enum MaybeByte {
Some{value: uint<8>},
None
}
fn byte_or_zero(in: MaybeByte) -> uint<8> {
match in {
// Bind the inner value to a new variable and return it
MaybeByte::Some(value) => value,
MaybeByte::None => 0,
}
}
but they can also be used on any values
If more than one pattern matches the value, the first pattern will be selected.
A match statement must cover all possible values of the matched expression. If this is not the case, the compiler emits an error.
Instantiation
The three kinds of units are instantiated in different ways in order to highlight to readers of the code what might happen beyond an instantiation. For example if you see a function instantiation, you know that there will be no state or other weird behavior behind the instantiation.
The following syntax is used to instantiate the different kinds of units:
- Functions:
unit()
- Entities:
inst unit()
- Pipelines
inst(<depth>) unit()
. The depth is the depth of the pipeline
Instantiation rules
Functions can be instantiated anywhere. Entities and pipelines can only be instantiated in entities or pipelines.
In addition, pipelines instantiated in other pipelines check the delay to make sure that values are ready before they are readable. For example,
let x = inst(3) subpipe();
let y = function();
reg;
read(x); // Compilation error. x takes 3 cycles to compute, but is read after 1
read(y); // Allowed, function is pure so its output is available immeadietly
reg * 2;
// Allowed, x has 3 stages internally, this will be the first value out of the pipeline
read(x)
Array Indexing
Arrays can be indexed using []
. Indices can either be single runtime integers
such as [i]
, or compile-time ranges, such as [10:15]
. Arrays are written
and indexed as most software languages: the leftmost array element is at index
0.
For example, [a, b, c][0:2]
returns a new array [a, b]
Single element indexing
Non-range indices can be runtime integers. The size of the index is the smallest power of two that can represent the size of the array. However, if the array size is not an even power of two, indexing outside the range causes undefined behavior.
Range indexing
The indices for range indexing can only be raw integers, i.e. not runtime values.
The leftmost, i.e. beginning of the range is included, and the end of the range
is exclusive. For example, a[0:1]
creates an array with a single element
containing the first element of a
.
Examples
let array = [10, 11, 12, 13, 14];
let _ = array[0]; // 10
let _ = array[1]; // 11
let _ = array[2]; // 12
let _ = array[5]; // Out of bounds access (array length is 5), result is undefined
let _ = array[0:1]; // [10]
let _ = array[1:3]; // [11, 12]
let _ = array[0:5]; // [10, 11, 12, 13, 14]
Tuple indexing
Tuples can also be indexed, though tuple indexing uses #
, for example tup#0
for the leftmost tuple value. Tuple indices can only be known at compile time
Stage references
TODO
Patterns
Patterns are used to bind values to variables, and as 'conditions' in
match
-expressions. Patterns match a set of values,
and bind (essentially assigns) a set of partial values to variables.
Name patterns
The simplest pattern is a
variable name, like x
. It matches all values, and binds the value to the
name, x
in this case.
Literal patterns
Integers and booleans can be matched on literals of their type. For example,
true
only matches booleans that are true
and 10
only matches integers
whose value is 10
. Literal patterns do not bind any variables.
Tuple patterns
Another simple pattern is the tuple-pattern. It matches tuples of a specific length, and binds all elements of the tuples to sub-patterns. All patterns can be nested
For example
let ((a, b), c) = ((1, 2), 3);
will result in a=1
, b=2
and c=3
.
If parts of a tuple pattern are conditional, the pattern will only match if the subpatterns do. For example,
match (x, y) {
(true, _) => true,
_ => false,
}
will only return true if x
is true, and false otherwise
Struct and enum patterns
Named patterns are used to match structs and enum variants. They consist of the name of the type or variant, followed by an argument list if the type has arguments.
Argument lists can be positional: ()
or named: $()
. In a positional
argument list, the fields of the type are matched based on the order of the
fields. In a named list, patterns are instead bound by name, either
field_name: pattern
or just field_name
which binds a new local variable
field_name
to field. Argument matching in patterns works the same way as in
argument lists during instantiation
This is best shown by examples
struct S {
x: int<8>,
y: int<8>,
}
// Positional pattern, binds `a` to the value of `x` and `b` to the value of `y`
S(a, b)
// Named pattern with no shorthand. The whole pattern matches if the `y` field is `0`
// in which case `a` will be bound to the value of `x`
S$(y: 0, x: a)
// Shorthand named. This binds a local variable `y` to the value of the field `y`. The field `x` is ignored.
S$(y, x: _)
enum
variants work the same way, but only match the enum of the specified name. For example
enum E {
A,
B{val: int<8>}
}
match e {
E::A => {},
E::B(0) => {},
E::B(val) => {}
}
Wildcard
The wildcard pattern _
. It matches all values but does not bind the value to any
variable. It is useful as a catch-all in match blocks
For example, if we want to do something special for 0
and 1
, but don't care about other
values we might write:
match integer {
0 => {},
1 => {},
_ => {}
}
Refutability
A pattern which matches all values of its type is irrefutable while one which only matches conditionally is refutable.
For example, a pattern unpacking a tuple is irrefutable because all values of type (T, Y)
will
match (a, b)
let tuple: (T, Y) = ...;
match tuple {
(a, b) => {...}
}
while one which matches an enum variant is refutable because the None
option will
not match
enum Option<T> {
Some{val: T},
None,
}
match x {
Some(x) => {...} // refutable: None not covered
...
}
Full documentation of the type system is yet to be written.
Primitive Types
These are the types built into the Spade language which aren't defined in the standard library.
bool
Generics
In a lot of cases, you want code to be generic over many different types, therefore both types and units support generic parameters.
Defining generics
Units and types which are generic have their generic parameters specified inside
angle brackets (<>
) after the name. The generics can be either integers
denoted by #
, or types which do not have #
sign. In the body of the generic
item, the generic types are referred to by their names
For example a struct storing an array of arbitrary length and type is defined as
struct ContainsArray<T, #N> {
inner: [T, N]
}
Using generics
When specifying generic parameters, angle brackets (<>
) are also used. For example, a function
which takes a ContainsArray
with 5 8-bit integers is defined as
fn takes_array(a: ContainsArray<int<8>, 5>) {
...
}
Ports and Wires
See the user-level documentation
Dynamic Pipelines
For conditionally executing pipelines, an enable condition can be specified on the reg statement. If this condition is false, the old value of all pipeline registers for this stage will be held, rather than being updated to the new values.
The stall condition is specified as follows
pipeline(1) pipe(clk: clock, condition: bool, x: bool) -> bool {
reg[condition];
x
}
where condition
is a boolean expression which when true
updates all the registers for this stage, and when false
the register content is undefined1.
The above code is compiled down to the equivalent of
entity pipe(clk: clock, condition: bool, x: bool) -> bool {
reg(clk) condition_s1 = if condition {condition} else {condition_s1}
reg(clk) x_s1 = if condition {x} else {x_s1}
x_s1
}
Pipeline enable conditions propagate to stages above the enabled stage, in order to make sure that values are not flushed. This means that in the following code
pipeline(1) pipe(clk: clock, x: bool) -> bool {
reg;
reg[inst check_condition()];
reg;
x
}
the first two stages will be disabled and keep their old value when
check_condition
returns false
while the registers in the final stage will
update unconditionally.
If several conditions are used, they are combined, i.e. in
pipeline(1) pipe(clk: clock, x: bool) -> bool {
reg;
reg[inst check_condition()];
reg;
reg[inst check_other_condition()];
reg;
x
}
the first two stages will update only if both check_condition()
and
check_other_condition()
are true
, and the next two registers are only going
to update if check_other_condition
is true.
stage.ready
and stage.valid
In some cases it is necessary to check if a stage will be updated on the next
cycle (ready) or if the values in the current cycle are valid. This is done
using stage.valid
and stage.ready
.
stage.ready
is true
if the registers directly following the statement will
update their values this cycle, i.e. if the condition of it and all downstream
registers are met.
stage.valid
is true
if the values in the current stage were
enabled, i.e. if none of the conditions for any registers
this value flowed
through were false
.
NOTE:
stage.valid
currently initializes as undefined, and needs time to propagate through the pipeline. It is up to the user to ensure that a reset signal is asserted long enough forstage.valid
to stabilize.
Example: Processor
This is part of a processor that stalls the pipeline in order to allow 3 cycles for a load instruction.
The program_counter
entity takes a signal indicating whether it should count up, or stall.
This signal is set to stage.ready
, to ensure that if the downstream registers don't accept new instructions, the program counter will stall.
pipeline(5) cpu(clk: clock) -> bool {
let pc = program_counter$(clk, stall: stage.ready)
reg;
let insn = inst(1) program_memory(clk)
let stall = stage(+1).is_load || stage(+2).is_load || stage(+3).is_load;
reg[stall];
let is_load = is_load(insn);
reg;
let alu_out = alu(insn);
reg;
reg;
let regfile_write = if stage.valid && insn_writes(insn) {Some(alu)} else {None()}
true // NOTE: Dummy output, we need to return something
}
the last line where regfile_write
is set uses stage.valid
to ensure that
results of an instruction are only written for valid signals, not signals
being undefined due to a stalled register.
Example: Latency Insensitive Interface
A common design method in hardware is to use a ready/valid interface. Here, a
downstream unit can communicate that it is ready to receive data by asserting a
ready
signal, and upstream unit indicate that their data is valid using a
valid
signal. If both ready
and valid
are set, the upstream unit hands
over a piece of data to the downstream unit.
What follows is an example of a pipelined multiplier that propagates a
ready/valid signal from its downstream unit to its upstream unit
struct port Rv<T> {
data: &T,
valid: &bool,
ready: &mut bool
}
pipeline(4) mul(clk: clock, a: Rv<int<16>>, b: Rv<int<16>>) -> Rv<int<32>> {
let product = a*b;
set a.ready = stage.ready;
set b.ready = stage.ready;
reg[*a.valid && *b.valid];
reg;
reg;
let downstream_ready = inst new_mut_wire();
reg[inst read_mut_wire(downstream_ready)];
Rv {
data: &product,
valid: &stage.valid,
ready: downstream_ready,
}
}
Currently, the implementation holds the previous value of the register,
which will also be done in hardware. However, this might change to setting
the value to X
for easier debugging, and to give more optimization
opportunities for the synthesis tool.
Binding
Constructs by syntax
This is a list of syntax constructs in the language with a link to a description of the underlying language construct. The list is split in two: constructs which start with a keyword and those which do not
Keywords
entity ...
Entity definitionenum ...
Struct definitionfn ...
Function definitioninst a(...) ...
entity instantiationinst(<depth>) a(...) ...
pipeline instantiationlet
let bindingpipeline(<depth>) ...
Pipeline definitionreg(<clk>) ...;
Register definitionreg;
Pipeline stage markerreg * <number>;
Pipeline stage markerreg[<condition>];
Pipeline stage markerstruct ...
Struct definition
Symbolic
<T>
,<#N>
Generic argumentsa(...) ...
function instantiationinst a(...) ...
entity instantiationinst(<depth>) a(...) ...
pipeline instantiationa[..]
array indexinga[..:..]
array range indexinga#..
tuple indexing
Config
The main project configuration specified in swim.toml
Summary
# The name of the library. Must be a valid Spade identifier
# Anything defined in this library will be under the `name` namespace
name = "…"
# List of optimization passes to apply in the Spade compiler. The passes are applied
# in the order specified here. Additional passes specified on individual modules with
# #[optimize(...)] are applied before global passes.
optimizations = ["…", …]
# List of commands to run before anything else.
preprocessing = ["…", …] # Optional
# Paths to verilog files to include in all verilog builds (simulation and synthesis).
# Supports glob syntax
extra_verilog = ["…", …] # Optional
# Map of libraries to include in the build.
#
# Example:
# ```toml
# [libraries]
# protocols = {git = https://gitlab.com/TheZoq2/spade_protocols.git}
# spade_v = {path = "deps/spade-v"}
# ```
libraries = {key: <Library>, …} # Optional
# Plugins to load. Specifies the location as a library, as well
# as arguments to the plugin
#
# Example:
# ```toml
# [plugins.loader_generator]
# path = "../plugins/loader_generator/"
# args.asm_file = "asm/blinky.asm"
# args.template_file = "../templates/program_loader.spade"
# args.target_file = "src/programs/blinky_loader.spade"
#
# [plugins.flamegraph]
# git = "https://gitlab.com/TheZoq2/yosys_flamegraph"
# ```
#
# Plugins contain a `swim_plugin.toml` which describes their behaviour.
# See [crate::plugin::config::PluginConfig] for details
plugins = {key: <Plugin>, …} # Optional
# Where to find the Spade compiler. See [Library] for details
[compiler]
<Library>
[simulation] # Optional
<Simulation>
[synthesis] # Optional
<Synthesis>
# Preset board configuration which can be used instead of synthesis, pnr, packing and upload
[board] # Optional
<Board>
[pnr] # Optional
<Pnr>
[packing] # Optional
<PackingTool>
[upload] # Optional
<UploadTool>
[log_output]
<LogOutputLevel>
name String
The name of the library. Must be a valid Spade identifier
Anything defined in this library will be under the name
namespace
optimizations [String]
List of optimization passes to apply in the Spade compiler. The passes are applied in the order specified here. Additional passes specified on individual modules with #[optimize(...)] are applied before global passes.
compiler Library
Where to find the Spade compiler. See [Library] for details
preprocessing [String]
List of commands to run before anything else.
extra_verilog [String]
Paths to verilog files to include in all verilog builds (simulation and synthesis). Supports glob syntax
simulation Simulation
synthesis Synthesis
board Board
Preset board configuration which can be used instead of synthesis, pnr, packing and upload
pnr Pnr
packing PackingTool
upload UploadTool
libraries Map[String => Library]
Map of libraries to include in the build.
Example:
[libraries]
protocols = {git = https://gitlab.com/TheZoq2/spade_protocols.git}
spade_v = {path = "deps/spade-v"}
plugins Map[String => Plugin]
Plugins to load. Specifies the location as a library, as well as arguments to the plugin
Example:
[plugins.loader_generator]
path = "../plugins/loader_generator/"
args.asm_file = "asm/blinky.asm"
args.template_file = "../templates/program_loader.spade"
args.target_file = "src/programs/blinky_loader.spade"
[plugins.flamegraph]
git = "https://gitlab.com/TheZoq2/yosys_flamegraph"
Plugins contain a swim_plugin.toml
which describes their behaviour.
See [crate::plugin::config::PluginConfig] for details
log_output LogOutputLevel
UploadTool
One of the following:
icesprog
tool = "icesprog"
Fields
iceprog
tool = "iceprog"
Fields
tinyprog
tool = "tinyprog"
Fields
openocd
tool = "openocd"
config_file = "path/to/file"
Fields
config_file FilePath
fujprog
tool = "fujprog"
Fields
custom
Instead of running a pre-defined set of commands to upload, run the specified list of commands in a shell. #packing_result# will be replaced by the packing output
tool = "custom"
commands = ["…", …]
Fields
commands [String]
PackingTool
One of the following:
icepack
tool = "icepack"
Fields
ecppack
tool = "ecppack"
idcode = "…" # Optional
Fields
idcode String
Pnr
One of the following:
ice40
architecture = "ice40"
[device]
<Ice40Device>
package = "…"
# If set, inputs and outputs of the top module do not need a corresponding field
# in the pin file. This is helpful for benchmarking when pin mapping is irreleveant, but
# when running in hardware, it is recommended to leave this off in order to get a warning
# when pins aren't set in the pin file.
allow_unconstrained = true|false
# Continue to the upload step even if the timing isn't met.
# This is helpful when you suspect that the place-and-route tool is conservative
# with its timing requirements, but gives no guarantees about correctness.
allow_timing_fail = true|false
# The path to a file which maps inputs and outputs of your top module to physical pins.
# On ECP5 chips, this is a `pcf` file, and on iCE40, it is an `lpf` file.
pin_file = "path/to/file"
Fields
device Ice40Device
package String
allow_unconstrained bool
If set, inputs and outputs of the top module do not need a corresponding field in the pin file. This is helpful for benchmarking when pin mapping is irreleveant, but when running in hardware, it is recommended to leave this off in order to get a warning when pins aren't set in the pin file.
allow_timing_fail bool
Continue to the upload step even if the timing isn't met. This is helpful when you suspect that the place-and-route tool is conservative with its timing requirements, but gives no guarantees about correctness.
pin_file FilePath
The path to a file which maps inputs and outputs of your top module to physical pins.
On ECP5 chips, this is a pcf
file, and on iCE40, it is an lpf
file.
ecp5
architecture = "ecp5"
[device]
<Ecp5Device>
package = "…"
# If set, inputs and outputs of the top module do not need a corresponding field
# in the pin file. This is helpful for benchmarking when pin mapping is irreleveant, but
# when running in hardware, it is recommended to leave this off in order to get a warning
# when pins aren't set in the pin file.
allow_unconstrained = true|false
# Continue to the upload step even if the timing isn't met.
# This is helpful when you suspect that the place-and-route tool is conservative
# with its timing requirements, but gives no guarantees about correctness.
allow_timing_fail = true|false
# The path to a file which maps inputs and outputs of your top module to physical pins.
# On ECP5 chips, this is a `pcf` file, and on iCE40, it is an `lpf` file.
pin_file = "path/to/file"
Fields
device Ecp5Device
package String
allow_unconstrained bool
If set, inputs and outputs of the top module do not need a corresponding field in the pin file. This is helpful for benchmarking when pin mapping is irreleveant, but when running in hardware, it is recommended to leave this off in order to get a warning when pins aren't set in the pin file.
allow_timing_fail bool
Continue to the upload step even if the timing isn't met. This is helpful when you suspect that the place-and-route tool is conservative with its timing requirements, but gives no guarantees about correctness.
pin_file FilePath
The path to a file which maps inputs and outputs of your top module to physical pins.
On ECP5 chips, this is a pcf
file, and on iCE40, it is an lpf
file.
Ecp5Device
One of these strings:
"LFE5U-12F"
"LFE5U-25F"
"LFE5U-45F"
"LFE5U-85F"
"LFE5UM-25F"
"LFE5UM-45F"
"LFE5UM-85F"
"LFE5UM5G-25F"
"LFE5UM5G-45F"
"LFE5UM5G-85F"
Ice40Device
One of these strings:
"iCE40LP384"
"iCE40LP1K"
"iCE40LP4K"
"iCE40LP8K"
"iCE40HX1K"
"iCE40HX4K"
"iCE40HX8K"
"iCE40UP3K"
"iCE40UP5K"
"iCE5LP1K"
"iCE5LP2K"
"iCE5LP4K"
Board
One of the following:
Ecpix5
name = "Ecpix5"
pin_file = "path/to/file" # Optional
config_file = "path/to/file" # Optional
Fields
pin_file FilePath
config_file FilePath
GoBoard
name = "GoBoard"
pcf = "path/to/file" # Optional
Fields
pcf FilePath
tinyfpga-bx
name = "tinyfpga-bx"
pcf = "path/to/file" # Optional
Fields
pcf FilePath
Icestick
name = "Icestick"
pcf = "path/to/file" # Optional
Fields
pcf FilePath
Synthesis
Summary
# The name of the unit to use as a top module for the design. The name must
# be an absolute path to the unit, for example `proj::main::top`, unless the
# module is marked `#[no_mangle]` in which case the name is used.
#
# Can also be set to the name of a module defined in verilog if a pure verilog top
# is desired.
top = "…"
# The yosys command to use for synthesis
command = "…"
# Extra verilog files only needed during the synthesis process.
# Supports glob syntax
extra_verilog = ["…", …] # Optional
top String
The name of the unit to use as a top module for the design. The name must
be an absolute path to the unit, for example proj::main::top
, unless the
module is marked #[no_mangle]
in which case the name is used.
Can also be set to the name of a module defined in verilog if a pure verilog top is desired.
command String
The yosys command to use for synthesis
extra_verilog [String]
Extra verilog files only needed during the synthesis process. Supports glob syntax
Simulation
Summary
# Directory containing all test benches
testbench_dir = "path/to/file"
# Extra dependencies to install to the test venv via pip
python_deps = ["…", …] # Optional
# The simulator to use as the cocotb backend. Currently verified to support verilator and
# icarus, but other simulators supported by cocotb may also work.
#
# Defaults to 'icarus'
#
# Requires a relatively recent version of verilator
simulator = "…"
# The C++ version to use when compiling verilator test benches. Anything that
# clang or gcc accepts in the -std= field works, but the verilator wrapper requires
# at least c++17.
# Defaults to c++17
cpp_version = "…" # Optional
# Extra arguments to pass to verilator when building C++ test benches. Supports substituting
# `#ROOT_DIR#` to get project-relative directories
verilator_args = ["…", …] # Optional
testbench_dir FilePath
Directory containing all test benches
python_deps [String]
Extra dependencies to install to the test venv via pip
simulator String
The simulator to use as the cocotb backend. Currently verified to support verilator and icarus, but other simulators supported by cocotb may also work.
Defaults to 'icarus'
Requires a relatively recent version of verilator
cpp_version String
The C++ version to use when compiling verilator test benches. Anything that clang or gcc accepts in the -std= field works, but the verilator wrapper requires at least c++17. Defaults to c++17
verilator_args [String]
Extra arguments to pass to verilator when building C++ test benches. Supports substituting
#ROOT_DIR#
to get project-relative directories
Library
Location of a library or external code. Either a link to a git repository, or a path relative to the root of the project.
compiler = {git = "https://gitlab.com/spade-lang/spade/"}
path = "compiler/"
One of the following:
Git
Downloaded from git and managed by swim
git = "…"
commit = "…" # Optional
tag = "…" # Optional
branch = "…" # Optional
Fields
git String
commit String
tag String
branch String
Path
A library at the specified path. The path is relative to swim.toml
path = "path/to/file"
Fields
path FilePath
PluginConfig
Summary
# True if this plugin needs the CXX bindings for the spade compiler to be built
requires_cxx = true|false
# Commands required to build the plugin. Run before any project compilation steps
build_commands = ["…", …]
# The files which this plugin produces
builds = [<BuildResult>, …]
# Arguments which must be set in the `swim.toml` of projects using the plugin
required_args = ["…", …]
# Commands to run after building swim file but before anything else
post_build_commands = ["…", …]
# Commands which the user can execute
commands = {key: <PluginCommand>, …}
# Things to do during the synthesis process
[synthesis] # Optional
<SynthesisConfig>
requires_cxx bool
True if this plugin needs the CXX bindings for the spade compiler to be built
build_commands [String]
Commands required to build the plugin. Run before any project compilation steps
builds [BuildResult]
The files which this plugin produces
required_args Set[String]
Arguments which must be set in the swim.toml
of projects using the plugin
post_build_commands [String]
Commands to run after building swim file but before anything else
synthesis SynthesisConfig
Things to do during the synthesis process
commands Map[String => PluginCommand]
Commands which the user can execute
PluginCommand
Summary
# List of system commands to run in order to execute the command
#
# Commands specified by the user, i.e. whatever is after `swim plugin <command>`
# is string replaced into `%args%` in the resulting command string. The arguments
# are passed as strings, to avoid shell expansion
script = ["…", …]
# The build step after which to run this command
[after]
<BuildStep>
script [String]
List of system commands to run in order to execute the command
Commands specified by the user, i.e. whatever is after swim plugin <command>
is string replaced into %args%
in the resulting command string. The arguments
are passed as strings, to avoid shell expansion
after BuildStep
The build step after which to run this command
BuildStep
One of these strings:
"Start"
Before any other processing takes place"SpadeBuild"
"Simulation"
"Synthesis"
"Pnr"
"Upload"
SynthesisConfig
Summary
# Yosys commands to run after the normal yosys flow
yosys_post = ["…", …]
yosys_post [String]
Yosys commands to run after the normal yosys flow
BuildResult
Summary
# The path of a file built by this build step
path = "…"
# The first build step for which this file is required. This will trigger
# a re-build of this build step if the file was changed
[needed_in]
<BuildStep>
path String
The path of a file built by this build step
needed_in BuildStep
The first build step for which this file is required. This will trigger a re-build of this build step if the file was changed
Compiler Internals
This chapter describes some internals of the compiler and details about code generation. Normally, this is not relevant to users of the language.
Naming
This chapter describes the naming scheme used by the compiler when generating Verilog. The goal of the Verilog generator is not to generate readable Verilog, but there should be a clear two-way mapping between signal names in the source Spade code and generated Verilog. This mapping should be clear both to users reading lists of signals, for example, in VCD files, and tools, for example VCD parsers.
Variables
Because Spade does not have the same scoping rules as Verilog, some deconfliction of names internal to a Verilog module is needed.
If a name x
only occurs once in a unit, the corresponding Verilog name is \x
.
(This is using the Verilog raw escape string system, and some tools may
report the name as x
).
If x
occurs more than once, subsequent names are given an index ordered sequentially
in the order that they are visited during AST lowering1
The k
th occurrence of a name is suffixed by _n{k}
Pipelined versions of names are suffixed with _s{n}
where n
is the absolute
stage index of the stage.
Names of port type with mutable wires have an additional variable for the mutable
bits. This follows the same naming scheme as the forward name, but is suffixed by
_mut
The following is an example of the naming scheme
pipeline(1) pipe(
x: bool, // "\x "
y: (&bool, &mut bool) // "\y ", "y_o "
) {
if true {
let x = true; // "x_n1"
} else {
let x = false; // "x_n2"
}
let x = true; // "x_n3"
reg; // "\x_s1 ", "x_n3_s1
let z = true; // "\z "
}
Spade makes no guarantees about name uniqueness between generated Verilog modules.
This is currently the lexical order of the occurrences, i.e. names which occur early in the module are given lower indices.
Type Representation
Description of the Verilog representation of Spade types
Mixed-direction types
Types with mixed direction wires are split in two separate variables, typically <name>
and <name>_mut
. The structure of the forward part is the same as if the backward part didn't exist, and the backward part is structured as if it were the forward part.
For example, (int<8>, &mut int<9>, int<2>, &mut int<3>)
is stored as (int<8>, int<2>)
and (int<9>, int<3>)
.
Tuples
Tuples are stored with their members packed side to side, with the 0th member on the left.
let x: (int<8>, int<2>, bool) = (a, b, c);
is represented as
logic x[10:0];
assign x = {a,b,c};
Binary representation
aaaaaaaabbc
Enums
Enums are packed with the leftmost bits being the discriminant and the remaining bits being the payload. The payload is packed left-to-right, meaning that the rightmost bits are undefined if a variant is smaller than the largest variant.
enum A {
V1(a: int<8>),
V2(b: int<2>),
V3(c: bool)
}
9 8 7 6 5 4 3 2 1 0
t t p p p p p p p p
-------------------
V1: 0 0 a a a a a a a a
V2: 0 1 b b X X X X X X
V3: 1 0 c X X X X X X X