Tutorial · Beginner · 30 min

Waarom delay() robots saboteert: timing met millis()

Een geblokkeerde lus ziet niets: wat delay() een robot in millimeters kost, het millis()-patroon dat het vervangt en de overflowbug erin.

Inleiding

Bijna elke robot op deze site draait een lus die meerdere dingen tegelijk moet doen: een sensor uitlezen, een regelaar bijwerken, de motoren aansturen, misschien een radio beantwoorden. delay() is het eerste timinggereedschap dat iedereen leert, en het is precies datgene waarvan al dat andere onmogelijk wordt.

Het probleem is niet dat delay() traag is. Het is dat tijdens een delay() de robot helemaal niets doet—geen sensoren uitlezen, niet sturen, niet merken dat hij zojuist tegen een muur gaat lopen. Het is een programma dat vrijwillig is gestopt.

Deze pagina gaat over wat dat werkelijk kost, over het patroon dat het vervangt, en over de ene bug die de voor de hand liggende vervanger nog steeds heeft.

Wat delay() werkelijk doet

delay(50) draait in een dichte lus naar een timer tot er 50 ms voorbij zijn. Je code komt niet verder. Het enige dat nog gebeurt, zijn interrupts—de timer die millis() aanstuurt, het interrupt voor het ontvangen van seriële data, alles wat je met attachInterrupt() hebt gekoppeld.

Die uitzondering is belangrijk en tegelijk een valkuil: een encoder die in een interrupt geteld wordt blijft tellen tijdens een delay(), dus het getal blijft kloppen terwijl het gedrag van de robot stilstaat. En dat maakt de storing juist lastiger te zien, niet makkelijker.

Wat het kost, in millimeters

Een regellus kan alleen reageren met de snelheid waarmee hij monsterneemt. Iets dat net na een meting opduikt, wordt pas bij de volgende opgemerkt.

Twee gestapelde tijdlijnen over 220 milliseconden. De bovenste, gelabeld een vertraging van 50 milliseconden tussen metingen, toont sensorleesacties als hoge strepen om de 50 milliseconden met de tussenruimtes gearceerd en gemarkeerd als geblokkeerd, niets anders actief. De onderste, gelabeld millis met een lus van 5 milliseconden, toont de metingen om de 5 milliseconden opeengepakt. Een rode stippellijn markeert een obstakel dat op 51 milliseconden verschijnt; de bovenste tijdlijn merkt het 49 milliseconden later, de onderste 4 milliseconden later.
Het obstakel verschijnt net na een meting, wat voor beide lussen bijna het slechtste geval is—het getal dat telt, want een regellus wordt op zijn slechtste latentie beoordeeld, niet op zijn gemiddelde. Bij 0,5 m/s legt de geblokkeerde robot 24 mm af voordat hij het weet; de gepollde robot 2 mm. SVG downloaden

Vierentwintig millimeter is het grootste deel van de sensorarray van een lijnvolger. Het is ook ruim genoeg om een mazecel te verlaten, een kruispunt te overschrijden, of een balancerende robot voorbij het herstelpunt te kantelen—die heeft een inwendige lus van 200 Hz nodig, en dat is een budget van 5 ms voor alles, waar één delay(50) er tien lussen van opmaakt. Latentie is altijd een afstand—hoe ver de robot rijdt voordat hij kan handelen bepaalt of hij op tijd stopt of van de rand rijdt.

Het patroon dat het vervangt

Vraag in plaats van te stoppen tot het tijd is bij elke doorloop aan of het tijd is:

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

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

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

  // everything else keeps running, every pass
  updateMotors();
  checkBumper();
}

loop() draait nu duizenden keren per seconde en de sensormeting gebeurt volgens schema erin. Er wordt niets geblokkeerd, en een tweede getimed taakje toevoegen is vier extra regels in plaats van een herschrijving.

De overflowbug

millis() geeft een unsigned long terug—32 bits op een AVR—dat milliseconden sinds het opstarten telt. Het loopt op 2³² ms uit en springt terug naar nul, en dat is 49,7 dagen.

Twee manieren om dezelfde vergelijking te schrijven, en er overleeft er maar één:

if (now - lastRead >= READ_INTERVAL)   // correct
if (now >= lastRead + READ_INTERVAL)   // wrong

Neem lastRead = 4294967290 met een interval van 100 ms, vlak voor de omloop. lastRead + 100 loopt over naar 94, en now >= 94 is meteen waar—dus de timer stopt met wachten en vuurt elke doorloop af tot de teller de inhaal heeft gedaan. De aftreksvorm heeft dat probleem niet, omdat aftrekken zonder teken precies zo omloopt dat het verschild er goed uit komt.

Daaruit volgen drie regels:

  • Trek altijd af, tel nooit op. now - last >= interval.
  • Houd elk tijdstempel unsigned long. Bewaar je millis() in een int of een long met teken, dan is de bug er meteen weer, en int loopt op een AVR na 32 seconden over.
  • Test nooit op gelijkheid. now == target is een muntworp; een drukke doorloop stapt er dwars doorheen en de gebeurtenis vuurt nooit.

micros() verdient zijn eigen waarschuwing. Het loopt over bij 2³² microseconden, en dat is 71,6 minuten—een volkomen normale bedrijfsduur voor een robot. Dezelfde regels, veel sneller.

