MSP430
Comment gérer une tâche prioritaire et une tâche de fond sur MSP430, sans se peler un RTOS (un OS temps réel) ?
Nous avons vu dans la page précédente comment fonctionnait les appels de fonction, ainsi que les interruptions. Et nous voulons gérer deux fonctions qui tournent simultanément. On va donc devoir se peler la gestion de deux piles, une pour chaque, afin de pouvoir les exécuter d'une manière qui paraitra simultanée, mais ça ne sera pas le cas, on n'a qu'un seul cœur.
Et on découvrira à la fin que cela peut engendrer pas mal de nouveaux problèmes...
Fonctions requises
Ce qu'on veut faire s'appelle un scheduler, et il sera très simplifié ici car nous sommes dans un cas très précis.
- La tâche background_task() est la tâche de fond, celle qui va subir les outrages des arrêts intempestifs. Quand elle a fini, elle met le processeur en sommeil.
- La tâche prioritaire high_task() tournera comme d'habitude, elle ne présentera rien de vraiment spécial, à part de "donner la main" lorsqu'elle aura finie, on aura donc une fonction spéciale à réaliser.
Dans mon cas, c'est le watchdog qui va tourner comme un timer. Il va générer des interruptions très régulièrement (toutes les 2 ms dans mon cas). Et ce sont ces interruptions qui provoqueront :
- l'arrêt de background_task(), ou l'arrêt de high_task(). L'interruption devra le savoir pour déterminer quoi faire.
- le réveil du processeur pour le sortir de sa torpeur s'il s'était endormi (peu importe chez qui).
- rendre la main à high_task(), afin d'exécuter les tâches urgentes.
C'est donc dans l'interruption que va se situer la partie la plus complexe.
En résumé :
- une fonction "donner la main au background "
- une fonction "donner la main à la tâche prioritaire " adaptée pour une interruption
Réservation mémoire
Nous aurons besoin de deux pointeurs de pile, et d'une variable (binaire) qui nous dise quelle tâche est actuellement en train de tourner, par exemple 0 pour la prioritaire et 1 pour le background.
unsigned int high_sp;
unsigned int bg_sp;
unsigned char scheduler_current_task; // binaire
Deux piles (stacks)
Habituellement, le développeur ne gère absolument pas la pile existante. Mais là, il faudra bien le faire !
Le plus simple est de réserver de l'espace pour une pile supplémentaire dédiée à la tâche de fond.
On gardera la pile habituelle, gérée par le compilateur, pour la tâche prioritaire.
#define BG_STACK_SIZE 256
unsigned int bg_stack[BG_STACK_SIZE / 2];
Il faudra initialiser le contenu de la pile pour le premier appel, peu importe les valeurs de registres, à part pour PC et SR. Notez que l'on démarre la pile en fin de mémoire.
// pile inutilisée, vide, SP pointe vers la dernière case
unsigned int *p = bg_stack + (BG_STACK_SIZE / 2);
// mais pour le premier lancement, on va préparer un contexte
// qui stocke 12 registres + SR + PC soit 14 cases
p -= 15; // on pointe 15 cases avant la fin du tableau
// pour pointer en bas de pile après le premier appel.
// R4 à R15 sont dans [0..11], valeurs indéfinies
p[12] = GIE; // SR
p[13] = (unsigned int) high_task; // pointe vers la fonction
// on note la valeur du Stack Pointer
bg_sp = (unsigned int) p;
// la première fois, SP pointera 15 cases avant la fin de la pile
// où se situe le début du contexte à charger
reti, l'instruction de retour d'interruption, dépile SR puis PC. Il s'agit de la lecture de la valeur contenue à l'adresse de SP (le Stack Pointer), qui est copiée dans SR, puis l'adresse dans SP est incrémentée de +2. Et on recommence pour charger PC. Donc il faut que SR soit placé AVANT PC dans la mémoire, ici en position 12 et 13.
Mais avant reti, il aura fallu restaurer le contexte en dépilant les 12 registres (ce qui est inutile la première fois puisque les registres ne contiennent rien, mais bon, c'est pour expliquer ici).
2 fonctions
Il faut les fonctions suivantes :
- Une fonction appelée par high_task() qui redonne la main au background quand elle a finie. Ce sera scheduler_ht2bg_asm().
- Une fonction qui sera exécutée par l'interruption watchdog, en assembleur. Dans l'assembleur, il y aura un appel à une fonction externe qui est normalement incluse directement dans le __interrupt(), mais cette fois elle sera indépendante (tant pis pour le « overhead », autrement dit les instructions supplémentaires qu'il faudra ajouter pour le contexte). C'est elle qui en sortie donnera la main à high_task().
Ces fonctions seront écrites en assembleur, car pour le coup, il ne faut pas laisser le compilateur tenter de faire une optimisation des registres et de la sauvegarde de contexte.
Ces fonctions en assembleur interviennent avec mes fonctions en C :
- background_task()
- high_task()
- watchdog_task()
(le traitement __interrupt pour le watchdog n'existe plus, à la place on aura la routine assembleur qui sera directement mise dans le vecteur d'interruption)
Donner la main au background
C'est la routine assembleur scheduler_ht2bg_asm(), placée à la fin de la fonction high_task(), qui repasse la main à la tâche en arrière-plan background_task().
La fonction high_task() est en train de s'exécuter avec la pile gérée par le compilateur. Lorsqu'elle se termine, il faut qu'elle passe la main à background_task(), là où en était cette tâche, avec sa propre pile.
Le CALL de scheduler_ht2bg_asm() a déjà placé l'adresse de retour sur la pile high_stack.
Mais c'est tout, comme on appelle de l'assembleur, le compilateur ne fait rien de spécial. Pour une fonction normale,
il aurait regardé les registres utilisés, les paramètres à passer pour adapter le code de manière à sauver le contexte
en utilisant le moins possible la pile.
Là, on ne fait pas le malin, on sauvegarde les 12 registres, mais on prépare aussi le retour en ajoutant
de quoi récupérer SP.
Dans mon cas particulier, quand on retourne vers high_task(), c'est au début et la fonction n'attend rien de spécial comme contexte de registres, donc c'est un peu overkill. Mais bon, faisons propre.
VERIFIER LORS DE LA COMPILATION CE QUI EST EFFECTIVEMENT FAIT À l'appel
.global scheduler_ht2bg_asm
scheduler_ht2bg_asm:
; le call de cette routine a provoqué l'empilement
; de l'adresse de retour, donc PC est déjà présent
; et SP a avancé d'une case (+2) dans la pile de high_task()
; pour relancer high_task(), on s'attend à trouver 14 cases
; 12 de registres, puis SR, puis PC
; il faut encore pousser SR dans la pile,
; puis les 12 registres, et on sera bon
;
push.w R2 ; sauve SR qui est dans R2
push.w R15 ; sauvegarde les registres
push.w R14
push.w R13
push.w R12
push.w R11
push.w R10
push.w R9
push.w R8
push.w R7
push.w R6
push.w R5
push.w R4
mov.w SP, &high_sp ; sauvegarde SP dans high_sp
; la variable globale va indiquer que c'est BG,
mov.b #BG_TASK, &scheduler_current_task
mov.w &bg_sp, SP
; SP pointe sur la pile de BG, vers les 14 cases à restaurer
pop.w R4 ; restauration contexte BG
pop.w R5
pop.w R6
pop.w R7
pop.w R8
pop.w R9
pop.w R10
pop.w R11
pop.w R12
pop.w R13
pop.w R14
pop.w R15
reti ; depile SR puis PC soit 14 cases en tout
Lorsque high_task() reprendra, on s'attend à trouver dans la pile d'abord les 12 registres, puis SR et enfin PC. On devra commencer par les restaurer.
wrapper interruption
C'est là que c'est le plus compliqué et critique.
Lorsque l'interruption intervient, on ne sait pas encore si on était avec la pile de high_task() ou la pile de background_task(), mais on pourra lever l'incertitude avec la variable globale scheduler_current_task.
Le mécanisme d'interruption fait que SR et PC ont déjà été mis dans la pile, on ne sait pas encore laquelle. Il faut empiler ensuite les 12 registres, et on sera bon pour la prochaine réactivation où il nous faut les 14 cases dans le bon ordre.
.global watchdog_irq_asm
watchdog_irq_asm:
; l'appel a déjà poussé PC puis SR. Reste les 12 registres.
push.w R15 ; sauvegarde du contexte interrompu
push.w R14
push.w R13
push.w R12
push.w R11
push.w R10
push.w R9
push.w R8
push.w R7
push.w R6
push.w R5
push.w R4
; à ma function "normale" de jouer. Zéro paramètre.
call #watchdog_timer_c ; appel du traitement watchdog
; Sortir du low-power. SR se situe en position 24 dans la pile
bic.w #0x00D0, 24(SP) ; sort du mode LPM3
; qui a été interrompu ?
cmp.b #HIGH_TASK, &scheduler_current_task
jeq watchdog_save_high
watchdog_save_bg:
mov.w SP, &bg_sp ; SP était celui de BG
jmp watchdog_switch_high
watchdog_save_high:
mov.w SP, &high_sp ; SP était celui de HIGH
watchdog_switch_high:
; on part vers high_task(), c'est un choix
mov.b #HIGH_TASK, &scheduler_current_task
mov.w &high_sp, SP ; charge la pile de high_task()
pop.w R4 ; restauration
pop.w R5
pop.w R6
pop.w R7
pop.w R8
pop.w R9
pop.w R10
pop.w R11
pop.w R12
pop.w R13
pop.w R14
pop.w R15
reti ; dépile SR puis PC
; indiquer au système ce vecteur d'interruption
;WDT_VECTOR 0xFFF4 mappé sur .int10
.sect ".int10"
.short watchdog_irq_asm
Usage
C'est assez simple :
- Avant de lancer le moteur des interruptions, le watchdog ici, faire les initialisations, et dire que c'est high_task() qui va tourner dans scheduler_current_task.
- Lancer les interruptions
- Lancer high_task(), rien de spécial ici.
- À la fin de high_task(), lorsqu'il n'y a plus rien à faire, appeler scheduler_ht2bg_asm().
Vous pouvez raffiner en détectant s'il existe une tâche en background à exécuter.
S'il n'y en a pas, passez en mode low power (arrêtez le processeur).
Normalement high_task() est une boucle infinie. - Pour l'interruption, rien de spécial à faire, le vecteur sera directement chargé, et la fonction assembleur s'exécutera si une interruption est déclenchée.
Drame
Et là, c'est le drame.
Pas de problème pour l'exécution du background, de la tâche prioritaire et des interruptions.
Mais si par malheur les deux tâches utilisent les mêmes ressources, par exemple accéder à une interface I2C ou SPI, alors la tâche background ne va pas du tout aimer se faire interrompre en plein milieu d'une transaction I2C.
Mais attention, ce n'est pas le mécanisme d'interruption qui cause problème. C'est le fait que le background s'arrête au milieu de la transaction, et que la tâche prioritaire essaye aussi de réaliser une transaction I2C, dézinguant celle du background. Au retour chez le background, l'I2C sera tout corrompu...
Conclusion : faites attention aux ressources utilisées. Le mécanisme de sauvegarde des registres permet effectivement de retrouver le contexte mémoire du processeur, mais ça ne marche pas pour les ressources matérielles. Il vous faudra réfléchir pour organiser les accès en évitant les possibles conflits, et ça ne sera pas facile.
Passons à la mise en œuvre.