Aller au contenu principal

Ne plus bloquer : millis() et machines à états

Ce que ce chapitre apporte

  • Mesurer un intervalle avec millis() sans suspendre le programme.
  • Mener plusieurs activités à des rythmes différents dans la même loop().
  • Écrire le test d'intervalle sous la forme qui reste juste quand millis() revient à zéro.
  • Décrire un comportement par des états et des transitions.
  • Traduire une machine à états en programme avec switch.

Un programme construit avec delay fait une chose à la fois, et s'aveugle pendant chaque pause. Dès qu'il faut clignoter tout en surveillant un bouton, ou mener une séquence qui réagit à tout instant, cette façon d'écrire ne suffit plus. Ce chapitre en présente une autre : au lieu de dormir, le programme regarde l'heure à chaque tour et décide s'il est temps d'agir. Associée à la notion d'état, elle permet d'écrire des comportements complets sans jamais bloquer.

Regarder l'heure au lieu de dormir

millis() rend le nombre de millisecondes écoulées depuis le démarrage de la carte. Le programme ne s'arrête plus : à chaque passage dans loop(), il compare l'heure actuelle à celle de sa dernière action, et n'agit que si l'intervalle voulu est écoulé.

Arduino
const int LED = 13;
const unsigned long INTERVALLE = 500;
unsigned long derniereAction = 0; // l'instant du dernier basculement
int etat = LOW;
void setup() {
pinMode(LED, OUTPUT);
}
void loop() {
unsigned long maintenant = millis();
if (maintenant - derniereAction >= INTERVALLE) { // assez de temps écoulé ?
derniereAction = maintenant;
etat = !etat; // basculer
digitalWrite(LED, etat);
}
// ici, le programme reste libre de faire autre chose
}

La LED clignote exactement comme avec delay, mais loop() tourne des milliers de fois par seconde au lieu de dormir. Tout ce qui est écrit après le if s'exécute à chaque tour.

Plusieurs choses à la fois

La conséquence est immédiate : chaque activité garde son propre horodatage, et elles avancent ensemble sans se gêner.

Arduino
const int LED_LENTE = 12, LED_RAPIDE = 11, LED_BOUTON = 13, BOUTON = 2;
unsigned long dernierLent = 0, dernierRapide = 0;
int etatLent = LOW, etatRapide = LOW;
void setup() {
pinMode(LED_LENTE, OUTPUT);
pinMode(LED_RAPIDE, OUTPUT);
pinMode(LED_BOUTON, OUTPUT);
pinMode(BOUTON, INPUT_PULLUP);
}
void loop() {
unsigned long maintenant = millis();
if (maintenant - dernierLent >= 500) { // toutes les 500 ms
dernierLent = maintenant;
etatLent = !etatLent;
digitalWrite(LED_LENTE, etatLent);
}
if (maintenant - dernierRapide >= 350) { // toutes les 350 ms
dernierRapide = maintenant;
etatRapide = !etatRapide;
digitalWrite(LED_RAPIDE, etatRapide);
}
digitalWrite(LED_BOUTON, digitalRead(BOUTON) == LOW); // réagit à chaque tour
}
50 ms par bit20 bauds40 bits, 2 s en tout
LED lente0000000000111111111100000000001111111111LED rapide00000001111111000000011111110000000111110400 ms800 ms1,2 s1,6 s2 s
Les deux premières secondes, par tranches de 50 ms. La LED lente bascule toutes les 500 ms, la rapide toutes les 350 ms : aucune des deux n'attend l'autre, et le bouton est lu entre-temps des milliers de fois par seconde.

Simulé avec un appui de 56 ms, ce programme allume la LED du bouton à l'instant même de l'appui et l'éteint à l'instant où il cesse. La même lecture, placée après un delay(500), l'aurait manqué près de neuf fois sur dix.

Le prix : écrire l'horodatage soi-même
delay gardait le temps pour le programmeur. Avec millis(), chaque activité doit mémoriser l'instant de sa dernière action, dans une variable unsigned long déclarée hors de loop(). Déclarée à l'intérieur, elle serait remise à zéro à chaque tour, et l'intervalle ne serait jamais atteint ou toujours dépassé.

