Ein Dot-Matrix-Display direkt (mit selbstgeschriebenem “Treiber”) ansteuern
https://oer-informatik.de/arduino_hardware-treiber-max7219
tl/dr; (ca. 15 min Lesezeit): Gedankenspiel: wenn es die ganze wunderbare Arduino-Community nicht gäbe. Nur uns, den Microcontroller, ein 8x8-Matrix-Display (MAX7219), die eine IDE und ein Datenblatt. Würden wir es dann schaffen, das Display an’s laufen zu bekommen? An diesem Beispiel soll gezeigt werden, dass hardwarenahe Programmierung ohne Bibliotheken - nur mit den Angaben eines Datenblatts - kein Zauberwerk ist…
Die wichtigen Infos aus dem Datenblatt des MAX7219 sammeln:
Bevor wir mit der Programmierung beginnen müssen wir erstmal herausfinden, wie die elektronische Verbindung zwischen Arduino und dem Display aufgebaut werden muss. Mir liegt ein gekapseltes Display vor, dass bereits mit dem MAX7219 verbaut wurde. Somit ist klar, dass ich die Pins VCC
, GND
, DIN
, CS
und CLK
zur Verfügung habe.
Der Schaltplan: Hardware / Pins
Das Datenblatt des von den Displays genutzten Chipts MAX7219 legt auf Seite 5 folgende Rahmenbedingungen für die Pinkontakte des MAX7219 fest:
Pin 19 des MAX7219: V+
Positive Supply Voltage. Connect to +5V.
Pin 4, 9 des MAX7219: GND
Ground. Both GND pins must be connected.
(Betrifft die Kontakte des Chips, nicht die des Dispays.)Pin 1 des MAX7219: DIN
Serial-Data Input. Data is loaded into the internal 16-bit shift register on CLK’s rising edge
Pin 12 des MAX7219: LOAD (\overline{CS})
(MAX7219) Load-Data Input. The last 16 bits of serial data are latched on LOAD’s rising edge.
Pin 13: CLK
Serial-Clock Input. 10MHz maximum rate. On CLK’s rising edge, data is shifted into the internal shift register. On CLK’s falling edge, data is clocked out of DOUT. On the MAX7221, the CLK input is active only while CS is low.
Ok, arduinoseitig kann die Pin-Belegung frei gewählt werden. Ich habe mich hier für die Belegung gemäß der internen SPI-Schnittstelle entschieden und notiere das erstmal in alter Gewohnheit als Liste der Ein- und Ausgabeelemente am Kopf des Arduino-Sketches. Bei anderen MCU (z.B. nodeMCU mit ESP32 oder ESP8266) müssen natürlich andere, als Ausgang zulässige Pins für LOAD
, CLK
und DIN
gewählt werden.
/*Datentyp Name Anschlusspin Verhalten, Eigenschaften*/
const int MAX_LOAD = 10; // ChipSelect, invertiert (LOW-Aktiv)
const int MAX_CLK = 13; // Clock
const int MAX_DIN = 12; // Dateneingang des Displays

Hardwareseitig gibt es noch eine weitere Festlegung:
VCC sollte zwischen 4.0V und 5V liegen
Die Clock darf nicht schneller als 10MHz werden, also ein Zyklus 0,1 ns. Müssen wir später mal nachrechnen, ob das kritisch wird…
Die Logiklevel liegen leider über 3,3 V - der Betrieb an MCU mit 3,3V (etwa nodeMCU) ist ohne zusätzliche Schaltungen also Glückssache (geht aber i.d.R.):
MIN | MAX | |
---|---|---|
V_{IH} | 3.5 | |
V_{IL} | 0.8 | |
V_{OH} | 3.5 | |
V_{OL} | 0.4 |
Soweit zur Elektronik. Aber welche Nachrichten verarbeitet das Display?
Das erwartete Nachrichtenformat
Datenblatt des MAX7219, Seite 6:
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.
Mit jeder steigenden Flanke der Clock wird ein Bit an DIN eingelesen (und im Schieberegister gespeichert), in 16 Bit Blöcken.
The data is then latched into either the digit or control registers
on the rising edge of 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.
Nach 16 Bit (also nach oder mit der 16. steigenden Flanke der CLK) muss LOAD auf HIGH
wechseln, damit die Daten aus dem Schieberegister gespeichert/verarbeitet werden.
Data bits are labeled D0–D15 (Table 1). D8–D11 contain the register address.
D0–D7 contain the data, and D12–D15 are “don’t care” bits.
The first received is D15, the most significant bit (MSB).
Die Daten werden in 16-Bit-Paketen versendet: mit jeder steigenden Flanke der Clock ein Bit. Dabei besteht die Nachricht aus drei Teilen:
die erste Hälfte des Address-Bytes (vier Bit) sind “don’t care”-Positionen. Sie werden nicht interpretiert (weil lediglich 14 Befehle im Max7219 implementiert sind).
die zweite Hälfte des Address-Bytes gibt die Befehle an, die der Max7219 ausführen soll (dazu unten mehr)
danach folgen 8 Daten-Bit, die die Parameter der Befehle darstellen.
Als Impulsdiagramm darf man sich die Aufteilung der 16 Bit etwa so vorstellen:

