Arduino delay() considered harmful

Das Arduino Blinky Programm ist das Hello World der embedded Anwender. Es wird auch von Entwicklern verwendet, die sich gut in C oder C++ auskennen – es dient als Test für die Entwicklungsumgebung und Anbindung an den jeweiligen Microcontroller. Für Einsteiger trägt es aber ein hohes Risiko, dass diese frühzeitig auf einen falschen Pfad geleitet werden.

Das Blinky Programm enthält zwei delay() Aufrufe mit einer erheblichen Wartezeit:

void loop() {
  digitalWrite(LED_BUILTIN, true);
  delay(500);
  digitalWrite(LED_BUILTIN, false);
  delay(500);
}

In diesem einfachen Fall ist das noch nicht mal falsch oder problematisch. Aber es ist einer der wenigen Sonderfälle in denen so ein delay() vernünftig einsetzbar ist. Der Sonderfall hier: es gibt nur einen einzigen Handlungsstrang, jede Sekunde wird eine LED aus- und wieder eingeschaltet. Soweit so gut. Aber wie sieht es aus, wenn eine zweite LED mit halber Zeitverzögerung geschaltet werden soll:

void loop() {
  digitalWrite(LED_1, true);
  digitalWrite(LED_2, true);
  delay(250);
  digitalWrite(LED_2, false);
  delay(250);
  digitalWrite(LED_1, false);
  digitalWrite(LED_2, true);
  delay(250);
  digitalWrite(LED_2, false);
  delay(250);
  digitalWrite(LED_1, false);
}

Und das ist noch einfach. Wie es aussieht, wenn die Zeit für die zweite LED 144 Millisekunden beträgt, mag ich gar nicht hinschreiben. Man sieht, dass man mit delay() schnell in eine Sackgasse gerät wenn es mehrere Handlungsstränge mit unterschiedlichen Zeitanforderungen gibt.

Ein erster Gedanke wäre der Einsatz eines real time operating systems (RTOS) und mehreren Threads. Das ist aber ein größeres Unterfangen. Zum Einen muss man sich in das System einarbeiten. Zum Anderen belegt jeder Thread nicht unerhebliche Ressourcen. Insbesondere bei den kleinen Microcontrollern ist der RAM Bereich oft sehr begrenzt, sodass sich der Einsatz nicht unbedingt lohnt.

Eine flexiblere Lösung in C

Eine naheliegende Lösung wäre z.B., dass man für jede Aktion eine Zeitvariable anlegt, die angibt, wann sie das nächste Mal ausgeführt werden soll.

void loop() {
  unsigned long now = millis();

  if (now > nextLed1) {
    nextLed1 = now + 500;
    digitalWrite(LED_1, led1State);
    led1State = !led1State;
  }

  if (now > nextLed2) {
    nextLed2 = now + 250;
    digitalWrite(LED_2, led2State);
    led2State = !led2State;
  }
}

Jetzt kann man leicht mehrere Handlungsstränge mit beliebigen Zeitverhalten ausführen. Die delay() Funktion wird gar nicht mehr verwendet, jede Teilaktion ist also in sehr kurzer Zeit abgeschlossen und wird erst wieder nach Ablauf der angegeben Zeit wieder aktiv. Aus meiner Sicht ist das für kleinere Projekte und insbesondere für Anfängerprojekte durchaus eine sinnvolle Vorgehensweise. Wenn das Projekt wächst, bekommt man im Laufe der Zeit aber eine unschöne Kette von if Abfragen in der loop Schleife.

Eine Variante für C++

Wenn man sich entschlossen hat, das Projekt nicht in C, sondern in C++ zu erstellen, bietet sich die Möglichkeit an, die Handlungsstränge jeweils in eine eigene Klasse zu packen. Der Konstruktor der Klasse meldet den Strang dann selbständig in der loop Schleife an. Die Aktion wird in einer Methode hinterlegt. Ich habe einen Rahmen hierzu erstellt, welcher einfach als #include tmux.hpp in das Programm eingebunden werden kann. Für jeden Handlungsstrang legt man dann eine Klasse an, die die jeweiligen Aktionen enthält.

https://github.com/Matthias-Thiele/TMux/tree/main

Der Rahmen für so einen Strang kann in der Entwicklungsumgebung als Template hinterlegt werden und somit leicht eingefügt werden:

class Handlungsstrang: public TMWorker {
  private:

  public:
    void setup() {
    }

    void loop() {
    }

} strang1;

Die Klasse muss im Anschluss einen sinnvollen Namen bekommen und die setup Methode mit dem Code zur Initialisierung (analog zur Setup() Funktion im Arduino) gefüllt werden. In der loop() Methode werden dann die Befehle ausgeführt, die zu dem gewünschten Zeitpunkt ausgeführt werden sollen. Der lokale Status wird im private: Abschnitt gespeichert. Blinky sieht dann so aus:

class LEDBlink: public TMWorker {
  private:
    bool m_LEDstate;

  public:
    void setup() {
      setDelay(500);
    }

    void loop() {
      digitalWrite(LED_BUILTIN, m_LEDstate);
      m_LEDstate = !m_LEDstate;
    }

} ledBlink;

In der setup Methode wird die Wartezeit von 500 Millisekunden voreingestellt, die zwischen zwei Aufrufen eingehalten werden soll. In der loop Methode wird dann die LED ein- oder ausgeschaltet, der Zustand wird über die Membervariable m_LEDstate bestimmt.

Das ist erst mal deutlich mehr Code als in der einfachen Variante mit den Zeitvariablen. Der Vorteil dieser Lösung ist, dass die einzelnen Handlungsstränge besser voneinander entkoppelt sind und auf den ersten Blick sichtbar wird, was zum jeweiligen Handlungsstrang gehört. Und der größte Teil des Codes kann durch ein Template automatisch eingefügt werden. Bei einfachen Aktionen kann man die Klassen direkt in der main.cpp Datei unterbringen. Wenn die Aktionen komplexer werden und deutlich mehr Code benötigen, kann es sinnvoll sein, diese in jeweils eigene Dateien aufzuteilen. Diese Aufteilung sorgt dann ebenfalls dazu, dass schneller sichtbar wird, was zu der jeweiligen Aktion dazu gehört oder nicht.

Die Basisklasse TMWorker kümmert sich im Konstruktor darum, dass jede Instanz dieser Klasse in einer Liste gespeichert wird. Diese ist als Array und nicht als eine variabel lange Liste angelegt, da ich keinen Heap verwenden möchte. Das Array enthält nur Zeiger auf die Instanzen, ist also nicht besonders groß und kann bei Bedarf verlängert werden. Die Liste wird in einer Variablen tmux gespeichert, welche automatisch durch das include der hpp Datei angelegt wird. Ein Verständnis dieser Internas ist aber nicht notwendig zur Verwendung der Klasse.

Auf der Arduino Seite muss in der loop Schleife nur noch die Methode tmux.loop() aufgerufen werden und in der Arduino setup Methode tmux.setup(). Die loop Methode durchläuft die Liste der registrierten Handlungsstränge und führt diese nacheinander aus.

void loop() {
  tmux.loop();
}

Weitere Funktionen

Falls die Zeiten zwischen den Aufrufen nicht konstant sind, kann die nächste Ausführung in der loop() Methode explizit eingestellt werden. Die geänderte Wartezeit gilt dann für alle nachfolgenden Aufrufe bis zur nächsten Änderung.

class LEDBlink: public TMWorker {
  private:
    bool m_LEDstate;
    int  m_actDelay = 250;

  public:
    void setup() {
      setDelay(m_actDelay);
    }

    void loop() {
      digitalWrite(LED_BUILTIN, m_LEDstate);
      m_LEDstate = !m_LEDstate;

      // Wartezeit bei jedem Durchlauf um 100 Millisekunden verlängern
      m_actDelay += 100;
      setDelay(m_actDelay);
    }

} ledBlink;

Ebenfalls kann man eine Startverzögerung einstellen, die dafür sorgt, dass der erste Aufruf der Aktion erst später stattfindet. Das kann z.B. sinnvoll sein, wenn sich der Zustand des Systems erst stabilisieren soll bevor eine Messung ausgeführt wird. Hierfür gibt es die Methode setStartupDelay():

    void setup() {
      setDelay(250);
      setStartupDelay(3000);
    }

Externe Ereignisse per Interrupt einbinden

Das Arduino Framework unterstützt die Verwendung von Interrupts zur schnellen Reaktion auf externe Ereignisse. Die Interrupt-Routine sollte aber möglichst kurz gehalten werden, andernfalls kann es Störungen in anderen Teilen des Programms oder des Frameworks geben. Falls die Aktion etwas aufwändiger ist, kann es deshalb sinnvoll sein, die Aktion als eine TMWorker Klasse (genau genommen – von TMWorker abgeleitete Klasse) mit ‚unendlicher‘ Wartezeit anzulegen. In der Interrupt Routine wird dann nur die Ausführung getriggert, sie ist also in sehr kurzer Zeit wieder beendet. Die Kopplung erfolgt dabei über die Methode attachWorker() welche das Worker Objekt mit einer Interrupt-Quelle verbindet.

class InterruptButton: public TMWorker {
  public:
    void setup() {
      pinMode(PA8, INPUT_PULLUP);
      attachWorker(0, PA8, CHANGE);
    }

    void loop() {
      Serial1.println("Button 2 changed.");
    }
} interruptButton;

In der setup Methode wird der Pin PA8 als Input mit pullup Widerstand definiert. Mittels attachWorker wird dieses Objekt als Interrupt-Quelle 0 zum Port PA8 angemeldet. CHANGE definiert, dass der Interrupt bei jedem Pegelwechsel ausgeführt werden soll. Vordefiniert sind drei Interrupt-Quellen (0, 1 und 2), falls weitere benötigt werden, muss die hpp Datei angepasst werden. In der setup Methode sieht man keinen Aufruf von setDelay() – das führt dazu, dass die default Wartezeit ‚unendlich‘ verwendet wird. Die Aktion soll schließlich nur beim Auftreten einer Port Änderung ausgeführt werden.

Damit die Aktion zu einem Interrupt möglichst schnell ausgeführt wird, speichert das tmux Objekt den Handlungsstrang mit dem letzten aktiven Interrupt und führt diesen beim nächsten loop als erstes aus, auch wenn er in der normalen Reihenfolge noch nicht dran wäre.

Verwendung einer State Machine

Im einfachsten Fall führt ein Handlungsstrang periodisch eine Aktion immer wieder aus. In der Praxis ist es aber oft komplizierter. In Abhängigkeit von der Vorgeschichte sollen unterschiedliche Aktionen ausgeführt werden. Hierfür werden gerne endliche Automaten (State Machine) eingesetzt. Sie sind einfach zu implementieren und können Handlungsketten gut und übersichtlich abbilden. So eine State Machine setzt sich im Wesentlichen aus zwei Informationen zusammen: welche Zustände gibt es und welche Übergänge zwischen den Zuständen sind möglich.

Eine einfache Möglichkeit der Implementierung ist ein switch Statement über alle Zustände. Dazu kann man zur besseren Lesbarkeit ein enum definieren, welche alle Zustände mit sprechenden Namen versieht. Im folgenden möchte ich es anhand einer einfachen Ampelsteuerung zeigen. Das enum bildet die Zustände Warten, Bereit zum Fahren, Fahren, Halten erwarten ab. Zusätzlich gibt es eine Initialisierung, dabei ist es eine Geschmacksfrage, ob man diesen Code in den Konstruktor legt oder in einen eigenen Zustand. Wenn es Übergänge gibt, die eine erneute Initialisierung erfordern (z.B. ein Reset im Fehlerfall), dann würde ich die Initialisierung immer in die State Machine übernehmen.

