Zum Hauptinhalt Zur Navigation

DIY & Raspberry Pi Pico: Den Pico mit programmierbarem IO und NES-Gamepad steuern

Mit programmierbarer Ein- und Ausgabe lässt sich ein altes Gamepad leicht am Mikrocontroller betreiben. Wir zeigen, wie das funktioniert.
/ Johannes Hiltscher
6 Kommentare News folgen (öffnet im neuen Fenster)
Ein Oszilloskop ist beim Entwickeln für die programmierbare Ein- und Ausgabe des Raspberry Pi Pico fast Pflicht. (Bild: Johannes Hiltscher/Golem.de)
Ein Oszilloskop ist beim Entwickeln für die programmierbare Ein- und Ausgabe des Raspberry Pi Pico fast Pflicht. Bild: Johannes Hiltscher/Golem.de

Eine Eingabemöglichkeit ist für viele Mikrocontroller-Projekte eine sinnvolle Ergänzung. Allerdings sind einzelne Knöpfchen unpraktisch, eine Tastatur oft zu viel und das USB-Protokoll sowieso recht komplex – ein Thema für einen eigenen Artikel. Einfacher geht es mit einem alten Gamepad aus der Ära der 8- oder 16-Bit-Konsolen. Das ist ziemlich simpel aufgebaut, entsprechend einfach ist das Protokoll für den Datenaustausch.

Normalerweise würde man das Protokoll mit Software nachbauen, diese Methode heißt Bit-Banging(öffnet im neuen Fenster) . Dabei erzeugt der Controller die benötigten Steuersignale an seinen Ausgabe-Pins und liest die Antwort von anderen Pins als Eingabe – ebenfalls gesteuert durch Software. Das funktioniert und es lassen sich so sogar USB-Schnittstellen bauen. Aber die Methode hat einen Nachteil: Die Programmteile zum Erzeugen und Einlesen der Signale blockieren den Prozessor, während sie laufen.

Genau für solche Anwendungen hat der von der Raspberry Pi Foundation entwickelte Mikrocontroller RP2040 eine elegante Lösung: programmierbare Ein- und Ausgabe (Programmable I/O oder kurz PIO). Hier geben wir einen kurzen Einblick in die Programmierung der PIO-Einheiten, alle Details finden sich im Datenblatt des RP2040 (PDF)(öffnet im neuen Fenster) .

Für PIO sind acht als Zustandsautomaten(öffnet im neuen Fenster) (state machines, SMs) bezeichnete, spezialisierte Prozessoren in zwei Modulen integriert. Sie werden mit neun speziellen Assembler-Befehlen programmiert, können über zwei FIFOs(öffnet im neuen Fenster) Daten mit dem Prozessor austauschen, von Eingangs-Pins lesen und auf Ausgabe-Pins schreiben. Damit lassen sich (fast) beliebige Schnittstellen realisieren. Zu komplex darf das Protokoll allerdings nicht sein, da der Programmspeicher auf 32 Anweisungen pro Modul begrenzt ist. Den Speicher teilen sich alle vier SMs des Moduls. Aber selbst digitale Bildsignale(öffnet im neuen Fenster) oder analoge Formate wie PAL (g+) sind möglich.

Das können PIO-Programme

Die Befehle, die zur Programmierung der SMs genutzt werden können, sind speziell darauf zugeschnitten, die Pins des Mikrocontrollers anzusteuern oder Daten von ihnen zu lesen. Dafür existieren zunächst die Befehle In und Out , die 1 bis 32 Bits bewegen. Beide nutzen ein eigenes Schieberegister, mit dem in mehreren Schritten ein Wert zusammengesetzt (In) oder ausgegeben (Out) werden kann. Ein fester Wert von bis zu 5 Bit kann mit dem Befehl Set gesetzt werden. Er kann entweder in einem der beiden Arbeitsregister X und Y oder auf zuvor festgelegten Pins landen.

Ist ein Wert vollständig eingelesen, muss er in die Empfangs-FIFO geschrieben werden, damit die Prozessorkerne des RP2040 etwas damit tun können. Das erledigt der Befehl Push . Pull entnimmt auszugebende Daten aus der Sende-FIFO. Beide arbeiten immer mit 32-Bit-Werten, egal wie viele Bits tatsächlich genutzt werden. Nach Ein- und Ausgabe sind Schleifen der nächste wichtige Punkt, denn bei den meisten Protokollen passiert oft das Gleiche. Hierfür gibt es den Befehl Jmp , mit dem die Programmausführung an einen anderen Punkt springt. Er kann unbedingt oder an eine von sieben Bedingungen geknüpft sein. Häufig genutzt werden Zählschleifen, um eine Sequenz von Anweisungen mehrfach auszuführen. Dafür wird eines der Arbeitsregister genutzt, ein Beispiel sehen wir uns später an.

PIO-Programme können über den Befehl Irq zudem Programmunterbrechungen (Interrupts) auslösen, die entweder an die Prozessoren oder andere PIO-Einheiten geleitet werden. Letzteres kann zur Synchronisation komplexer Programme genutzt werden, die mit mehreren SMs realisiert sind. Dafür käme der Befehl Wait zum Einsatz. Zuletzt existiert noch der Befehl Mov , der Werte zwischen Registern verschiebt und dabei die Werte der einzelnen Bits umkehren (invertieren) oder die Reihenfolge umdrehen kann.

Warum PIO?

Die Frage, warum man sich entschlossen hat, PIO-Module in den RP2040 zu integrieren, beantwortet die Raspberry Pi Foundation im oben verlinkten Datenblatt: Jede SM benötigt in etwa so viel Chipfläche wie eine reguläre serielle Schnittstelle (SPI oder I 2 C), kann aber viel mehr. Anstatt eine Menge Funktionseinheiten zu integrieren, die oft als IP-Core (für Intellectual Property) eingekauft werden, gibt es anpassbare Einheiten mit flexibler Funktion. Das spart Kosten, ohne auf die Funktionalität angepasster Hardware verzichten zu müssen: PIO hat ein ebenso vorhersagbares zeitliches Verhalten und verringert den Aufwand für den Prozessor.

Am Anfang steht auch bei PIO-Programmen, wie bei jedem Programm, die Überlegung: Was soll es tun und wie erreiche ich das? Also sehen wir uns zuerst das Gamepad an, das unser PIO-Programm abfragen soll.

So funktioniert das Gamepad

Nachdem wir nun die Grundfunktion der PIO-Einheiten kennen, ist es erst einmal Zeit für Reverse Engineering. Ich habe seit Jahren ein altes NES-Gamepad rumliegen, das ich immer mit einem Mikrocontroller nutzen wollte – die Ziele änderten sich dabei von Zeit zu Zeit. Das aktuelle Ziel ist, mit dem Raspberry Pi Pico eine Spielekonsole zu bauen, die ähnliche Möglichkeiten bietet wie alte 8-Bit-Konsolen.

Die Schaltung des Gamepads zu verstehen, ist keine große Herausforderung, es ist extrem simpel aufgebaut. Die größte Herausforderung ist, den Stecker zu öffnen. Auf der Platine sind abgesehen vom fünfpoligen Kabel lediglich drei Komponenten aufgelötet: zwei Widerstände und ein Chip, dessen Funktion anhand der aufgelaserten Bezeichnung HD14021 leicht herauszufinden ist.

Bei dem Chip handelt es sich um einen 8-Bit-Parallel-Seriell-Wandler, genau passend für die acht Schalter des Gamepads. An die Konsole (oder den Mikrocontroller) angeschlossen wird der Chip über drei Leitungen, dazu kommen noch zwei für die Spannungsversorgung. Glücklicherweise ist der Chip mit 3 Volt zufrieden, zum Anschluss an den RP2040 wird also kein Pegelwandler benötigt.

So funktioniert der HD14021

