Les plates-formes d'exécution industrielles sont souvent très chères et opaques, tant pour le matériel que pour le système d'exploitation. De ce fait, une vraie alternative s'ouvre au chercheur motivé : celle de créer une plate-forme d'expérimentation et d'enseignement complètement ouverte et abordable. Le prix à payer est un certain investissement en temps et des sensations fortes.
Introduction
L'objectif de cet article est d'expliquer comment un non-spécialiste de la conception de systèmes d'exploitation (OS) ou de la programmation bas-niveau - à savoir moi-même - peut développer un OS prototype en un temps acceptable, pour des besoins liés à la recherche et à l'enseignement. L'article commence par une section expliquant pourquoi j'ai choisi de développer un nouvel OS, et non pas en utiliser un existant. Ensuite, je décris brièvement la norme avionique ARINC 653, qui définit la structure de l'OS à construire. J'évoque ensuite des ressources à disposition (plate-forme Raspberry Pi, documentation, forums de discussion, exemples de code, outils) avant de décrire le travail de programmation et les plus importantes difficultés rencontrées.
1. Motivation
A priori, le système de contrôle embarqué d'un avion ou d'une fusée a peu de choses en commun avec une carte Raspberry Pi. Dans ces systèmes, matériel et logiciel sont conçus spécialement pour la tâche de contrôle à réaliser. Ils utilisent des bus et des réseaux de communications dédiés à l'avionique, suivant des normes ayant des noms exotiques comme ARINC 429, ARINC 664 (AFDX), MIL-STD-1553, SAE AS6802 (TTEthernet). Les calculateurs, qui sont souvent des « single-board computers (SBC) » produits en petites séries. Ces calculateurs font tourner du logiciel critique soit directement sur le matériel à l'aide d'un séquenceur minimal (approche dite « bare metal »), soit en utilisant des OS dédiés aux systèmes critiques temps réel, comme les diverses variétés de VxWorks, QNX, ou les OS respectant la norme ARINC 653 sur laquelle nous reviendrons plus tard. Toutes ces normes et tous ces composants matériels et logiciels sont spécialement conçus pour assurer la fiabilité de systèmes temps réel produits en petites séries et, bien sûr, cela a un prix. À titre d'exemple, une plate-forme de démonstration avionique avec juste 2 SBC connectés par un réseau dédié et faisant tourner un OS de type ARINC 653 a coûté récemment plus de 50000 euros (après réduction « académique » de 50 % sur le prix du matériel, mais avec des licences d'utilisation d'un an seulement…).
Mis dans une perspective industrielle, ces prix ne sont pas prohibitifs, car en contrepartie le respect strict des normes avioniques facilite le développement d'applications et, surtout, leur certification, qui est un processus lourd et très formalisé visant à assurer la sûreté des systèmes créés.
Mais ces prix sont un obstacle important pour la communauté scientifique, et en particulier pour les chercheurs travaillant dans les domaines de l'ordonnancement temps réel et de la compilation, ceux-là même qui sont appelés à développer des méthodes et des techniques d'implantation pour les systèmes embarqués du futur. Et qui dit futur, dit besoin d'automatisation, car les systèmes sont en passe de devenir tellement compliqués, que les techniques d'implantation manuelles qui sont aujourd'hui prévalentes ne passeront plus à l'échelle.
Un de mes sujets de recherche est la génération automatique de code pour des systèmes où les ordinateurs ont un OS respectant la norme ARINC 653, que je présenterai plus tard dans l'article. Pour ce type de systèmes, j'ai développé, avec mes collaborateurs, un « compilateur de systèmes temps réel » capable de générer directement à partir d'une spécification de haut niveau (langage SCADE) le code applicatif tournant sur l'OS et respectant un ensemble de propriétés appelées « exigences non fonctionnelles » comme les dates d'arrivée et les échéances temps réel. Cet outil synthétise tout le code nécessaire à l'exécution (y compris le code de communication spécialisé) en respectant l'interface de programmation (API) APEX de ARINC 653, et optimise des aspects comme le nombre de tâches ou le nombre de changements de contexte.
Pour évaluer cet outil sur des cas d'études industriels, nous avons déjà entamé plusieurs collaborations, qui nous ont permis, entre autres, d'avoir accès pendant un an à la plate-forme décrite plus haut (travail en cours). Cependant, cet accès ponctuel n'est pas suffisant. La recherche fonctionne bien quand plusieurs équipes travaillent sur un sujet, d'où l'importance d'avoir des plates-formes de test ayant un prix abordable et capables non pas de remplacer le matériel professionnel, mais de permettre une expérimentation réaliste avec les outils de synthèse avant de passer à l'expérimentation avec matériel professionnel.
Ces arguments (et d'autres encore) ont déjà motivé plusieurs groupes de recherche à développer des implantations libres d'ARINC 653 [1][2][3] dont nous avons déjà utilisé une version (POK) comme plate-forme de démonstration. Le travail décrit dans cet article a commencé comme un portage de POK sur la carte Raspberry Pi. Mais rapidement je me suis rendu compte que je devais réécrire pratiquement tout le code, et qu'il valait mieux partir de zéro. Cela est justifié d'abord par des différences dans l'architecture cible : ARM pour la Raspberry Pi, alors que POK tourne sur SPARC/PPC/x86. Ensuite, je voulais faire des changements dans l'organisation de l'OS. ARINC 653 décrit des OS ayant un ordonnanceur hiérarchique à 2 niveaux. Dans POK, ces 2 niveaux sont gérés tous deux par la même fonction tournant en mode privilégié du processeur. Mon implantation sépare les deux niveaux d'ordonnancement, le niveau supérieur tournant en mode privilégié, alors que le deuxième niveau tourne en mode utilisateur (cela facilite le changement de l'ordonnanceur de niveau 2).
Je voulais également donner plus d'attention aux aspects bas niveau (gestion des 3 caches, gestion de la relation avec le GPU et les périphériques, gestion des modes processeur, etc.) en essayant de rendre l'exécution le plus déterministe temporellement. Pour faciliter l'utilisation pour l'enseignement, je voulais rendre le processus de compilation le plus simple et transparent possible, et avoir un système de chargement des exécutables qui tire avantage des périphériques de la Raspberry Pi (dont la carte microSD). Finalement, ce qui a fait pencher la balance est la volonté de comprendre finement ce qui peut être fait à l'intérieur d'un OS pour le rendre plus « prédictible » dans un contexte temps réel sur une architecture cible très commune.
2. Un OS respectant la norme ARINC 653
La norme avionique ARINC 653 a été introduite pour permettre de réduire le nombre de calculateurs d'un avion. En assurant une propriété nommée « isolation spatiale et temporelle », un OS de type ARINC 653 permet à plusieurs applications (chacune comportant une ou plusieurs tâches) de s'exécuter sur le même SBC sans que les exécutions s'influencent entre elles. Une application peut donc être développée et analysée sans prendre en compte les autres applications. Par exemple, des variations dans la durée d'exécution d'une tâche ne doivent pas influencer l'exécution des tâches d'autres applications. Plus encore, les modifications d'état de la machine (e.g. état des caches, des périphériques) faites par une tâche ne doivent pas changer la durée d'exécution des tâches d'autres applications.
Assurer un tel degré d'isolation n'est pas simple. La norme ARINC 653 le réalise en employant un mécanisme de « partitionnement spatial et temporel » de toutes les ressources du système (temps CPU, système mémoire, périphériques, etc.). La structure d'un système de type ARINC 653 est présentée en figure 1. Les ressources du système sont divisées entre plusieurs conteneurs nommés partitions, et chaque application s'exécute à l'intérieur d'une partition qui lui est dédiée en utilisant seulement les ressources de la partition. Le partage des ressources est statique, et peut se réaliser (selon la ressource) dans l'espace ou dans le temps. La mémoire RAM est - quant à elle - divisée dans l'espace, chaque partition recevant une ou plusieurs plages mémoires auxquelles elle a l'accès exclusif. Ce type d'isolation est facile à réaliser à l'aide d'une MMU (Memory Management Unit).
Le temps de processeur est partagé suivant une politique de multiplexage temporel statique (TDM) présentée intuitivement en figure 2. Le temps de processeur est alloué aux partitions suivant un motif de longueur fixe, nommé « Major Time Frame » (MTF), qui se répète indéfiniment dans le temps. Le MTF est divisé en plusieurs fenêtres temporelles de taille et position fixe (W0-W4 en figure 2). À chaque partition est alloué un ensemble de fenêtres. Dans notre exemple, les fenêtres W1 et W3 sont allouées à la partition 1. Lors de l'exécution, les tâches de la partition 1 (ici T1_1, T1_2 et T1_3) ne peuvent s'exécuter que pendant les fenêtres W1 et W3 (qui se répètent à chaque répétition du MTF). Pour l'exécution des tâches d'une application à l'intérieur des fenêtres allouées à sa partition, ARINC 653 laisse au concepteur système le choix de la politique d'ordonnancement. Le résultat est donc un système à ordonnancement hiérarchique, où l'ordonnanceur des partitions (L1) suit une politique TDM, et chaque partition a son propre ordonnanceur de tâches (L2).
Partitionnement spatial et temporel.
Cependant, partitionner mémoire et temps de CPU n'est pas suffisant. Par exemple, l'exécution des tâches d'une partition peut :
- changer l'état des caches du processeur, ce qui peut changer le temps d'exécution des tâches des autres partitions.
- changer l'état des périphériques système, ce qui peut modifier le résultat de l'exécution des autres partitions (temporellement et fonctionnellement).
Il faut donc éliminer complètement ces deux phénomènes, de telle sorte que les seuls mécanismes de communication entre partitions soient des canaux explicitement définis.
Exécution en multiplexage temporel statique (TDM).
Cela est réalisé en imposant l'isolation spatiale et temporelle au niveau du cache et de tous les périphériques. L'accès à certains périphériques peut être restreint à une seule partition. De même, certaines architectures permettent de partager le cache statiquement entre applications. Pour les cas où ce partage dans l'espace n'est pas possible, il faut s'assurer que l'état des périphériques partagés et des caches est remis à un état prédéfini à chaque changement de partition. En figure 2, cette opération de remise à zéro est représentée par un rectangle rouge en début de chaque fenêtre temporelle. L'opération est également chargée de réaliser les transferts de données liés aux canaux de communications.
3. La plate-forme d'expérimentation
Dans ce paragraphe, nous allons décrire la plate-forme matérielle ainsi que les outils utilisés.
3.1 La carte Raspberry Pi
La carte Raspberry Pi a été conçue pour démocratiser l'accès à l'informatique, et notamment pour faciliter l'apprentissage de l'informatique par les enfants. Les principaux objectifs dans sa conception sont le prix bas, l'existence d'une importante documentation, et la possibilité de l'utiliser dans des contextes variés, allant du développement d'applications embarquées (e.g. robotique) à l'utilisation comme ordinateur de bureau (sous OS Linux Raspbian ou autre). Plusieurs versions de cette carte ont été proposées, les développements de cet article étant basés sur sa version B+.
Le cœur de la carte est un système sur puce (SoC) Broadcom BCM2835. Ce SoC contient un processeur généraliste (CPU) ARM1176JZF-S tournant à 700 MHz (fréquence réglable par logiciel) et un processeur graphique (GPU) VideoCore IV tournant à 250 MHz. En plus du SoC, la carte Raspberry Pi contient aussi un banc de mémoire vive (RAM) de 512Mo, un contrôleur USB/Ethernet, et les lignes d'entrée-sortie.
Le SoC a une mémoire cache L2 de 128ko partagée entre GPU et CPU, et le CPU a un cache L1 de 32 ko, dont 16ko pour le cache de données et 16ko pour les instructions. Le contrôleur mémoire (MMU) du CPU contient lui aussi une mémoire de type cache (« Translation Lookaside Buffer », TLB) afin de stocker la configuration des pages de mémoire. Le CPU utilise le jeu d'instructions ARMv6, ce qui facilite la programmation. La RAM est partagée statiquement entre le CPU et le GPU, la quantité de RAM dédiée à la GPU étant configurable.
La carte Raspberry Pi possède une variété de ports d'entrée-sortie : entrée et sortie vidéo et audio, USB, Ethernet, et des ports bas-niveau (GPIO) permettant, entre autres, des I/O suivant les protocoles UART, I2C, SPI, I2S. Pour le stockage de données et de programmes, y compris celui du programme de démarrage, la carte a un support de carte mémoire MicroSD.
Très facile d'utilisation et bien documentée, la carte Raspberry Pi pose quand même quelques problèmes lorsque l'on veut faire du temps réel. D'abord, le CPU ARM de la carte utilise des caches avec politique de remplacement FIFO ou « random » (configurable), ce qui rend pessimistes les analyses de temps d'exécution. Deuxièmement, le CPU et le GPU sont en compétition pour l'accès à la RAM, ce qui pose aussi des problèmes de déterminisme temporel. Finalement, l'accès au réseau se fait par le contrôleur USB, qui doit être échantillonné à 8kHz pour des performances optimales. Cela demande plus de puissance de calcul de la part du processeur par rapport à d'autres configurations où la carte réseau interrompt le CPU lors de l'arrivée de trames.
3.2 Outils de programmation et de débogage
Pour programmer la carte, j'ai utilisé la configuration la plus simple permettant le traçage de l'exécution. En plus de la carte elle-même, de l'alimentation et de la carte microSD, j'ai utilisé un ordinateur (PC) avec port de carte microSD et un câble convertisseur UART (série) vers USB permettant d'avoir sur le PC une console série avec entrées et sorties vers la Raspberry Pi. Cette configuration très peu onéreuse s'est révélée suffisante dans mon cas. Le prix à payer a été l'impossibilité d'utiliser des outils de débogage standards, ce qui a rendu la détection de bogues liés à la gestion des caches particulièrement difficile.
Pour programmer la carte, j'ai utilisé un cross-compilateur gcc-4.7 et binutils-2.22 pour l'architecture ARM. Charger les exécutables sur la carte se faisait par la carte microSD.
Rétrospectivement, j'aurais clairement dû améliorer au moins deux aspects de cette chaîne. Premièrement, pour faciliter le débogage j'aurais dû utiliser un adaptateur JTAG. Deuxièmement, j'aurais dû utiliser un « bootloader » (comme U-Boot) permettant le chargement de la configuration et des exécutables sans utiliser la carte microSD (par Ethernet ou par UART). Je ne l'ai pas fait, car je ne voulais pas utiliser du code dont je ne comprenais pas complètement les effets, sachant que je voulais intégrer par la suite d'autres pilotes de périphériques.
4. Les ressources
Pour implanter ce type d'OS, je ne partais pas de zéro. D'abord, j'avais une certaine expérience de la programmation système et « bare metal », acquise sur des architectures pluricœur à base de processeurs MIPS32. J'avais acquis aussi, par mon travail de recherche, une certaine compréhension du fonctionnement interne des OS temps réel, et une certaine discipline de développement de logiciels prototypes d'assez grande taille.
Cependant, je n'avais jamais programmé d'ordonnanceur « préemptif », ni même de gestionnaire d'interruption. Je n'avais pas dû gérer les caches, ni le chargement d'applications binaires avec format (ELF) et interface binaire-programme (ARM-EABI) fixés, et pour le débogage j'avais toujours eu beaucoup d'informations. Heureusement, j'ai pu profiter de nombreuses ressources disponibles gratuitement en ligne (mais qui sont toutes en anglais).
4.1 Documentation officielle
Parmi les sources de documentation officielle, plusieurs se sont révélées indispensables, certaines ponctuellement, d'autres tout au long du travail de développement :
- ARM Architecture Reference Manual (ARM ARM) – décrit l'architecture du processeur, ses modes d'exécution, son jeu d'instructions, les principes de gestion bas niveau (MMU/caches, synchronisation…). Il existe plusieurs versions de ce document. Celle qui couvre l'architecture ARMv6 est officiellement l’« ARM Architecture Reference Manual for the ARMv7-A and ARMv7-R », mais j'ai utilisé le manuel de l'architecture ARMv5 (plus simple, assez complet, et plus largement disponible).
- ARM1176JZF-S™ Technical Reference Manual – décrit l'implantation de l'architecture ARMv6 dans le processeur ARM11, et plus précisément sa variante utilisée dans le SoC de la Raspberry Pi. Ce manuel contient des détails indispensables concernant la structure des caches et de la MMU, l'organisation exacte des registres de contrôle du processeur, etc.
- Broadcom BCM2835 ARM Peripherals – décrit l'organisation mémoire du SoC, et la manière dont les registres de contrôle des divers périphériques sont mappés en mémoire. L'écriture et le débogage de drivers sont impossibles sans ce document.
- GCC 4.7.4 Manual – contient les détails concernant la programmation d'architectures embarquées et la programmation de processeurs ARM. Cela inclut des options de compilation permettant de se passer des fichiers « header » et des bibliothèques standards, le contrôle du type de code généré, les attributs de fonctions nécessaires en programmation bas niveau (e.g. naked) et ARM (e.g. fonctions gestionnaires d'interruptions).
- Binutils Manual – contient la documentation des commandes ld (édition de liens), objdump (désassemblage), objcopy (création d'images binaires exécutables à partir de fichiers ELF), nm et readelf (analyse de fichiers ELF). La documentation de ld contient notamment la syntaxe des fichiers ld script, nécessaire pour définir l'organisation du code en mémoire.
- Application Binary Interface for the ARM Architecture (AEABI) – décrit l'interfaçage bas niveau des applications sur plates-formes ARM embarquées. De cette spécification, deux parties ont été très importantes dans mon travail :
- Run-time ABI for the ARM Architecture (AEABI) – contient la liste de fonctions que gcc utilise lors de la génération de code même si on l'appelle avec l'argument -nostdlib. Particulièrement importantes sur la Raspberry Pi sont les fonctions d'arithmétique entière div et mod qui doivent être implantées en logiciel.
- Procedure Call Standard for the ARM Architecture – contient la convention d'appel de fonctions que le compilateur respecte. Il faut bien comprendre cette convention avant d'interfacer code C et assembleur.
- Executable and Linkable Format Specification – nécessaire pour manipuler des fichiers binaires en format ELF (pour les charger et lancer des fonctions contenues dedans).
Les documents en provenance d'ARM et la spécification ELF sont très complets, le problème étant souvent de retrouver l'information importante et ignorer les autres détails. La documentation de gcc est assez complète, elle aussi (mais moins que celle d'ARM, par exemple en ce qui concerne les attributs de fonctions, qui sont plus difficiles à trouver). Par contre, le manuel du SoC Broadcom est assez incomplet et fait le recours à de l'information en ligne indispensable (e.g. au sujet de l'interface entre CPU et GPU).
4.2 Ressources en ligne
Malheureusement pour le programmeur bas niveau débutant (comme moi), la documentation officielle n'est pas suffisante. Non seulement elle est parfois incomplète, mais partir de zéro dans la programmation bas niveau est très difficile. Heureusement, j'ai pu trouver sur Internet énormément de ressources concernant tant la programmation de systèmes ARM en général que la programmation de la carte Raspberry Pi.
J'ai commencé ma formation en suivant deux cours introductifs se trouvant aux adresses suivantes :
- http://www.valvers.com/open-software/raspberry-pi/step01-bare-metal-programming-in-cpt1/ ;
- http://wiki.osdev.org/Raspberry_Pi_Bare_Bones.
Ces cours permettent de mettre en place rapidement l'environnement de compilation et la console série (deux activités qui, sans aide, peuvent prendre beaucoup de temps). Ensuite, j'ai pu écrire mes premiers programmes « bare metal » qui gèrent la console et les LED de la carte Raspberry Pi, et qui mettent en place une exécution dirigée par le temps au travers d'interruptions.
Une fois ces cours finis, j'ai pu commencer mon travail incrémental de développement au cours duquel j'ai utilisé plusieurs types de ressources :
- de courts exemples d'utilisation de divers aspects de la carte. Je ne mentionne ici qu'une seule source, la plus riche : https://github.com/dwelch67/raspberrypi ;
- le forum de discussion officiel pour la programmation de la Raspberry Pi : https://www.raspberrypi.org/forums/. Sur cette page, la section « bare metal » et en particulier le sujet « bare metal resources », contiennent des informations très utiles sur la programmation bas niveau de la carte.
- des logiciels libres. Par exemple, j'ai pu écrire mon pilote de carte microSD en adaptant à mon architecture logicielle celui de rpi-boot : https://github.com/jncronin/rpi-boot.
5. Travail de programmation
La programmation a été réalisé d'une manière incrémentale. Chaque élément rajouté au code a été testé avant de passer au suivant. Les grandes étapes ont été les suivantes :
- gestion des interruptions et exécution dirigée par les horloges (« timers »).
- sauvegarde et chargement de contexte processeur (registres généralistes et état du processeur) et exécution multi-tâche.
- implantation d'une partie des bibliothèques standards. Les fonctions arithmétiques entières div et mod mandatées par l'AEABI permettent d'utiliser les opérateurs / et % dans le code C. Ces fonctions ont une convention d'appel spécifique qui demande beaucoup d'attention lors de la programmation. La gestion du tas mémoire (« heap ») par malloc et free est nécessaire lors du chargement de fichiers. D'autres fonctions des librairies standards (comme getc, putc, puts, sscanf, sprintf, etc.) ont aussi été implantées d'une manière qui permet leur utilisation sans faire beaucoup d'hypothèses sur l'environnement d'exécution. Par exemple, ces fonctions n'utilisent pas le tas.
- chargement de fichiers (configuration et binaires) à partir de la carte microSD avec système de fichiers FAT, ce qui demandait aussi de pouvoir gérer la relation avec la GPU (par « mailboxes ») pour déterminer l'état d'activation du contrôleur de carte microSD.
- chargement et « relocation » de fichiers ELF (code et données des partitions ARINC653). Ici encore, malgré l'existence de bibliothèques déjà disponibles, j'ai préféré éviter leur complexité en ne codant qu'une partie minimale du standard ELF.
- isolation mémoire des partitions par configuration du MMU (décrite plus bas dans l'article).
- services système par interruptions logicielles (instruction assembleur svc).
- ordonnanceur hiérarchique à deux niveaux respectant le standard ARINC 653 et groupage des pilotes de périphériques dans une partition système isolée des partitions applicatives. Ordonnanceur L2 en espace utilisateur.
5.1 Choix simplificateurs
Pour pouvoir mener à bien mon projet, il a été très important de faire des choix de simplification à chaque pas du processus d'implantation. Ces choix sont justifiés par mon objectif de créer un OS facile à comprendre et à analyser. Nous avons donc fait les choix suivants :
- la sauvegarde et le chargement de contextes processeurs affecte toujours tous les registres, même si l'architecture ARMv6 permettrait de sauver parfois seulement une partie d'entre eux.
- seul le cache L1 est utilisé par le CPU ARM, pour éviter les interférences avec le GPU, qui utilise ce cache par défaut. Ce cache est complètement vidé lors du passage d'une partition à la suivante pour assurer l'isolation.
- le GPU est utilisé seulement pendant la phase d'initialisation, pour éviter les interférences durant l'exécution des partitions. Cela rend impossible l'utilisation d'un écran géré directement par la Raspberry Pi, mais rend le système plus « prédictible » temporellement.
- utilisation restreinte du format ELF, des options d'édition de liens (aucune utilisation de librairies dynamiques).
Mais les simplifications les plus importantes concernent la configuration mémoire. La MMU (« Memory Management Unit ») est utilisée comme une simple MPU (« Memory Protection Unit »), les adresses virtuelles étant identiques aux adresses physiques. De plus, la MMU est configurée avec une taille de page mémoire de 1Mo, alors que le processeur permet d'avoir des pages de 4ko. Ce choix simplifie beaucoup la configuration. Un seul niveau de table de pages est nécessaire (contre 2 pour des pages plus petites). De plus, j'ai fait le choix d'utiliser une seule table de pages, et de faire la gestion des permissions en me servant seulement du registre de contrôle d'accès du processeur ARM.
Ces deux choix ont aussi l'avantage de rendre le système mémoire plus prédictible temporellement, car le contenu du TLB reste inchangé après la phase d'initialisation du système. Il y a deux désavantages : le nombre de partitions applicatives est limité à 13 et les périphériques doivent tous être gérés par une seule partition, nommée la partition système. La première contrainte est due au fait que le registre de contrôle d'accès ne peut représenter que 16 jeux de permissions. Trois d'entre eux sont utilisés par le noyau, ce qui laisse 13 disponibles pour les partitions applicatives. La deuxième contrainte vient du fait que les registres d'accès aux périphériques se trouvent tous dans une page de 1Mo, ce qui rend impossible de fixer des permissions d'accès au niveau de périphériques individuels.
Cela simplifie l'organisation interne de notre OS, présentée en figure 3. L'accès aux périphériques est réservé au noyau L1 et à la partition 0, qui est appelée « partition système » et contient tous les pilotes. Le code applicatif se trouve dans les partitions. Le code des partitions, y compris l'ordonnanceur L2, s'exécute en mode utilisateur du processeur, et avec les interruptions activées. L'ordonnanceur L1 s'exécute en mode privilégié, et avec les interruptions désactivées.
Structure du système implanté.
5.2 Difficultés rencontrées
Les choix simplificateurs décrits plus haut ont clairement contribué à rendre mon projet réalisable en un temps raisonnable. J'estime l'effort dépensé pour la programmation à 1 mois à temps complet – week-ends compris et parfois nuits -, car le projet s'est révélé addictif. À cela s'ajoute le temps de documentation et de travail sur papier, que j'ai plus de mal à évaluer, au moins une semaine à temps complet.
Mais malgré les simplifications, le projet a gardé des aspects difficiles. Il fallait bien sûr s'attendre à passer du temps pour apprendre les bases de la programmation « bare metal » sur plate-forme ARM. Mais 4 points se sont révélés particulièrement difficiles :
- La programmation des caches. Même avec le cache L2 désactivé, le processeur ARM en possède 3 : le cache L1 d'instructions, le cache L1 de données, et le TLB, qui est le cache du MMU (pour la table de pages). Gérer les caches correctement devrait être le sujet d'un cours dédié, car il est très facile d'écrire du code faux avec des « heisenbugs », c'est-à-dire des bogues qui n'apparaissent que de temps en temps et qui peuvent disparaître quand on les observe. Comme c'était la première fois que je gérais moi-même les caches, j'ai rencontré 2 bogues de ce type et je présenterai ici le plus simple à expliquer, qui concerne le chargement de programmes exécutables de la carte microSD en mémoire. Une fois le fichier ELF chargé en mémoire à partir de la carte, il est modifié pour tenir compte de son adresse de destination (les « relocations » sont réalisées), et le résultat est copié à son emplacement final, où il peut être lancé. Voici une version très simplifiée du code de copie et lancement que j'avais écrit initialement :
memcpy((char*)0x300000,load_buffer,prog_size) ;
f = (entry_function_type) 0x300000 ;
/* autres traitements */
f() ;
- Sur la plate-forme Raspberry Pi, l'exécution de ce code peut résulter dans une exécution correcte de f(), ou dans une erreur (exécution de code aléatoire), en fonction du code des « autres traitements ». L'explication est simple : la première ligne va laisser le programme (ou du moins une partie de celui-ci) à l'adresse 0x300000, mais en cache de données, pas en mémoire. Si les « autres traitements » forcent l'écriture de ce code en mémoire, l'exécution se passe correctement. Sinon, le code chargé dans le cache d'instructions et exécuté est celui qui se trouve en mémoire à l'adresse 0x300000, c'est-à-dire un code aléatoire. Corriger ce problème est très simple : il faut juste forcer l'écriture en mémoire du code chargé (opération de « cache flush »). En plus des bugs de ce type, il faut aussi faire attention à l'utilisation des caches lors des échanges CPU-GPU, qui passent par la RAM.
- Les conventions d'appel ARM. Le code de démarrage et le début des routines d'interruption sont écrits en assembleur. Ce code réalise des tâches comme la création du contexte d'exécution (configuration des piles, mise à zéro du .bss, etc.) et des tâches très liées à la machine, comme la sauvegarde et la restauration de contextes d'exécution processeur. Ce code interagit avec le restant du code de l'OS, écrit en C, et il doit donc respecter les conventions d'appel de fonctions C. Par exemple, sur l'architecture ARM, lors d'un appel de fonction avec paramètres scalaires, les trois premiers paramètres sont stockés dans les registres processeurs r0, r1, r1. Si la fonction utilise des registres parmi r4-r11, elle doit les restaurer avant de retourner. Ces informations peuvent être utilisées pour optimiser le code de sauvegarde de restauration de contexte.
- L'interface binaire AEABI. Pour faire de l'arithmétique quand les opérations ne correspondent pas à une opération assembleur, le compilateur gcc suppose l'existence de fonctions de bibliothèque. Dans le cas du processeur ARM de la Raspberry Pi, les opérations entières div et mod (/, %) doivent être fournies, avec un nom et un prototype précis, et en respectant des conventions d'appel. Les difficultés liées à l'interface binaire AEABI sont moins liées à la complexité technique du sujet, et plus à la difficulté à trouver les bonnes sources de documentation. Dans ce cas, la référence est « Run-time ABI for the ARM Architecture ».
- Ordonnanceur L2 en mode utilisateur. À la différence de l'OS POK, nous avons choisi de laisser tout le code d'une partition, ordonnanceur L2 compris, tourner en mode utilisateur. La fonction d'ordonnancement est alors préemptible, ce qui rend possible son appel par interruption (e.g. « timer ») alors qu'elle est déjà en cours d'exécution en réponse à une fin de tâche ou à une demande de changement de priorité. Résoudre cette compétition demande beaucoup d'attention pour s'assurer que la demande en cours est exécutée avant celle due à l'interruption.
Conclusions
Pour conclure, je reviendrai aux objectifs décrits au début de l'article. Le développement de son propre OS respectant le standard avionique ARINC 653 est faisable dans un contexte où la documentation et les exemples sont abondants, ce qui est clairement le cas pour la Raspberry Pi.
Il s'agit bien sûr d'OS prototypes – assez complexes pour fonctionner comme plate-forme d'études, mais sans prétention à devenir des OS commerciaux. D'ailleurs, la plate-forme Raspberry Pi a des caractéristiques rendant problématique son utilisation pour des applications temps réel embarquées critiques, comme celles utilisant le standard ARINC 653 (caches de type FIFO, manque de contrôle sur le GPU).
Pour le futur, l'objectif est d'améliorer l'OS pour une utilisation de type recherche et enseignement (et sa publication comme logiciel libre), et essayer de contourner les problèmes de la plate-forme.
Bibliographie
[1] Julien Delange et Laurent Lec.POK, an ARINC653-compliant operating system released under the BSD license. Dans « 13th Real-Time Linux Workshop », 2011
[2] XtratuM, http://www.xtratum.org
[3] José Rufino, Sérgio Filipe, Manuel Coutinho, Sérgio Santos, et James Windsor, ARINC 653 INTERFACE IN RTEMS. Dans « DASIA 2007 »