Aufbau mit Arduino-Nano Klon
Startseite

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.

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);
}

Das Arduino Framework ruft die setup() Funktion einmal auf, und danach immer wieder die loop() Funktion. Etwa so:

int main() 
{
    setup();
    
    while(1)
    {
        loop();
    }
}

Nun soll das Programm so erweitert werden, dass alle drei LEDs unanbhängig voneinander unterschiedlich schnell blinken:

Denke kurz darüber nach, dann wird klar, dass dies mit dem obigen Ansatz nicht machbar ist. 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äemptives MultitaskingDas 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 RTOS bekannt.
ZustandsautomatenMerken sich ihren Zustand selbst, und 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 der Zustandsautomat besser, weil er viel weniger Speicher benötigt und das aufwändige Sichern und Wiederherstellen der CPU Register entfällt.

Planung eines Zustandsautomaten

Wenn man Zustandsautomaten entwickelt, plant man für jeden Thread die möglichen Zustände und die möglichen Übergänge zum jeweils nächsten Zustand. 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:

ZustandBedingung/EreignisReaktionNächster Zustand
AUSSchalter is anAnlage startenEIN
EINSchalter ist ausAnlage herunterfahrenAUS
Es ist zu warmKühlung einschaltenKÜHLEN
Es ist zu kaltHeizung einschaltenHEIZEN
KÜHLENSchalter ist ausAnlage herunterfahrenAUS
Kühlung defektStörung anzeigenSTOERUNG
HEIZENSchalter ist ausAnlage herunterfahrenAUS
Heizung defektStörung anzeigenSTOERUNG
STOERUNGSchalter ist ausAnlage herunterfahrenAUS

Konkrete Umsetzung für die LED-Blinker

Aufgabe: Drei LEDs sollen unabhängig voneinander blinken:

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:

ZustandBedingung/EreignisReaktionNächster Zustand
LED ist aus100 ms späterLED ein schaltenLED ist an
LED ist an100 ms späterLED aus schaltenLED ist aus
Für die gelbe LED:
ZustandBedingung/EreignisReaktionNächster Zustand
LED ist aus253 ms späterLED ein schaltenLED ist an
LED ist an253 ms späterLED aus schaltenLED ist aus
Für die grüne LED:
ZustandBedingung/EreignisReaktionNächster Zustand
LED ist aus378 ms späterLED ein schaltenLED ist an
LED ist an378 ms späterLED aus schaltenLED ist aus

Das Programm muss alle drei Zustandsautomaten parallel ausführen.

Quelltext

Die drei Zustandsautomaten sind in separaten Funktion umgesetzt. Die Zustände werden durch die case der switch-Anweisungen umgesetzt:
#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: LED ist 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: LED ist 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

Die delay() Funktion ist tabu, weil sie alle drei Threads pausieren würde. Wir benutzen daher stattdessen millis(), um Zeitintervalle zu messen. Diese Funktion liefert uns die Laufzeit des Systems in Millisekunden. Wer AVR Mikrocontroller ohne Arduino Framework programmieren möchte, kann von dieser Seite abgucken, wie man so einen Systemtimer realisiert.

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 LED wird eingeschaltet. Außerdem merkt sich das Programm in der Variable warteSeit den Zeitpunkt dieses Ereignisses. Der Status wechselt nach EIN.

Dann wird wieder 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. 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, z.B. nach 65535 Millisekunden).

if ( millis() - warteSeit >= 100 ) // richtig
{
    ...
}

if ( millis() >= warteseit + 100 )  // falsch!
{
   ...
}

Ein etwas komplexeres Beispiel

Ich füge folgende Anforderung hinzu: Die rote LED soll nicht mehr blinken, wenn irgendwer 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. Für die rote LED:

ZustandBedingung/EreignisReaktionNächster Zustand
LED ist auswennn pause=truekeinePause
100 ms späterLED ein schaltenLED ist aus
LED ist an100 ms späterLED aus schaltenLED ist aus
Pausewenn pause=falseLED ein schaltenLED ist an
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 die LED pausieren soll
            {
                ;                                // Reaktion: nichts tun, die LED ist schon aus
                zustand = PAUSE;                 // Neuer Zustand: LED macht 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: LED ist 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: LED ist AUS
            }          
            break;
            
        case PAUSE:
            if (pause_flag==false)               // Bedingung: wenn die LED nicht mehr pausieren soll
            {
                digitalWrite(LED_ROT, HIGH);     // Reaktion: LED ein schalten
                warteSeit = millis();  
                zustand = EIN;                   // Neuer Zustand: LED ist 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.

Ein anderer auch durchaus häufig anzutreffender Lösungsansatz besteht darin, dass die Hauptschleife ihre Threads in regelmäßigen Intervallen ausführt (z.B. alle 10 ms) und die Treads ihre eigenen Aufrufe zählen, um Zeitspannen zu ermitteln (10 Aufrufe = 100 ms).

void thread_rot()
{
    static enum {AUS, EIN} zustand = AUS;
    static unsigned int intervalle = 0;

    intervalle = intervalle + 1;
    switch (zustand)
    {      
        case AUS:
            if (intervalle >= 10)                 // Bedingung: 100 ms später
            {
                digitalWrite(LED_ROT, HIGH);
                intervalle = 0;  
                zustand = EIN;
            }          
            break;

        case EIN: 
            if (intervalle >= 10) 
            {
                digitalWrite(LED_ROT, LOW); 
                intervalle = 0;  
                zustand = AUS; 
            }          
            break;
    }
}


void loop() 
{
    static unsigned long int warteSeit = 0;
    
    if (millis() - warteSeit >= 10) 
    {
        thread_rot();
        thread_gelb();
        thread_gruen();
        warteSeit = warteSeit + 10;
    }
}