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 edge

  • LOAD (\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.

Fritzing-Entwurf der Schaltung, DIN-Pin12, CLK-Pin13, CS-Pin10
Fritzing-Entwurf der Schaltung, DIN-Pin12, CLK-Pin13, CS-Pin10

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 als Impuls dargestellt. Die unteren drei ist nur Kommentare/Interpretationen, um zu verdeutlichen, was wirklich gesendet wird.

Impulsdiagramm einer Nachricht des Max7219
Impulsdiagramm einer Nachricht des Max7219

Auf geht’s an die Software

Voraussetzungen

Der Aufbau eines Microcontroller-Programms umfasst häufig fünf Bereiche, in denen Programmcode eingefügt wird:

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:

Bevor 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:

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:

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.:

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 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. Zeile
xxxxx111: 1.-8. Zeile
Wir wollen ja immer alle 8 Spalten anzeigen, das muss also entsprechend eingestellt werden
Ausschalten
Shutdown
xxxx1100 xxxxxxxx: shutdown
xxxxxxx1: normal
Display Test xxxx1111 xxxxxxx0: normal
xxxxxxx1: 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 off
11111111 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:

Diese 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:

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:

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.

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…


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]


  1. bitRead() ist deutlich leichter verdaulich, als die bitshift-Operatoren <<, die wir alternativ nutzen könnten.

Kommentare gerne per Mastodon, Verbesserungsvorschläge per gitlab issue (siehe oben). Beitrag teilen: