Multithreading in C und Arduino
Einleitung
Da ich oft gefragt werde, wie man Multithreading oder Multitasking auf Mikrocontrollern ohne Betriebssystem implementiert, habe ich hier ein konkretes Beispiel für Arduino aufgeschrieben. Das Prinzip lässt sich ebenso mit anderen Entwicklungsumgebungen und Programmiersprachen umsetzen. Ein konkretes Beispiel wäre mein AVR I/O Modul mit Webserver.
Begriffe
Multitasking bedeutet, dass ein Gerät mehrere Programme (Tasks) gleichzeitig ausführen kann. Das kennt jeder vom PC. Beim Multithreading kann jedes Programm wiederum mehrere Aufgaben (Threads) parallel abarbeiten. So können Videospiele zum Beispiel Bild und Ton erzeugen, während sie Eingaben verarbeiten und Daten mit anderen Spielern austauschen. Der Begriff Zustand umfasst alle Informationen darüber, was der Thread gerade tut und weiß.
Das Problem
Zuerst ein kleines Beispielprogramm (Sketch), um das "Problem" zu verdeutlichen:
#define LED_ROT 2 // Pin PD2 #define LED_GELB 3 // Pin PD3 #define LED_GRUEN 4 // Pin PD4 void setup() { pinMode(LED_ROT, OUTPUT); pinMode(LED_GELB, OUTPUT); pinMode(LED_GRUEN, OUTPUT); } void loop() { digitalWrite(LED_ROT, LOW); // rote LED aus schalten delay(100); digitalWrite(LED_ROT, HIGH); // rote LED ein schalten delay(100); }
Für die jenigen, die Arduino nicht kennen: Das Arduino Framework ruft die setup() Funktion einmal auf, und danach die loop() Funktion in einer Endlosschleife. Etwa so:
int main() { setup(); while(1) { loop(); } }
Das obige Programm lässt eine einzelne LED blinken. Stelle dir nun vor, du sollst 3 LEDs unabhängig voneinander in unterschiedlichen Intervallen blinken lassen:
- rot: soll im 100ms Takt blinken
- gelb: soll im 253ms Takt blinken
- grün: soll im 378ms Takt blinken
Mit dem obigen Ansatz ist das nicht machbar, denn du bräuchtest dazu drei Loops, die parallel ausgeführt werden. Das kann der kleine Mikrocontroller aber nicht, weil er nur einen CPU Kern hat. Zwei Lösungen haben sich etabliert:
Präemptiv mit Betriebssystem | Das Betriebssystem unterbricht laufende Threads, um Rechenzeit an andere Threads zu vergeben. Bei jedem Wechsel muss das Betriebssystem den Zustand des laufenden Threads sichern, um ihn später beim Fortsetzen wieder herzustellen. Das betrifft alle CPU Register und den Stack. Die bekannteste Umsetzung für Mikrocontroller ist unter dem Namen "Real Time Operating System" (FreeRTOS) bekannt. |
Kooperativ mit Zustandsautomaten | Zustandsautomaten merken sich ihren Zustand selbst in Variablen außerhalb des Stack, und sie geben von sich aus möglichst viel Rechenzeit an andere Threads ab. Sie werden auch "Endliche Automaten" (EA) oder "Finite State Machines" (FSM) genannt. |
Für kleine Mikrocontroller eignet sich die kooperative Variante besser, weil sie weniger Speicher und CPU Leistung benötigt. Ein einzelner Zustandsautomat macht noch kein Multithreading, aber dorthin ist es nur ein kleiner Schritt. Im Folgenden erkläre ich zuerst den Zustandsautomaten und danach, wie man mehrere davon parallel ausführt.
Planung eines Zustandsautomaten
Wenn man Zustandsautomaten entwickelt, dokumentiert man die möglichen Zustände und Reaktionen auf Bedingungen oder Ereignisse. Das Zustandsdiagramm einer fiktiven Klimaanlage könnte so aussehen:
Die orangen Kreise stellen die möglichen Zustände dar. Jeder Zustand kann mehrere Bedingungen/Ereignisse definieren, die eine Reaktion und einen Wechsel in den nächsten Zustand auslösen. Das stellen die schwarzen Pfeile mit ihren Beschriftungen dar. Man kann das auch tabellarisch darstellen:
Zustand | Bedingung/Ereignis | Reaktion | Nächster Zustand |
---|---|---|---|
AUS | Schalter is an | Anlage starten | EIN |
EIN | Schalter ist aus | Anlage herunterfahren | AUS |
Es ist zu warm | Kühlung ein schalten | KÜHLEN | |
Es ist zu kalt | Heizung ein schalten | HEIZEN | |
KÜHLEN | Schalter ist aus | Anlage herunterfahren | AUS |
Es ist kalt genug | Kühlung aus schalten | EIN | |
Kühlung defekt | Störung anzeigen und Kühlung aus schalten | STOERUNG | |
HEIZEN | Schalter ist aus | Anlage herunterfahren | AUS |
Es ist warm genug | Heizung aus schalten | EIN | |
Heizung defekt | Störung anzeigen und Heizung aus schalten | STOERUNG | |
STOERUNG | Schalter ist aus | Anlage herunterfahren | AUS |
Umsetzung in C
Der folgende Arduino Quelltext zeigt, wie man die obige Steuerung programmieren kann.
#define SCHALTER 2 // Eingang PD2, Hauptschalter HIGH=Ein, LOW=Aus #define ANLAGE 3 // Ausgang PD3, Anlage ist bei HIGH in Betrieb #define KUEHLUNG 4 // Ausgang PD4, Anlage kühlt, wenn HIGH #define HEIZUNG 5 // Ausgang PD5, Anlage heizt, wenn HIGH #define STOER_LAMPE 6 // Ausgang PD6, Leuchtet bei HIGH #define STOER_SENSOR 7 // Eingang PD7, Geht auf HIGH wenn eine Störung erkannt wurde #define FUEHLER_TEMP A0 // Eingang PA0, Temperaturfühler, liefert die Raumtemperatur direkt in °C #define SOLL_TEMP 21 // Soll-Temperatur 21°C void setup() { pinMode(SCHALTER, INPUT); pinMode(ANLAGE, OUTPUT); pinMode(KUEHLUNG, OUTPUT); pinMode(HEIZUNG, OUTPUT); pinMode(STOER_LAMPE, OUTPUT); pinMode(STOER_SENSOR, INPUT); } void herunterfahren() { digitalWrite(ANLAGE, LOW); digitalWrite(KUEHLUNG, LOW); digitalWrite(HEIZUNG, LOW); digitalWrite(STOER_LAMPE, LOW); } void loop() { static enum {AUS, EIN, KUEHLEN, HEIZEN, STOERUNG} zustand = AUS; switch (zustand) { case AUS: if (digitalRead(SCHALTER)==HIGH) // Bedingung: Schalter is an { digitalWrite(ANLAGE, HIGH); // Reaktion: Anlage starten zustand = EIN; // Neuer Zustand: EIN } break; case EIN: if (digitalRead(SCHALTER)==LOW) // Bedingung: Schalter is aus { herunterfahren(); // Reaktion: Anlage herunterfahren zustand = AUS; // Neuer Zustand: AUS } else if (analogRead(FUEHLER_TEMP) > SOLL_TEMP) // Bedingung: es ist zu warm { digitalWrite(KUEHLUNG, HIGH); // Reaktion: Kühlung ein schalten zustand = KUEHLEN; // Neuer Zustand: AUS } else if (analogRead(FUEHLER_TEMP) < SOLL_TEMP) // Bedingung: es ist zu kalt { digitalWrite(HEIZUNG, HIGH); // Reaktion: Heizung ein schalten zustand = HEIZEN; // Neuer Zustand: AUS } break; case KUEHLEN: if (digitalRead(SCHALTER)==LOW) // Bedingung: Schalter is aus { herunterfahren(); // Reaktion: Anlage herunterfahren zustand = AUS; // Neuer Zustand: AUS } else if (analogRead(FUEHLER_TEMP) <= SOLL_TEMP) // Bedingung: es ist kalt genug { digitalWrite(KUEHLUNG, LOW); // Reaktion: Kühlung aus schalten zustand = EIN; // Neuer Zustand: EIN } else if (digitalRead(STOER_SENSOR)==HIGH) // Bedingung: Kühlung defekt { digitalWrite(STOER_LAMPE, HIGH); // Reaktion: Störung anzeigen und Kühlung aus schalten digitalWrite(KUEHLUNG, LOW); zustand = STOERUNG; // Neuer Zustand: STOERUNG } break; case HEIZEN: if (digitalRead(SCHALTER)==LOW) // Bedingung: Schalter is aus { herunterfahren(); // Reaktion: Anlage herunterfahren zustand = AUS; // Neuer Zustand: AUS } else if (analogRead(FUEHLER_TEMP) >= SOLL_TEMP) // Bedingung: es ist warm genug { digitalWrite(HEIZUNG, LOW); // Reaktion: Heizung aus schalten zustand = EIN; // Neuer Zustand: EIN } else if (digitalRead(STOER_SENSOR)==HIGH) // Bedingung: Kühlung defekt { digitalWrite(STOER_LAMPE, HIGH); // Reaktion: Störung anzeigen und Heizung aus schalten digitalWrite(HEIZUNG, LOW); zustand = STOERUNG; // Neuer Zustand: STOERUNG } break; case STOERUNG: if (digitalRead(SCHALTER)==LOW) // Bedingung: Schalter is aus { herunterfahren(); // Reaktion: Anlage herunterfahren zustand = AUS; } break; } }
Die Variable "zustand" zeigt stets an, in welchem Zustand sich die Anlage befindet. Jeder case in der switch-Anweisung repräsentiert einen Zustand. In jedem Zustand werden relevante Bedingungen/Ereignisse abgefragt um Reaktionen auszulösen und in andere Zustände zu wechseln. Wenn keine der programmierten Bedingungen zutrifft, behält der Zustandsautomat den aktuellen Zustand bei. Da dieser Automat in einer Endlosschleife (loop) läuft, werden die Eingänge immer wieder abgefragt.
Multithreaded LED-Blinker
Nun möchte ich erklären, wie man auf einem Mikrocontroller mehrere Zustandsautomaten gleichzeitig laufen lässt. Ich komme hierbei auf die einleitende Aufgabe zurück, drei LEDs unabhängig voneinander blinken zu lassen:
- rot im 100ms Intervall
- gelb im 253ms Intervall
- grün im 378ms Intervall
Jede der drei LEDs kann zwei Zustände haben: AUS und EIN. Nach einigen Millisekunden soll der Zustand gewechselt werden. Wir brauchen also drei Zustandsautomaten, die quasi parallel ausgeführt werden. Ihre Diagramme sehen so aus:
Wir können es auch tabellarisch darstellen. Für die rote LED:
Zustand | Bedingung/Ereignis | Reaktion | Nächster Zustand |
---|---|---|---|
AUS | 100 ms später | LED ein schalten | EIN |
EIN | 100 ms später | LED aus schalten | AUS |
Zustand | Bedingung/Ereignis | Reaktion | Nächster Zustand |
---|---|---|---|
AUS | 253 ms später | LED ein schalten | EIN |
EIN | 253 ms später | LED aus schalten | AUS |
Zustand | Bedingung/Ereignis | Reaktion | Nächster Zustand |
---|---|---|---|
AUS | 378 ms später | LED ein schalten | EIN |
EIN | 378 ms später | LED aus schalten | AUS |
Quelltext
Die drei Zustandsautomaten sind hier in separaten Funktionen umgesetzt. Die Zustände werden wieder durch case der switch-Anweisungen umgesetzt. Die Funktion millis() liefert die aktuelle Systemzeit in Millisekunden (ein fortlaufender Zähler).
#define LED_ROT 2 // Pin PD2 #define LED_GELB 3 // Pin PD3 #define LED_GRUEN 4 // Pin PD4 void setup() { pinMode(LED_ROT, OUTPUT); pinMode(LED_GELB, OUTPUT); pinMode(LED_GRUEN, OUTPUT); } void thread_rot() { static enum {AUS, EIN} zustand = AUS; static unsigned long int warteSeit = 0; switch (zustand) { case AUS: if (millis() - warteSeit >= 100) // Bedingung: 100 ms später { digitalWrite(LED_ROT, HIGH); // Reaktion: LED ein schalten warteSeit = millis(); zustand = EIN; // Neuer Zustand: EIN } break; case EIN: if (millis() - warteSeit >= 100) // Bedingung: 100 ms später { digitalWrite(LED_ROT, LOW); // Reaktion: LED aus schalten warteSeit = millis(); zustand = AUS; // Neuer Zustand: AUS } break; } } void thread_gelb() { static enum {AUS, EIN} zustand = AUS; static unsigned long int warteSeit = 0; switch (zustand) { case AUS: if (millis() - warteSeit >= 253) { digitalWrite(LED_GELB, HIGH); warteSeit = millis(); zustand = EIN; } break; case EIN: if (millis() - warteSeit >= 253) { digitalWrite(LED_GELB, LOW); warteSeit = millis(); zustand = AUS; } break; } } void thread_gruen() { static enum {AUS, EIN} zustand = AUS; static unsigned long int warteSeit = 0); switch (zustand) { case AUS: if (millis() - warteSeit >= 378) { digitalWrite(LED_GRUEN, HIGH); warteSeit = millis(); zustand = EIN; } break; case EIN: if (millis() - warteSeit >= 378) { digitalWrite(LED_GRUEN, LOW); warteSeit = millis(); zustand = AUS; } break; } } void loop() { thread_rot(); thread_gelb(); thread_gruen(); }
Erklärung
Beim ersten Schleifendurchlauf ist der Thread für die rote LED im Status AUS. Da die 100ms noch
nicht erreicht sind, passiert hier erstmal nichts.
Beim zweiten Aufruf sind die 100 ms immer noch nicht erreicht.
Beim dritten Aufruf sind die 100 ms immer noch nicht erreicht.
...
Irgendwann ist es dann so weit, die rote LED wird eingeschaltet. Außerdem merkt sich das Programm in der Variable "warteSeit" den Zeitpunkt dieses Ereignisses. Der Status wechselt nach EIN. Dann wird solange gewartet, bis weitere 100 ms verstrichen sind - der Status wechselt nach AUS.
Der entscheidende Trick ist, dass die Thread-Funktion niemals hängen bleibt, also kein delay() benutzt. Jeder einzelne Aufruf dauert nur wenige Mikrosekunden. In der Hauptschleife loop() kannst du daher viele solcher Threads aufrufen. Es sieht von aussen betrachtet so aus, als ob sie gleichzeitig laufen würden.
Das Schlüsselwort "static" vor den lokalen Variablen sorgt dafür, dass sie ihren Wert zwischen den Funktionsaufrufen nicht vergessen.
Bei der Berechnung der verstrichenen Zeit ist es wichtig, dass die Variable "warteSeit" den gleichen unsigned (!) Integer Typ hat, wie der Rückgabewert von millis() und dass man die Differenz mit einer Subtraktion bildet. Denn nur dann stimmt das Ergebnis auch, wenn der Zeit-Zähler zwischendurch einmal überläuft, was zwangsläufig irgendwann passiert.
if ( millis() - warteSeit >= 100 ) // richtig { ... } if ( millis() >= warteseit + 100 ) // falsch! { ... }
Wer AVR Mikrocontroller ohne Arduino Framework programmieren möchte, kann von meiner Hello World Vorlage abgucken, wie man so einen Millisekunden-Zähler realisiert.
Ein etwas komplexeres Beispiel
Ich füge folgende Anforderung hinzu: Die rote LED soll nicht mehr blinken, wenn irgend jemand ein "pause" Flag auf true setzt. Dazu kann man einen weiteren Status und entsprechende Bedingungen für die Status-Übergänge hinzufügen. Wir können es wieder tabellarisch darstellen:
Zustand | Bedingung/Ereignis | Reaktion | Nächster Zustand |
---|---|---|---|
AUS | pause Flag wurde gesetzt | keine | PAUSE |
100 ms später | LED ein schalten | AUS | |
EIN | 100 ms später | LED aus schalten | AUS |
PAUSE | pause Flag wurde gelöscht | LED ein schalten | EIN |
Der relevante Quelltext dazu ist:
bool pause_flag=false; void thread_rot() { static enum {AUS, EIN, PAUSE} zustand = AUS; static unsigned long int warteSeit = 0; switch (zustand) { case AUS: if (pause_flag==true) // Bedingung: wenn das pause Flag gesetzt wurde { ; // Reaktion: keine, die LED ist schon aus zustand = PAUSE; // Neuer Zustand: PAUSE } else if (millis() - warteSeit >= 100) // Bedingung: 100 ms später { digitalWrite(LED_ROT, HIGH); // Reaktion: LED ein schalten warteSeit = millis(); zustand = EIN; // Neuer Zustand: EIN } break; case EIN: if (millis() - warteSeit >= 100) // Bedingung: 100 ms später { digitalWrite(LED_ROT, LOW); // Reaktion: LED aus schalten warteSeit = millis(); zustand = AUS; // Neuer Zustand: AUS } break; case PAUSE: if (pause_flag==false) // Bedingung: wenn das pause Flag gelöscht wurde { digitalWrite(LED_ROT, HIGH); // Reaktion: LED ein schalten warteSeit = millis(); zustand = EIN; // Neuer Zustand: EIN } break; } }
Sicher gibt es mehrere Möglichkeiten, die Anforderung korrekt umzusetzen. Dies hier ist nur ein Beispiel.
Modifikation für exakte Intervalle
Bei dem obigen Lösungsansatz blinken die Leuchtdioden ein kleines bisschen langsamer, als programmiert wurde, weil die Befehle zwischen den Warteschleifen auch noch etwas Zeit benötigen. Die Abweichung vom Soll wird im Laufe der Zeit immer größer weil sich die Fehler von jedem Schleifendurchlauf aufaddieren. Wenn man exaktere Intervalle benötigt, kann man zum Beispiel so vorgehen:
void thread_rot() { static enum {AUS, EIN} zustand = AUS; static unsigned long int warteSeit = 0; switch (zustand) { case AUS: if (millis() - warteSeit >= 100) { digitalWrite(LED_ROT, HIGH); warteSeit = warteSeit + 100; zustand = EIN; } break; case EIN: if (millis() - warteSeit >= 100) { digitalWrite(LED_ROT, LOW); warteSeit = warteSeit + 100; zustand = AUS; } break; } }
Dieses mal wird immer ein festes Intervall von 100 ms zur Variable warteSeit addiert. Kleine Abweichungen der Zeit können sich daher nicht mehr aufaddieren. Die Intervalle schreiten immer im 100 ms Raster weiter.
Bei Arduino beginnt millis() immer mit 0. Wenn das bei deinem System nicht der Fall ist, musst du einen zusätzlichen Initialisierungsschritt einfügen. Ansonten würde die LED zu Beginn viele male Flackern, bis warteSeit an millis() angeglichen ist. Beispiel:
void thread_rot() { static enum {INIT, AUS, EIN} zustand = INIT; static unsigned long int warteSeit = 0; switch (zustand) { case INIT: warteSeit = millis(); zustand = AUS; break; case AUS: if (millis() - warteSeit >= 100) { digitalWrite(LED_ROT, HIGH); warteSeit = warteSeit + 100; zustand = EIN; } break; case EIN: if (millis() - warteSeit >= 100) { digitalWrite(LED_ROT, LOW); warteSeit = warteSeit + 100; zustand = AUS; } break; } }