Objectifs

Le but de ce TD est :

  • de vous familiariser avec la cross-compilation
  • de vous donner une première expérience de développement sur carte nue (bare-metal).

Ce TD n'est pas simple : contrairement à ce dont vous avez l'habitude, ici vous allez devoir développer dans un environnement où rien n'est fait. Vous n'aurez à votre disposition ni OS, ni bibliothèque C, ni code de boot, ni rien... Ce sera à vous de tout faire !

C'est pourquoi le TD sera progressif : vous allez progresser étape par étape, et construire petit à petit un programme complet. N'hésitez à pas solliciter les encadrants (de visu, ou par mail en dehors des heures de TD).

Bon courage !

Introduction

Vous allez développer sur des cartes STM32 IoT Node. Ces cartes sont basées sur un microcontrôleur de ST, le STM32L475VGT6. Un microcontrôleur est un circuit intégré rassemblant un coeur de processeur ainsi que plusieurs périphériques usuels. En l'occurrence le coeur de processeur est un ARM Cortex M4 associé à plusieurs périphériques, comme des entrées-sorties basiques appelées GPIO (Global Purpose Input Ouput), des bus (I2C, UART, SPI, …), des générateurs PWM, des convertisseurs analogiques-numériques, etc.

Cette carte inclut plusieurs capteurs, dont :

  • des modules de communication radio (WiFi, Bluetooth, SubGHz, …),
  • un transpondeur NFC,
  • deux microphones,
  • un capteur d'humidité et de température,
  • un magnétomètre, un accéléromètre et un gyroscope 3D,
  • un baromètre de précision,
  • un détecteur de distance et de gestes,
  • de l'USB OTG,
  • un bouton poussoir,
  • des LED,
  • des connecteurs d'expansion pour brancher des cartes additionelles…

Photo de la carte IoT Node

Documentation

Durant tout le TP, vous aurez besoin très régulièrement des différents documents relatifs à la carte de TP :

Impératif : téléchargez au moins le manuel de référence du processeur et stockez-le dans votre dépôt git, ce sera votre bible pour le reste de l'UE. Tant que vous y êtes, téléchargez aussi les autres documents…

Outils de débug

La carte de développement intègre une sonde JTAG, ce qui est bien pratique. Elle est disponible sur le connecteur appelé "USB débug" (voir figure sur cette page). Plus exactement, ce connecteur USB vous donne accès à deux choses :

  • la sonde JTAG intégrée
  • un port série sur USB, vu sous Linux comme /dev/ttyACM0 (115200 bauds, pas de parité, 8 bits, pas de contrôle de flux)

La sonde JTAG intégrée native est une sonde STLink V2.1. Nous avons reflashé ces sondes de façon à ce qu'elles se comportent comme des sondes JLink de Segger, qui comptent parmi les sondes les plus efficaces du marché et vous offrent des possibilités de débug qui vous seront bien utiles dans la suite du TP.

Comme pour toutes les sondes JTAG, nous devrons utiliser un "driver" pour faire le pont entre gdb et la sonde. Ce driver s'appelle JLinkGDBServer ou JLinkGDBServerExe en version graphique.

Il est disponible ici (pour ceux qui souhaitent travailler sur leur portable). Ce "driver" est également installé sur toutes les machines des salles de TP de l'école, mais il vous faudra mettre le chemin /comelec/softs/bin dans votre variable d'environnement $PATH pour que les programmes puissent être trouvés. Pour cela, vous pouvez ajouter la ligne PATH=/comelec/softs/bin:$PATH dans votre fichier .bashrc ou votre fichier .profile.

Si votre carte n'est reconnue que lorsque vous êtes root, c'est que vous avez probablement oublié d'installer les règles udev nécessaires (cf. fichier README.TXT).

Branchement de la carte

Pour alimenter et débugger la carte, il suffit de brancher un câble micro-USB entre la prise USB débug de la carte et votre PC. C'est tout !

Procédure de remise à zéro

Si votre carte a été utilisée précédemment et que la flash contient un programme, elle peut être dans un état qui complexifie le debug. Pour effacer le contenu de la mémoire flash du microcontrôleur et la remettre dans son état d'origine, voici la procédure à suivre :

❎ Branchez la carte

❎ Lancez l'outil JLinkExe en ligne de commande

JLinkExe -device STM32L475VG  -speed auto  -if SWD

❎ Connectez-vous

connect

❎ Stoppez le processeur

halt

❎ Effacez la flash

erase

❎ Vérifiez que ça a fonctionné et quitter

mem32 0 128
exit

Mapping mémoire

Le mapping mémoire du processeur est disponible en page 75 du manuel de référence du processeur. Pas la peine de tout lire, pour l'instant retenez juste les emplacements et tailles des éléments suivants :

  • Flash : adresse de début = 0x08000000, taille = 1 MB
  • RAM : elle est séparée en deux blocs non contigus :
    • SRAM1 : début = 0x20000000, taille = 96 kB
    • SRAM2 : début = 0x10000000, taille = 32 kB

Dans un premier temps, le programme qu'on écrira sera logé en RAM. Pour des raisons qu'on verra plus tard, nous allons le loger dans SRAM1. SRAM2 sera réservé pour autre chose (patience petit Padawan).

Pilotage de la sonde

Depuis gdb, il est possible d'envoyer à la sonde des commandes spécifiques, en les préfixant par monitor (ou mon pour faire plus court) . Voici les commandes les plus utiles pour les sondes JLink :

  • reset : déclenche un reset hardware du processeur et le maintient en pause.
  • halt : met le processeur en pause.
  • endian little : déclare à la sonde que le processeur est câblé pour être en mode little-endian.

Pour lancer / débugger un programme, une procédure typique serait la suivante (ces commandes ne sont pas à taper tout de suite) :

  1. Lancer le driver : JLinkGDBServer -device STM32L475VG -endian little -if SWD -speed auto -ir -LocalhostOnly
  2. Dans un autre terminal, lancer le cross-débugger : arm-none-eabi-gdb xxx.elf (remplacer xxx.elf par le nom du programme que vous voulez débugger)
  3. Dire à gdb qu'on fait du débug distant et sur quel port communiquer avec le driver de sonde : target ext :2331
  4. Transmettre au driver de sonde la commande disant que le processeur est en mode little-endian : mon endian little
  5. Remise à zéro le processeur et le maintenir en pause par mon reset.
  6. Transférer le programme sur la carte : load (ici, gdb se charge de positionner le PC à l'entry point de votre exécutable si celui-ci en a un)
  7. Lancer l'exécution : cont
  8. Débugger de façon normale (si pour avancer d'une instruction assembleur, etc.)

Plutôt que de taper à chaque fois toutes ces commandes, il est préférable de se créer un fichier se203.gdb (le nom est arbitraire) et de demander à arm-none-eabi-gdb de l'exécuter à chaque lancement, à l'aide de l'option -x se203.gdb.

set architecture armv7e-m
target ext :2331
mon endian little
mon halt

# interface with asm, regs and cmd windows
define split
  layout split
  layout asm
  layout regs
  focus cmd
end

# interface with C source, regs and cmd windows
define ss
  layout split
  layout src
  layout regs
  focus cmd
end

define flash
  dont-repeat
  mon reset
  load
end

# Useful function when the processor is in hardfault to see
# where it comes from.
define armex
  printf "EXEC_RETURN (LR):\n",
  info registers $lr
    if ($lr & 0x4)
      printf "Uses MSP 0x%x return.\n", $MSP
      set $armex_base = $MSP
    else
      printf "Uses PSP 0x%x return.\n", $PSP
      set $armex_base = $PSP
    end

    printf "xPSR            0x%x\n", *($armex_base+28)
    printf "ReturnAddress   0x%x\n", *($armex_base+24)
    printf "LR (R14)        0x%x\n", *($armex_base+20)
    printf "R12             0x%x\n", *($armex_base+16)
    printf "R3              0x%x\n", *($armex_base+12)
    printf "R2              0x%x\n", *($armex_base+8)
    printf "R1              0x%x\n", *($armex_base+4)
    printf "R0              0x%x\n", *($armex_base)
    printf "Return instruction:\n"
    x/i *($armex_base+24)
    printf "LR instruction:\n"
    x/i *($armex_base+20)
end

document armex
ARMv7 Exception entry behavior.
xPSR, ReturnAddress, LR (R14), R12, R3, R2, R1, and R0
end

❎ Créez le fichier se203.gdb avec le contenu ci-dessus.

Attention : ce fichier est à comprendre au moins dans ses grandes lignes, surtout si vous comptez suivre SE302 : il faudra l'adapter à vos besoins (vous comprendrez lorsque vous aurez fait la partie sur les IRQ).

Une fois que vous avez ce fichier en place (pensez à le committer), tout ce que vous aurez à faire est, après avoir lancé arm-none-eabi-gdb -x se203.gdb xxx.elf (où xxx.elf est le programme à exécuter) :

  1. flash, pour charger le programme sur le processeur. Si vous avez défini un ENTRY point dans votre script de link, gdb positionne automatiquement le PC à la bonne valeur.
  2. cont pour démarrer le programme.

❎ Dans votre Makefile, définissez une cible virtuelle qui lance JLinkGDBServer (ou la version graphique) avec les bonnes options.

À partir de maintenant vous devriez avoir, dans un répertoire nommé TD, un Makefile qui permet de lancer JLinkGDBServer, ainsi que le fichier se203.gdb.

Avant de continuer

L'environnement de débug est en place, on peut maintenant entrer dans le vif du sujet !

Au fait, vous avez pensé à committer / pusher combien de fois ?

❎ Une fois que vous pensez avoir terminé cette partie, mettez le tag git INTRO sur le commit de fin, et pushez le par git push --tags.

Attention : lors de la correction du TP, seuls les commit avec les tags précisés ici seront examinés.

Création d'un exécutable

Avant de commencer à débugger, on va déjà créer un exécutable minimal qui nous permettra de vérifier qu'on peut :

  • télécharger un programme sur la carte dans une zone adaptée (en RAM dans un premier temps),
  • lancer ce programme pas à pas (instruction assembleur par instruction assembleur),
  • bref, qu'on sait générer un exécutable correct et le débugger.

Pour cela nous allons procéder en plusieurs temps :

  1. D'abord la création d'un script de link minimal qui assurera que l'exécutable est logé aux bonnes adresses.
  2. Écriture d'un programme minimal (un main qui fait une boucle infinie) et on le testera.
  3. Écriture d'un programme un peu plus complexe, faisant appel à la pile, qu'on testera.
  4. Une fois arrivés là, on aura de quoi commencer à programmer les périphériques !

Mapping mémoire

On rappelle que le mapping mémoire du processeur est disponible en page 75 du manuel de référence du processeur. L'emplacement des zones qui nous intéressent est rappelé ci-dessous :

  • Flash : adresse de début = 0x08000000, taille = 1 MB
  • RAM : elle est séparée en deux blocs non contigus :
    • SRAM1 : début = 0x20000000, taille = 96 kB
    • SRAM2 : début = 0x10000000, taille = 32 kB

Dans un premier temps, le programme qu'on écrira sera logé en RAM. Plus précisément, le programme (code + données) sera dans SRAM1 et la pile dans SRAM2.

L'avantage de cette façon de faire est qu'un débordement de pile se traduira par un accès dans une zone réservée qui passera le processeur en mode erreur. On pourra donc tout de suite identifier le problème. A contrario, si on avait mis la pile tout en haut de la RAM (à la fin de SRAM1), un débordement de pile se serait traduit par un écrasement de la zone de données (bss ou data), ce qui ne produit un comportement erratique (crash) que longtemps après. À débugger, c'est un enfer !

Linker script

Layout mémoire

❎ Créez un fichier appelé ld_ram.lds, dans lequel à l'aide de la directive MEMORY vous définirez les différentes régions de mémoire disponibles dans le processeur.

Création des sections

❎ Dans votre script de link, à l'aide de la directive SECTIONS, créez les différentes sections dont vous aurez besoin. Pour l'instant on partira du principe que l'exécutable réside entièrement en RAM, il n'y a donc pas de "recopies" à faire. Autrement dit, pas besoin de spécifier les LMA des sections, la flash n'étant pas utilisée.

On mettra en premier la section .text, puis la section .rodata, puis .data, puis le bss / COMMON.

Point d'entrée

Le programme est destiné à être exécuté directement par le processeur, sans passer par un loader ELF. Il n'y aurait donc pas besoin de spécifier un point d'entrée.

Mais le processeur est câblé pour chercher l'adresse où démarrer à l'adresse 0x0000_0004 qui se situe en flash. Or on voudrait qu'il boote directement sur notre programme en RAM. Pour cela, on pourrait flasher à l'adresse 0 un petit bout de code faisant juste un saut au début de la RAM. Mais on va plutôt exploiter une caractéristique bien pratique de gdb : lorsqu'on lui demande de transférer un exécutable ELF sur une carte, si celui-ci comporte un point d'entrée, alors gdb positionne automatiquement le PC sur ce point d'entrée. On n'a plus après qu'à faire continue, et tout se passe comme si on avait booté directement depuis la RAM (ou presque : on verra par la suite qu'en fait pas tout à fait. Patience !)

❎ À l'aide de la directive ENTRY, définissez donc un point d'entrée, par exemple sur la fonction main.

Programme de base

Écriture du programme de test

❎ Écrivez en C un programme test le plus simple possible : une fonction main qui fait une boucle infinie.

Compilation du programme

Pour compiler votre programme, il faut indiquer au compilateur que le processeur cible est un cortex-M4 en mode thumb. Ce qui donne une commande du style :

arm-none-eabi-gcc -c -g -O1 -mcpu=cortex-m4 -mthumb main.c -o main.o`

Comme nous souhaitons en plus tirer parti du FPU pour les opérations flottantes vectorisées, il faut rajouter les options :

-mfloat-abi=hard -mfpu=fpv4-sp-d16

Pour linker votre programme, la commande est du style :

arm-none-eabi-gcc -T ld_ram.lds main.o -o main

❎ Bien entendu, il est hors de question de taper ces lignes à la main. Créez donc un Makefile, en utilisant les variables et règles implicites adéquates !

Avant de charger votre programme sur la carte, il est impératif de vérifier qu'il a bien été généré correctement.

❎ Pour cela utilisez objdump pour vérifier que :

  • le point d'entrée est bien en 0x20000000,
  • tout l'exécutable est bien logé dans SRAM1 (à partir de 0x20000000).

Tant que ce n'est pas le cas, ne passez surtout pas à la suite, vous risqueriez de programmer une zone contenant des fusibles et de passer la carte dans un état irrécupérable !

Test du programme de base

❎ Lancez le driver de sonde : make startgdbserver (cf. pages précédentes).

❎ Dans un autre terminal, lancez le débuggeur en lui passant en argument le fichier ELF généré : arm-none-eabi-gdb main.

❎ Chargez le programme : flash.

❎ Mettez-vous en affichage "registres + code assembleur + fenêtre de commande" : split.

❎ Vérifiez que le PC est positionné à la bonne valeur.

❎ Exécutez votre programme pas à pas (instruction assembleur par instruction assembleur) : si.

Si tout se passe bien, tant mieux ! Sinon, recherchez la cause de l'erreur en examinant à chaque fois les registres du processeur et en vérifiant que ce qui est exécuté l'est correctement.

Pour sortir de gdb, tapez quit ou control-d.

Note : si vous terminal se retrouve dans un mode bizarre après la sortie de gdb, ne paniquez pas ! Tapez reset (à l'aveugle si nécessaire) et tout devrait rentrer dans l'ordre :)

Programme plus évolué

Fibonacci

❎ Écrivez une fonction récursive int fibo(int n), qui calcule le n-ième nombre de la suite de Fibonacci.

❎ Modifiez main pour qu'il ne fasse que renvoyer fibo(8).

❎ Compilez, et vérifiez avec objdump que le programme est bien logé aux bonnes adresses.

❎ Testez-le en vrai. Que se passe-t-il, et pourquoi ?

Indice crucial : exécutez le programme instruction assembleur par instruction assembleur et vérifiez à chaque étape que tout s'est bien passé :

  • pour toute opération arithmétique / logique, vérifiez le résultat en examinant les registres ;
  • pour tout accès à la mémoire, regardez le contenu de la mémoire avant l'instruction puis après l'instruction et vérifiez que c'est cohérent.

Correction des choses

Vous venez de constater qu'il manque quelque chose de crucial avant main pour que les choses s'exécutent correctement.

❎ Créez donc le fichier qui va bien, qui se chargera de mettre en place un environnement d'exécution correct pour le code C.

❎ Compilez, et vérifiez avec objdump que le programme est bien logé aux bonnes adresses.

❎ Testez votre programme, qui doit à présent s'exécuter correctement.

Attention :

  • L'assembleur a besoin de connaître le processeur cible : -mcpu=cortex-m4.
  • Contrairement au C, en assembleur les symboles sont privés par défaut. Pour les exporter (de façon à ce qu'ils soient visibles depuis le C ou le linker script), il faut les déclarer .global.
  • On utilisera la syntaxe unifiée, donc la directive .syntax unified.
  • Enfin il faut dire à l'assembleur qu'on compile en mode thumb : .thumb.

Exemple :

    .syntax unified
    .global _start
    .thumb

_start:
    blablabla

Initialisation du BSS

❎ Dans le fichier que vous venez de créer, appelez avant main une procédure void init_bss() (écrite en C dans un fichier appelé init.c), qui se chargera d'initialiser le BSS à zéro, en s'aidant de symboles exportés depuis le script de link.

❎ Testez cette procédure en déclarant des variables qui seront stockées dans le BSS et en vérifiant au débugger qu'une fois arrivé à main, elles valent bien 0.

Mais pourquoi le compilateur appelle memset ?

En fonction de la version du compilateur et du niveau d'optimisation que vous utilisez, la boucle d'initialisation du BSS que vous avez écrite peut être remplacée automatiquement par le compilateur par un appel à la fonction memset. Or, si vous avez choisi de ne pas lier votre exécutable avec la bibliothèque standard (-nostdlib) conformément à la philosophie de l'UE, l'éditeur de liens ne trouvera pas le code de cette fonction et générera donc une erreur.

❎ À partir de maintenant, ajouter le drapeau -ffreestanding lors de vos appels au compilateur pour lui indiquer que l'on est dans un environnement où il n'y a pas de bibliothèque standard.

Néanmoins, même dans ce cas, il est nécessaire (c'est précisé dans la documentation de gcc) de fournir une implémentation pour quatre fonctions que gcc peut choisir d'utiliser à certains endroits : memcpy, memmove, memset et memcmp.

❎ Dans un fichier nommé memfuncs.c, fournissez l'implémentation de ces quatre fonctions (n'hésitez pas à consulter les pages de manuel de ces fonctions pour obtenir leur signature et ce qu'elles doivent faire).

Après le main

❎ Dans un système embarqué, main ne devrait jamais terminer. Au cas où ça arriverait, pour éviter que le processeur aille exécuter ce qui se trouve dans la zone de code après l'appel à main, insérez après l'appel à main une boucle infinie sur un symbole appelé _exit.

Avant de continuer

Vous avez maintenant un environnement d'exécution correct pour exécuter du C. Nous pouvons passer à la mise en marche du premier périphérique, le plus basique : l'allumage d'une LED !

Au fait, vous avez pensé à committer / pusher combien de fois ? :)

❎ Mettez le tag LDSCRIPT sur le commit prêt à être corrigé et pushez le.

GPIO

Le premier périphérique qu'on met en marche traditionnellement quand on démarre une carte en bare-metal est le contrôleur de GPIO, de façon à pouvoir allumer / éteindre une LED. Les GPIO sont des broches du processeur qu'on peut configurer à volonté en entrée ou en sortie. Lorsqu'elles sont en entrée, on peut lire leur état dans un registre spécial. Lorsqu'elles sont en sortie, on peut les mettre à l'état haut ou bas, en écrivant dans un registre spécial.

La plupart des GPIO peuvent avoir des configurations supplémentaires :

  • on peut leur demander de générer une interruption si le signal qui arrive dessus change d'état, ou fait un front montant ou un front descendant,
  • on peut configurer l'intensité qu'elles peuvent débiter (ce qu'on appelle le slew-rate), ce qui influe sur leur vitesse de commutation,
  • activer des résistances de pull-up / pull-down,
  • etc.

Multiplexage des broches

Un microcontrôleur n'a qu'un nombre limité de broches. Pas assez pour pouvoir utiliser tous les périphériques internes. La plupart des broches peuvent donc être configurées pour être utilisées comme des GPIO, port série, port I2C, port SPI, pour USB, etc. Les broches sont aussi groupées par "ports", groupes de 16 broches, pour faciliter leur repèrage. Par exemple, la broche PA5 est la broche 5 du port A.

Notre processeur dispose de neuf ports : GPIOA à GPIOI, mais, en fonction des boîtiers, seulement certaines broches sont accessibles.

Certains contrôleurs permettent à chaque broche d'être configurée pour servir à n'importe quel périphérique (exemple : nRF52840, ESP32), mais ce n'est pas le cas du nôtre. La table 17 de la datasheet du processeur (pages 69 et suivantes) liste, pour chaque broche, les fonctions disponibles dessus.

La fonction qui sera utilisée sur une broche particulière est déterminée par plusieurs registres. Le registre GPIOx_MODER (cf. manuel de référence page 303) détermine si la broche est :

  • une GPIO en entrée,
  • une GPIO en sortie ou bidirectionnelle,
  • une fonction analogique,
  • une fonction spéciale (I2C, SPI…). Dans ce cas, les registres GPIOx_AFRL (pour les broches de 0 à 7) et GPIOx_AFRH (pour les broches de 8 à 15) déterminent la fonction en question. Cf. manuel de référence page 307 pour le layout de ces registres, et la datasheet table 17 page 69 pour la liste des fonctions spéciales (Alternate Functions).

(x = A … I dans les lignes ci-dessus en dessous)

Exemples :

  • Pour utiliser la broche PA5 comme une GPIO en entrée, on écrira 00 dans les bits [11:10] de GPIOA_MODER.
  • Pour utiliser la même broche comme une GPIO en sortie, on écrira 01 dans les bits [11:10] de GPIOA_MODER.
  • Pour utiliser la même broche comme le fil d'horloge du bus SPI1 (Alternate Function 5), on écrira 10 dans les bits [11:10] de GPIOA_MODER, et 0101 dans les bits [23:20] de GPIOA_AFRL.

Pour l'instant, nous allons piloter une seule LED, verte (LED2), branchée sur la broche PB14. Pour allumer cette LED, il faudra mettre la broche PB14 à l'état haut, et pour l'éteindre la mettre à l'état bas.

Clock gating

Les Cortex-M sont des processeurs faits spécialement pour les applications à faible, voire très faible, consommation. Pour réduire la consommation au minimum, l'utilisateur a la possibilité d'arrêter l'horloge de chaque périphérique : sa consommation devient donc nulle. Par défaut, tous les périphériques ont leur horloge arrêtée. Si on veut en utiliser un, il faut donc d'abord activer son horloge.

Les horloges sont activées dans le module RCC, dans l'un des registres RCC_AHBxENR, RCC_APB1ENRx ou RCC_APB2ENR. Voir le manuel de référence, section 6.4.16 (page 249) et suivantes.

LED verte

On va déjà commencer par organiser les fichiers de façon propre : chaque périphérique aura son propre fichier source, avec le header associé dans lequel il spécifie les fonctions / variables à exporter. Pour les LED, on créera donc les fichiers led.c et led.h.

Pour chaque périphérique, on créera une fonction d'initialisation (void led_init(void) pour les LED, etc.) qui se chargera d'initialiser le périphérique : activation de l'horloge associée, configuration diverse, etc.

❎ Écrivez une fonction void led_init(), qui

  • active l'horloge du port B dans le registre RCC_*ENR* idoine,
  • configure la broche PB14 en mode sortie.

Les adresses des registres sont obtenues en additionnant une adresse de base (table 1 page 77 du manuel de référence) à un offset donné dans la partie "register map" de chaque périphérique.

❎ Écrivez ensuite les fonctions suivantes :

  • led_g_on() : allume la LED2
  • led_g_off() : éteint la LED2

Indice : la lecture de la documentation du registre GPIOx_BSRR pourra s'avérer utile !

❎ Depuis main, appelez la fonction led_init(), puis faites clignoter la LED2 en faisant des boucles d'attente active ainsi (attention, c'est sale) :

for (int i=0; i<....; i++)
  asm volatile("nop");

On verra plus tard comment implémenter des délais de façon plus précise et (beaucoup) plus propre.

LED bleue/jaune

Vous disposez sur ces cartes de deux autres LED, une bleue (LED4) et une jaune (LED3), toutes les deux pilotées par la même broche du microcontrôleur, PC9.

Connexion LED bleue et jaune

  • Quand PC9 est en sortie à l'état haut, LED3 est allumée, LED4 est éteinte.
  • Quand PC9 est en sortie à l'état bas, LED3 est éteinte, LED4 est allumée.
  • Quand PC9 est en entrée (= haute impédance), les deux LED sont éteintes.

Et, pour l'instant, on n'a pas de moyen simple d'allumer les deux LED en même temps (on verra comment faire plus tard).

❎ Complétez la fonction led_init() pour pouvoir contrôler la broche PC9.

❎ Écrivez une fonction void led(state) avec state étant une constante (enum ou autre) qui vaut LED_OFF, LED_YELLOW ou LED_BLUE.

❎ Testez cette fonction dans votre main en faisant boucler les leds : vert - jaune - bleu - vert - jaune - bleu, etc.

Avant de continuer

Félicitations, vous avez mis en marche votre premier périphérique. Passons maintenant à quelque chose de plus complexe !

❎ Mais auparavant, mettez le tag GPIO sur le commit prêt à être corrigé et pushez-le :)

Horloges

Lorsque l'on démarre un processeur, une des premières choses à faire est de configurer les différents éléments intervenant dans la génération des horloges afin d'obtenir les différentes fréquences d'horloges désirées.

Dans le processeur utilisé sur vos cartes de TP, le cœur et les périphériques utilisent des horloges différentes. Le schéma du manuel de référence page 208 détaille les différentes sources d'horloge et les différents composants intervenant dans la génération des horloges utilisées au sein de la puce.

Comme vous pouvez le constater, c'est un sujet complexe. Mais pour la suite du TP, nous allons avoir besoin d'avoir des horloges précises (notamment pour l'UART et les timers).

Pour l'instant nous allons vous donner un fichier objet contenant une fonction void clocks_init(void), qui initialise les horloges ainsi :

  • SYSCLK : 80 MHz
  • PCLK1 : 80 MHz
  • PCLK2 : 80 MHz
  • LSE : 32.768 kHz
  • HSI / LSI : inutilisées
  • MSI : 4 MHz

❎ Récupérez donc le fichier clocks.tgz, décompressez-le, puis comittez-le dans votre dépôt (oui, pour une fois vous avez le droit de committer un objet ! :) ).

❎ Appelez la fonction clocks_init() au début de votre main. Puis passez à la suite. Ce n'est qu'à la fin du TP que vous ferez vous-même l'initialisation des horloges.

Attention, votre processeur fonctionne maintenant à pleine vitesse (80 MHz au lieu de 4 MHz), donc vos LED clignotent 20 fois plus vite !

CMSIS

ARM a défini un standard d'écriture de code pour s'interfacer avec le processeur et les périphériques : le Cortex Microcontroller Software Interface Standard (CMSIS). Cela permet d'écrire du code réutilisable facilement, portable entre différent Cortex et de faciliter la conception et l'utilisation de bibliothèques. Le CMSIS contient aussi du code, dont des headers, des drivers, un OS temps-réel (RTOS), etc.

Jusqu'à présent, vous avez défni vous-même vos registres à la main et souvent utilisé des constantes numériques pour initialiser / modifier des registres. Cela n'est pas pratique : pour vérifier / débugger votre code, on doit sns cesse se reporter à la documentation pour vérifier ces constantes.

Les constructeurs utilisant des coeurs ARM ont l'obligation de fournir des headers au standard CMSIS spécifiques à leurs microcontrôleurs, nommant les différents objets selon des règles bien précises. Pour ST, on les trouve sur leur site web ou dans un outil appelé STM32CubeMX.

❎ Téléchargez l'archive cmsis.tgz, et décompressez-la à la racine de votre projet. Elle contient des définitions d'accès aux différents Cortex ainsi qu'un fichier spécifiant tous les registres et les bits du STM32L475 : stm32l475xx.h.

Le nommage des registres / bits est extrêmement simple et clair. Les noms sont exactement les mêmes que dans la datasheet.

Exemple 1

Si on cherche à modifier le bit USART1RST dans le registre APB2RSTR du bloc de contrôle des horloges et du reset, le RCC :

  • le registre est RCC->APB2RSTR
  • le bit est RCC_APB2RSTR_USART1RST

On peut donc écrire :

RCC->APB2RSTR |= RCC_APB2RSTR_USART1RST;

Exemple 2

Vous avez aussi des masques (qui se finissent par Msk) et la position de chaque bit / champ de bits (qui se finit par Pos).

Si on cherche à mettre à zéro les 4 bits du champ USART1SEL dans le registre CCIPR du RCC :

RCC->CCIPR = RCC->CCIPR & ~RCC_CCIPR_USART1SEL_Msk;

Si on cherche à mettre 40 dans le champ PLLN du registre PLLCFGR du RCC, ainsi que mettre à 1 le bit MSI du même registre :

RCC->PLLCFGR = (40 << RCC_PLLCFGR_PLLN_Pos) | RCC_PLLCFGR_PLLSRC_MSI;

Enfin, dans le fichier stm32l4xx.h, vous avez des macros bien pratiques que vous pouvez utiliser si vous le souhaitez. Si les noms ne parlent pas d'eux-mêmes, allez voir leur définition dans le fichier en question.

  • SET_BIT(REG, BIT)
  • CLEAR_BIT(REG, BIT)
  • READ_BIT(REG, BIT)

Les macros suivantes sont très discutables, nous ne les utiliserons donc PAS :

  • CLEAR_REG(REG)
  • WRITE_REG(REG, VAL)
  • MODIFY_REG(REG, CLEARMASK, SETMASK)

❎ Modifiez votre Makefile pour permettre à gcc d'accéder aux répertoires CMSIS/Device/ST/STM32L4xx/Include et CMSIS/Include.

❎ Puis modifiez votre code des LED de façon à utiliser les noms de registres / bits tels que définis dans le fichier stm32l475xx.h (qu'il faudra donc inclure dans les .c qui en ont besoin).

UART

Après l'allumage d'une LED, l'un des premiers périphériques qu'on utilise dans un système embarqué est le port série, qui permet de disposer d'une console et donc de fonctions "à la" printf.

Le protocole

Le port série (UART) est un port de communication qui était très répandu sur les PC jusqu'il y a peu. Il est par contre présent sur la totalité des systèmes embarqués. C'est un protocole série, qui permet de transmettre des trames de bits les unes à la suite des autres. Pour que la communication fonctionne, les deux dispositifs désirant communiquer ensemble doivent se mettre d'accord sur un certain nombre de points :

  • le nombre de bits par trame : généralement 8, mais ça peut aller de 5 à 11.
  • la durée de chaque bit : pour une communication lente mais sûre, on utilise traditionnellement une vitesse de 9600 bps (bits/secondes), pour une transmission rapide on transmet à 115200 bps. D'autres vitesses sont possibles (de 1200 bps à 10 Mbps).
  • la présence ou non d'un bit de parité (paire qu'on note E, ou impaire qu'on note O), permettant de détecter certaines erreurs de transmission. Si on ne met pas de bit de partité, ce qui est le cas la plupart du temps, on note ça N.
  • la durée du "bit" de stop (le bit de fin de trame) : 0.5, 1, 1.5, ou 2 bits. Généralement 1 bit.

L'état de repos est l'état haut (VCC).

Le format standard d'une trame au format 115200 8N1 (8 bits de données, pas de parité, 1 bit de stop, 115200 bps, soit 115200 8N1 en abrégé) est celui-ci :

Trame UART

Le périphérique se chargeant de formater les trames et de les envoyer / les réceptionner est appelé UART. Dans un microcontrôleur standard, il en existe de 1 à 4 (voire plus).

Utilisation

Les PC portable ne sont plus équipés de port série, et les PC nécessitent un adaptateur de tension (pour des raisons historiques, l'état haut correspond à une tension de -9 à -15V, et l'état bas à une tension de +9 à +15V), ce qui n'est pas pratique.

Heureusement, la sonde JTAG intégrée à la carte de développement intègre un pont UART / USB. Lors du branchement du câble USB sur le PC pour débugger la carte, deux périphériques sont automatiquement créés par Linux :

  • celui qui permet de communiquer avec le driver de sonde et gdb,
  • un port série "virtuel", avec lequel tout se passe comme si on était directement branché sur l'UART1 du microcontrôleur. Ce port série, sous Linux, a pour nom /dev/ttyACM0.

Attention : pour ceux qui ont un PC personnel sous Ubuntu, un programme est installé par défaut qui peut empêcher la communication avec ce port série : modem-manager. Pensez à le supprimer en tapant sudo apt-get purge modemmanager.

Pour communiquer avec le port série depuis le PC, plusieurs programmes existent dont :

  • putty : interface graphique, pratique, installé sur toutes les stations des salles de TP
  • tio : purement textuel, pratique, installé sur toutes les stations des salles de TP
  • picocom, cu, tip, screen, kermit, etc.
  • ou le plugin de vscode serial monitor

Nous vous conseillons tio ou putty. Lorsque vous les lancez, réglez les sur :

  • port série : /dev/ttyACM0
  • vitesse : 115200
  • 8 bits de données
  • pas de contrôle de flux (ni logiciel ni matériel)
  • 1 bit de stop
  • pas de parité

Une fois lancé, chaque caractère tapé est envoyé à l'UART du microcontrôleur, et vice-versa.

❎ Pour activer le port série virtuel de votre carte, tapez les commandes suivantes une fois pour toutes :

JLinkExe -device STM32L475VG -if SWD -autoconnect 1 -speed auto
...
Cortex-M4 identified.
J-Link>vcom enable
...
J-Link>exit

❎ Pour tester que tout fonctionne bien, vous pouvez télécharger l'exécutable hello.gz et l'exécuter. Si vous ne voyez rien, vérifiez vos paramètres !

Configuration

Le STM32L475 dispose de plusieurs UART, qui ont la possibilité de gérer d'autres protocoles (synchrones). Ils sont donc appelés USART.

Vous allez devoir envoyer des caractères sur le port série USART1 du processeur. Pour cela, il va falloir le piloter manuellement (pas de libC, pas de printf !). Les registres clefs sont indiqués ci-dessous (cf. pages 1376 et suivantes du manuel de référence). Il est aussi conseillé de lire la partie sur le fonctionnement de l'USART.

❎ Dans un fichier uart.c, écrivez une fonction void uart_init() qui se charge de :

  • Passer les broches RX et TX du port B (à vous de trouver lesquelles) en mode USART (ne pas oublier d'activer l'horloge du PORTB par la même occasion). Pour cela, configurez ces broches en mode Alternate Function (registre MODER), et donnez le bon numéro de fonction dans le registre AFRL (numéro que vous trouverez dans la datasheet du processeur).
  • Activer l'horloge du port série USART1.
  • Spécifier que l'horloge sur laquelle se base l'USART1 pour timer chaque bit est PCLK : registre RCC->CCIPR.
  • Faire un reset du port série par précaution : registre RCC->APB2RSTR.
  • Configurer la vitesse du port série à 115200 bauds : registres USART1->BRR.
  • Configurer l'oversampling à 16, et mettre le port série en mode 8N1 : à vous de trouver les bons registres !
  • Activer l'USART1, le transmetteur et le récepteur.

Attention : cette configuration doit être faite alors que l'USART1 est désactivée !

Envoi et réception

❎ Écrivez dans l'ordre les fonctions suivantes :

  • void uart_putchar(uint8_t c), qui attend que l'USART1 soit prêt à transmettre quelque chose, puis lui demande de l'envoyer (registres USART1->ISR et USART1->TDR)
  • uint8_t uart_getchar(), qui attend que l'UART ait reçu un caractère puis le retourne (registres USART1->ISR et USART1->RDR)
  • void uart_puts(const char *s), qui fait la même chose que puts sous Linux
  • void uart_gets(char *s, size_t size), qui fait la même chose que fgets sous Linux (sauf pour le EOF, qui n'a pas de sens pour un port série)

Remarques :

  • Testez vos fonctions à chaque étape (= n'attendez pas d'avoir tout écrit pour tester).
  • Si quelque chose ne marche pas, débuggez instruction assembleur par instruction assembleur.
  • En réception, si vous avez une erreur de framing ou d'overrun, déclenchez une boucle sans fin.

Tests

Pour tester le fonctionnement correct de votre UART :

❎ Testez la transmission en envoyant une lettre au PC, qui doit l'afficher dans votre terminal série.

❎ Testez l'envoi d'une chaîne de caractères : Hello World!

❎ Testez la réception. Pour cela faite un programme d'écho, qui renvoie au PC tout ce qui en vient.

❎ Enfin téléchargez le programme checksum.py et rendez-le exécutable : chmod +x checksum.py. Ce programme génère des octets aléatoires et les envoie sur le port série du PC. Pour voir les options que vous pouvez lui passer, lancez-le avec l'option -h : ./checksum.py -h.

Puis :

❎ Écrivez une fonction pour votre carte qui reçoit des octets sur le port série et en calcule la somme sur 32 bits.

❎ Lancez votre programme, et envoyez des octets à l'aide de checksum.py.

❎ Vérifiez que la somme que vous recevez est bien correcte.

Avant de continuer

Nous avons maintenant de quoi simuler des printf, la suite sera beaucoup plus simple !

❎ Avez-vous pensé à committer / pusher, et mettre le tag UART sur le commit que nous devrons corriger ? :)

Passons maintenant au pilotage d'une carte fille : un module de LED.

Afficheur à LED

Nous allons maintenant ajouter une carte fille à la carte de TP. Cette carte fille est un afficheur sur une matrice de 8X8 LEDs RGB. Il est basé sur le circuit intégré DM163, qui permet de faire varier l'intensité de chaque couleur des LEDs de façon "très simple".

Fonctionnement de la matrice

La matrice de LED comportent 8 rangées de 8 colonnes de LED RVB (Rouge Vert Bleu - on dit aussi RGB en anglais). Chaque LED RGB est composée en fait de 3 LED mises dans un même chip : une rouge, une verte et une bleue.

Comme indiqué dans le schéma ci-dessous, les LED sont reliées entre elles ("multiplexées") de façon à réduire le nombre de broches de la matrice à 32 :

  • Les anodes ("le côté +") des LED d'une même rangée sont reliées ensemble : 8 broches, reliées à VCC
  • Les cathodes ("le côté -") des LED d'une même couleur et d'une même colonne sont reliées ensemble : 3*8 = 24 broches, reliées au contrôleur de LED DM163.

Connexion des LED sur la matrice

Pour allumer une certaine LED, il suffit de mettre la broche de sa ligne à VCC, et la broche de sa colonne à 0.

Mais le multiplexage pose un problème : il est impossible d'allumer en même temps deux LED situées sur des lignes et colonnes différentes. Par exemple, si on veut allumer la LED verte la plus en haut à gauche et la bleue en bas à droite, on obtiendra en fait ceci :

Exemple d'allumage problématique de deux LED

On va donc utiliser un processus de persistance rétinienne, et allumer les LEDs, rangée par rangée. En cyclant suffisamment vite, on aura l'impression que toutes les rangées sont pilotées en même temps alors qu'en fait elles le seront qu'un huitième du temps.

Contrôleur de LED

Le contrôleur, dont vous trouverez la documentation ici, permet de faire varier l'intensité lumineuse de 24 LEDs, en utilisant une modulation appelée PWM. Il est relié aux broches "colonnes" de la matrice de LED. Les broches "lignes" sont directement sur des GPIO sur processeur. Conséquence, pour allumer la LED en haut à gauche en rouge, on fera ceci :

  • on mettra la GPIO correspondant à la ligne 0 à VCC, et celles aux autres lignes à GND.
  • on programmera le DM163 pour qu'il fasse passer du courant sur la LED rouge de la colonne 0 (broche 28 de la matrice), et pas de courant sur les autres broches de colonne.

Comment contrôler le DM163 ?

Le DM163 est un double registre à décalage :

  • le premier, appelé BANK0 qui stocke 6 bits par LED. Il contient donc 24*6 = 144 bits.
  • le deuxième, appelé BANK1 qui stocke 8 bits par LED. Il contient donc 24*8 = 192 bits.

L'intensité passant dans chaque LED sera proportionnelle à (BANK0/64) * (BANK1/256).

Nous n'utiliserons que le BANK1, de façon à avoir 256 degrés d'intensité par LED, ce qui nous donne 16 millions de couleurs par point de la matrice.

Conséquence : pour pouvoir utiliser le BANK1, il faudra auparavant mettre tous les registres de BANK0 à 1 (si on les met à zéro, les LEDs seront éteintes quel que soit ce qui est stocké dans BANK1).

Le protocole de communication avec le DM163 est simple. C'est un protocole série, où :

  1. on commence par sélectionner le registre à décalage qu'on veut mettre à jour à l'aide du signal SB : 0 pour BANK0, 1 pour BANK1
  2. on envoie sur SDA le bit de poids fort de la dernière LED (led23[7] si on met à jour le BANK1, led23[5] si on met à jour le BANK0),
  3. puis on fait un pulse positif sur SCK (front montant puis front descendant)
  4. et on recommence en 2 jusqu'à ce que tous les bits aient été envoyés
  5. enfin, on fait un pulse négatif sur LAT, ce qui transfère le contenu du registre à décalage dans le BANK choisi. Les sorties du DM163 sont alors mises à jour instantanément.

Timings communication DM163

Connexion sur la carte IoT Node

Le driver se branche naturellement sur la carte IoT_Node. Attention de ne pas vous tromper de sens et de ne le faire que si vous êtes hors-tension sinon vous cramez tout !

La matrice de LED se branche sur la carte driver, mais le sens est un peu difficile à repérer : la broche marquée 1 sur la matrice doit être branchée sur le connecteur blue 1 du driver.

Les broches du driver, telles que documentées dans le user guide, sont branchées ainsi sur le processeur :

Broche du driverBroche du processeur
SBPC5
LATPC4
RSTPC3
SCKPB1
SDAPA4
C0PB2
C1PA15
C2PA2
C3PA7
C4PA6
C5PA5
C6PB0
C7PA3

Contrôle de base

Initialisation des broches

❎ Dans un fichier matrix.c, écrivez une fonction void matrix_init() qui :

  1. met en marche les horloges des ports A, B et C
  2. configure toutes les broches reliées au driver en mode GPIO Output avec la vitesse maximale (très haute vitesse)
  3. positionne ces sorties à une valeur initiale acceptable :
  • RST : 0 (reset le DM163)
  • LAT : 1
  • SB : 1
  • SCK et SDA : 0
  • C0 à C7 : 0 (éteint toutes les lignes)
  1. attend au moins 100ms que le DM163 soit initialisé, puis passe RST à l'état haut.

Contrôle des broches

❎ Écrivez les fonctions ou macros suivantes permettant de piloter indépendamment chaque broche :

  • RST(x)
  • SB(x)
  • LAT(x)
  • SCK(x)
  • SDA(x)
  • ROW0(x) à ROW7(x)

Par exemple RST(0)̀ mettra la broche RST à 0, LAT(1) mettra la broche LAT à 1, ROW6(1) mettra C6 à 1, etc.

Utilisez-les dans la fonction d'initialisation de la matrice.

Génération de pulses

❎ Écrivez, à l'aide de la macro SCK, une macro (ou fonction) pulse_SCK qui effectue un pulse positif (état bas, attente, état haut, attente, état bas, attente) sur SCK respectant les timings attendus par le DM163.

❎ Écrivez, à l'aide de la macro LAT, une macro (ou fonction) pulse_LAT qui effectue un pulse négatif (état haut, attente, état bas, attente, état haut, attente) sur LAT respectant les timings attendus par le DM163.

Si vous devez faire des pauses, faites pour l'instant des boucles d'attente active à l'aide de asm volatile ("nop").

Contrôle des lignes

❎ Écrivez, à l'aide des macros ROW0 à ROW7, une fonction void deactivate_rows() qui éteint toutes les lignes.

❎ Écrivez, à l'aide des macros ROW0 à ROW7, une fonction void activate_row(int row) qui active la ligne dont le numéro est passé en argument.

Contrôle du DM163

❎ Écrivez une fonction void send_byte(uint8_t val) qui, à l'aide des macros pulse_SCK et SDA, envoie 8 bits consécutifs au bank actif du DM163 (dans l'ordre attendu par celui-ci).

❎ Définissez le type rgb_color comme une structure représentant la couleur d'une case de la matrice :

typedef struct {
  uint8_t r;
  uint8_t g;
  uint8_t b;
} rgb_color;

❎ Écrivez, à l'aide de send_byte et activate_row, une fonction void mat_set_row(int row, const rgb_color *val) qui :

  • prend en argument un tableau val de 8 pixels
  • à l'aide de send_byte envoie ces 8 pixels au bank actif du DM163 (B7, G7, R7, B6, G6, R6, ..., B0, G0, R0)
  • puis à l'aide de activate_row et pulse_LAT active la rangée passée en paramètre et les sorties du DM163.

Initialisation du BANK0

❎ Écrivez une fonction void init_bank0() qui à l'aide de SB, SDA, pulse_SCK et pulse_LAT met tous les bits du BANK0 à 1.

❎ Faites en sorte que cette fonction soit appelée par matrix_init.

❎ Toujours dans matrix_init, positionnez le bank par défaut à BANK1 en utilisant SB pour que toutes les opérations suivantes n'utilisent que ce bank.

Premier test

❎ Écrivez une fonction void test_pixels() qui teste votre affichage, par exemple en allumant successivement chaque ligne avec un dégradé de bleu, puis de vert puis de rouge. Cette fonction devra être appelée depuis la fonction main, de façon à ce que nous n'ayons qu'à compiler (en tapant make) puis à charger votre programme pour voir votre programme de test fonctionner.

❎ Committez tout ça avec le tag TEST_MATRIX.

Test d'affichage d'une image statique

❎ Récupérez le fichier image.raw. Ce fichier est un binaire contenant les valeurs de chaque pixel stockées au format suivant :

  • octet 0 : ligne 0, LED 0, rouge
  • octet 1 : ligne 0, LED 0, vert
  • octet 2 : ligne 0, LED 0, bleu
  • octet 3 : ligne 0, LED 1, rouge
  • octet 4 : ligne 0, LED 1, vert
  • octet 5 : ligne 0, LED 1, bleu
  • ...
  • octet 189 : ligne 7, LED 7, rouge
  • octet 190 : ligne 7, LED 7, vert
  • octet 191 : ligne 7, LED 7, bleu

Faites en sorte que fonction main affiche automatiquement cette image, en cyclant sur les lignes suffisament vite pour que l'œil ait l'impression d'une image statique.

Vous devriez obtenir une image ressemblant à peu près à ceci (désolé pour la qualité de la photo) :

Résultat du test

❎ Committez le code avec le tag TEST_STATIC_IMAGE.

Avant de continuer

Nous savons maintenant piloter l'afficheur. On va voir comment faire pour afficher des images animées, mais pour cela il va falloir apprendre à :

  • contrôler le temps de façon plus précise qu'avec des boucles d'attente active,
  • pouvoir faire plusieurs tâches en parallèle : gérer les interruptions.

C'est le but des prochaines étapes.

IRQ

Vous savez maintenant récupérer un flux sur le port série, contrôler des GPIO, etc. Mais que se passe-t-il si on a envie de faire tout ça en même temps ? Si des caractères arrivent sur le port série alors qu'on est en train de faire une autre tâche (longue), on risque de les rater. De même, comment faire pour exécuter une tâche périodique en fond ? Pour cela nous allons avoir besoin de deux choses :

  • gérer les interruptions / exceptions,
  • générer automatiquement des interruptions à intervalles réguliers. C'est le rôle des timers.

Commençons par les interruptions.

Les exceptions sur processeurs à base de Cortex M

Introduction

Les exceptions sont des événements qui interrompent le cours normal d'exécution d'un programme. Lorsqu'une exception arrive, le programme en cours d'exécution est stoppé, et un bout de code spécifique à cette exception (appelé handler) est exécuté. Puis si l'exception n'était pas fatale, le programme reprend son cours comme si de rien n'était.

Les exceptions sont de deux types :

  • causées par un événement interne au processeur (Cortex M4) : reset, bus fault (quand on accède à une adresse non mappée en mémoire), division par 0, … Elles sont généralement graves et empêchent souvent la reprise du cours normal du programme interrompu.
  • causées par un événement externe au processeur : un périphérique qui a besoin de signaler quelque chose, un changement d'état d'une GPIO, ... On les appelle alors communément interruptions (interrupt request ou IRQ).

En pratique, on utilise souvent indifféremment le terme exception ou interruption…

Le microcontrôleur STM32L475 est composé d'un cœur d'ARM (Cortex M4) et de périphériques. Nous aurons donc deux types d'exceptions : celles propres au Cortex, et les IRQ dues aux périphériques.

Lorsqu'une exception est traitée, le processeur doit sauvegarder son état actuel. Cette sauvegarde peut être automatique (c'est le cas sur les Cortex M) ou manuelle (SPARC, ARM7TDMI, etc.).

Les exceptions peuvent avoir différentes priorités, permettant à une exception prioritaire d'interrompre le traitement d'une autre moins prioritaire. Ces priorités peuvent être réglées manuellement ou fixes. Ces priorités sont représentées par un nombre. Sur Cortex M, plus ce nombre est petit, plus l'exception est prioritaire.

En plus de ces priorités, la plupart des exceptions peuvent être désactivées ou activées logiciellement.

Les exceptions du STM32L275

Le STM32L275 intègre un contrôleur d'interruption très souple appelé NVIC (Nested Vectored Interrupt Controller) qui permet de gérer :

  • 82 sources d'IRQ externes au processeur,
  • 1 NMI (Non Maskable Interrupt) : une interruption externe non désactivable,
  • les exceptions internes au processeur.

Chaque exception a un numéro qui lui est propre :

  • les exceptions internes au processeur et la NMI sont numérotées de 1 à 15,
  • les 82 IRQ externes sont numérotées de 16 à 98. Elles ont en plus leur propre numéro d'IRQ externe, appelé position dans le "Reference Manual", qui vaut numéro d'exception - 16. Elles sont de priorités réglables et désactivables / activables à volonté. Il y a une par périphérique (UART0, UART1, SPI0, etc.).

Ces exceptions sont décrites en page 396 du manuel de référence : les exceptions internes en grisé (oui, il y en a bien 15 !), les IRQ en non grisé.

Traitement d'une exception

Le Cortex s'attend à avoir en mémoire une table donnant pour chaque exception l'adresse du handler associé. C'est ce qu'on appelle la table des vecteurs d'interruption. L'emplacement de cette table en mémoire est stocké dans le registre VTOR(voir la section 4.4.4 Vector table offset register (VTOR) du Programming Manual). Ce registre est accessible à l'adresse 0xE000ED08. Au reset, le VTOR est chargé avec la valeur 0x00000000, ce qui veut dire que la table des vecteurs d'interruption est en flash. Mais vous avez la liberté de construire une autre table en RAM contenant les adresses de vos propres handlers, et de faire pointer VTOR sur cette table.

Lors de l'arrivée d'une exception (pour plus de détails voir la section 2.3.7 Exception entry and return du Programming Manual):

  1. Le processeur sauvegarde automatiquement sur la pile les registres R0 à R3, R12, R14, PC, et xPSR.
  2. Il stocke dans LR une valeur spéciale EXC_RETURN signifiant "retour d'exception" : 0xfffffff1, 0xfffffff9 ou 0xfffffffd.
  3. Il va chercher l'adresse du handler à exécuter à l'adresse suivante en mémoire : VTOR + 4*exception_number.
  4. Il saute à cette adresse et exécute le code qui s'y trouve.
  5. À la fin de ce code, on trouve typiquement l'instruction BX LR, qui signifie "branchement à EXC_RETURN". Le processeur recharge depuis la pile les registres sauvegardés, et reprend le cours normal de l'exécution du programme.

Le bit indiquant qu'une interruption est en train d'être traitée est clearé automatiquement lors de l'entrée ou de la sortie du handler.

Vous avez dû remarquer que le processeur sauvegarde sur la pile les registres caller saved (en plus du PC et des xPSR). Cela permet de coder les handlers d'interruption comme des fonctions C tout à fait normales ! Il suffit juste que le SP soit positionné à une adresse correcte avant le déclenchement d'une exception.

Cela pose un problème pour la NMI qui n'est pas désactivable et peut se déclencher dès le boot avant que le SP ne soit bien positionné. Pour cela, les Cortex disposent d'une fonctionnalité rusée. La table des vecteurs d'interruption est organisée ainsi :

AdresseVecteur (numéro d'exception)Numéro d'IRQ externe ("position")Description
Exceptions internes au Cortex M4
0x000000000-SP initial
0x000000041-PC initial (Reset Handler)
0x000000082-NMI Handler
0x0000000C3-Hard Fault Handler
0x000000104--
0x0000003C15-SysTick Handler
IRQ externes au Cortex M4
0x00000040160WWDG
0x00000044171PVD_PVM
0x00000048182TAMP_STAMP
0x0000004C193RTC_WKUP

Autrement dit :

  • la première entrée est la valeur à laquelle positionner le SP au reset,
  • la deuxième entrée est la valeur à laquelle positionner le PC au reset (= le point d'entrée du programme).

Au reset, le processeur va positionner automatiquement le SP et le PC avec les deux premières entrées de la table. Une interruption peut donc survenir tout de suite, elle sera traitée correctement : le pointeur de pile sera bien positionné. Et sinon, c'est ce qui se trouve à l'adresse contenue dans PC (le point d'entrée du programme, généralement _start) qui sera exécuté.

Toutes les exceptions du STM32L275 sont disponibles en page 393 du manuel de référence du processeur et leur nom CMSIS dans le fichier stm32l475xx.h en ligne 82.

NVIC

Le contrôleur d'interruption (NVIC) n'est pas spécifique au STM32L475 mais aux Cortex M. Sa documentation est donc dans la documentation officielle d'ARM sur les Cortex M4. ARM donnant la possibilié aux fabricants de customiser le NVIC dans une large mesure, nous vous recommandons en premier la documentation du NVIC de notre processeur disponible dans le Programming Manual, en page 207.

Il dispose de plusieurs registres qui permettent de contrôler les IRQ externes (et seulement les externes). Les deux principaux sont :

  • NVIC_ISER : qui permet, en écrivant un 1 sur le bit n, d'activer l'IRQ externe numéro n (= l'exception numéro 16+n)
  • NVIC_ICER : qui permet, en écrivant un 1 sur le bit n, de désactiver l'IRQ externe numéro n (= l'exception numéro 16+n)

Par exemple, pour activer / désactiver les interruptions spécifiques au WATCHDOG, dont le numéro d'IRQ externe est 0 (numéro d'exception 16), on écrira ceci :

// Pour activer les IRQ du WATCHDOG
SET_BIT(NVIC_ISER[0], 1);

// Pour désactiver les IRQ du WATCHDOG
SET_BIT(NVIC_ICER[0], 1);

Seule l'écriture d'un 1 dans ces registres a un effet. Cela évite d'avoir à faire un read-modify-write pour activer ou désactiver une interruption dans le NVIC.

On peut aussi utiliser les fonctions de la CMSIS void NVIC_EnableIRQ(IRQn_Type IRQn) et void NVIC_DisableIRQ(IRQn_Type IRQn), disponibles dans le fichier core_cm4.h. Par exemple, pour activer / désactiver les interruptions spécifiques au SPI1, dont le numéro d'IRQ externe est 35, on écrira ceci :

// Active les IRQ du SPI1
NVIC_EnableIRQ(35);

// Désactive les IRQ du SPI1
NVIC_DisableIRQ(35);

Les exceptions internes ne sont pas modifiables par le NVIC.

Activation / désactivation de toutes les interruptions

Il est possible d'autoriser ou d'interdire toutes les interruptions (sauf le reset et la NMI), en positionant le registre PRIMASK du processeur. On modifie ce registre par les instructions assembleur suivantes :

  • cpsie i : active les interruptions
  • cpsid i : désactive les interruptions

Ces fonctions sont aussi disponibles en C dans la CMSIS, fichier stm32l475xx.h : __enable_irq(void) et __disable_irq(void).

Mise en place

Création d'une table de vecteurs d'interruptions par défaut

Les tables de vecteurs d'exceptions sont généralement écrites en assembleur (exemple), mais dans le cas des Cortex il est possible de les écrire en C. C'est ce que nous allons faire.

❎ Dans un fichier irq.c, créez une table de vecteurs d'interruptions, sur ce modèle :

void * const vector_table[] = {
    // Stack and Reset Handler
    &_stack,            /* Top of stack  (initial value of the SP register) */
    _start,             /* Reset handler (initial value of the PC register) */

    // ARM internal exceptions
    NMI_Handler,        /* NMI handler */
    HardFault_Handler,  /* Hard Fault handler */
    MemManage_Handler,
    BusFault_Handler,
    UsageFault_Handler,
    0,                  /* Reserved */
    0,                  /* Reserved */
    0,                  /* Reserved */
    0,                  /* Reserved */
    SVC_Handler,        /* SVC handler */
    0,                  /* Reserved */
    0,                  /* Reserved */
    PendSV_Handler,     /* Pending SVC handler */
    SysTick_Handler,    /* SysTick hanlder */

    // STM32L475 External interrupts
    WWDG_IRQHandler,         /* Watchdog IRQ */
    PVD_PVM_IRQHandler,      /* ... */
    TAMP_STAMP_IRQHandler,   /* ... */
    ...
};

Cette table est un tableau de pointeurs constants non typés (void * const), qu'on peuple avec les adresses de handlers par défaut.

Pour respecter la convention CMSIS, appelez les handlers d'exceptions internes XXX_Handler, et les handlers d'IRQ externes XXX_IRQHandler (exemple : DMA1_Channel1_IRQHandler).

Création des handlers d'interruptions par défaut

❎ Dans le fichier irq.c, avant la table des vecteurs, définissez des handlers par défaut qui feront la chose suivante :

  • désactiver toutes les interruptions
  • faire une boucle sans fin

On pourra ainsi vite voir si on sait générer une interruption et si le bon handler est appelé.

Bien entendu, il est hors de question d'écrire 40 fois le même code ! Écrivez donc une macro MAKE_DEFAULT_HANDLER, qui prend en argument un nom de handler (par exemple truc_IRQHandler) et qui déclare et instancie la fonction truc_IRQHandler.

Les handlers par défaut seront amenés à être surchargés par d'autres fichiers C, qui voudront mettre en place leur propre handler. Pour cela, faites en sorte que la définition des handlers inclue bien l'attribut weak : void __attribute__((weak)) truc_IRQHandler(void) {...}.

Initialisation des interruptions

❎ Toujours dans irq.c, écrivez une fonction void irq_init(void), qui stocke dans VTOR l'adresse de la table des vecteurs d'interruptions. Le registre VTOR fait partie du System Control Block (SCB) dont vous trouverez la définition dans core_cm4.h.

❎ Pensez à appeler irq_init dans votre fonction main

Génération d'une interruption par l'appui du bouton B2 (bleu)

Nous allons faire en sorte que l'appui sur le bouton poussoir B2 (le bleu, relié à la broche PC13) génère une interruption qui fera changer d'état la LED verte.

Dans le STM32L475, les GPIO ne sont pas reliées directement au NVIC. Elles passent d'abord par un bloc appelé EXTI (cf ref manual page 400), capable de générer des interruptions sur état (haut ou bas), sur front (montant ou descendant), ou des "événements" (sortes d'interruptions spéciales dont on ne s'occupera pas dans cette UE). Il va donc falloir programmer l'EXTI pour qu'il génère une interruption sur front descendant de la broche PC13. Pour cela, lisez les sections 14.3 et 14.4, sachant que les GPIO sont des "lignes d'IRQ configurables".

Une fois que l'interruption est déclenchée, elle reste active jusqu'à que vous l'acquittiez en écrivant ce qu'il faut dans le registre EXTI_PRn. Le pending register du NVIC est remis à zéro (cleared) automatiquement lors de l'appel du handler d'interruption (page 225), il n'y a donc rien à faire de ce côté-là.

❎ Créez dans un fichier buttons.c la fonction void button_init(void) qui

  • active l'horloge du port C,
  • configure la broche PC13 en GPIO et en entrée,
  • sélectionne la broche PC13 comme source d'IRQ pour EXTI13 (registre SYSCFG_EXTICRn, cf page 403),
  • configure l'EXTI selon la procédure décrite en 14.3.4.

❎ Compilez, et testez en appuyant sur B2 que le handler par défaut des GPIO du PORTC est bien appelé.

❎ Rajoutez maintenant dans buttons.c un handler spécifique, qui primera sur le handler par défaut. Ce handler fera les choses suivantes :

  • acquittement de l'interruption dans l'EXTI (registre EXTI_PR1)
  • toggle de la LED verte

❎ Compilez et testez : à chaque appui sur B2 la LED verte doit "toggler", et ce sans que cela perturbe votre programme habituel (affichage d'une l'image sur le color shield).

Avant de continuer

❎ Vous savez maintenant comment générer et traiter des exceptions sur Cortex M. Nous allons voir dans la suite comment générer des interruptions périodiques pour effectuer des tâches de fond par exemple. En attendant, tag IRQ ! :-)

UART+IRQ+LED

Vous savez récupérer des octets sur le port série et piloter l'afficheur de LED. L'objectif de cette partie est de pouvoir envoyer des trames par le port série depuis un PC à la carte de TP, et de les afficher en temps réel. Si on arrête d'envoyer des trames, la dernière en date doit rester affichée. Et on doit pouvoir recommencer à envoyer des trames sans avoir à redémarrer le programme sur la carte.

Le port série

❎ Les échanges sur le port série seront faits à la vitesse de 38400 bauds, 8 bits, pas de parité, 1 bit de stop (38400 8N1). Pour cela, modifiez le protoytpe de la fonction uart_init de la façon suivante : void uart_init(int baudrate).

À chaque fois que la carte est débranchée et rebranchée, le port série du PC doit être reconfiguré en 38400 8N1 en exécutant sh ./stty.sh (programme fourni ici).

Pour envoyer le contenu d'un fichier bidule.bin sur le port série, on utilisera la commande suivante : cat bidule.bin > /dev/ttyACM0.

Format des trames envoyées par le PC

Les fichiers qu'on affichera contiennent une ou plusieurs trames au format suivant :

  • Chaque début de trame est indiqué par l'octet 0xff.
  • Chaque octet différent de 0xff fait partie d'un pixel.
  • Les pixels sont stockés dans l'ordre naturel : d'abord ceux de la première ligne en commençant par la gauche, puis ceux de la deuxième ligne, etc.
  • Chaque pixel est composé de 3 octets, d'abord R, puis G puis B (chacun compris entre 0x00 et 0xfe).

Ainsi, une trame qui allume le pixel en haut à gauche en bleu sera composée des octets suivants : 0xff 0x00 0x00 0xfe 0x00 ... 0x00.

Le fichier de test one_frame.bin contient une seule trame et permet d'afficher l'image suivante (toutes les LED éteintes, sauf les 3 en haut à droite en RVB) :

Vous pouvez voir le contenu du fichier en question par la commande od -tx1 -v one_frame.bin.

Architecture du code

Vous devez pouvoir afficher une image en même temps que vous recevez des pixels. Il va donc falloir faire en sorte que deux tâches s'exécutent en parallèle :

  1. une qui reçoit les trames et les stocke à un endroit approprié en mémoire,
  2. l'autre qui rafraichit en permanence l'écran.

Vous allez donc faire en sorte que le port série fonctionne sous interruption, de façon à ce qu'une IRQ soit générée dès qu'un octet est reçu. Votre handler d'IRQ traitera cet octet et mettra à jour au fur et à mesure la trame qui est affichée. On n'attendra pas qu'une trame soit complètement reçue pour mettre à jour l'affichage, ni que les trois composantes d'un pixel soient reçues entièrement.

Remarque : la trame affichée est un objet partagé entre la tâche de réception et celle d'affichage. Des problèmes de concurrence peuvent donc se poser, si on envoie une ligne à l'afficheur alors qu'on n'a reçu qu'une ou deux des trois composantes d'un pixel. On ne s'en préoccupera pas ici, l'œil n'étant pas assez rapide pour le voir. Ce problème sera abordé en SE202 et SE302.

Travail

❎ Définissez un objet global qui contiendra la trame affichée. Il sera modifé par le handler d'IRQ du port série, et lu par la tâche d'affichage.

❎ Écrivez la tâche de réception du port série (handler d'IRQ) qui traitera les octets reçus.

❎ Écrivez la tâche d'affichage qui affiche en permanence la trame courante (normalement vous l'avez déjà écrite dans le TP…).

Pour tester votre code, vous avez trois fichiers à votre disposition. Par ordre de difficulté croissante :

  • one_frame.bin qui contient une seule trame,
  • many_frames.bin qui contient plusieurs trames pour avoir un affichage animé,
  • final.bin qui contient lui aussi plusieurs trames et vous permettra de tester la robustesse de votre code face à des trames potentiellement mal formées. Ce fichier vous affichera aussi un message vous donnant l'URL de la suite de cette partie.

❎ N'oubliez pas le tag UART_IRQ_1 !

Gestion des erreurs de transmission

❎ Faites en sorte que si votre UART reçoit une erreur de transmission (FE ou OR), alors la trame courante est ignorée et qu'on se remette en attente de la prochaine trame.

❎ N'oubliez pas le tag UART_IRQ_2 !

Timers

Tous les microcontrôleurs sont équipés de dispositifs appelés "timers". Ce sont des compteurs / décompteurs qui offrent de nombreuses fonctionnalités :

  • générer une interruption lorsqu'ils ont atteint une certaine valeur, ce qui permet d'effectuer des tâches à intervalles réguliers
  • changer automatiquement l'état d'une une broche du microprocesseur, ce qui permet de générer facilement du PWM
  • compter non pas les cycles d'horloges mais des événements externes
  • déterminer automatiquement la largeur d'un créneau appliqué à une broche externe
  • etc.

Les Cortex incluent tous un timer spécial appelé SysTick. Ce timer est fait spécifiquement pour donner une base de temps aux OS multitâches, de façon à ce qu'ils puissent effectuer des changements de contexte à intervalles réguliers.

Les microcontrôleurs incluent d'autres timers en tant que périphériques externes, disponibles pour l'utilisateur (le SysTick étant généralement réservé à l'OS). Le STM32L475VGT6 dispose de plusieurs timers (9 TIMx et 2 LPTIM).

Il y a quelques différences de fonctionnalités entre les différents timer, mais ils partagent les caractéristiques suivantes (voir le manuel de référence pour le détail):

  • une résolution de 16 ou 32 bits
  • mode PWM, comptage/décomptage, déclenchement de DMA, ou d’interruptions...
  • peuvent être utilisés indépendamment ou chaînés si nécessaire
  • peuvent être reliées aux IOs (en sortie ou en entrée pour la capture d’événements)

Mise en œuvre du TIM2

Le TIM2 est un "general-purpose timer" avec une résolution de 32 bits (voir page 1012 du manuel). Nous allons l'utiliser pour générer régulièrement des interruptions pour avoir une base de temps qui nous permettra de synchroniser certains événements (faire clignoter une led puis mettre à jour la matrice de leds)

L'horloge du timer

Le timer TIM2 est connecté sur le bus APB1 (voir figure 15 page 208).

Ce timer est sur le bus APB1. Comme l'horloge de ce bus (PCLK1) est la même que l'horloge principale (SYSCLK) (défini par le champ PPRE1 de RCC_CFGR page 227), le timer utilisera aussi cette horloge (voir 6.2.15 page 218).

Configuration du timer

Nous voudrions avoir le comportement suivant :

  • mode compteur (upcounter)
  • le compteur change chaque microseconde (fréquence de 1MHz)
  • auto chargement (quand le compteur arrive à la valeur max, il repasse à 0 et recommence)
  • génération d'un événement à chaque fois qu'il repasse à 0 (update event) qui servira comme source d'interruption

❎ Dans un fichier timer.c écrivez la fonction void timer_init(int max_us) qui effectue les initialisations suivantes:

  • active l'horloge du timer TIM2 (chercher le bon ENR du RCC)
  • effacer d'éventuels flags dans le registre de statut (TIMx_SR) et mettre à zéro la valeur du compteur (TIMx_CNT)
  • configurer le timer pour avoir le bon mode de fonctionnement, sans l'activer
  • changer le prescaler du timer (TIMx_PSC) pour compter à la microseconde
  • modifier la valeur d'auto chargement (TIMx_ARR) en fonction du paramètre de la fonction
  • autoriser la génération d'une interruption sur l'événement de mise à jour du timer (TIMx_DIER)
  • autoriser l'interruption de TIM2 au niveau du NVIC (attention, il faudra avoir un nouveau handler comme décrit dans la suite)
  • activer le timer

❎ Pour pouvoir tester que l'interruption est bien prise en compte, dans le même fichier, redéfinir le handler pour le TIM2 pour changer l'état de la led verte à chaque interruption.

❎ Dans le programme principal, appeler la fonction d'initialisation du timer pour que la led clignote toutes les secondes

Fonctionnement de la matrice sous IRQ

❎ Faites maintenant en sorte que l'affichage de la matrice soit géré par des interruptions du timer: affichage de l'image courante au moins 60 fois par seconde.

❎ L'affichage est-il régulier ? Toutes les lignes bénéficient-elles du même temps d'exposition ? Si ce n'est pas le cas, modifiez votre programme pour que le timer détermine la date d'affichage de chaque ligne plutôt que la date d'affichage de chaque image.

❎ N'oubliez pas de commiter avec le tag TIMERS.

Mise en flash

Le but de cette partie est d'obtenir une carte qui puisse fonctionner toute seule, dès qu'on la met sous tension, sans avoir besoin d'uploader un programme ni de lancer gdb. Pour cela vous allez placer votre exécutable en flash.

Mapping mémoire

On rappelle que le mapping mémoire du processeur est disponible en pages 75 et suivantes du reference manual du processeur :

  • Flash : adresse de début = 0x08000000, taille = 1 MB
  • RAM : elle est séparée en deux blocs non contigus :
    • SRAM1 : début = 0x20000000, taille = 96 kB
    • SRAM2 : début = 0x10000000, taille = 32 kB

Attention : La zone en 0x1FFF 7000 est une zone OTP : One Time Programmable. Surtout n'écrivez rien dedans : une mauvaise écriture dedans peut mettre la carte dans un mode "sécurisé" où ne peut plus ni relire, ni modifier, ni effacer le contenu de la flash : la carte devient donc inutilisable pour les prochains TP. Nous vérifierons les cartes, si vous avez écrit dedans vous serez pénalisés.

Problème

Après un reset, le processeur va exécuter l'instruction dont l'adresse se trouve à la 2e position de votre table des vecteurs. Cette adresse doit correspondre à l'adresse d'une fonction thumb (i.e. le bit 0 doit être à 1).

❎ Est-ce bien le cas (utilisez objdumb pour vérifier) ?

❎ Si ce n'est pas le cas, ajoutez dans votre crt0.s la directive qui permet de dire que le symbole _start est bien une fonction thumb. Aidez-vous pour cela de la documentation spécifique aux directives pour les processeurs ARM De GNU AS.

❎ De plus, il ne faut plus utiliser le fichier clocks.o qui vous avait été fourni, mais le recompiler vous-même à partir de clocks.c, avec les mêmes options que les autres fichiers et les optimisations -Og ou -O1 (pas de -O2 ni -O3).

Passage du code en XIP

❎ En vous rappelant des étapes de boot d'un processeur et de tout ce que doit faire le crt0.s, modifiez votre linker script et votre code d'initialisation de façon à ce que la carte puisse booter et que le programme s'exécute depuis la flash.

Attention : avant de reflasher votre carte avec gdb, vérifiez bien avec objdump que vous ne touchez qu'aux zones autorisées en flash.

❎ Fermez gdb et le driver de sonde JLink, débranchez votre carte puis rebranchez-la. Vérifiez que votre s'exécute correctement tout seul.

❎ N'oubliez pas de mettre le tag XIP.

Recopie du code en RAM

Sur beaucoup de processeurs l'accès à la flash est plus lent que l'accès à la RAM. C'est le cas sur votre processeur.

❎ Modifiez votre code et votre linker script de façon à ce que le code

  • commence par s'exécuter en flash,
  • s'auto-recopie en RAM,
  • transfère son exécution à la partie située en RAM.

On laissera la section .rodata en flash. On relogera la table des vecteurs d'interruption en RAM, de façon à donner à l'utilisateur la possibilité de mettre en place ses propres handlers d'IRQ.

❎ N'oubliez pas de mettre le tag FLASH sur votre dernier commit et de pusher le tout sur votre branche main :-)

Conclusion

Si vous êtes arrivé jusqu'ici, BRAVO ! Vous savez maintenant comment un système embarqué démarre, est configuré, comment accéder à ses périphériques et comment lancer un exécutable. Vous êtes aussi en mesure de programmer une carte from scratch, sans libC, sans qu'il n'y ait rien dessus. C'est une compétence rare, soyez en fiers !

Nous espérons que cette UE et ce TD vous ont plu et ont pu démystifier la génération des exécutables et le fonctionnement d'un système à processeur basique :-)