Arduino millis() statt delay(): Multitasking ohne Blockieren – so laufen mehrere Aufgaben parallel
Deine LED soll blinken, ein Taster soll sofort reagieren und alle zehn Sekunden soll ein Messwert raus – aber sobald irgendwo ein delay(1000) steht, friert dein ganzer Sketch für eine Sekunde ein. Der Taster? Wird ignoriert. Die zweite LED? Kommt aus dem Takt. Die Lösung ist eine einzige Funktion, die jeder Arduino ab Werk mitbringt: millis(). In diesem Tutorial lernst du Schritt für Schritt, wie du delay() aus deinen Sketchen verbannst, mehrere Aufgaben mit unterschiedlichen Intervallen parallel laufen lässt, Taster sauber entprellst und mit einer kleinen Zustandsmaschine sogar komplexe Abläufe wie eine Ampelschaltung baust – ganz ohne Betriebssystem und auf jedem Arduino, vom UNO bis zum Nano.
Warum delay() dein Feind ist
delay(1000) klingt harmlos: „Warte eine Sekunde." Tatsächlich bedeutet es aber: „Tu eine Sekunde lang absolut nichts." Der Prozessor zählt stur Takte und ignoriert währenddessen alles – Tastendrücke, Sensorwerte, serielle Befehle. Bei einem einzelnen Blink-Sketch fällt das nicht auf. Aber sobald dein Projekt zwei Dinge gleichzeitig tun soll, bricht das Konzept zusammen:
- Eine LED soll alle 200 ms blinken, eine zweite alle 700 ms – mit zwei delays gerät alles aus dem Takt
- Ein Taster soll sofort reagieren – während eines delays ist er tot
- Ein Sensor soll alle 10 Sekunden messen, das Display aber flüssig aktualisieren – unmöglich mit delay
Der Trick mit millis(): Statt zu warten, bis es Zeit ist, prüfst du bei jedem Schleifendurchlauf, ob es Zeit ist. millis() liefert die Millisekunden seit dem Einschalten – eine ständig mitlaufende Stoppuhr. Dein loop() rast tausende Male pro Sekunde durch und jede Aufgabe schaut kurz auf die Uhr: „Bin ich dran? Nein? Weiter." So bleibt der Sketch immer reaktionsfähig.
ℹ️ Und was ist mit FreeRTOS? Auf dem ESP32 kannst du echtes Multitasking mit Tasks nutzen – dazu gibt es ein eigenes FreeRTOS-Tutorial. Auf UNO, Nano & Co. gibt es kein Betriebssystem – dort ist das millis()-Muster der Standardweg. Und selbst auf dem ESP32 ist es für viele Projekte die einfachere Wahl.
Das brauchst du
Verkabelung
| Bauteil | Anschluss | Arduino-Pin |
|---|---|---|
| LED Rot (Anode) | über 220 Ω | D8 |
| LED Gelb (Anode) | über 220 Ω | D9 |
| LED Grün (Anode) | über 220 Ω | D10 |
| alle LED-Kathoden | direkt | GND |
| TTP223 – VCC | direkt | 5V |
| TTP223 – GND | direkt | GND |
| TTP223 – I/O (Signal) | direkt | D2 |
Mehr zum TTP223 und seinen Modi findest du im Artikel TTP223 Touch-Sensor mit Arduino auslesen.
1Blink ohne delay: das Grundmuster
Das ist die wichtigste Code-Struktur dieses Artikels – präge sie dir ein, sie taucht in fast jedem fortgeschrittenen Arduino-Projekt auf:
const int LED_ROT = 8;
unsigned long letzterWechsel = 0; // wann haben wir zuletzt geschaltet?
const unsigned long INTERVALL = 500; // alle 500 ms
bool ledStatus = LOW;
void setup() {
pinMode(LED_ROT, OUTPUT);
}
void loop() {
// Bin ich dran? Vergleiche: Wie lange ist der letzte Wechsel her?
if (millis() - letzterWechsel >= INTERVALL) {
letzterWechsel = millis(); // Zeitpunkt merken
ledStatus = !ledStatus; // Zustand umkehren
digitalWrite(LED_ROT, ledStatus);
}
// Hier ist Platz fuer beliebig viele weitere Aufgaben -
// der loop() laeuft tausende Male pro Sekunde durch!
}
Drei Dinge sind entscheidend: Die Zeitvariable ist unsigned long (niemals int – der läuft nach 32 Sekunden über). Verglichen wird immer per Differenz (millis() - letzterWechsel), nie per absolutem Zeitpunkt. Und es gibt kein einziges delay() – der loop bleibt frei für andere Aufgaben.
💡 Der Überlauf ist mitgedacht: millis() läuft nach etwa 49,7 Tagen über und beginnt wieder bei 0. Die gute Nachricht: Die Differenz-Schreibweise millis() - letzterWechsel rechnet dank unsigned-Arithmetik auch über den Überlauf hinweg korrekt! Genau deshalb schreibt man den Vergleich so – und niemals als millis() >= letzterWechsel + INTERVALL.
2Drei Aufgaben parallel: LEDs + Touch-Sensor
Jetzt zeigt das Muster seine Stärke: Zwei LEDs blinken in völlig unterschiedlichen Rhythmen, während der Touch-Sensor jederzeit sofort reagiert und die dritte LED schaltet:
const int LED_ROT = 8;
const int LED_GELB = 9;
const int LED_GRUEN = 10;
const int TOUCH = 2;
unsigned long letzterWechselRot = 0;
unsigned long letzterWechselGelb = 0;
bool rotStatus = LOW;
bool gelbStatus = LOW;
void setup() {
pinMode(LED_ROT, OUTPUT);
pinMode(LED_GELB, OUTPUT);
pinMode(LED_GRUEN, OUTPUT);
pinMode(TOUCH, INPUT);
}
void loop() {
unsigned long jetzt = millis(); // einmal pro Durchlauf ablesen
// Aufgabe 1: rote LED alle 200 ms
if (jetzt - letzterWechselRot >= 200) {
letzterWechselRot = jetzt;
rotStatus = !rotStatus;
digitalWrite(LED_ROT, rotStatus);
}
// Aufgabe 2: gelbe LED alle 700 ms
if (jetzt - letzterWechselGelb >= 700) {
letzterWechselGelb = jetzt;
gelbStatus = !gelbStatus;
digitalWrite(LED_GELB, gelbStatus);
}
// Aufgabe 3: Touch-Sensor reagiert SOFORT - kein Warten noetig
digitalWrite(LED_GRUEN, digitalRead(TOUCH));
}
Mit delay() wäre diese Kombination unmöglich – mit millis() sind es einfach drei unabhängige if-Blöcke. Du kannst beliebig viele solcher „Software-Timer" hinzufügen: Sensor alle 10 Sekunden auslesen, Display alle 100 ms aktualisieren, Herzschlag-LED alle 2 Sekunden.
3Taster entprellen mit millis()
Mechanische Taster „prellen": Beim Drücken schließt der Kontakt nicht sauber einmal, sondern flattert einige Millisekunden. Ohne Gegenmaßnahme zählt dein Sketch dann drei Tastendrücke statt einem. Die klassische Lösung ist – natürlich – millis()-basiert. Das Beispiel zählt Berührungen sauber mit und gibt sie im Seriellen Monitor aus:
const int TOUCH = 2;
const unsigned long ENTPRELL_ZEIT = 50; // 50 ms Ruhe verlangen
int letzterRohwert = LOW; // letzter direkt gelesener Wert
int stabilerZustand = LOW; // entprellter, "offizieller" Zustand
unsigned long letzteAenderung = 0;
unsigned long zaehler = 0;
void setup() {
Serial.begin(115200);
pinMode(TOUCH, INPUT);
}
void loop() {
int rohwert = digitalRead(TOUCH);
// Rohwert hat sich geaendert? Stoppuhr neu starten
if (rohwert != letzterRohwert) {
letzteAenderung = millis();
letzterRohwert = rohwert;
}
// Wert ist seit 50 ms stabil? Dann gilt er offiziell
if (millis() - letzteAenderung >= ENTPRELL_ZEIT) {
if (rohwert != stabilerZustand) {
stabilerZustand = rohwert;
if (stabilerZustand == HIGH) { // nur steigende Flanke zaehlen
zaehler++;
Serial.print("Beruehrung Nr. ");
Serial.println(zaehler);
}
}
}
}
ℹ️ TTP223 prellt nicht – Taster schon: Der kapazitive TTP223 liefert von Haus aus saubere Flanken, das Entprellen ist bei ihm streng genommen unnötig. Der Code oben funktioniert aber 1:1 auch mit jedem mechanischen Taster – und genau dort brauchst du ihn. So hast du beides einmal sauber gelernt.
4Die Königsdisziplin: Ampel als Zustandsmaschine
Abläufe mit mehreren Phasen unterschiedlicher Dauer – Rot 5 s, Rot-Gelb 1 s, Grün 4 s, Gelb 1 s – schreien förmlich nach delay(). Aber es geht auch nicht-blockierend, mit einer Zustandsmaschine (State Machine): Der Sketch merkt sich, in welcher Phase er ist und wann sie begann. Der Touch-Sensor bleibt dabei jederzeit reaktionsfähig und schaltet die Ampel als „Fußgängertaster" schneller auf Rot:
const int LED_ROT = 8;
const int LED_GELB = 9;
const int LED_GRUEN = 10;
const int TOUCH = 2;
enum AmpelPhase { ROT, ROT_GELB, GRUEN, GELB };
AmpelPhase phase = ROT;
unsigned long phasenStart = 0;
bool fussgaengerWunsch = false;
// Dauer der Phasen in ms
unsigned long phasenDauer(AmpelPhase p) {
switch (p) {
case ROT: return 5000;
case ROT_GELB: return 1000;
case GRUEN: return 4000;
case GELB: return 1000;
}
return 1000;
}
void zeigePhase(AmpelPhase p) {
digitalWrite(LED_ROT, p == ROT || p == ROT_GELB);
digitalWrite(LED_GELB, p == ROT_GELB || p == GELB);
digitalWrite(LED_GRUEN, p == GRUEN);
}
void setup() {
pinMode(LED_ROT, OUTPUT);
pinMode(LED_GELB, OUTPUT);
pinMode(LED_GRUEN, OUTPUT);
pinMode(TOUCH, INPUT);
phasenStart = millis();
zeigePhase(phase);
}
void loop() {
// Fussgaenger-Wunsch jederzeit registrieren (reaktionsschnell!)
if (digitalRead(TOUCH) == HIGH) fussgaengerWunsch = true;
unsigned long dauer = phasenDauer(phase);
// Wunsch verkuerzt die Gruenphase auf 1 Sekunde
if (phase == GRUEN && fussgaengerWunsch) dauer = 1000;
// Phase abgelaufen? Weiterschalten
if (millis() - phasenStart >= dauer) {
phasenStart = millis();
switch (phase) {
case ROT: phase = ROT_GELB; break;
case ROT_GELB: phase = GRUEN; break;
case GRUEN: phase = GELB; fussgaengerWunsch = false; break;
case GELB: phase = ROT; break;
}
zeigePhase(phase);
}
}
Dieses Muster – enum für die Zustände, eine Startzeit pro Phase, ein switch für die Übergänge – skaliert von der Ampel bis zur Waschmaschinensteuerung. Es ist die Grundlage fast aller professionellen Embedded-Programme.
Wann ist delay() trotzdem okay?
⚖️ Die ehrliche Einordnung
-
Im setup(): Ein
delay(100)beim Start, damit sich ein Sensor initialisiert – völlig unproblematisch, dort wartet ohnehin nichts anderes - In simplen Test-Sketchen: Zum schnellen Ausprobieren eines Sensors ist delay() der kürzeste Weg
-
Sehr kurze Pausen (< 5 ms): Etwa für Timing-Anforderungen eines Bauteils – dafür gibt es auch
delayMicroseconds() - Nicht okay: Überall dort, wo der Sketch nebenbei auf Eingaben, Funk oder weitere Timer reagieren soll – also in praktisch jedem echten Projekt
Faustregel: delay() zum Testen, millis() zum Bauen
Troubleshooting
⚠️ Timer „spinnt" nach ein paar Minuten: Die Zeitvariable ist als int oder long deklariert. Zeitstempel von millis() gehören immer in unsigned long.
⚠️ LED blinkt unregelmäßig: Irgendwo im loop() versteckt sich noch ein delay() oder eine langsame Operation (z. B. viele Serial-Ausgaben mit 9600 Baud). Alles Blockierende entfernen bzw. Baudrate auf 115200 erhöhen.
⚠️ Vergleich mit addierter Zeit schlägt fehl: if (millis() >= startzeit + intervall) geht beim millis()-Überlauf schief. Immer die Differenz-Form if (millis() - startzeit >= intervall) verwenden.
⚠️ Ein Tastendruck wird mehrfach gezählt: Fehlende Entprellung (bei mechanischen Tastern) oder es wird der Pegel statt der Flanke ausgewertet. Wie in Schritt 3 nur auf den Wechsel von LOW nach HIGH reagieren.
⚠️ Zustandsmaschine bleibt hängen: Vergessen, phasenStart beim Phasenwechsel neu zu setzen – dann läuft die nächste Phase sofort ab oder nie. Startzeit gehört in jeden Übergang.
🚀 Alles für dein Multitasking-Setup
Mit diesen Teilen aus dem Shop baust du alle Beispiele nach:
- UNO R3 Entwicklungsboard (USB-C) – der Einsteiger-Klassiker
- Nano V3 Board – kompakt fürs Breadboard
- Breadboard 400 Kontakte – die Basis für alle Aufbauten
- 3mm LED-Sortiment + Widerstands-Set – Ampel-Farben inklusive
- TTP223 Touch-Sensoren (10er-Pack) – prellfreie Eingabe für deine Projekte
Fazit
Der Schritt von delay() zu millis() ist der Wendepunkt auf dem Weg vom Blink-Sketch zum echten Projekt. Du hast das Grundmuster gelernt (Zeitstempel merken, Differenz prüfen, weitermachen), mehrere unabhängige Software-Timer parallel laufen lassen, Taster sauber per Zeitstempel entprellt und mit der Zustandsmaschine ein Muster kennengelernt, das bis in die Profi-Entwicklung trägt.
Die drei Merksätze zum Schluss: Zeitstempel immer in unsigned long. Immer die Differenz vergleichen, nie absolute Zeitpunkte – dann ist sogar der 49-Tage-Überlauf ungefährlich. Und jede Aufgabe bekommt ihren eigenen Timer-Block im loop(). Wenn du das Muster einmal verinnerlicht hast, wirst du dich wundern, wie du je mit delay() leben konntest. Viel Spaß beim Bauen!