I/O on Steroids - PIO, die programmierbare Ein-/Ausgabe des Raspberry Pi Pico

Der Pragmatische Architekt Michael Stal  –  12 Kommentare

Moderne Mikrocontroller-Boards müssen eine ganze Menge von Schnittstellen integrieren wie UARTs, IIS, IIC, SPI. Was aber, wenn eine benötigte Schnittstelle fehlt, etwa 1-Wire oder CAN? Für solche Fälle bietet der Raspberry Pi Pico die PIO (Programmable Input Output). Der vorliegende Blog-Post gliedert sich in zwei Teile: Im ersten ist die Funktionsweise einer PIO Gegenstand der Betrachtung, während der zweite Teil untersucht, wie sich PIOs in der Praxis nutzen lassen.

Motivation

Auf einem Raspberry Pi Pico finden sich keine Komponenten für Bluetooth oder WiFi. Nicht nur deshalb wäre ein Vergleich mit Mikrocontrollern auf Basis von ESP8266 beziehungsweise ESP32 ein Vergleich von Äpfel und Birnen. Eher entspricht der Pico mit seinen Anwendungsmöglichkeiten leistungsfähigen Arduino-Boards.

Was den Pico aber gegenüber anderen Boards auszeichnet, ist seine Möglichkeit, I/O-Pins programmatisch über eine eigene Hardwarekomponente des RP2040-Chips namens PIO (Programmable I/O) zu steuern. Jeder der zwei vorhandenen PIO-Blöcke enthält zu diesem Zweck vier Zustandsmaschinen, auf denen in PIO-Assembler geschriebene Programme ablaufen. Assemblerprogrammierung mag bei manchem ungute Assoziationen wecken. Allerdings bestehen PIO-Programme beim Pico aus lediglich neun relativ einfachen Befehlen.

Vorteil des PIO-Konzepts ist die sich daraus ergebende große Flexibilität und Anwendungsvielfalt. Unter anderem haben Maker damit schon VGA-Bildschirme, WS2812b-LEDs und Schrittmotoren angesteuert oder HW-Schnittstellen wie UART realisiert. Natürlich sind diese Beispiele grundsätzlich auch über ein rein in Software gegossenes Bit-Banging umsetzbar, was aber in der Praxis mit vielen Problemen verbunden ist, etwa bezüglich genauem Timing und Wartbarkeit, sowie der hohen Belastung der Rechnerkerne.

Zustandsmaschinen und I/O-Mappings
Jeder der beiden PIO-Blocks enthält vier Zustandsmaschinen, die über den Bus mit der Außenwelt verbunden sind (Bild: Raspberry Pi Foundation)

Ein Raspberry Pi Pico enthält zwei PIO-Blöcke (pio0 und pio1) mit je vier Zustandsmaschinen, die sich einen Speicher von 32 Instruktionen teilen. Instruktionen haben eine Länge von 16 Bit. Für jedes Programm auf einer Zustandsmaschine definieren Entwickler ein I/O-Mapping, das alle GPIO-Pins umfasst, die das Programm nutzen möchte. Ein Mapping legt ein Fenster fest, das aus einem GPIO-Start-Pin und der Zahl der im Mapping eingeschlossenen GPIOs besteht. Eigentlich bietet der Raspberry Pi Pico nur 30 GPIOs, aber das Mapping-Register hat zwei virtuelle GPIOs hinzugefügt (GPIO30 und GPIO31). Kein Wunder, besteht das Mapping-Register doch aus 32 Bits. Beispiels-Mappings sind etwa [GPIO4, GPIO5, GPIO6] oder [GPIO 28, GPIO29, GPIO30, GPIO31, GPIO0, GPIO1]. Insofern repräsentieren sie Fenster auf einen Bereich aufeinanderfolgender GPIOs. Wie im zweiten Beispiel zu sehen ist, folgt auf GPIO31 zyklisch wieder GPIO0. Die im Mapping enthaltenen Pins können sowohl zur Eingabe als auch zur Ausgabe dienen. Genau genommen sind es sogar vier Mappings:

Die vier Mappings der PIO
  • Ein in(put)-Mapping, das aus einem einzelnen Basis-Eingabe-GPIO besteht.
  • Ein out(put)-Mapping, das bis zu 32 GPIO-Pins enthält, wobei die einzelnen GPIOs entweder als Ein- oder Ausgabe-GPIOs fungieren können.
  • Ein set-Mapping mit bis zu 5 GPIOs.
  • Ein side-(set)-Mapping mit bis zu 5 GPIOs.

Das Basis-GPIO jedes Mappings zählt aus Sicht der Zustandsmaschine als Pin 0, die darauffolgenden Pins sind dementsprechend Pin 1, Pin 2, Pin 3, usw. Die vier Mappings dürfen sich sogar überlappen. Es gibt in dieser Hinsicht keinerlei Einschränkungen.

Die Namen der Mappings (in, out, set, side-set) beziehen sich auf die gleichnamigen Kommandos für Einlesen, Auslesen, Setzen von GPIO-Pins, von denen später noch die Rede sein soll.

Interaktion

Zur Kommunikation mit der Außenwelt bieten sich einer PIO-Zustandsmaschine neben I/O zwei Möglichkeiten:

  1. Sie kann einen IRQ nutzen (Interrupt Request), um sich mit den RP2040-Cores oder mit anderen Zustandsmaschinen zu synchronisieren.
  2. Sie kann von einem RP2040-Core Daten erhalten (pull) oder nach dort senden (push).

Da sich alle Zustandsmaschinen eines PIO-Blocks einen gemeinsamen Programmspeicher teilen, können mehrere Zustandsmaschinen dasselbe Programm ausführen.
Beispiel: Jede Zustandsmaschine bedient einen separaten LED-Strip.