Le retour à zéro

millis() est un unsigned long : après environ 49,7 jours de fonctionnement, il dépasse sa capacité et repart de 0. Un thermostat ou une alarme restent allumés bien plus longtemps, et le test d'intervalle doit y survivre.

La forme par addition
if (millis() >= derniereAction + INTERVALLE) paraît équivalent. Mais si derniereAction vaut 4 294 967 000, peu avant le retour à zéro, l'addition dépasse la capacité et vaut 204. Cent millisecondes plus tard, millis() vaut 4 294 967 100, qui est bien supérieur à 204 : l'action se déclenche après 100 ms au lieu de 500.
La forme par soustraction, millis() - derniereAction >= INTERVALLE, calcule directement la durée écoulée. En arithmétique non signée, cette différence reste juste de part et d'autre du retour à zéro : elle vaut 100 dans le même cas, et l'action attend.

Penser en états

Un feu, une lampe à plusieurs modes, une porte automatique ne font pas « une suite d'instructions » : ils sont dans un état, et certains événements les font passer dans un autre.

Définition

Une machine à états décrit un comportement par un ensemble fini d'états, et par des transitions qui font passer de l'un à l'autre quand un événement survient : un appui, une durée écoulée, une mesure qui franchit un seuil.

Exemple : une lampe à trois modes, commandée par un seul bouton. Chaque appui la fait passer au mode suivant, et le mode clignotant doit clignoter sans cesser d'écouter le bouton.

Graphe orienté et pondéré3 sommets, 3 arêtes
appuiappuiappuiETEINTEFIXECLIGNOTANTE
Les trois états de la lampe et leurs transitions. Le même événement, un appui, mène à un état différent selon l'état de départ : c'est ce qu'une simple condition sur le bouton ne sait pas exprimer.

La traduction en programme suit toujours le même schéma : une variable retient l'état courant, un switch exécute le comportement de cet état et teste ses transitions.

Arduino
const int BOUTON = 2, LAMPE = 9;
const unsigned long STABLE_MS = 30, DEMI_PERIODE = 250;
enum Mode { ETEINTE, FIXE, CLIGNOTANTE };
Mode mode = ETEINTE;
int lectureBrute = HIGH, etatStable = HIGH;
unsigned long dernierChangement = 0, dernierBasculement = 0;
int allumee = LOW;
bool appuiDetecte() { // anti-rebond du chapitre 5, sans delay
int lecture = digitalRead(BOUTON);
if (lecture != lectureBrute) {
lectureBrute = lecture;
dernierChangement = millis();
}
if (millis() - dernierChangement >= STABLE_MS && lectureBrute != etatStable) {
etatStable = lectureBrute;
return etatStable == LOW;
}
return false;
}
void setup() {
pinMode(BOUTON, INPUT_PULLUP);
pinMode(LAMPE, OUTPUT);
}
void loop() {
bool appui = appuiDetecte();
switch (mode) {
case ETEINTE:
digitalWrite(LAMPE, LOW);
if (appui) mode = FIXE;
break;
case FIXE:
digitalWrite(LAMPE, HIGH);
if (appui) {
mode = CLIGNOTANTE;
allumee = HIGH; // la lampe part allumée
dernierBasculement = millis();
}
break;
case CLIGNOTANTE:
if (millis() - dernierBasculement >= DEMI_PERIODE) {
dernierBasculement = millis();
allumee = !allumee;
digitalWrite(LAMPE, allumee);
}
if (appui) mode = ETEINTE;
break;
}
}

enum Mode donne un nom à chaque état : le programme se lit comme le diagramme. Le mode clignotant emploie millis(), si bien que l'appui qui le termine est pris en compte à n'importe quel instant, et non à la fin d'une pause. Le bouton est lu par la fonction d'anti-rebond du chapitre 5, qui ne bloque pas non plus.

Dessiner d'abord
Le diagramme d'états se dessine avant le code, sur papier. Chaque flèche devient un if dans le cas correspondant du switch, et un état sans flèche sortante signale un comportement oublié : que doit faire la lampe si l'on appuie pendant qu'elle clignote ?

Exercices type

Un programme clignote avec delay(500) et doit aussi allumer une LED quand on appuie sur un bouton. Pourquoi l'appui est-il souvent ignoré, et comment corriger ?

Pendant chaque delay(500), le programme est suspendu : le bouton n'est lu qu'une fois par demi-seconde. Un appui bref tombe le plus souvent pendant une pause.

La correction remplace la pause par un test d'intervalle : if (millis() - derniereAction >= 500). Le clignotement garde le même rythme, et loop() tourne sans arrêt, ce qui fait lire le bouton à chaque tour.

derniereAction vaut 4 294 967 000 et INTERVALLE vaut 500. Cent millisecondes plus tard, que valent millis() - derniereAction et le test par addition ?

millis() vaut 4 294 967 100. La soustraction donne 100 : l'intervalle n'est pas écoulé, rien ne se passe, ce qui est juste.

L'addition derniereAction + 500 dépasse la capacité d'un unsigned long et vaut 204. Le test millis() >= 204 est vrai : l'action se déclenche à tort, 400 ms trop tôt. Seule la forme par soustraction est correcte.

La variable derniereAction a été déclarée dans loop(). La LED ne clignote plus. Pourquoi ?

À chaque tour, derniereAction est recréée et remise à 0. Le test millis() - 0 >= 500 est vrai dès que la carte tourne depuis plus d'une demi-seconde : la LED bascule à chaque tour, des milliers de fois par seconde, et paraît allumée à moitié.

L'horodatage doit survivre d'un tour à l'autre : il se déclare hors de loop().

Dans la lampe à trois modes, que se passerait-il si le mode CLIGNOTANTE utilisait delay(250) pour clignoter ?

Pendant chaque pause, appuiDetecte() ne serait pas appelée. Un appui bref serait perdu, et même un appui long ne serait pris en compte qu'après la fin de la pause en cours. La lampe paraîtrait ne pas obéir.

La machine à états ne suffit donc pas seule : c'est l'association d'états et d'un temps mesuré avec millis() qui permet de réagir à tout instant.

Vérification

Vérification rapideon peut se reprendre

1.Que rend millis() ?

2.Pourquoi l'horodatage de la dernière action se déclare-t-il hors de loop() ?

3.Quel test d'intervalle reste juste quand millis() revient à zéro ?

4.Après combien de temps millis() revient-il à zéro ?

5.Dans une machine à états, qu'est-ce qui fait passer d'un état à un autre ?

La méthode

  1. Lister les activités qui doivent avancer ensemble, et donner à chacune son intervalle et son horodatage.
  2. Écrire chaque test sous la forme maintenant - derniere >= INTERVALLE, jamais par addition.
  3. Déclarer les horodatages en unsigned long, hors de loop().
  4. Dessiner le diagramme d'états avant le code : les états, les événements, une flèche par transition.
  5. Traduire avec une variable d'état, un enum pour nommer les états, un switch pour le comportement, un if par flèche.
  6. Vérifier qu'aucun état ne bloque : pas de delay dans un état qui doit rester attentif à un événement.

Synthèse

  • millis() rend le temps écoulé depuis le démarrage ; il permet d'agir quand un intervalle est écoulé, sans suspendre le programme.
  • Chaque activité garde son horodatage dans un unsigned long global ; plusieurs activités avancent alors ensemble.
  • Le test s'écrit par soustraction : millis() - derniere >= INTERVALLE reste juste quand millis() revient à zéro, après environ 49,7 jours.
  • Une machine à états décrit un comportement par des états et des transitions déclenchées par des événements.
  • En programme : une variable d'état, un enum, un switch, un if par transition.
  • États et millis() ensemble permettent de réagir à tout instant, ce qu'aucun delay ne permet.