SPI Controller
In this next example, we will demonstrate enum types.
SpiController is a circuit which sends data to a device using the SPI protocol.
enum type SpiState width 3 {
Idle = 0b001
Set = 0b010
Tick = 0b100
}
mod SpiController {
incoming clock : Clock
incoming reset : Reset
incoming data : Word[8]
incoming valid : Bit
outgoing ready : Bit {
it := state == #Idle
}
outgoing cs : Bit {
it := state != #Set && state != #Tick
}
outgoing sck : Bit {
it := state == #Tick
}
outgoing do : Bit {
it := buffer[7]
}
reg state : SpiState on clock {
if reset {
it <= #Idle
} else {
match it {
case #Idle => {
if valid {
it <= #Set
}
}
case #Set => {
it <= #Tick
}
case #Tick => {
if bits == 7 {
it <= #Idle
} else {
it <= #Set
}
}
}
}
}
// bits left to send
reg buffer : Word[8] on clock {
match state {
case #Idle => {
if valid {
it <= data
}
}
case #Tick => {
it <= word(it[7..0], 0w1)
}
}
}
// how many bits have been clocked out
reg bits : Word[3] on clock {
match state {
case #Idle => {
it <= 0
}
case #Tick => {
it <= it + 1
}
}
}
}
Sending Data
To send data using SpiController, we use two ports:
incoming data : Word[8]
incoming valid : Bit
You place the byte you want to send into data, and then you set valid to true.
The SPI protocol allows us to send one bit at a time. This means that SpiController takes several cycles to send the data. During this time, it is not ready to receive another byte to send, and it will ignore its inputs until it’s ready again.
You can tell whether the SpiController is ready by looking at the value of the ready port:
outgoing ready : Bit {
it := state == #Idle
}
We’ll look more into this syntax shortly.
The SpiController will begin a transaction as soon as both ready and valid are asserted.
Serial Protocol Interface
Serial Protocol Interface is a 4-wire protocol for communicating data to a device:
Port |
Name |
Description |
|---|---|---|
sck |
serial clock |
the clock used to synchronize the controller and device |
cs |
chip select |
whether a transaction is in progress (active-low) aka ss (slave select) |
do |
data out |
the bit of data being sent by the controller aka mosi (master out, slave in) |
di |
data in |
the bit of data being received by the controller aka miso (master in, slave out) |
Note
For the sake of simplicity, our SpiController example is designed to only send data, not to receive it. And so, it includes sck cs and do but omits di.
When a transaction begins, SpiController pulls cs low (sets it to false). This is how you “wake up” a device using SPI.
The SpiController then alternates between setting do (the data out) to the next bit of data and ticking sck (the serial clock).
Once the transaction is complete, SpiController brings cs back high, signaling the end of the transaction.
Enum Types
The SpiController is a state machine. When it is idle, it waits for you to initiate the next transaction. And once a transaction is in progress, it alternates between setting up the next bit to send and actually sending it with a clock tick.
To represent these state values, we use a feature of Virdant called enum types:
enum type SpiState width 3 {
Idle = 0b001
Set = 0b010
Tick = 0b100
}
We use enum type to define a new enum type. We specify its width in bits, and then list all of the values in the type, together with their numeric representations.
The values of our new type are prefixed with a #.
They are: #Idle #Set and #Tick.
Enum types are very useful for when we want to list all of something in one place. Here are just a few examples where we might use them:
states of a state machine (as we are doing here)
opcodes for a protocol (eg, Get Ack PutFullData etc of TileLink)
opcodes for an instruction set (eg, LOAD STORE OP BRANCH, etc in RISC-V)
CPU privilege modes (eg, Machine Supervisor User)
Exception reasons (eg, ILLEGAL_INSTRUCTION DIV_BY_ZERO PAGE_FAULT, etc)
In SpiController, we track the current state in the state register, and we give it the type SpiState.
Equality Testing
In Virdant, we can compare values with ==. This makes it easy when we want to ask if we’re in a particular state.
outgoing ready : Bit {
it := state == #Idle
}
outgoing sck : Bit {
it := state == #Tick
}
And as you might expect, we use != to test that two values are not equal:
outgoing cs : Bit {
it := state != #Set && state != #Tick
}
State Machines
The SpiController also defines how state changes over time. This is done with a driver block:
reg state : SpiState on clock {
if reset {
it <= #Idle
} else {
match it {
case #Idle => {
if valid {
it <= #Set
}
}
case #Set => {
it <= #Tick
}
case #Tick => {
if bits == 7 {
it <= #Idle
} else {
it <= #Set
}
}
}
}
}
We see an if statement at the top which handles the reset logic. Resetting the controller puts it into #Idle mode.
Next, we see a match statement which looks at state. Inside, we see several cases, some with nested if statements. Finally, in each of these, we have a driver statement that begins with it <= .... (Note that it here refers to state).
So in total, this block of code simply says:
“Look at the current state and then, on the next cycle, it becomes ...”
Notice, by the way, that not all situations were fully covered. If state is #Idle, but valid is not true, the code doesn’t say what happens!
Virdant will interpret this to mean that nothing happens: state just keeps whatever value it had before. (In this case, it stays #Idle).
Note that this behavior only works for <= (latched drivers). When using := (continuous drivers), we must always have full case coverage. Virdant will flag an error if you forget.
Note
In guides on Verilog, you will often read of strict prescriptions on how to write state machines. This has to do with the subtleties of Verilog’s non-blocking assignments and latch inference behaviors.
Luckily, we do not have to worry about these things in Virdant.
Shifting out Data
The buffer register is used to keep track of what data is left to be sent. The do port is simply an alias for the top bit of buffer:
outgoing do : Bit {
it := buffer[7]
}
The definition of buffer itself comes a bit later:
reg buffer : Word[8] on clock {
match state {
case #Idle => {
if valid {
it <= data
}
}
case #Tick => {
it <= word(it[7..0], 0w1)
}
}
}
We see that it latches the value of data when a transaction begins.
On the next cycle after each tick of sck, we shift it over 1 bit. We do this using bit slicing: it[7..0]. This returns the lower 7-bits of it (buffer).
The first index (7) is exclusive, while the second index (0) is inclusive. This makes it easy to calculate at a glance the result is 7 - 0 = 7 bits, or Word[7].
Note
This convention is closer to the convention found in programming languages.
For experienced Verilog developers, this will take some getting used to. Luckily, Virdant’s type system will quickly alert you to where you have made a mistake.
Clocks
We end by looking at a subtle point about clocks.
In SpiController, we see clock has type Clock, but sck has type Bit. What’s the difference?
The Clock type is special in Virdant because they affect the timing of our design. The only things we can do with Clocks are:
pass them around, and
feed them to the on clause of a reg
However, we cannot use operators like && || ! or apply any other logic to them. Clocks must be carefully regulated so we don’t end up with bad things like clock skew or timing violations.
SPI is a protocol for talking to off-chip peripherals. If we synthesize our design and put it onto an FPGA, we fully expect sck to end up going out to an IO port, not to any registers. While timing concerns are still important, as far as Virdant is concerned, we can treat it like a standard value. And so we use Bit instead.