Aufbau mit Arduino-Nano Klon
Startseite

Multithreading mit/ohne Arduino

Einleitung

Da ich immer wieder 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 LED's 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" oder State-Machines genannt. Abkürzung: FSM

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. Es gibt unterschiedliche Varianten von Zustandsautomaten. Zwei davon stelle ich nun in den folgenden Kapiteln vor.

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 aus schaltenAUS
Es ist zu warmKühlung einschaltenKÜHLEN
Es ist zu kaltHeizung einschaltenHEIZEN
KÜHLENSchalter ist ausAnlage aus schaltenAUS
Kühlung defektStörung anzeigenSTOERUNG
HEIZENSchalter ist ausAnlage aus schaltenAUS
Heizung defektStörung anzeigenSTOERUNG
STOERUNGSchalter ist ausAnlage aus schaltenAUS

Umsetzung in C

Was heisst das jetzt für den LED-Blinker? Jede der drei LEDs kann zwei Zustände haben: AUS und EIN. Nach einigen Millisekunden soll der Zustand gewechselt werden. Die Zustandsdiagramme sehen daher so aus:

einfaches Zustandsdiagramm

Das Programm muss alle drei Zustandsautomaten parallel ausführen. 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 einschalten
                warteSeit=millis();  
                zustand=EIN;                     // Nächster Zustand: EIN
            }          
            break;

        case EIN: 
            if (millis()-warteSeit >= 100) 
            {
                digitalWrite(LED_ROT, LOW); 
                warteSeit=millis();  
                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

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 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 sehr 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 Integer Typ hat, wie der Rückgabewert von millis() und dass man die Differenz mit einer Subtraktion bildet. Diese Berechnung funktioniert sogar korrekt, wenn der Zeit-Zähler zwischenzeitlich einmal übergelaufen ist.

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

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

Lösungsansatz 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. Wenn man exakte Intervalle benötigt, kann man 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+=100;  
                zustand=EIN; 
            }          
            break;

        case EIN: 
            if (millis()-warteSeit >= 100) 
            {
                digitalWrite(LED_ROT, LOW); 
                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 exakt im 100 ms Raster weiter.

Alternative mit Zeitscheiben

Ein ganz anderer Lösungsansatz beruht auf der Idee, die Threads nacheinander in regelmäßigen Intervallen aufzurufen, zum Beispiel alle 10 Millisekunden.
#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);
}

// LED_ROT blinkt
void thread_rot(uint8_t intervall)
{
    switch (intervall)
    {
        case 0:
            digitalWrite(LED_ROT,HIGH); // LED ein schalten            
            break;
        case 128:
            digitalWrite(LED_ROT,LOW); // LED aus schalten
            break;
    }
}

// LED_GELB blitzt
void thread_gelb(uint8_t intervall)
{
    switch (intervall)
    {
        case 0:
            digitalWrite(LED_GELB,HIGH); // LED ein schalten            
            break;
        case 25:
            digitalWrite(LED_GELB,LOW); // LED aus schalten
            break;
    }
}

// LED_GRUEN blitzt doppelt
void thread_gruen(uint8_t intervall)
{
    switch (intervall)
    {
        case 0:
            digitalWrite(LED_GRUEN,HIGH); // LED ein schalten
            break;
        case 10:
            digitalWrite(LED_GRUEN,LOW); // LED aus schalten
            break;
        case 60:
            digitalWrite(LED_GRUEN,HIGH); // LED ein schalten
            break;
        case 70:
            digitalWrite(LED_GRUEN,LOW); // LED aus schalten
            break;        
    }
}

void loop() 
{
    static unsigned long int warteSeit=0;
    static uint8_t intervall=0;

    thread_rot(intervall);
    thread_gelb(intervall);
    thread_gruen(intervall);

    // Schleifen-Durchläufe zählen (0-255)
    intervall++;

    // Warte bis das nächste 10ms Intervall beginnt
    while (millis()-warteSeit < 10) 
    {
       // Leere Warteschleife
    }
    warteSeit+=10;
}

Dieser Lösungsansatz beansprucht weniger CPU Leistung weil die Zeitmessungen innerhalb der Threads entfallen. Außerdem kann man an Stelle der leeren Warteschleife sehr einfach die ungenutzte CPU Zeit ermitteln, um die Auslastung des Systems abzuschätzen. Die CPU ist überlastet, wenn das Zeitintervall schon vor Eintritt in die Warteschleife abgelaufen ist.

Ein großer Nachteil beim obigen Code ist, dass alle drei Threads zusammen nicht länger dauern dürfen, als das vorgegebene Intervall vorschreibt. Denn sonst gerät das Timing aller Threads durcheinander. Man kann daher nicht pauschal sagen, dass dies immer der bessere Ansatz ist. Es kommt auf die konkrete Anwendung an.