Tutoriel · Débutant · 30 min de lecture

Pourquoi delay() casse les robots : timing avec millis()

Une boucle bloquée ne voit rien. Ce que delay() coûte en millimètres, le motif millis() qui le remplace, et le bogue de bouclage du correctif évident.

Introduction

Presque chaque robot de ce site fait tourner une boucle qui doit faire plusieurs choses à la fois : lire un capteur, actualiser un contrôleur, piloter les moteurs, peut-être répondre à une radio. delay() est le premier outil de temporisation appris, et c’est celui qui rend tout cela impossible.

Le problème n’est pas que delay() est lent. C’est que pendant un delay(), le robot ne fait rien du tout — ne lit pas ses capteurs, ne braque pas, ne remarque pas qu’il va heurter un mur. C’est un programme qui s’est volontairement arrêté.

Cette page traite de ce que cela coûte vraiment, du motif qui le remplace, et de l’unique bogue que le remplacement évident a encore.

Ce que delay() fait vraiment

delay(50) tourne en boucle serrée en regardant un timer jusqu’à ce que 50 ms aient passé. Votre code n’avance pas. Les seules choses qui arrivent encore sont les interruptions — le timer qui pilote millis(), l’interruption de réception série, tout ce que vous avez attaché avec attachInterrupt().

Cette exception compte et c’est aussi un piège : un encodeur compté en interruption continue de compter pendant un delay(), donc le nombre reste juste pendant que le comportement du robot s’arrête. Ce qui rend la panne plus dure à voir, pas plus facile.

Ce que cela coûte, en millimètres

Une boucle de commande ne peut réagir qu’au rythme où elle échantillonne. Quelque chose qui apparaît juste après une lecture n’est remarqué qu’à la suivante.

Deux chronologies empilées sur 220 millisecondes. Celle du haut, légendée delay de 50 millisecondes entre lectures, montre des lectures de capteurs en marques hautes toutes les 50 millisecondes avec les intervalles ombrés et marqués comme bloqués sans rien d'autre qui tourne. Celle du bas, légendée millis avec une boucle de 5 millisecondes, montre des lectures serrées toutes les 5 millisecondes. Une ligne rouge en pointillés marque un obstacle apparaissant à 51 millisecondes ; la chronologie du haut le remarque 49 millisecondes plus tard, celle du bas 4 millisecondes plus tard.
L'obstacle apparaît juste après une lecture, ce qui est proche du pire cas pour les deux boucles — le nombre qui compte, parce qu'une boucle de commande se juge sur sa pire latence, pas sa moyenne. À 0,5 m/s le robot bloqué parcourt 24 mm avant de savoir ; celui sondé parcourt 2 mm. Télécharger le SVG

Vingt-quatre millimètres sont l’essentiel de la barrette de capteurs d’un suiveur de ligne. C’est aussi largement assez pour quitter une case de labyrinthe, dépasser une intersection, ou faire basculer un robot équilibriste sans retour — celui-là exige une boucle interne à 200 Hz, soit un budget de 5 ms pour tout, et un seul delay(50) dépense dix boucles de cela. La latence est toujours une distance — la distance parcourue par le robot avant d’agir est ce qui décide s’il s’arrête à temps ou roule hors du bord.

Le motif qui le remplace

Au lieu de s’arrêter jusqu’à l’heure, demandez à chaque passage si c’est l’heure :

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() tourne désormais des milliers de fois par seconde et la lecture du capteur arrive à l’heure dedans. Rien n’est bloqué, et ajouter une seconde tâche temporisée fait quatre lignes de plus plutôt qu’une réécriture.

Le bogue de bouclage

millis() renvoie un unsigned long — 32 bits sur AVR — comptant les millisecondes depuis le boot. Il s’épuise à 2³² ms et reboucle à zéro, soit 49,7 jours.

Deux façons d’écrire la même comparaison, et une seule survit à cela :

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

Prenez lastRead = 4294967290 avec un intervalle de 100 ms, juste avant le bouclage. lastRead + 100 déborde à 94, et now >= 94 est aussitôt vrai — donc le timer cesse d’attendre et part à chaque passage jusqu’à ce que le compteur rattrape. La forme avec soustraction n’a pas ce problème, parce que la soustraction non signée boucle exactement de façon à ce que la différence sorte juste.

Trois règles suivent :

  • Toujours soustraire, jamais additionner. now - last >= interval.
  • Gardez chaque horodatage unsigned long. Stocker millis() dans un int ou un long signé réintroduit aussitôt le bogue, et int sur AVR boucle après 32 secondes.
  • Ne testez jamais l’égalité. now == target est un pile ou face ; un passage occupé l’enjambe et l’événement ne part jamais.

micros() mérite son avertissement propre. Il boucle à 2³² microsecondes, soit 71,6 minutes — une durée de fonctionnement tout à fait ordinaire pour un robot. Mêmes règles, bien plus tôt.

Plusieurs choses, à des rythmes différents

Une fois passé deux ou trois tâches temporisées, donnez-leur un tableau au lieu d’un tas de variables :

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

C’est un ordonnanceur coopératif, et c’est tout ce dont la plupart des robots ont besoin. Deux notes dessus :

t.last = now redémarre l’horloge quand la tâche a vraiment tourné, donc un passage en retard rend le suivant en retard aussi et la cadence dérive lentement. t.last += t.interval tient la cadence à la place — mieux pour une boucle de commande, pire si une tâche dépasse jamais son intervalle, parce qu’alors elle essaie de rattraper et tourne dos à dos. Utilisez += pour la boucle dont la temporisation compte et = now pour tout le reste.

Et le tout dépend d’aucune tâche bloquante. Un delay() dans reportStatus() et chaque autre tâche est en retard.

Quand bloquer reste la bonne réponse

Toute attente n’est pas une erreur. Le test est si elle est plus courte qu’un passage de boucle.

  • delayMicroseconds(10) pour faire l’impulsion de déclenchement d’un HC-SR04 va bien. Dix microsecondes ne sont rien, et il n’y a pas de façon non bloquante de la produire.
  • pulseIn() ne va pas bien. Il bloque jusqu’au retour de l’écho ou l’expiration du délai. Un HC-SR04 à sa limite de 4 m fait un aller-retour de 8 m, soit 23 ms à 343 m/s — et un écho manqué vous coûte tout le délai réglé. C’est pourquoi ce tutoriel insiste pour en régler un.
  • Serial.print() cache un delay. Il revient aussitôt tant que le tampon de 64 octets a de la place, et bloque une fois plein. À 9 600 bauds le tampon prend 67 ms à se vider, donc afficher une ligne de 40 caractères à chaque passage à 100 Hz calera absolument la boucle. Utilisez 115 200, affichez moins, et affichez sur un timer lent.

Votre temps de boucle est un vrai nombre

Cessez de le deviner :

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
}

Suivez le passage pire, pas la moyenne. La moyenne dit que la boucle est confortable ; le pire dit ce que la boucle de commande doit vraiment survivre, et c’est d’ordinaire un Serial.print, un délai pulseIn, ou une lecture I²C qui a dû attendre.

Cela compte le plus pour un contrôleur PID. Le terme dérivé divise un changement d’erreur par le temps entre échantillons, donc la gigue de cet intervalle devient directement du bruit sur D — et des gains réglés à une fréquence de boucle cessent d’être justes à une autre. Soit faites tourner la boucle à intervalle fixe et traitez dt comme constant, soit mesurez dt à chaque passage et utilisez la vraie valeur. Ne faire ni l’un ni l’autre est pourquoi un contrôleur sage sur l’établi se comporte mal une fois le code radio ajouté.

Quoi utiliser à la place

Au lieu de Utilisez
delay() pour rythmer une lecture de capteur Contrôle d’intervalle millis()
delay() pour clignoter une LED Un timer dans la même boucle
delay() pour attendre qu’un servo arrive Estimez le temps de trajet et vérifiez millis()
delay() pour anti-rebondir un bouton Horodatez le front, ignorez les changements 20 ms
delay() entre paliers de vitesse moteur Un profil de mouvement piloté au temps écoulé
delay() pour ralentir toute la boucle Rien — tournez vite et temporisez les tâches une par une
pulseIn() sans délai pulseIn(pin, HIGH, 30000) et une branche « pas d’écho »

Quand ça tourne mal

Symptôme Généralement
Le robot ignore son pare-chocs en tournant Un delay() dans le virage
Les lectures de capteurs arrivent par rafales Tout partage un timer au lieu d’intervalles séparés
Bien sur l’établi, mou avec les logs activés Serial.print qui bloque une fois le tampon plein
Le timer part en continu après ~50 jours La forme now >= last + interval
Le timer déraille après environ une heure Bouclage de micros() à 71,6 minutes
Le timer ne part jamais du tout Horodatage stocké en int, ou test d’égalité
PID bien réglé, puis se comporte différemment La fréquence de boucle a changé ; dt n’est plus ce que les gains supposaient
L’encodeur compte juste mais le robot réagit tard Les interruptions tournent encore pendant delay() — les comptes ne sont pas le problème

L’habitude à bâtir est de traiter loop() comme quelque chose qui doit toujours revenir vite. Une fois vrai, ajouter une fonction est ajouter une tâche, et le robot garde ses réflexes. Une fois faux, chaque nouvelle fonction rend le robot légèrement plus aveugle, et la cause n’est jamais là où est le symptôme.

Explorer le graphe

Fait partie de ces réalisations

Les projets et parcours d'apprentissage qui incluent ce tutoriel.

Pour aller plus loin

Références