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) ?
Nota : les principes généraux décrits ci-après sont valables pour d'autres microcontrôleurs que le MSP430.
Description du problème
Voilà le problème :
Sur un MSP430, on n'a pas tant de mémoire ni de CPU que ça. Alors installer un RTOS capable de gérer diverses tâches, c'est vraiment gros et c'est le marteau pour écraser la mouche.
Ici, c'est pourtant assez simple :
- J'ai une routine très longue à exécuter (des secondes, voire une dizaine de secondes), pas urgente, mais quand on est dedans, le processeur ne sait rien faire d'autre. À part effectuer des interruptions.
- Les interruptions sont très importantes : elles sont de priorité maximale
(il existe même une hiérarchie des interruptions, mais c'est secondaire ici).
Les interruptions paraissent magiques au premier abord : le processeur est averti (par un mécanisme interne) que quelque chose arrive (par exemple vous avez appuyé sur un bouton, ou une horloge indique qu'une nouvelle seconde est arrivée), et lance la petite procédure que vous avez écrite en C. Et quand c'est fini, le programme reprend comme si de rien était. - Seulement, lorsqu'une interruption arrive, j'ai des tâches spécifiques à réaliser, qui peuvent être éventuellement
complexes et imbriquées, et qui sont vraiment urgente à réaliser. Alors j'aimerais que le processeur bascule vers
cette routine de haute priorité quand une interruption arrive.
Et quand elle est finie, la routine de haute priorité peut rendre la main à la routine longue. Ou mettre le processeur en sommeil si elle est finie, histoire d'économiser l'énergie. Ce que ne fait JAMAIS votre processeur, quand il n'a rien à faire, il exécute la tâche "je ne fais rien", qu'il appelle inactive, mais en réalité, le processeur tourne en se regardant le nombril, z'avez qu'à regarder les processus qui tournent sur votre PC en mode administrateur...
En résumé, nous avons 3 items :
- __interrupt : les interruptions archi-prioritaires, et impérativement très courtes (car il peut en arriver plusieurs rapidement). Ces interruptions arment des flags en mémoire pour dire qu'il y a du boulot à réaliser assez vite.
- high_task() : un programme de haute priorité, qui traite le boulot généré par les interruptions. Ce programme peut être interrompu par les priorités, mais rien d'autre. Du coup, pas question que ce programme soit lui-même une interruption, sinon ça devient ingérable (on n'interrompt pas une interruption).
- background_task() : un programme de basse priorité, qui s'exécute "quand le processeur n'a rien d'autre à faire". Il peut éventuellement présenter plusieurs tâches à faire, mais une seule à la fois.
Il nous faut donc avant examiner comment travaille un processeur, quel qu'il soit, pour appeler des routines.
Les appels de fonction
Lorsque vous écrivez un programme en C (ou dans n'importe quel langage), à la fin, vous aurez un paquet de routines, dont la première est souvent appelée « main ».
Du travail routinier
Chaque routine est un petit programme qui doit s'exécuter sur votre matériel, un des cœurs de votre CPU de votre PC, ce qui n'est jamais qu'une suite d'instructions de base. Votre compilateur transforme votre langage C en cette suite d'instructions élémentaires, avec des règles souvent compliquées et nombreuses suivant ce que le matériel sait faire et manipuler comme mémoire.
Chaque routine peut appeler d'autres routines, mais à un moment, ça s'arrête, la routine n'appelle plus personne, il n'y a que des instructions basiques (sauf les gars qui écrivent des routines récursives, qui s'appellent elles-mêmes, ou carrément des boucles de routines, ce qui n'est guère recommandable).
Appeler une routine n'est pas compliqué : il suffit que le pointeur d'exécution (le PC) aille sur la première instruction de la routine cible, et le tour est joué.
Sauf que.
Sauf qu'à la fin de la routine, que fait le processeur pour "revenir" au programme qui l'a appelé ? Et c'est pire que ce que vous pensez.
Ce serait bien que le processeur se retrouve dans l'état où il était au moment d'appeler la routine, non ? Ses registres internes étaient dans un état particulier, on aimerait bien qu'ils reviennent dans cet état !
Sauvegarde/restauration de contexte
Ainsi, lors de l'appel de la routine, et juste avant d'y aller, le processeur doit impérativement sauver son état, pour le retrouver et le recharger comme il était afin de reprendre le boulot.
C'est là qu'il faut comprendre comment ça marche afin de programmer les bascules entre les diverses fonctions. Surtout qu'un appel de routine en C, et bien ça peut être différent d'une interruption, même si cela parait du même tonneau au premier abord.
Le processeur va devoir faire avant la bascule, une sauvegarde en mémoire de ses registres internes. La question est de savoir comment et où dans la mémoire.
Stack et pile
On a donc inventé un truc qui s'appelle la pile, ou stack en anglais. C'est une zone mémoire particulière, réservée, malheureusement pas infinie, où les registres internes du processeur vont être sauvegardés, et plus tard, quand la routine appelée sera terminée, récupérés pour être remis dans les registres, ce qui permettra au processeur de continuer son programme.
Le concept est simple : on peut pousser push dans la pile des informations, puis retirer pop ces informations dans l'ordre inverse (le premier sorti est le dernier arrivé).
Un pointeur de mémoire spécial pour ça est le SP, le stack pointer. Il pointe sur la dernière case de la pile.
Autant vous dire que le programmeur de base ne touche JAMAIS à la stack, il laisse le compilateur gérer tout ça. Quand on commence là-dedans, vous pouvez être sûr de planter votre système. Et on va le faire 🥳
push et pop
Le Stack Pointer SP est dans le registre R1 du MSP430, il contient une adresse mémoire. Lorsque l'instruction push est exécutée :
- l'adresse contenue dans le Stack Pointer est décrémentée, on soustrait 2 si c'est un mot de 16 bits qui sera poussé.
- la valeur demandée est écrite à l'adresse décrémentée (la nouvelle adresse calculée)
Lorsque l'instruction pop est exécutée :
- la valeur située à l'adresse contenue dans le Stack Pointeur est lue
- l'adresse contenue dans le Stack Pointer est incrémentée de 2 si c'était un mot de 16 bits.
Quand on empile des données, on part d'une adresse haute (par exemple 0x1000), et la pile grandit en allant vers les adresses basses (le SP va vers 0x0F00).
Quand on réservera de la mémoire pour créer une nouvelle pile, on obtiendra par exemple 100 octets entre l'adresse 0 et l'adresse 99. On devra placer l'adresse du Stack Pointer à la fin du tableau, vers les adresses hautes (près de 99), et en utilisation, le SP ira progressivement vers le 0 (oui, ça recule dans le tableau, ça peut être surprenant).
Empilement de routines
Empiler les routines est bien l'expression qui convient. À chaque appel de routine, on va pousser dans la pile l'état du processeur (ce qu'on appelle le contexte).
Si on a dix appels imbriqués les uns dans les autres, ça fera dix états empilés dans la pile, et on comprend vite qu'il vaut mieux éviter d'imbriquer plein de fonctions, même courtes, pour éviter de déborder de la pile, car là le programme plantera à coup sûr.
Et si par malheur une de vos routines vient écrire (par erreur) dans la pile, c'est également le plantage assuré.
Appels et MSP430
16 registres
Le MSP430 possède 16 registres 16 bits dont 14 contiennent des données et/ou adresses. Ce sont ces 14 registres qu'il faut sauver lors d'un appel, et récupérer au retour.
- 12 registres de travail R4 à R15
- PC Program Counter qui pointe où il en est dans l'exécution de son programme.
C'est là-dedans qu'il faut charger l'adresse de la prochaine instruction à exécuter. - SP Stack Pointer est le pointeur dans la pile, qui permettra au processeur de récupérer son état lorsque la routine se termine,
et qu'il faut revenir à la routine d'appel.
C'est là qu'on va jouer pour avoir nos deux tâches, car on peut déjà deviner qu'il va falloir une pile pour le background, et une pile pour la tâche prioritaire. - SR Status Register contient l'état du processeur et quelques informations importantes comme le bit CPUoff qui indique si le processeur est actif, GIE qui autorise les interruptions. Il faudra le gérer si on doit sortir du sommeil (par exemple si on était en LPM3) en fin de routine. On se doute que les interruptions réveillent le processeur, mais il se rendort lorsque c'est fini, chose qu'on ne voudra pas.
Les appels de fonction
Remarque : il existe des conventions (ABI) pour déterminer ce qui peut/doit être fait. Ici, je donne directement le résultat, sinon, il faudra vous taper le document suivant :
Après compilation, on entre dans une fonction avec son adresse. On doit donc sauter vers cette adresse, autrement dit la mettre dans le PC.
Au niveau instruction, nous avons divers sauts, dont jmp qui est « jump to label » : le processeur va directement à l'adresse donnée. Ce n'est évidemment pas ce qui est fait pour appeler une fonction, car il va falloir sauvegarder l'environnement pour pouvoir revenir plus tard.
Caller, la fonction appelante, doit faire un call :
- L'adresse de l'instruction suivante (là où on doit revenir) est poussée sur la pile.
- Pour passer les arguments de la fonction, on dispose des 4 registres R12 à R15. S'il y a plus d'arguments, il faudra les pousser sur la pile.
- Puis on peut mettre dans le PC l'adresse de la fonction appelée (callee)
Callee, la fonction appelée, doit préserver les registres qu'elle va modifier pour ne pas les corrompre :
- pousser (push) R4 à R10 (si utilisés)
Avant le retour de fonction, callee devra restaurer le contexte en effectuant l'opération inverse (pop) depuis la pile.
Voici un exemple de fonction compilée en C :
int simple_calcul(int a, int b, int c, int d, int e)
{
int somme = a + b + c + d + e;
int resultat = somme * 2;
return resultat;
}
Et la version assembleur, une fois que le compilateur a fait son job. Notez les 4 arguments passés dans R12, R13, R14, R15, et le cinquième, e, passé dans la pile.
simple_calcul:
; 1. préambule & sauvegarde du contexte
push.w r10 ; sauvegarde r10 sur la pile (sp décrémenté de 2)
push.w r9 ; sauvegarde r9 sur la pile (sp décrémenté de 2)
push.w r4 ; sauvegarde r4 sur la pile (sp décrémenté de 2)
sub.w #4, sp ; 4 octets dans la pile pour somme et resultat
; 2. corps de la fonction. e est à récupérer dans la pile
mov.w 10(sp), r9 ; r9 = e (avec les décalages des push/sub)
add.w r13, r12 ; r12 = a + b
add.w r14, r12 ; r12 = (a + b) + c
add.w r15, r12 ; r12 = (a + b + c) + d
add.w r9, r12 ; r12 = (a + b + c + d) + e
mov.w r12, 2(sp) ; sauvegarde 'somme' dans (sp+2)
rla.w r12 ; r12 = r12 << 1 (somme * 2)
mov.w r12, 0(sp) ; sauvegarde 'resultat' dans (sp+0)
; la valeur de retour 'resultat' est déjà dans r12
; 3. épilogue & restauration du contexte
add.w #4, sp ; libération des 4 octets des variables locales
pop.w r4 ; restauration de l'ancien r4
pop.w r9 ; restauration de l'ancien r9
pop.w r10 ; restauration de l'ancien r10
ret ; retour au parent (dépile le pc et saute)
Pour info, ret est une instruction émulée, c'est en fait :
MOV.W @SP+, PC ; depuis la pile dans le PC, puis SP + 2
mais on s'en fiche ici.
Au milieu du calcul, la stack a cette tronche :
| Adresse mémoire | Contenu | Qui ? | |
|---|---|---|---|
| SP+12 | ... | Caller | données précédentes |
| SP+10 | argument e | Caller | poussé sur la pile avant le call |
| SP+8 | adresse retour | matériel call | adresse qui suit le call |
| SP+6 | ancien R10 | Callee push | sauvegarde contexte parent |
| SP+4 | ancien R9 | Callee push | |
| SP+2 | variable somme | Callee sub | espace local |
| SP+0 <- SP | variable resultat | Callee sub | sommet de la pile |
Que faut-il retenir de tout ça ?
Que la fonction appelante pose dans la pile certains éléments qu'elle récupèrera plus tard.
Lesquels ? Ben ça dépend de la fonction appelée, mais il peut y en avoir beaucoup si on met beaucoup d'arguments...
Et la fonction appelée peut aussi se servir de la pile, mais il faudra faire le ménage avant de terminer.
Heureusement que le compilateur se charge de gérer tout ça. Mais cela montre l'importance de la pile pour mes futures histoires de routine background, et de routine de haute priorité. On se doute qu'il va nous falloir deux piles, et qu'il faudra gérer ça.
Les interruptions ISR
Une interruption est une intervention matérielle dans le programme en cours d'exécution, éventuellement pendant une période de sommeil où le processeur est arrêté, mais ça ne change pas grand-chose car s'il doit redémarrer, il partira de là où il s'est endormi.
Dans le cas d'une interruption, comme elle peut intervenir à tout moment pendant que le processeur bosse, la gestion du contexte est plus stricte, mais c'est du même tonneau, on va sauvegarder dans la pile. Comme nous allons avoir deux piles, une pour background et l'autre pour la haute priorité, ça va devenir intéressant, comme on dit lorsque l'on est en face d'un problème.
- Le processeur sauvegarde automatiquement le PC, c'est l'adresse de retour.
- Le processeur sauvegarde aussi le registre d'état SR
- Dans l'interruption, le compilateur (faut pas demander ça à un développeur) doit sauvegarder tout ce qu'il va utiliser.
- Avant de finir, il faudra restaurer
- Et utiliser l'instruction reti (return from interruption) qui restaurera SR et le PC
Coup de bol, une interruption n'a aucun paramètre, et ne retourne pas de valeur. Sinon vous voyez le boxon.
Le même exemple. Il a fallu mettre les paramètres en variables globales.
int a, b, c, d, e, resultat; // Variables globales
#pragma vector = TIMER0_A0_VECTOR // Association
__interrupt void moninterruption_isr(void) {
int somme = a + b + c + d + e;
resultat = somme * 2;
}
moninterruption_isr:
; 1. sauvegarde matérielle (automatique) pc et sr sont poussés
; 2. sauvegarde logicielle du contexte (générée par le compilateur)
push.w r15
push.w r14
push.w r13
push.w r12
push.w r4
sub.w #2, sp ; pour 'somme'
; 3. corps de la fonction
mov.w &a, r12 ; r12 = a
add.w &b, r12 ; r12 = a + b
add.w &c, r12 ; r12 = (a + b) + c
add.w &d, r12 ; r12 = (a + b + c) + d
add.w &e, r12 ; r12 = (a + b + c + d) + e
mov.w r12, 0(sp) ; sauvegarde 'somme' (sp+0)
rla.w r12 ; r12 = somme * 2
mov.w r12, &resultat ; écriture dans la variable globale
; 4. épilogue & restauration du contexte
add.w #2, sp ; libération de la variable locale
pop.w r4
pop.w r12
pop.w r13
pop.w r14
pop.w r15
reti ; pop SR puis pop PC. vraie instruction de cœur
La tête de la pile :
| Adresse mémoire | Contenu | Qui ? | |
|---|---|---|---|
| SP+16 | ... | principal | données interrompues |
| SP+14 | adresse retour | matériel | interruption CPU |
| SP+12 | SR status register | matériel | sauvegarde l'état des flags |
| SP+10 | ancien R15 | compilateur | considéré comme scratch |
| SP+4,6,8 | ancien R14,R13,R12 | compilateur | car modifié |
| SP+2 | ancien R4 | compilateur | registre protégé |
| SP+0 <- SP | variable somme | compilateur | espace local temporaire |