Driving the Max7219 7-Segment Display Device from a TinyFPGA using Verilog

Driving the Max7219 7-Segment Display Device from a TinyFPGA using Verilog

I recently bought a TinyFPGA to replace my Mojo FPGA. The primary reason for this was the outdated toolset required by the Mojo (Xilinx ISE Web doesn't properly run on Windows 10 without some hacking).

TinyFPGA
tinyfpga.github.io :
Affordable, Open-Source FPGA

One of the first things that I wanted to do was get my DEADBEEF demo working with SPI on the Max7219 7-Segment display. I've written a post on this in the past for the Mojo using the Lucid HDL, but the TinyFPGA uses Verilog. I had to use the transpiled Verilog source code from the previous project and modify it a little bit to work with the TinyFPGA.

My source code can be found on GitHub. Before I get started, I want to link to my original post where I describe the Finite State Machines that I used to model this code. It also has a wiring diagram for the Mojo, but I used a TinyFPGA this time.

The first thing we can look at is the top module that I created to drive the 7-Segment display device.

module top (
    input wire CLK,    // 16MHz clock
    output reg PIN_13, // Max7219 CLK
    output reg PIN_12, // Max7219 DIN
    output reg PIN_11, // Max7219 CS
    output wire USBPU  // USB pull-up resistor
);

The first thing we do is define the module and its wires/registers. The following pins should be attached to the Max7219:

  • Pin 11 - CS
  • Pin 12 - DIN
  • Pin 13 - CLK
    reg rst = 1'b1;

    wire M_max_cs;
    wire M_max_dout;
    wire M_max_sck;
    wire M_max_busy;
    reg [7:0] M_max_addr_in;
    reg [7:0] M_max_din;
    reg M_max_start;
``

We need to define a register for the reset and initialize it to 1 (reset) since the TinyFPGA doesn't have a reset button (we'll set it to 0 later on).

Next, we define some more wires and registers to maintain our state, address, and data.

max7219 max (
      .clk(CLK),
      .rst(rst),
      .addr_in(M_max_addr_in),
      .din(M_max_din),
      .start(M_max_start),
      .cs(M_max_cs),
      .dout(M_max_dout),
      .sck(M_max_sck),
      .busy(M_max_busy)
    );

The next thing we'll do is define the max7219 instance to use. We'll go over this module in detail below.

Next, we'll define our states as localparam variables.

    localparam IDLE_state = 3'd0;
    localparam SEND_RESET_state = 3'd1;
    localparam SEND_MAX_INTENSITY_state = 3'd2;
    localparam SEND_NO_DECODE_state = 3'd3;
    localparam SEND_ALL_DIGITS_state = 3'd4;
    localparam SEND_WORD_state = 3'd5;
    localparam HALT_state = 3'd6;

After that, we need to setup some registersfor flip-flop (DFF) states.

    reg [2:0] M_state_d, M_state_q = IDLE_state;
    reg [63:0] M_segments_d, M_segments_q = 1'h0;
    reg [2:0] M_segment_index_d, M_segment_index_q = 1'h0;

the M_state_d and M_state_q flip flop is used for our state machine. The 64 bit M_segments DFF is used to store all of the characters that we'll display. I'm using this as a sort of FIFO memory structure in this implementation. The 3 bit M_segment_index DFF is used to track which of the characters should current;y be displayed from the segments DFF.

    reg [7:0] max_addr;
    reg [7:0] max_data;

Here, we're simply defining two registers to keep up with the data and address values to send to the Max7219.

    // Define the Characters used for display
    localparam C0 = 8'h7e;
    localparam C1 = 8'h30;
    localparam C2 = 8'h6d;
    localparam C3 = 8'h79;
    localparam C4 = 8'h33;
    localparam C5 = 8'h5b;
    localparam C6 = 8'h5f;
    localparam C7 = 8'h70;
    localparam C8 = 8'h7f;
    localparam C9 = 8'h7b;
    localparam A = 8'h77;
    localparam B = 8'h1f;
    localparam C = 8'h4e;
    localparam D = 8'h3d;
    localparam E = 8'h4f;
    localparam F = 8'h47;
    localparam O = 8'h1d;
    localparam R = 8'h05;
    localparam MINUS = 8'h40;
    localparam BLANK = 8'h00;

