On ne va pas se mentir: la première fois qu’on lit un code Arduino qui utilise attachInterrupt(), on a l’impression d’avoir trouvé le secret pour faire dix choses à la fois. Une broche qui bascule, une routine qui s’exécute immédiatement, et la loop() qui continue de tourner comme si de rien n’était. Le rêve de tout développeur qui en a assez de voir son bouton poussoir ignoré pendant que le code lit la température.
La réalité technique est un peu plus rugueuse. Sur un ATmega328 cadencé à 16 MHz, la latence pour entrer dans l’ISR est d’environ 1,5 microseconde (soit environ 24 cycles, dont un minimum matériel de 4 cycles), et non plusieurs microsecondes (source: Arduino Stack Exchange). C’est rapide, mais ce n’est pas magique. Et les pièges qui transforment un compteur d’impulsions en désordre aléatoire se cachent dans des détails que la plupart des tutoriels survolent.
Le point aveugle de la loop() et ce qu’une interruption vient y planter
Un microcontrôleur Arduino Uno exécute une instruction par cycle d’horloge, soit jusqu’à 16 millions par seconde. Dans un programme classique, il passe son temps à éplucher la boucle loop() en séquence: lecture d’une entrée, calcul, écriture d’une sortie, et ainsi de suite. Si un événement extérieur survient pendant qu’il est occupé à autre chose, il est purement ignoré jusqu’au prochain digitalRead().
C’est là que les interruptions entrent en scène. Plutôt que de demander à l’Arduino « est-ce que tu as vu quelque chose? » toutes les millisecondes, on configure un matériel interne qui déclenche une routine (l’ISR, pour Interrupt Service Routine) dès que l’événement se produit. Le processeur suspend ce qu’il faisait, sauvegarde le contexte, exécute la routine, puis reprend exactement là où il en était. Le temps de latence entre le front du signal et l’entrée dans la routine est de l’ordre de quelques microsecondes.
Le gain n’est pas seulement une question de réactivité. Il évite de surcharger la boucle principale avec une scrutation permanente qui consomme des cycles pour ne rien faire. Dans un régulateur de charge MPPT pour panneaux solaires, par exemple, c’est une interruption de timer qui génère le signal PWM du convertisseur, pendant que le reste du code peut surveiller la production. Sans ce mécanisme, chaque microseconde passée à vérifier l’état d’une broche est une microseconde non consacrée à l’optimisation du point de puissance.
Les deux seules broches vraiment faites pour ça sur Uno
Sur un Arduino Uno, les interruptions externes matérielles sont au nombre de deux. La broche 2 correspond à l’interruption INT0, la broche 3 à INT1. Le Mega 2560 en propose six, sur les broches 2, 3, 18, 19, 20 et 21. Si vous avez besoin de réagir à plus de deux événements sur un Uno, il faudra passer par les interruptions par changement de pin (PCINT) ou changer de carte. Ce n’est pas un détail: un projet avec trois capteurs tout-ou-rien sur un Uno ne pourra pas utiliser d’interruptions dédiées sur chacun sans recourir à une logique de partage ou à un multiplexage externe.
La documentation officielle d’Arduino précise que ces interruptions peuvent être configurées pour réagir à un niveau bas, un changement d’état, un front montant ou un front descendant. Le mode LOW, en particulier, est piégeux: tant que la broche reste à l’état bas, l’interruption se déclenche en boucle. Si votre capteur de fin de course reste actionné mécaniquement, l’Arduino va saturer son traitement d’interruptions et vous n’obtiendrez plus rien d’autre.
La vidéo ci-dessus décortique le mécanisme pas à pas, depuis le câblage jusqu’à l’exécution. Même après des années de pratique, on y revient pour se rappeler que le signal d’interruption ne se comporte pas toujours comme sur le papier une fois que les parasites s’en mêlent.
attachInterrupt() mode d’emploi sans fioritures
La fonction attachInterrupt() s’utilise à l’intérieur du setup() et ne prend que trois paramètres: l’interruption (sous forme de digitalPinToInterrupt(pin) ou directement INT0), la fonction ISR à appeler, et le mode de déclenchement. Un quatrième paramètre, la priorité, n’existe pas sur AVR: toutes les interruptions ont la même priorité, et une ISR en cours ne peut pas être interrompue par une autre.
volatile bool flag = false;
void setup() {
pinMode(2, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(2), monISR, FALLING);
}
void loop() {
if (flag) {
// action différée en dehors de l'ISR
flag = false;
}
}
void monISR() {
flag = true;
}
Ce squelette minimal cache plusieurs écueils. D’abord, la variable flag est déclarée volatile. Sans ce mot-clé, le compilateur peut décider que flag ne change jamais dans le contexte de la boucle loop() et optimiser le test en conséquence. Résultat: la condition n’est jamais vraie, et vous passez des heures à vérifier la broche avec un oscilloscope en accusant le bouton. Ensuite, notez que l’action réelle (allumer une LED, incrémenter un compteur affiché) est réalisée dans loop(), pas dans l’ISR. On y reviendra.
L’exemple de la vidéo illustre parfaitement le décalage entre le monde idéal du schéma et le monde réel du bouton mécanique. Sans traitement du rebond, une pression unique peut générer trois, quatre, parfois dix déclenchements de l’ISR en quelques millisecondes. On mesure ce rebond sur une entrée standard avec un petit script de comptage et un afficheur série: vous verrez que la fonction monISR() est appelée bien plus souvent que le nombre de fois que vous avez physiquement appuyé.
Le parent pauvre qu’on devrait plus souvent utiliser: les PCINT
Les interruptions par changement de pin (Pin Change Interrupts) sont disponibles sur la quasi-totalité des broches de l’Uno, réparties en trois groupes: D0 à D7, D8 à D13, et A0 à A5. Leur gros défaut? Une seule routine d’interruption par groupe, ce qui oblige à déterminer quelle broche exactement a changé d’état à l’intérieur de l’ISR en lisant un registre.
La configuration passe par les registres PCICR, PCMSK0, PCMSK1 et PCMSK2, bien moins sexy qu’un simple attachInterrupt. En revanche, une fois domptée, elle permet d’intercepter n’importe quel changement sur n’importe quelle broche. Dans un contrôleur de charge d’éolienne domestique 3000W, par exemple, où l’on doit scruter à la fois un capteur de vitesse de vent, un fin de course de frein et une entrée de synchronisation réseau, les deux interruptions externes ne suffisent pas. Les PCINT deviennent alors le seul moyen de conserver un code réactif sans empiler des boucles de scrutation.
Le revers de la médaille, c’est la latence supplémentaire: il faut quelques instructions pour lire le registre et identifier le front qui a bougé. Sur une application où chaque microseconde compte, un compteur d’impulsions rapide, cette gymnastique peut vous faire rater une transition si vous ne la traitez pas en priorité.
Les règles d’or d’une ISR qui ne plante pas
Une ISR doit être exécutée en un temps minimal. À l’intérieur, les interruptions sont désactivées, ce qui signifie que les fonctions millis(), delay() ou Serial.print() ne doivent jamais y figurer. Un appel à delay() dans une ISR est un blocage complet du microcontrôleur, car delay() s’appuie sur l’interruption de timer pour compter le temps. Si les interruptions sont coupées, plus rien ne compte, plus rien ne bouge.
Une mesure sur un ATmega328 à 16 MHz montre qu’un appel à micros() prend environ 3,56 microsecondes et millis() 1,94 microseconde (source: Arduino Stack Exchange). Ces temps, déjà significatifs, doublent si l’on multiplie les lectures. Toute variable lue ou modifiée à la fois dans une ISR et dans le programme principal doit être déclarée volatile. Toute variable de plus d’un octet (comme un int) manipulée dans l’ISR et lue ailleurs doit voir son accès protégé, car l’interruption peut se déclencher entre l’écriture des deux octets, corrompant la valeur lue.
Les variables partagées sont le piège le plus vicieux, parce que le bogue qu’elles produisent est intermittent. Un compteur qui affiche 0, puis 65535, puis 3, trahit une corruption de ce type, et vous pouvez tourner en rond des jours avant de soupçonner l’accès concurrent.
Le traitement ci-dessus montre l’usage des instructions assembleur cli() et sei() pour désactiver brièvement les interruptions pendant la copie d’une variable multi-octets. Ce n’est pas de la micro-optimisation: c’est la seule manière de garantir qu’une valeur de 16 bits lue dans la loop() ne soit pas à cheval entre deux mises à jour atomiques effectuées par l’ISR.
Ce que les tutoriels zappent: le mode LOW n’est pas un front
Le mode LOW de attachInterrupt() déclenche l’interruption tant que la broche reste au niveau bas, pas uniquement lors de la transition. Si un capteur positionné sur une pièce mobile reste en butée basse, l’Arduino va réentrer dans l’ISR immédiatement après en être sorti, remplissant la pile et bloquant le reste du code. Le mode RISING ou FALLING est presque toujours préférable pour des événements ponctuels. Le mode CHANGE cumule les fronts montants et descendants, ce qui le rend utile pour doubler la précision d’un codeur incrémental, mais au prix d’un doublement de la fréquence d’interruption.
Lorsqu’on couple un bouton poussoir mécanique à une interruption en mode FALLING, le rebond provoque des fronts multiples qui ne sont pas filtrés par la logique d’interruption. La solution la plus robuste consiste à désactiver temporairement l’interruption dès la première détection via detachInterrupt(), puis à la réactiver après un délai logiciel dans la boucle loop(), ou à enregistrer le temps du front et à ignorer les suivants dans une fenêtre de 20 à 30 millisecondes. Dans un code côté industriel, on utilise un timer pour effectuer ce debounce proprement sans bloquer la boucle principale.
Interruptions de timer: un métronome sans scorie
Les timers de l’ATmega328P (Timer0, Timer1, Timer2) offrent une source d’interruptions totalement détachée des broches. Timer0, en 8 bits, est utilisé par les fonctions millis() et delay(), mieux vaut ne pas y toucher sauf à vouloir casser le décompte du temps. Timer1, en 16 bits, peut générer une interruption périodique avec une résolution très fine. Timer2, en 8 bits, est similaire mais moins souple.
Configurer un timer pour déclencher une ISR toutes les millisecondes permet de cadencer une lecture de capteur, d’incrémenter une horloge logicielle ou de générer une modulation de largeur d’impulsion sans monopoliser le processeur. Sur un transformateur triphasé monophasé commandé par pont de thyristors, c’est exactement ce principe qui synchronise les impulsions de gâchette avec le passage par zéro du réseau. L’interruption de timer garantit une précision de phase que la boucle loop() ne pourrait jamais atteindre.
Le code de configuration est moins immédiat que attachInterrupt, mais il se limite à quelques lignes de manipulation de registres:
noInterrupts();
TCCR1A = 0;
TCCR1B = 0;
TCNT1 = 3036;
TCCR1B |= (1 << CS12);
TIMSK1 |= (1 << TOIE1);
interrupts();
Ce bloc programme le Timer1 en overflow à 1 Hz avec un prédiviseur de 256. Une fois en place, la routine ISR(TIMER1_OVF_vect) s’exécute à intervalles réguliers, quels que soient les serpents de mer qui encombrent la loop(). Le piège, là encore, est de vouloir faire trop de choses dans cette ISR: si le code qu’elle contient dure plus que la période du timer, les interruptions suivantes sont perdues et vous obtenez une fréquence effective aléatoire.
Un compteur d’impulsions qui ne rate pas un front
Pour clore avec du concret, prenons le cas d’un anémomètre à contact sec qui génère un front descendant par tour. Avec un simple digitalRead() dans la loop(), un vent fort peut produire des impulsions espacées de moins de 5 millisecondes, qu’une boucle occupée à rafraîchir un afficheur LCD risque de manquer.
L’approche par interruption externe en mode FALLING incrémente un compteur dans l’ISR et lève un drapeau. La boucle principale, quand elle détecte le drapeau, lit le compteur, le remet à zéro et met à jour l’affichage. Le traitement lourd est déporté hors de l’ISR, qui se contente d’une simple incrémentation. Résultat: aucune impulsion perdue, un affichage fluide.
Le détail qui tue: le compteur incrémenté dans l’ISR doit être au moins un unsigned long volatile. Un unsigned int sur 16 bits déborde trop vite dans le cas d’un vent soutenu, et le drapeau bool volatile doit être testé et remis à zéro dans une section atomique si l’architecture le nécessite. Négliger cela, c’est s’exposer à un compteur qui semble correct la plupart du temps et qui dérape de quelques unités chaque heure, un défaut strictement impossible à déboguer avec un simple Serial Monitor.
Questions fréquentes
Quelle est la différence entre une interruption externe et une interruption par changement de pin?
Les interruptions externes (INT0 et INT1) sont dédiées à deux broches spécifiques, avec un matériel capable de distinguer le front montant, le front descendant ou le niveau bas. Les interruptions PCINT couvrent presque toutes les broches, mais en groupe, et ne détectent qu’un changement d’état sans en préciser la nature. Il faut donc relire le niveau de la broche dans l’ISR pour déterminer ce qui s’est passé, ce qui ajoute un peu de latence.
Peut-on utiliser Serial.print() dans une interruption si on a un buffer assez grand?
Non. Même si la bibliothèque Serial utilise un buffer, l’appel à Serial.print() active les interruptions pour vider le buffer d’émission. Or, à l’intérieur d’une ISR, les interruptions sont désactivées, ce qui bloque cette vidange et peut mener à un blocage complet. Les fonctions de la bibliothèque Serial ne sont pas reentrantes et ne doivent jamais être employées dans une routine d’interruption.
Comment protéger une variable partagée entre l’ISR et la loop() autrement qu’avec volatile?
Pour les variables de plus d’un octet, volatile garantit que la variable est relue depuis la mémoire à chaque accès, mais ne règle pas le problème d’accès concurrent. On désactive brièvement les interruptions avec noInterrupts() avant de copier la variable, puis on les réactive avec interrupts(). Cette technique empêche l’ISR de modifier la valeur pendant la lecture, au prix de quelques microsecondes de blocage.
Les interruptions de timer sur Arduino Uno consomment-elles plus de courant qu’un polling?
Oui, mais la différence est marginale dans la plupart des applications. Le réveil périodique du cœur depuis un mode de sommeil via une interruption de timer est en réalité plus économe qu’une boucle active qui maintient le processeur en éveil permanent. Si l’autonomie sur batterie est un critère, le couple interruption de timer + mise en veille est le bon schéma.
Votre recommandation sur interruptions arduino
Quelques questions rapides pour adapter la recommandation à votre cas.
Merci, voici notre conseil personnalisé sur interruptions arduino.
D'après vos réponses, le mieux est de reprendre l'article ci-dessus en focalisant sur les passages qui parlent de votre situation : c'est là que se trouvent les recommandations les plus concrètes pour vous. Bonne lecture !