samedi 12 juin 2021

ARDUINO : les Interruptions




ARDUINO : les Interruptions


Qu'est-ce qu'une interruption ? c'est un mécanisme présent sur beaucoup de processeurs, qui permet d'exécuter un code particulier, appelé routine d'interruption, quand une entrée change d'état, mais pas seulement dans ce cas.

Une interruption peut également être déclenchée par un timer, ou servir à réveiller un microcontrôleur endormi.

Dans cet article nous allons également aborder un point important : les sections critiques.

1. Les interruptions

1.1. Changement d'état d'une entrée

Le code d'une application peut demander à ce qu'une routine particulière soit exécutée si une entrée digitale change d'état, en attachant une routine à un vecteur d'interruption, à l'aide de la fonction attachInterrupt().

Lorsqu'une interruption survient le code principal de l'application est interrompu, le code de la routine d'interruption est exécuté, puis le code principal de l'application reprend son exécution là où elle s'était arrêtée.

Déclencher une interruption sur changement d'état d'une entrée digitale est facile. Ce code permet de changer l'état d'une LED en fonction des changements d'état de l'entrée D2 :

const byte ledPin = 13;
const byte interruptPin = 2;
volatile byte state = LOW;

void setup()
{
  pinMode(ledPin, OUTPUT);
  pinMode(interruptPin, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(interruptPin), blink, CHANGE);
}
void loop()
{
  digitalWrite(ledPin, state);
}
void blink() {
  state = !state;
}

La fonction blink() (c'est elle la routine d'interruption) est appelée à chaque fois que l'entrée change d'état.

Sur un ATMEGA328P seules les entrées 2 et 3 sont utilisables. Sur un MEGA2560 elles sont plus nombreuses : 2, 3, 18, 19, 20, 21.

1.1.1. Front montant ou descendant ?

La routine d'interruption peut être appelée uniquement sur les fronts montants ou descendant du signal, ou les deux, en modifiant le 3ème paramètre de la fonction attachInterrupt() :

  • CHANGE : front montant ou descendant
  • RISING : front montant uniquement
  • FALLING : front descendant uniquement

1.1.2. digitalPinToInterrupt()

A quoi peut bien servir la fonction digitalPinToInterrupt() ? c'est une fonction du core ARDUINO qui retourne le N° de l'interruption correspondant à une broche donnée :

digitalPinToInterrupt(2) retournera 0, et digitalPinToInterrupt(3) retournera 1.

  attachInterrupt(digitalPinToInterrupt(2), blink, CHANGE);

Est donc équivalent à :

  attachInterrupt(0, blink, CHANGE);

1.1.3. Pin change interrupts

Il faut mentionner aussi les Pin Change Interrupts (PCINT), qui permettent de déclencher une interruption sur une broche digital quelconque :

https://www.robot-maker.com/forum/topic/12765-pin-change-interrupt-sur-arduino-mega-et-uno/

Les PCINT sont un peu plus coûteuses en temps d'exécution si plusieurs entrées peuvent déclencher une interruption, car dans le code de la routine d'interruption il faut déterminer quelle entrée est responsable de l'interruption.

1.2. Timer interrupts

Une interruption peut être générée également sur expiration d'un timer, ou comparaison du compteur d'un timer à une certaine valeur.

On peut bien entendu manipuler directement les registres d'un timer :

https://www.locoduino.org/spip.php?article84

https://www.locoduino.org/spip.php?article88

https://www.locoduino.org/spip.php?article89

https://www.instructables.com/Arduino-Timer-Interrupts/

Heureusement, des librairies, bien plus faciles à utiliser, existent :

TimerOne

MsTimer2

Comme je le conseille souvent, essayer les exemples des librairies est un bon point de départ.

ATTENTION : certaines librairies utilisent les timers :

  • timer0 (8 bits) compte de 0 à 256 et commande la PWM des broches 5 et 6. Il est aussi utilisé par les fonctions delay(), millis() et micros().
  • timer1 (16 bits) compte de 0 à 65535 et est utilisé pour la commande PWM des broches 9 et 10. Il est utilisé également par la libriaire Servo.h
  • timer2 (8 bits) est utilisé par la fonction Tone() et la génération de la PWM sur les broches 3 et 11.

Par exemple la librairie IRREMOTE utilise également le timer2, et son utilisation entrera en conflit avec la librairie tone si elles sont utilisées dans le même logiciel :

core.a(Tone.cpp.o): In function tone(unsigned char, unsigned int, unsigned long)': /home/user/arduino-1.0.1/hardware/arduino/cores/arduino/Tone.cpp:230: multiple definition of__vector_7'

IRremote/IRremote.cpp.o:/home/user/arduino-1.0.1/libraries/IRremote/IRremote.cpp:70: first defined here

1.3. Analog interrupts

On peut même déclencher une interruption à l'aide du comparateur analogique intégré à l'ATMEGA328P :

http://www.gammon.com.au/forum/?id=11916

https://zestedesavoir.com/articles/2106/arduino-les-secrets-de-lanalogique/#4-comparaison-directe-avec-arduino

1.4. Pièges

Piège courant : que signifie cet attribut volatile dans l'exemple ci-dessus ? il faut savoir que lorsque l'on lit dans le programme principal une variable susceptible d'être modifiée par une routine d'interruption cet attribut est indispensable si l'on veut éviter que le compilateur procède à certaines optimisations. L'attribut volatile forcera le code généré par le compilateur à relire la variable à chaque fois.

Dans l'exemple donné ci-dessus au paragraphe 1.1. l'attribut volatile est inutile car la variable state n'est lue qu'une fois dans la fonction loop(). Si elle était lue répétitivement dans une boucle for() ou while() l'attribut volatile serait indispensable. Dans le doute, utiliser volatile systématiquement n'est pas une mauvaise idée.

Deuxième piège : une routine d'interruption doit être la plus courte possible. Dans l'exemple précédent la routine blink() se contente de modifier une variable. Il est hors de question de faire des traitements lourds dans une routine d'interruption (Serial.print() par exemple). Le risque est de rater une nouvelle interruption pendant que l'interruption courante est en cours de traitement.

1.5. Temps d'exécution

Il est possible de surveiller une entrée par une lecture répétitive dans le programme principal, ce que l'on appelle polling, ou à l'aide d'une interruption.

La gestion d'une interruption n'est pas un mécanisme gratuit. Son exécution prend du temps :

  • sauvegarde sur la pile du contexte d'exécution de l'application (un certain nombre de registres)
  • exécution de l'interruption
  • restitution du contexte d'exécution de l'application

Il convient donc d'utiliser les interruptions à bon escient. Il peut arriver que l'exécution répétitive d'interruptions à fréquence élevée consomme plus de temps CPU qu'un traitement classique dans le programme principal.

Il est parfois plus économique et plus efficace de travailler en polling plutôt que sous interruption.

2. Section critique

2.1. Code critique

Comme dit plus haut :

Lorsqu'une interruption survient le code principal de l'application est interrompu, le code de la routine est exécuté, puis le code principal de l'application reprend son exécution.

Il s'en suit donc que l'exécution du code principal peut être perturbé, d'un point de vue temporel, par une interruption. Si la précision d'exécution dans le temps est critique, il convient de protéger le code en désactivant les interruptions. Cela s'appelle une section critique :

void setup() {}

void loop()
{
  noInterrupts(); // désactivation des interruptions
  // portion de code critique, sensible au temps, ici
  interrupts();      // réactivation des interruptions
  // le reste du code ici
}

Dans l'exemple suivant (la librairie OneWire, écrite par un développeur très compétent), les interruptions sont désactivées pendant la lecture et l'écriture sur le bus, car la communication OneWire doit respecter des timings très précis :

https://github.com/PaulStoffregen/OneWire/blob/master/OneWire.cpp

Il faut savoir que même si le logiciel que l'on est en train d'écrire ne met en place aucune routine d'interruption, certaines routines d'interruption sont déjà installées par la librairie ARDUINO :

  • ligne série (Serial)
  • timer ZÉRO (utilisé entre autres par millis())

On n'est donc jamais totalement à l'abri.

2.2. Protection de variables

Une section critique peut également être utilisée lorsque l'on désire lire ou modifier des variables pouvant être également modifiées par une interruption.

Première règle : ne jamais travailler directement avec des variables modifiables par une interruption.

