FPGA for Fun #1 (Part 2) – Driving the MAX 7219 LED Display Module

In a previous post, I covered the wiring
diagrams, finite state machines, and LED segment encoding for the MAX7219
display module wired up to the Mojo FPGA.

In this post, I will cover the Lucid HDL code used to implement the state
machines.

All of this Lucid HDL (and the resulting Verilog) can be found on my GitHub
page:

FPGA for Fun on GitHub

Blog posts in this series:

The first thing to do after creating the project is define the constraints
file to setup our pinouts. See the wiring
diagram
for
details.

NET "max7219_load" LOC = P40 | IOSTANDARD = LVTTL;  
NET "max7219_data" LOC = P50 | IOSTANDARD = LVTTL;  
NET "max7219_clock" LOC = P51 | IOSTANDARD = LVTTL;

The MAX7219 Component

After adding an SPI Master component to the project from within the Mojo IDE,
we need to add a new max7219 component to handle our operation states.

Here are the states that we’ll be implementing:

FPGA for Fun #1 (Part 2) - Driving the MAX 7219 LED Display
Module

module max7219 (  
    input clk,  // clock
    input rst,  // reset
    input addr_in[8],
    input din[8],
    input start,
    output cs,
    output dout,
    output sck,
    output busy
  ) {

addr_in is used for our address data (from the MAX7219 Register
Map
),
din is used for the data values coming in, and cs, dout, and sck are
all used to manage the SPI interface with the MAX7219. We use busy to
indicate that we’re not in the IDLE state. start is used to trigger our
state transfer from IDLE to TRANSFER_ADDR.

Let’s continue with the Lucid code.

.clk (clk) {  
  .rst(rst) { 
      fsm state(#INIT(IDLE)) = {IDLE, TRANSFER_ADDR, TRANSFER_DATA};
      spi_master spi(.miso(0));
      dff data[8];
      dff addr[8];
      dff load_state;
    }
  }

We’re initializing the components that we’ll use in this module with the clk
and rst values coming from the Mojo.

We define our finite state machine (fsm) and initialize it to a default
state of IDLE. We then initialize an spi_master component to manage our
SPI clock and data transfers. We’re using d-flip-flops (dff) to hold the
values that we send to the address and data lines because we need to store
those values between clock cycles. We’re also using a d-flip-flip to store the
current state of our load (cs) pin.

  sig data_out[8];  
  sig mosi;
  counter count;

  always  {
    sck = spi.sck;
    // synchronize our counter with the spi clock so we can keep up with where we are
    count.clk = spi.sck;
    count.rst = 0;

    data_out = 8b0;
    spi.start = 0;
    mosi = 0;
    busy = state.q != state.IDLE;  // busy when not idle
    dout = 0;

In lines 1-2, we define a few signals to deal with the output data (what we’ll
actually send to the MAX7219 display) and the MOSI (Master Out Slave In). We
also define a counter to use to keep up with how many SPI clock cycles have
passed. This will let us manage the load pin and toggle it at the right time
to latch the input data.

It’s important to notice that in line 8, we set the clock of the counter to
our SPI component’s sck value. This ensures that our counter only increments
in sync with the SPI data clock. It will help us keep up with how much data
has been sent to the MAX7219.

  case (state.q) {  
      state.IDLE:
        load_state.d = 1;
        if (start) { // if we should start a new transfer
          // save our data and address values to memory
          addr.d = addr_in;
          data.d = din;
          count.rst = 1;

          // Toggle the load pin (makes the 7219 start listening for data)
          load_state.d = 0;

          state.d = state.TRANSFER_ADDR;
        }
      state.TRANSFER_ADDR:
        spi.start = 1;
        data_out = addr.q;
        dout = spi.mosi;
        if (count.value == 8){
          state.d = state.TRANSFER_DATA;
        }
      state.TRANSFER_DATA:  
        spi.start = 1;
        data_out = data.q;
        dout = spi.mosi;
        if (count.value == 16){
          // latch the data by pulsing the load pin (cs)
          load_state.d = 1;
          count.rst = 1;
          state.d = state.IDLE;
        }
    }

    cs = load_state.q;
    spi.data_in = data_out;

The last section of code in this component is the state management case
statement. During the IDLE state, we first set the load pin high. The load
pin stays high until we wish to write data to the MAX7219 (see the Timing
Diagram
).
Then, we check to see if the start value is high. If so, we set the d-flip-
flop values for address and data to the values coming in to the component. We
then reset the counter and set the load pin low (to start reading into the
MAX7219). After all of this, we transfer to the next state.

In the TRANSFER_ADDR state, we start the SPI component, set our data_out
signal to equal the value of the addr flip-flop and set the dout value to
the spi component’s mosi value. We repeat the process 8 times total so that
the SPI component will send the 8 bits we need for our address value. After
the 8 bits are sent, we change to the next state.

In the TRANSFER_DATA state, we start the SPI component and set the
data_out signal to our data value stored in the data flip-flop. We set the
dout to the SPI mosi value. We repeat this process 8 times to ensure that we
send all 8 bits of the data to the MAX7219. After sending the 8th bit (our
counter is now at 16), we pulse the load pin high to latch the address and
data into the MAX7219. We then reset the counter and switch to the IDLE
state.

In line 34, we set the cs output to the value of our load flip-flop. Line 35
shows how we send the value from our data_out signal to the SPI data input.
This is a bit confusing because we’re mixing in with out. Think of it like
this, we’re outputting data from this component into another component. That
component has an input that we’re matching to our output. Because we’re always
connecting the dout output to the SPI component’s mosi output (lines 18 and
25), our MAX7219 is always receiving its data from the SPI component. It’s
also managing the serial clock, so everything is in sync.

That’s everything for the max7219 component. By using this component, we can
control the MAX7219 chip from our Mojo FPGA.

Main Module

The next HDL to cover is the main component (mojo_top). This is a bit more
complicated, because it uses the component we just covered to display
“DEADBEEF” on the display.

The flow of this is basically just state management to switch from states
based on this state diagram:

FPGA for Fun #1 (Part 2) - Driving the MAX 7219 LED Display
Module

module mojo_top (  
    input clk,              // 50MHz clock
    input rst_n,            // reset button (active low)
    output led [8],         // 8 user controllable LEDs
    input cclk,             // configuration clock, AVR ready when high
    output spi_miso,        // AVR SPI MISO
    input spi_ss,           // AVR SPI Slave Select
    input spi_mosi,         // AVR SPI MOSI
    input spi_sck,          // AVR SPI Clock
    output spi_channel [4], // AVR general purpose pins (used by default to select ADC channel)
    input avr_tx,           // AVR TX (FPGA RX)
    output avr_rx,          // AVR RX (FPGA TX)
    input avr_rx_busy,      // AVR RX buffer full
    output max7219_load,
    output max7219_data,
    output max7219_clock
  ) {

  sig rst;                  // reset signal

  .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) {
      max7219 max;
      fsm state(#INIT(IDLE)) = {IDLE, SEND_SHUTDOWN, SEND_RESET, SEND_NO_DECODE, SEND_ALL_DIGITS, SEND_TEST_ON, SEND_TEST_OFF, SEND_WORD, HALT};
      dff segments[8][8];
      dff segment_index[3];
    }
  }

  sig max_addr[8];
  sig max_data[8];

In line 29, you can see where we use a two-dimensional d-flip-flop to hold the
segment data. The first dimension is the segment to display the data on (1-8
on the MAX7219), the second dimension is used to store the binary values
representing the actual segment display (defined below as constants). We use
the segment_index to keep up with what segment we’re currently displaying.

Segment Encodings

  const C0 = b01111110;  
  const C1 = b00110000;
  const C2 = b01101101;
  const C3 = b01111001;
  const C4 = b00110011;
  const C5 = b01011011;
  const C6 = b01011111;
  const C7 = b01110000;
  const C8 = b01111111;
  const C9 = b01111011;
  const A = b01110111;
  const B = b00011111;
  const C = b01001110;
  const D = b00111101;
  const E = b01001111;
  const F = b01000111;
  const O = b00011101; // O
  const R = b00000101; // R
  const MINUS = b01000000; // -
  const BLANK = b00000000; // BLANK

These are the binary values for the segments on the MAX7219. I added O and R
so that I could display the word “Error”.

always {   

    segments.d[7] = D;
    segments.d[6] = E;
    segments.d[5] = A;
    segments.d[4] = D;
    segments.d[3] = B;
    segments.d[2] = E;
    segments.d[1] = E;
    segments.d[0] = F;

If you are a software engineer like I am, you may be tempted to read this like
source code. Hardware Description Languages (like Lucid, Verilog, and VHDL)
are not programming languages. We need to have some way of expressing when
things take place in relation to other things. Everything in the always
block runs all of the time, as the word suggests. That’s why we have to manage
these states like we do, because this “code” is “executing” every time the
clock cycles on the FPGA (the Mojo has a 50 MHz clock). It is truly parallel
processing. That’s one of the great advantages of FPGAs over microcontrollers.
It’s also why it’s more complicated to manage.

The segments are numbered from right to left, so we need to display the
characters in reverse order. We do this starting on line 2. Basically, we need
to set the data stored in the segments flip-flop to the binary values of the
character we wish to display. So, in line 2, the 7th segment (which will be
segment 8 on the MAX7219 module) is set to the values stored in the D
constant (b00111101). We’ll loop through all 8 of these values in the
SEND_WORD state and send the value of the flip-flop as the data to the max
component.

The rest is fairly self-explanatory. We just transition from one state to
another, sending in the appropriate address and data values as required by
that state to get the desired result. We’re not using the SEND_TEST_ON or
SEND_TEST_OFF states, so those can be ignored.

Main Module State Machine Implementation

    reset_cond.in = ~rst_n; // input raw inverted reset signal  
    rst = reset_cond.out;   // conditioned reset

    led = 8h00;             // turn LEDs off
    spi_miso = bz;          // not using SPI
    spi_channel = bzzzz;    // not using flags
    avr_rx = bz;            // not using serial port
    max_addr = 8b0;
    max_data = 8b0;
    max.start = 0;

    case(state.q) {
      state.IDLE:
        segment_index.d = 0;
        state.d = state.SEND_SHUTDOWN;
      state.SEND_SHUTDOWN:
        max.start = 1;
        max_addr = h0C;
        max_data = h00;
        if(max.busy != 1) {
          state.d = state.SEND_RESET;
        }
      state.SEND_RESET:
        max.start = 1;
        max_addr = h0C;
        max_data = h01;
        if(max.busy != 1) {
          state.d = state.SEND_NO_DECODE;
        }
      state.SEND_TEST_ON:
        max.start = 1;
        max_addr = h0F;
        max_data = h01;
        if(max.busy != 1) {
          state.d = state.SEND_NO_DECODE;
        }
      state.SEND_TEST_OFF:
        max.start = 1;
        max_addr = h0F;
        max_data = h00;
        if(max.busy != 1) {
          state.d = state.SEND_NO_DECODE;
        }
      state.SEND_NO_DECODE:
        max.start = 1;
        max_addr = h09;
        max_data = h00;
        if(max.busy != 1) {
          state.d = state.SEND_ALL_DIGITS;
        }
      state.SEND_ALL_DIGITS:
        max.start = 1;
        max_addr = h0B;
        max_data = h07;
        if(max.busy != 1) {
          state.d = state.SEND_WORD;
        }
      state.SEND_WORD:
        if(segment_index.q < 8)
        {
          max.start = h01;
          max_addr = segment_index.q + 1;
          max_data = segments.q[segment_index.q];
          if(max.busy != 1) {
            segment_index.d = segment_index.q + 1;
          }
        } 
        else {
          segment_index.d = 0;
          state.d = state.HALT;      
        }
      state.HALT:
        max_addr = 8b0;
        max_data = 8b0;
    }

    max.addr_in = max_addr;
    max.din = max_data;
    max7219_clock = max.sck;
    max7219_data = max.dout;
    max7219_load = max.cs;
  }

}

On line 59, we check to see if our segment index is less than 8. If so, we set
the address to the value of the segment index +1 on line 62 (to account for
the fact that index 0 should write to segment 1, index 1 to segment 2, etc.)
We wait for the max component to finish by keeping these values until the busy
flag goes low (line 64). When that happens, we simply increment our segment
index by 1.

On line 69, we handle the case when the index is equal to 8 (well,
technically, it’s no longer less than 8, but it’s 8 in our case). Then, we
simply transition to the HALT state, which does nothing but send a no-op
(data 0 at address 0).

Starting on line 79, we need to set the output values going to the actual
module to the values that we generate in the max component. We set the
clock, data, and load pins accordingly.

That’s it! This will display “DEADBEEF” on the MAX7219 display.

FPGA for Fun #1 (Part 2) - Driving the MAX 7219 LED Display
Module

Video:

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top