La vie d’un processeur est un long fleuve tranquille. Le pointeur de programme PC indique la prochaine instruction à exécuter et il progresse au gré des instructions et des sauts qu’il rencontre. Cependant cet inexorable déroulement séquentiel peut être perturbé pour trois raisons essentielles :
Il se passe un événement matériel grave qui l’empêche de continuer. Typiquement un boitier mémoire ne répond pas (ou plus).
On demande au processeur de faire quelque chose qu’il ne parvient pas à faire comme aller lire un mot de 32 bits à une adresse impaire.
Une unité périphérique a besoin de la CPU pour effectuer une tâche essentielle. Par exemple la liaison série (USART) vient de recevoir un caractère et il serait opportun de le stocker en mémoire avant qu’une autre donnée n’arrive et ne l’écrase.
Les deux premiers cas relèvent de l’exception et le troisième est plus communément appelé interruption. Mais leur traitement est similaire : dans tous ces cas on demande au processeur d’arrêter momentanément de faire ce qu’il était en train de faire et d’aller exécuter une procédure spécifique pour répondre à l’urgence de la demande. Ceci peut s’avérer complexe dans le développement d’une application et surtout sa mise au point. En effet le processeur doit répondre à ces demandes qui surviennent généralement de façon asynchrone et quelles que soient les circonstances. C’est un peu comme un coup de téléphone urgent qui vous parvient : vous décrochez que vous soyez dans votre bain, en train de lire votre magazine préféré, en train de conduire….
Principe et mécanisme d’une interruption
Le principe du traitement d’une exception ou d’une interruption est générique ; seul le cas du RESET est un peu particulier puisqu’il consiste à faire redémarrer entièrement le processeur. Nous ne détaillerons pas ici les différentes exceptions. Il est certes possible et parfois souhaitable de reprogrammer les routines liées à des erreurs ou des problèmes matériels mais cela relève de la programmation avancée. Comme le principe est identique aux interruptions le lecteur qui voudra s’y aventurer ne rencontrera pas de réels problèmes. Concentrons-nous donc sur les vecteurs d’interruption qui correspondent donc à des demandes de requêtes formulées par un périphérique (Timer, ADC, USART,…)
Le principe de fonctionnement repose sur une Table des Vecteurs d’Interruptions (TVI) qui est placée aux adresses les plus basses de la mémoire, c.à.d. en 0x00000000. Cette table pour le Cortex-M3 est la suivante :
Les données stockées en position 0 et en position 1 de cette table sont des valeurs obligatoires et essentielles pour que le processeur puisse commencer à dérouler un programme. En effet à la mise sous tension ou suite à une exception RESET (d’où son caractère particulier par rapport aux autres exceptions) la séquence d’initialisation du processeur consiste à :
Redonner à l’ensemble des registres leur valeur d’initialisation
Aller lire les 32 bits contenus à l’@ 0x0000000 pour initialiser le pointeur de pile SP
Aller lire les 32 bits contenus à l’@ 0x0000004 pour initialiser le pointeur de programme PC
Une fois cela réalisé, le processeur est en marche. À la conception du programme qui sera chargé en mémoire, le compilateur/linker aura donc à créer cette table avec au moins ces deux valeurs. Si les autres valeurs ne sont pas complétées, ce ne sera grave que si l’exception ou l’interruption à laquelle elle correspond se déclenche. Cette table est cependant initialisée par défaut. En effet dans la suite du fichier startup_stm32f10x_md.s que nous avions vu pour la création de la pile système on trouve la création de cette table sous le nom (reconnu par le linker) _VECTORS
:
__Vectors DCD __initial_sp ; Top of Stack
DCD Reset_Handler ; Reset Handler
DCD NMI_Handler ; NMI Handler
DCD HardFault_Handler ; Hard Fault Handler
DCD MemManage_Handler ; MPU Fault Handler
DCD BusFault_Handler ; Bus Fault Handler
DCD UsageFault_Handler ; Usage Fault Handler
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD SVC_Handler ; SVCall Handler
DCD DebugMon_Handler ; Debug Monitor Handler
DCD 0 ; Reserved
DCD PendSV_Handler ; PendSV Handler
DCD SysTick_Handler ; SysTick Handler
;External Interrupts
DCD WWDG_IRQHandler ; Window Watchdog
DCD PVD_IRQHandler ; PVD through EXTI Line detect
…
On trouve en première place l’adresse du haut de la réservation mémoire pour la pile système, puis l’adresse du programme lié au RESET. Pour le reste, ce sont toutes des procédures qui existent dans la suite de ce fichier. Par exemple pour le traitement d’une erreur de bus, il existe la procédure suivante :
BusFault_Handler\
PROC
EXPORT BusFault_Handler [WEAK]
B .
ENDP
Le B .
est un saut sur place, donc une boucle infinie. Et c’est le cas pour toutes les autres procédures. En clair cela veut dire que si une interruption ou une exception arrive, le processeur ne va pas faire n’importe quoi ; il sera aiguillé vers une procédure minimale et bloquante. Restera donc à redéfinir ces fonctions d’interruption si l’on veut faire un traitement moins définitif.
Avant de voir comment redéfinir ces procédures par défaut, approfondissons un peu la technique de détournement. Lorsqu’une demande d’interruption arrive (avec un numéro d’identification) le processeur va réaliser les opérations suivantes :
terminer l’instruction en cours,
sauvegarder sur la pile et dans cet ordre R0,R1,R2,R3, R12, l’@ de retour, xPSR et LR,
mettre dans le registre LR un code spécifique (0xFFFFFFFx),
récupérer le n° de la demande d’interruption et lire l’entrée correspondante dans la TVI,
affecter PC avec l’@ trouvée dans la table au n° correspondant.
Comme pour un appel à une procédure, le processeur a besoin de stocker l’adresse du point de programme qu’il laisse pour pouvoir le reprendre une fois le traitement de l’interruption terminée. La différence avec une procédure classique est qu’au lieu de stocker cette adresse de retour dans le registre LR, c’est directement sur la pile système qu’est faite cette sauvegarde. Les autres registres sauvegardés permettent de conserver une grande partie du contexte de travail. A noter toutefois que ce n’est pas l’intégrité de l’ensemble des registres généraux qui est préservée. Aussi si la procédure de traitement de l’interruption modifie R4 à R11 ils seront altérés au retour de l’interruption. Il est alors plus que prudent de prévoir dans la procédure un ensemble de PUSH/POP pour s’en prévenir. N’ayez pas d’inquiétude, si votre programme est écrit en langage C, le compilateur est au courant de cette particularité et prévoira les sauvegardes nécessaires.
La dernière instruction d’une routine de traitement sera un classique BX LR comme pour le retour de procédure. Sauf que dans ce cas LR ne contient pas l’adresse de retour mais un code spécifique. L’architecture du Cortex-M3 est prévue pour détecter ce cas et mettre en œuvre le déclenchement du retour d’interruption. Celui-ci consiste uniquement à lire sur la pile système les données 32 bits successives pour affecter dans l’ordre les registres LR, xPSR, PC, R12, R3, R2, R1, R0. Tous les registres listés reprennent ainsi leur valeur d’avant interruption à l‘exception de PC qui prend l’adresse de retour, ce qui permet de faire le saut de retour d’interruption et de reprendre le travail courant là où il avait été interrompu.
Rôle du NVIC
NVIC pour Nested Vectored Interrupt Controller… mais qu’est-ce donc ? Tout d’abord c’est une unité propre du Cortex d’ARM, on le trouvera donc sur tous les processeurs à base de ce cœur bien que son exploitation peut être différente selon les choix fait par les fondeurs comme STMicroelectronics.
Cette unité particulière n’a certes pas été schématisée lors de la présentation de l’architecture du Cortex, mais son rôle est essentiel. En effet, la table des vecteurs d’interruption peut faire jusque 255 entrées. Cela veut dire que potentiellement 255 événements différents peuvent demander l’intervention de la CPU pour résoudre un problème. Que se passe-t-il si plusieurs demandes arrivent simultanément ? Idem si une nouvelle demande est levée alors qu’une autre est en cours de traitement. Il faut donc un chef d’orchestre pour organiser cela et c’est le rôle du NVIC. Il a pour tâche :
de recevoir les demandes d’interruption,
de contenir les priorités relatives de chacun des vecteurs de la table,
de contenir les autorisations de transmette à la CPU les demandes d’interruption,
de transmettre ou de mettre en attente les demandes en cours selon les autorisations et les priorités respectives.
Le NVIC permet en agissant sur sa configuration de définir une politique d’autorisation et de priorité des différentes interruptions. Dans le cahier des charges du Cortex-M3, il existe jusque 240 vecteurs d’interruption dont la priorité peut varier de 0 à 255 sachant que plus le niveau est faible plus priorité est grande. Les 3 premières entrées de la table (Reset, NMI et Hard_Fault) possède un niveau figé (respectivement en -3,-2 et -1) les autres peuvent être programmées (le niveau -3 du Reset est donc le plus élevé).
Lorsqu’un fabricant conçoit son microcontrôleur, il n’utilise qu’une partie de ces ressources. Si l’on considère les choix fait par STMicro pour ses STM32, il n’y a au plus (au maximum) 67 vecteurs d’interruption reconfigurables sur 16 niveaux de priorité (de 0 à 15) plus 6 vecteurs d’exception.
La redéfinition des niveaux de priorité des exceptions (Bus_Fault,Usage_Fault,…) se fait à travers deux registres spécifiques. Comme nous n’aurons a priori pas à nous préoccuper de ces exceptions, concentrons-nous sur les vecteurs liés aux périphériques. Leur niveau de priorité se fixe en affectant le contenu des registres IPx (x allant de 0 à 17) du NVIC. Ces registres sont organisés comme suit :
STMicro pour ces STM32 ne définit le niveau de priorité que sur 4 bits soit donc une valeur entre 0 et 15. Ces 4 bits correspondent aux 4 bits de poids forts des différent octets (notés IP[k]) de ces 21 registres.
Concrètement pour affecter la priorité 11 au timer n°2 comment faire ?
En premier lieu il est nécessaire de connaître le numéro de l’interruption lié au périphérique « timer 2 » dans la table des vecteurs d’interruption. La lecture de la documentation (table 63 page 204 du Reference Manuel) nous informe qu’il s’agit du numéro 28. La priorité se règle donc en fixant les bits 4 à 7 du registre 32 bits IPR[7]. Pour faire cette affectation, le fichier core_cm3.h définit la structure logicielle du NVIC suivante :
typedef struct
{
__IO uint32_t ISER[8]; /*!< Offset: 0x000 (R/W)
Interrupt Set Enable Register */
uint32_t RESERVED0[24];
__IO uint32_t ICER[8]; /*!< Offset: 0x080 (R/W)
Interrupt Clear Enable Register */
uint32_t RSERVED1[24];
__IO uint32_t ISPR[8]; /*!< Offset: 0x100 (R/W)
Interrupt Set Pending Register */
uint32_t RESERVED2[24];
__IO uint32_t ICPR[8]; /*!< Offset: 0x180 (R/W)
Interrupt Clear Pending Register */
uint32_t RESERVED3[24];
__IO uint32_t IABR[8]; /*!< Offset: 0x200 (R/W)
Interrupt Active bit Register */
uint32_t RESERVED4[56];
__IO uint8_t IP[240]; /*!< Offset: 0x300 (R/W)
Interrupt Priority Register (8Bit wide) */
uint32_t RESERVED5[644];
__O uint32_t STIR; /*!< Offset: 0xE00 ( /W)
Software Trigger Interrupt Register */
} NVIC_Type;
Les pseudo-registres de 8 bits sont donc définis dans cette structure. Inutile donc d’aller calculer le registre IPR à utiliser. La programmation des différents niveaux est alors très simple :
NVIC->IP[28] = 11<<4;
11 est la priorité à imposer et le décalage de 4 bits (<<4) est fait pour déposer cette valeur dans les 4 bits de poids fort de l’octet IP.
Intéressons-nous maintenant à l’autorisation de transmettre l’interruption à la CPU. Pour chacun des vecteurs d’interruptions il existe 5 bits qui permettent de gérer les autorisations et l’état courant de ces interruptions. Ces 5 bits sont répartis dans 5 familles de registres sur le modèle suivant :
Dans ce tableau la première colonne fait correspondre chaque numéro d’interruption avec un des 32 bits du registre concerné.
Les registres IABR[x] contiennent l’état actif de chaque interruption. Si elle a été détectée et servie le bit correspondant passe à un. Ces registres ne sont accessibles qu’en lecture puisque qu’il est mis à 1 suite à la mise en place d’un détournement.
Les 4 autres registres fonctionnent par paire SET/RESET. C’est une technique classique qui permet d’éviter certaines erreurs d’écriture. Pour activer la fonction il faut mettre à 1 le bit SET mais sa mise à 0 ne le désactive pas. Pour désactiver il faut imposer un reset et donc mettre à 1 le bit de RESET.
La paire ISER[x]/ICER[x] permet ainsi de gérer l’autorisation de déclenchement de l’interruption concernée.
La paire ISPR[x]/ICPR[x] gère quant à elle la mise en attente. En effet quand une interruption intervient et qu’elle n’est pas prioritaire elle est mise dans cet état dit d’attente afin d’être servie au moment où le niveau de priorité courant le permet. Jouer sur l’état de mise en attente à travers ISPR est un moyen logiciel pour déclencher une interruption. Cela peut être utile dans la phase de test des procédures de traitement par exemple.
Reprenons la mise en place de l’interruption du timer n°2. Pour l’autoriser il faut affecter le bit 28 du registre ISER[0] comme :
NVIC->ISER[0] = NVIC->ISER[0] | (0x01 << 28);
On peut également utiliser les masques prédéfinis dans la fichier stm32f10x.h soit comme un véritable masque :
NVIC->ISER[0] = NVIC->ISER[0] | NVIC_ISER_SETENA_28;
Soit en se rappelant que ce sont des registres de SET/RESET, donc que le zéro n’a pas d’effet. Par conséquent une affectation directe du masque est dans ce cas possible (faisant économiser l’opération de OU logique :
NVIC->ISER[0] = NVIC_ISER_SETENA_28;
Écriture d’un handler
Maintenant que nous savons autoriser le déclenchement d'une interruption en programmant le NVIC, reste à déterminer la technique qui permette de « remplir » la table des vecteurs d’interruption avec l’adresse de nos propres routines.
Tout d’abord oublions que cela puisse se faire dynamiquement, c’est à dire que le programme vienne lui-même réécrire (dans une phase d’initialisation ou pour faire évoluer dynamiquement le contexte de l’application) dans cette table. C’est de prime abord impossible puisque par défaut la table est en mémoire 0x0000000
c’est-à-dire en mémoire morte non accessible en écriture. Cela reste cependant techniquement possible : il faut alors déplacer la table et reconfigurer le Cortex pour lui indiquer le nouvel emplacement. Nous ne développerons pas ce point ici.
Il faut donc que la table soit construite par le duo compilateur/linker afin d’être ensuite chargée en même temps que le chargement de l’application
Comme nous l’avons déjà indiqué, la table est créée par défaut avec des procédures « puits » de type boucle infinie. Pour les interruptions liées aux périphériques, il s’agit même que d’une seule procédure appelée Default_Handler. Cette procédure par défaut est ensuite renommée avec les noms qui seront ensuite utilisés pour fabriquer la table de vecteurs d’interruption, ce qui donne (en version raccourcie) :
Default_Handler PROC
EXPORT WWDG_IRQHandler [WEAK]
EXPORT PVD_IRQHandler [WEAK]
EXPORT TAMPER_IRQHandler [WEAK]
...
EXPORT TIM2_IRQHandler [WEAK]
...
EXPORT USBWakeUp_IRQHandler [WEAK]
WWDG_IRQHandler
PVD_IRQHandler
TAMPER_IRQHandler
...
TIM2_IRQHandler
...
USBWakeUp_IRQHandler
B .
ENDP
Tous ces nouveaux noms recopient la valeur associée au symbole Default_Handler. Cela signifie qu’au départ toutes les entrées de la table d’interruption seront affectées avec la même valeur, à savoir l’adresse de cette routine par défaut.
Il faut remarquer que la redéfinition du nom est faite avec l’option [WEAK], c’est-à-dire faible. Cela rend possible ce qui est communément appelé en informatique une surcharge. Si quelque part dans votre projet vous déclarez une nouvelle procédure dont le nom est un des noms prédéfinis pour cette table, le linker va prendre l’adresse de votre procédure pour remplacer l’ancienne. Le doublon de définition de procédure qui normalement devrait mener à une erreur du linker, est ici accepté par le simple fait qu’une des définitions a été déclarée comme faible.
Concrètement pour vous cela signifie qu’il vous faut connaitre le nom générique de la routine (handler) de traitement d’interruption qui va être déclenchée par l’usage d’un périphérique. En utilisant le même nom vous pouvez écrire la routine de traitement qui fera le travail à faire lors de la survenue de l’interruption.
Illustrons cela toujours avec le timer n°2. On suppose que ce timer a été correctement configuré pour lever une interruption toutes les secondes et que le NVIC a été programmé pour autoriser la prise en compte. Pour que la variable globale MaSeconde
s’incrémente à chaque interruption, il suffit d’écrire en langage C :
void TIM2_IRQHandler(void)
{
MaSeconde = MaSeconde +1;
}
À la fin de ce chapitre, vous êtes capable :
d'expliquer le cheminement d'une exécution lors d'une interruption,
d'identifier les éléments nécessaires à la configuration d'une interruption et à son traitement.