Ein Dot-Matrix-Display direkt (mit selbstgeschriebenem “Treiber”) ansteuern
https://oer-informatik.de/arduino_hardware-treiber-max7219
https://bildung.social/@oerinformatik/109331947537138297
tl/dr; (ca. 18 min Lesezeit, 90 min Bearbeitungszeit): Gedankenspiel: wenn es die ganze wunderbare Arduino-Community nicht gäbe. Nur uns, den Microcontroller, ein 8x8-Matrix-Display (mit Ansteuerungs-IC MAX7219), die eine IDE und ein Datenblatt. Würden wir es dann schaffen, das Display an’s Laufen zu bekommen? Schritt für Schritt gehe ich hier durch die relevanten Absätze des Datenblatts und implementiere die nötigen Befehle, um das Display mit Microcontroller anzusteuern. Hardwarenahe Programmierung ohne Bibliotheken ist auch für Einsteigende kein Zauberwerk… (Zuletzt geändert am 11.09.2024)
Die wichtigen Infos aus dem Datenblatt des MAX7219 sammeln:
Bevor wir mit der Programmierung beginnen, müssen wir erst einmal 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:
V+ (Pin 19 ):
Positive Supply Voltage. Connect to +5V.GND (Pin 4, 9):
Ground. Both GND pins must be connected.(Betrifft die Kontakte des Chips, nicht die des Dispays.)DIN (Pin 1):
Serial-Data Input. Data is loaded into the internal 16-bit shift register on CLK’s rising edgeLOAD (\overline{CS}) (Pin 12):
(MAX7219) Load-Data Input. The last 16 bits of serial data are latched on LOAD’s rising edge.CLK (Pin 13):
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:
VCCsollte zwischen 4.0V und 5V liegenDie 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 als Impuls dargestellt. Die unteren drei ist nur Kommentare/Interpretationen, um zu verdeutlichen, was wirklich gesendet wird.

