Linux Embedded

Le blog des technologies libres et embarquées

Bare Metal - From zero to blink

Présentation

Cet article est destiné à vous exposer les premiers concepts du Bare Metal sur STM32. Concrètement, Il vous apportera toutes les explications nécessaires à une première approche de cette façon de programmer puis aboutira à un test réel sur l’une des deux cartes suivantes : la BluePill (à base de STM32F103C8T6) et/ou la Nucleo-F446RE (à base de STM32F446RE). Le titre de l’article s’explique par le fait que l’on partira de fichiers totalement vides. Ce sera la magie de voir naître un programme en Bare Metal à partir de rien.

 

Le Bare Metal est une façon particulière de programmer les micro-contrôleurs. Ici, pas d’OS, pas d’IDE, pas de programme généré automatiquement mais simplement un bootloader et un exécutable. Parfois, on peut même se passer de bootloader pour un démarrage encore plus rapide et une empreinte mémoire plus faible. Évidemment cela ne se fait pas sans une rigueur sans faille et un respect total de certaines règles. C’est ce que nous allons voir plus loin. En Bare Metal, c’est vous la maître à bord et vous qui guidez le micro-contrôleur.

Malgré tout, le process de mise en œuvre est assez simple. À partir de fichiers sources, le compilateur puis l’éditeur de liens vont créer un exécutable qui va être téléchargé sur la carte pour exécution. En voici un résumé :

Principes généraux

 

La Cross compilation

Pour commencer, il peut être important de décrire le processus de "cross-compilation" car c’est celui-ci que nous allons utiliser pour créer notre exécutable. Derrière ce mot se cache un mécanisme tout simple constituant à demander à une machine (ici un PC sous Linux ou Windows) de compiler un code fait pour une plateforme différente afin de l’exécuter, dans notre cas, sur un micro-contrôleur STM32. Le PC sert d’outil pour éditer, compiler et télécharger l’exécutable sur la carte.

Avant d’aller plus loin, il faut installer ces outils. Voici la procédure pour Linux et Windows.

Installation de GNU tools GCC sous Débian/Linux :

  • Exécutez la commande sudo apt install gcc-arm-none-eabi.
  • Vérifiez que les binaires se trouvent dans le PATH et s’exécutent de n’importe où.

 

