Tinkering with a LED matrix: first steps

Index, feed.

[ Date | 2017-04-30 10:46 -0400 ]
[ Mod. | 2020-02-21 21:49 -0500 ]
[ Current movie | The Matrix ]

Maxim Integrated makes a chip, the MAX7219 / MAX7221, that can drive 64 LEDs using a serial protocol. I got a pre-assembled 32x8 display on eBay (this uses the cascading feature, which I will detail below). My eventual goal is to make a clock; while the results may not be cheaper than a store-bought clock, this has the killer feature of always being on time and following daylight saving time, thanks to the controlling computer being a Linux machine that supports NTP and tzdata. Otherwise, this is more fun hacking than practical value.

32x8 LED display, Arduino, Chip

32x8 LED display, Arduino, Chip

In this initial article, I will cover the part closest to the display: setting up an Arduino that will be a bridge, acting as an I²C slave that sends out MAX7219 commands.

Hardware setup


 |  32x8 MAX7219 |
 |  LED display  |
         | MAX7219 protocol
 |  Arduino Uno  |
         | I²C
| Single-board    |
| Linux computer  |

I used Arduino pins 5, 6, and 7 connected to MAX7219 pins CLK, LOAD, and DIN, respectively, but the choice is arbitrary; any GPIO pin capable of digital output is fine. The choice of an Arduino Uno is also arbitrary; any model supporting at least three digital outputs in addition to I²C will work. The two devices should share the ground level. In my case, I simply powered the display from the Arduino. Depending on display brighness, Arduino model, and power supply, this could be an issue, but with the hardware I selected, this works fine.

The single-board computer I used was a C.H.I.P., which I like and had lying around but, once again, other boards supporting I²C, such as a Raspberry Pi, would work just as well. The I²C hardware wiring is straightforward: connect SDA to SDA and SCL to SCL between the Arduino and the single-board computer, and make sure the two devices share a common ground level.

