Communications

Maxim's MAX7221 LED Driver chips communicate with the host computer via the SPI (Synchronous Peripheral Interface) bus. SPI based systems normally have a single bus controller (in this case a Raspberry Pi) and one or more target devices (in this case, LED Driver chips). It is normally a short distance bus and transfer rates range from a few hundred Kbits/second up to 25Mbits/second. Most uses of it are at the lower range of speeds. A SPI bus has 4 primary signals and a ground. These signals are:

CS  Chip Select or Chip Enable, a usually active low signal that indicates the beginning and end of a SPI transaction. This signal comes from the bus controller.

SCLK Serial Clock, this signal synchronizes the data transfer within the transaction. This signal comes from the bus controller.

MOSI Master Out Slave In, this is the data out of the bus controller, destined to the target device(s). On target devices, this may be labelled Data In. This signal comes from the bus controller.

MISO Master In Slave Out, this is the data from the target devices to the bus controller. On target devices, this may be labelled Data Out. This signal comes from the target device.

One method of creating a bus with SPI communications is via "daisy chaining".  Daisy chaining allows connecting multiple target devices in a sequence. Not all SPI devices support the daisy chain method of interconnecting targets, but the MAX7221 devices do. In a daisy chained system, the CS and SCLK signals are common to all devices on the bus. The MOSI signal from the controller is connected to the DataIn signal of the first target device in the chain. The DataOut signal of the first device becomes the InterDigit_Data_1 signal and is connected to the Data In signal of the second target device in the chain. This connection of target DataOut to the next target DataIn signals is repeated for the rest of the devices in the chain as InterDigit_Data_N. In some systems, the last MISO/DataOut signal may be connected back to the bus controller, but in this system, it must not be. The Raspberry Pi runs on 3.3V and the MAX7221 chips run on 5.0V. The last target devices MISO/DataOut pin will be a 5.0V signal that would damage the 3.3V MISO/DataIn pin on the Raspberry Pi. There is nothing useful to read back in any event.

Block Diagram of a Daisy Chained SPI Interface.
Block Diagram of a Daisy Chained SPI Interface.

Pin numbers on the Raspberry Pi 40 Pin Connector are shown. Pin numbers on the MAX7221/7219 chips are shown for reference. In practice, there will be connectors on the edge of the board for each display module, but the layout and pin numbering of the display modules are specific to the design of the modules and will vary depending on the module source.

3 Display Modules forming a Daisy Chain SPI Bus.
3 Display Modules forming a Daisy Chain SPI Bus.

In the picture above, the Raspberry Pi is connected to the cable at the right end of the 3 modules. Note the In and Out labels on the boards below the connector.

                 Details of a SPI Transaction

The following description of a SPI transaction refers to the most common configuration called Mode 0 that is used by the MAX7221 and should be accepted by the MAX7219 chips. Other Mode configurations result in different timing relationships between the SCLK and the MOSI signals and the CS signal may be inverted from what is described here.

Scope Shot of a Single Byte SPI Transaction With a Data Value of 0xA3
A scope shot of a single byte SPI transaction with a data value of 0xA3. Ch1 = CS, Ch2 = SCLK, Ch3 = MOSI

In the scope shot above, the picture starts with the Chip Select (CS) signal high and the Serial Clock (SCLK) signal is inactive. The value of the MOSI signal does not matter while the CS signal is high and the SCLK inactive. The falling edge of the CS signal is the start of the SPI transaction. This transaction is sending the hex value of A3 or binary 1010 0011. The Most Significant Bit (bit 7) is applied to the MOSI signal some time after the falling edge of the CS signal but before the first rising edge of the Serial Clock (SCLK). On the rising edge of the SCLK signal, the data value (1) on the MOSI pin is captured in the target device. Around the falling edge of the SCLK signal, the next data bit (bit 6) value is presented on the MOSI pin. The MOSI data value (0) is captured again on the rising edge of the SCLK signal. This sequence repeats until all of the bits are transferred, then the CS signal goes high, ending this transaction.

More bytes can be sent in a transaction as shown below.

A Two Byte Transaction. Data values: 0x07, 0xA3 . Ch1 = CS, Ch2 = SCLK, Ch3 = MOSI
A Two Byte Transaction. Data values: 0x07, 0xA3 . Ch1 = CS, Ch2 = SCLK, Ch3 = MOSI

The time scale on this scope shot is compressed to show both bytes in the transaction. The 0x07 value can be seen in the first 8 cycle burst of clock signals and the 0xA3 value can be seen in the second 8 cycle burst of clock signals. There is generally a short delay between the bytes on a SPI transaction, but it is not required.

SPI transactions can be extended to as many bytes as required. For the MAX7221 chips, each chip gets 2 bytes from a transaction. With 6 modules daisy chained together, each transaction will contain 12 bytes of data.

A SPI transaction in a daisy chained bus results in the first byte of data that is sent out from the controller landing in the last target device in the chain. The photograph of the 3 modules chained together is viewed from the back side. If you flip the chain over, the first module with the connection to the Raspberry Pi (bus controller) will be on the left end. In this system, the end device in the chain is the display module on the right side, so the outgoing data must be sequenced from right to left. It would have been a little bit more convenient if the SPI chain ran the other direction, but (putting on my obnoxious hardware guy hat), it is easily fixed in the software :). Later designs have this detail fixed.

From the Maxim data sheet, the operation of the SPI interface is the most visible difference between the 7221 and the 7219 chips. Both chips behave similarly at the rising edge of the CS signal. The Raspberry PI SPI transactions will work with either chip.

                        Driver Software

The driver software bridges the gap between the top level application software and the operating system SPI bus calls to output data. It is organized to be easily ported across multiple environments. In order to simplify porting, the low level device driver code that handles the controller SPI interface is separated out into it's own file. The driver software is written in ANSII C and compiles with GCC.

Viewed from the top level, the main task of the driver software is to translate ASCII data into command sequences to the MAX7221 chips that result in the correct characters being displayed on the LED matrix modules. There are also functions to perform some simple initialization of the MAX7221 chips and control of the brightnesss of the LEDs. There are also functions to simplify scrolling the data across the display.

Functions that control scrolling across the display are the interface to the application level code. Passing a C string into one of the two scrolling functions results the characters in the string getting shifted into an internal array of characters whose length matches the number of LED modules in the daisy chain. If there are 6 LED modules, up to 6 characters from the input string will get shifted into the internal array.  Passing the string into the insert from left scroll function shifts up to 6 characters into the left end of the internal array. Passing a string with one character in it into the insert from left scroll function results in one character getting shifted into the left end of the internal array. The internal array is maintained as a shadow storage between calls of the scroll functions. This internal array is the method that character data gets passed into the function that translates the ASCII data to control commands to the MAX7221 chips.

The MAX7221 chip is capable of driving an 8x8 common cathode LED matrix. There are 8 registers with 8 bits each in the MAX7221 chip that control which of the LEDs are on or off. These on/off registers are called "Digit 0" through "Digit 7" in the Maxim data sheet because these chips were originally designed to drive a series of 7 segment displays instead of a matrix display. There is a BCD to 7 segment decode option in the chip that is not needed in this application. The decode option defaults to being disabled, so it can be ignored from here on. The way that these chips drive a matrix display results in each "digit" register controlling one vertical column in a matrix display. The displays that I used have a 5x8 matrix with each cathode in a vertical column  tied together.  Each on/off register controls a vertical column of 8 LEDs and since there are only 5 columns in the LED matrix, only 5 of those registers are used. The least significant bit in each register controls the top LED in the corresponding column. Setting bit 0 of the Digit0 register turns on the LED in the upper left corner of the display.

Each of the MAX7221 chips gets a 2 byte command to set the value of each digit register. The first byte is the register address and the second byte is the value to deposit in the digit register. The data sheet talks about a 16 bit value, but it is easier to ensure the proper order of address and data if the data is kept in 8 bit arrays.

A two dimensional array is used to translate ASCII input values to on/off register values for the MAX7221 chips. There are 96 "printable" ASCII characters and each character requires 5 columns of on/off data so the array is sized as 96x5 of 8 bit values. This array is termed the character generator array. The contents of the character generator array are what control the appearance of each character. In the interest of allowing other character sets than US English,  the character generator is in a separate file.

The send_buffer function accepts the internal array from the scroll functions and assembles a series of 5 SPI transactions, each with a register command to specify which "digit" register is written and the  on/off data for that column to all of the LED modules. Every LED module gets completely updated from the internal array contents each time the send_buffer() function is called.

The init_matrix_display_system function opens the SPI port through a function call, then sends the commands required for the MAX7221 chips to operate. First, the MAX7221 chips get enabled. No display activity can happen until they are enabled. Next, the 7 segment decode is set to disabled. This step is probably unnecessary, but I did it for completeness. The Scan Limit registers tell the MAX7221 chips how to drive the LED matrix. In this case they get set to drive 5 columns. When the MAX7221 chips power up, they often power up with all of the LEDs on. Before we turn up the brightness on the LEDs, we set the on/off values to all of the LEDs to off. Finally, the brightness for the LEDs gets set to a mid-range values.

In order to test the lower level code, a simple top level function starts up the display system and accepts inputs from the user for display or limited changes to operation and calls the functions in the MatrixDriver_MAX7221 file. This is a very simplistic test driver.

                                    The Code

I started messing with these LED matrix display modules a few months ago. The first boards that I designed to drive them used a different driver chip (IS32FL3738) that uses an I2C interface. I wrote the character generator GUI tool and a first pass on the code to drive the modules. Initial development was done on a PC using MINGW and a Total Phase Systems Aardvark for the I2C interface. The early modules used two of the IS32FL3738 chips, one for the red LED matrix and one for the green matrix. They were expensive to build. I built a GPS disciplined clock using these modules and the code written for them. The clock ran the driver software on an Atmel SAMD21 processor and required very little modification of the driver code to use.

A little bit later, I found another driver chip IS31FL3730) that is capable of drivig both the red and green matrices with one chip. I designed and built a few boards using these chips, with a strong leaning toward making the modules less expensive to build. These chips also used the I2C bus to drive. I ported the original code to drive the new chips. Only the lowest level functions changed.

Around the time I was finishing up those boards, I had a conversation with someone who wanted to use the Maxim MAX7219 chips to drive some of these displays. I liked the MAX7221 parts a little better, so I designed and built some boards to play with these chips. The MAX7221 chips use the SPI interface which solved some system design problems that I had with the I2C chips. They are also very friendly to build boards with because they are in an old school DIP package with 2.54mm lead pitch. Also, they require only 3 passive components to support them. Unfortunately, they are expensive. In an effort to reduce the module costs, I opted to only support one of the matrices on the LED module which simplified the driver code quite a bit as the color management code was almost as big as the character management code. Starting from the IS31FL3730 driver code, I ported that over to operate on the SPI interfaced MAX7221 chips. Development was done on the PC with the Aardvark providing the SPI interface. Changing over to the SPI interface required re-thinking the design of the low level code. Once the code was operating OK on the PC, I ported it to run on the Raspberry Pi. If you are digging through the source code and find references to I2C or varks in the comments, that is how they got there... If you are interested in more details on the development of these driver boards including schematics, see my project #LED Matrix Display .

The lowest level code that deals with the SPI interface directly is isolated in files SPI.c and SPI.h for the generalized SPI interface stuff. The code in these files is a wrapper for the Linux specific code. The Linux specific code is contained in the files raspi_spi_io.c and raspi_spi_io.h. Code in the raspi_spi_io files was written from the spidevtest.c file by Anton Vorontsov and MonteVista Software along with reading through the comments in the include file:  linux/spi/spidev.h. I don't claim great understanding of this code, I just messed with it until it worked for what I wanted. The code to deal with the gritty parts of the SPI interface will not be discussed in this article.

As mentioned above, the driver software is written in ANSII C. Each functional area is contained in a .c file. Macro definitions and function prototypes for each of the .c files live in an accompanying .h file.  This code is released under the BSD open source license. The BSD license text appears in each of the source code files, but will not be repeated in the explanation for brevity.