Installation de GNU tools GCC sous Windows :

  • Google : recherchez gcc for arm.
  • Premier lien (https://developer.arm.com/).
  • Recherchez « GNU arm toolchain » dans la zone de recherche.
  • Choisissez le lien « GNU Arm Embedded Toolchain Downloads ».
  • Puis téléchargez gcc-arm-none-eabi-10-2020-q4-major-win32.exe.
  • Installez le programme.
  • Vérifiez la présence des binaires dans C:\Program files (86)\ GNU Tools ARM Embedded\bin.
  • Dans l’Invite de commandes, essayez de lancer : arm-none-eabi-gcc.

Fonctions des binaires

Lorsque l’on installe gcc ARM, on trouve une grande quantité de binaires différents. Parmi ceux-ci, voici un descriptif des plus courants, ceux qui nous seront utiles par la suite :

arm-none-eabi-gcc: permet de compiler, de linker et d’assembler du code.
arm-none-eabi-as: permet d’assembler uniquement.
arm-none-eabi-ld: permet de linker uniquement.
arm-none-eabi-objdump: permet d'explorer les fichiers après compilation.
arm-none-eabi-readelf: permet de lire les fichier .elf.
arm-none-eabi-nm: permet de lister les symboles d’un fichier objet.
arm-none-eabi-objconv: permet de convertir les fichiers.

 

Les étapes de la compilation

Ces étapes sont celles que nous allons avoir besoin de faire lors de notre développement. Elles sont simples mais les décrire ici en détails donne une bonne vision de la compilation et de ses différentes phases. On compte quatre étapes : le pré-processing, la génération de code, l’assemblage et l’édition de liens. Cette dernière étape sera détaillée plus loin dans un paragraphe lui étant destiné.

Pre-processing

Il s’agit d’une étape où l’on résout toutes les directives de pré-processing. On obtient un fichier extension .i .

Pre processing

Génération du code

Dans cette étape, nous générons le code en mnémoniques assembleurs. Nous obtenons un fichier d’extension .s .

Compilation

Assemblage

L’étape suivante convertit les mnémoniques en code machine donc presque exécutables. On obtient un fichier relogeable d’extension .o. Ce fichier aura besoin de liens vers toutes les références externes, ses programmes et ses variables. Établir tout cela, sera le rôle de l’éditeur de liens.

Assemblage

 

Pour résumer tout cela, sur ce schéma, nous distinguons le process de compilation complet, étape par étape.

Process summary

Première compilation

D’une façon générale, voici la ligne de compilation permettant de générer du code pour le micro‑contrôleur.

$ arm-none-eabi-gcc -c main.c -o main.o -mcpu=cortex-m3 -mthumb

Puis, jetons un œil sur les options :

-c Effectue uniquement l’étape de compilation. N’effectue pas l’édition de liens.
-o Spécifie le fichier de sortie (ici, main.o).
-mcpu Choix du CPU (ici, cortex-m3 pour la BluePill).
-mthumb Demande au compilateur d’utiliser le jeu d’instruction T32 composé d’un mélange d’instructions 16-bits et 32-bits.
-S Permet de générer le code assembleur généré par la compilation.
-O0 Réduit le temps de compilation et produit du code debuggable. Option par défaut.

 

Cette commande va générer un fichier relogeable (c’est à dire un fichier non encore linké et à l’extension .o). Ce fichier de sortie .o est au format .elf (Executable and Linkable Format). Le format elf est le format standard lorsque vous utilisez gcc.

Ce format va décrire l’organisation des différents éléments d’un programme découpé en sections. On y trouvera essentiellement les données (data), les données en read-only (rodata), le code exécutable (text) ainsi que les données non-initialisées (bss).

Si vous voulez uniquement générer un fichier assembleur (donc un fichier en .s), vous pouvez utiliser la commande -S:

$ arm-none-eabi-gcc -S main.c -o main.s -mcpu=cortex-m3 -mthumb

Le lien suivant vous donnera toutes les informations nécessaires sur les options de compilation de gcc: https://gcc.gnu.org/onlinedocs/gcc-10.2.0/gcc/

 

Le Makefile

Comment peut-on se passer d’un Makefile en Bare Metal ? Impossible, je répondrais. Voici donc un premier fichier Makefile permettant de compiler nos sources :

CC=arm-none-eabi-gcc
MACH=cortex-m3
CFLAGS= -c -mcpu=$(MACH) -mthumb -std=gnu11 -O0

main.o: main.c
	$(CC) $(CFLAGS) $^ -o $@

Avec:

MACH=cortex-m3 La plateforme de destination de notre code (la BluePill ici).
$^ Les dépendances (ici main.c).
$@ La target (ici main.o).

 

Pour continuer, il faut installer Make, rien de plus simple :

  • sur Windows une recherche Google : make for Windows , indiquera la marche à suivre.
  • sur Debian/Linux: sudo apt install make .

Note: Faire attention au PATH de make. Éventuellement, rajoutez-le.

Le fichier .o et le format ELF

Commençons à rentrer dans le vif du sujet en décrivant le format ELF.

  • Le fichier en .o est un fichier intermédiare au format ELF qui est exécutable et auquel on résoudra les liens de symboles ("linkage").
  • Le format ELF est le standard pour les fichiers exécutables et objets lorsque GCC est utilisé.
  • Un format de fichier permet d’organiser les données situées à l’intérieur : données, données en lecture seule, code, initialisation, etc… Il existe plusieurs sections différentes pour stocker les différentes données.
    • .text pour les instructions du code machine.
    • .data pour les données statiques initialisées.
    • .bss pour les données statiques non initialisées.
    • .rodata pour les données en read-only.
  • Le fichier .o peut être examiné avec la commande arm-none-eabi-objdump
    • Option -h : visualise les sections: arm-none-eabi-objdump -h main.o
    • Option -c : extrait le code assembleur : arm-none-eabi-objdump -c main.o > main.log
    • Option -s : affiche le contenu complet.
    • Option -D : désassemble toutes les sections: arm-none-eabi-objdump -D main.o > main.log

!!! Attention : comme le programme tente de désassembler les sections, il aura tendance à chercher des instructions assembleurs et à les afficher. Ne pas tenir compte de ces « fausses  instructions » dans les zones de données (.data et .bss).

 

Le Startup file

Le Startup file est l’un des fichiers les plus importants lorsque l’on fait du Bare Metal. Il doit être construit avec la plus grande vigilance sous peine de crash ou de carte ne démarrant pas donc devenue inutilisable.

Ses fonctions sont les suivantes :

  • Il doit définir le bon environnement pour le code à venir (main()).
  • Il doit se lancer avant le main() puis lancer lui-même le main().
  • Certaines parties de son code peuvent être dépendantes de la target utilisée (processor).
  • Il doit veiller au bon emplacement de la table de vecteurs dans le code comme l’exige les processeurs ARM Cortex-M.
  • Il doit faire attention à la ré-initialisation de pile.
  • Il est responsable de l’initialisation des sections .data et .bss dans la SRAM.

Pour créer un Startup file valide, vous devez respecter les règles suivantes:

  1. Créer une table de vecteurs pour le microcontrôleur (spécifique).
  2. Exécuter un code permettant l’initialisation des sections .data et .bss.
  3. Lancer le main().

Alors, c’est parti pour la création de votre premier Startup file ? Dans les lignes suivantes, nous allons prendre comme exemple la carte STM32 BluePill à base de MCU STM32F103C8T6.

STM32 Bluepill

Voici maintenant les étapes de construction de notre Startup file :

  1. Placez vous dans votre répertoire de travail et créez un fichier vide : $ touch stm32_startup.c
  2. Examinons une table de vecteurs pour un MCU STM32F103C8T6 :

    VT Bluepill

    Selon ce schéma, la table de vecteurs fait 304 octets au total : 4 octets pour le MSP, 15 x 4 octets pour les 15 premières IRQ système et 4 x 60 octets pour les IRQ spéciales.

    Chaque MCU a sa propre table de vecteurs. Par exemple le STM32F407VGTx (sur les cartes Nucleo Discovery) possède la même structure pour le premier vecteur (MSP) et les 15 vecteurs systèmes suivants mais possède ensuite 82 IRQ alors que le STM32F446RE (sur carte Nucleo) en possède 97. Nous traiterons le F446RE plus loin dans la partie « Mise en oeuvre ».

    Pour y voir plus clair, cherchez votre MCU sur le site ST Microelectronics puis récupérez le Reference Manual dans la section Documentation et enfin cherchez Vector table à l’intérieur de ce document. Cela vous donnera toutes les informations nécessaires pour la construction du fichier Startup file. Nous en aurons besoin très vite. Voici le début de la table pour le F103C8T6 (p204 du document) :Vectors table

  3. Au tout début du fichier Startup file, ajoutez #include <stdint.h>. L'include <stdint.h> contient tout un ensemble de macros définissant des types d'entiers particuliers (uint32_t par exemple). Créez un tableau devant contenir les adresses d’IRQ (de 0 à 59 pour dans notre exemple): uint32_t vectors[] ={};
  4. Indiquez au compilateur que ce tableau ne doit pas se trouver dans la zone .data mais dans une autre zone (voir __attribute__ plus loin).
  5. Déclarez ensuite quelques #define concernant la mémoire et la stack (exemple ici pour la BluePill qui possède 20 kb de SRAM). Notez également l’adresse de départ pour la SRAM (0x20000000):
    #define SRAM_START 0x20000000U
    #define SRAM_SIZE  (20U * 1024U)            /* 20 kb       */
    #define SRAM_END   ((SRAM_START) + (SRAM_SIZE))
    
    #define STACK_START SRAM_END
  6. Créez l’entête de la fonction Reset_Handler() puis son corps après le tableau des vecteurs. C’est cette fonction qui sera exécutée en premier et sera responsable de lancer le main().
  7. Commencez par remplir la table de vecteurs (STACK_START et Reset_Handler). On obtient ceci (nous verrons le contenu complet plus loin):
    #include <stdint.h>
    
    #define SRAM_START 0x20000000U
    #define SRAM_SIZE  (20U * 1024U)            /* 20 kb       */
    #define SRAM_END   ((SRAM_START) + (SRAM_SIZE))
    
    #define STACK_START SRAM_END
    
    void Reset_Handler(void);
    
    uint32_t vectors[]={
        STACK_START,
        (uint32_t) &Reset_Handler,
    }; 
    
    void Reset_Handler(void) 
    {
    }
  8. Avant d’aller plus loin, essayez de compiler ce fichier pour vérifier que tout va bien. Pour cela, on modifie le Makefile pour tester la compilation. On en profite pour rajouter l’option ‑Wall et une entrée clean.
    CC=arm-none-eabi-gcc
    MACH=cortex-m3
    CFLAGS= -c -mcpu=$(MACH) -mthumb -std=gnu11 -O0 -Wall
    
    all: main.o stm32_startup.o
    
    main.o: main.c
    	$(CC) $(CFLAGS) $^ -o $@
    	
    stm32_startup.o: stm32_startup.c
    	$(CC) $(CFLAGS) $^ -o $@
    
    clean:
    	rm -rf *.o *.elf
  9. Nous allons maintenant utiliser le mot-clé __attribute__ pour spécifier au compilateur de mettre le tableau vectors à un autre endroit que dans .data (qui est la section par défaut).

    Rajoutez le mot-clé dans la définition de la variable. Vous pouvez utiliser n’importe quel nom :

    ...
    
    uint32_t vectors[] __attribute__ ((section(”.isr_vector”))) ={ 
        STACK_START,
        (uint32_t) &Reset_Handler,
        (uint32_t) &NMI_Handler,
    }; 
  10. Créez ensuite l’entrée pour le handler NMI (voir le Reference manual). On utilise les alias et le handler par défaut Default_Handler() pour éviter d’avoir à créer 60 handlers. À nouveau, c’est le mot-clé __attribute__ qui va nous aider.
    #include <stdint.h>
    
    #define SRAM_START 0x20000000U
    #define SRAM_SIZE  (20U * 1024U)            /* 20 kb       */
    #define SRAM_END   ((SRAM_START) + (SRAM_SIZE))
    
    #define STACK_START SRAM_END
    
    void Reset_Handler(void);
    void NMI_Handler(void)       __attribute__ ((alias(“Default_Handler”)));
    
    uint32_t vectors[] __attribute__ ((section(“.isr_vector”)))={
        STACK_START,
        (uint32_t) &Reset_Handler,
        (uint32_t) &NMI_Handler,
    }; 
    
    void Reset_Handler(void) {
    }
    
    void Default_Handler(void) {
        while (1);
    }

    Grâce à cette définition, le NMI_Handler() pointera vers le Default_Handler(), routine par défaut qui ne fait rien de spécial. C’est une boucle while(1) infinie (voir code ci-dessus).

  11. De la même façon, rajoutez ensuite l’entrée suivante de la table, le HardFault().
    #include <stdint.h>
    
    #define SRAM_START 0x20000000U
    #define SRAM_SIZE  (20U * 1024U)            /* 20 kb       */
    #define SRAM_END   ((SRAM_START) + (SRAM_SIZE))
    
    #define STACK_START SRAM_END
    
    void Reset_Handler(void);
    void NMI_Handler(void)       __attribute__ ((alias(“Default_Handler”)));
    void HardFault_Handler(void) __attribute__ ((alias(“Default_Handler”)));
    
    uint32_t vectors[] __attribute__ ((section(“.isr_vector”)))={
        STACK_START,
        (uint32_t) &Reset_Handler,
        (uint32_t) &NMI_Handler,
        (uint32_t) &HardFault_Handler,
    }; 
    
    void Reset_Handler(void) {
    }
    
    void Default_Handler(void) {
        while (1);
    }
  12. Rajoutez ensuite le mot-clé weak pour permettre l’écriture de futurs handlers à la place du Default_Handler(). Si le nom de la fonction existe dans le code, elle sera prise en compte et dans le cas contraire, ce sera l’alias (donc DefaultHandler()).
    #include <stdint.h>
    
    #define SRAM_START 0x20000000U
    #define SRAM_SIZE  (20U * 1024U)            /* 20 kb       */
    #define SRAM_END   ((SRAM_START) + (SRAM_SIZE))
    #define STACK_START SRAM_END
    
    void Reset_handler(void);
    void NMI_Handler(void)       __attribute__ ((weak, alias(“Default_Handler)));
    void HardFault_Handler(void) __attribute__ ((weak, alias(“Default_Handler)));
    
    uint32_t vectors[] __attribute__ ((section(“.isr_vector”)))={
        STACK_START,
        (uint32_t) &Reset_Handler,
        (uint32_t) &NMI_Handler,
        (uint32_t) &HardFault_Handler,
    }; 
    
    void Reset_Handler(void) {
    }
    
    void Default_Handler(void) {
        while (1);
    }
  13. Continuez en créant toutes les autres fonctions de handlers (et oui, les 60 !!!). Les parties contenant Reserved (Reference manual) peuvent se remplir avec un simple 0 (zéro) dans le tableau vectors[]. Toutes les fonctions (à part le Reset_handler() si vous avez bien suivi) pointent sur Defaut_Handler().

    Voici un détail du fichier résultant :

    ...
    
    uint32_t vectors[] __attribute__((section(".isr_vector"))) = {
    	STACK_START,
    	(uint32_t) &Reset_Handler,
    	(uint32_t) &NMI_Handler,
    	(uint32_t) &HardFault_Handler,
    	(uint32_t) &MemManage_Handler,
    	(uint32_t) &BusFault_Handler,
    	(uint32_t) &UsageFault_Handler,
    	0,
    	0,
    	0,
    	0,
    	(uint32_t) &SVC_Handler,
    	(uint32_t) &DebugMon_Handler,
    	0,
           ...
           (uint32_t) &TIM7,
           (uint32_t) &DMA2_Channel1,
           (uint32_t) &DMA2_Channel2
           (uint32_t) &DMA2_Channel3,
           (uint32_t) &DMA2_Channel4₅
    }
    
    ...
  14. L’étape suivante est importante. Elle sert à définir le contenu du Reset_Handler(). Il doit effectuer les actions suivantes :
    • Copier la section .data dans la SRAM.
    • Initialiser le contenu de .bss à 0 dans la SRAM.
    • Appeler la fonction init() de la standard library (si elle est utilisée).
    • Appeler le main().

     

    Sur ce schéma nous voyons des informations importantes : le déplacement de la zone .data vers la SRAM et la présence de la zone .bss dans la SRAM.

    Resethandler()

 

Nous verrons cela un peu plus loin dans le chapitre consacré au Reset_Handler().

Vous pouvez récupérer un Startup file complet pour Bluepill (ici) en effectuant un clic-droit/Enregistrez la cible du lien sous...

 

Maintenant que nous avons défini toute la table de vecteurs, voyons comment le processeur va démarrer :

  1. Après un reset, le PC (Program Counter) est chargé avec l’adresse 0x00000000 puis le contenu de cette adresse est lu et stocké dans le registre interne MSP. Il s’agit du Master Stack Pointer, l’endroit en mémoire où commence la pile. Sa valeur se trouve dans la première case de la table de vecteurs, comme défini plus haut.
  2. Ensuite, le PC est chargé avec l’adresse contenue en 0x00000004 . Selon la table de vecteur, il s’agit de l’adresse du Reset_Handler() .
  3. Enfin, la main est donnée au Reset_Handler() qui pourra commencer son travail.

Signalons pour finir que cette procédure est gravée en dur dans certains processeurs (M3, M4) mais que pour d’autres l’opération de lecture de la valeur du MSP devra se faire avec quelques lignes d’assembleur ou de C dans une routine appelée par le Reset_Handler().

 

Le Linker script

Le Linker script est un autre fichier très important en Bare Metal. Voici comment définir ce fichier :

  • C’est un fichier texte qui explique comment les différentes sections du fichier objet doivent être rassemblées pour créer un fichier de sortie.
  • Il est en charge de déterminer les adresses absolues des différentes sections en faisant référence aux informations mentionnées dans le Linker script.
  • Il contient également des définitions de zones, des adresses et tailles mémoires.
  • Il est écrit en commandes de linker GNU.
  • L’extension de fichier est .ld .
  • Le Linker script est fourni au linker par l’option -T .

Les principales commande du Linker script seront :

  • ENTRY
  • MEMORY
  • SECTIONS
  • KEEP
  • ALIGN
  • AT>

La commande ENTRY

Cette commande est utilisée pour définir les adresses des points d’entrée (Entry point address) dans le header du fichier final ELF.

Dans notre cas, le Reset_Handler() est le point d’entrée applicatif, le premier code exécuté après le reset du processeur et le debugger utilisera cette information pour localiser la première instruction à exécuter. Cette commande n’est pas obligatoire mais sera indispensable pour le debug de fichier ELF par GDB.

Syntaxe:

ENTRY(_symbol_name_)
 

Exemple:

ENTRY(Reset_Handler)
 

La commande MEMORY

Cette commande permet de décrire les différentes zones mémoires présentes sur notre target, leur adresse de début ainsi que leur taille. Ensuite, le linker va utiliser ces informations pour assigner des adresses à des sections. On appelle cela la relocation. Ensuite, il va utiliser ces valeurs pour calculer la taille du code et de la partie mémoire afin d’alerter l’utilisateur si un dépassement de mémoire et engendré. La commande Memory permet également une gestion fine de la mémoire et sert à positionner les sections dans les zones mémoire.

Généralement, un linker script contiendra une seule commande MEMORY. Attention à bien respecter les espaces dans la commande.

Syntaxe:

MEMORY
{
	name (attr):ORIGIN =origin, LENGTH =len
}
 

Avec:

  • name : label permettant de référencer une zone mémoire dans la suite du fichier.
  • attr  : attribut de la zone (r: read, w: write, x: code exécutable, a: section d’allocation, i: section d’initialisation et ! servant de not pour ces attributs).
  • origin  : début de la zone.
  • Len  : longueur de la zone.

Exemple:

Prenons en exemple, à nouveau le STM32F103C8T6. Attention cependant car certaines BluePill sont données pour 64 kb de Flash alors qu’il est possible d’aller jusqu’à 128 kb.

BluePill resume

Cela donne :

MEMORY
{
	FLASH(rx):ORIGIN =0x08000000,LENGTH =64K
	SRAM(rwx):ORIGIN =0x20000000,LENGTH =20K
} 
 

Commençons à remplir le fichier Linker script en créant un fichier vide : stm32_ls.ld. Rajoutez les commandes ENTRY et MEMORY pour obtenir :

ENTRY(Reset_Handler)

MEMORY
{
	FLASH(rx):ORIGIN =0x08000000,LENGTH =64K
	SRAM(rwx):ORIGIN =0x20000000,LENGTH =20K
}
 

La commande SECTIONS

Cette commande est utilisée pour créer différentes sections de sortie dans le fichier ELF généré ainsi que pour regrouper les sections des différents fichiers .o . Elle contrôle également l’ordre d’apparition des sections de sortie comprises dans le fichier ELF. Enfin, en utilisant cette commande, vous pourrez placer une section dans une zone mémoire particulière. Par exemple, vous demanderez au linker de placer la section .text dans la zone FLASH décrite juste avant avec la commande MEMORY.

Syntaxe:

SECTIONS
{   /* This section should include .text section of all input files */
    .text
    {
        /* Merge all .isr_vector section of all input files */
        /* Merge all .text section of all input files */
        /* Merge all .rodata section of all input files */
    }> (vma) AT> (lma)

    /* This section should include .data section of all input files */
    .data
    {
        /* Merge all .data section of all input files */
        /* Merge all .text section of all input files */
        /* Merge all .rodata section of all input files */
    }> (vma) AT> (lma)
}
 

Les parties en bleu représentent des instructions d’adressage à destination du linker et du locator pour leur permettre de placer correctement ces sections en mémoire. Les mots-clés vma et lma signifient virtual memory address (emplacement final des données) et load memory address (emplacement original des données).

Dans notre exemple, les sections .text iront toutes dans la zone FLASH. Cela donne :

SECTIONS
{
    .text
    {
        *(.isr_vector)
        *(.text)
        *(.rodata) 
    }> FLASH AT> FLASH
}
 

Remarque : si vma est égal à lma, il est possible d’utiliser une syntaxe plus courte :

SECTIONS
{
    .text
    {
        *(.isr_vector)
        *(.text)
        *(.rodata) 
    }> FLASH
}
 

Ayez également toujours en tête le schéma ci-dessous qui vous donne l’ordre à respecter pour la commande SECTIONS. Dans l’ordre (en partant par le bas), nous avons la table de vecteurs, la section .text, la section .rodata et la section .data.

Memory mapping

Continuons avec la section .data. Cela donne :


SECTIONS
{   
    ...

    .data
    {
        *(.data)
    }> SRAM AT> FLASH

    ...
}
 

Selon le schéma ci-dessous, nous voyons que la zone .data doit être copiée de la FLASH (load address) vers la SRAM (virtual address ou absolute address). C’est ce que nous retrouvons dans la syntaxe de la commande SECTIONS > SRAM AT> FLASH

Data move

Nous continuons avec la zone non-initialisée (la .bss). Dans ce cas, seule la SRAM va contenir ces données. Elle se place uniquement dans la SRAM (voir schéma ci-dessus). Cela donne :

SECTIONS
{
    ... 

    .bss
    {
        *(.bss)
    }> SRAM

    ...
}
 

La notion de Location counter (symbole .)

Le location counter est un symbole spécial du linker représenté par le point (.). Ce symbole est automatiquement mis-à-jour avec des adresses par le linker. Le symbole est utilisé à l’intérieur du Linker script pour définir et calculer les frontières entre sections. Les location counters se trouvent uniquement à l’intérieur de la commande SECTIONS et sont modifiés automatiquement en fonction des tailles des sections. Un symbole est un nom pointant sur une adresse. Attention à ne pas voir cela comme une variable C, par exemple.

Remarque : dans le Linker script, on ne définit pas des variables mais des entrées dans la table de symboles.

Quelques exemples :

Un location counter (.):

 
SECTIONS
{
    ...

    .text:
    {
        *(.isr_vector)
        *(.text)
        *(.rodata) 
        end_of_text = .;       /* Store the updated location counter value for */
                               /* ‘end_of_text’ symbol.                        */
    }> FLASH

    ...
}
  

Un symbole :

 
SECTIONS
{
    ...

    .data:
    {
        start_of_data = 0x20000000 .;   /* Store the symbol start_of_data */                                    
                                        /* with 0x20000000                */
        *(.data) 
        
    }> FLASH
    
    ...
}
 

Nous pouvons maintenant créer des boundaries (frontières) dans notre linker script. Voici le contenu (encore provisoire) du Linker script pour notre exemple :

ENTRY(Reset_Handler)
MEMORY
{
	FLASH(rx):ORIGIN =0x08000000,LENGTH =64K
	SRAM(rwx):ORIGIN =0x20000000,LENGTH =20K
}

SECTIONS
{
	.text :
	{
		*(.isr_vector)
		*(.text)
		*(.rodata)
		_etext = .;
	}> FLASH
	
      _la_data = LOADADDR(.data);

	.data :
	{
		_sdata = .;
		*.(.data)
		_edata = .;
	}> SRAM AT> FLASH

	.bss :
	{
		_sbss = .;
		*.(.bss)
		_ebss = .;
	}> SRAM
}
 

Remarque : un location counter (symbole .) fait toujours référence à une adresse en vma.

La fonction LOADADDR permet de faire référence à une adresse présente en lma. Ici _la_data pointe sur le début de la zone .data présente en Flash. Ce sera la source utilisée dans le Reset_Handler(). Nous détaillerons cela plus loin dans le chapitre concerné.

La commande ALIGN

Cette commande est utilisée pour aligner les sections sur des adresses multiples de 4, par exemple. On peut trouver ce genre de syntaxe dans les sections. Exemple :

 
SECTIONS
{
    .text:
    {
        *(.isr_vector)
        *(.text)
        *(.rodata) 
        . = ALIGN(4);
        end_of_text = .;       /* Store the updated location counter value for */
                               /* ‘end_of_data’ symbol.                        */
    }> FLASH
}
 

Essayez toujours d’aligner les différentes sections avant leur fin. Dans notre cas, nous allons aligner les sections .text, .data et .bss. Nous obtenons finalement le fichier startup complet:

ENTRY(Reset_Handler)

MEMORY
{
	FLASH(rx):ORIGIN =0x08000000,LENGTH =64K
	SRAM(rwx):ORIGIN =0x20000000,LENGTH =20K
}

SECTIONS
{
	.text :
	{
		*(.isr_vector)
		*(.text)
		*(.rodata)
		. = ALIGN(4);
		_etext = .;
	}> FLASH
	
	_la_data = LOADADDR(.data);

	.data :
	{
		_sdata = .;
		*.(.data)
		. = ALIGN(4);
		_edata = .;
	}> SRAM AT> FLASH

	.bss :
	{
		_sbss = .;
		*.(.bss)
		. = ALIGN(4);
		_ebss = .;
	}> SRAM
}
 

L’édition de liens

Essayons maintenant d’effectuer une édition de lien pour notre exemple afin de créer un fichier exécutable. L’éditeur de lien est encore arm-none-eabi-gcc (et oui, il sait tout faire !!).

Pour lancer une édition de lien, on utilise la commande suivante :

$ arm-none-eabi-gcc -T stm32_ls.ld -nostdlib *.o -o final.elf

Mais comme nous sommes des gens organisés, nous allons placer cette étape dans le Makefile (parties en rouge) et optimiser les règles de compilations:

CC=arm-none-eabi-gcc
MACH=cortex-m4
CFLAGS= -c -mcpu=$(MACH) -mthumb -std=gnu11 -O0 -Wall
LDFLAGS= -T stm32_ls.ld -nostdlibf

all: final.elf

%.o: %.c
	$(CC) $(CFLAGS) $^ -o $@

final.elf: main.o stm32_startup.o
	$(CC) $(LDFLAGS) $^ -o $@

clean:
	rm -rf *.o *.elf

Il est possible pendant l’édition de lien de créer un fichier .map. Pour cela, il faut rajouter une option à LDFLAGS dans le Makefile. Le fichier .map est très intéressant pour distinguer les adresses mémoires des différents éléments.

 
...
LDFLAGS= -T stm32_ls.ld -nostdlib -Wl,-Map=final.map

Attention à la syntaxe pour la génération du fichier map. L’option -Wl (avec l comme link) sert à passer des commande à la partie éditeur de liens de arm-none-eabi-gcc.

Le Reset_Handler()

Il est temps maintenant de remplir la partie manquante du Reset_Handler(). Rappelons ce qu’il doit faire :

  • Copier la section .data dans la SRAM.
  • Initialiser le contenu de .bss à 0 dans la SRAM.
  • Appeler la fonction init() de la standard library (si elle est utilisée).
  • Appeler le main().

Pour cela, il faut tout d’abord rajouter des références externes _etext, _sdata, _edata, _sbss et _ebss aux variables de boundaries (frontières) au début du stm32_startup.c juste après les #define :

...

#define STACK_START   SRAM_END

extern uint32_t _etext;
extern uint32_t _sdata;
extern uint32_t _edata;
extern uint32_t _sbss;
extern uint32_t _ebss;
...
 

Petite parenthèse intéressante: le binaire arm-none-eabi-nm permet ici de voir les symboles extraits :

 
$ arm-none-eabi-nm final.elf
20000058 B __bss_end__
20000004 B __bss_start__
20000058 B __end__
20000058 B _ebss
20000004 D _edata
080007e8 T _etext
080007e8 A _la_data
20000004 B _sbss
20000000 D _sdata
08000750 W ADC_IRQHandler
...
 

Occupons-nous maintenant du contenu de la fonction Reset_Handler(). Passons en revue les différentes tâches de cette fonction.

Copie de la section .data dans la SRAM

Grâce aux externals pointant sur les zones en mémoire, c’est très simple. Le code à utiliser est le suivant :

void Reset_Handler(void) {
  /* Copy .data section to SRAM   */ 
  size = (uint32_t) &_edata - (uint32_t) &_sdata;

  uint8_t *pSrc = (uint8_t *) &_etext;     /* Flash to ...   */
  uint8_t *pDst = (uint8_t *) &_la_data;   /* ... SRAM       */

  for (uint32_t i; i< size; i++) {
    *pDst++ = *pSrc++;
  }
}
 

Quelques remarques :

  • Le calcul de la taille est simple (fin - début).
  • La source à copier (pSrc) se trouve placée à la suite de la zone .text dans la Flash. En effet, en Flash .data est à la suite de .text.
  • La destination est en SRAM au début de la zone .data (ici _la_data).

Initialiser le contenu de .bss à zéro dans la SRAM

Un code encore plus simple :

void Reset_Handler(void) {
  ...

  /* Initilaze the .bss zone to 0   */ 
  uint32_t size = (uint32_t) &_ebss - (uint32_t) &_sbss;

  pDst = (uint8_t *) &_sbss;        /* Into SRAM            */

  for (uint32_t i; i < size; i++) {
    *pDst++ = 0;
  }
}

Appel au code d’initialisation de la libc (si besoin)

Très simple :

void Reset_Handler(void) {
  ...

  __libc_init_array();

   ...
}
  

Appeler le main()

On ne peut pas faire plus simple :

void Reset_Handler(void) {
  ...

  main();

  ...
}
 

Un exemple de programme main() (et oui, il faut bien faire quelque chose) :

Avant de passer à la dernière étape (le téléversement sur la carte), nous devons mettre un peu de code dans le main.c pour faire clignoter la led de la BluePill:

#include <stdint.h>

// register address
#define RCC_BASE 0x40021000
#define GPIOC_BASE 0x40011000
#define RCC_APB2ENR *(volatile uint32_t *)(RCC_BASE + 0x18)
#define GPIOC_CRH *(volatile uint32_t *)(GPIOC_BASE + 0x04)
#define GPIOC_ODR *(volatile uint32_t *)(GPIOC_BASE + 0x0C)

// bit fields
#define RCC_IOPCEN (1<<4)
#define GPIOC13 (1UL<<13)


int main(void)
{
    RCC_APB2ENR |= RCC_IOPCEN;       /* Clock configuration  */
    GPIOC_CRH &= 0xFF0FFFFF;         /* Port configuration   */
    GPIOC_CRH |= 0x00200000;

    while(1)
    {
        GPIOC_ODR |= GPIOC13;        /* GPIOC13 is ON        */
        for (int i = 0; i < 500000; i++); /* arbitrary delay */

        GPIOC_ODR &= ~GPIOC13;       /* GPIOC13 is OFF       */
        for (int i = 0; i < 500000; i++); /* arbitrary delay */
    }
}
 

Un code un peu étrange car on s’adresse directement aux registres internes du micro-contrôleur. Ce n’est pas le sujet de cet article mais certaines parties seront détaillées plus loin (section Mise en oeuvre).

Une fois tout cela fait, vous pouvez compiler l’ensemble pour vérifier qu’il n’y a pas d’erreur. Si tout va bien, on passe au download du fichier ELF dans la carte grâce à OpenOCD.

OpenOCD

Il est temps de passer enfin aux choses sérieuses : le téléchargement du code sur la carte. Dans notre cas, il s’agit de télécharger le fichier ELF sur la carte (dans la mémoire flash) puis d’en effectuer le debug.

Nous allons effectuer les branchements suivants :

OpenOCD connection

Le logiciel OpenOCD tourne sur une machine, reliée à un circuit (le debug adapter) chargé de convertir les données destinées à (ou venant de) la carte. Ce circuit est relié à la carte.

Nous allons décrire les éléments constituant notre labo.

Le logiciel OpenOCD :

  • Il est utilisé pour programmer une carte et en faire le debug.
  • Il est gratuit et utilise GDB pour programmer, debugger et analyser le code.
  • Il supporte un grand nombre de cartes et de microprocesseurs.
  • Il est compatible avec presque tous les adaptateurs.
  • Le debug via GBD est très riche (ARM, Cortex, Xscale, Energy Micro, Intel Quark, etc).
  • Il est capable de flasher presque toutes les mémoires.

L’adaptateur permet :

  • L’accès à l’interface de debug présent sur la carte et utilisant le protocole SWD ou JTAG.
  • La conversion des protocoles (paquets USB convertit au format compris par la carte.
  • Le téléchargement et le debug.
  • Le traçage des instructions à la volée.

Parmi les plus connus, nous trouvons le très fameux ST-Link, présent d’ailleurs sur toutes les cartes Nucleo (ci-dessous situé au dessus de la ligne de pointillés rouges). La connexion se fait alors uniquement par un port USB.

Nucleo and ST-Link

Ensuite, le principe de connexion est le suivant : une fois lancé et connecté à la carte, OpenOCD crée deux serveurs (telnet et GDB) permettant à un utilisateur d’envoyer des commandes à la carte.

OpenOCD connection

Voici maintenant les étapes nécessaires au debug :

  1. Sous Debian/Linux, tapez sudo apt install openocd.
  2. Sous Windows, téléchargez et installez OpenOCD (GNU OpenOCD dans Google).
  3. Rajoutez un chemin dans le PATH vers le binaire openocd, si besoin.
  4. Installez un client telnet (Putty fait bien l’affaire) ou un client GDB (MinGW sous Windows est très bien : https://sourceforge.net/projects/mingw/postdownload).
  5. Lancez OpenOCD en précisant le fichier faisant référence à la carte.
  6. Lancez des commandes via Putty ou GDB.

La bonne pratique est de rajouter une target load dans le Makefile prenant en charge le lancement d’OpenOCD :

...

load:
    openocd -f board/bluepill.cfg

...
 
 

Le fichier relatif à la carte se trouve dans l’arborescence d’OpenOCD, sous le répertoire script\board, par exemple st_nucleo_f4.cfg pour la Nucleo-F446RE. Pour la BluePill, il est nécessaire de créer un fichier. Nommez le bluepill.cfg et déposez-le dans le répertoire board (/usr/share/openocd/script/board):

source [find interface/stlink.cfg]

transport select hla_swd

source [find target/stm32f1x.cfg]

#reset_config srst_only

reset_config none separate

#Enable Trace output in open OCD
#Not tested yet
#tpiu config external uart off 72000000 2000000

Le lancement de la commande make load doit effectuer la connexion à la carte tout en ouvrant les ports 4444 (telnet) et 3333 (gdb) :

$ make load
openocd -f board/bluepill.cfg
xPack OpenOCD, x86_64 Open On-Chip Debugger 0.10.0+dev (2020-10-13-17:29)
Licensed under GNU GPL v2
For bug reports, read
        http://openocd.org/doc/doxygen/bugs.html
WARNING: interface/stlink-v2.cfg is deprecated, please switch to interface/stlink.cfg
Info : The selected transport took over low-level target control. The results might differ compared to plain JTAG/SWD
none separate

Info : Listening on port 6666 for tcl connections
Info : Listening on port 4444 for telnet connections
Info : clock speed 1000 kHz
Info : STLINK V2J35S7 (API v2) VID:PID 0483:3748
Info : Target voltage: 3.143445
Info : stm32f1x.cpu: hardware has 6 breakpoints, 4 watchpoints
Info : starting gdb server for stm32f1x.cpu on 3333
Info : Listening on port 3333 for gdb connections
 

À partir de maintenant, nous allons lancer le debugger GDB. S’il n’est pas installé, tapez (Debian.Linux):

sudo apt install gdb-arm-none-eabi

Lancez GDB :

$ arm-none-eabi-gdb
 

Le prompt (gdb) s’affiche. La connexion à la session OpenOCD doit s’établir avec la commande suivante :

(gdb) target extended-remote localhost:3333
 

Vérifiez cela dans la première fenêtre (celle d’OpenOCD) :

Info : accepting 'gdb' connection on tcp/3333
 

Avant toutes choses, lancez un reset init sous GDB. Pour information, toutes les commandes GDB doivent être précédées de monitor :

(gdb) monitor reset init
 

Après cela, vous pouvez télécharger le fichier exécutable final.elf:

(gdb) monitor flash write_image erase final.elf
wrote 2048 bytes from file final.elf in 0.179967s (11.113 KiB/s)
 

Pour examiner le code et en faire du debug, il faut redémarrer la carte puis en stopper l’exécution de suite. Bien que ce ne sera pas le sujet de cet article, vous aurez malgré tout, un bel outil de travail pour vous lancer dans cette aventure. Lancez et stoppez la carte avec cette commande :

(gdb) monitor reset halt
 

Ensuite, pour lancer le code, il suffit de lancer un resume :

(gdb) monitor resume
 

Pour redémarrer simplement l’exécution du code après le flashage, il faut faire un reset :

(gdb) monitor reset
 

Si tout est bon, vous devriez voir la led clignoter. Si cela ne marche pas, il s’agit sans doute d’une toute petite erreur car en Bare Metal, l’erreur n’est pas permise. Revoyez tous vos fichiers un par un.

Si votre led clignote, alors bravo !

Mise en œuvre sur BluePill

Nous allons détailler un peu plus le main.c lié à la carte BluePill. Voici, à nouveau, le code exécuté:

#include <stdint.h>

// register address
#define RCC_BASE 0x40021000
#define GPIOC_BASE 0x40011000
#define RCC_APB2ENR *(volatile uint32_t *)(RCC_BASE + 0x18)
#define GPIOC_CRH *(volatile uint32_t *)(GPIOC_BASE + 0x04)
#define GPIOC_ODR *(volatile uint32_t *)(GPIOC_BASE + 0x0C)

// bit fields
#define RCC_IOPCEN (1<<4)
#define GPIOC13 (1UL<<13)

int main(void)
{
    RCC_APB2ENR |= RCC_IOPCEN;       /* Clock configuration  */
    GPIOC_CRH &= 0xFF0FFFFF;         /* Port configuration   */
    GPIOC_CRH |= 0x00200000;

    while(1)
    {
        GPIOC_ODR |= GPIOC13;        /* GPIOC13 is ON        */
        for (int i = 0; i < 500000; i++); /* arbitrary delay */

        GPIOC_ODR &= ~GPIOC13;       /* GPIOC13 is OFF       */
        for (int i = 0; i < 500000; i++); /* arbitrary delay */
    }
}
 

Revenons rapidement sur la façon dont il fonctionne. Ici, nous nous adressons directement au registres internes du micro-contrôleur. Ces registres se retrouvent dans la documentation Reference Manual. Détaillons rapidement les différents registres :

RCC_BASE (d’adresse fixe 0x40021000). Il est utilisé pour contrôler les périphériques internes ainsi que pour distribuer les clocks et les signaux de reset. On le trouve dans le manuel à la page 50 sous le nom Reset and clock control RCC.

GPIOC_BASE (d’adresse fixe 0x40011000) fait référence au GPIO de port C. On le trouve dans la manuel à la page 51 puis 171.

RCC_APB2ENR se calcule par rapport au RCC_BASE (RCC_BASE + 0x18). Il sert à définir les horloges pour les périphériques. On le trouve à la page 112.

GPIOC_CRH se calcule par rapport au GPIOC_BASE (GPIOC_BASE + 0x04) . Il fait référence à la configuration des ports et on le trouve à la page 172.

GPIOC_ODR se calcule par rapport au GPIOC_BASE (GPIOC_BASE + 0x0C) . Il sert à configurer la sortie d’un port (Output Data Register) et on le trouve à la page 173.

Les champs de bits RCC_IOPCEN et GPIOC13 servent à adresser les bons emplacements dans les registres ci-dessus.

Les commentaires dans le code vous donneront des indications sur la marche à suivre. Notons que certaines HAL (comme libopencm3) fournissent des macros toutes faites pour effectuer la configuration et le setting des ports. Ici, c’est volontairement sans aucun artifice que j’ai voulu vous montrer cet exemple.

Mise en œuvre sur Nucleo-F446RE

Pour transformer tout cela en code compatible pour une autre carte (ici une Nucleo-F446RE), quelques petits ajustements sont nécessaires :

→ Fichier stm32_startup.c

Ici, il faut modifier la table de vecteurs. Une seule façon de faire. Récupérez le Reference Manual et mettez à jour la table de vecteurs. Elle contient 97 entrées pour les IRQ.

Vous pouvez récupérer un Startup file complet pour la Nucleo (ici) en effectuant un clic-droit/Enregistrez la cible du lien sous...

Modifiez ensuite la taille de la RAM au début du fichier :

...

#define SRAM_SIZE   (128U * 1024U) 					/* 128 KB */

...
 

→ Fichier stm32_ls.ld

Effectuez uniquement la modification des valeurs de la taille de la SRAM et de la FLASH dans la section MEMORY:

...

MEMORY
{
  FLASH(rx):ORIGIN =0x08000000,LENGTH =512K
  SRAM(rwx):ORIGIN =0x20000000,LENGTH =128K
}

...
 

→ Fichier main.c

Ici, il s’agit simplement de réajuster les valeurs des adresses des registres. Le Reference Manual est encore ton meilleur ami. Voici le fichier complet et modifié :

#include <stdint.h>

#define RCC   		0x40023800
#define GPIOA_BASE 	0x40020000

#define GPIOA_MODER 	*(volatile uint32_t *) (GPIOA_BASE + 0x0)
#define GPIOA_OTYPER 	*(volatile uint32_t *) (GPIOA_BASE + 0x04)
#define GPIOA_ODR  	*(volatile uint32_t *) (GPIOA_BASE + 0x14)
#define GPIOA_BSRR  	*(volatile uint32_t *) (GPIOA_BASE + 0x18)


#define RCC_AHB1ENR 	*(volatile uint32_t *) (RCC + 0x30)

#define PORTA 	(0)		                /* 0 = PORTA	                       */
#define LED_PIN    	(5)		        /* 5 = PIN5		               */

#define RCC_GPIOAEN 	(1 << PORTA)

int main(){

    RCC_AHB1ENR |= RCC_GPIOAEN;			/* Enable clock on PORTA	       */
	
    GPIOA_MODER  &= ~(0x3 << (LED_PIN*2));
    GPIOA_MODER  |=  (0x1 << (LED_PIN*2));
    GPIOA_OTYPER &= ~(1 << LED_PIN);
	
    GPIOA_ODR |= (1 << LED_PIN);		/* LED is ON				*/

    while(1){
	GPIOA_ODR ^= (1 << LED_PIN);		/* Toggle the led			*/
		
	for (int i = 0; i < 500000; i++); 	/* Arbitrary delay			*/
    }
}

→ Fichier Makefile

Rien de bien compliqué ici. Il suffit de changer la machine cible et le fichier lié à la carte, paramètre MACH et load:

...

MACH=cortex-m4

... 

load: 
    openocd -f board/st_nucleo_f4.cfg  

...

C’est tout. Relancez la compilation et téléversez le code, la led devrait clignoter sur la Nucleo-F446RE.

Conclusion de l'article

Bien que complexe et sensible, cette façon de programmer les microcontrôleurs est très gratifiante à bien des égards : l’impression d’être un véritable créateur et de donner vie à nos petites machines, le fait de mieux comprendre les choses en profondeur et enfin, l’audace de partir de ne presque rien et d’utiliser le strict nécessaire pour aboutir à un résultat concret. Tout cela me semblent être les plus belles victoires que nous venons d’accomplir.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée.