Tutorial · Einsteiger · 30 Min. Lesezeit

Warum delay() Roboter lähmt: Timing mit millis()

Blockierte Schleifen sehen nichts. Was delay() einen Roboter in Millimetern kostet, das millis()-Muster als Ersatz und der Rollover-Bug im naheliegenden Fix.

Einleitung

Fast jeder Roboter auf dieser Seite läuft in einer Schleife, die mehrere Dinge gleichzeitig tun muss: Sensor lesen, Regler aktualisieren, Motoren ansteuern, vielleicht ein Funkmodul bedienen. delay() ist das erste Timing-Werkzeug, das man lernt — und genau das macht all das unmöglich.

Das Problem ist nicht, dass delay() langsam ist. Sondern dass der Roboter während eines delay() überhaupt nichts tut — keine Sensoren liest, nicht lenkt, nicht bemerkt, dass er gleich gegen eine Wand fährt. Es ist ein Programm, das sich freiwillig angehalten hat.

Diese Anleitung erklärt, was das konkret kostet, welches Muster es ersetzt und welchen einen Bug der naheliegende Ersatz noch enthält.

Was delay() wirklich tut

delay(50) dreht in einer engen Schleife und beobachtet einen Timer, bis 50 ms vergangen sind. Dein Code schreitet nicht voran. Das Einzige, was weiterläuft, sind Interrupts — der Timer hinter millis(), der serielle Empfangs-Interrupt, alles, was du mit attachInterrupt() angehängt hast.

Diese Ausnahme zählt, und sie ist zugleich eine Falle: ein per Interrupt gezählter Encoder zählt während eines delay() weiter, also bleibt die Zahl richtig, während das Verhalten des Roboters steht. Dadurch wird der Fehler schwerer zu sehen, nicht leichter.

Was es kostet, in Millimetern

Ein Regelkreis kann nur mit der Rate reagieren, mit der er abtastet. Etwas, das direkt nach einem Lesevorgang auftaucht, wird erst beim nächsten bemerkt.

Zwei gestapelte Zeitachsen über 220 Millisekunden. Die obere, beschriftet mit Delay von 50 Millisekunden zwischen Lesungen, zeigt Sensorlesungen als hohe Markierungen alle 50 Millisekunden, die Lücken schattiert und als blockiert markiert, nichts anderes läuft. Die untere, beschriftet mit millis und 5-Millisekunden-Schleife, zeigt dicht gepackte Lesungen alle 5 Millisekunden. Eine gestrichelte rote Linie markiert ein Hindernis bei 51 Millisekunden; die obere Zeitachse bemerkt es 49 Millisekunden später, die untere 4 Millisekunden später.
Das Hindernis erscheint direkt nach einer Lesung — nahe am schlechtesten Fall für beide Schleifen, also an der Zahl, die zählt, denn ein Regelkreis wird nach seiner schlechtesten Latenz beurteilt, nicht nach dem Mittelwert. Bei 0,5 m/s legt der blockierte Roboter 24 mm zurück, bevor er es weiß; der abgefragte nur 2 mm. SVG herunterladen

Vierundzwanzig Millimeter sind fast das ganze Sensorarray eines Linienfolgers. Es reicht auch, um eine Labyrinthzelle zu verlassen, eine Kreuzung zu überfahren oder einen balancierenden Roboter über den Punkt ohne Wiederkehr zu kippen — der braucht eine 200-Hz-Innenschleife, also ein 5-ms-Budget für alles, und ein einziges delay(50) verbraucht zehn Schleifendurchläufe davon. Latenz ist immer eine Strecke — wie weit der Roboter fährt, bevor er handeln kann, entscheidet, ob er rechtzeitig stoppt oder über die Kante fährt.

Das Muster, das es ersetzt

Statt anzuhalten, bis es Zeit ist, frag bei jedem Durchlauf, ob es schon Zeit ist:

unsigned long lastRead = 0;
const unsigned long READ_INTERVAL = 50;

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

  if (now - lastRead >= READ_INTERVAL) {
    lastRead = now;
    readSensor();
  }

  // alles andere läuft weiter, bei jedem Durchlauf
  updateMotors();
  checkBumper();
}