Die oberen drei Graphen (Clock, DIN, LOAD) sind die eigentlichen gesendeten Werte. Die unteren drei ist nur Kommentare/Interpretationen.
Auf geht’s an die Software
Voraussetzungen
Bevor Daten gesendet werden, müssen zunächst die Ausgänge korrekt gesetzt werden. Sicherheitshalber setzen wir direkt alle Ausgänge auf LOW
:
void setup() {
pinMode(MAX_LOAD, OUTPUT);
pinMode(MAX_CLK, OUTPUT);
pinMode(MAX_DIN, OUTPUT);
digitalWrite(MAX_LOAD, LOW);
digitalWrite(MAX_CLK, LOW);
digitalWrite(DIN, LOW);
}
Zusammensetzung eines ersten Befehls
Der einfachste Befehl aus dem Datenblatt (Seite 7, Tabelle “Table 2: Register Address Map”) ist “Display Test”. Wir müssen dazu zunächst 8-Bit für die Befehlsadresse senden, also an DIN
bei steigenden Flanken der CLK
nacheinander: LOW
, LOW
, LOW
, LOW
, HIGH
, HIGH
, HIGH
, HIGH
(wobei die ersten vier Bit beliebig sein können (don’t care positions). Danach werden die Daten gesendet: sieben beliebige Bit an DIN
bei steigender CLK
-Flanke und ein HIGH
. Damit sollte das Display im Test-Modus sein und alle LED leuchten. Danach muss einmal LOAD
auf HIGH gesetzt werden, um den Befehl auszuführen. Das müssen wir jetzt nur noch programmieren:
Befehle senden in zwei 8-Bit-Blöcken
Die Daten werden in zwei 8-Bit Blöcken gesendet: 8 Bit Befehlsadresse, 8 Bit Daten (Parameter der Befehle). Bei den Arduino Uno handelt es sich um 8-Bit-Microcontroller, daher ist es nicht unüblich, in dieser Paketgröße Daten zu senden. Der Übersicht halber habe ich eine Funktion erstellt, die einen 8-Bit-Block (also ein Byte) an DIN
mit CLK
-Flanke ausgibt.
Gemäß der obigen Beschreibung wird erst das Datenbit auf den gewünschten Wert gesetzt, danach taktet die Clock einmal.
Die 8-Bit übergeben wir als Byte-Parameter an diese neue Funkton shiftByte(inputByte)
. Wir notieren den Byte-Wert in binärer Schreibweise, dann erkennen wir auch die einzelnen Bit, z.B. für die Befehlsadresse: shiftByte(0b00001111)
. Das ist deutlich leichter zu interpretieren als das gleichwertige shiftByte(15)
.
Wir lesen die einzelnen Bit aus dem Byte mit der Funktion bitRead(byte, pos). Diese Funktion ist Bestandteil der Arduino IDE und etwas leichter verdaulich, als die bitshift-Operatoren <<
. Wichtig noch: wir fangen beim Index 7 an und arbeiten uns bis zur 0 vor - die Reihenfolge ist also umgekehrt, als das viele erwarten würden:
void shiftByte(byte inputByte){
for (int myBitNr = 0; myBitNr<8; myBitNr++){
bool myBit = bitRead(inputByte, 7-myBitNr);
digitalWrite(MAX_DIN,myBit);
delayMicroseconds(delaytime_us);
digitalWrite(MAX_CLK,HIGH);
delayMicroseconds(delaytime_us);
digitalWrite(MAX_CLK,LOW);
}
}
Der MAX7219 erlaubt laut Datenblatt Taktraten der Clock bis zu 10\ \mathrm{MHz} (siehe oben). Bei langsamen Microcontrollern wie dem Arduino ist das kein Problem: dessen ATMega 328P Microcontroller taktet selbst nur 1\ \mathrm{MHz}. Bei den deutlich schnelleren ESP-Varianten ESP8266 (80\ \mathrm{MHz}) und ESP32 ( bis zu 240\ \mathrm{MHz}) oder anderen schnellen Microcontrollern sollten wir sicherstellen, dass an der Clock des MAX7219 nicht mehr als 10\ \mathrm{MHz} anliegen. Aus dieser Frequenz folgt eine Periodendauer von T = \frac{1}{f} = \frac{1}{10\ \mathrm{MHz}} = 0{,}1\ \mathrm{\mu s}. Es ist also völlig hinreichend, wenn wir jeweils nach dem HIGH
und LOW
an der Clock 1\ \mathrm{\mu s} warten. Ich definiere das global (ganz oben im Code, vor den Funktionen):
int delaytime_us = 1;
.
Die eben erstellte Funktion shiftByte()
müssen wir zweimal aufrufen: einmal für den Befehl, den wir an das Grafik IC (MAX7219) senden wollen, einmal für dessen Parameter. Erst dann haben wir die 16 Bit geschrieben.
Im Anschluss an diese 16-Bit muss LOAD
kurz auf HIGH
gezogen werden, damit der Befehl verarbeitet wird. Eine Funktion, die Befehl und Parameter schreibt und dann per LOAD
ausführt, wäre z.B.:
void shiftBinaryWord(byte address, byte data){
digitalWrite(MAX_LOAD,LOW);
shiftByte(address);
shiftByte(data);
digitalWrite(MAX_LOAD,HIGH);
delayMicroseconds(delaytime_us);
}
Ich habe den Namen der Funktion shiftBinaryWord(address, data)
bewusst mit word
gewählt. Als ein word
wird im Microcontrollerumfeld die kleinste Einheit verstanden, die Prozessoren zur Adressierung verwenden. Bei einem 8-Bit MCU wie dem Arduino also 8 Bit (1 Byte). Im Sinne unserer 16 Bit-Nachrichten definiere ich ein word
hier als eine Nachricht von der Länge 2 Byte
: address
+data
.
Eine Funktion zum Testen des Displays
Nun ist es mühsam, die einzelnen Befehle einzeln aufzurufen. Daher sollten wir für alle Befehle, die unser MAX7219 bietet, eigene Funktionen schaffen, die jeweils den Befehl ausführen. Im Fall des Displaytests habe ich beispielsweise eine Funktion erstellt, die über den Parameter isTestOn
den Displaytest einschaltet (testMatrix(true)
) und ausschaltet (testMatrix(false)
):
Relevante Befehle laut Datenblatt
Das Datenblatt verrät uns eine ganze Reihe von Befehlen, die wir in ähnlicher weise zu Funktionen zusammentragen sollten. Bevor wir damit anfangen könne, an die einzelnen Zeilen des Displays Bitmuster zu senden, sollten wir aber zunächst das Display in einen Zustand versetzten, in dem es Daten empfängt und korrekt darstellt.
Name | Register- adresse |
Daten Beispiele |
Beschreibung |
---|---|---|---|
Zeile 1-8 | xxxx0001 xxxx0010 xxxx0011 xxxx0100 xxxx0101 xxxx0110 xxxx0111 xxxx1000 |
00111100 01000010 10100101 10100101 10011001 10000001 01000010 00111100 |
Setzt das übertragene Bitmuster in die jeweilige Spalte (bei Decode off) |
Helligkeit Intensity |
xxxx1010 |
xxxx0000 : 0%xxxx1111 : 100% |
Ausprobieren: die LEDs sind ganz schön hell, es ist augenschonen, die etwas zu dimmen. |
Anzahl der genutzten Zeilen Scan Limit |
xxxx1011 |
xxxxx000 : 1. Zeilexxxxx111 : 1.-8. Zeile |
Wir wollen ja immer alle 8 Spalten anzeigen, das muss also entsprechend eingestellt werden |
Ausschalten Shutdown |
xxxx1100 |
xxxxxxxx : shutdownxxxxxxx1 : normal |
|
Display Test | xxxx1111 |
xxxxxxx0 : normalxxxxxxx1 : Test |
Nur zum debuggen relevant - vielleicht beim Start kurz aktivieren, dann deaktivieren. |
Matrix-Dummy No-Op |
xxxx0000 |
xxxxxxxx |
Dummy zum weiterschieben von 16 Bit im Schieberegister, falls mehr als |
Decode Mode | xxxx1001 |
00000000 off11111111 on |
Schaltet das Dekodieren von Zahlen für 7-Segment-Displays an/aus. (bei Dot-Matix: off) |
Es müssen also eine Reihe von neuen Funktionen geschaffen werden, die diese Befehle ausführen. Diese Funktionen müssen dann am Ende der Setup-Funktion ausführt werden. Beispielsweise könnten die Aufrufe so aussehen (die Funktionen selbst müssen natürlich analog zu testMatrix()
implementiert werden):
poweronMatrix(true); // better safe than sorry: normal-Modus
matrixIntensity(0b00001111); // 100% Helligkeit
matrixScanlimit(0b00000111); // alle 8 Zeilen nutzen
testMatrix(false); // Displaytest-Modus ausschalten
matrixDecode(false); // Den Decode-Modus deaktivieren
Ein Bitmuster auf das Display zaubern
Jetzt kommt der spannende Teil: in gleicher Weise, wie wir einzelne Befehle mit Daten an das Display gesendet haben, können wir jetzt auch Bitmuster für jede Spalte mit 8 LED senden. Da jede Spalte einen eigenen Befehl hat, könnten wir natürlich acht Funktionen erstellen. Dass das Blödsinn wäre, ist aber klar. Stattdessen wäre ein Befehl gut, dem wir nur das Bitmuster und die Spaltennnummer übergeben. Praktischerweise entspricht die Spaltennummer genau den Befehl, der die jeweilige Spalte beschreibt (0b00000010
= 2 schreibt die zweite Spalte).
void displayByte(byte bitMuster, byte col) {
if ((col>0) and (col<=8)) { // nur binäre Werte zwischen 1 und 8 sind zulässige Befehle
shiftBinaryWord(col, bitMuster);
}
}
Damit lässt sich jede Zeile schon einmal super beschreiben. Jetzt fehlt noch eine Funktion, die alle Spalten auf einmal beschreibt.
Hierzu müssen wir in die Array-Trickkiste greifen. Wir benötigen insgesamt acht Bitmuster à acht Bit. Eine einfache Struktur, die mehrere gleichartige Werte speichern kann, ist das Array.
Ein Array mit acht Byte kann also ein komplettes Bild auf dem Dot-Matrix-Display speichern. Ein Kreis sähe etwa so aus:
byte bitMuster[] = {0b00111100,
0b01000010,
0b10000001,
0b10000001,
0b10000001,
0b10000001,
0b01000010,
0b00111100};
Auf die einzelnen Elemente des Arrays kann ich per Index zugreifen, wobei Arrays “Null-Index-basiert” sind, d.h. die Zählung der Indizes fängt bei 0
an. bitMuster[0]
gibt also die erste Spalte, bitMuster[7]
die letzte Spalte aus.
Jetzt fehlt nur noch eine Funktion, die dieses Array als Parameter übergeben bekommt und jedes Element des Arrays an unsere Funktion displayByte(...)
mit aufsteigender Spaltennummer übergibt.
Vorsicht ist hier aber geboten: der Index der ersten Spalte im Array ist 0
, der Befehl für die erste Spalte ist aber 0b00000001
(also 1). Die Indizes sind also immer um eins kleiner als die Spaltennummern.
void displayScreen(byte bitMuster[]) {
for (byte col = 1; col <= 8; col = col + 1) {
displayByte(bitMuster[col-1], col);
}
}
Wenn das obige Array erstellt wurde, reicht schon ein einfacher Aufruf, um den Kreis auf dem Display auszugeben.
Fazit, wie geht’s weiter?
Es ist also kein Hexenwerk, mit Hilfe eines Datenblatts ein eigenes Programm zu schreiben, das ohne fremde Treiber auskommt.
Wie könnte es jetzt weiter gehen? Es liegt auf der Hand, jetzt eine eigene Schrift zu erstellen, Grafiken zu zeichnen und und und…
Irgendwann sollten wir nur den Absprung wieder bekommen, denn schließlich gibt es diese ganzen Bibliotheken bereits (eine Suche nach “Max7219” in den Arduino-Bibliotheken hat unzählige Treffer). Uns ging es hier ja nur darum, die Infos in Datenblättern zu entschlüsseln.
Eins haben wir dabei aus Versehen noch gelernt: Die Grundlagen für das SPI-Protokoll. Das schauen wir uns in einem nächsten Schritt mal genauer an…
Links und weitere Informationen
Gute Zusammenfassung der technischen Hintergründe zum Max7219 von best-microcontroller-projects.com
Die Ansteuerung eines Dot-Matrix-Displays mit dem Max7219 ist bei tronixstuff.com/ gut erklärt
Inspiration für dieses Tutorial: Treiberentwicklung mit Python (auf französisch)
Quellen und offene Ressourcen (OER)
Die Ursprungstexte (als Markdown), Grafiken und zugrunde liegende Diagrammquelltexte finden sich (soweit möglich in weiterbearbeitbarer Form) in folgendem git-Repository:
https://gitlab.com/oer-informatik/mcu/arduino-esp.
Sofern nicht explizit anderweitig angegeben sind sie zur Nutzung als Open Education Resource (OER) unter Namensnennung (H. Stein, oer-informatik.de) freigegeben gemäß der Creative Commons Namensnennung 4.0 International Lizenz (CC BY 4.0).