In this tutorial we will create a project that will send "Hello World!" over the USB (serial) port when the letter "h" is received. This will help teach you how to use finite state machines (FSM).
Setup
As with all the tutorials, we first need to create a new project based on the Base Project. I called mine "Hello World" but you are free to choose whatever name you want.
With the new empty project, we now need to add the avr_interface component. This will be used to talk to the AVR and expose a nice interface to send data over the USB port.
You should know how to add a component to your project from the last tutorial. If you need a refresher, click here.
The component we need to add is the AVR Interface component and it can be found under Interfaces. This component will include a bunch of other components as dependencies.
The AVR Interface
Let's first take a look at what the avr_interface module looks like.
module avr_interface #(
CLK_FREQ = 50000000 : CLK_FREQ > 0, // clock frequency
BAUD = 500000 : BAUD > 0 && BAUD < CLK_FREQ/4 // baud rate
)(
input clk,
input rst,
// cclk, or configuration clock is used when the FPGA is begin configured.
// The AVR will hold cclk high when it has finished initializing.
// It is important not to drive the lines connecting to the AVR
// until cclk is high for a short period of time to avoid contention.
input cclk,
// AVR SPI Signals
output spi_miso, // connect to spi_miso
input spi_mosi, // connect to spi_mosi
input spi_sck, // connect to spi_sck
input spi_ss, // connect to spi_ss
output spi_channel[4], // connect to spi_channel
// AVR Serial Signals
output tx, // connect to avr_rx (note that tx->rx)
input rx, // connect to avr_tx (note that rx->tx)
// ADC Interface Signals
input channel[4], // ADC channel to read from, use hF to disable ADC
output new_sample, // new ADC sample flag
output sample[10], // ADC sample data
output sample_channel[4], // channel of the new sample
// Serial TX User Interface
input tx_data[8], // data to send
input new_tx_data, // new data flag (1 = new data)
output tx_busy, // transmitter is busy flag (1 = busy)
input tx_block, // block the transmitter (1 = block) connect to avr_rx_busy
// Serial Rx User Interface
output rx_data[8], // data received
output new_rx_data // new data flag (1 = new data)
) {
We will only be looking at the interface to the module since we don't need to know how it all works to use it properly (the magic of components).
There are lots of comments explaining what the signals are for, but the ones we care about I'll explain in more detail.
cclk
cclk is a very important signal. When the FPGA is still being configured by the AVR, the AVR uses this to send the configuration data. The signal toggles as data is being sent. However, once the FPGA is up and running, the AVR holds it high. We can then use this to make sure that the FPGA doesn't try to drive it's outputs before the AVR is ready. This is because there is a small window between when the FPGA is configured and when the AVR has initialized its IO for the FPGA post-load. We don't have to worry about this since the avr_interface module will take care of it. We just need to pass the signal in.
tx and rx
These signals are the serial connection to the AVR. Data sent on these will be sent over the USB port.
Serial TX and RX User Interfaces
These interfaces have a few signals which we will be using internally to tell the module what data to send and to receive any incoming data. One signal to take note of is the tx_block signal. This is used by the AVR to to do some hand-shaking with the FPGA to make sure the FPGA doesn't send too much data (as it will start dropping bytes). Again we just need to connect this to the correct external signal and everything else is taken care of for us.
Adding avr_interface to mojo_top
We now can add avr_interface to our top level module, mojo_top.
.clk(clk) {
// The reset conditioner is used to synchronize the reset signal to the FPGA
// clock. This ensures the entire FPGA comes out of reset at the same time.
reset_conditioner reset_cond;
.rst(rst){
// the avr_interface module is used to talk to the AVR for access to the USB port and analog pins
avr_interface avr;
}
}
always {
reset_cond.in = ~rst_n; // input raw inverted reset signal
rst = reset_cond.out; // conditioned reset
// connect inputs of avr
avr.cclk = cclk;
avr.spi_ss = spi_ss;
avr.spi_mosi = spi_mosi;
avr.spi_sck = spi_sck;
avr.rx = avr_tx;
avr.channel = hf; // ADC is unused so disable
avr.tx_block = avr_rx_busy; // block TX when AVR is busy
// connect outputs of avr
spi_miso = avr.spi_miso;
spi_channel = avr.spi_channel;
avr_rx = avr.tx;
led = 8h00; // turn LEDs off
}
All of the external signals are already defined for us in the Base Project. We simply connect them up. Something to notice is that avr_tx connects to avr.rx and avr.tx connects to avr_rx. This is because the signals avr_rx and avr_tx are from the point of view of the AVR, while avr.rx and avr.tx are from the point of view of the FPGA. We want the FPGA's transmitter to connect to the AVR's receiver and visa-versa.
We haven't yet connected all of the signals of avr.
The Serial Interface
Receiving Data
We will first look at how we know when data comes in.
There are two signals that are important for this, avr.rx_data and avr.new_rx_data. Their names are pretty self explanatory. The signal new_rx_data is a single bit wide and acts as a flag. When this signal is 1, the value of rx_data is valid and it's the byte we just received.
This means if we want to wait for a specific byte, we can simply wait for new_rx_data to be high and rx_data to be the value we want. This is exactly what we will do later.
Transmitting Data
Transmitting data is a tiny bit more complicated since we now have three signals. The signals avr.new_tx_data and avr.tx_data are exactly the same as their rx counterparts, except this time they are inputs to the transmitter, so we generate the values. When we want to send a a byte, we set tx_data to be that byte and pulse new_tx_data high for one clock cycle (if you left it high, the same byte would be sent over and over). There is one more thing to consider, that is that the transmitter can't send an entire byte every clock cycle (the fastest we can provide data). Therefore, we need to look at the third signal, avr.tx_busy, to know when it is safe to send more data. This signal will be high when the transmitter is busy. When it is busy, any new data will be ignored. That means we must ensure that this signal is low before we attempt to send any data if we want to ensure we don't drop any bytes.
We will now create two new modules that will deal with all these signals to send "Hello World!" when an "h" is received.
ROMs
Before we get too deep into generating and handling these signals, we need to create a ROM (Read Only Memory).
Our ROM will hold the message we want to send, in our case "Hello World!".
Create a new module named hello_world_rom and add the following to it.
1 module hello_world_rom (
2 input address[4], // ROM address
3 output letter[8] // ROM output
4 ) {
5
6 const TEXT = "\r\n!dlroW olleH"; // text is reversed to make 'H' address [0]
7
8 always {
9 letter = TEXT[address]; // address indexes 8 bit blocks of TEXT
10 }
11 }
We have a single input, address, and a single output, letter. We want to output the first letter, "H", when address is 0 and the second letter, "e", when address is 1. This continues for each letter in our message.
This is actually pretty simple to do. First we need an array of the data we want to send. This is done in the following line.
6 const TEXT = "\r\n!dlroW olleH"; // text is reversed to make 'H' address [0]
Here we are using a string to represent our data. Strings of more than one letter are 2D. The first dimension has an index for each letter and the second dimension is 8 bits wide.
Note that the text is reversed. This is because we want, as the comment says, for "H" to be the first letter. Also note that "\n" and "\r" are actually single characters each. That means when we reversed the text we didn't write "n\r" which would be wrong. These characters will make sure the text is on a new line each time it is sent. "\n" goes to the next line and "\r" returns the cursor to the beginning of the new line.
Next, we simply need to set letter to the correct value in TEXT based on the given address. We do that on line 9.
9 letter = TEXT[address]; // address indexes 8 bit blocks of TEXT
Since the text is reversed, we can simply output the corresponding letter.
This wraps up the ROM!
The Greeter
This is where we will talk to the avr_interface module to actually send and receive data.
Create a new module named greeter and fill it with the following.
1 module greeter (
2 input clk, // clock
3 input rst, // reset
4 input new_rx, // new RX flag
5 input rx_data[8], // RX data
6 output new_tx, // new TX flag
7 output tx_data[8], // TX data
8 input tx_busy // TX is busy flag
9 ) {
10
11 const NUM_LETTERS = 14;
12
13 .clk(clk) {
14 .rst(rst) {
15 fsm state = {IDLE, GREET};
16 }
17 dff count[$clog2(NUM_LETTERS)]; // min bits to store NUM_LETTERS - 1
18 }
19
20 hello_world_rom rom;
21
22 always {
23 rom.address = count.q;
24 tx_data = rom.letter;
25
26 new_tx = 0; // default to 0
27
28 case (state.q) {
29 state.IDLE:
30 count.d = 0;
31 if (new_rx && rx_data == "h")
32 state.d = state.GREET;
33
34 state.GREET:
35 if (!tx_busy) {
36 count.d = count.q + 1;
37 new_tx = 1;
38 if (count.q == NUM_LETTERS - 1)
39 state.d = state.IDLE;
40 }
41 }
42 }
43 }
The inputs and outputs should look a little familiar. They will connect to the avr module in mojo_top.
We are using the constant NUM_LETTERS to specify how big the ROM is. In our case, we have 14 letters to send (this includes the new line characters).
FSMs
On line 15 we instantiate an FSM.
15 fsm state = {IDLE, GREET};
fsm is similar to dff in that they both have .clk, .rst, and .d inputs and a .q output. They behave much the same way, with one important exception. FSMs are used to store a state, not a value.
In this example, our FSM can have one of two states, IDLE or GREET. In a more complicated example we could add more states to our FSM simply by adding them to the list.
To access a state, we can use state.IDLE or state.GREET. This is done in the case statement (covered below) as well as when we assign a new state to state.
Functions
17 dff count[$clog2(NUM_LETTERS)]; // min bits to store NUM_LETTERS - 1
Here we are declaring a counter that will be use to keep track of what letter we are on. That means we need the counter to be able to count from 0 to NUM_LETTERS - 1. How do we know how many bits we will need when NUM_LETTERS is a constant? We could simply compute this by hand and type in the value. However, this is fragile since it would be easy to change NUM_LETTERS and forget to change the counter size. This is where the function $clog2() comes in handy. This function will compute the ceiling log base 2 of the value passed to it. This happens to be the number of bits you need to store the values from 0 to one minus the argument. How convenient! Just what we needed.
It is important to note that this function can only be used with constants or constant expressions. This is because the tools will compute the value at runtime. Your circuit isn't doing anything fancy here. Computing this function in hardware would be far too complicated for a single line to properly handle.
Saying Hello
We instantiate a copy of our hello_world_rom and call it rom so we know what data to send.
Since we are only going to be sending the letters from the ROM, we can wire them up directly to tx_data.
hello_world_rom rom;
always {
rom.address = count.q;
tx_data = rom.letter;
We also can set the ROM's address to simply be the output of our counter since that's what the counter is for!
Case Statements
Case statements are an easy way to do a bunch of different things depending on the value of something. You could always use a bunch of if statements but this can be way more compact and easier to read.
The general syntax for a case statement is below.
case (expr) {
const: statements
const: statements
const: statements
const: statements
default: statements
}
Basically, you pass in some expression and then have a bunch of blocks of statements that are considered based on the value of that expression. It sounds way more complicated than it is. Let's look at our example.
case (state.q) {
state.IDLE:
count.d = 0;
if (new_rx && rx_data == "h")
state.d = state.GREET;
state.GREET:
if (!tx_busy) {
count.d = count.q + 1;
new_tx = 1;
if (count.q == NUM_LETTERS - 1)
state.d = state.IDLE;
}
}
When state.q is state.IDLE, we only look at the lines 30-32. However, when state.q is state.GREET we only look at lines 35-40.
Putting it all Together
So how does it all work? Since IDLE was the first state we listed, it is, by default, the default state. You can specify an alternate default state by using the parameter #INIT(STATE_NAME).
Because we start in the idle state, the counter is set to 0 and we do nothing until we see "h". To wait for "h" we wait for new_rx to be high and rx_data to be "h".
Once we receive an "h", we change states to state.GREET
Here we wait for tx_busy to be low to signal we can send data. We then increment the counter for next time and signal we have a new letter to send by setting new_tx high. Remember we already set tx_data as the output of our ROM.
Once we are out of letters, we return to the idle state to wait for another "h".
Adding the Greeter to mojo_top
Finally we need to add the greeter module to mojo_top
First, let's add an instance of it.
.clk(clk), .rst(~rst_n){
// the avr_interface module is used to talk to the AVR for access to the USB port and analog pins
avr_interface avr;
greeter greeter; // instance of our greeter
}
Next, we need to connect it up.
greeter.new_rx = avr.new_rx_data;
greeter.rx_data = avr.rx_data;
avr.new_tx_data = greeter.new_tx;
avr.tx_data = greeter.tx_data;
greeter.tx_busy = avr.tx_busy;
That's it! Go ahead, build your project and load it on your Mojo. You can then fire up a serial monitor and send "h" to your board to be nicely greeted!