Faire clignoter plusieurs LED

Ce tutoriel un peu technique aborde le sujet de la programmation multitâches avec MicroPython. Bien évidemment, nous n’allons pas réaliser un système d’exploitation ! Nous nous limiterons à démontrer comment, par une gestion astucieuse du temps, et éventuellement par l’usage de timers, il est possible de donner l’illusion que la STM32WB55 exécute plusieurs tâches indépendantes simultanément. Nous avons choisi trois tâches très simples :

  • Tâche 1 : fait clignoter la LED1 à la fréquence de 0.5 Hz
  • Tâche 2 : fait clignoter la LED2 à la fréquence de 2 Hz
  • Tâche 3 : fait clignoter la LED3 à la fréquence de 10 Hz

Nous n’aborderons pas les problématiques d’échanges de données ou d’interactions entre les tâches.

Méthode numéro 1 : partage du temps avec la méthode time.ticks_ms()

Cette première approche est directement inspirée de nombreux exemples que l’on trouve sur Internet, généralement pour l’API Arduino. Tout le traitement est entièrement réalisé dans la “boucle principale” (while True:). Le code est particulièrement lisible, mais la méthode est impitoyable pour le microcontrôleur qui est actif et consomme de l’énergie 100% du temps alors que le clignotement des LED - la partie utile du programme - ne l’occupe qu’une très petite fraction du temps.

Voici le code :

import time # Bibliothèque pour gérer les temporisations

# Variable pour suivre le temps écoulé depuis le lancement du script
n = 0

# Variables globales pour les dates de clignotement des LED
blink1 = 0
blink2 = 0
blink3 = 0

while True: # Boucle infinie

	# On teste le temps écoulé pour chaque tâche.
	# Remarquez que les tests sur les durées ne portent pas sur une égalité (stricte) mais sur un dépassement.
	# Par exemple on écrit "if time.ticks_diff(now, blink1) > 99" et pas "if time.ticks_diff(now, blink1) == 99".
	# En effet, il est peu probable que la boucle infinie réalise son test exactement à l'instant qui est prévu.
	# Il y aura donc une certaine incertitude (faible) sur les fréquences de clignotement des LED.

	# Nombre de millisecondes écoulées depuis le lancement du script
	n = time.ticks_ms()

	# Tâche 1 : clignotement LED1
	if time.ticks_diff(n, blink1) > 1999: # Toutes les 2000 ms depuis la dernière inversion de LED1...
		pyb.LED(1).toggle() # On inverse la LED 1...
		blink1 = n # Et on mémorise la date de cet évènement.

	# Tâche 2 : clignotement LED2
	if time.ticks_diff(n, blink2) > 499: # Toutes les 500 ms depuis la dernière inversion de LED2...
		pyb.LED(2).toggle() # On inverse la LED 2...
		blink2 = n # Et on mémorise la date de cet évènement.

	# Tâche 3 : clignotement LED3
	if time.ticks_diff(n, blink3) > 99: # Toutes les 100 ms depuis la dernière inversion de LED3...
		pyb.LED(3).toggle() # on inverse la LED 3...
		blink3 = n # Et on mémorise la date de cet évènement.

Méthode numéro 2 : partage du temps à l’aide d’un timer système

Cette deuxième approche utilise un timer qui est démarré avant la boucle principale et qui décompte le temps de façon totalement indépendante. Vous pouvez imaginer le timer comme un compteur intégré au microcontrôleur.

Une fois paramétré et démarré, un timer va incrémenter son compteur interne CNT par pas de 1 de 0 jusqu’à une valeur maximum, par exemple CNT_max = 4095. Lorsque CNT = CNT_max le timer génère une interruption dite “de dépassement de compteur”. A ce moment là, CNT revient à zéro et le timer recommence son cycle de décompte : CNT = 0, CNT = 1… Le schéma qui suit illustre le décompte d’un timer pour CNT_max=5.

Principe décompte timer

