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:

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:

komplexes Zustandsdiagramm

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 herunter­fahren 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 herunter­fahren 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 herunter­fahren AUS
Es ist warm genug Heizung aus schalten EIN
Heizung defekt Störung anzeigen und Heizung aus schalten STOERUNG
STOERUNG Schalter ist aus Anlage herunter­fahren 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:

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:

einfaches Zustandsdiagramm

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
Für die gelbe LED:
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
Für die grüne LED:
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;
    }
}
Startseite