Oder sie nehmen unterschiedliche Aufgaben für dieselbe Schnittstelle wahr.
Beispiel: Eine Zustandsmaschine implementiert den RX-Teil (Empfangen von Daten) eines UART, während sich eine andere Zustandsmaschine um die TX-Komponente (Senden von Daten) kümmert.

Sie können natürlich auch unterschiedliche Programme ausführen, um verschiedene Arten von I/O-Schnittstellen parallel zu implementieren.

Das Innenleben einer Zustandsmaschine mit Schieberegistern ISR und OSR, Scratchregistern X und Y, dem Clock-Divider, den Program Counter, und der Kontrollogik (Bild: Raspberry Pi Foundation)

Eine Zustandsmaschine enthält eine Warteschlange TX-FIFO mit einer Kapazität von vier 32-Bit-Worten, die dem Entgegennehmen von Daten aus der CPU dient. Diese Daten holt sie sich über einen Pull-Befehl in das sogenannte OSR (Output-Shift-Register). Durch Links- oder Rechts-Schiebe-Operationen auf dem OSR lassen sich ein oder mehrere Bits zwecks Ausgabe an die GPIOs senden. Insofern stellt das OSR sowohl die Schnittstelle zwischen Zustandsmaschine und Ausgabe-GPIOs als auch die zwischen Zustandsmaschine und CPU-Cores dar. Durch Pull kann ein PIO-Programm Daten von der CPU über die TX-FIFO lesen.

Die TX-FIFO dient zum Schreiben von Daten über das OSR auf die Ausgabe-Pins sowie zum Empfangen von Informationen von den CPU-Cores (Bild: Raspberry Pi Foundation)

Es gibt eine weitere Warteschlange namens RX-FIFO, die ebenfalls eine Kommunikation mit der Außenwelt implementiert. Die RX-FIFO-Warteschlange umfasst vier Slots mit 32-Bit-Worten. Ihre Daten enthält sie im Normalfall vom Input-Shift-Register, dem ISR, das dem Einlesen von digitalen Pins dient. Somit bildet das ISR die Schnittstelle zwischen GPIO-Pins und der RX-FIFO. Werte der Eingabe-Pins lassen sich bitweise ins ISR übermitteln, das dazu mit (Left- oder Right-)Shift-Operationen arbeitet. Der push-Befehl überträgt den Inhalt des ISR an die RX-FIFO, von wo sie ein Hauptprogramm in der CPU / MCU übernimmt.

Die RX-FIFO dient zum Einlesen von Daten aus dem ISR sowie zum Senden von Informationen an die CPU-Cores

Die beiden FIFOs können Entwickler auch zu einer einzelnen Eingabe-Queue (RX-FIFO) oder zu einer einzigen Ausgabe-Queue (TX-FIFO) mit je acht 32-Bit-Worten kombinieren, das sogenannte FIFO-Join.

Wird nur eine Warteschlange (TX oder RX) benötigt, lassen sich beide Warteschlangen zu einer großen (RX oder TX)-Warteschlange kombinieren (Bild: Raspberry Pi Foundation)

Ein Pull-Befehl auf der TX-FIFO blockiert, solange dort keine Daten vorliegen.

Ein Push-Befehl blockiert, solange die RX-FIFO voll ist, weil keine Daten von der CPU abgeholt wurden.

Des Weiteren besteht die Möglichkeit, für eine Zustandsmaschine Parameter wie AUTOPUSH oder AUTOPULL mit entsprechenden Schwellwerten zu definieren, die automatisiert push- und pull-Operationen beim Einreichen dieser Schwellwerte ausführen.

DMA & PIO

Eine weitere Möglichkeit soll nicht unerwähnt bleiben. Ein Raspberry Pi Pico enthält einen DMA-Controller, der ohne Belastung der Rechnerkerne schnelle Speichertransfers durchführen kann, ein 32-Bit-Wort pro Maschinenzyklus. Diese Daten können als Ziel auch eine Zustandsmaschine haben. Beispielanwendung: Die Zustandsmaschine implementiert eine VGA-Schnittstelle, und enthält die Grafikdaten über DMA. Würde dieser Transfer über die CPU erfolgen, wäre er zum einen langsamer und würde zum anderen die CPU belasten.

Alle Register ziehen

Neben den Registern ISR und OSR existieren noch zwei allgemein nutzbare Register X und Y, die sogenannten Scratch-Register.
Ein ungenutztes ISR oder OSR können Programme nach eigenem Gusto ebenfalls als Register einsetzen.
Zusätzlich gibt es einen PC (Program Counter), der auf den nächsten auszuführenden Befehl im gemeinsamen Befehlsspeicher verweist. Dieser ist sogar programmatisch nutzbar, wie weiter unten erläutert.

Auf Befehl

Programme auf der Zustandsmaschine können aus lediglich 9 Befehlen auswählen, die dafür einige Flexibilität erlauben. Wichtig zu wissen: Die Abarbeitung eines Befehls benötigt stets einen Maschinenzyklus, dessen Zeitdauer sich aus 1 geteilt durch 133.000.000 Hz berechnet. Das ergibt eine Zykluszeit von ungefähr 7,5 Nanosekunden. Wie wir später kennenlernen, lässt sich die Geschwindigkeit der PIO-Blöcke auch auf kleinere Frequenzen beziehungsweise höhere Zykluszeiten einstellen.

Zustandsmaschinen werden über 9 Instruktionen programmiert (Bild: Raspberry Pi Foundation)

In den nachfolgenden Abschnitten folgt nun die komplette Liste der Instruktionen.

PUSH (iffull) (block | noblock)
(Bild: Raspberry Pi Foundation)

kopiert den Inhalt des ISR (Input-Shift-Register) in die RX-FIFO und löscht danach den Inhalt des ISR. Bei Angabe von iffull geschieht dies ausschließlich dann, wenn das ISR eine konfigurierbare Zahl von Bits enthält. Ist der Befehl mit der Option block versehen, wartet die Zustandsmaschine so lange bis die RX-FIFO Platz für die Daten aus dem ISR vorweist. Hingegen würde bei Angabe von noblock die Zustandsmaschine zum nächsten Befehl springen, sofern das RX-FIFO belegt sein sollte, aber gleichzeitig den Inhalt des ISR löschen. PUSH ohne Argumente ist übrigens gleichbedeutend mit PUSH block.

PULL (ifempty) (block | noblock)
(Bild: Raspberry Pi Foundation)

überträgt den Inhalt der TX-FIFO in das OSR (Output-Shift-Register). Eine Angabe von ifempty bewirkt, dass dieser Befehl nur durchgeführt wird, wenn das OSR keinen Inhalt aufweist - es ist folglich leer. Bei Angabe von block wartet die Zustandsmaschine solange blockierend bis Daten in der TX-FIFO vorliegen. Bei noblock springt die Zustandsmaschine unverrichteter Dinge zum nächsten Befehl, sollte die TX-FIFO leer sein. PULL ohne Argumente ist gleichbedeutend mit PULL block. C- oder Python-Programme können über die TX-FIFO direkt Instruktionen an die Zustandsmaschine senden.

Ebenso besteht die Option, direkt Instruktionen in das INSTRUCTION-Register der Zustandsmaschine zu schreiben, worauf sie die Instruktion ausführt. Danach setzt sie mit dem nächsten vom PC (Program Counter) adressierten Befehl fort. Achtung: Solche Instruktionen können auch gezielt den PC ändern. Wie das programmatisch aussieht, folgt weiter unten.

JMP (Bedingung) Sprungziel
(Bild: Raspberry Pi Foundation)

dient, nomen est omen, für bedingte und unbedingte Sprünge an eine Zieladresse. Das Sprungziel kann eine physikalische Adresse von 0 bis 31 sein, oder ein logisches Sprungziel, also ein Label, das der Assembler durch die Physikalische Adresse ersetzt. Die Bedingung ist optional, weshalb etwa

jmp ende 

einen sofortigen Sprung zum Label „ende“ bewirkt.

Der Sprung kann auch abhängig von einer Bedingung erfolgen:

!X bzw. !Y => Sprung erfolgt, falls der Inhalt des X- bzw. Y-Registers gleich 0 ist.

X-- bzw. Y-- => Sprung erfolgt, sofern X beziehungsweise Y ungleich 0 ist. Mit dem Sprung wird gleichzeitig X bzw. Y dekrementiert.

X!=Y => Sprung erfolgt, wenn X und Y unterschiedliche Werte aufweisen.

PIN => Sprung erfolgt, falls Pin auf HIGH.

!OSRE => Sprung erfolgt, falls das OSR (Output-Shift-Register) nicht leer ist. Somit steht OSRE für Output-Shift-Register Empty.

IN Quelle, 1-32
(Bild: Raspberry Pi Foundation)

bewirkt das Kopieren von 1-32 Bits aus einer Quelle in das Input-Shift-Register. Die Quelle kann sein:

ISR

OSR

PINS ————- PINS verwendet dabei das In-GPIO-Mapping.

X

Y

NULL

OUT Ziel, 1-32
(Bild: Raspberry Pi Foundation)

kopiert die definierte Zahl an Bits vom OSR zum Ziel, wobei das Ziel sein kann:

PINS —- GPIO-Pins gemäß dem Out-Mapping

X

Y

ISR

NULL

PINDIRS —- Durch Angabe von PINDIRS als Ziel lässt sich für die Pins des Out-Mappings festlegen, ob sie als Ausgabe-Pin dienen sollen (1) oder als Eingabe-Pin (0).

Mit

   out pindirs, 3  

würden somit die ersten 3 Pins des Mappings entweder als Ausgabe-oder Eingabe-GPIOs je nach Belegung der entsprechenden Bits im OSR festgelegt. Auch der Program Counter PC lässt sich mit OUT verändern.

Ein

   out pc, 5

führt dazu, dass der Programmzähler auf den in den entsprechenden 5 Bits des OSR gespeicherten Werts eingestellt wird. Wie erwähnt: Der Programmspeicher besteht aus 32 16-Bit-Worten, weshalb zur Adressierung 5 Bits ausreichen.

Bei

    out exec, 16 

interpretiert die Zustandsmaschine 16 Bits des OSRs als Kommando und führt dieses aus. Der Wert wird im Instruktionsregister abgelegt.

SET Ziel, 0-31
(Bild: Raspberry Pi Foundation)

kopiert den Wert (0..31) in das Ziel. Ziel kann sein:

PINS, also die im set-Mapping angegebenen GPIO-Pins. Es werden somit die entsprechenden Bits über die bis zu fünf Pins des set-Mappings ausgegeben.

PINDIRS set pindirs, 3 legt beispielsweise die ersten 2 Pins des set-Mappings als Ausgabe-Pins fest.

X Auch die Scratch-Register x und y können als Ziel auftauchen

Y

MOV Ziel, Quelle
(Bild: Raspberry Pi Foundation)

bewegt, wenig überraschend, den Inhalt der Quelle zum Ziel. Ziele können sein:

  • PINS gemäß out-Mapping
  • EXEC
  • ISR
  • OSR
  • PC
  • X
  • Y

Quellen können sein:

  • PINS
  • X
  • Y
  • ISR
  • OSR
  • NULL
  • STATUS

Bislang blieb die Quelle STATUS unerwähnt. Sie lässt sich gemäß Anforderungen des Entwicklers konfigurieren, etwa als „TX-FIFO leer“ oder „TX-FIFO voll“.

Übrigens: Stellen Programmierer der Quelle ein ! oder ~ voran, kopiert MOV den invertierten Wert der Quelle. Beim Voranstellen von :: kopiert MOV die Bits der Quelle in umgekehrter Reihenfolge.

