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).

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.

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.