The next bit of code defines that characters that we'll display. These values will light up different parts of the 7-segment display based on the values defined in the Max7219 Datasheet.

Max7219 7-Segment Display Table

Next, we need to set our DFF d values from the corresponding q values.

  always @* begin
      M_state_d = M_state_q;
      M_segments_d = M_segments_q;
      M_segment_index_d = M_segment_index_q;

We always do this (There's another always block that triggers on the posedge of the CLK that will set the q values from the d values. That's at the bottom of the code).

After that, we'll initialize our FIFO to hold the values that we wish to display. In this case, it will be from the localparam values of our characters to spell out "DEADBEEF".

      M_segments_d[56+7-:8] = D;
      M_segments_d[48+7-:8] = E;
      M_segments_d[40+7-:8] = A;
      M_segments_d[32+7-:8] = D;
      M_segments_d[24+7-:8] = B;
      M_segments_d[16+7-:8] = E;
      M_segments_d[8+7-:8] = E;
      M_segments_d[0+7-:8] = F;

We need to define these in descending order so that they will be sent out to the Max7219 in the correct order.

We'll then initialize some variables to 0:

      max_addr = 8'h00;
      max_data = 8'h00;
      M_max_start = 1'h0;

After that, we start looking at our current state. The first state that we'll handle is the IDLE state:

    case (M_state_q)
        IDLE_state: begin
          rst <= 1'b0;
          M_segment_index_d = 1'h0;
          M_state_d = SEND_RESET_state;
        end