Über die erste Leitung, im Datenblatt mit P/S bezeichnet, wird ausgewählt, was der Chip tut: Liegt hier der 1-Pegel an, werden die Werte der acht parallelen Eingänge in jeweils ein eigenes Register geladen. Die Register sind untereinander zu einem Schieberegister(öffnet im neuen Fenster) verschaltet. Liegt hingegen der 0-Pegel an, arbeitet der Chip als Schieberegister, die zuvor eingelesenen Werte werden von Register zu Register bewegt und kommen nach und nach an drei verschiedenen Ausgängen an. Gesteuert wird das Verschieben von einem Taktsignal, das die Konsole erzeugt. Unten gibt es dazu ein Bild.

Über die drei verschiedenen Ausgänge kann der HD14021 als 6-, 7- oder 8-Bit-Wandler betrieben werden. Beim NES-Gamepad werden, wie bereits erwähnt, alle 8 Bit genutzt. Entsprechend ist der Ausgang Q8, welcher der letzten Registerstufe und damit dem parallelen Eingang P8 zugeordnet ist, auf den Stecker und damit zur Konsole geführt. Die genaue Funktion des Chips ist im Datenblatt beschrieben, das leider nur als relativ schlechter Scan verfügbar ist. Zudem ist es nur noch über Archive für Datenblätter zu finden, beim Hersteller Hitachi existiert es nicht mehr.

Aus dem Datenblatt geht hervor, dass die Register neue Werte immer mit der fallenden Signalflanke (1-0-Wechsel) des Taktsignals übernehmen. Jedes Register ist einem der acht Eingänge zugeordnet, die mit P1 bis P8 bezeichnet sind. Im seriellen Modus wird der Inhalt jedes Registers ins nächsthöhere verschoben, der Wert aus dem P1-Register landet also im P2-Register und so weiter. Nach acht Takten sind die Werte aller Register über den Ausgang Q8 ausgegeben worden, das höchstwertige Bit von Eingang P8 zuerst. Auch zum Einlesen der an den Pins anliegenden Werte scheint das Taktsignal erforderlich zu sein.

Ein Blick auf die Platine

Jetzt müssen wir nur noch herausfinden, wie das Steuerkreuz und die Buttons des Gamepads an den Chip angeschlossen sind – wo und in welcher Form also die jeweiligen Daten zu erwarten sind. Dazu habe ich den Schaltplan der Platine mit Kicad nachgezeichnet. Insgesamt gibt es hier acht Kontakte, die mit den typischen Gummimatten einen Schalter bilden. Jeder Schalter ist mit einem Eingang des Parallel-Seriell-Wandlers verbunden.

Über einen Pull-up-Widerstand(öffnet im neuen Fenster) ist jeder Eingang zudem mit der Versorgungsspannung verbunden, wodurch er auf dem 1-Pegel liegt. Wird eine Taste gedrückt, schließt dies den entsprechenden Kontakt, der Eingang wird auf Masse und entsprechend den 0-Pegel gezogen. Eine gedrückte Taste erkennen wir also an einer 0 im entsprechenden Bit. Herauszufinden, welchem Bit die einzelnen Tasten zugeordnet sind, ist reine Fleißarbeit. Dazu müssen nur die entsprechenden Leitungen auf der Platine verfolgt werden, heraus kommt der oben abgebildete Schaltplan.

Jetzt müssen die Daten also nur noch vom Gamepad mittels PIO in den Mikrocontroller.

Das PIO-Programm

Die Funktion des Programms ist recht einfach: Es muss zuerst den P/S-Eingang des Wandler-Chips auf 1-Pegel setzen, einen Taktzyklus erzeugen, den Eingang wieder auf 0-Pegel setzen und dann 8 Bits einlesen, wobei parallel das Taktsignal erzeugt wird. Dann sind die Daten des Gamepads komplett ausgelesen und können in die Empfangs-FIFO geschrieben werden.