Pourquoi ? tout simplement parce que pendant le traitement, la variable peut changer de valeur suite à une interruption.

J'ai déjà travaillé sur un driver LIN (LIN est un protocole de communication largement utilisé dans l'industrie). Chaque trame LIN était reçue sous interruption et l'auteur n'avait pas pris la peine de recopier celle-ci avant de la traiter. Cela semblait fonctionner la plupart du temps, mais de temps à autre une nouvelle trame arrivait avant que la précédente ne soit complètement traitée. Le résultat était catastrophique !

Dans le programme principal il faut donc toujours copier les variables avant de les utiliser, et ceci doit être fait dans une section critique.

2.2.1. Cas simple

Le cas le plus simple est la modification d'une variable sur 32 bits. Si le programme principal consulte une variable de type long ou unsigned long susceptible d'être modifiée par une interruption, une lecture ou une écriture fiable n'est pas garantie.

Le microcontrôleur ATMEGA328P ne possède pas d'instructions de lecture ou d'écriture 32 bits. La lecture ou l'écriture sera donc effectuée en deux instructions. Si une interruption survient entre les deux instructions, le résultat peut être faussé.

const byte interruptPin = 2;
volatile uint32_t counter = 0;

void setup()
{
  pinMode(interruptPin, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(interruptPin), isr, CHANGE);
}

void loop()
{
  uint32_t cnt;
  if (counter >= 100000UL) {
    cnt = counter;
    counter = 0;
    // traitement de cnt
  }
}

void isr()
{
  counter++;
}

Lorsque counter est copié dans cnt dans la fonction loop(), l'opération est réalisée en réalité par deux copies de mots de 16 bits, LSB (octet de poids faible) et MSB (octet de poids fort) :

Exemple : 
  • counter = 0x0001FFFF
  • copie du LSB : cnt vaut donc 0x0000FFFF
  • interruption ! : counter est incrémenté : counter vaut maintenant 0x00020000
  • copie du MSB : cnt devient 0x0002FFFF
  • le résultat est totalement faux

Une section critique garantira que LSB et MSB soient copiés correctement :

void loop()
{
  uint32_t cnt;
  if (counter >= 100000UL) {
    noInterrupts();
    cnt = counter;
    counter = 0;
    interrupts();
    // traitement de cnt
  }
}

Avec cette modification le déroulement devient : 

  • counter = 0x0001FFFF
  • désactivation des interruptions
  • copie du LSB : cnt vaut donc 0x0000FFFF
  • pas d'interruption possible
  • copie du MSB : cnt devient 0x0001FFFF
  • réactivation des interruptions
  • le résultat est correct

Bien sûr on pourra objecter que cet exemple est extrême et qu'il y a très peu de chances pour que counter soit égal à 0x0001FFFF et qu'une interruption survienne pendant la copie. Mais sur un temps d'exécution très long cela peut parfaitement arriver. Mieux vaut prévenir ce genre de situation, car en cas de problème il sera très difficile d'en déterminer l'origine.

2.2.2. Cas plus complexe

Prenons un autre exemple, avec deux variables :

#define MAXBUFF      100

const byte interruptPin = 2;
volatile byte counter = 0;
byte buffer[MAXBUFF];

void setup()
{
  pinMode(interruptPin, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(interruptPin), isr, CHANGE);
}

void loop()
{
  byte localBuffer[MAXBUFF];

  if (counter >= MAXBUFF / 2) {
    // recopie de buffer
    memcpy(localBuffer, buffer, counter);
    counter = 0;
    // traitement différé du buffer localBuffer
  }
}

void isr()
{
  if (counter < MAXBUFF) {
    buffer[counter] = random(0, 200);
    counter++;
  }
}

Dans cet exemple la routine d'interruption isr() ajoute à un buffer un caractère aléatoire et incrémente un index nommé counter. Dans la réalité ce caractère pourrait parfaitement provenir de la lecture d'un port physique d'entrée / sortie, ou de la lecture d'un timer.

Dans la fonction loop(), si le buffer est à moitié plein il est recopié dans un autre buffer localBuffer pour être traité ensuite, puis counter est remis à ZÉRO.

Une interruption peut survenir pendant que loop() effectue cette opération, ce qui pourrait provoquer une incohérence entre le contenu du buffer et l'index.

Exemple : imaginons que counter soit égal à 55 :

  • loop() recopie le buffer
  • interruption ! la routine isr() place un octet supplémentaire à l'emplacement 55 et incrémente la variable counter. counter devient égal à 56
  • loop() remet counter à ZÉRO

Résultat : avant l'interruption counter vaut 55, et 56 après l'interruption. Après l'interruption, loop() remet la variable counter à ZÉRO. Le caractère à l'emplacement 55 est donc perdu, puisqu'il n'a pas été recopié.

Le remède est l'adoption d'une section critique :

void loop()
{
  byte localBuffer[MAXBUFF];

  if (counter >= MAXBUFF / 2) {
    noInterrupts();
    memcpy(localBuffer, buffer, counter);
    counter = 0;
    interrupts();
    // traitement du buffer localBuffer
  }
}

On préservera ainsi la cohérence entre les deux variables.

3. Le mode veille

Le mode veille est utilisé lorsque l'on désire consommer un minimum d'énergie dans une configuration alimentée par piles ou batterie. En pratique seule la carte PRO MINI est adaptée à ce genre d'utilisation, ainsi que certaines cartes ESP8266 et ESP32.

J'en parle ici :

ARDUINO PRO MINI & basse consommation

Consommation d'une carte ARDUINO, ESP8266 ou ESP32

Le mode veille est intimement lié aux interruptions, car lorsque le microcontrôleur est endormi, l'unique moyen de le réveiller est une interruption, et pas n'importe laquelle.

On peut sortir du mode veille en utilisant différentes méthodes :

  • réveil au bout d'un temps défini à l'aide du watchdog
  • réveil par une entrée digitale

Exemple : on peut parfaitement réveiller un ARDUINO à l'aide d'un capteur de présence sur une entrée digitale.

Sur un ATMEGA328P seules les entrées 2 et 3 sont utilisables pour le réveil. Sur un MEGA2560 elles sont plus nombreuses : 2, 3, 18, 19, 20, 21.

3.1. ESP8266 et ESP32

Contrairement à ce qui se passe sur un ARDUINO, le réveil par GPIO n'existe pas vraiment sur un ESP8266 ou un ESP32, en tous cas pas de la même façon. Un RESET est provoqué et le logiciel doit aller consulter les informations de RESET et lire l'état de la GPIO pour connaître la cause du réveil.

L'ESP32 possède une autre possibilité de réveil : le touchpad

voir cet article : ESP8266 et ESP32 sur batterie

3.2. Tutoriels

On trouve énormément de tutoriels sur le sujet :

TUTORIAL:A GUIDE TO PUTTING YOUR ARDUINO TO SLEEP

Waking up an Arduino with Input from a Sensor

Comme on peut le constater le code n'est pas toujours très abordable pour un débutant.

3.3. Librairie LowPower

Pour ma part j'utilise la librairie LowPower, très simple à mettre en oeuvre (voir les exemples) :

Réveil périodique : idleWakePeriodic

Réveil par une entrée digitale : powerDownWakeExternalInterrupt

Dans ce dernier exemple on aura avantage à remplacer la ligne suivante :

    attachInterrupt(0, wakeUp, LOW);

Par :

    attachInterrupt(0, wakeUp, RISING);

ou 

    attachInterrupt(0, wakeUp, FALLING);

Ceci afin de réveiller le microcontrôleur seulement sur un front montant ou descendant.

4. Conclusion

Maîtriser les interruptions peut être un atout dans certaines situations, mais en abuser serait une erreur.


Cordialement

Henri

2 commentaires:

  1. bonjour,
    J'ai une question sur les interruptions car bloqué dans mon code, est-il possible de coder plus d'une interruption ? Dans mon cas j'en ai codé 2 mais cette 2eme est comme inexistante, elle ne se déclenche absolument pas. Pourtant, comparé à la première interruption codée (qui elle fonctionne a merveille), m'a l'air tout a fait semblable.
    Un avis ?
    Merci !

    RépondreSupprimer
    Réponses
    1. Sans le code, difficile de se prononcer.
      Cela va être difficile de poster le code dans les commentaires de ce blog. Sur https://forum.arduino.cc/c/international/francais/49 ce sera plus facile.

      Supprimer