Extensive use of macro (#define) statements contributes to the readability of the code. The defines that are only used in a particular .c file will appear at the top of that .c file, but defines that are used elswhere in the code go into the .h files. An example of a define that is used widely is the number of display modules that are in the chain. The macro definition #define NUM_DISPLAY_MODULES is at the top of the file MatrixDriver_MAX7221.h. This macro is used extensively in the code to initialize the modules, build the command sequences to control the modules and handle the scrolling. It is also used at the top level to handle data sizing. The macro definitions for the MAX7221 register numbers are only used in the MatrixDriver_MAX7221.c file, so they are defined near the top of that file.

First, the character generator code will be discussed. The chargen_5x8.h file contains the defines required to build and use the character generator array. The extern declaration for the character generator array is in this file as well.

/*---------------------------------------------------------------------------
* Filename: CharGen_5x8.h
* $Revision: 1.2 $
* Author: Bob Harbour
* $Date: 2020-11-26 20:59:58 $
*
*
* Revisions:
*------------------------------------------------------------------------*/

//====================================================================
// Defines
//====================================================================
#define MATRIX_BYTES_PER_CHAR (5)    // Matrix bytes of data per character
#define GEN_NUM_CHARS  (96)    // number of characters in generator table
#define GEN_FIRST_PRINTABLE (0x20)   // first printable ASCII character
//====================================================================
// Typedefs
//====================================================================

//=====================================================================
// Externals
//=====================================================================
// A character with just one dot in the center. Sub for non-printable characters
extern const uint8_t center_dot_char [MATRIX_BYTES_PER_CHAR];

// Table to map ASCII character to columns of pixels.
extern const uint8_t cgen_matrix [GEN_NUM_CHARS] [MATRIX_BYTES_PER_CHAR];

MATRIX_BYTES_PER_CHAR is the number of columns in the LED matrix and the number of digit registers that need to be set to display a character.

GEN_NUM_CHARS is the number of printable ASCII characters and defines the length of the character generator array. The printable ASCII characters range from 0x20 (space character) to 0x7F (DEL character).

GEN_FIRST_PRINTABLE defines the beginning character of the table. In order to look up a character, this value is subtracted from each character value to get the index into the character generator array.

In the Externals section, both arrays are declared as const because they will not be modified in program operation. In an embedded system, this would result in this data staying in non-volatile memory instead of using up limited RAM space.

The center_dot_char is a special character that is used to replace non-printable characters that appear in the input string. This "character" is not in the normal character generator array because this is the character that is used when input data to the send_buffer function() would result in an illegal array subscript into the character generator array.

The two dimensional array cgen_matrix contains the column data required to display each character. In C, a two dimensional array can be thought of as an array of arrays. The first subscript is used to look up data for a particular character. The second subscript is used to get the column values for the MAX7221 chips.

/*---------------------------------------------------------------------------
* Filename: CharacterGenerator_5x8.c
* $Revision: 1.2 $
* Author: Bob Harbour
* $Date: 2020-11-26 20:59:58 $
*------------------------------------------------------------------------*/
/=====================================================================
// INCLUDES
//====================================================================
#include <stdint.h>
#include "chargen_5x8.h"
//====================================================================
// Defines


//====================================================================
//====================================================================
// Globals
//====================================================================
// A character with just one dot in the middle. Sub for unprintable
// characters.
const uint8_t center_dot_char[MATRIX_BYTES_PER_CHAR] = 
                              {0x00,0x00,0x08,0x00,0x00};

// Map ASCII characters to columns of pixels suitable to load into an
// MAX7221 or MAX7219 LED Driver chip driving a LiteOn LTP2588AA display.
// Character Generator file: Driver/CharacterGenerator.c

//----------------  Matrix 1 character table  -------------------
const uint8_t cgen_matrix [GEN_NUM_CHARS] [MATRIX_BYTES_PER_CHAR] = {
{0x00,0x00,0x00,0x00,0x00},   // 0x20
{0x00,0x5F,0x00,0x00,0x00},   // 0x21   !
{0x00,0x03,0x00,0x03,0x00},   // 0x22   "
{0x14,0x7F,0x14,0x7F,0x14},   // 0x23   #
{0x24,0x2A,0x7F,0x2A,0x12},   // 0x24   $
{0x23,0x13,0x08,0x64,0x62},   // 0x25   %
{0x36,0x49,0x56,0x20,0x50},   // 0x26   &
{0x00,0x0B,0x07,0x00,0x00},   // 0x27   '
{0x00,0x00,0x3E,0x41,0x00},   // 0x28   (
{0x00,0x41,0x3E,0x00,0x00},   // 0x29   )
{0x08,0x2A,0x1C,0x2A,0x08},   // 0x2A   *
{0x08,0x08,0x3E,0x08,0x08},   // 0x2B   +
{0x00,0xB0,0x70,0x00,0x00},   // 0x2C   ,
{0x08,0x08,0x08,0x08,0x08},   // 0x2D   -
{0x00,0x60,0x60,0x00,0x00},   // 0x2E   .
{0x20,0x10,0x08,0x04,0x02},   // 0x2F   /
{0x3E,0x51,0x49,0x45,0x3E},   // 0x30   0
{0x00,0x42,0x7F,0x40,0x00},   // 0x31   1
{0x62,0x51,0x49,0x49,0x46},   // 0x32   2
{0x22,0x41,0x49,0x49,0x36},   // 0x33   3

   Skip a bunch of entries

{0x2A,0x55,0x2A,0x55,0x2A}}; // 0x7F   ^?

The first group of punctuation marks and a few number characters are shown from the chargen_5x8.c file to show the format used to initialize the data in a constant, 2 dimensional array. 

The character generator data was created via a GUI tool that I wrote (Labview) that allows selecting which LEDs are lit up for each character and then storing that data in a text file that can get massaged into the initialized array declaration seen above.

In the file MatrixDriver_MAX7221,h, a few constants are defined and the prototypes for the functions in the MatrixDriver_MAX7221.c file appear at the end.

/*---------------------------------------------------------------------------
* Filename: MatrixDriver_MAX7221.h
* $Revision: 1.2 $
* Author: Bob Harbour
* $Date: 2020-11-26 20:59:58 $
*
*
* Revisions:
*------------------------------------------------------------------------*/
//====================================================================
// Defines
//====================================================================
#define NUM_DISPLAY_MODULES (3)   // Number of display modules on the I2C bus

#define SPI_TXB_LEN ((NUM_DISPLAY_MODULES+1)*2)  // size of SPI com buffers

#define MX_DEFAULT_BRIGHTNESS (0x0A)   // MAX7221 Default brightness value
#define MX_MIN_BRIGHTNESS (0)          // MAX7221 Minimum brightness value
#define MX_MAX_BRIGHTNESS (0x0F)       // MAX7221 Maximum brightness value

//====================================================================
// Typedefs
//====================================================================

//====================================================================
// Externals
//====================================================================

//====================================================================
// Function Prototypes
//====================================================================
int init_matrix_display_system (void);
int blank_display (void);
int set_display_brightness (uint8_t brightness);
int send_buffer (char *inbuf);
int shutdown_matrix_display_system (int display_off);
int insert_display_chars_from_left (char *instring, int lenstring, int *trunc_boundary);
int insert_display_chars_from_right (char *instring, int lenstring, int *trunc_boundary);

First, the function send_buffer() will be covered. This function accepts the internal character array from the insert_display_chars functions and calls the  write_spi_bus() function to send the data to the chain of MAX7221 chips. The internal character array is NOT a C string, in that it does not have the end of string null value that a string would. It is just an array of character that is NUM_DISPLAY_MODULES long.

After the function definition and variable declarations, there is a stretch of code that is enclosed in the ifdef LEFT_TO_RIGHT_SPI_CHAIN conditional compile operator. Remember the obnoxious hardware guy comment? This is where that SPI chain direction issue gets fixed or not, depending on whether the hardware has the problem fixed in it.

There are 3 things going on in the code between the ifdef and the corresponding else statement. The incoming character array is translated to an array of pointers to the LED column data for each character in the array.  In the process of this translation, the incoming character is checked to see if it is a printable character. If is is not a printable character, a pointer to the center_dot_char is substituted for it. The third thing that is done, is the order of the character sequence is reversed. Code in the else clause for the ifdef is similar except is is not reversing the order of the character sequence.

After the endif statement, is the code that assembles the sequences of commands that control the LED columns. The term "command" is used interchangeably for what the data sheet calls Register Addresses in table 2 in the data sheet. Commands and data are assembled into a transmit buffer array called tx_buf.  Pointers are used in this process because it is faster to access data at a pointer than data from an array subscript value. The transmit buffer will get the command and the data value for one column of data out of the character generator array for each character and this data gets sent out the SPI interface with the write_spi_bus() function call.

For example, with display chain containing 3 modules configured as LEFT_TO_RIGHT_SPI_CHAIN and an incoming character array value of ABC, the sequences would look like:

CMD_DIGIT_0  0x3C  CMD_DIGIT_0  0x7F  CMD_DIGIT_0  0x7C

send to chain

MX_CMD_DIGIT_1  0x41  MX_CMD_DIGIT_1  0x49  MX_CMD_DIGIT_1  0x0A

send to chain

MX_CMD_DIGIT_2  0x41  MX_CMD_DIGIT_2  0x49  MX_CMD_DIGIT_2  0x09

send to chain

MX_CMD_DIGIT_3  0x41  MX_CMD_DIGIT_3  0x49  MX_CMD_DIGIT_3  0x0A

send to chain

MX_CMD_DIGIT_4  0x22  MX_CMD_DIGIT_4  0x36  MX_CMD_DIGIT_4  0x7C

send to chain

This is done with 2 nested loops. The outer loop runs from column 0 to column 4, and counts each column and sends it. The inner loop runs from 0 to NUM_DISPLAY_MODULES and fills in the commands and data going to each module.

This code takes advantage of the fact that the register addresses for the Digit_ registers are sequential. Prior to entering the outer loop, the variable cmdval gets initialized to the address of the Digit_0 register. The column count is also set to 0.

At the top of the outer loop, the transmit buffer pointer outptr gets reset to the beginnng of the transmit buffer and the module number modnum gets reset to 0.

Inside the inner loop, the register address for the column being assembled gets copied into the transmit buffer and the transmit buffer pointer gets incremented to the next free space. The column data from the character generator array for the character destined to the module being assembled gets copied into the transmit buffer. The pointer to the character generator byte gets incremented to the next column. The module number gets incremented to work on the next module in the chain. The inner loop iterates until the command and column data for one column for all of the modules is assembled into the transmit buffer.

After the inner loop exits, the contents of the transmit buffer get sent to the display modules with the write_spi_bus() call. The register address for the Digit_ registers is incremented and the column count is incremented. The outer loop iterates until all 5 sets of the column data have been assembled and sent to the displays,

The hardware design of the MAX7221 chips dictates that each chip can only get a single digit register updated for each SPI transaction. This adds to the complexity of the driver software.

//===================================================================
// send_buffer ()
//   Send the pixel data for all characters in the buffer to the displays.
//
// written 10/25/2020 Bob Harbour
//===================================================================
int send_buffer (char *inbuf)
{
  int retval, col_count, modnum, cmdval;
  char *inptr;
  uint8_t *outptr;

  retval = OK;

#ifdef LEFT_TO_RIGHT_SPI_CHAIN
  // The first iteration of the displays has the spi chain needing the end
  // character in the first slot of the tx buffer. Reverse the inbuf sequence
  // here as well as providing pointers to the pixel arrays.
  modnum = 0;

  inptr = &inbuf [NUM_DISPLAY_MODULES-1];
  while (modnum < NUM_DISPLAY_MODULES)
  {
    // replace non-printable characters with a dot in the center of the display
    if (*inptr < GEN_FIRST_PRINTABLE)
      pixelptrs[modnum] = (uint8_t*)¢er_dot_char[0];
    else
      pixelptrs[modnum] = (uint8_t*)&(cgen_matrix [(unsigned int)*inptr - GEN_FIRST_PRINTABLE] [0]);

    inptr--;
    modnum += 1;
  }
#else
  // Later versions of the displays run the spi chain right to left, so the
  // first byte out of the master winds up in the leftmost character controller.
  modnum = 0;
  inptr = &inbuf[0];

  while (modnum < NUM_DISPLAY_MODULES)
  {
    // replace non-printable characters with a dot in the center of the display
    if (*inptr < GEN_FIRST_PRINTABLE)
      pixelptrs[modnum] = (uint8_t*)¢er_dot_char[0];
    else
      pixelptrs[modnum] = (uint8_t*)&(cgen_matrix [(unsigned int)*inptr - GEN_FIRST_PRINTABLE] [0]);
    inptr++;
    modnum += 1;
  }
#endif

  // assemble the tx buffer for each column for each display driver, one column 
  // at a time
  cmdval = MX_CMD_DIGIT_0;
  outptr = &tx_buf[0];
  col_count = 0;

  // assemble and send all columns of pixel data for each character in buffer
  while ((retval == OK) && (col_count < MATRIX_BYTES_PER_CHAR))
  {
    // fill the output buffer with a command and a column of pixel data for
    // each driver chip.
    modnum = 0;
    outptr = &tx_buf[0];
    while ((retval == OK) && (modnum < NUM_DISPLAY_MODULES))
    {
      *(outptr++) = cmdval;
      *(outptr++) = *pixelptrs[modnum];
      pixelptrs[modnum] += 1;
      modnum += 1;
    }
    // send the data to the displays
    retval = write_spi_bus (&spi_handle, (2*NUM_DISPLAY_MODULES), tx_buf);

    col_count += 1;
    cmdval    += 1;
  }

  return (retval);
}



The blank_display() function is pretty simple. It assembles command strings like the send_buffer() function except all of the data values are 0.

/====================================================================
// blank_display ()
//   Turn off all the LEDs in all of the display drivers
//
// written 10/24/2020 Bob Harbour
//===================================================================
int blank_display (void)
{
  int retval, module_count, col_count;
  uint8_t cmd_val, *u8ptr;

  retval = OK;
  col_count = 0;
  cmd_val = MX_CMD_DIGIT_0;

  while ((retval == OK) && (col_count < NUM_DISPLAY_COLS))
  {
    module_count = 0;
    u8ptr = &(tx_buf[0]);
    while ((retval == OK) && (module_count < NUM_DISPLAY_MODULES))
    {
      *(u8ptr++) = cmd_val;
      *(u8ptr++) = 0;
      module_count += 1;
    }
    retval = write_spi_bus (&spi_handle, (2*NUM_DISPLAY_MODULES), tx_buf);

    col_count += 1;
    cmd_val += 1;
  }
  return (retval);
}

 The set_display_brightness() function is even simpler. It accepts a brightness value of 0 to 0x0f and verifies that the incoming value is legal, then assembles a single SPI transaction with the command for the intensity register and the requested brightness value to each module in the chain. All of the modules get the same brightness value, so the order does not matter.

For example, in the example with 3 display modules and a brightness value of 0x04, the outgoing command would look like:

MX_CMD_INTENSITY  0x4   MX_CMD_INTENSITY  0x4  MX_CMD_INTENSITY  0x4

send to chain

//===================================================================
// set_display_brightness ()
//   Set the current control for all modules. Range 0-0xF. Values greater
// than 0xE will result in Max current settin (same as 0xE).
//
// written 10/24/2020 Bob Harbour
//==================================================================
int set_display_brightness (uint8_t brightness)
{
  int retval, module_count;
  uint8_t *u8ptr;

  retval = OK;

  // map the brightness level to the current set value
  if (brightness > 0x0F)
    brightness = MX_DEFAULT_BRIGHTNESS;

  // build the brightness command
  module_count = 0;
  u8ptr = &(tx_buf[0]);
  while ((module_count < NUM_DISPLAY_MODULES) && (retval == OK))
  {
    *(u8ptr++) = MX_CMD_INTENSITY;
    *(u8ptr++) = brightness;
    module_count += 1;
  }
  retval = write_spi_bus (&spi_handle, (2*NUM_DISPLAY_MODULES), tx_buf);

  return (retval);
}

Initialization is the last of the direct hardware interface functions in MatrixDisplay_MAX7221.c. All of the MAX7221 chips in the chain get the same set of initialization values, so it is just a series of loops to assemble the transmit buffers of data and then send them.  I was not careful in the handling of the errors in this file.

//=========================================================================
// init_matrix_display_system ()
//   Initialize the SPI interface and send the initialization data to each
// display module. Leaves page register pointing to page 0 ready to accept
// pixel data.
//
// written 7/27/2020 Bob Harbour
//=========================================================================
int init_matrix_display_system (void)
{
  int retval;
  int module_count;
  uint8_t *u8ptr;

  // hook the default spi device name to the config structure
  spi_handle.spi_device = default_spi_device;

  // initialize the I2C port first
  if ((retval = init_spi_sys (&spi_handle)) == OK)
  {
    // I2C port opened OK, start initializing the LED Driver chips

    // enable the displays
    module_count = 0;
    u8ptr = &(tx_buf[0]);
    while (module_count < NUM_DISPLAY_MODULES)
    {
      *(u8ptr++) = MX_CMD_SHUTDOWN;
      *(u8ptr++) = MX_DISPLAY_ENABLE;
      module_count += 1;
    }
    retval = write_spi_bus (&spi_handle, (2*NUM_DISPLAY_MODULES), tx_buf);

    // set the decode mode register
    module_count = 0;
    u8ptr = &(tx_buf[0]);
    while (module_count < NUM_DISPLAY_MODULES)
    {
      *(u8ptr++) = MX_CMD_DECODE_MD;
      *(u8ptr++) = MX_NO_DECODE;
      module_count += 1;
    }
    retval = write_spi_bus (&spi_handle, (2*NUM_DISPLAY_MODULES), tx_buf);

   // set the scan limits register
    module_count = 0;
    u8ptr = &(tx_buf[0]);
    while (module_count < NUM_DISPLAY_MODULES)
    {
      *(u8ptr++) = MX_CMD_SCAN_LIMIT;
      *(u8ptr++) = MX_SCAN_LIMIT_VAL;
      module_count += 1;
    }
    retval = write_spi_bus (&spi_handle, (2*NUM_DISPLAY_MODULES), tx_buf);

    // clear all the displays
    retval = blank_display ();

    // set the brightness level to the default value
    retval = set_display_brightness (MX_DEFAULT_BRIGHTNESS);
  }

  // clear the display buffer now
  for (module_count=0; module_count<NUM_DISPLAY_MODULES; module_count++)
  {
    md_display_buffer  [module_count] = SPACE;
  }

  return (retval);
}

2/17/2021 While working on a variation of this project, I found that the MAX7221 chips are kind of sensitive to the power supply rise rate. The system I was working on would power up with every LED on random modules lit at Max brightness. It turned out that the LED test bits in some modules were not getting cleared during power up. I added another loop (not shown here) right after the modules were enabled that cleared the test mode bits in all of the modules and it works fine now.

Two scroll functions are used to accept C string data into the display system. One copies data in to the left end of the display and the other copies data into the right end of the display. Only the insert_display_chars_from_left() will be discussed.

Operation of insert_display_chars_from_left () breaks down into 3 cases.

The first case is the simplest, there is no data in the incoming string. Set the return value to OK and the truncation boundary to 0 and return.

The second case actually does something. The incoming string has as many as or more characters in it than the display chain has modules. Just copy the incoming string characters into the internal buffer string for display. The variable trunc_boundary gets set to the number of display modules in the chain. Then call send_buffer() to display the contents of the internal buffer string.

The third case is the most complex. The incoming string has fewer characters in it than there are display modules in the chain. Shift the existing characters in the internal buffer to the right enough spaces to make room for the new characters in the incoming string. Then copy the incoming characters into the left side of the internal buffer. Call send_buffer to display the contents of the internal buffer string.

//===================================================================
// insert_display_chars_from_left ()
//   Put one or more characters into display buffer from left end. If input
// is greater than the length of the character modules in the display, it
// will truncate the string to the number of display modules.
// If there are fewer characters in the input string than display modules,
// characters already in the display will be shifted to the right by the
// number of characters in the input string and the input charcters added.
// The display modules will be updated.
//
// written 8/1/2020 Bob Harbour
//==================================================================
int insert_display_chars_from_left (char *instring, int lenstring, int *trunc_boundary)
{
  char *src, *dest;
  int retval, count, shift_dist;

  retval = OK;

  if (lenstring > 0)
  {
    if (lenstring > NUM_DISPLAY_MODULES)
    {
      // This is the simplest case, there are same or more characters in instring
      // than display modules. Display the leftmost characters in instring until
      // we run out of modules. Return the index of the first undisplayed
      // character.
      src = instring;
      dest = &md_display_buffer[0];

      for (count=0; count < NUM_DISPLAY_MODULES; count++)
      {
        *(dest++) = *(src++);
      }

      // does caller want truncation point back?
      if (trunc_boundary != NULL)
        *trunc_boundary = count;
    }
    else
    {
      // Inserting fewer characters in the display buffer than the number of
      // display modules. Shift existing characters to the right to make space
      // for the new character(s), discarding the existing ones that don't fit.

     // shift the existing data to the right before inserting new characters
      shift_dist = NUM_DISPLAY_MODULES-lenstring;
      dest = &md_display_buffer[NUM_DISPLAY_MODULES-1];
      src = &md_display_buffer[shift_dist-1];

      while (shift_dist >0)
      {
        *(dest--) = *(src--);
        shift_dist -= 1;
      }

      // now copy the new data in to the left side of the buffer
      src = instring;
      dest = &md_display_buffer[0];
      for (count=0; count<lenstring; count++)
      {
        *(dest++) = *(src++);
      }

      // no truncation done. just return 0 to flag.
      if (trunc_boundary != NULL)
        *trunc_boundary = 0;
    }

    // shift array contains proper characters. Send it to the display modules
    retval = send_buffer (md_display_buffer);
  }
  else
  {
    // lenstring < 1, dont do anything
    if (trunc_boundary != NULL)
      *trunc_boundary = 0;
  }
  return (retval);
}


It seems that Hackaday has a maximum length allowed for the DETAILS section, and I hit it with the test driver code. The test driver code and build instructions appear in the first Log File.