Das Taktsignal könnte erzeugt werden, indem abwechselnd der entsprechende Pin mittels Set auf 0 und 1 gesetzt wird. Das hat aber zwei Nachteile: Der erste ist, dass die Ausgabe etwas komplizierter nachzuvollziehen wird, da das P/S-Signal ebenfalls mittels Set erzeugt wird. Jedes Set muss passende Werte für beide Pins ausgeben, da die PIO-Hardware sie als eine Einheit sieht. Der zweite Nachteil ist, dass einige Befehle benötigt werden, die eigentlich nichts Sinnvolles tun. Genau für solche Fälle gibt es die Funktion Side Set.

Mittels Side Set kann jeder Befehl die Werte von bis zu fünf Pins setzen – zusätzlich zu seiner eigentlichen Funktion. Außerdem sind Side Set und der Set -Befehl unabhängig, sie können unterschiedliche Pins ansprechen. Anhand des Codes (ich habe wieder alles als Git-Repository(öffnet im neuen Fenster) bereitgestellt) lässt sich das am besten erklären, er ist in nes_gamepad.pio(öffnet im neuen Fenster) zu finden:

        
.program NES_controller_interface
.side_set 1

.wrap_target
set PINS, 1  side 0  [3]
set X, 7     side 1  [3]
set PINS, 0  side 1  [3]

readBits:
    in PINS, 1        side 0  [3]
    jmp X-- readBits  side 1  [3]

irq 0        side 1  [3]

.wrap

Betrachten wir zunächst einmal die einzelnen Befehle, zu den eckigen Klammern und den Zeilen mit Punkt kommen wir noch. Zuerst wird mittels Set eine 1 für das P/S-Signal auf die Pins geschrieben – wo sie herauskommt, bestimmt ein Konfigurationsregister, auch dazu später mehr. Mittels Side Set wird der Takt-Pin auf 0 gesetzt. Um mehrere Pins zu setzen, wird die dem jeweiligen Binärwert entsprechende Zahl genutzt. Beispielsweise würde eine 9 bei vier Pins die beiden mit der höchsten und niedrigsten GPIO-Nummer auf 1 setzen, die beiden anderen auf 0.

Danach wird der Wert 7 in das Arbeitsregister X geladen, das von der folgenden Schleife zum Zählen der Durchläufe genutzt wird. Zusätzlich wird mittels Side Set der Takt-Pin wieder auf 1 gesetzt – der erste Taktzyklus ist beendet, die Daten von den Pins sind ins Register geladen. Um das Schieberegister des HD14021 auslesen zu können, setzt der folgende Befehl den P/S-Pin wieder auf 0, das Taktsignal bleibt auf 1, um nicht bereits das erste Bit hinauszuschieben.

Eine Zählschleife für die Eingabe

Das passiert erst in der Schleife: Es wird ein Bit in das Eingabe-Schieberegister (Input Shift Register, ISR) der SM gelesen, gleichzeitig der Takt-Pin wieder auf 0 gesetzt. Dadurch verschiebt der HD14021 alle Bits um eine Position, im nächsten Schleifendurchlauf lesen wir den Eingang P7 aus. Dafür springt der folgende Jmp -Befehl zum Einlesebefehl zurück, sofern der Wert in X nicht 0 ist. Der Wert in X wird um eins verringert und mittels Side Set das Taktsignal wieder auf 1 gesetzt, um den Taktzyklus zu beenden.

Sprungziele lassen sich mittels sogenannter Labels festlegen, beliebige Folgen von Zahlen und Buchstaben, die mit einem Doppelpunkt enden. Das verbessert die Lesbarkeit. Ist der Wert im Register X bei 0 angekommen, wird der Sprung nicht ausgeführt, das Programm läuft nach unten weiter zum Interrupt-Befehl. Damit werden die Prozessoren informiert, dass neue Daten vorhanden sind. Das Taktsignal bleibt wieder auf 1, um den Zustand des HD14021 nicht zu verändern.

Aber halt, wie kommen denn die gelesenen Daten in die Empfangs-FIFO?

