Multitasking auf dem Arduino: Blockaden lösen ohne RTOS

Multitasking auf dem Arduino: Blockaden lösen ohne RTOS

Wer mit dem Arduino arbeitet, stößt schnell an eine Grenze: Sobald man eine LED blinken lässt und gleichzeitig einen Taster abfragen oder einen Motor steuern möchte, reagiert das System träge oder gar nicht. Dieser Artikel erklärt, wie man echtes Multitasking ohne ein komplexes Echtzeitbetriebssystem (RTOS) realisiert.

Arduino Multitasking


Das Problem: Blockierende Schleifen und der Timer-Überlauf

Das Hauptproblem bei vielen Arduino-Sketchen ist die Function delay(). Während ein delay(1000) läuft, steht der Prozessor für eine Sekunde komplett still. In dieser Zeit können keine Sensoren ausgelesen, keine Benutzereingaben verarbeitet und keine weiteren Berechnungen angestellt werden. Das System ist "blockiert".

Die Gefahr des Timer-Überlaufs

Um Zeit ohne Blockade zu messen, nutzt man die Function millis(), die die Millisekunden seit dem Systemstart zählt. Dieser Wert wird in einer Variable vom Typ unsigned long gespeichert (32 Bit), die einen Bereich von 0 bis 4.294.967.295 abdeckt.

  • Der Überlauf: Nach etwa 50 Tagen (genau 49 Tage, 17 Stunden) erreicht der Zähler seinen Maximalwert und springt zurück auf 0.
  • Falsche Berechnung: Wer versucht, die Zeit mit millis() >= (startZeit + Intervall) zu prüfen, riskiert, dass die Bedingung nach 50 Tagen sofort wahr wird, obwohl die Zeit noch nicht um ist.
  • Die sichere Lösung: Nur die Subtraktion $(millis() - startZeit) \ge Intervall$ ist mathematisch sicher gegenüber dem Überlauf, da das Ergebnis bei vorzeichenlosen Ganzzahlen (unsigned) auch beim Umspringen korrekt bleibt.

Die Lösung: Die millisDelay-Bibliothek

Anstatt die Logik der Zeitberechnung jedes Mal manual zu schreiben, bietet die SafeString-Bibliothek (V3+) die Klasse millisDelay. Diese übernimmt das Zeitmanagement im Hintergrund, ist sicher gegen den 50-Tage-Überlauf und macht den Code lesbarer.

Das Prinzip dahinter ist der Task-basierte Ansatz:

  1. Jede Aufgabe (z. B. LED blinken, Sensor lesen) wird in eine eigene Method ausgelagert.
  2. Diese Methods werden in der loop() nacheinander aufgerufen.
  3. Jede Method prüft kurz, ob sie etwas zu tun hat (Zeit abgelaufen?), erledigt dies sofort und gibt die Kontrolle direkt wieder an die loop() zurück.

Beispiele: Einmaliges Delay und Repeating Timer

Hier sehen Sie, wie die Bibliothek die Programmierung vereinfacht:

1. Einmaliges Delay (Single-Shot)

Ein Single-Shot-Delay ersetzt das klassische delay(), ohne den Rest des Codes anzuhalten.

  • Anwendung: Eine Aktion soll genau einmal nach einer bestimmten Zeit ausgeführt werden, z. B. eine LED nach 10 Sekunden ausschalten.
  • Ablauf: Man started den Timer mit start(10000). In jeder loop() wird mit justFinished() geprüft, ob die Zeit um ist.

2. Wiederkehrender Timer (Repeating Timer)

Dies ist ideal für Aufgaben, die regelmäßig stattfinden müssen.

  • Anwendung: Ein Herzschlag-Signal oder das regelmäßige Auslesen eines Temperatursensors.
  • Besonderheit: Die Bibliothek bietet die Function repeat(). Im Gegensatz zu einem Neustart (restart()) korrigiert repeat() kleine zeitliche Verzögerungen, die durch andere Aufgaben in der loop() entstehen könnten (sog. Drift), und hält das Interval langfristig präzise.

Praktische Beispiele

1. Declaration (Declaration)

In diesem Bereich werden die Bibliotheken eingebunden und die Timer-Objekte instanziiert. Es wird zwischen einem wiederholenden Timer (Repeating Timer) und einer einmaligen Verzögerung (Single-Shot Delay) unterschieden.

#include <millisDelay.h> 

// Definition der Zeit-Intervalle
const unsigned long BLINK_INTERVAL = 1000; // 1 Sekunde 
const unsigned long WELCOME_INTERVAL = 5000; // 5 Sekunden