It may seem weird and overkill to use an Arduino as an intermediate between a very capable single-board computer and a displaythat already has its own dirver chips, and it probably is! Apart from the learning opportunity, a reason for that is that the Arduino does level-shifting (it connects with the display at 5V, and happens to accept the single-board computer's 3.3V logical output levels). Another reason is that the Arduino frees the Linux machine from doing low-level driver operations, in the absence of a proper kernel driver. Using I²C would also allow connecting additional displays without using any more GPIO pins.


The Arduino sketch provides an abstraction to the 32x8 display: while it is really a series of four 8x8 MAX7219-driver LED matrices, the interface that the Arduino I²C slave exposes is a 32x8 pixel buffer, which it then properly writes out to each chip on demand.

The current code is structured as single file to make its transmission simple. It includes, on one hand, functions to push MAX7219 instructions to the display and, on the other hand, a handler for commands received via I²C, that allow editing the in-memory display buffer, committing it to the display, and otherwise setting up the display. (I realize now that identifier case is not completely consistent, alas.)

Instruction set

I²C register Data Effect
0x01 - Initialize
0x02 - Clear
0x03 - Commit buffer to display
0x7f addr, bytes Write to display buffer

The initialize instructions sets up the MAX7219 display: after this is all done, each chip is ready to accept bytes to be copied verbatim to LED states. The display gets initialized on Arduino startup, so this instruction does not need to be called, unless the display got messed up (e.g., by plugging it in after booting the Arduino).

The clear instructions sets the display buffer to all-zero, and causes a commit.

The commit instruction copies the display buffer to the MAX7219 chips.

Finally, the write instruction allows one to set bytes in the buffer. The first I²C data byte is the address where writing should start; for a 32x8 display, addresses range from 0 to 0x1f. The instruction allows writing multiple bytes at once, by incrementing the address counter after each additional byte. For example, doing write 0x00 0xff would turn on all LEDs on the top row of the leftmost 8x8 matrix. Doing write 0x00 0x80 0x00 0x00 0x01 would turn on just the top left and top right LEDs of the display.

Program sketch

What follows is a high-level description of the source code.

The setup function (which is run at Arduino initialization time) sets up the device as an I²C slave: it has address 7 and the handler is function onReceive. The other standard Arduino function, loop, does nothing but sleep; all of the useful work is done in the I²C handler.

Function onReceive interprets "instructions" according to the table above. Any invalid messages are dropped, as are writes that would overflow the display buffer.

Variable current is a byte array, representing the state of the display when it was last committed, while scratch is the buffer that writes go to when the write instruction is called. Function refresh_display causes scratch to be copied to the display and to current.

The remaining functions implement the MAX7219 protocol. From highest to lowest level, pushInstrAll executes a (MAX7219) instruction on all four chips, by sending it out four times and spiking the commit line afterwards. pushInstr pushes a single 16-bit instruction down the MAX7219 pipeline, by sending every bit, in sequence, most significant bit first, as the protocol requires. Sending a single bit is accomplished by pushBit, which sets the data pin to the bit value, and then "spikes" the clock pin by setting it to HIGH and then to LOW. (Insert a delay between those last two operations if running on an Arduino that is so fast that the data rate would exceed the 10MHz that the MAX7219 supports.) setPin is a wrapper around digitalWrite that also flashed the LED pin for visual feedback: having this function gives the opportunity to process the input as needed, e.g., to invert its value if an inverting level shifter is used, for example.

Example client code

The command line utility i2cset from package i2c-tools can send I²C commands. The following code sets the display as it is in the picture at the top of this article:

# Write top half of the 32x8 display
# (16 bytes starting at address 0x00)
i2cset -y 2 7 0x7f 0x00 \
    0x42 0x44 0x2e 0x4e \
    0xa6 0xaa 0xa8 0xa2 \
    0xaa 0x22 0xa8 0x82 \
    0xa2 0x44 0xec 0xc4 i

# Write bottom half of the 32x8 display
# (16 bytes starting at address 0x10)
i2cset -y 2 7 0x7f 0x10 \
    0xa2 0x82 0x22 0xa8 \
    0xa2 0x82 0x22 0xa8 \
    0xa2 0x8a 0x2a 0xa8 \
    0x42 0xe4 0x24 0x48 i

# Commit
i2cset -y 2 7 0x3

Option -y 2 uses I²C bus number 2, and 7 is the device address, as set in the Arduino sketch: adapt as needed. Then a list of bytes to send follows (the first byte is interpreted as an instruction by the handler code, as already described), and i indicates an "I²C block write", i.e., sending multiple bytes in sequence.


I said earlier I would mention how the MAX7219's cascading feature is used: each chip has input pins "data" ("DIN" in the data sheet), where the instruction bits are clocked in; "clock" ("CLK"); and "commit" ("LOAD"). The clock pins for all chips are connected together, and the same is true of the commit pins.

The data pins, on the other hand, are each connected to the preceding chip's "DOUT" pin, which is a 16 ½-cycle-delayed copy of that chip's "DIN". This means that, to push four instructions A, B, C, D to four chained displays, one should clock in D, C, B, A, and only then spike the commit pin: the first display will, at this point, have the last pushed instruction, A, in its buffer, the second display will be one instruction late and have B, etc.

This is apparent in the code: function pushInstrAll simply clocks out the same instructions four times, and only then commits.


Arduino sketch source code i2c-max7219-relay-1.ino:

// -*-c++-*-
#include <Wire.h>

int i2caddr = 7;

int outData =   7;
int outClock =  5;
int outCommit = 6;

int outLed = 13;

#define DISPLAYS 4
#define LINES    8

byte scratch[BYTES] = {0};
byte current[BYTES] = {0};

void setup() {

    pinMode(outData,   OUTPUT); // MAX7219 DIN
    pinMode(outClock,  OUTPUT); // MAX7219 CLK
    pinMode(outCommit, OUTPUT); // MAX7219 LOAD / ¬CS

    pinMode(outLed,    OUTPUT);


void loop() {

static void display_init() {
    pushInstrAll(0x900);        // Decode none
    pushInstrAll(0xa01);        // Set intensity
    pushInstrAll(0xb07);        // Scan limit all
    pushInstrAll(0xc01);        // Shutdown off
    pushInstrAll(0xf00);        // Display test off

static void setPin(int pin, int value) {
    value = value ? HIGH : LOW;
    digitalWrite(pin, value);
    digitalWrite(outLed, 1);
    digitalWrite(outLed, 0);

static void pushBit(int value) {
    setPin(outData, value);
    setPin(outClock, 1);
    setPin(outClock, 0);

static void commit() {
    setPin(outCommit, 1);
    setPin(outCommit, 0);

static void pushInstr(unsigned short opcode, bool do_commit = true) {
    for (int i = 0; i < 16; ++i) {
        pushBit(opcode & 0x8000);
        opcode <<= 1;
    if (do_commit) commit();

static void pushInstrAll(unsigned short opcode) {
    pushInstr(opcode, false);
    pushInstr(opcode, false);
    pushInstr(opcode, false);
    pushInstr(opcode, true);

static byte revbyte(byte b) {
    byte out = 0x00;
    for (int i = 0; i < 8; ++i) {
        out <<= 1;
        out |= (b & 0x1);
        b >>= 1;

    return out;

static void refresh_display() {
    byte *b = scratch;
    unsigned short opcode = 0x800;
    for (int l = 0; l < LINES; ++l, opcode -= 0x100, b += DISPLAYS) {
        pushInstr(opcode | revbyte(b[3]), false);
        pushInstr(opcode | revbyte(b[2]), false);
        pushInstr(opcode | revbyte(b[1]), false);
        pushInstr(opcode | revbyte(b[0]), true);

    memcpy(current, scratch, BYTES);

// I²C handler
static void onReceive(int bytes) {
    if (bytes < 1) return;

    byte instr = Wire.read();

    switch (instr) {
    case 0x01: display_init(); break;     // Init
    case 0x02: memset(scratch, 0, BYTES); // Clear
    case 0x03: refresh_display(); break;  // Commit

    case 0x7f: {
        byte addr = Wire.read();
        if (addr >= BYTES) return;

        for (; bytes > 0 && addr < scratch + BYTES; --bytes) {
            scratch[addr++] = Wire.read();




Quick links:

Camp info 2007
Camp Faécum 2007
Japanese adjectives
Couleurs LTP
French English words
Petites arnaques
DSC-W17 patch
Scarab: dictionnaire de Scrabble
Omelette soufflée au sirop d'érable
Camembert fondu au sirop d'érable
La Mona de Tata Zineb
Cake aux bananes, au beurre de cacahuètes et aux pépites de chocolat