Bare-Metal STM32: Exploration des E / S mappées en mémoire et des scripts Linker

Dans le premier volet de cette série, nous avons examiné brièvement les étapes nécessaires pour qu’une application bare-metal s’exécute sur un microcontrôleur STM32. Bien que cela nous ait permis d’accéder rapidement aux choses juteuses, il y a deux éléments essentiels qui rendent un MCU si facile à utiliser. L’un se trouve du côté matériel, sous la forme d’E / S dites mappées en mémoire (entrée / sortie), l’autre est l’information contenue dans les fichiers qui sont passés à l’éditeur de liens lorsque nous construisons une image de firmware.

Le mappage de mémoire des registres périphériques matériels est un moyen simple de les rendre accessibles au cœur du processeur, car chaque registre est accessible en tant qu’adresse mémoire. C’est à la fois pratique lors de l’écriture du code du firmware, ainsi que pour les tests, car nous pouvons utiliser un mappage de mémoire spécifique pour les tests unitaires ou d’intégration.

Nous examinerons en profondeur cette méthode de test, ainsi que la manière dont ces fichiers de script de l’éditeur de liens sont connectés à la disposition de la mémoire.

C’est la mémoire jusqu’au bout

À l’instar de la philosophie d’UNIX «tout est un fichier», pour les microcontrôleurs Cortex-M, il est juste de dire que «tout est une adresse mémoire». Mapper des périphériques sur un espace mémoire plat est en fait une approche courante pour les systèmes informatiques. Même les systèmes Intel x86 ont utilisé cette approche, avec des périphériques ISA, PCI, SMBus, AGP et PCIe détectés au moment du démarrage et mappés dans l’espace d’adressage plat.

En passant, cette propriété a également conduit à la situation étrange sur les systèmes x86 32 bits où la limite d’espace d’adressage mémoire ~ 4 Go ne pouvait pas prendre en charge 4 Go de RAM, car la RAM de la carte vidéo serait également mappée dans l’espace d’adressage. Cela est devenu problématique car la VRAM sur les GPU augmentait au-delà de 512 Mo, et tout cela devait être mappé dans le même espace d’adressage.

Mais revenons aux microcontrôleurs. Les microcontrôleurs Cortex-M ont également un espace d’adressage 32 bits, de 0x0000 0000 à 0xFFFF FFFF:

Carte mémoire STM32F051 de sa fiche technique.

Par défaut, la mémoire Flash sur les microcontrôleurs STM32F0 commence à 0x0800 0000 et le début avec 0x0000 0000 est utilisé pour mapper sur le support de démarrage. Il s’agit de Flash par défaut, mais peut également être commuté pour mapper vers la RAM externe ou interne à l’aide des bits de configuration BOOT0 / 1:

Configuration du mode d’amorçage pour STM32F0xx (RM0091, chapitre 2.5).

Cela montre à quel point le mappage de la mémoire est flexible: sans avoir à changer le chargeur de démarrage de premier étage, la même adresse peut toujours être chargée au démarrage, le contenu de la zone de démarrage étant facilement commuté vers une source différente.

C’est le temps de liaison

Avant que le code compilé puisse être assemblé dans l’image finale du micrologiciel, l’outil de liaison doit savoir comment disposer les données ainsi que quelques autres détails, tels que le point d’entrée. Ces informations sont décrites dans un script de l’éditeur de liens, qui utilise une syntaxe que l’outil de l’éditeur de liens (généralement ld) comprend. Passons en revue le script de l’éditeur de liens pour la cible STM32F042 à titre d’exemple:


ENTRÉE (Reset_Handler)

Ceci spécifie le symbole de la section (fonction) qui sera placée dans le fichier binaire résultant comme début de la .text (code) section. Lorsque le MCU démarre, c’est le premier code qui sera exécuté lors du démarrage à partir de la mémoire Flash. Ici, nous ciblons le Reset_Handler fonction.


_empile = 0x20001800;

Ceci définit l’adresse de la fin de la pile (estack). La pile commence à 0x2000 0000 (démarrage de la SRAM) et augmente jusqu’à la limite indiquée. Avec 6 ko de SRAM (0x1800) sur le MCU STM32F042, cela signifie que la pile est autorisée à atteindre la taille de la totalité de la SRAM. Évidemment, cela ne laisserait aucun espace pour un tas d’allocation dynamique.


MÉMOIRE

