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

I recently ordered a few LED display modules based on the MAX7219 chip. When they arrived, I immediately began putting together projects to use them. The first project was to wire one up to my Raspberry Pi and use C to bitbang some numbers to display. I wrote about this in a previous post.
The Lucid HDL "code" can be found on GitHub - FPGA for Fun on GitHub
Blog posts in this series:
- Part 1 (This post)
- Part 2
This time, I'd like to talk about how I wired the MAX7219 LED display module up to my Mojo FPGA.
The Module
Here is a module on eBay similar to the ones that I purchased:
MAX7219 8-Digit LED Display SPI Control Module Digital Tube For Arduino
If this link is dead (or the sale has ended), search for the above text on eBay and you should find one.
Wiring the MAX7219 to the Mojo
First, I started by wiring the Max 7219 to the Mojo.
I wired RAW from the Mojo to VCC, GND to GND, Pin 50 to Data In (DIN), Pin 51 to CLK, and Pin 40 to CS (Load).
Timing Considerations
The timing diagram for the MAX7219 module is fairly straight forward. It only covers four of the interface pins. We won’t be using the data out pin (DOUT), so we can ignore that one.
We’re interested in the behavior of the Load, Clock, and the data in pins and how they interact.
Since we are working with an FPGA clock that runs too fast for our display module, we’ll need to use an SPI component to manage our communication and synchronize with that clock instead of the FPGA. In addition, we’ll use a d-flip-flop to manage the state of the load pin between clock cycles.
After creating a new Mojo project, I added the SPI Master component to it (it's one of the available components in the Mojo IDE).
The load pin must be low to clock data in, so that’s our first step when sending data. Once the load pin is low, we start our clock pulses to the CLK pin. As you can see in the timing diagram, we need to send one bit of data at roughly the same time as our clock pulse. The Max 7219 uses a 16-bit word for each message that it processes, so we need to keep up with how many clock cycles have passed since we started (we'll use a d-flip-flop to do this in the implementation).
Once we get to 16 clock pulses on the CLK pin, the data is then latched into either the digit or control registers by pulsing LOAD/CS.
LOAD/CS must go high concurrently with or after the 16th rising clock edge, but before the next rising clock edge or data will be lost. This is very important to remember and was the source of a great deal of trouble for me when first building this project. I spent a few hours debugging this (I don't yet have the wave capture utility setup in my Mojo IDE and the logic probe that I ordered hasn't arrived from Hong Kong, yet).
Finite State Machines
I've found that planning my logic out in terms of finite state machines really helps me get my head around the problem. The Max7219 component is driven by three main states: Idle, Transfer Address, and Transfer Data. It requires a little setup to synchronize the clocks and manage the state of the load pin.
MAX7219 Component FSM
We need to make an assumption about the overall state of the component; there is an SPI Clock counter with its clock input set to the SCK output of the SPI component.
IDLE
- Set Load (CS) to 1
- If Start == 1
- Save incoming address and data to memory
- Reset the SPI clock counter (helps manage two clock domains)
- Set Load to 0 (tells the MAX7219 to start listening)
- Transition to next state
TRANSFER_ADDR
- Start the SPI component
- Send the address we have in memory to the SPI component’s input
- Set the data out to the value of the SPI.MOSI
- If the SPI clock counter == 8
- Transfer to the next state.
TRANSFER_DATA
- Start the SPI component
- Send the data we have in memory to the SPI component’s input
- Set the data out to the value of the SPI.MOSI
- If the SPI clock counter == 16
- Latch the data by pulsing the load pin high
- Reset the SPI clock counter
- Transition back to the IDLE state to await more input
Register Map
There are 13 registers on the Max7219 module including a no-op.
For example, if I wanted to turn on all of the segments on the display (test mode), I would send 0x0F as the first 8 bits of the message (the address), and then 0x01 as the next 8 bits (the data). I would then pulse the Load pin to latch the message into the Max7219.
Let’s go over the order of operations that I’ll use in my test component:
- Shutdown = 0
- Shutdown = 1
- Decode Mode = 0 (display segments instead of BCD data)
- Scan Limit = 7 (use all 8 digits)
- Send 8 characters
- Halt (send all zeros - no-op)
Main Component (Mojo Top) FSM
The main component is a little more complicated because it is the one that drives the MAX7219 component. It keeps up with the characters that are being sent, as well as the states required to initialize the MAX7219 module.
This particular component was written as an initial test to see if I could get data to display. It does not handle any sort of number conversion or true display driver functionality. It merely outputs “DEADBEEF” to the display. The segment index is simply a d-flip-flop that I use to keep up with which segment we're sending the data for. Since there is more than one segment, we have to send them one at a time. We use the index to determine which one to send.
IDLE
- Reset segment index to 0
- Transition to next state
SEND_SHUTDOWN
- Start the Max7219 component
- Set the max7219 address to 0C (Shutdown)
- Set the max7219 data to 00
- When max is complete, transition to next state
Remember that all actions shown are simultaneous and happen for each clock cycle. That’s why we have to wait until the Max7219 component has completed sending its message and is no longer busy before moving on to the next state. Otherwise, we’d overload the module and nothing would be displayed. It’s a careful balancing act of clock, data, and load.
SEND_RESET
- Start the Max7219 component
- Set the max7219 address to 0C (Shutdown)
- Set the max7219 data to 01 (Normal operation)
- When max is complete, transition to next state
SEND_NO_DECODE
- Start the Max7219 component
- Set the max7219 address to 09 (Decode Mode)
- Set the max7219 data to 00
- When max is complete, transition to next state
SEND_ALL_DIGITS
- Start the Max7219 component
- Set the max7219 address to 0B (Scan Limit)
- Set the max7219 data to 07 (use all 8 digits)
- When max is complete, transition to next state
SEND_WORD
- Start the Max7219 component
- If segment index < 8
- Set the max7219 address to the segment index +1 (to account for a zero-based index and a 1 based segment count)
- Set the max7219 data to segment array with the index of segment index (segments.q[segment_index.q])
- When max is complete, increment the segment index by 1
- If segment index = 8
- Reset segment index to 0
- Transition to the next state (HALT)
HALT
- Set the max7219 address to 0
- Set the max7219 data to 0
Segment Lines
Programming the 7-segments is done by sending an 8-bit number representing the state of the segments in order from A-G with the decimal point being the most significant bit.
I coded the 16 hexadecimal digits and added a few characters so that the word “Error” could be displayed, as well as a minus sign and a blank space.
0 = 01111110
1 = 00110000
2 = 01101101
3 = 01111001
4 = 00110011
5 = 01011011
6 = 01011111
7 = 01110000
8 = 01111111
9 = 01111011
A = 01110111
B = 00011111
C = 01001110
D = 00111101
E = 01001111
F = 01000111
O = 00011101
R = 00000101
MINUS = 01000000
BLANK = 00000000
That's the basic state workflow for the module on the FPGA. I had a great time figuring this out and I hope this has helped you understand how you might wire a MAX7219 module to your FPGA.
In the next post, we'll take a look at the implementation in the Lucid Hardware Description Language. Lucid is the HDL used by the Mojo. It is a simpler HDL than Verilog or VHDL, but transpiles to Verilog for synthesis onto the hardware.
Video:
Read Part 2