// Instanzen der millisDelay-Klasse 
millisDelay ledTimer;      // Timer für das Blinken (Repeating)
millisDelay welcomeDelay;  // Einmalige Verzögerung (Single-Shot)

const int LED_PIN = 13;

2. Initialisierung (Setup)

Im setup() werden die Pins konfiguriert und die Timer gestartet. Das Starten am End des Setups stellt sicher, dass die Zeitmessung genau mit dem Begin der Hauptschleife started.

void setup() {
  Serial.begin(9600);
  pinMode(LED_PIN, OUTPUT);

  // Timer starten
  ledTimer.start(BLINK_INTERVAL); 
  welcomeDelay.start(WELCOME_INTERVAL); 

  Serial.println("System startet…");
}

3. Hauptschleife (Loop) und Tasks

Die loop() bleibt "schlank", indem sie lediglich verschiedene Aufgaben (Tasks) aufruft. Jede Aufgabe prüft eigenständig, ob ihre Zeit abgelaufen ist, und gibt die Kontrolle sofort wieder an die Schleife zurück.

void loop() {
  // Aufruf der einzelnen Aufgaben
  taskHeartbeat();
  taskOneTimeMessage();

  // Hier könnten weitere Aufgaben stehen, die sofort ausgeführt werden müssen
}

// Aufgabe: LED blinken lassen (Wiederholend)
void taskHeartbeat() {
  // justFinished() muss in jedem Durchlauf aufgerufen werden
  if (ledTimer.justFinished()) { 

    // Startverzögerung ohne Drift 
    ledTimer.repeat();

    digitalWrite(LED_PIN, !digitalRead(LED_PIN)); // LED Zustand umkehren

  }
}

// Aufgabe: Eine Nachricht nach 5 Sekunden senden (Einmalig)
void taskOneTimeMessage() {
  if (welcomeDelay.justFinished()) { 
    Serial.println("Willkommen! Das System ist seit 5 Sekunden aktiv.");
    // welcomeDelay wird nicht neu gestartet und läuft daher nicht mehr
  }
}

Performance-Check: Der Loop Monitor

Um sicherzustellen, dass dein Code tatsächlich effizient läuft, ist ein „Loop Monitor“ unerlässlich. Da jede Verzögerung in einer der Aufgaben (Tasks) die gesamte loop() ausbremst, sollte die Durchlaufgeschwindigkeit überwacht werden. Ein langsamer Loop führt dazu, dass Timer unpräzise werden und Eingaben verloren gehen. Mit der Klasse loopTimer aus der SafeString-Bibliothek kannst du messen, wie oft die Hauptschleife pro Sekunde durchlaufen wird und wie viel Zeit die langsamste Aufgabe beansprucht. Als Faustregel gilt:

Eine gut programmierte loop() sollte eine Frequenz von über 500 Hz (weniger als 2 ms pro Durchlauf) beibehalten. Sinkt dieser Wert drastisch, hast du eine blockierende Stelle im Code identifiziert, die optimiert werden muss.


Fazit und Best Practices

Einfaches Multitasking auf dem Arduino erfordert kein RTOS, sondern lediglich eine disziplinierte Programmierung.

Best Practices für einen reaktionsschnellen Arduino:

  • loop() schnell halten: Die Hauptschleife sollte so oft wie möglich pro Sekunde durchlaufen (idealerweise über 500 Hz). Achte darauf, dass keine andere Function innerhalb der loop() (wie z. B. langsame Serial.print()-Ausgaben) die Schleife blockiert, damit justFinished() rechtzeitig abgefragt werden kann.
  • delay() verbannen: Nutzen Sie konsequent millisDelay für alle zeitabhängigen Aufgaben.
  • Vermeidung von Drift: Verwenden Sie repeat() anstelle von restart(), wenn Aufgaben in exakten Intervallen (z. B. 100 Mal pro Sekunde) ausgeführt werden sollen.
  • Serielle Blockaden vermeiden: Serial.print() kann die loop() verlangsamen, wenn der Puffer voll ist. Nutzen Sie stattdessen BufferedOutput aus der SafeString-Bibliothek.
  • Bibliotheken prüfen: Viele Drittanbieter-Bibliotheken nutzen intern delay(). Diese sollten modifiziert oder durch nicht-blockierende Alternativen ersetzt werden.
  • Loop-Timer verwenden: Nutzen Sie die loopTimer-Klasse, um während der Entwicklung die Geschwindigkeit Ihrer loop() zu überwachen und Engpässe zu finden.

Quellenangabe

Simple Multitasking Arduino on any board without using an RTOS by Matthew Ford

How to code Timers and Delays in Arduino by Matthew Ford