Cette section définit les différentes régions de mémoire, ainsi que leurs autorisations, leur début et leur longueur. Pour le STM32F042, nous n’avons que deux régions, FLASH (lecture / exécution) et RAM (lecture / exécution / écriture), de 32K et 6K octets, respectivement.


SECTIONS

Cela définit les propriétés des sections de sortie individuelles. Cela détermine également l’ordre dans lequel les sections se retrouvent dans la mémoire Flash, ce qui pour notre MCU signifie que la table vectorielle et le code de démarrage similaire dans .isr_vector passe en premier, suivi du code du firmware dans .text et constantes dans .rodata.

Viennent ensuite les données initialisées (.data) et les données non initialisées (.bss) ainsi que quelques sections plus spécialisées. Finalement, le ._user_heap_stack partie, qui contient des informations permettant à l’éditeur de liens de vérifier qu’il y a suffisamment de RAM et de FLASH sur l’appareil pour notre code.

Lorsque nous ajoutons ensuite l’indicateur de temps de liaison --print-memory-usage à ld, nous pouvons voir quelque chose comme cette sortie lorsque les objets sont assemblés dans l’image ELF finale:

Memory region         Used Size  Region Size  %age Used
           FLASH:        9956 B        32 KB     30.38%
             RAM:        4008 B         6 KB     65.23%

Tests unitaires de mappage de mémoire

Jusqu’à présent, nous avons obtenu une assez bonne image de l’architecture de la mémoire des microcontrôleurs STM32 et de la manière dont notre code s’y adapte. Comme quiconque a déjà eu à écrire du code au niveau du registre sur un MCU peut probablement en témoigner, il peut être assez frustrant de passer par d’innombrables cycles d’écriture-flash-cassé-tweak-reflash-encore-cassé, même quand on peut lancer un débogueur exécuter ou une douzaine au problème.

Une approche que j’ai trouvée plutôt utile ici est de tester d’abord mon code par rapport à un test local pour voir si mon code écrit correctement les registres appropriés. Cela permet également l’intégration dans les systèmes CI / CD, où un test unitaire peut être exécuté et ensuite les valeurs de tous les registres comparées automatiquement.

À titre d’exemple, considérons le test de périphérique GPIO dans mon framework Nodate. Il utilise la classe GPIO comme on le ferait normalement dans un projet de firmware STM32, après quoi les registres du périphérique GPIO sont inspectés. Étant donné que ces tests ne s’exécutent pas sur un MCU STM32, il n’utilise évidemment pas la magie GDB distante sur du matériel réel.

Toutes les classes Nodate incluent un en-tête commun (common.h) qui comprend normalement les en-têtes spécifiques au périphérique. Au lieu de cela, un en-tête différent dans le même dossier de tests est inclus, qui définit les structures périphériques et les instructions de préprocesseur que le code Nodate utilise. Par exemple le périphérique GPIO sur STM32F0:


struct GPIO_TypeDef {
  __IO uint32_t MODER;        //!< GPIO port mode register,                     Address offset: 0x00      
  __IO uint32_t OTYPER;       //!< GPIO port output type register,              Address offset: 0x04      
  __IO uint32_t OSPEEDR;      //!< GPIO port output speed register,             Address offset: 0x08      
  __IO uint32_t PUPDR;        //!< GPIO port pull-up/pull-down register,        Address offset: 0x0C      
  __IO uint32_t IDR;          //!< GPIO port input data register,               Address offset: 0x10      
  __IO uint32_t ODR;          //!< GPIO port output data register,              Address offset: 0x14      
  __IO uint32_t BSRR;         //!< GPIO port bit set/reset register,      Address offset: 0x1A 
  __IO uint32_t LCKR;         //!< GPIO port configuration lock register,       Address offset: 0x1C      
  __IO uint32_t AFR[2];       //!< GPIO alternate function low register,  Address offset: 0x20-0x24 
  __IO uint32_t BRR;          //!< GPIO bit reset register,                     Address offset: 0x28      
};

Dans le associé common.cpp fichier source, des instances de ce type sont créées sur la pile, avec une référence de pointeur (par exemple GPIOA) étant rendu disponible globalement, comme cela se produirait autrement par les instructions du préprocesseur dans les en-têtes de périphérique fournis par ST. Ceux-ci placeraient ces instances périphériques à des décalages spécifiques dans la RAM, bien sûr, pour correspondre aux registres périphériques. Pour nos besoins, cela n’est pas pertinent, cependant, et simplifie considérablement notre code.