Meerdere dingen, op verschillende snelheden

Zodra er meer dan twee of drie getimede taakjes zijn, geef ze een tabel in plaats van een stapel variabelen:

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();
    }
  }
}

Dat is een coöperatieve scheduler, en het is alles wat de meeste robots ooit nodig hebben. Twee opmerkingen erbij:

t.last = now herstart de klok vanaf het moment dat de taak daadwerkelijk draaide, dus een late doorloop maakt de volgende ook laat en het tempo loopt langzaam weg. t.last += t.interval houdt het tempo wel vast—beter voor een regellus, slechter als een taak ooit zijn interval overschrijdt, want dan probeert die in te halen en draait hij rug aan rug. Gebruik += voor de lus waarvan de timing ertoe doet en = now voor al het andere.

En het hele ding hangt af van geen enkele taak die blokkeert. Eén delay() binnen reportStatus() en alle andere taken zijn te laat.

Wanneer blokkeren nog steeds het juiste antwoord is

Niet elke wachttijd is een fout. De test is of hij korter is dan één doorloop van de lus.

  • delayMicroseconds(10) om de triggerpuls van een HC-SR04 te maken is prima. Tien microseconden is niets, en er is geen niet-blokkerende manier om die te produceren.
  • pulseIn() is niet prima. Het blokkeert tot de echo terugkomt of de time-out verloopt. Een HC-SR04 op zijn limiet van 4 m is een heen-en-terug van 8 m, wat bij 343 m/s 23 ms is—en een gemiste echo kost je de volledige time-out die je hebt ingesteld. Daarom houdt die tutorial er zo nadrukkelijk op aan er een in te stellen.
  • Serial.print() verbergt een vertraging. Het geeft meteen terug zolang de buffer van 64 bytes ruimte heeft, en blokkeert zodra die vol is. Bij 9600 baud heeft de buffer 67 ms nodig om leeg te lopen, dus elke doorloop op 100 Hz een regel van 40 tekens printen loopt de lus zeker vast. Gebruik 115200, print minder en print op een trage timer.

Je lusduur is een echt getal

Stop met hem te schatten:

unsigned long worst = 0;

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

  // ... the whole loop body ...

  unsigned long dt = micros() - t0;
  if (dt > worst) worst = dt;   // report this on a slow timer, not here
}

Volg de slechtste doorloop, niet het gemiddelde. Het gemiddelde vertelt je dat de lus ruim zit; het slechtste vertelt je wat de regellus werkelijk moet doorstaan, en dat is meestal een Serial.print, een time-out van pulseIn, of een I²C-leesbeurt die moest wachten.

Dit telt het meest voor een PID-regelaar. De derivaatterm deelt een verandering van de fout door de tijd tussen monsters, dus onregelmatigheid in dat interval wordt ruis op D—en gains die bij één lussnelheid zijn afgesteld, kloppen bij een andere niet meer. Draai de lus óf op een vast interval en behandel dt als constant, óf meet dt elke doorloop en gebruik de echte waarde. Dat je geen van beide doet, is waarom een regelaar die het op de werkbank goed deed, zich gaat misdragen zodra er radiocode bij komt.

Wat je in plaats daarvan gebruikt

In plaats van Gebruik
delay() om het tempo van een sensormeting te bepalen een millis()-intervalcontrole
delay() om een LED te laten knipperen een timer in dezelfde lus
delay() om op een servo te wachten de reistijd schatten en millis() controleren
delay() om een knop te ontdenderen de flank van een tijdstempel voorzien en veranderingen 20 ms negeren
delay() tussen snelheidsstappen van de motor een bewegingsprofiel op basis van de verstreken tijd
delay() om de hele lus te vertragen niets—ren snel en time de taakjes afzonderlijk
pulseIn() zonder time-out pulseIn(pin, HIGH, 30000) en een tak voor “geen echo”

Als het misgaat

Symptoom Meestal
Robot negeert zijn bumper tijdens het draaien Een delay() in de draai
Sensormetingen komen in salvo’s Alle delen één timer in plaats van aparte intervallen
Prima op de werkbank, traag met logging aan Serial.print dat blokkeert zodra de buffer vol is
Timer vuurt continu na ongeveer 50 dagen De vorm now >= last + interval
Timer doet raar na ongeveer een uur micros() loopt om de 71,6 minuten over
Timer vuurt nooit Tijdstempel bewaard in int, of een gelijkheidstest
PID goed afgesteld, gedrag daarna anders Lussnelheid veranderd; dt is niet wat de gains aannamen
Encoders tellen goed maar de robot reageert laat Interrupts draaien gewoon door tijdens delay()—de tellingen zijn niet het probleem

De gewoonte die de moeite waard is om op te bouwen: behandel loop() als iets dat altijd snel moet terugkeren. Zodra dat waar is, is een functie toevoegen een taak toevoegen, en houdt de robot zijn reflexen. Zodra dat niet waar is, maakt elke nieuwe functie de robot een beetje blinder, en de oorzaak staat nooit waar het symptoom staat.

Verken de graaf

Onderdeel van deze bouwprojecten

Projecten en leerroutes waarin deze tutorial voorkomt.

Verder lezen

Bronnen