When we hit the IDLE state, we'll set reset to low so it is no longer active. We then reset our segment index to 0 and switch to the SEND_SHUTDOWN_state by setting the value of the d register on the state DFF.

    SEND_RESET_state: begin
          M_max_start = 1'h1;
          max_addr = 8'h0c;
          max_data = 8'h01;
          if (M_max_busy != 1'h1) begin
            M_state_d = SEND_MAX_INTENSITY_state;
          end
        end

In the SEND_RESET_state, we switch to normal operation mode by passing 0x01 value to the 0x0C address. Once those values have been transmitted and the Max7219 is no longer busy, we switch to the SEND_MAX_INTENSITY_state.

    SEND_MAX_INTENSITY_state: begin
          M_max_start = 1'h1;
          max_addr = 8'h0a;
          max_data = 8'hFF;
          if (M_max_busy != 1'h1) begin
            M_state_d = SEND_NO_DECODE_state;
          end
        end

This sends 0xFF to the intensity register on the Max7219.

    SEND_NO_DECODE_state: begin
          M_max_start = 1'h1;
          max_addr = 8'h09;
          max_data = 1'h0;
          if (M_max_busy != 1'h1) begin
            M_state_d = SEND_ALL_DIGITS_state;
          end
        end

The next state is SEND_NO_DECODE_state. This state will set the device to use the "No decode" mode, meaning that it will expect that we'll send the actual digit values to it to display, rather than using the built-in Code B font. Once we've set the values, we wait until the Max7219 is no longer busy and then switch to the SEND_ALL_DIGITS_state.

Following the no decode state is the SEND_ALL_DIGITS_state.

    SEND_ALL_DIGITS_state: begin
          M_max_start = 1'h1;
          max_addr = 8'h0b;
          max_data = 8'h07;
          if (M_max_busy != 1'h1) begin
            M_state_d = SEND_WORD_state;
          end
        end

By setting it to 0x07, we are telling the Max7219 to use all 8 digits.

After that, we want to send the word "DEADBEEF". This is where it gets a little tricky, as we have to enumerate the values stored in our 64 bit M_segments register.

    SEND_WORD_state: begin
          if (M_segment_index_q < 4'h8) begin
            M_max_start = 1'h1;
            max_addr = M_segment_index_q + 1'h1;
            max_data = M_segments_q[(M_segment_index_q)*8+7-:8];
            if (M_max_busy != 1'h1) begin
              M_segment_index_d = M_segment_index_q + 1'h1;
            end
          end else begin
            M_segment_index_d = 1'h0;
            M_state_d = HALT_state;
          end
        end

The first thing we do here is check to see if our segment index is less than 8 (0-7). That determines whether or not we have a character to display. Then, we set the start bit to high to signal that we're setting the address and data lines. We set the max_addr to equal the value in our index DFF + 1 (it's 1 based on the Max7219, so we have to account for that). We then set the max_data value to be the value stored in the segments DFF at the current index location with a little math thrown in to account for the location within the 64 bit DFF. Once we've exhausted the available characters (our index == 8), then we simply set our index back to zero and switch to the HALT_state.

Our HALT_state is very easy. We simply set the addr and data values to 0:

    HALT_state: begin
          max_addr = 8'h00;
          max_data = 8'h00;

The last thing we do in this always block is set the values of our registers to the values that we calculated above:

      M_max_addr_in = max_addr;
      M_max_din = max_data;

      PIN_11 <= M_max_cs;
      PIN_12 <= M_max_dout;
      PIN_13 <= M_max_sck;

The next always block in our top module handles the state of our Flip-Flops.

    always @(posedge CLK) begin
      if (rst == 1'b1) begin
        M_segments_q <= 1'h0;
        M_segment_index_q <= 1'h0;
        M_state_q <= 1'h0;
      end else begin
        M_segments_q <= M_segments_d;
        M_segment_index_q <= M_segment_index_d;
        M_state_q <= M_state_d;

      end
    end

The next module that we'll analyze is the max7219 module.

module max7219 (
    input clk,
    input rst,
    input [7:0] addr_in,
    input [7:0] din,
    input start,
    output reg cs,
    output reg dout,
    output reg sck,
    output reg busy
  );

We first set the wires and registers for this module.

Then, we define our states:

  localparam IDLE_state = 2'd0;
  localparam TRANSFER_ADDR_state = 2'd1;
  localparam TRANSFER_DATA_state = 2'd2;

After that, we set some initialization values:

  reg [1:0] M_state_d, M_state_q = IDLE_state;
  wire M_spi_mosi;
  wire M_spi_sck;
  wire [7:0] M_spi_data_out;
  wire M_spi_new_data;
  wire M_spi_busy;
  reg M_spi_start;
  reg [7:0] M_spi_data_in;

We set and initialize the state machine to the IDLE_state.

We then define an SPI module to use to communicate with SPI.

spi_master spi (
    .clk(clk),
    .rst(rst),
    .miso(1'h0),
    .start(M_spi_start),
    .data_in(M_spi_data_in),
    .mosi(M_spi_mosi),
    .sck(M_spi_sck),
    .data_out(M_spi_data_out),
    .new_data(M_spi_new_data),
    .busy(M_spi_busy)
  );

After that, we define a few DFFs to use for some state.

  reg [7:0] M_data_d, M_data_q = 1'h0;
  reg [7:0] M_addr_d, M_addr_q = 1'h0;
  reg M_load_state_d, M_load_state_q = 1'h0;

We need to store that current address and the current data in a flip flop between clock cycles as well as the state of the CS pin (For the MAX7219, serial data at DIN, sent in 16-bit packets, is shifted into the internal 16-bit shift register with each rising edge of CLK regardless of the state of LOAD/CS, but I implemented the CS logic anyway).

We then define some more registers/wires to hold the following values:

  reg [7:0] data_out;

  reg mosi;

  wire [8-1:0] M_count_value;
  reg [1-1:0] M_count_clk;
  reg [1-1:0] M_count_rst;

Data out is what will be sent out to the Max7219 DIN wire. Mosi is used to send the actual data on the rising edge of the spi clock.

We then make a wire for M_count_value, which will contain the current value of the clock we created that is synchronized to the SPI clock. This will let us see how many bits we have transferred over mosi.

M_count_clk and M_count_rst are support registers for our counter.

We will then instantiate our bit counter:

counter count (
    .clk(M_count_clk),
    .rst(M_count_rst),
    .value(M_count_value)
  );

Now we will begin our always block that will continuously run.

always @* begin
    M_state_d = M_state_q;
    M_load_state_d = M_load_state_q;
    M_data_d = M_data_q;
    M_addr_d = M_addr_q;

The first thing we do is to set the state of our DFFs for state, CS, data, and address.

Then, we'll initialize some values:

    sck = M_spi_sck;
    M_count_clk = M_spi_sck;
    M_count_rst = 1'h0;
    data_out = 8'h00;
    M_spi_start = 1'h0;
    mosi = 1'h0;
    busy = M_state_q != IDLE_state;
    dout = 1'h0;

For our busy state output, we'll check to see if we're in the IDLE state. If not, then busy will be high.

Our first state is the IDLE_state.

case (M_state_q)
      IDLE_state: begin
        M_load_state_d = 1'h1;
        if (start) begin
          M_addr_d = addr_in;
          M_data_d = din;
          M_count_rst = 1'h1;
          M_load_state_d = 1'h0;
          M_state_d = TRANSFER_ADDR_state;
        end
      end

In the idle state, we wait for the start signal and then set the address and data values for the Max7219. We then move to the TRANSFER_ADDR_state.

    TRANSFER_ADDR_state: begin
        M_spi_start = 1'h1;
        data_out = M_addr_q;
        dout = M_spi_mosi;
        if (M_count_value == 4'h8) begin
          M_state_d = TRANSFER_DATA_state;
        end
      end

In the TRANSFER_ADDR_state, we set the spi start to high to signal that we have data, then set the addr values from its respective DFF. We then set the value of dout to M_spi_mosi. We wait until we've sent 8 bits and then switch to the TRANSFER_DATA_state.

    TRANSFER_DATA_state: begin
        M_spi_start = 1'h1;
        data_out = M_data_q;
        dout = M_spi_mosi;
        if (M_count_value == 5'h10) begin
          M_load_state_d = 1'h1;
          M_state_d = IDLE_state;
        end
      end

In the TRANSFER_DATA_state, we set the spi start to high to signal that we have data, then set the value of data_out to the value of the M_data DFF. We then set the value of dout to the value of M_spi_mosi and wait for 8 more bits. Then we set the CS/LOAD bit to high and switch back to the IDLE_state. This will set busy to low and signal the consumer that we're ready for more data.

After the case statement, we set the data values to use for our CS/LOAD state and the SPI data in value.

    cs = M_load_state_q;
    M_spi_data_in = data_out;

Our final always block runs on the posedge of the CLK and manages our DFF state.

always @(posedge clk) begin
    if (rst == 1'b1) begin
      M_data_q <= 1'h0;
      M_addr_q <= 1'h0;
      M_load_state_q <= 1'h0;
      M_state_q <= 1'h0;
    end else begin
      M_data_q <= M_data_d;
      M_addr_q <= M_addr_d;
      M_load_state_q <= M_load_state_d;
      M_state_q <= M_state_d;
    end
  end

This was a fun project. I spent an entire day trying to get it to work with the SPI master from nandland, but after a whole day, I was unable to get it working. So I went back to the transpiled source from my Lucid code from before. It worked great.

The TinyFPGA is a really fun FPGA and it uses more modern tools. I'm saving up for another FPGA that can use the new Vivado suite from Xilinx, but right now, $38 was all I was willing to spend.