GPIO_TypeDef tGpioA;
GPIO_TypeDef* GPIOA = &tGpioA;

Une fois cela en place, le code du framework utilisera avec plaisir ces variables globales comme s’il s’agissait de décalages dans l’espace d’adressage d’un MCU, ce qui nous permettra de lire nos registres GPIO et de voir comment le code que nous testons après chaque exécution.

Définir le succès

En général, chaque registre est un champ de 32 bits. Le moyen le plus simple de valider le résultat du test consiste à utiliser le manuel de référence du MCU pour déterminer à l’avance la valeur que nous nous attendons à y lire à partir du champ entier non signé. Une simple comparaison d’entiers permettra alors à notre système de validation de cracher une réponse «fausse» ou «correcte». Bien qu’efficace, ce serait également assez inutile.

Bien qu’une «passe» soit agréable, on risque le piège de la taille du Grand Canyon pour les jeunes joueurs qui est souvent résumé comme «tous les tests verts, explosés en production». C’est-à-dire qu’il est impossible de dire avec certitude qu’un test (unitaire) spécifique est parfait, seulement qu’un problème n’a pas été trouvé encore. C’est là que la vérification manuelle est très utile, en particulier lorsque les cas de test deviennent plus volumineux et plus compliqués.

En outre, il est également essentiel de pouvoir obtenir une impression du résultat du test rejeté, avec quels paramètres d’entrée. Pour la plupart des tests que j’ai effectués jusqu’à présent, j’ai utilisé des impressions simples des valeurs de registre dans le terminal, que je pourrais ensuite mettre à côté des registres dans le manuel de référence pour une comparaison facile. Comme indiqué dans le fichier de test GPIO lié ci-dessus, cela se fait en utilisant le <bitset> En-tête STL:


std::cout << "GPIOA" << std::endl;
	std::cout << "MODER:  t" << std::bitset<32>(GPIOA->MODER) << std::endl;
	std::cout << "PUPDR:  t" << std::bitset<32>(GPIOA->PUPDR) << std::endl;
	std::cout << "OTYPER: t" << std::bitset<32>(GPIOA->OTYPER) << std::endl;
	std::cout << "OSPEEDR:t" << std::bitset<32>(GPIOA->OSPEEDR) << std::endl;
	std::cout << "IDR:    t" << std::bitset<32>(GPIOA->IDR) << std::endl;
	std::cout << "ODR:    t" << std::bitset<32>(GPIOA->ODR) << std::endl;

Cela convertit le uint32_t tapez dans un champ de bits qui est ensuite imprimé comme ceci:

GPIOA
MODER:          00000000000000000000000001000000
PUPDR:          00000000000000000000000001000100
OTYPER:         00000000000000000000000000000000
OSPEEDR:        00000000000000000000000000000000
IDR:            00000000000000000000000000000000
ODR:            00000000000000000000000000001000

On pourrait rendre cela un peu plus pratique à lire en le divisant en grignotages, mais cela restera ici comme un exercice pour le lecteur.

Emballer

Il y a une raison pour laquelle cet article se concentre principalement sur la famille STM32F0 de MCU STM32: leur hiérarchie de mémoire simple. Les familles de microcontrôleurs F4, F7 et H7 ont des cartes mémoire plus complexes. Cependant, les principes de base abordés dans cet article s’appliquent toujours.

La flexibilité des E / S mappées en mémoire devrait être assez claire à ce stade, ainsi que la facilité de leur intégration dans les systèmes de test et de validation. Si vous avez des conseils ou des conseils de votre part sur ce sujet ou sur d’autres sujets abordés dans l’article, n’hésitez pas à les laisser dans les commentaires.

François Zipponi
Je suis François Zipponi, éditorialiste pour le site 10-raisons.fr. J'ai commencé ma carrière de journaliste en 2004, et j'ai travaillé pour plusieurs médias français, dont le Monde et Libération. En 2016, j'ai rejoint 10-raisons.fr, un site innovant proposant des articles sous la forme « 10 raisons de... ». En tant qu'éditorialiste, je me suis engagé à fournir un contenu original et pertinent, abordant des sujets variés tels que la politique, l'économie, les sciences, l'histoire, etc. Je m'efforce de toujours traiter les sujets de façon objective et impartiale. Mes articles sont régulièrement partagés sur les réseaux sociaux et j'interviens dans des conférences et des tables rondes autour des thèmes abordés sur 10-raisons.fr.