IRQ (option) IRQ-Nummer (_rel)
(Bild: Raspberry Pi Foundation)

dient zum Auslösen eines der acht möglichen Interrupts 0 bis 7. Dadurch synchronisieren sich Zustandsmaschinen untereinander (siehe WAIT-Kommando) oder mit der CPU. Mit dem optionalen _rel sind IRQs abhängig von der Zustandsmaschine nutzbar.

Dabei erfolgt folgende Operation: ((0b11 & Nummer-des-IRQ) + Nummer-der Zustandsmachinen) mod 4. Die logische And-Operation extrahiert also die letzten 2 Bits der IRQ-Nummer.

Beispiel: Für IRQ 6 (= binär 0b110) und Zustandsmachine 2 (0b10) ergibt sich 4 % 4 = 0.

Mögliche Optionen:

set nowait IRQnr     => das IRQ Flag ohne Prüfung setzen ohne zu warten.
set wait IRQnr       => das Flag erst dann setzen, nachdem es woanders auf 0 gesetzt wurde. Das ist ein Mechanismus, um sich mit anderen Zustandsmaschinen zu synchronisieren.
set IRQnr clear      => Flag löschen ohne zu warten.
WAIT
(Bild: Raspberry Pi Foundation)
WAIT Polarität GPIO Nummer 

wartet bis das GPIO mit der angegebenen Nummer den in Polarität angebenen Wert (0 oder 1) erreicht. Mit Nummer ist die tatsächliche GPIO-Nummer auf dem Pico gemeint.

WAIT Polarität PIN Nummer 

verhält sich wie obere Variante, nutzt aber das in-Mapping für die Nummerierung der Pins.

WAIT Polarität IRQ Nummer (_rel)

wartet auf das Setzen eines Interrupts von außen. Achtung! Polarität verhält sich hier anders als bei der ersten Variante: Ist Polarität = 1, löscht der Befehl das IRQ-Flag, sobald es gesetzt wurde. Ist Polarität = 0, bleibt das Flag hingegen unverändert. Das optionale _rel verhält sich wie bei der IRQ-Instruktion.

Teilweise ist in PIOASM-Programmen, etwa in MicroPython oder in C, auch der Befehl NOP zu sehen. Diese Instruktion gibt es nicht wirklich. Stattdessen ersetzt der Assembler diese Pseudoinstruktion durch

mov y,y
Von Delays und Clock Dividern

Wer I/O-Schnittstellen programmiert, ist sich im allgemeinen darüber bewusst, dass Timing-Probleme zu den häufigsten Herausforderungen zählen. Zum Beispiel sind für LEDs des Typs WS2812b genau getaktete Signal-Flanken notwendig, die sich über eine genau festgelegte Anzahl von Zyklen erstrecken. Zustandsmaschinen offerieren dafür Delays gemessen in Maschinenzyklen, die neben Befehlen in eckigen Klammern stehen, etwa:

set pins, 1  [4] 
set pins, 0

Hier wird am ersten im set-Mapping definierten GPIO-Pin ein HIGH-Signal ausgegeben. Dieser Befehl benötigt einen Maschinenzyklus. Die Anweisung [4] rechts daneben veranlasst die Zustandsmaschine, weitere 4 Maschinenzyklen dranzuhängen, wodurch das HIGH-Signal für 5 Maschinenzyklen anliegt, bevor der nächste set-Befehl das GPIO-Pin wieder auf LOW setzt. Da für Delays 5 Bits vorhanden sind, lassen sich zu jeder Instruktion maximal 31 Wartezyklen hinzufügen.

Allerdings nützen Delays alleine nichts, würde die Zustandsmaschine stets mit der vollen Pico-Freqenz von 133 MHz laufen. Zum Glück können Entwickler die Frequenz konfigurieren, indem sie einen 16-Bit Teiler definieren, den Clock Divider.

Die minimal mögliche Taktfrequenz einer Zustandsmaschine liegt daher bei etwa 2029 Hz = 133.000.000 Hz / 65536. Daraus ergibt sich eine maximale Dauer des Zustandsmaschinenzyklus von 0,492 Millisekunden. Es lassen sich zwar niedrigere Frequenzen konfigurieren, die aber zu Instabilität führen beziehungsweise nicht funktionieren. Benötigt der Entwickler längere Zykluszeiten, kann er sie zusätzlich mit Delays kombinieren.

Side-set

Wer sich gefragt hat, was es denn mit dem side-set-I/O-Mapping auf sich hat, erhält nun die Antwort: Wie gesagt, definiert das Mapping bis zu 5 aufeinanderfolgende GPIO-Pins. Mit dem side-set haben Entwickler die Möglichkeit parallel zu einer Instruktionsausführung bis zu 5 Pins auf HIGH oder LOW zu setzen.

Das sieht im Assemblercode folgendermaßen aus:

set pins, 1   side 0;  Side-set erfolgt auch, wenn die Instruktion hängt

Es wird das erste Bit des OSR am GPIO-Pin ausgegeben (gemäß out-Mapping) und danach fünf Wartezyklen eingelegt. Parallel gibt die Zustandsmaschine 0 an allen im side-set-Mapping definierten GPIO-Pins aus. Ein „side 1“ würde an den entsprechenden GPIO-Pins 1 ausgeben.
Im PIO-Programm muss der Entwickler dafür spezifizieren:

.side_set 1

Mittels der Variante

.side_set 1 pindirs

lassen sich die side-Anweisungen alternativ dafür nutzen, um keine Ausgaben an den „side-Pins“ vorzunehmen sondern stattdessen deren Richtung festzulegen. Ein side 0 definiert in der Folge die entsprechenden Pins als Eingabe-Pins, während side 1 sie zu Ausgabe-Pins macht.

