Écriture de gestionnaires d’interruption¶
Sur du matériel approprié, MicroPython offre la possibilité d’écrire des gestionnaires d’interruption en Python. Les gestionnaires d’interruption – aussi appelés routines de service d’interruption (ISR) – sont définis comme des fonctions de rappel. Ils sont exécutés en réponse à un événement tel qu’un déclenchement de minuteur ou un changement de tension sur une broche. De tels événements peuvent survenir à n’importe quel moment de l’exécution du code du programme. Cela entraîne des conséquences importantes, certaines propres au langage MicroPython. D’autres sont communes à tous les systèmes capables de répondre à des événements en temps réel. Ce document traite d’abord des problèmes propres au langage, puis propose une brève introduction à la programmation temps réel pour ceux qui la découvrent.
Cette introduction utilise des termes vagues comme « lent » ou « aussi vite que possible ». C’est délibéré, car les vitesses dépendent de l’application. Les durées acceptables pour une ISR dépendent de la fréquence à laquelle les interruptions surviennent, de la nature du programme principal et de la présence d’autres événements concurrents.
Conseils et pratiques recommandées¶
Ceci résume les points détaillés ci-dessous et énumère les principales recommandations pour le code des gestionnaires d’interruption.
Gardez le code aussi court et simple que possible.
Évitez l’allocation de mémoire : pas d’ajout à des listes ni d’insertion dans des dictionnaires, pas de virgule flottante.
Envisagez d’utiliser
micropython.schedulepour contourner la contrainte ci-dessus.Lorsqu’une ISR renvoie plusieurs octets, utilisez un
bytearraypré-alloué. Si plusieurs entiers doivent être partagés entre une ISR et le programme principal, envisagez un tableau (array.array).Lorsque des données sont partagées entre le programme principal et une ISR, envisagez de désactiver les interruptions avant d’accéder aux données dans le programme principal et de les réactiver immédiatement après (voir Sections critiques).
Allouez un tampon d’exception d’urgence (voir ci-dessous).
Problèmes propres à MicroPython¶
Le tampon d’exception d’urgence¶
Si une erreur survient dans une ISR, MicroPython est incapable de produire un rapport d’erreur à moins qu’un tampon spécial ne soit créé à cette fin. Le débogage est simplifié si le code suivant est inclus dans tout programme utilisant des interruptions.
import micropython
micropython.alloc_emergency_exception_buf(100)
Le tampon d’exception d’urgence ne peut contenir qu’une seule trace de pile d’exception. Cela signifie que si une deuxième exception est levée pendant le traitement d’une exception alors que le tas est verrouillé, la trace de pile de cette deuxième exception remplacera l’originale – même si la deuxième exception est traitée proprement. Cela peut conduire à des messages d’exception déroutants si le tampon est imprimé par la suite.
Simplicité¶
Pour diverses raisons, il est important de garder le code des ISR aussi court et simple que possible. Il ne doit faire que ce qui doit être fait immédiatement après l’événement qui l’a provoqué : les opérations qui peuvent être différées doivent être déléguées à la boucle du programme principal. Généralement, une ISR traite le périphérique matériel qui a provoqué l’interruption, le préparant pour la prochaine interruption à survenir. Elle communique avec la boucle principale en mettant à jour des données partagées pour indiquer que l’interruption a eu lieu, puis elle retourne. Une ISR doit rendre le contrôle à la boucle principale aussi rapidement que possible. Ce n’est pas un problème propre à MicroPython, il est donc traité plus en détail ci-dessous.
Communication entre une ISR et le programme principal¶
Normalement, une ISR a besoin de communiquer avec le programme principal. Le moyen le plus simple de le faire est via un ou plusieurs objets de données partagés, déclarés soit comme globaux, soit partagés via une classe (voir ci-dessous). Il existe diverses restrictions et risques associés à cette pratique, qui sont détaillés plus loin. Les entiers, les objets bytes et bytearray sont couramment utilisés à cette fin, ainsi que les tableaux (du module array) qui peuvent stocker divers types de données.
L’utilisation de méthodes d’objet comme fonctions de rappel¶
MicroPython prend en charge cette technique puissante qui permet à une ISR de partager des variables d’instance avec le code sous-jacent. Elle permet aussi à une classe implémentant un pilote de périphérique de prendre en charge plusieurs instances de périphérique. L’exemple suivant fait clignoter deux LED à des cadences différentes.
import machine
import micropython
micropython.alloc_emergency_exception_buf(100)
class Foo(object):
def __init__(self, freq, led):
self.led = led
self.timer = machine.Timer(-1, freq=freq, callback=self.cb, hard=True)
def cb(self, tim):
self.led.toggle()
red = Foo(1, machine.LED("LED_RED"))
green = Foo(0.8, machine.LED("LED_GREEN"))
Dans cet exemple, l’instance red pilote la LED rouge depuis un minuteur virtuel à 1 Hz : chaque fois que le minuteur se déclenche, red.cb() est appelée, basculant la LED rouge. L’instance green fonctionne de manière similaire avec un minuteur à 0,8 Hz basculant la LED verte. L’utilisation de méthodes d’instance confère deux avantages. Premièrement, une seule classe permet de partager le code entre plusieurs instances matérielles. Deuxièmement, en tant que méthode liée, le premier argument de la fonction de rappel est self. Cela permet à la fonction de rappel d’accéder aux données d’instance et de sauvegarder l’état entre des appels successifs. Par exemple, si la classe ci-dessus possédait une variable self.count initialisée à zéro dans le constructeur, cb() pourrait incrémenter le compteur. Les instances red et green maintiendraient alors des comptages indépendants du nombre de fois que chaque LED a changé d’état.
Création d’objets Python¶
Les ISR ne peuvent pas créer d’instances d’objets Python. Cela est dû au fait que MicroPython doit allouer de la mémoire pour l’objet à partir d’une réserve de blocs de mémoire libres appelée le heap. Cela n’est pas autorisé dans un gestionnaire d’interruption, car l’allocation sur le tas n’est pas réentrante. En d’autres termes, l’interruption peut survenir alors que le programme principal est en train d’effectuer une allocation – pour préserver l’intégrité du tas, l’interpréteur interdit les allocations de mémoire dans le code des ISR.
Une conséquence en est que les ISR ne peuvent pas utiliser l’arithmétique en virgule flottante ; cela est dû au fait que les flottants sont des objets Python. De même, une ISR ne peut pas ajouter un élément à une liste. En pratique, il peut être difficile de déterminer exactement quelles constructions de code tenteront d’effectuer une allocation de mémoire et provoqueront un message d’erreur : une raison supplémentaire de garder le code des ISR court et simple.
Une façon d’éviter ce problème est que l’ISR utilise des tampons pré-alloués. Par exemple, un constructeur de classe crée une instance de bytearray et un indicateur booléen. La méthode de l’ISR affecte des données à des emplacements dans le tampon et positionne l’indicateur. L’allocation de mémoire a lieu dans le code du programme principal au moment où l’objet est instancié plutôt que dans l’ISR.
Les méthodes d’E/S de la bibliothèque MicroPython offrent généralement une option pour utiliser un tampon pré-alloué. Par exemple, machine.I2C.readfrom_into() lit dans un tampon mutable fourni par l’appelant : cela permet son utilisation dans une ISR.
Un moyen de créer un objet sans employer de classe ni de variables globales est le suivant :
def set_volume(t, buf=bytearray(3)):
buf[0] = 0xa5
buf[1] = t >> 4
buf[2] = 0x5a
return buf
Le compilateur instancie l’argument buf par défaut lorsque la fonction est chargée pour la première fois (généralement lorsque le module qui la contient est importé).
Une instance de création d’objet se produit lorsqu’une référence à une méthode liée est créée. Cela signifie qu’une ISR ne peut pas passer une méthode liée à une fonction. Une solution consiste à créer une référence à la méthode liée dans le constructeur de la classe et à passer cette référence dans l’ISR. Par exemple :
class Foo():
def __init__(self):
self.bar_ref = self.bar # Allocation occurs here
self.x = 0.1
self.tim = machine.Timer(-1, freq=2, callback=self.cb, hard=True)
def bar(self, _):
self.x *= 1.2
print(self.x)
def cb(self, t):
# Passing self.bar would cause allocation.
micropython.schedule(self.bar_ref, 0)
D’autres techniques consistent à définir et instancier la méthode dans le constructeur, ou à passer Foo.bar() avec l’argument self.
Utilisation d’objets Python¶
Une restriction supplémentaire sur les objets découle du fonctionnement de Python. Lorsqu’une instruction import est exécutée, le code Python est compilé en bytecode, une ligne de code correspondant généralement à plusieurs bytecodes. Lorsque le code s’exécute, l’interpréteur lit chaque bytecode et l’exécute sous forme d’une série d’instructions en code machine. Étant donné qu’une interruption peut survenir à tout moment entre les instructions en code machine, la ligne de code Python d’origine peut n’être que partiellement exécutée. Par conséquent, un objet Python tel qu’un ensemble, une liste ou un dictionnaire modifié dans la boucle principale peut manquer de cohérence interne au moment où l’interruption survient.
Un résultat typique est le suivant. En de rares occasions, l’ISR s’exécutera précisément au moment où l’objet est partiellement mis à jour. Lorsque l’ISR tente de lire l’objet, un plantage en résulte. Comme de tels problèmes surviennent généralement à des occasions rares et aléatoires, ils peuvent être difficiles à diagnostiquer. Il existe des moyens de contourner ce problème, décrits dans Sections critiques ci-dessous.
Il est important d’être clair sur ce qui constitue la modification d’un objet. Modifier le contenu d’un array ou d’un bytearray est sûr. Cela est dû au fait que les octets ou les mots sont écrits sous la forme d’une seule instruction en code machine qui n’est pas interruptible : dans le jargon de la programmation temps réel, l’écriture est atomique. Il en va de même pour la mise à jour d’un élément de dictionnaire, car les éléments sont des mots machine, étant des entiers ou des pointeurs vers des objets. Un objet défini par l’utilisateur peut instancier un array ou un bytearray. Il est valide que la boucle principale comme l’ISR modifient le contenu de ceux-ci.
Le risque survient lorsque la structure d’un objet est modifiée, notamment dans le cas des dictionnaires. L’ajout ou la suppression de clés peut déclencher un rehachage. Si une ISR matérielle (hard) s’exécute pendant qu’un rehachage est en cours et tente d’accéder à un élément, un plantage peut survenir. En interne, les variables globales sont implémentées sous forme de dictionnaire. Par conséquent, le programme principal doit créer toutes les variables globales nécessaires avant de démarrer un processus générant des interruptions matérielles (hard). Le code de l’application doit aussi éviter de supprimer des variables globales.
MicroPython prend en charge les entiers de précision arbitraire. Les valeurs comprises entre 230 -1 et -230 sont stockées dans un seul mot machine. Les valeurs plus grandes sont stockées comme des objets Python. Par conséquent, les modifications des entiers longs ne peuvent pas être considérées comme atomiques. L’utilisation d’entiers longs dans les ISR n’est pas sûre, car une allocation de mémoire peut être tentée lorsque la valeur de la variable change.
Surmonter la limitation des flottants¶
En général, il est préférable d’éviter d’utiliser des flottants dans le code des ISR : les périphériques matériels manipulent normalement des entiers et la conversion en flottants se fait normalement dans la boucle principale. Cependant, il existe quelques algorithmes DSP qui nécessitent la virgule flottante. Sur les plateformes dotées d’une virgule flottante matérielle (telles que les OpenMV Cam basées sur STM32), l’assembleur ARM Thumb en ligne peut être utilisé pour contourner cette limitation. Cela est dû au fait que le processeur stocke les valeurs flottantes dans un mot machine ; les valeurs peuvent être partagées entre l’ISR et le code du programme principal via un tableau de flottants.
Utilisation de micropython.schedule¶
Cette fonction permet à une ISR de planifier une fonction de rappel pour exécution « très bientôt ». La fonction de rappel est mise en file d’attente pour exécution, qui aura lieu à un moment où le tas n’est pas verrouillé. Elle peut donc créer des objets Python et utiliser des flottants. Il est également garanti que la fonction de rappel s’exécute à un moment où le programme principal a terminé toute mise à jour d’objets Python, de sorte que la fonction de rappel ne rencontrera pas d’objets partiellement mis à jour.
L’usage typique consiste à gérer un capteur matériel. L’ISR acquiert les données du matériel et lui permet d’émettre une nouvelle interruption. Elle planifie ensuite une fonction de rappel pour traiter les données.
Les fonctions de rappel planifiées doivent respecter les principes de conception des gestionnaires d’interruption décrits ci-dessous. Cela permet d’éviter les problèmes résultant de l’activité d’E/S et de la modification de données partagées qui peuvent survenir dans tout code qui préempte la boucle du programme principal.
Le temps d’exécution doit être considéré par rapport à la fréquence à laquelle les interruptions peuvent survenir. Si une interruption survient pendant l’exécution de la fonction de rappel précédente, une nouvelle instance de la fonction de rappel sera mise en file d’attente pour exécution ; elle s’exécutera après l’achèvement de l’instance en cours. Une cadence de répétition d’interruptions élevée et soutenue comporte donc un risque de croissance incontrôlée de la file d’attente et d’échec final avec une RuntimeError.
Si la fonction de rappel à passer à schedule() est une méthode liée, considérez la note dans « Création d’objets Python ».
Exceptions¶
Si une ISR lève une exception, celle-ci ne se propagera pas à la boucle principale. L’interruption sera désactivée à moins que l’exception ne soit traitée par le code de l’ISR.
Interfaçage avec asyncio¶
Lorsqu’une ISR s’exécute, elle peut préempter l’ordonnanceur asyncio. Si l’ISR effectue une opération asyncio, le fonctionnement de l’ordonnanceur peut être perturbé. Cela s’applique que l’interruption soit matérielle (hard) ou logicielle (soft) et s’applique également si l’ISR a transmis l’exécution à une autre fonction via micropython.schedule. En particulier, la création ou l’annulation de tâches est invalide dans un contexte d’ISR. La manière sûre d’interagir avec asyncio est d’implémenter une coroutine avec une synchronisation effectuée par asyncio.ThreadSafeFlag. Le fragment suivant illustre la création d’une tâche en réponse à une interruption :
tsf = asyncio.ThreadSafeFlag()
def isr(_): # Interrupt handler
tsf.set()
async def foo():
while True:
await tsf.wait()
asyncio.create_task(bar())
Dans cet exemple, il y aura une quantité variable de latence entre l’exécution de l’ISR et l’exécution de foo(). Cela est inhérent à l’ordonnancement coopératif. La latence maximale dépend de l’application et de la plateforme, mais peut généralement se mesurer en dizaines de ms.
Problèmes généraux¶
Ceci n’est qu’une brève introduction au sujet de la programmation temps réel. Les débutants doivent noter que les erreurs de conception dans les programmes temps réel peuvent conduire à des défauts particulièrement difficiles à diagnostiquer. Cela est dû au fait qu’ils peuvent survenir rarement et à des intervalles essentiellement aléatoires. Il est crucial de bien concevoir le système dès le départ et d’anticiper les problèmes avant qu’ils ne surviennent. Les gestionnaires d’interruption comme le programme principal doivent être conçus en tenant compte des problèmes suivants.
Conception des gestionnaires d’interruption¶
Comme mentionné ci-dessus, les ISR doivent être conçues pour être aussi simples que possible. Elles doivent toujours retourner en un laps de temps court et prévisible. C’est important, car lorsque l’ISR s’exécute, la boucle principale ne s’exécute pas : inévitablement, la boucle principale subit des pauses dans son exécution à des points aléatoires du code. De telles pauses peuvent être une source de bogues difficiles à diagnostiquer, en particulier si leur durée est longue ou variable. Afin de comprendre les implications du temps d’exécution d’une ISR, une compréhension de base des priorités d’interruption est nécessaire.
Les interruptions sont organisées selon un schéma de priorités. Le code d’une ISR peut lui-même être interrompu par une interruption de priorité plus élevée. Cela a des implications si les deux interruptions partagent des données (voir Sections critiques ci-dessous). Si une telle interruption survient, elle introduit un délai dans le code de l’ISR. Si une interruption de priorité plus basse survient pendant l’exécution de l’ISR, elle sera retardée jusqu’à ce que l’ISR soit terminée : si le délai est trop long, l’interruption de priorité plus basse peut échouer. Un autre problème avec les ISR lentes est le cas où une deuxième interruption du même type survient pendant son exécution. La deuxième interruption sera traitée à la fin de la première. Cependant, si la cadence des interruptions entrantes dépasse constamment la capacité de l’ISR à les traiter, le résultat ne sera pas heureux.
Par conséquent, les constructions en boucle doivent être évitées ou minimisées. Les E/S vers des périphériques autres que le périphérique interrupteur doivent normalement être évitées : les E/S telles que l’accès au disque, les instructions print et l’accès à l’UART sont relativement lentes, et leur durée peut varier. Un autre problème ici est que les fonctions du système de fichiers ne sont pas réentrantes : utiliser les E/S du système de fichiers dans une ISR et dans le programme principal serait risqué. Surtout, le code d’une ISR ne doit pas attendre un événement. Les E/S sont acceptables si le code peut être garanti de retourner en un laps de temps prévisible, par exemple le basculement d’une broche ou d’une LED. L’accès au périphérique interrupteur via I2C ou SPI peut être nécessaire, mais le temps requis pour de tels accès doit être calculé ou mesuré et son impact sur l’application évalué.
Il est généralement nécessaire de partager des données entre l’ISR et la boucle principale. Cela peut se faire soit par des variables globales, soit par des variables de classe ou d’instance. Les variables sont généralement de type entier ou booléen, ou des tableaux d’entiers ou d’octets (un tableau d’entiers pré-alloué offre un accès plus rapide qu’une liste). Lorsque plusieurs valeurs sont modifiées par l’ISR, il est nécessaire de considérer le cas où l’interruption survient à un moment où le programme principal a accédé à certaines, mais pas à toutes, les valeurs. Cela peut conduire à des incohérences.
Considérez la conception suivante. Une ISR stocke les données entrantes dans un bytearray, puis ajoute le nombre d’octets reçus à un entier représentant le total des octets prêts à être traités. Le programme principal lit le nombre d’octets, traite les octets, puis remet à zéro le nombre d’octets prêts. Cela fonctionnera jusqu’à ce qu’une interruption survienne juste après que le programme principal a lu le nombre d’octets. L’ISR place les données ajoutées dans le tampon et met à jour le nombre reçu, mais le programme principal a déjà lu le nombre, il traite donc les données initialement reçues. Les octets nouvellement arrivés sont perdus.
Il existe diverses façons d’éviter ce risque, la plus simple étant d’utiliser un tampon circulaire. S’il n’est pas possible d’utiliser une structure dotée d’une sûreté intrinsèque vis-à-vis des threads, d’autres moyens sont décrits ci-dessous.
Réentrance¶
Un risque potentiel peut survenir si une fonction ou une méthode est partagée entre le programme principal et une ou plusieurs ISR, ou entre plusieurs ISR. Le problème ici est que la fonction peut elle-même être interrompue et qu’une nouvelle instance de cette fonction soit exécutée. Si cela doit se produire, la fonction doit être conçue pour être réentrante. La façon de procéder est un sujet avancé qui dépasse le cadre de ce tutoriel.
Sections critiques¶
Un exemple de section critique de code est une section qui accède à plus d’une variable pouvant être affectée par une ISR. Si l’interruption survient justement entre les accès aux variables individuelles, leurs valeurs seront incohérentes. C’est un cas d’un risque connu sous le nom de condition de concurrence : l’ISR et la boucle du programme principal se disputent la modification des variables. Pour éviter l’incohérence, un moyen doit être employé pour garantir que l’ISR ne modifie pas les valeurs pendant la durée de la section critique. Une façon d’y parvenir est d’émettre machine.disable_irq() avant le début de la section, et machine.enable_irq() à la fin. Voici un exemple de cette approche :
import machine
import micropython
import array
import random
import time
micropython.alloc_emergency_exception_buf(100)
class BoundsException(Exception):
pass
ARRAYSIZE = const(20)
index = 0
data = array.array('i', [0] * ARRAYSIZE)
def callback1(t):
global data, index
for x in range(5):
data[index] = random.getrandbits(30) # simulate input
index += 1
if index >= ARRAYSIZE:
raise BoundsException('Array bounds exceeded')
tim = machine.Timer(-1, freq=100, callback=callback1, hard=True)
for loop in range(1000):
if index > 0:
irq_state = machine.disable_irq() # Start of critical section
for x in range(index):
print(data[x])
index = 0
machine.enable_irq(irq_state) # End of critical section
print('loop {}'.format(loop))
time.sleep_ms(1)
tim.deinit()
Une section critique peut se composer d’une seule ligne de code et d’une seule variable. Considérez le fragment de code suivant.
count = 0
def cb(): # An interrupt callback
count += 1
def main():
# Code to set up the interrupt callback omitted
while True:
count += 1
Cet exemple illustre une source subtile de bogues. La ligne count += 1 dans la boucle principale comporte un risque spécifique de condition de concurrence connu sous le nom de lecture-modification-écriture. C’est une cause classique de bogues dans les systèmes temps réel. Dans la boucle principale, MicroPython lit la valeur de count, lui ajoute 1, et la réécrit. En de rares occasions, l’interruption survient après la lecture et avant l’écriture. L’interruption modifie count mais sa modification est écrasée par la boucle principale lorsque l’ISR retourne. Dans un système réel, cela pourrait conduire à des défaillances rares et imprévisibles.
Comme mentionné ci-dessus, il faut faire attention si une instance d’un type intégré Python est modifiée dans le code principal et que cette instance est accédée dans une ISR. Le code effectuant la modification doit être considéré comme une section critique afin de garantir que l’instance est dans un état valide lorsque l’ISR s’exécute.
Il faut faire particulièrement attention si un jeu de données est partagé entre différentes ISR. Le risque ici est que l’interruption de priorité plus élevée puisse survenir lorsque celle de priorité plus basse a partiellement mis à jour les données partagées. La gestion de cette situation est un sujet avancé qui dépasse le cadre de cette introduction, si ce n’est pour noter que les objets mutex décrits ci-dessous peuvent parfois être utilisés.
Désactiver les interruptions pendant la durée d’une section critique est la façon de procéder habituelle et la plus simple, mais cela désactive toutes les interruptions plutôt que seulement celle susceptible de causer des problèmes. Il est généralement indésirable de désactiver une interruption longtemps. Dans le cas des interruptions de minuteur, cela introduit une variabilité dans le moment où une fonction de rappel survient. Dans le cas des interruptions de périphérique, cela peut conduire à ce que le périphérique soit servi trop tard, avec une possible perte de données ou des erreurs de dépassement dans le matériel du périphérique. Comme les ISR, une section critique dans le code principal doit avoir une durée courte et prévisible.
Une approche pour gérer les sections critiques qui réduit radicalement le temps pendant lequel les interruptions sont désactivées consiste à utiliser un objet appelé mutex (nom dérivé de la notion d’exclusion mutuelle). Le programme principal verrouille le mutex avant d’exécuter la section critique et le déverrouille à la fin. L’ISR teste si le mutex est verrouillé. S’il l’est, elle évite la section critique et retourne. Le défi de conception consiste à définir ce que l’ISR doit faire dans le cas où l’accès aux variables critiques est refusé. Un exemple simple de mutex peut être trouvé ici. Notez que le code du mutex désactive bien les interruptions, mais seulement pour la durée de huit instructions machine : l’avantage de cette approche est que les autres interruptions sont pratiquement non affectées.
Les interruptions et le REPL¶
Les gestionnaires d’interruption, tels que ceux associés aux minuteurs, peuvent continuer à s’exécuter après la fin d’un programme. Cela peut produire des résultats inattendus là où vous auriez pu vous attendre à ce que l’objet déclenchant la fonction de rappel soit sorti de portée. Par exemple, sur une OpenMV Cam :
def bar():
foo = machine.Timer(-1, freq=4, callback=lambda t: print('.', end=''), hard=True)
bar()
Cela continue de s’exécuter jusqu’à ce que le minuteur soit explicitement désactivé ou que la carte soit réinitialisée avec Ctrl-D.