Nous utilisons l’interruption de dépassement de compteur pour générer une base de temps qui sert au clignotement des LED. Par comparaison avec la première solution, cette version n’est pas tellement plus compliquée et présente un avantage : ce n’est plus le Cortex M4 qui compte le temps écoulé dans la boucle principale, mais l’un des timers du STM32WB55. Le Cortex M4 sera donc soulagé de cette tâche et on peut imaginer que le timing des diodes sera plus précis. Il reste néanmoins monopolisé à 100% (du temps) par la boucle principale, mais cette fois-ci, du fait que nous utilisons une interruption, nous pouvons utiliser la fonction pyb.wfi() pour placer le microcontrôleur en mode économie d’énergie en mode économie d’énergie jusqu’à la prochaine interruption (qui va le “réveiller”), ou pendant une milliseconde.

Voici le code :

from pyb import Timer # Bibliothèque pour gérer les timers

# Variable globale qui servira de rééfrence de temps
n = 0

# Variables globales pour les dates de clignotement des LED
blink1 = 0
blink2 = 0
blink3 = 0

# Routine de service de l'interruption (ISR) de dépassement de compteur du timer 1.
# Elle incrémente laz variable n de 1 tous les 100-ièmes de seconde.
def tick(timer):
	global n # TRES IMPORTANT, ne pas oublier le mot-clef "global" devant la variable n
	n += 1

# Démarre le timer 1 à la fréquence de 100 Hz.
tim1 = pyb.Timer(1, freq=100)

# Assigne la fonction "tick" à l'interruption de dépassement de compteur du timer 1.
# Elle sera appelée 100 fois par seconde.
tim1.callback(tick)

while True: # Boucle infinie

	# On teste le temps ; chaque unité de n indiquant qu'un centième de seconde s'est écoulé.
	# Remarquez que les tests sur les durées ne portent pas sur une égalité (stricte) mais sur un dépassement.
	# Par exemple on écrit "n - blink1 > 9" et pas "n - blink1 == 10".
	# En effet, le timer incrémentant n de façon idépendante, il est probable que la boucle infinie "rate" la valeur
	# n = 10 l'essentiel de ses itérations.

	pyb.wfi() # Place le microcontrôleur en mode économie d'énergie

	if n - blink1 > 9: # Toutes les 0.1s depuis la dernière inversion de LED1...
		pyb.LED(3).toggle() # On inverse la LED 3...
		blink1 = n # Et on mémorise la date de cet évènement.
	if n - blink2 > 49: # Toutes les 0.5s depuis la dernière inversion de LED2...
		pyb.LED(2).toggle() # On inverse la LED 2...
		blink2 = n # Et on mémorise la date de cet évènement.
	if n - blink3 > 199: # Toutes les deux secondes depuis la dernière inversion de LED3...
		pyb.LED(1).toggle() # On inverse la LED 1...
		blink3 = n # Et on mémorise la date de cet évènement.

		# On remet les compteurs de temps à zéro lorsque la LED avec la plus longue période a changé d'état.
		# Ceci afin d'éviter un dépassement de capacité de la variable n si le script "tourne" trop longtemps.
		n = 0
		blink1 = 0
		blink2 = 0
		blink3 = 0

Méthode numéro 3 : utiliser plusieurs timers

Cette troisième approche utilise trois timers du STM32WB55, les paramétrise aux fréquences désirées et exploite leurs interruptions respectives de dépassement de compteur pour inverser les LED.

  • Avantage 1 : On se rapproche d’un véritable multitâche matériel, mais on n’y arrive pas tout à fait car les fonctions de service de l’interruption de dépassement des timers doivent être exécutées par le Cortex M4 du STM32WB55.
  • Avantage 2 : Il n’y a pas de programme “principal” (ie : de boucle “while True:”) ; cette approche consomme donc bien moins d’énergie que celles qui précèdent.
  • Inconvénient : On utilise un timer pour piloter chaque LED. Cette débauche de ressources matérielles atteindra vite ses limites.L’interpréteur MicroPython pour la NUCLEO-WB55 implémentant 4 timers (TIM1, TIM2, TIM16 et TIM17) on ne pourra pas gérer plus que 4 LED (ou, plus généralement, 4 tâches) de cette façon.

Voici le code :

from pyb import Timer # Bibliothèque pour gérer les timers

tim1 = Timer(1, freq= 0.5) # Fréquence du timer 1 fixée à 0.5 Hz

# Callback : appelle la routine de service de l'interruption de dépassement du timer.
# Ici cette routine est pyb.LED(1).toggle(), l'écriture est condensée sous forme d'une lambda-expression.
tim1.callback(lambda t: pyb.LED(1).toggle()) # On inverse LED 1 une fois toutes les deux seconde

tim2 = Timer(2, freq= 2) # Fréquence du timer 2 fixée à 2 Hz
tim2.callback(lambda t: pyb.LED(2).toggle()) # On inverse LED 2 deux fois par seconde.

tim16 = Timer(16, freq=10) # Fréquence du timer 16 fixée à 10 Hz
tim16.callback(lambda t: pyb.LED(3).toggle()) # On inverse LED 3 dix fois par seconde.

Pour finir, une petite variation sur ce dernier code, qui n’utilise pas de lamda-expressions dans les “callbacks” des timers. On écrit explicitement les routines de service des trois interruptions (ISR) de dépassement des timers.

Attention, ** contrairement aux méthodes 1 et 2, cette méthode qui repose entièrement sur des fonctions “callback” / sur des ISR n’est pas généralisable à des programmes plus compliqués. En effet, le code d’une ISR **doit toujours être le plus simple et le plus bref possible et déléguer ensuite les calculs et traitements plus compliqués à une boucle while True:. Par ailleurs, en MicroPython, il est déconseillé de mener des calculs sur des “nombres à virgule flottante” dans les ISR (par exemple -1.25 ou 2/3) il faut privilégier la manupulation d’entiers (-1, 245, 680852, etc.).

D’autres optimisations sont mises en oeuvre :

  • La directive micropython.native avant une fonction, qui indique au compilateur de l’interpréteur MicroPython qu’il faut optimiser le code de celle-ci. En général, cela conduit à un code deux fois plus performant au prix de quelques contraintes de programmation que nous ne détaillerons pas ici.
  • Une boucle while True: qui réduit la consommation du microcontôleur en l’absence d’interruption, gâce à l’instruction pyb.wfi() déjà présentée et utilisée dans la deuxième méthode.
from pyb import Timer # Bibliothèque pour gérer les timers

@micropython.native # Demande à Micropython de générer un code binaire optimisé pour cette fonction
def blink_LED1(timer): # ISR du timer 1
	pyb.LED(1).toggle()

tim1 = Timer(1, freq= 0.5) # Fréquence du timer 1 fixée à 0.5 Hz
tim1.callback(blink_LED1) # Appelle l'ISR de l'interruption de dépassement du timer 1 : la fonction blink_LED1

@micropython.native
def blink_LED2(timer): # ISR du timer 2
	pyb.LED(2).toggle()

tim2 = Timer(2, freq= 2) # Fréquence du timer 2 fixée à 2 Hz
tim2.callback(blink_LED2) # Appelle l'ISR de l'interruption de dépassement du timer 2 : la fonction blink_LED2

@micropython.native
def blink_LED3(timer): # ISR du timer 16
	pyb.LED(3).toggle()

tim16 = Timer(16, freq=10) # Fréquence du timer 16 fixée à 10 Hz
tim16.callback(blink_LED3) # Appelle l'ISR de l'interruption de dépassement du timer 16 : la fonction blink_LED3

@micropython.native
def main():
	while True:
		pyb.wfi() # Place le micrcontrôleur en mode économie d'énergie

# Boucle principale
main()