Das Einsetzen von side_set hat aber einen Preis, weil die Anweisung dafür 1 Bit vom möglichen Delay stiehlt. Dadurch bleiben nur 4 Bits für Delays übrig, was Werten von 0, 1, 2, ... ,15 entspricht. Zudem ist bei jeder Instruktion verpflichtend ein side 0 oder side 1 anzugeben.

Wer side-Befehle benötigt, aber sie nur bei einzelnen Instruktionen, also optional verwenden möchte, gibt im PIO-Assemblerprogramm .side_set 1 opt an, was aber ein weiteres Bit des Delays kostet, sodass nur noch Delays von 0 bis 7 möglich sind.

Der Wrapper

Ein PIO-Programm, das ein Blinken der eingebauten LED erzeugt, das richtige set-Mapping vorausgesetzt, könnte wie folgt aussehen:

.program blinker
; eventuell weitere Instruktionen
start:
set pins, 1 ; LED ein
set pins, 0 ; LED aus
jmp start ; Let‘s do it again

Da die meisten PIO-Programme mit einer unendlichen Schleife arbeiten, erweist sich der jmp-Befehl am Ende des Programms als notwendig. Diese Instruktion reduziert gleichzeitig die mögliche Zahl nutzbarer Anweisungen in einem PIO-Block um 1. Noch dazu ist das Programm „asymmetrisch“, da die Ausgabe von 1 einen Maschinenzyklus dauert, während die Ausgabe von 0 wegen jmp sich über zwei Maschinenzyklen erstreckt.

Abhilfe könnte natürlich ein Delay schaffen:

.program blinker
; eventuell weitere Instruktionen
start:
set pins, 1 [1] ; LED ein und einen Zyklus dranhängen
set pins, 0 ; LED aus
jmp start ; Let‘s do it again

Jetzt dauern sowohl die Erleuchtung als auch die Verdunklung gleich lang, nämlich zwei Maschinenzyklen.
Da die Endlosschleife einen verbreiteten Anwendungsfall darstellt, haben die Entwickler der PIO hierfür Abhilfe geschaffen:

.program blinker
; eventuell weitere Instruktionen
.wrap_target
set pins, 1 ; LED ein und einen Zyklus dranhängen
set pins, 0 ; LED aus
.wrap ; Let‘s do it again

Mittels .wrap legt der Entwickler einen Sprung fest, der an der mit .wrap_target markierten Stelle landet. Vorteil: Diese Anweisungen beziehungsweise Attribute benötigen keinen eigenen Befehl, sodass die Zahl der benötigten Anweisungen sich um 1 reduziert. Im obigen Beispiel liegt somit für einen Maschinenzyklus 1 und für einen Maschinenzyklus 0 an. Die LED blinkt regelmäßig.

Simples Beispiel in Python

MicroPython macht es Entwicklern leicht, mit den Möglichkeiten der PIO zu experimentieren. Programme für die Zustandsmaschine sind nicht (notwendig) in PIO-Assembler notiert, sondern verwenden Python-Wrapper. Das nachfolgende Beispielprogramm illustriert die Nutzung einer PIO-Zustandsmaschine. Das set-Mapping definiert GPIO25 als Ausgabe-Pin - es handelt sich um die eingebaute LED. Mit set(pins, 1) schaltet sich die LED ein, mit set(pins, 0) wieder aus. Mittels der Pseudoinstruktion nop()[31] legt das Programm 31 Wartezyklen ein (Delays).

# set-Mapping als Ausgabe-Pins
@rp2.asm_pio(set_init=rp2.PIO.OUT_LOW)

def blink():
wrap_target()
set(pins, 1) [31] # LED an
nop() [31] # Viele Delays
nop() [31]
nop() [31]
nop() [31]
set(pins, 0) [31] # LED aus
nop() [31] # Viele Delays
nop() [31]
nop() [31]
nop() [31]
wrap()

# Zustandsmaschine 0, PIO-Programm = blink, Frequenz = 2029,
# set-Mapping mit 1 Pin: GPIO25 => eingebaute LED
sm = StateMachine(0, blink, freq=2029, set_base=Pin(25))

sm.active(1) # Zustandsmaschine starten
time.sleep_ms(3000) # 3 Sekunden warten
sm.exec('set(pins, 0)') # LED durch Senden einer PIO-Instruktion auf 0 setzen
sm.active(0) # Zustandsmaschine stoppen

Interessant ist der Aufruf von sm.exec(), mit dem sich eine Instruktion direkt an die Zustandsmaschine übertragen lässt. Im vorliegenden Fall schaltet die Instruktion die LED wieder aus, bevor das Hauptprogramm die Zustandsmaschine deaktiviert.

Beispiel Ampelsteuerung

Um das Thema PIO praktisch durchzuspielen, bieten sich vor allem Beispiele an, die weder zu trivial noch zu komplex sind. Eine mögliche Option besteht aus dem Simulieren einer Verkehrsampel, wobei natürlich auch andere Signalanlagen in Frage kämen.

In Deutschland haben Ampelsteuerungen bekanntlich vier verschiedene Phasen:

  1. Rot-Phase: Verkehrsteilnehmer müssen warten.
  2. Rot-Gelb-Phase: Verkehrsteilnehmer müssen bis zur Grün-Phase warten.
  3. Grün-Phase: Verkehrsteilnehmer können fahren.
  4. Gelb-Phase: Verkehrsteilnehmer müssen anhalten.
  5. zurück zu 1.
Die vier Phasen einer Verkehrsampel

Die Phasen Grün und Rot sind individuell konfigurierbar, etwa abhängig von Verkehrsaufkommen. Die Dauer der Gelbphasen (Gelb und Rot-Gelb) ergibt sich aus der am Aufstellungsort erlaubten Höchstgeschwindigkeit.

Gesucht: ein Pico-Programm, das diese Steuerung implementiert. Die Implementierung erfolgt über C beziehungsweise C++ und PIO-Assemblerinstruktionen. Mögliche Variante: Statt C beziehungsweise C++ ließe sich auch MicroPython verwenden.
Es soll möglich sein, die Rot- beziehungsweise Grünphase von außerhalb des PIO zu konfigurieren.

Die Ampel-Schaltung

Die verschiedenen Leuchten der Ampel ersetzt die sehr einfache Beispielschaltung durch LEDs mit Anschluss an GPIO-Pins des Pico. Diese Schaltung lässt sich später erweitern, etwa durch einen Schalter, der die Ampel außer Betrieb nimmt oder wieder aktiviert. Auf einem Pico stehen zwei PIO-Blocks mit je 4 Zustandsmaschinen zur Verfügung, sodass ebenfalls eine Schaltung mehrerer Ampeln möglich wäre.
Insgesamt ist die Schaltung also sehr einfach. Die LEDs sind an GPIO17 (weisse LED oder andere Empfänger), GPIO18 (rote LED), GPIO19 (gelbe LED), und GPIO20 (grüne LED) über jeweils einen 220 Ohm Widerstand und an GND angeschlossen.

Eine einfache Schaltung für eine Ampel
Ampelkontrollsoftware

Gleich vorab: Den Code für dieses Beispiel finden Sie auf GitHub.

Die eigentliche Steuerungsaufgabe für die vier Ampelphasen übernimmt ein PIO-Programm, dem das Hauptprogramm zu Beginn die Länge der Rot- beziehungsweise Grünphase sowie ein Bit-Pattern übergibt. Die zwei Gelbphasen (Gelb, Rot-Gelb) dauern jeweils wenige Sekunden. Da die LEDs in der Reihenfolge Rot, Gelb, Grün angeschlossen sind, ergeben sich folgende Patterns. Das Bit für die frei verwendbare Ausgabe an GPI017 fehlt in folgenden Bit-Sequenzen:

Phase 1 (Rot) lässt sich durch eine Bitsequenz wie 1-0-0 repräsentieren (rote LED an, gelbe LED aus, grüne LED aus).

Phase 2 (Rot-Gelb) entspricht dann der Bitsequenz 1-1-0.

Phase 3 (Grün) lautet 0-0-1.

Phase 4 (Gelb) entspricht 0-1-0.

Die GPIO-Pins fungieren im PIO-Programm demzufolge als Ausgabe-GPIOs. Damit dies funktioniert, benötigen wir für das entsprechende I/O-Mapping, konkret für das Output-Mapping, benachbarte GPIOs. In dem Mapping sind GPIO-Pins 17, 18 (rot), 19 (gelb), 20 (grün) im Einsatz, wobei GPIO17 mit jeder Rot-Phase ein HIGH-Signal ausgibt. Das könnte man zur Ansteuerung einer weiteren LED benutzen oder für andere Zwecke. Die Bit-Sequenzen von jeweils 4 Bits legt die sm (state machine) per out-Kommando an die LEDs. Für einen vollständigen Durchlauf aller vier Phasen sind somit 16 Bit notwendig. Da die TX-FIFO mit 32 Bit arbeitet, übergeben wir das um einen kompletten Ampelzyklus verdoppelte Pattern als 32-Bit-Wort an das OSR (Output-Shift-Register). Bis das OSR leer ist, vergehen demzufolge zwei vollständige Iterationen durch alle vier Ampelphasen. Da der Entwickler das Pattern nach Belieben definieren kann, lässt sich die Signalisierung am GPIO17 beliebig einstellen, etwa HIGH-Signal in jeder Rot-Phase oder High Signal zu Beginn aller Phasen.

Das Pattern erthält das Programm vom C-Hauptprogramm, das zuvor die Zeit für die Rot- und Grünphase an die Zustandsmaschine übergibt:

        pull       ; Zeit aus der TX-Queue ins OSR holen 
mov x,osr ; und ins Register x
mov isr,x ; sowie ins ungenutzte ISR
; kopieren
pull ; Pattern von CPU-Core aus
; der TX-Queue ins OSR holen
mov y,osr ; und zusätzlich in y speichern

Weil das Programm nach je zwei Ampelzyklen das OSR (Output-Shift-Register) komplett leer geräumt hat, ist das Pattern auch in y abgelegt, um es erneut ins OSR laden zu können:

        jmp !OSRE cont  ; OSR != EMPTY 
; => weiter zu cont
mov osr,y ; Sonst: OSR neu laden
cont:
....

Ebenfalls wird das im Programm unbenutzte ISR (Input-Shift-Register) als Backup für die Zahl der Zeitschleifen benutzt:

        mov x,isr              ; ISR enthält Schleifenzahl für x
...
lgreen:
nop [31]
jmp x-- lgreen [31] ; hier wird x in jeder
; Schleife dekrementiert

Das PIO-Assembler-Programm schiebt das OSR immer um 4 Bit nach rechts, um mit den Bits die 4 Ausgabe-Pins anzusteuern:

        out pins, 4    ; Schiebe 4 Bits aus dem OSR 
; nach rechts zu den LEDs bzw. GPIOs

So entstehen nacheinander und zyklisch die vier Ampelphasen (rot, rotgelb, grün, gelb).