enum TrafficLightStates {
  INIT,
  STOP,
  PREPARE_GO,
  GO,
  PREPARE_STOP
};

In der Klasse TrafficLight wird eine lokale Member Variable angelegt welche den aktuellen Zustand speichert – oder genauer gesagt: den Zustand, der beim nächsten Schritt ausgeführt werden soll. Sie wird mit INIT belegt, damit wird die Ampel dann in den Grundzustand gesetzt.

class TrafficLight: public TMWorker {
  private:
    TrafficLightStates nextState = INIT;

In der setup Methode werden die Microcontroller Ausgänge für die LEDs gesetzt. An dieser Stelle soll die State Machine noch ohne Verzögerung zum nächsten Zustand übergehen, setDelay(0) übernimmt diese Aufgabe.

    void setup() {
      pinMode(TLED_RED, OUTPUT);
      pinMode(TLED_YELLOW, OUTPUT);
      pinMode(TLED_GREEN, OUTPUT);
      setDelay(0);
    }

In der loop() Methode gibt es dann das switch Statement über den nextState.

    void loop() {
      switch(nextState) {

Für jeden Zustand aus den TrafficLightStates gibt es dann ein case Label innerhalb des switch Statements. In dem INIT Zustand werden alle Lichter dunkel geschaltet und als nächster Zustand wird Halten (STOP: Rot) gewählt.

        case INIT:
          digitalWrite(TLED_RED, false);
          digitalWrite(TLED_YELLOW, false);
          digitalWrite(TLED_GREEN, false);
          nextState = STOP;
          break;

Im STOP Zustand wird die rote LED eingeschaltet. Da dieser State normalerweise aus der Gelb-Phase kommt, muss die gelbe LED noch abgeschaltet werden. Wenn man auf Nummer Sicher gehen will, kann man auch die grüne LED noch abschalten, sie ist an dieser Stelle aber immer aus.

        case STOP:
          Serial1.println("RED");
          digitalWrite(TLED_RED, true);
          digitalWrite(TLED_YELLOW, false);
          setDelay(5000);
          nextState = PREPARE_GO;
          break;

Die Rot-Phase soll 5 Sekunden dauern, das wird mittels setDelay(5000) erreicht. Nach diesen 5 Sekunden soll die Ampel in die Rot-Gelb Phase übergehen, der nextState wird deshalb auf PREPARE_GO gesetzt. Wichtig: nicht das break am Ende jedes Zustands vergessen!

So werden nach und nach alle Zustände implementiert. Die komplette State Machine könnte dann so aussehen:

int TLED_RED = PA_10, TLED_YELLOW = PA_11, TLED_GREEN = PA_12;

enum TrafficLightStates {
  INIT,
  STOP,
  PREPARE_GO,
  GO,
  PREPARE_STOP
};

class TrafficLight: public TMWorker {
  private:
    TrafficLightStates nextState = INIT;

  public:
    void setup() {
      pinMode(TLED_RED, OUTPUT);
      pinMode(TLED_YELLOW, OUTPUT);
      pinMode(TLED_GREEN, OUTPUT);
      setDelay(0);
    }

    void loop() {
      switch(nextState) {
        case INIT:
          digitalWrite(TLED_RED, false);
          digitalWrite(TLED_YELLOW, false);
          digitalWrite(TLED_GREEN, false);
          nextState = STOP;
          break;

        case STOP:
          digitalWrite(TLED_RED, true);
          digitalWrite(TLED_YELLOW, false);
          setDelay(5000);
          nextState = PREPARE_GO;
          break;

        case PREPARE_GO:
          digitalWrite(TLED_YELLOW, true);
          setDelay(1000);
          nextState = GO;
          break;

        case GO:
          digitalWrite(TLED_RED, false);
          digitalWrite(TLED_YELLOW, false);
          digitalWrite(TLED_GREEN, true);
          setDelay(4000);
          nextState = PREPARE_STOP;
          break;

        case PREPARE_STOP:
          digitalWrite(TLED_YELLOW, true);
          digitalWrite(TLED_GREEN, false);
          setDelay(1000);
          nextState = STOP;
          break;
      }
    }
} trafficLight;

Das Schreiben der Aktionen direkt in das switch Statement ist nur sinnvoll, wenn die Zahl der Zustände klein bleibt und die jeweiligen Aktionen überschaubar sind. Andernfalls bekommt man ein langes switch Statement welches unübersichtlich ist und schnell zu Fehlern führt. In diesem Fall sollte man jede Zustandsaktion in eine eigene Methode auslagern und aus dem switch Statement heraus nur die jeweilige Methode aufrufen.

    void loop() {
      switch(nextState) {
        case INIT:
          processInit();
          break;

        case STOP:
          processStop();
          break;

        case PREPARE_GO:
          processPrepareGo();
          break;

        case GO:
          processGo();
          break;

        case PREPARE_STOP:
          processPrepareStop();
          break;
      }
    }

Jetzt hat man schon wieder zusätzlichen Code geschrieben, dafür aber den Vorteil, dass die Aktionen der Zustände deutlicher getrennt werden. Im richtigen Leben sollte man die einzelnen Teile natürlich auch mit Dokumentation versehen. Die komplette Ampelsteuerung sieht dann so aus:

int TLED_RED = PA_10, TLED_YELLOW = PA_11, TLED_GREEN = PA_12;

enum TrafficLightStates {
  INIT,
  STOP,
  PREPARE_GO,
  GO,
  PREPARE_STOP
};

class TrafficLight: public TMWorker {
  private:
    TrafficLightStates nextState = INIT;

    void processInit() {
      digitalWrite(TLED_RED, false);
      digitalWrite(TLED_YELLOW, false);
      digitalWrite(TLED_GREEN, false);
      nextState = STOP;
    }

    void processStop() {
      digitalWrite(TLED_RED, true);
      digitalWrite(TLED_YELLOW, false);
      setDelay(5000);
      nextState = PREPARE_GO;
    }

    void processPrepareGo() {
      digitalWrite(TLED_YELLOW, true);
      setDelay(1000);
      nextState = GO;
    }

    void processGo() {
      digitalWrite(TLED_RED, false);
      digitalWrite(TLED_YELLOW, false);
      digitalWrite(TLED_GREEN, true);
      setDelay(4000);
      nextState = PREPARE_STOP;
    }

    void processPrepareStop() {
      digitalWrite(TLED_YELLOW, true);
      digitalWrite(TLED_GREEN, false);
      setDelay(1000);
      nextState = STOP;
    }
    
  public:
    void setup() {
      pinMode(TLED_RED, OUTPUT);
      pinMode(TLED_YELLOW, OUTPUT);
      pinMode(TLED_GREEN, OUTPUT);
      setDelay(0);
    }

    void loop() {
      switch(nextState) {
        case INIT:
          processInit();
          break;

        case STOP:
          processStop();
          break;

        case PREPARE_GO:
          processPrepareGo();
          break;

        case GO:
          processGo();
          break;

        case PREPARE_STOP:
          processPrepareStop();
          break;
      }
    }
} trafficLight;

Hier befindet sich die GitHub Seite zu der hpp Datei:

https://github.com/Matthias-Thiele/TMux/tree/main

Für PlatformIO habe ich ein Snippet erstellt, welches man sich in der CPP Snippet Datei hinterlegen kann. Wenn man tmuxw (für TMux Worker) eintippt und Enter drückt, wird ein komplettes Worker Termplate angelegt.

	"TMux Task":{
		"prefix": "tmuxw",
		"body": [
			"class $1: public TMWorker {",
			"  private:",
			"",
			"  public:",
			"    void setup() {",
			"    }",
			"",
			"    void loop() {",
			"    }",
			"",
			"} $0story1;",
			""
		],
		"description": "Task Multiplexer action template"
	}