loop() läuft nun tausendfach pro Sekunde, und die Sensorlesung geschieht planmäßig darin. Nichts blockiert, und ein zweiter zeitgesteuerter Job kostet vier Zeilen mehr statt einer Neufassung.

Der Rollover-Bug

millis() liefert ein unsigned long — 32 Bit auf einem AVR — und zählt Millisekunden seit dem Start. Es läuft bei 2³² ms über und springt auf null zurück, das sind 49,7 Tage.

Zwei Schreibweisen desselben Vergleichs, und nur eine überlebt das:

if (now - lastRead >= READ_INTERVAL)   // richtig
if (now >= lastRead + READ_INTERVAL)   // falsch

Nimm lastRead = 4294967290 mit 100 ms Intervall, kurz vor dem Überlauf. lastRead + 100 läuft auf 94 über, und now >= 94 ist sofort wahr — der Timer wartet also nicht mehr und feuert bei jedem Durchlauf, bis der Zähler aufgeholt hat. Die Subtraktionsform hat dieses Problem nicht, weil die vorzeichenlose Subtraktion genau so überläuft, dass die Differenz wieder stimmt.

Daraus folgen drei Regeln:

  • Immer subtrahieren, nie addieren. now - last >= interval.
  • Jeden Zeitstempel als unsigned long halten. millis() in einem int oder vorzeichenbehafteten long zu speichern, holt den Bug sofort zurück, und int auf einem AVR läuft schon nach 32 Sekunden über.
  • Nie auf Gleichheit testen. now == target ist ein Münzwurf; ein vielbeschäftigter Durchlauf springt direkt darüber, und das Ereignis feuert nie.

micros() verdient eine eigene Warnung. Es läuft bei 2³² Mikrosekunden über, das sind 71,6 Minuten — eine völlig normale Laufzeit für einen Roboter. Gleiche Regeln, nur viel früher.

Mehrere Dinge, mit verschiedenen Raten

Sobald es mehr als zwei oder drei zeitgesteuerte Jobs sind, gib ihnen eine Tabelle statt eines Haufens Variablen:

struct Task {
  unsigned long last;
  unsigned long interval;
  void (*run)();
};

Task tasks[] = {
  { 0,   5, readSensors   },
  { 0,  10, updateControl },
  { 0, 200, reportStatus  },
};

void loop() {
  unsigned long now = millis();
  for (Task &t : tasks) {
    if (now - t.last >= t.interval) {
      t.last = now;
      t.run();
    }
  }
}

Das ist ein kooperativer Scheduler, und mehr braucht kaum ein Roboter je. Zwei Anmerkungen dazu:

t.last = now startet die Uhr ab dem Zeitpunkt, zu dem der Task wirklich lief, also macht ein verspäteter Durchlauf auch den nächsten spät, und die Kadenz driftet langsam. t.last += t.interval hält dagegen die Kadenz — besser für einen Regelkreis, schlechter, wenn ein Task je sein Intervall überzieht, denn dann versucht er aufzuholen und läuft direkt hintereinander. Nutze += für die Schleife, deren Timing zählt, und = now für alles andere.

Und das Ganze hängt davon ab, dass kein Task blockiert. Ein delay() in reportStatus(), und jeder andere Task ist spät dran.

Wann Blockieren trotzdem richtig ist

Nicht jedes Warten ist ein Fehler. Der Test ist, ob es kürzer ist als ein Schleifendurchlauf.

  • delayMicroseconds(10) für den Triggerpuls eines HC-SR04 ist in Ordnung. Zehn Mikrosekunden sind nichts, und es gibt keine nicht-blockierende Art, sie zu erzeugen.
  • pulseIn() ist nicht in Ordnung. Es blockiert, bis das Echo zurückkommt oder das Timeout abläuft. Ein HC-SR04 an seiner 4-m-Grenze bedeutet 8 m Hin- und Rückweg, bei 343 m/s sind das 23 ms — und ein verpasstes Echo kostet dich das volle gesetzte Timeout. Darum besteht jene Anleitung auf einem Timeout.
  • Serial.print() versteckt ein Delay. Es kehrt sofort zurück, solange der 64-Byte-Puffer Platz hat, und blockiert, sobald er voll ist. Bei 9600 Baud braucht der Puffer 67 ms zum Leeren, also blockiert das Drucken einer 40-Zeichen-Zeile bei jedem Durchlauf mit 100 Hz die Schleife garantiert. Nutze 115200, drucke weniger und drucke auf einem langsamen Timer.