.program trafficlight
pull ; Zeit von CPU-Core holen
mov x,osr ; und in x speichern
mov isr,x ; sowie im ungenutzten ISR
pull ; Pattern von CPU-Core holen
mov y,osr ; und in y speichern
.wrap_target
jmp !OSRE cont; OSR != EMPTY => weiter bei cont
mov osr,y ; Sonst: OSR leer => neu aufladen
cont:
; ROT-PHASE
mov x,isr ; ISR enthält Schleifenzahl für x
out pins,4 ; 4 Bits aus OSR zur Ausgabe
lred:
nop [31]
jmp x-- lred [31]
; ROT-GELB-PHASE
mov x,isr ; ISR enthält Schleifenzahl für x
out pins,4 ; 4 Bits aus OSR zur Ausgabe
lredyellow:
nop [10]
jmp x-- lredyellow [10]
; GRÜN-PHASE
mov x,isr ; ISR enthält Schleifenzahl für x
out pins,4 ; 4 Bits aus OSR zur Ausgabe
lgreen:
nop [31]
jmp x-- lgreen [31]
; GELB-PHASE
mov x,isr ; ISR enthält Schleifenzahl für x
out pins,4 ; 4 Bits aus OSR zur Ausgabe
lyellow:
nop [10]
jmp x-- lyellow [10]
.wrap



% c-sdk {

// Hilfsfunktion, um die Zustandsmaschine zu konfigurieren

void trafficlight_program_init(PIO pio, uint sm, uint offset,
uint pin, uint pincount, uint freq) {

for (uint i = 0; i < pincount; i++) {
pio_gpio_init(pio, (pin+i) % 32); // initialisieren aller Pins
}

// pins als Ausgabe-Pins (true) festlegen
pio_sm_set_consecutive_pindirs(pio, sm, pin, pincount, true);

// Default Configuration holen
pio_sm_config c = trafficlight_program_get_default_config(offset);

// Die vier Pins definieren das out-Mapping:
sm_config_set_out_pins(&c, pin, pincount);

// div <= 65535 (= 2^16-1) - wird hier nicht geprüft
float div = (float)clock_get_hz(clk_sys) / freq;

// Jetzt Clock Divider übergeben
sm_config_set_clkdiv(&c, div);

// Wir kombinieren beide FIFOs zu einer TX_FIFO;
sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_TX);

// Rechts-Schieber, kein auto-pull, Schwellwert: 32 Bits
sm_config_set_out_shift(&c, true, false, 32);

// Zustandsmaschine initialisieren
pio_sm_init(pio, sm, offset, &c);

// und starten
pio_sm_set_enabled(pio, sm, true);
}
%}

Am Ende des Assemblercodes befindet sich die mit % c-sdk { beziehungsweise %} geklammerte C-Funktion void trafficlight_program_init(...), die der PIO-Assembler in die generierte xxxxpio.h-Datei übernimmt.
Diese Hilfsfunktion hat mehrere Aufgaben:
Zunächst teilt sie den einzelnen GPIOs über Aufrufe von pio_gpio_init()mit, dass sie unter Kontrolle der PIO stehen.

Die Anweisung

pio_sm_set_consecutive_pindirs() 

legt fest, dass die hintereinander folgenden GPIO-Pins als Ausgabe-Pins fungieren.
Zur Konfiguration der Zustandsmaschine bedarf es einer entsprechenden Datenstruktur (offset ist die Anfangsadresse des Programmes):

pio_sm_config c = trafficlight_program_get_default_config(offset);

Nun erfolgt die Festlegung des out-Mappings:

sm_config_set_out_pins(&c, pin, pincount);

Die Zeitdauern für die verschiedenen Ampelphasen hängen von zwei Faktoren ab:
Der für die Zustandsmaschine gewählten Frequenz und den in Schleifen hinzugefügten Delays.
Die Frequenz einer Zustandsmaschine ist mittels des Clock Dividers beeinflussbar. Nimmt man mit 65535 das Maximum, liegt die Frequenz der Zustandsmaschine, wie bereits erwähnt, bei grob 2000 Hz (eigentlich 2029 Hz), was einer Zykluszeit von grob 0,5 Millisekunden entspricht. Mittels der Wahl der Delays in den PIO-Instruktionen und der von aussen übergebenen Verzögerungszeit können Entwickler die gewünschten Ampelphasen sehr gut annähern.

Um eine gewünschte Frequenz einzustellen, berechnet die Funktion einen Clock-Divider und konfiguriert damit die Zustandsmaschine:

float div = (float)clock_get_hz(clk_sys) / freq;
sm_config_set_clkdiv(&c, div);

Da das Programm die RX-FIFO nicht nutzt, verbinden wir sie mit der TX-Queue zu einer doppelt so großen TX-Queue, was eigentlich im Ampel-Beispiel nicht nötig ist, sondern lediglich zur Illustration dieser Möglichkeit dient:

sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_TX);

Das OSR (Output-Shift-Register) schiebt die Bitmuster für die Ampelsignale nach rechts (erstes true), macht dies manuell statt automatisch (false) und hat einen Schwellwert von 32: Das heißt, nach geschobenen 32 Bits interpretiert die Zustandsmaschine das OSR als leer:

sm_config_set_out_shift(&c, true, false, 32);

Am Ende wird die Zustandsmaschine entsprechend der Konfiguration initialisiert:

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

und scharf gestellt beziehungsweise gestartet:

pio_sm_set_enabled(pio, sm, true);

Nun folgt noch das in C geschriebene Hauptprogramm:

#include <stdio.h>
#include "pico/stdlib.h"
#include "hardware/pio.h"
#include "hardware/clocks.h"
#include "pio_trafficlight.pio.h"


const uint freq = 2029; // Gewünschte Frequenz der Zustandsmaschine
const uint pin = 17; // Start-Pin
const uint pincount = 4; // Zahl der Ausgabe-Pins
// ==> RIGHT SHIFT ==>
// 2 x Ausgabe-Pattern für GPIOs
const uint32_t pattern = 0x48634863; // = 0100 1000 0110 0011 ...

const uint32_t delay = 200; // initial Zeitdauer für Gelb-Phasen

int main() {
setup_default_uart();

// Wir verwenden pio0
PIO pio = pio0;
// Programm zum Programmspeicher hinzufügen => offset
uint offset = pio_add_program(pio, &trafficlight_program);
printf("Programm geladen an Adresse %d\n", offset);
// Frei verfügbare Zustandsmaschine akquirieren
int sm = pio_claim_unused_sm(pio, true);
// Initialisieren und Konfigurieren der Zustandsmaschine
trafficlight_program_init(pio, sm, offset, pin, pincount, freq);

// Delay an TX-FIFO der Zustandsmaschine übergeben
pio_sm_put(pio, sm, delay);
// Bisschen warten
sleep_ms(500);
// Bitmuster zum LED-Schalten an Zustandsmaschine übergeben
pio_sm_put(pio, sm, pattern);
sleep_ms(1000000); // Lange laufen lassen
pio_sm_set_pins(pio, sm, 0); // Alle Pins auf Low
pio_sm_set_enabled(pio, sm, false); // Zustandsmaschine stoppen
}

Das Programm benutzt den Block pio0, fügt das PIO-Programm zu dessen Programmspeicher hinzu, um am Schluss eine freie Zustandsmaschine anzufordern:

PIO pio = pio0;
uint offset = pio_add_program(pio, &trafficlight_program);
int sm = pio_claim_unused_sm(pio, true);

Danach folgt der Aufruf der eben erwähnten Hilfsfunktion:

trafficlight_program_init(pio, sm, offset, pin, pincount, freq);

Erst sendet das Programm die gewünschte Zahl der Warteschleifen zur TX-Queue der Zustandsmaschine:

pio_sm_put(pio, sm, delay);

und danach das Bitmuster für die Ansteuerung der LEDs:

pio_sm_put(pio, sm, pattern);

Wichtig: Das Bit-Muster ist wegen der Right-Shifts zu den 4 GPIOs in 4er-Gruppen von rechts nach links zu lesen. Die 4er-Gruppen steuern jeweils mit dem LSBit GPIO17 (Kontrollausgabe zur beliebigen Verwendung), GPIO18 (Rot), GPIO19 (gelb) und mit dem MSBit GPIO20 (Grün). Das Muster 0x4863.... (= binär 0100 1000 0110 0011) bedeutet also die Abfolge:

  1. 0011 => GPIO17 = HIGH, RED = HIGH, YELLOW = LOW, GREEN = LOW
  2. 0110 => GPIO17 = LOW, RED = HIGH, YELLOW = HIGH, GREEN = LOW
  3. 0001 => GPIO17 = LOW, RED = LOW, YELLOW = LOW, GREEN = HIGH
  4. 0100 => GPIO17 = LOW, RED = LOW, YELLOW = HIGH, GREEN = LOW
  5. ....

Das Programm wartet nach den put()-Operationen für eine festgelegte Zeit von einer Million Millisekunden (etwa 16 Minuten), während die PIO die Ampel steuert:

sleep(1000000);

Nach dieser Wartezeit setzt es alle Ausgabe-Pins auf Low und deaktiviert die Zustandsmaschine wieder:

pio_sm_set_pins(pio, sm, 0); 
pio_sm_set_enabled(pio, sm, false);
PIO-Programmoptimierung

Mit Instruktionen sollten Entwickler gut haushalten, weil für alle vier Zustandsmaschinen eines PIO-Blocks nur insgesamt 32 Slots im gemeinsamen Programmspeicher verfügbar sind.

Wie also ließe sich das Programm optimieren? Im PIO-Teil der Ampelsteuerung durchläuft das Assemblerprogramm die vier Phasen einer Ampel. Eigentlich lässt sich das alles als zweimal zwei Phasen betrachten: je eine lange Phase (rot oder grün), gefolgt von einer kurzen Phase (gelb oder rot & gelb). Somit ist folgende gekürzte Version möglich:

.program trafficlight
pull ; Zeit von CPU-Core holen
mov x,osr ; und in x speichern
mov isr,x ; sowie im ungenutzten ISR
pull ; Pattern von CPU-Core holen
mov y,osr ; und in y speichern
.wrap_target
jmp !OSRE cont ; OSR != EMPTY => weiter bei cont
mov osr,y ; Sonst: OSR leer => neu aufladen
cont:
; ROT oder GRÜN
mov x,isr ; ISR enthält Schleifenzahl für x
out pins,4 ; 4 Bits aus OSR zur Ausgabe
lredgreen:
nop [31]
jmp x-- lredgreen [31]
; ROT-GELB oder Gelb
mov x,isr ; ISR enthält Schleifenzahl für x
out pins,4 ; 4 Bits aus OSR zur Ausgabe
lyellowred:
nop [10]
jmp x-- lyellowred [10]
.wrap

Nachteil:

Jetzt dauern Rot- und Grünphase sowie Rotgelb- und Gelbphase jeweils gleich lange, aber das ist leicht zu verschmerzen.
Die Versuchsschaltung im Einsatz
Fazit

Die Programmierbare Ein-/Ausgabe des Raspberry Pi Pico, kurz PIO, bietet einen echten Mehrwert für Entwickler. Natürlich ließen sich für den gleichen Zweck FPGAs nutzen, aber die sind teuer und benötigen wesentlich mehr Erfahrung in digitaler Schaltungstechnik. Gegenüber dem sonst üblichen Bit-Banging hat PIO den Vorteil, dass Zustandsmaschinen zeitlich genau arbeiten und nicht die beiden CPU-Kerne belasten. Sie offerieren einen kleinen, aber leistungsfähigen Satz von neun Instruktionen, erlauben dabei die Umsetzung auch komplexerer I/O-Schnittstellen, wofür sich im Internet bereits zahlreiche Beispiele finden. Das vorgestellte Beispiel ist bewusst einfach gehalten, sollte aber einen Einblick in die Möglichkeiten der PIO zeigen. Wie heißt es in der Werbung doch so schön: Entdecke die Möglichkeiten!

Referenzen