Noch mehr Befehle sparen

Die gelesenen Daten müssen erst einmal in die Empfangs-FIFO übertragen werden. Dazu existiert der Push -Befehl, der dies explizit erledigt, allerdings gibt es noch eine implizite Methode. Die PIO-Module haben ein weiteres nützliches Feature, das Befehle spart: Auto-Push. Damit wird der Inhalt des ISR automatisch in die FIFO verschoben, sobald eine vorgegebene Anzahl Bits eingelesen wurde. Genau das nutze ich hier. Die äquivalente Funktion für die Ausgabe heißt Auto-Pull.

Bleiben noch ein paar Fragen zu klären: Was sollen die eckigen Klammern und die Zeilen mit Punkt? Über die eckigen Klammern kann man Anweisungen eine zusätzliche Verzögerung mitgeben. Normalerweise wird jede Anweisung innerhalb eines PIO-Takts ausgeführt. Manchmal kann es aber erforderlich sein, eine Wartezeit einzufügen, um ein bestimmtes zeitliches Verhalten zu erreichen. Hier dauert jede Anweisung vier Takte (einer für die Ausführung, drei Verzögerung), den Grund sehen wir gleich.

Jede Anweisung kann bis zu 32 zusätzliche Wartetakte haben – es sei denn, Side Set wird verwendet. Für beide zusammen sind in den Befehlscodes fünf Bits vorgesehen. Werden Bits für Side Set genutzt, stehen entsprechend weniger für die Angabe der Wartetakte zur Verfügung. In diesem Beispiel sind nur noch vier Bits für die Wartetakte nutzbar, entsprechend sind maximal 16 möglich.

Anweisungen für den Assembler

Alle Zeilen, die mit einem Punkt beginnen, sind Anweisungen für den Assembler. Er übersetzt den PIO-Programmcode und erstellt daraus eine C-Header-Datei. Dafür braucht er ein paar zusätzliche Informationen, mindestens den Programmnamen. Der steht ganz oben. Danach teile ich ihm mit, dass das Programm ein Side-Set-Bit nutzt. Damit wird automatisch Code eingefügt, der das Feature grundlegend konfiguriert.

Dann finden sich noch die Anweisungen .wrap und .wrap_target . Sie sind wieder ein Feature zum Einsparen von Befehlen: Mit ihnen lässt sich ein Punkt festlegen, an dem das Programm automatisch an einen anderen springt – ohne explizites Jmp . Meist sind das wie hier Anfang und Ende des Programms, die Punkte können aber beliebig definiert werden. Allerdings kann das Feature nur einmal pro Programm genutzt werden.

Übersetzen und Einbinden in ein Projekt

PIO-Programme zu übersetzen ist nicht besonders schwer, sofern das Build-System Cmake genutzt wird. Es wird vom Software Development Kit (SDK) des RP2040 verwendet, daher liegt es nahe, es für die eigenen Projekte ebenfalls zu nutzen. Hierfür ist ein Eintrag in der Konfigurationsdatei CMakeLists.txt(öffnet im neuen Fenster) erforderlich, die dem Build-System sagt, was zu tun ist, um aus dem Quellcode ein Programm für den Mikrocontroller zu machen. Lediglich folgende Zeile muss eingefügt werden:

        
pico_generate_pio_header(gptest ${CMAKE_CURRENT_LIST_DIR}/nes_gamepad.pio)

Damit erledigt Cmake alles Erforderliche, das Ergebnis ist eine C-Header-Datei im build-Verzeichnis. Die heißt so wie die PIO-Quellcode-Datei, lediglich mit angehängtem .h , und enthält den übersetzten Programmcode, einige Konstanten und eine Hilfsfunktion, die eine Standardkonfiguration für einen Zustandsautomaten erzeugt. Darüber hinaus kann zusätzlicher C-Code in die PIO-Quellcode-Datei eingefügt werden, der weitere Konfigurationen vornimmt. Er wird einfach in die erstellte Header-Datei kopiert und ermöglicht es, alles erforderliche Wissen in eine Datei zu packen – sinnvoll, wenn man seine PIO-Programme teilen will.

Sehen wir uns als nächstes an, wie das PIO-Programm genutzt wird und was noch an Konfiguration erforderlich ist.

Konfiguration der State Machine

Das PIO-Programm allein tut noch nichts, es muss zuerst durch einen der Prozessorkerne in den Programmspeicher eines PIO-Moduls geladen werden. Dann kann es einer State Machine zugewiesen und diese konfiguriert werden. Dabei muss mindestens festgelegt werden, welche GPIO-Pins für die einzelnen Funktionen – Ein- und Ausgabe sowie Set und Side Set – verwendet werden sollen. Sie müssen zudem passend konfiguriert werden.

Außerdem benötigen wir ein Programm, das die eingelesenen Daten verarbeitet. Dessen Aufbau hängt davon ab, wie das jeweilige PIO-Programm funktioniert. Für das Gamepad habe ich mir Folgendes überlegt: Anstatt das Auslesen des Gamepads aktiv im C-Programm anzustoßen, läuft das PIO-Programm die ganze Zeit, allerdings so langsam, dass nur 50 Abfragen pro Sekunde erfolgen. Nach jedem vollständigen Auslesen des HD14021 wird ein Interrupt ausgelöst, darauf reagiert eine Interrupt Service Routine (ISR, eine spezielle Funktion). Sie liest den aktuellen Wert aus der Empfangs-FIFO und schreibt ihn an eine konfigurierbare Speicheradresse.

Damit muss sich die eigentliche Anwendung nicht um das Gamepad kümmern, einmal gestartet kommen die neuen Werte automatisch. Werfen wir einen Blick auf den Code, zuerst die Konfiguration der SM, für die es in der PIO-Quellcode-Datei eine Funktion gibt:

        
static inline void nesControllerProgramInit(PIO pio, uint sm, uint offset, uint dataPin, uint clkPin, uint psPin) {
    pio_gpio_init(pio, dataPin);
    pio_gpio_init(pio, clkPin);
    pio_gpio_init(pio, psPin);

    pio_sm_set_pindirs_with_mask(pio, sm, (1 < clkPin) | (1 < psPin), (1 < dataPin) | (1 < clkPin) | (1 < psPin));

    pio_sm_config c = NES_controller_interface_program_get_default_config(offset);

    sm_config_set_in_pins(&c, dataPin);
    sm_config_set_set_pins(&c, psPin, 1);
    sm_config_set_sideset_pins(&c, clkPin);

    sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_RX);
    sm_config_set_clkdiv_int_frac(&c, 35400, 0);
    sm_config_set_in_shift(&c, false, true, 8);

    pio_sm_init(pio, sm, offset, &c);
}

Die Pins konfigurieren

Die ersten drei Zeilen machen alle das Gleiche, nur für einen anderen der drei vom PIO-Programm genutzten Pins: Sie verbinden das verwendete PIO-Modul mit dem entsprechenden Pin. Das ist erforderlich, da die Pins des RP2040 von mehreren Funktionseinheiten genutzt werden können. Daher muss festgelegt werden, an welche sie angebunden werden sollen.

Danach wird festgelegt, ob die Pins als Ein- oder Ausgang verwendet werden. Die beiden letzten Argumente der dafür genutzten Funktion mögen etwas verwirrend wirken. Sie besagen lediglich, welche Pins konfiguriert werden sollen (letztes Argument) und welche Funktion sie haben (vorletztes Argument). Alle Pins, für die bei beiden Argumenten eine 1 auftaucht, werden zu Ausgängen. Taucht nur im letzten Argument eine 1 auf, wird der Pin als Eingang konfiguriert, hier also dataPin .

Danach wird eine Standardkonfiguration für die genutzte SM geladen. Die aufgerufene Funktion hat der PIO-Assembler erstellt, hier wird bereits die Side-Set-Funktion konfiguriert. Anschließend wird festgelegt, welche PIO-Befehle welche Pins verwenden. Zuerst wird dataPin als Eingangs-Pin definiert, jedes In liest also von dem hier angegebenen Pin. Analog schreibt Set auf psPin , Side Sets werden auf clkPin ausgegeben.

Die Funktion der SM konfigurieren

Die folgenden drei Funktionen konfigurieren Funktionen der SM. Zuerst wird festgelegt, dass lediglich eine Empfangs-FIFO verwendet werden soll. Werden Daten entweder nur aus- oder eingegeben, können die beiden FIFOs der SM zu einer doppelt so großen zusammengefasst werden. Danach wird der Taktteiler der SM konfiguriert. Er ermöglicht es, die Ausführungsgeschwindigkeit des PIO-Codes zu reduzieren. Die PIO-Module führen Programme normalerweise mit Systemtakt aus, also standardmäßig mit 133 MHz. Das ist für die meisten Schnittstellen viel zu schnell, der Taktteiler verlangsamt die Ausführung.

Um das Gamepad mit 50 Hz abzufragen, muss der SM-Takt auf 1 kHz reduziert werden, da jede Abfrage 20 Befehle umfasst. Allerdings kann der Systemtakt maximal durch 65.536 geteilt werden, damit liefe das PIO-Programm zu schnell. Aus diesem Grund habe ich allen Befehlen im PIO-Programm noch drei Verzögerungstakte spendiert, dann wird die gewünschte Abfragerate mit 4 kHz erreicht. Der Taktteiler unterstützt übrigens auch nicht-ganzzahlige Teiler, hierfür würde der letzte Parameter der verwendeten Funktion genutzt, der hier 0 ist.

Zuletzt wird noch das Verhalten des Eingangs-Schieberegisters ISR angepasst. Der zweite Parameter ( false ) besagt, dass das Register nach links schieben soll, mit jeder Eingabe rutschen die Bits im Register an die nächsthöhere Position. Damit befindet sich der von Eingangs-Pin 8 des HD14021 gelesene Wert am Ende in Bit 8 des ausgegebenen Werts. Die beiden letzten Parameter konfigurieren Auto Push, der erste aktiviert die Funktion, der zweite gibt an, nach wie vielen gelesenen Bits der Inhalt des ISR in die Empfangs-FIFO verschoben wird. Wenig verwunderlich sind das hier acht.

Abschließend wird die angepasste Konfiguration mit pio_sm_init() in die Register der ausgewählten SM kopiert. Sie ist damit fertig vorbereitet, muss aber noch gestartet werden. Dafür gibt es eine kleine weitere Funktion.

Programm aktivieren, testen und letzte Worte

Jetzt sind wir fast so weit, das PIO-Programm zu testen. Es müssen lediglich das zu verwendende PIO-Modul und die SM ausgewählt, das Programm geladen und gestartet sowie die ISR konfiguriert werden. Dafür habe ich eine kleine Funktion geschrieben (in nes_gamepad.c(öffnet im neuen Fenster) ). Betrachten wir Laden und Starten des Programms sowie die Konfiguration der ISR:

        
pioProgOffset = pio_add_program(pio, &NES_controller_interface_program);

nesControllerProgramInit(pio, (uint) pioSm, pioProgOffset, dataPin, clkPin, psPin);

irq_set_exclusive_handler(pio_get_index(pio) ? PIO1_IRQ_0 : PIO0_IRQ_0, gamepadIrqHandler);
irq_set_enabled(pio_get_index(pio) ? PIO1_IRQ_0 : PIO0_IRQ_0, true);
irq_set_priority(pio_get_index(pio) ? PIO1_IRQ_0 : PIO0_IRQ_0, PICO_LOWEST_IRQ_PRIORITY);
pio_set_irq0_source_enabled(pio, pis_interrupt0 + pioSm, true);

pio_sm_set_enabled(pio, pioSm, true);

Interrupt konfigurieren

Der interessante Teil ist dabei die Konfiguration des Interrupts. Hierfür teilen wir dem Interrupt-Controller des RP2040 zuerst über die Funktion irq_set_exclusive_handler() mit, welche Funktion er aufrufen soll, wenn der Interrupt ausgelöst wird. Jedes der beiden PIO-Module hat zwei Interrupt-Signale (0 und 1), so können verschiedene Quellen, beispielsweise verschiedene SMs, leichter unterschieden werden. Hier wird das erste Interrupt-Signal (0) verwendet, mit pio_get_index() lassen sich die beiden PIO-Module unterscheiden und das entsprechende Signal auswählen. Danach wird der Interrupt mit irq_set_enabled() im Interrupt-Controller aktiviert.

Interrupts können zudem verschiedene Prioritäten haben, mit der Funktion irq_set_priority() wird der Gamepad-Schnittstelle hier die niedrigste zugewiesen. So lassen sich zeitkritische Unterbrechungen bevorzugen, beispielsweise für die Ausgabe von Audio- oder Videodaten. Abschließend muss im PIO-Modul noch konfiguriert werden, dass Interrupt 0 der für das Gamepad verwendeten SM auf Interrupt 0 des Moduls geleitet werden soll, andernfalls käme er nicht beim Interrupt-Controller an. Dazu bringt das SDK die Funktion pio_set_irq0_source_enabled() mit. In der letzten Zeile wird die SM, die das Gamepad-Programm abarbeitet, aktiviert. Damit wird das Programm ausgeführt und beginnt, das Gamepad abzufragen.

Das Testprogramm

Zum Testen habe ich noch ein kleines Programm geschrieben ( gptest.c(öffnet im neuen Fenster) ). Es startet das PIO-Programm und legt dabei das zu verwendende PIO-Modul (0) und die zu nutzenden Pins fest. Ich habe folgende Pins verwendet: Pin 6 als Dateneingang, Pin 7 für das P/S-Signal und Pin 8 für den Takt. Hier der Aufruf der Start-Funktion:

        
gamepadStart(pio0, 6, 8, 7, &gpState)

Außerdem wird die ISR angewiesen, den jeweils aktuellen gelesenen Wert in die Variable gpState zu kopieren. Sie liest das Programm dann fünfmal pro Sekunde aus, prüft, welche Tasten gedrückt sind, und gibt deren Bezeichnung als Text aus.

Fazit und letzte Worte

Für mich ist PIO ein echtes Killer-Feature des RP2040, einen so breiten Funktionsumfang kannte ich von anderen Mikrocontrollern zuvor nicht. PIO macht einen Mikrocontroller zu einer echten FPGA-Alternative, da komplexe und vor allem leistungsfähige Schnittstellen einfach und flexibel realisiert werden können.

Allerdings hat PIO eine unangenehme Gemeinsamkeit mit FPGAs: Die Programmentwicklung ist keinesfalls trivial. Es gibt keine einfache Möglichkeit, Programme zu testen oder zu debuggen. Zwar hat jemand einen Emulator(öffnet im neuen Fenster) entwickelt, der ist aber so komplex, dass ich bislang immer das Oszilloskop vorgezogen habe. Ohne dieses Hilfsmittel wäre ich ziemlich aufgeschmissen gewesen, denn anfangs machte das Programm ziemliche Probleme.

Der Fehler lag allerdings nicht im Code, sondern in einem USB-Kabel mit Wackelkontakt und einem ausgeleierten Kontakt des Bread Boards. Durch den schlechten Kontakt kamen beim Gamepad nur 1,6 statt 3,3 V an, was, wenig verwunderlich, zu seltsamem Verhalten des Wandler-Chips führte. Ohne eine Möglichkeit, das Signal zu betrachten, hätte ich einfach nur gesehen: Es funktioniert nicht. Ganz so komfortabel wie Softwareentwicklung ist Schnittstellenentwicklung also nicht – aber die vielen neuen Möglichkeiten sind das wert.


Relevante Themen