Deine Schleifenzeit ist eine echte Zahl

Rate sie nicht:

unsigned long worst = 0;

void loop() {
  unsigned long t0 = micros();

  // ... der ganze Schleifenkörper ...

  unsigned long dt = micros() - t0;
  if (dt > worst) worst = dt;   // auf einem langsamen Timer melden, nicht hier
}

Verfolge den schlechtesten Durchlauf, nicht den Mittelwert. Der Mittelwert sagt dir, dass die Schleife bequem ist; der schlechteste sagt dir, was der Regelkreis wirklich aushalten muss — meist ein Serial.print, ein pulseIn-Timeout oder ein I²C-Lesevorgang, der warten musste.

Am wichtigsten ist das für einen PID-Regler. Der D-Anteil teilt eine Fehleränderung durch die Zeit zwischen Abtastungen, also wird Jitter in diesem Intervall direkt zu Rauschen auf D — und Verstärkungen, die bei einer Schleifenrate eingestellt wurden, stimmen bei einer anderen nicht mehr. Entweder du lässt die Schleife mit festem Intervall laufen und behandelst dt als konstant, oder du misst dt bei jedem Durchlauf und nutzt den echten Wert. Keins von beidem zu tun ist der Grund, warum ein Regler, der auf dem Tisch brav war, plötzlich spinnt, sobald der Funkcode dazukommt.

Was stattdessen zu nutzen ist

Stattdessen Nutze
delay() zum Takten einer Sensorlesung millis()-Intervallprüfung
delay() zum Blinken einer LED Einen Timer in derselben Schleife
delay() zum Warten auf ein Servo Fahrzeit schätzen und millis() prüfen
delay() zum Entprellen eines Tasters Flanke mit Zeitstempel versehen, 20 ms Wechsel ignorieren
delay() zwischen Motordrehzahlstufen Ein Bewegungsprofil über verstrichene Zeit
delay() zum Verlangsamen der ganzen Schleife Nichts — laufe schnell und takte die Jobs einzeln
pulseIn() ohne Timeout pulseIn(pin, HIGH, 30000) plus einen „kein Echo“-Zweig

Wenn es schiefgeht

Symptom Meistens
Roboter ignoriert seinen Bumper beim Drehen Ein delay() in der Drehung
Sensormesswerte kommen in Schüben Alles teilt sich einen Timer statt getrennter Intervalle
Auf dem Tisch brav, mit Logging träge Serial.print blockiert, sobald der Puffer voll ist
Timer feuert dauerhaft nach ~50 Tagen Die Form now >= last + interval
Timer spinnt nach etwa einer Stunde micros()-Überlauf nach 71,6 Minuten
Timer feuert gar nie Zeitstempel in int gespeichert, oder ein Gleichheitstest
PID gut eingestellt, verhält sich dann anders Schleifenrate geändert; dt ist nicht mehr, was die Verstärkungen annahmen
Encoder zählt richtig, aber Roboter reagiert spät Interrupts laufen während delay() weiter — die Zählwerte sind nicht das Problem

Die Gewohnheit, die sich lohnt: Behandle loop() als etwas, das immer schnell zurückkehren muss. Sobald das gilt, ist ein neues Feature ein neuer Task, und der Roboter behält seine Reflexe. Sobald nicht, macht jedes neue Feature den Roboter etwas blinder, und die Ursache liegt nie dort, wo das Symptom ist.

Den Graphen erkunden

Teil dieser Bauprojekte

Projekte und Lernpfade, die dieses Tutorial enthalten.

Weiterführende Literatur

Quellen