Auf geht’s an die Software
Voraussetzungen
Der Aufbau eines Microcontroller-Programms umfasst häufig fünf Bereiche, in denen Programmcode eingefügt wird:
/** BEREICH 1: EXTERNE BIBLIOTHEKEN IMPORTIEREN (#include)
Als erstes werden externe Bibliotheken eingebunden (per #include).
Wir nutzen hier keine Bibliothek, daher bleibt dieser Bereich leer.
**/
/** BEREICH 2: DEKLARATION GLOBALER VARIABLEN UND KONSTANTEN
Hier steht z.B. die Liste der Ein- und Ausgabelemente, die festlegt,
welcher Sensor/Aktor an welchem Pin angeschlossen ist. Auch andere
Variablen oder Konstanten, auf die das Gesamtprogramm Zugriff haben
muss, werden hier deklariert.
**/
void setup() {
/** BEREICH 3: INITIALISIERUNGEN ZU BEGINN
Operationen, die ein einziges Mal am Anfang durchgeführt werden müssen
Hier werden Operationen zur Initialisierung der Pins
oder zum Starten der Aktoren aufgerufen.
**/
}
void loop() {
/** BEREICH 4: HAUPTSCHLEIFE - DIE EIGENTLICHE LOGIK
Operationen, die immer wieder wiederholt aufgerufen werden
Solange der Microcontroller mit Strom versorgt wird durchläuft er
diese Funktion - immer wieder als Schleife. Hier werden direkt oder
indirekt die meisten eigenen Funktionen aufgerufen.
**/
}
/** BEREICH 5: EIGENE FUNKTIONEN
Damit die `loop()`-Funktion nicht unübersichtlich wird, sollte der
Code gegliedert sein in viele kleine Funktionen, die jeweils eine
Aufgabe übernehmen. Jede dieser Funktionen muss irgendwann im
Programm mal aufgerufen werden, damit sie ausgeführt wird - entweder
direkt in `loop()` oder `setup()`, oder in einer Funktion die diese
aufrufen.
**/Damit der Code besser lesbar ist und wir später Anschlusspins ändern können, legen wir zunächst im Bereich 2 eine Liste der Ein- und Ausgabelemente mit globalen Konstanten für jeden Pin an. Ich ergänze immer noch Funktion und ggf. Verhalten der angeschlossenen Bauelemente (z.B. externer PullUp-Widerstand, LOW-Aktiv), damit ich das zentral dokumentiert habe:
/*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 DisplaysBevor Daten gesendet werden, müssen zunächst die drei Ausgänge korrekt gesetzt werden. Das geschieht in der setup()-Funktion in Bereich 3. Sicherheitshalber setzen wir dabei 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(MAX_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 8-Bit-Paketgröße Daten zu senden. Der Übersicht halber habe ich eine Funktion erstellt, die jeweils 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 Funktion (ich habe sie mal shiftByte() genannt). Wir notieren den Byte-Wert der Befehlsadresse und Daten jeweils in binärer Schreibweise.
Im Programmcode wird durch ein vorangestelltes 0b ausgedrückt, dass wir im folgenden eine binäre Zahl eingeben. In unseren Beispielen ist dies eine Zahl aus acht Bit (also einem Byte):
Wir erkennen so auch die einzelnen Bit, z.B. für die Befehlsadresse: shiftByte(0b00001111). 0b00001111 ist nichts anderes als die binäre Darstellung der Zahl 15:
1 \cdot 2^0 + 1 \cdot 2^1 + 1 \cdot 2^2 + 1 \cdot 2^3 = 1+2+4+8 = 15
Statt shiftByte(0b00001111) könnten wir also ebenso den gleichwertigen Aufruf shiftByte(15) nutzen, können dann aber die einzelnen Bitwerte nicht mehr so leicht ablesen.
Unsere neue Funktion soll die einzelnen Bit aus dem Byte der Reihe nach auswerten. Dazu nutzen wir die Funktion bitRead(byte, pos). Diese Funktion ist Bestandteil der Arduino IDE.1.
Wichtig noch: wir fangen beim Index 7 an und arbeiten uns bis zur 0 zurück - die Reihenfolge ist also umgekehrt, als das viele erwarten würden. Den Wert jeden Bits legen wir an MAX_DIN an und geben im Anschluss ein Taktsignal und halten eine kurze Zeiteinheit inne. Die Funktion, die das umsetzt legen wir etwa so in Bereich 5 an:
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 (siehe oben: delayMicroseconds(delaytime_us);). Ich definiere die Zeit in der globalen Variable delaytime_us, in dem ich ganz oben im Bereich 2, nach der Liste der Eingabeelemente und vor der setup()-Funktion einfüge:
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 geforderten 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);
}Dies ist wieder eine eigene Funktion, die wir in Bereich 5 einfügen. 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 neue Funktion (wieder Bereich 5) erstellt, die über den Parameter isTestOn den Displaytest einschaltet (testMatrix(true)) und ausschaltet (testMatrix(false)):
Wir senden also das Befehlsbyte für den Displaytest (0b00001111) und das Datenbyte zum einschalten (0b00000001). Da wir die führenden Nullen weglassen können, langt es, wenn wir statt 0b00000001 einfach 0b1 senden: eine binäre 1 - also schlicht ein Wahrheitswert (Boolean, true/false bzw HIGH/LOW).
Damit die Funktion testMatrix() auch ausgeführt wird, müssen wir sie noch aufrufen - in der loop() (Bereich 4), wenn wir den Test immer wieder ein- und ausschalten wollen - oder in der setup() (Bereich 3), wenn wir dies nur einmal tun wollen. Ich ergänze also in der setup()-Funktion nachdem wir die Pins auf LOW gesetzt haben ans Ende:
Damit sollte das Programm ausführbar sein und das komplette Dispay zu Beginn einmal kurz blinken.
Relevante Befehle laut Datenblatt
Ein einmal blinkendes Display ist ja noch nicht das, was wir wollen. Das Datenblatt verrät uns eine ganze Reihe von weiteren 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. In der folgenden Tabelle sind die wesentlichen Punkte zu Befehlen aus dem Datenblatts zusammengefasst:
| Name | Register- adresse |
Daten Beispiele |
Beschreibung |
|---|---|---|---|
| Zeile 1-8 | xxxx0001xxxx0010xxxx0011xxxx0100xxxx0101xxxx0110xxxx0111xxxx1000 |
0011110001000010101001011010010110011001100000010100001000111100 |
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 (Bereich 3) aufgerufen werden. Beispielsweise könnten die Aufrufe so aussehen:
poweronMatrix(true); // better safe than sorry: normal-Modus aktivieren
matrixIntensity(0b00001111); // 100% Helligkeit einstellen (Wert von 0-15, also `0b00000000`-`0b00001111`)
matrixScanlimit(0b00000111); // alle 8 Zeilen nutzen (Werte von 0-7 möglich, `0b00000000` bis `0b00000111`)
testMatrix(false); // Displaytest-Modus ausschalten
matrixDecode(false); // Den Decode-Modus (für 7-Segment-Anzeigen) deaktivierenDiese Funktionen selbst müssen natürlich analog zu testMatrix() in Bereich 5 implementiert werden: es muss der jeweilige Parameter als Datenbyte an shiftBinaryWord(BEFEHLSBYTE,DATENBYTE); mit dem passenden Befehlsbyte (siehe Liste oben) übergeben werden.
Wenn diese Funktionen implementiert sind wie oben gezeigt in der setup() (Bereich 3) aufgerufen werden, dann beginnt der eigentliche Spaß:
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). Wir können also eine Funktino (in Bereich 5) definieren, die jeweils eine Spalte schreibt:
void displayByte(byte bitMuster, byte col) {
if ((col>0) and (col<=8)) { // nur erte 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. Wenn wir die einzelnen Zeilenbyte bündig untereinander schreiben, kann man sogar schon das Resultat erahnen. 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.
Wir erstellen eine eigene Funktion (wieder Bereich 5), die so ein Array von acht Byte übergeben bekommt, und dann Spalte für Spalte darstellt.
void displayScreen(byte bitMuster[]) {
for (byte col = 1; col <= 8; col = col + 1) {
displayByte(bitMuster[col-1], col);
}
}Wenn das obige Array erstellt wurde (direkt oberhalb oder in Bereich 2), 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)
Hinweis zur Nachnutzung als Open Educational Resource (OER)
Dieser Artikel und seine Texte, Bilder, Grafiken, Code und sonstiger Inhalt sind - sofern nicht anders angegeben - lizenziert unter CC BY 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: “Ein Dot-Matrix-Display direkt (mit selbstgeschriebenem "Treiber") ansteuern” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/arduino_hardware-treiber-max7219 veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/mcu/arduino-esp. Stand: 11.09.2024.
[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]
