![](/themes/connect/images/creative-commons.png)
Pour faire suite aux nombreux articles parus dans Open Silicium abordant souvent le thème des systèmes embarqués sur des micro-contrôleurs, cet article vous présente Lepton. Il s'agit d'un petit système d'exploitation temps réel taillé pour des cibles possédant des ressources matérielles restreintes. Lepton amène avec lui des possibilités intéressantes qui permettent de développer rapidement des applications évoluées malgré cette contrainte de puissance de traitement limitée.
Introduction
On entend beaucoup parler de systèmes embarqués, de systèmes d'exploitation temps réel. Souvent, ce sont des appareils autonomes avec des puissances de traitement de plus en plus importantes et des ressources mémoires flash et RAM de l'ordre de plusieurs dizaines de méga-octets. Sur ces plateformes matérielles, on retrouve des systèmes d'exploitation évolués, Linux par exemple, ou d'autres systèmes propriétaires ou non. Comme nous l'avait fait remarquer Denis Bodor dans un de ses éditoriaux, les systèmes embarqués qui utilisent des micro-contrôleurs plus modestes en termes de puissance et de mémoire sont un peu oubliés alors qu'ils permettent de réaliser tout un panel d'applications étonnantes, loin des sentiers battus, à peu de frais et à une échelle maîtrisable par un seul individu. Dans ces petits systèmes au ras de l'électronique, on peut apprendre, (re)découvrir des principes de base, développer des solutions astucieuses (pas trop le choix vu les contraintes matérielles).
De nombreux noyaux «temps réel» sont destinés à ce type de cibles. Ils utilisent l'appellation RTOS (RealTime Operating System) et ils font référence à la notion de système d'exploitation dans leur appellation alors que souvent ils n'offrent « que » la partie noyau temps réel (gestion de taches, synchronisations, temporisateurs logiciels) mais pas de gestion de périphériques, de système de fichiers ou de pile protocolaire. Ces services supplémentaires sont disponibles mais moyennant un petit détour par la caisse enregistreuse et le plus souvent ne font pas preuve d'une réelle intégration.
Cette approche système d'exploitation est présente dans les solutions comme eCos [ECOS] ou RTEMS [RTEMS] . C’est aussi celle de Lepton. Toutefois Lepton ajoute quelques propriétés plus avancées qui le rendent un peu plus proche de système plus imposant sur lesquels il a pris exemple mais en conservant sa légèreté.
Une des raisons majeures qui a motivé le développement de lepton [LEPTON] est la réutilisation logicielle. Offrir la possibilité de concevoir des briques logicielles et de les réutiliser sur plusieurs développements de produits différents. Partager les évolutions et les résolutions d'anomalies entre les différentes équipes de développeurs. Créer une réelle synergie et par conséquent mettre en place une organisation de développement logiciel dont le moteur central est le système d'exploitation. En résumé, utiliser le modèle de développement que l'on retrouve dans le monde « open source » pour l'appliquer à un projet industriel interne à l'entreprise.
L'interaction des périphériques matériels qui contrôle l'envoi et la réception de données avec la couche logicielle qui réalise le traitement de ces mêmes données peut-être présentées comme point de départ originel du développement de lepton. Ce sera aussi celui de cet article. Nous verrons ainsi comment nous sommes passés d'un développement monolithique, fortement intriqué et très peu réutilisable (voir pas du tout) à une approche plus modulaire. Modulaire et plus évolué certes, mais en préservant au maximum la légèreté et la réactivité du système ainsi que les propriétés du noyau temps-réel sur lequel il est basé.
1. Historique
Pour expliquer la genèse de ce projet, il est, je pense, judicieux d'en rappeler le contexte. Ce projet a été initié au sein d'une entreprise qui développe des appareils de mesure pour un secteur essentiellement industriel. Ces appareils sont pour la plupart portables et amenés à fonctionner dans des environnements ou leur robustesse est mise régulièrement à l'épreuve. Des contraintes sur la taille des appareils et leur autonomie obligent à utiliser des micro-contrôleurs de basse consommation avec peu de puissance de calcul et des capacités mémoires faibles. À chaque nouvelle étude, peu de chose était repris des développements précédents, essentiellement pour des raisons d'architecture logicielle. Cette dernière était souvent monolithique avec un enchevêtrement inextricable entre le code en charge de la mesure et celui qui assure la gestion des périphériques d'entrées et de sorties.
Nous avons d'abord isolé l'ensemble des fonctionnalités qui nous semblaient les plus transversales sur tous les appareils jusqu'alors réalisés. Il est rapidement apparu que c'était majoritairement les fonctionnalités de communication avec l'environnement extérieur des appareils. Communication avec d'autres appareils (liaison série RS232 ou RS485, réseau informatique, réseau industriel…) ou avec l'utilisateur (clavier, écran graphique, terminal).
Le premier objectif que nous nous étions fixé consistait à obtenir une utilisation le plus générique possible des périphériques d'entrées et de sorties et ainsi diminuer le couplage entre les «briques» logicielles et le matériel sur lequel elles seraient utilisées. Disposer aussi de mécanismes simples qui permettent leur intégration naturelle et rapide pour une réutilisation qui le sera tout autant.
Alors, pourquoi avoir choisi de développer en interne une telle solution plutôt que de réutiliser des solutions déjà existantes comme eCos ou RTEMS par exemple? Il faut remonter à plus d'une dizaine d'années, les produits développés utilisaient encore des micro-contrôleurs 8 bits et nous commencions à basculer vers du 16 bits. Les empreintes mémoires étaient par conséquent très réduites, une trentaine de kilo-octets de mémoire vive et quelques centaines de kilo-octets pour le code.
Nous n'avions alors que peu de recul sur l'utilisation dans le monde industriel de solutions logicielles issues de l'open source. Le système eCos était encore peu répandu et supportait uniquement des architectures 32 Bits. Nous nous sommes résolus à développer notre propre solution en utilisant un noyau temps réel propriétaire.
La première version de notre « système d'exploitation » proposait une gestion de périphériques, de fichier et du réseau. Nous avions commencé son développement en septembre 2001 et achevé environ 6 mois plus tard. Malheureusement les fonctions et appels systèmes proposés manquaient de cohérence. Il y avait une interface logicielle dédiée à la gestion de périphérique, une autre pour les fichiers et encore une autre pour le réseau. Cette solution certes fonctionnait mais ce n'était pas encore ce que nous recherchions. Il était nécessaire de s'inspirer de systèmes d'exploitations évolués malgré leurs exigences en ressources matérielles qui étaient à des années-lumière de ce que nous pouvions espérer.
Toujours sur la même base de noyau temps-réel, dans le courant de l'année 2003, la première version de lepton tournait enfin sur une cible réelle, un micro-contrôleur Renesas M16c62p avec 30 Kilo-octets de mémoire RAM et 256 Kilo-octets de Flash. Cette version intégrait les gestions de processus, de périphériques et les systèmes de fichiers par l'intermédiaire d'un VFS. Le premier processus lancé fut l’« init » suivi d'un petit interpréteur de commandes.
Les évolutions logicielles suivantes ont fini de débarrasser les dernières dépendances à des solutions propriétaires. L'environnement de développement est dorénavant sous une distribution GNU/Linux et la partie noyau temps-réel fait appel à eCos. Nous avons ainsi maintenant une version de lepton complètement open source, que ce soit pour lepton lui-même, certaines briques logicielles dont il est aujourd'hui constitué (noyau temps réel, pile réseau, librairie graphique…) et pour finir son environnement de développement (GNU Tools chain).
Tout au long de ce processus d'évolution, le matériel lui aussi a suivi la tendance. Nous sommes passés d'une architecture 16 bits à 32 bits avec des micro-contrôleurs intégrant un cœur ARM. Cette migration matérielle nous a permis d'évaluer la capacité de portage de lepton entre ces deux architectures matérielles. Les applications développées sur lepton sur les architectures 16bits n'ont nécessité que très peu de modifications (quelques effets de bord avec les « typages » de données dépendant de l'architecture) et pour la majeure partie aucune. Le portage fut donc réalisé très rapidement, une demi-journée, le temps d'écrire un pilote de périphérique pour la liaison série UART afin de pouvoir interagir avec l'interpréteur de commande.
Aujourd'hui lepton est à disposition de la communauté open source et son utilisation libre sous licence mixte ePL (eCos Public License) et MPL v1 (Mozilla Public License).
2. Architectures
Pour créer lepton nous avons donc fait le tour des architectures de système d'exploitation et sélectionner celles qui pourraient servir nos objectifs. Pour des raisons de concision et ainsi éviter l'écriture d'un roman, je résume la démarche à travers quelques points clés qui me semble être les plus significatifs et qui, je l'espère, pourront vous éclairer sur notre démarche et nos choix.
Les schémas de la figure 1 représentent différentes architectures logicielles possibles pour développer une application embarquée, disons « baremetal ». Ce n'est pas exhaustif mais ce sont celles que nous avons rencontrées.
Fig. 1 : Illustration des architectures logicielles possibles.
La première de ces architectures (fig1.a) est celle que l'on retrouve assez souvent sur les petits systèmes. C'est une architecture monolithique avec une forte intrication entre le code qui réalise le traitement des données et celui qui prend en charge leur réception et émission par l'entremise de périphériques matériels de communication.
La seconde (fig1.b) propose une séparation entre la partie traitement des données et celle qui assure la communication. Cette partie communication est scindée en plusieurs modules logiciels chacun ayant sa spécificité et une interface dédiée pour accéder aux fonctionnalités proposées. C'est ce type d'organisation que l'on retrouve souvent notamment dans les solutions commerciales, avec un paquet noyau temps-réel, un autre orienté réseau, ou système de fichier, etc. On ne retrouve pas d'intégration homogène de toutes ces fonctionnalités.
La suivante (fig1.c) illustre quant à elle une approche plus structurée. On obtient une organisation clairement séparée. D'un coté le traitement des données que l'on peut appeler « application » et d'un autre coté des flux de données. Le contrôle et l'accès à ces flux de données sont à présent complètement génériques. Les interfaces d'accès sont identiques quelques soit la nature du matériel qui permet de faire transiter ces flux de données (ex :liaison série, réseau, système de fichier, spi, i2c...).
Cette dernière approche est celle que nous avons choisie afin d'obtenir le couplage minimum entre la partie qui utilise les données à des fins de traitements logiciels (filtrage, conversion, affichage…) et celle qui les achemine matériellement.
2.1 Comment contrôler et utiliser les périphériques ?
Première étape, définir une interface de programmation qui permette depuis la partie logicielle en charge du traitement des données (l'application) de manipuler facilement les périphériques d'entrées et de sorties. Les opérations de base que l'on utilise le plus souvent sur un périphérique peuvent être réduites à la liste suivante
1. Désigner et réserver le périphérique à utiliser,
2. Lire des données en provenance de ce périphérique,
3. Écrire des données vers ce périphérique,
4. Libérer le périphérique,
5. Configurer le périphérique.
Si possible, il serait encore plus sympathique si l'ensemble de ces opérations pouvait être appliqué à tout type de périphériques et donc obtenir une interface la plus générique possible.
Premier cas, aucune interface logicielle générique. On attaque « brutalement » les périphériques en développant quelques fonctions très spécifiques. On obtient alors des choses dans ce style :
prepare_periph_a()
lire_periph_a()
ecrire_perih_a()
configurer_periph_a()
fermer_periph_a()
prepare_periph_b()
lire_periph_b()
ecrire_perih_b()
configurer_periph_b()
fermer_periph_b()
Pour la réutilisation ce n'est pas la méthode la plus efficace. Une interface à chaque type de périphérique impose du code en doublon avec tous les problèmes de maintenance et d'évolution que cela entraîne. C'est un modèle d'architecture logicielle similaire à celle décrite par la figure 1.b.
Second cas, une interface générique:
prepare_reserve_periph(a)
lire_periph(a)
ecrire_periph(a)
configurer_periph(a)
fermer_periph(a)
prepare_reserve_periph(b)
lire_periph(b)
ecrire_periph(b)
configurer_periph(b)
fermer_periph(b)
Le code qui utilise cette interface est un peu plus découplé de la couche sous-jacente. Les fonctions, du point de vue de l'utilisateur de l'interface, sont toutes identiques, quel que soit le type de périphérique. Seule une référence est passée en paramètre et permet de discriminer le périphérique.
Utilisons un petit interpréteur de commandes comme exemple. L’utilisateur interagit avec un terminal qui utilise un périphérique de communication qui peut être par exemple une liaison série du type «rs232», une connexion réseau IP par l’intermédiaire d’une interface Ethernet. L'idéal serait donc d'utiliser une interface de contrôle des périphériques qui, quelle que soit la nature de ces derniers (liaison série, interface réseau…), permette d'obtenir un code identique. En appliquant l'interface générique décrite précédemment nous pourrions obtenir quelque chose dans ce style :
référence périphérique;
tampon de réception;
tampon d'émission;
prépare_réserve_périph( référence périphérique a);
tampon de réception = lire_périph(référence périphérique a);
résultat de la commande = interprétation et exécution de la commande(tampon de réception);
affichage du résultat(résultat de la commande){
tampon d'émission = mise en forme du résultat commande(résultat de la commande);
écrire_périph(référence périphérique a, tampon d'émission);
}
fermer_periph(référence périphérique)
Sur ce petit exemple, certes très schématique et simplifié, on peut réutiliser ce code directement sur un autre périphérique. Il suffit simplement de changer la désignation du périphérique dans la fonction générique prépare_réserve_périph() :
...
prépare_réserve_périph( référence périphérique b);
tampon de réception = lire_périph(référence périphérique b);
...
Cette approche logicielle existe depuis très longtemps mais elle est très peu répandue sur les petits micro-contrôleur qui nous intéresse. Elle est pourtant utilisée tous les jours sur tous les systèmes d'exploitation et notamment les systèmes UNIX et Linux. Les spécifications du standard POSIX formalisent cette approche nous l'aborderons un peu plus loin.
2.2 Gestion et pilotes de périphériques
Une interface logicielle générique qui offre l'accès aux périphériques c'est un bon début mais si les mécanismes internes pouvaient, eux aussi, être communs, ce serait encore mieux.
Prenons pour illustrer le propos, le cas de la récupération de données, l'exemple d'un périphérique que l'on retrouve assez souvent et dont on a rapidement besoin pour poursuivre son développement, la liaison série.
Les données arrivent sur la liaison série octet par octet et peuvent être asynchrones vis-à-vis du programme qui l'exploite. Asynchrone, dans le sens où le programme ne peut à priori savoir quand les données vont lui être envoyées. Donc, dans ce contexte, il y a deux façons de fonctionner :
1. Tester périodiquement un indicateur d'arrivée d'une nouvelle donnée (méthode appelée aussi polling).
2. Un mécanisme interne du processeur exécute un code particulier qui est appelé à l'arrivée d'une nouvelle donnée. Ce mécanisme est l'interruption.
Ajoutons dans notre cas un nouvel élément à prendre en compte: la présence d'un ordonnanceur. Le programme qui utilise la donnée en provenance de la liaison série est exécuté périodiquement. L'ordonnanceur lui alloue une unité de temps d'exécution, c'est une tâche.
Si on utilise la première méthode dans un contexte ou plusieurs tâches sont en concurrence, il y a un fort risque de perte de donnée ou un risque de monopolisation des temps d'exécution sur la tâche qui est en charge de contrôler la présence de nouvelles données sur le périphérique.
En revanche la seconde solution ne sollicite le processeur que lorsqu’une donnée est arrivée sur le périphérique en question. Reste à signaler au programme concerné qu'une donnée est disponible.
Sur les systèmes à base d'ordonnanceur de tâches, on retrouve des mécanismes logiciels qui permettent d'attendre un événement et qui tant que celui-ci n'est pas survenu, place la tâche concernée en sommeil. L'ordonnanceur ne lui accordera pas d'unité de temps d'exécution en libérant ainsi du même coup un peu plus de temps d'exécution pour les autres tâches concurrentes.
Le plus souvent sur les périphériques comme la liaison série, il existe un registre (une zone mémoire interne au processeur) qui est utilisé pour sauvegarder la donnée qui vient juste d'être reçue. Nous verrons un peu plus loin que d'autres mécanismes internes possibles.
La donnée est donc placée dans ce registre, le processeur déclenche une interruption qui entraîne l'appel d' un programme spécialement écrit pour gérer cette situation. C’est un vecteur d'interruption.
Rappelons que l'objectif consiste à fournir les données reçues, sans en perdre, à la tâche concernée. Cette tâche est en attente d'un événement logiciel lui signalant la disponibilité d'une donnée. Dans la fonction d'interruption, il convient d'utiliser alors le mécanisme fourni par l'ordonnanceur qui lui permettra de réveiller notre tâche en sommeil.
Cette tâche se verra donc accorder une unité de temps d'exécution par l'ordonnanceur et le code exécuté dans ce contexte viendra alors lire la donnée dans le registre de réception.
Reste encore un problème. Si le temps entre deux octets consécutifs est plus faible que le temps de réaction de l'ensemble du système logiciel de réception, des données seront alors perdues. Le temps de réaction c'est le temps de réponse à l'interruption auquel on ajoute le temps d'exécution logiciel pour récupérer la donnée dans le registre de réception du périphérique, ainsi que le temps de commutation de contexte (si tant est que ce soit notre tâche de réception qui soit élue immédiatement. Problème typique dans le temps réel dur). Bref, cela fonctionne mais ce n'est pas encore très fiable.
On peut mettre en place un mécanisme logiciel pour améliorer la situation. Dans la fonction d'interruption, on peut utiliser un tampon intermédiaire dans lequel on vient placer le plus rapidement possible la donnée présente dans le registre de réception, qui sera immédiatement prêt à recevoir une nouvelle donnée évitant ainsi une perte possible. La tâche aura probablement plus de temps pour venir lire de son côté les données présentes dans le tampon intermédiaire. La taille de cette zone tampon n'est bien sûr pas infinie. Par conséquent nous aurons le même problème si le débit de traitement est inférieur au débit de données entrantes. Il y aura alors un débordement du tampon et de nouveau une perte de données. Mais on peut tout de même dire que statistiquement on lisse le débit entrant et améliore ainsi la capacité à encaisser des débits importants. Surtout que sur une tâche pour une unité de temps donnée, il est plus intéressant de traiter un maximum de données entrantes d'un coup en évitant d'appeler des mécanismes de l'ordonnanceur qui peuvent être plus ou moins pénalisants.
D'autres stratégies se basent, elles aussi, sur une utilisation de tampon intermédiaire, mais en faisant cette fois appel à des mécanismes matériels. Ces mécanismes sont bien sûr beaucoup plus rapides et permettent de moins solliciter l'unité d’exécution de micro-contrôleur.
Le premier de ces mécanismes matériels utilise une zone de mémoire spécifique associée au périphérique matériel. La taille est souvent assez réduite (16 octets). Ce mécanisme connue sont le nom de FIFO (First In First Out) fait office de tampon de données, elle permet de diminuer, toujours statistiquement sur des flux de données importants, le nombre d'interruptions générées. En effet dans le cas d'un tampon logiciel à chaque octet reçu, il y a une interruption du code exécuté pour placer la donnée dans le tampon. Le temps nécessaire pour réaliser toutes ces opérations n'est pas négligeable au regard des débits que l'on peut obtenir sur une liaison série. Par conséquent, en factorisant ce temps sur la réception de plusieurs octets, l'efficacité est augmentée. Donc dès qu'un octet est reçu, il est placé directement dans la FIFO sans nécessiter l'exécution de code. L'opération est réalisée matériellement dans le périphérique.
Le second mécanisme c'est le DMA (Direct Memory access). Le principe est identique à celui de la FIFO, à la différence près que les données ne sont pas copiées dans une zone mémoire interne au périphérique mais transférées directement dans la mémoire à usage générale, par exemple à l'adresse d'un tableau. Une plus grande souplesse d'utilisation est ainsi obtenue en permettant de contrôler le dimensionnement des tampons mémoires.
Quelle que soit la méthode utilisée, FIFO ou DMA, une interruption est générée soit après un temps durant lequel aucun nouveau caractère n'a été reçu, soit lorsque le tampon (FIFO ou zone mémoire DMA) est rempli à moitié ou encore si le tampon est plein. Il n'est pas toujours possible de connaître à priori la quantité de donnée que l'on doit recevoir.
Dans la fonction d'interruption qui sera appelée pour l'un des trois cas précédemment énumérés, le code copiera l'ensemble des octets reçus et placé dans la pile, vers un tampon logiciel. Dans le cas du DMA il est possible aussi de se dispenser de cette copie et d'utiliser directement la zone mémoire DMA. Mais dans le cas d'un mécanisme générique, il n'est pas toujours possible de faire cette économie d'un tampon intermédiaire.
Je me suis concentré sur la réception parce que c'est le cas le plus délicat car on ne contrôle pas le moment où les données vont arriver, leur quantité et leur débit. Les mécanismes décrits existent à l'identique pour l'émission et peuvent être étendus à bon nombre de périphériques de communication.
Si l'on essaye de formaliser ce fonctionnement de façon plus générale:
1. Attente de données,
2. réception des données ,
3. signalisation,
4. lecture des données reçues.
Pour l’émission le raisonnement est identique.
1. écriture des données à envoyer,
2. émission des données,
3. attente de la fin d'envoi des données,
4. signalisation de la fin d'envoie des données.
Nous obtenons là, une description des étapes nécessaires à la réception et l'envoi séquentiels de données pour un ensemble de périphériques d'entrées et de sorties. Ce type de périphérique regroupe par exemple les liaisons séries asynchrones (UART) ou synchrones comme l'i2c, le spi, on peut aussi intégrer l'Ethernet. Il est maintenant possible de créer une interface commune qui décrit comment la gestion de périphériques va interagir avec le code en charge de contrôler le périphérique matériel, ce code c'est ce que l'on appelle communément le pilote de périphérique. Ainsi la gestion de périphérique ne connaît que cette interface et ne voit pas comment les périphériques sont contrôlés par le pilote de périphérique. Le pilote doit répondre aux spécifications de l'interface. Ces spécifications comprennent les prototypes des fonctions de l'interface logicielle ainsi que le comportement attendu pour chacune d'entre elles.
Fig. 2 : Illustration gestion générique des périphériques.
La figure 2 illustre pour la fonction de lecture de donnée sur un périphérique, l'utilisation d'un mécanisme commun de réception séquentielle de données au moyen des trois interfaces génériques de la gestion de périphériques («Données reçues disponibles?», «attente de données disponibles» et «lecture de données reçues»). Ces trois interfaces sont implémentées spécifiquement dans chaque pilote de périphérique alors que le mécanisme de gestion de périphériques qui les utilise demeure identique.
Nous avons une couche d'abstraction de l'application vis-à-vis des mécanismes de gestion de périphériques et une autre entre cette gestion de périphérique et les périphériques d'entrées de sorties. C'est l'architecture décrite dans la figure 1.c. Dans les paragraphes suivants, nous allons voir comment concrètement mettre en place cette architecture.
Interruption versus polling :
Sur les systèmes utilisant des ordonnanceurs, il est d'usage de favoriser un mécanisme évènementiel en utilisant des fonctions de signalisation comme les sémaphores ou les événements pour s'intégrer au mécanisme de gestion des tâches. Pour la gestion des périphériques, l’utilisation des interruptions matérielles est souvent privilégiée. La routine de gestion de l'interruption utilise alors une des catégories de primitives de signalisation précédemment citées pour remonter les évènements aux couches supérieures. Même si les interruptions s'avèrent rapides et plus adaptées, parfois le «polling» peut se montrer plus efficace notamment pour des communications très rapides et sur un court laps de temps. Par exemple les bus comme le SPI et l'I2c, il est préférable d'utiliser le contrôle périodique de l'état du périphérique de réception ou d'émission dans une simple boucle. Les temps de latence entrainés par la gestion de l'interruption (même si une grande partie est gérée par du matériel) peuvent être suffisamment importants pour pénaliser la transmission ou la réception de données. Les deux méthodes peuvent parfois être mixées et le passage de l'une à l'autre est dynamiquement choisi en fonction des débits sur les flux d'entrées et de sorties (voir l'utilisation de NAPI dans le noyau Linux).
2.3 Le standard POSIX
Le standard POSIX [POSIX] définit et spécifie entre autres le comportement (pas l'implémentation même s’il peut avoir une influence comme nous allons le voir) de toutes les interfaces logicielles avec les services fournis par une architecture logicielle qui peut être un système d'exploitation ou un noyau temps réel. Le standard définit aussi les commandes utilisateurs.
Pourquoi s'inspirer de POSIX ? Il était toujours possible de définir sa propre API, c'est assez flatteur pour l'égo, par contre le risque est grand de se retrouver isolé sur son petit îlot de codes. De plus des solutions éprouvées existent et proposent de nombreux outils prêts à l'emploi ainsi que des communautés importantes de développeurs aguerris. Pour des raisons de cursus académique, les premiers systèmes vers lesquels nous nous sommes tournés furent ceux s'inspirant de la « philosophie » UNIX. Leur point commun est l'interface système basée sur ce standard POSIX. Des systèmes d'exploitation d'implémentations différentes dans leurs fondations, mais une interface de programmation système commune. Les mêmes applications avec un code source identique (ou presque) nécessitant tout au plus une recompilation pouvaient fonctionner sur l'ensemble de ces systèmes, preuve d'une réelle portabilité dans des environnements d'exécution différents. Autre avantage, nombre de ces systèmes d'exploitation mettent à disposition leur code source. Nous avons ainsi eu le loisir d'étudier dans les détails leurs mécanismes internes. Nous nous en sommes inspirés pour ensuite les adapter de telle manière qu'ils puissent répondre à nos contraintes. Bien sûr le standard POSIX couvre une grande quantité de fonctionnalité possible et par conséquent le nombre d'appels système et de fonctions spécifiés est bien plus important que ceux présentés dans cet article.
En conséquence, et pour faire suite aux précédents paragraphes, nous allons nous concentrer sur quelques fonctions de l'interface POSIX qui concernent la gestion des périphériques d'entrées et de sorties.
On peut citer les quelques fonctions basiques comme open(), read(), write(), ioctl() et close() qui permettent de manipuler les périphériques depuis une application.
Pour reprendre le petit exemple de l’interpréteur de commandes du chapitre 2.1, voilà ce que l'on obtiendrait en C avec ces fonctions POSIX :
int reference_preriphérique;
char tampon_de_reception[MAX_BUF];
char tampon_d_emission[MAX_BUF];
int resultat_de_commande;
int cb;
reference_preriphérique = open("/dev/ttyS3",O_RDWR);
cb = read( reference_preriphérique,tampon_de_reception,sizeof(tampon_de_reception));
resultat_de_commande=interpretation_execution_commande( tampon_de_reception, cb);
cb = mise_en_forme_résultat_commande(&tampon_d_emission,resultat_de_commande);
cb = write(reference_preriphérique, tampon_d_emission, cb);
close( reference_preriphérique);
Pour changer de périphérique comme nous l'avons fait dans ce même chapitre 2.1, il suffit de modifier le chemin vers le fichier de périphérique pour faire référence à celui que l'on souhaite dorénavant utiliser :
reference_preriphérique = open("/dev/ttyS0",O_RDWR);
ou encore :
reference_preriphérique = open("/etc/mon_fichier_de_commandes",O_RDWR);
C’est là que les choses deviennent très intéressantes. La fonction d'ouverture open() permet d'ouvrir, comme son nom l'indique, un fichier, quelle que soit sa nature, notamment s’il s’agit d’un pilote de périphérique. Ces concepts introduits à l'origine par les systèmes UNIX montrent tout leur intérêt pour faciliter notamment la réutilisation logicielle. En effet sur ces systèmes tout est fichier et la fonction open() est une clé qui ouvre tout un éventail de possibilités. Ce ne sera pas sans conséquence sur l'étendue des fonctionnalités et des interfaces POSIX que nous serons amenées à implémenter et qui vont bien au-delà du cadre de la gestion de fichiers.
2.4 Unix et les fichiers
Ce paragraphe peut être considéré comme le corollaire du précédent. Sur ces systèmes d'exploitation adoptant la démarche empruntée par UNIX tout est visible et accessible sous la forme de fichiers. Et qui dit «fichier» dit aussi système de fichiers. Cette approche influe sur l'architecture du système d'exploitation et amène de nouvelles possibilités grâce à l'abstraction supplémentaire amenée par le fichier.
Il y a plusieurs types de fichiers. Il y a le fichier de type régulier, c'est un fichier de données simple comme l'est un fichier texte par exemple. On trouve aussi le fichier de type répertoire. Le répertoire contient la liste d'un ensemble de fichiers quel qu’en soit le type et souvent sous la forme d'un couple identifiant unique (appelé aussi numéro d'inoeud), nom du fichier. Dernière catégorie de fichier, les fichiers de type spéciaux comme notamment les pilotes de périphériques de type blocs et caractères. L'intérêt de cette approche est de tout considérer comme des flux d'entrées et de sorties, indépendamment de leur nature. Par conséquent les méthodes d'accès sont identiques (génériques?), même si les mécaniques sous-jacentes peuvent être complètement spécifiques.
Pour en revenir à nos périphériques, ils sont donc visibles sous la forme de fichiers et sont eux-mêmes contenus dans un système de fichiers.
Le premier paramètre de la fonction open() représente le chemin du fichier auquel on souhaite accéder. Le second paramètre concerne le mode d'ouverture, c'est-à-dire donner la possibilité d'ouvrir le fichier uniquement en lecture ou en écriture (exclusif), voir les deux. Le retour de cette fonction est un identifiant qui sera utilisé par toutes les opérations ultérieures sur ce fichier. Cet identifiant est communément appelé descripteur du fichier.
Le descripteur du fichier est très important dans cette architecture. Il apporte deux autres concepts très intéressants.
Le premier est la notion d'entrée et de sortie standards. L'unité d’exécution dans le temps est le processus et chaque processus possède un tableau de descripteurs de fichier dont les entrées sont indexées de 0 à N (N étant le nombre maximum de fichiers qui peut être ouvert par un processus). Les indices 0 à 2 de ce tableau sont définis par convention comme suit :
L'indice 0, c'est l'entrée standard. Le descripteur de fichier qui a pour valeur 0, fait référence à un fichier ouvert au moins en lecture.
L'indice 1 fait référence à la sortie standard, le descripteur du fichier qui, lui, a donc pour valeur 1 et il fait référence à un fichier ouvert au moins en écriture. Pour l'indice 2, donc le descripteur du fichier qui a pour valeur 2, il est ouvert lui aussi au moins en écriture.
Ces valeurs sont elles aussi standardisées avec les déclarations suivantes : STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO.
Le contenu de ce tableau de descripteurs est un autre indice dans un tableau qui est la table des fichiers ouverts. Ce qui a pour première conséquence que pour un même numéro de descripteur dans deux processus différents, on peut faire référence à deux entrées différentes dans la table des fichiers ouverts. Ou encore dans un même processus deux descripteurs de fichiers différents peuvent faire référence à la même entrée toujours dans cette table des fichiers ouverts (par exemple avec la fonction POSIX dup()).
Le second concept c'est l'héritage de la table des descripteurs entre le processus père et le processus fils.
Si l'on reprend l'exemple de notre petit interpréteur de commandes, la commande pourra être exécutée dans un nouveau processus (processus « fils ») créé par le processus (processus « père ») qui exécute le code de l’interpréteur. L’interpréteur a préalablement ouvert la liaison série de telle manière que les valeurs des descripteurs de fichiers correspondent aux valeurs d'entrées et de sorties standard définies plus haut. Le processus fils exécutera le code désigné par la commande et pourra directement, par héritage, lire et écrire lui aussi sur ses entrées et sorties standards. C’est-à-dire sur la liaison série. Le processus fils ignore totalement la nature des périphériques qui prennent en charge les flux de données en entrée et en sortie, son comportement n'est pas déterminé par ce critère. En conséquence si les entrées et sorties standards sont dirigées sur un fichier de données, un socket, une liaison série ou quoi que ce soit d'autre, le code de notre interpréteur ne s’en trouve absolument pas affecté.
En modifiant le code du petit interpréteur de commande du précédent paragraphe comme suit :
int descripteur_fichier_lecture;
int descripteur_fichier_ecriture;
char tampon_de_reception[MAX_BUF];
char tampon_d_emission[MAX_BUF];
int resultat_de_commande;
int cb;
descripteur_fichier_lecture = open("/dev/ttyS3",O_RDONLY);
descripteur_fichier_ecriture = open("/dev/ttyS3",O_WRONLY);
cb = read(descripteur_fichier_lecture,tampon_de_reception,sizeof(tampon_de_reception));
resultat_de_commande=interpretation_execution_commande( tampon_de_reception, cb);
cb = mise_en_forme_résultat_commande(&tampon_d_emission,resultat_de_commande);
cb = write(descripteur_fichier_ecriture, tampon_d_emission, cb);
close( reference_preriphérique);
Le code est exécuté dans le contexte d'un processus dont aucune entrée dans le table des descripteurs n'est encore utilisée. Pour la première opération d'ouverture, ici en lecture, descripteur_fichier_lecture = open("/dev/ttyS3",O_RDONLY); la première entrée dans la table sera utilisée. La variable lecture descripteur_fichier_lecture se verra attribuer la valeur 0. La seconde opération d'ouverture cette fois en écriture descripteur_fichier_ecriture = open("/dev/ttyS3",O_WRONLY); la variable descripteur_fichier_ecriture c'est la valeur 1 qui lui sera affectée. Le comportement de l'allocation des descripteurs est déterministe puisque la valeur attribuée est celle du descripteur libre qui a la valeur la plus faible dans la table de descripteur du processus en cours. Ce comportement est spécifié dans le standard POSIX pour l'appel système open(). Si la fonction mise_en_forme_résultat_commande() créer un nouveau processus et exécute un nouveau programme, par héritage de la table de descripteurs de processus « père » le code du processus « fils » pour lire et écrire respectivement sur l'entrée et la sortie standard pourrait ressembler à ceci :
cb = read(STDIN_FILENO,autre_tampon_de_reception,sizeof(autre_tampon_de_reception));
...
cb = write(STDOUT_FILENO, autre_tampon_d_emission, cb);
C'est ce système d'entrée sortie standard qui utilise par exemple la fonction printf(). Elle utilise l'appel système write() avec comme paramètre la valeur descripteur de fichier défini par STDOUT_FILENO. Même raisonnement avec la fonction getc() qui, elle, utilise l'appel système read() avec cette fois-ci la valeur descripteur de fichier défini par STDIN_FILENO. Le code de ces fonctions printf() et getc() reste identique quelque soit le périphérique d'entrée et sortie utilisé. Elles font aussi partie du standard POSIX.
Sur un système d'exploitation mettant en œuvre ces mécanismes, on peut réaliser ce type d'opération depuis l'interface de ligne de commande (shell) et ainsi effectuer plusieurs traitements élémentaires consécutifs sur un flux de données.
prog1 </dev/ttyS3 | prog2 | prog3 > /tmp/fichier_resultat.txt
Le tout fichier, les entrées et sorties standards et l'héritage des tables de descripteur, certes très simple, sont très intéressantes pour la réutilisation du code et pour cause ils ont été pensés dans cette optique. Une autre notion est aussi apparue, celle de processus. Nous allons l'aborder dans le paragraphe suivant.
2.5 Processus et thread
Nous avons vu jusqu'à présent comment organiser les flux données en provenance et à destination des périphériques et jusqu'à la frontière de l'application. Maintenant, regardons comment il est possible d'organiser le traitement de ces données réalisées par l'application.
Les systèmes comme eCos ou RTEMS implémentent les interfaces POSIX, possèdent une gestion de périphérique et une gestion de fichiers. Ils ont une approche système d'exploitation. L'objectif est toujours d'obtenir un exécutif temps réel mais avec des concepts plus avancés. Pour ces deux systèmes, l'exécutif est vu comme un processus unique contenant plusieurs fils d'exécution que l'on appelle aussi thread. Par conséquent, pour reprendre ce que nous avons vu dans le chapitre précédent, il n'y a qu'une seule table de descripteurs qui fait aussi office de table centrale des fichiers ouverts.
Avec lepton il y a toujours un exécutif temps-réel unique mais nous avons ajouté une organisation logique qui découpe les applications logicielles en processus. Chaque processus peut contenir plusieurs threads. Donc suivant l'application que l'on souhaite réaliser il est possible d'organiser son code sous la forme d'un seul processus avec plusieurs threads, comme on l'aurait fait avec eCos ou RTEMS mais il est aussi possible de créer plusieurs processus chacun pouvant accueillir à leur tour plusieurs threads. Comme il s'agit d'un seul exécutif temps réel ce ne sont pas de véritable processus, le code exécutable n'est pas réellement chargé dynamiquement et relogé en mémoire. Avec ce découpage logique en processus, il est possible d’accéder à certaines propriétés des systèmes UNIX comme la redirection des entrées et sorties, l'héritage entre les processus, et de lancer des commandes qui sont vues comme des programmes depuis le système de fichiers. Ces propriétés permettent d'aller encore plus loin dans la réutilisation logicielle.
Ces différentes organisations sont décrites dans le standard POSIX 1003.13 qui a été créé pour classifier les architectures logicielles temps-réel orientées micro-contrôleur. Ce standard définit pour chaque profil les fonctionnalités attendues et les interfaces logicielles nécessaires. Les noyaux temps-réel simples peuvent être classés dans le profil PSE51, pas de gestion de fichiers ni de terminal; ils sont utilisés sur micro-contrôleurs sans MMU (Memory Management Unit). Les systèmes comme eCos et RTEMS en PSE52 sont plus évolués proposent des systèmes de gestion de fichiers, des dispositifs d'interaction avec l'utilisateur et ne requiert pas de MMU. Dans le cas de lepton, on peut le situer entre les profils PSE52 et PSE53 puisqu'il amène une notion de multi-processus et pourra bientôt utiliser efficacement la protection mémoire fournie par des dispositifs matériels comme la MPU (Memory Protection Unit).
L'utilisation ou non de la MMU a des conséquences sur les possibilités offertes par le système et en particulier sur la création de processus. Si le système n'est pas en mesure de gérer la MMU l'appel système POSIX fork() ne peut être implémenté. Seule l'utilisation de vfork() est possible. D'autres impacts sont à prévoir, notamment pour l'appel système mmap() qui ne pourra supporter qu'une partie des options possibles.
2.6 Résumé
Nous avons vu comment nous sommes arrivés à l'architecture d'un système d'exploitation en voulant favoriser la réutilisation logicielle et faciliter le développement en proposant, comme premier objectif, un accès aux périphériques d'entrées et de sorties le plus générique possible. Ensuite nous avons aborder la gestion de fichiers pour obtenir une orientation tout fichier pour notre système. Pour finir la notion de processus et de thread, qui ne sont pas dissociés de cette approche du tout fichier, ont aussi été ajoutées.
Tous ces éléments permettent de réaliser des traitements puissants en réutilisant des fonctions élémentaires organiser sous forme de programme réutilisable à l'envie.
À présent nous avons un ensemble de possibilité qui vont bien au-delà de la simple gestion de périphériques et accessible au moyen de fonctions et d'appels système. Elles permettent d'obtenir un système bien plus complet, cohérent et puissant.
Évidemment l'architecture UNIX qui nous a servi de base d'inspiration n'est pas la seule pour atteindre cet objectif. C'est un parti pris pour des raisons de culture académique mais pas uniquement. Le monde «open source» est historiquement plus proche de ce type d'architecture et permet d’augmenter la taille du vivier de ressources disponibles (code source, développeurs, outils).
L'ensemble des concepts succinctement présentés dans ce chapitre pourra être approfondi par les ouvrages d'Andrew Tanenbaum[ATNBM], Jean-Marie Rifflet [JMRFFLT] et Christophe Blaess [CBLSS]. Ils m'ont servi de référence tout au long du développement de Lepton.
3. Lepton
Entrons dans le vif du sujet pour voir comment lepton intègre les concepts présentés dans les paragraphes précédant. Nous verrons aussi l'interaction de ces mécanismes avec le noyau temps-réel.
3.1 Architecture générale
Le système lepton est architecturé sur une structure multicouche dont la plus haute est la couche applicative qui utilise comme interface avec le noyau celle décrite dans le standard POSIX.
Les services offerts par le noyau sont basés sur un ensemble de briques logicielles open source. Rien n'empêche d'intégrer pour son propre développement des solutions propriétaires.
Fig. 3 : Illustration lepton architecture générale.
Parmi les services disponibles, il y a en premier lieu les primitives temps réels POSIX. Ces dernières sont basées sur les primitives temps-réel internes proposées par le noyau temps réel «eCos». Dans les premières versions de lepton nous utilisions un noyau temps réel propriétaire, ensuite nous avons intégré «eCos» pour offrir une solution entièrement open source. Les deux solutions ont cohabité très longtemps d'où la présence d'une interface appelée KAL (Kernel Abstraction Layer). C’est un ensemble de fonctions et de quelques macros génériques qui permettent de diminuer le couplage entre les services implémentés dans lepton et le noyau temps réel utilisé. Lepton n'utilise que les primitives de base du noyau temps-réel, celles que l'on retrouve dans quasiment toutes les implémentations courantes à savoir la gestion de tache, les sémaphores, les mutex, les timers logiciels et la HAL (Hardware Abstraction Layer). Les caractéristiques temps réel du noyau sont bien sûr conservées.
Le noyau offre aussi un système de fichiers virtuel (VFS virtual file system) qui permet d'intégrer et d'utiliser par l’intermédiaire d'une interface générique toute sorte de systèmes de fichier (ou de pseudo système de fichier). Une pile protocolaire IP qui utilise pour l'instant la version 1.3.2 de lwIP [LWIP]. La pile uIP [uIP] (dégrafée de contiki) sera prochainement disponible dans le dépôt. Et bien sur la gestion des périphériques que nous allons explorer dans les sections suivantes.
Sur d'autres projets, nous avons eu la nécessité d'utiliser une librairie graphique. Au vu des contraintes d'empreinte mémoire, nous nous sommes intéressés à NanoX et FLTK. Ces deux librairies s'appuient sur les services déjà disponibles dans lepton. De plus, l’architecture inspirée des systèmes UNIX et de l'interface POSIX ont permis un portage rapide.
3.2 Le noyau temps réel.
Lepton, n'implémente pas, du moins pour l'instant, son propre noyau temps réel. Il vient se greffer au-dessus du noyau temps réel pour en utiliser les services de base comme la gestion des tâches, les objets de synchronisation (sémaphore), d'exclusion mutuelle (mutex) et des temporisateurs logiciels (timers). Lepton utilise ces mécanismes de base en interne mais en propose aussi l'accès par des fonctions systèmes POSIX.
Fig. 4 : Illustration architecture lepton et le noyau temps réel
Dans le cas de lepton, nous utilisions au début un noyau temps réel propriétaire (embOS de Segger). Toutefois, malgré les qualités de ce noyau, nous voulions obtenir une solution totalement open source. Le choix s’est porté sur eCos. Nous n’utilisons d'eCos que la partie relative au noyau temps réel, la couche d'abstraction matérielle HAL (Hardware Abstraction Layer) et sa gestion d'interruption particulière.
3.3 Processus, pthreads et fichiers.
Comme nous l'avons vu précédemment Lepton utilise une notion de processus et de threads.Il est, je pense, nécessaire d'établir la relation entre les processus tels qu'ils sont gérés par lepton, les pthread et les tâches ordonnancées et exécutées par le noyau temps réel utilisé.
Pour commencer, les processus de lepton ne possèdent pas sa propre zone mémoire. Le code et les variables ne sont pas chargés et relogés dynamiquement en mémoire.
#include <stdlib.h>
#include "kernel/core/libstd.h"
#include "kernel/core/time.h"
#include "kernel/core/devio.h"
static char var;
int echo_main(int argc,char* argv[]){
char buf[32];
int cb;
int i;
if(argc<1){
while((cb=read(0,buf,sizeof(buf)))){
int i;
for(i=0;i<cb;i++)
if(buf[i]==0x18)//ctrl-x
return 0;
write(1,buf,cb);
}
}
for(i=1;i<argc;i++){
int l=strlen(argv[i]);
cb=0;
while(l-cb){
int w=0;
if((w=write(1,argv[i]+cb,l-cb))<=0)
break;
cb+=w;
}
}
write(1,"\n\r\n",3);
return 0;
}
Dans l'exemple de code d'une application, ici « echo ». La fonction principale (« main ») est préfixée du nom de l'application tel qu'il apparaitra dans le système de fichiers. En fait la fonction int echo_main(int argc,char* argv[]) sera le point d'entrée du fil d'exécution principal du processus qui exécutera le code de l'application « echo ». Par conséquent la variable static char var; déclarée en dehors de la fonction principale sera unique pour toutes les applications echo qui pourront être lancés simultanément. Au contraire des variables déclarées dans la fonction principale qui, elles, sont allouées dynamiquement dans la pile du fil d’exécution principale et qui, elles, ont une existence propre à chaque instance d'exécution de l'application. L'application echo est visible sous la forme d'un fichier. Le contenu du fichier n'est pas le code de l'application comme on l'aurait eu avec un système d'exploitation comme GNU/Linux et qui aurait chargé ou pour ucLinux relogé le code dynamiquement en mémoire. Avec lepton le contenu du fichier permet de retrouver l'adresse de la fonction int echo_main(int argc,char* argv[]). Donc pour que plusieurs instances d'une application puissent être lancées concurremment il faut veiller à ne pas utiliser de variables globales et les déclarer de telle manière qu'elle soient allouées sur la pile du fil d'exécution. En revanche si l'application ne peut avoir qu'une seule instance cette limitation ne s'impose. Il s'agit donc d'un artifice pour obtenir une notion de processus. Le processus ici à pour objectif d'organiser les applications de telle manière qu'elles puissent être réutilisées facilement et aussi mettre en oeuvre un certain nombre de propriétés des systèmes UNIX que nous avons abordé théoriquement dans le chapitre précédent et dont nous allons voir la mise en pratique. Il est prévu d'utiliser dans une prochaine évolution de vrai fichier exécutable et de reloger le code dynamiquement, mais il faut étudier l'impact sur la taille du code et les performances. Une solution comme XIP (eXecute In Place) peut être intéressante.
Le processus au sens lepton est en fait une structure de données, à laquelle est attachée au moins un fil d’exécution (c'est-à-dire un pthread) que nous appellerons pthread principal, avec la possibilité de créer d'autres fils d'exécution, les pthreads secondaires. Les fils d'exécution, les pthreads, s'appuient sur les unités d'exécutions (les tâches) qui sont contrôlées par l'ordonnanceur du noyau temps réel. Le noyau temps réel ne voit que ses tâches et leurs applique ses règles d'ordonnancement. Ces règles d'ordonnancement sont ainsi de fait appliquées aux pthreads de Lepton. Lepton hérite donc des propriétés de l’algorithme d’ordonnancement du noyau temps-réel utilisé.(voir figure 5).
Fig. 5 : Illustration processus, pthreads et tâches.
La structure de donnée que représente le processus contient aussi une table pour les signaux standards et temps réels (figure 6). Chaque entrée dans la table des signaux fait la correspondance entre le numéro du signal (numéro qui correspond à la position dans ce tableau) et un pointeur de fonction. L'adresse de cette fonction sera le point départ vers lequel le fil d'exécution du pthread concerné sera détourné lors de la réception d'un signal.
Fig. 6 : Illustration des processus et pthread.
Autre information disponible : la table des descripteurs (figure 7). Comme nous l'avons vu plus haut, pour chaque ouverture d'un fichier, la fonction utilisée retourne un identifiant unique dans le contexte du processus. Cet identifiant, appelé descripteur du fichier, est en fait un index dans la table des descripteurs de fichiers du processus dans lequel l'ouverture a été réalisée.
Fig. 7 : Illustration relation entre la table des descripteurs du processus et la table globale des fichiers ouverts
Cette table est un tableau à deux dimensions dont la première correspond aux descripteurs de fichiers et la seconde correspond à un index dans la table globale de tous les fichiers ouverts dans le système. Cet index nous l’appellerons descripteur global.
Chaque entrée dans la table globale contient une structure de données regroupant tout un ensemble d'informations qui seront nécessaires pour les opérations ultérieures à l'ouverture du fichier.
Dans la suite de cet article un descripteur de fichier sera noté fd et type entier et le descripteur dans la table globale des fichiers ouverts sera quand à lui nommé « desc » et de type desc_t.
Voici ci-après la liste de toutes les informations disponibles dans la structure de données associées à chaque entrée dans la table globale des fichiers ouverts (sys/root/src/kernel/fs/vfs/vfstypes.h):
/* Identifiant du processus qui utilise
le fichier associé à cette entrée.*/
typedef struct ofile_s{ pid_t owner_pid;
/* Identifiant du pthread qui utilise le
fichier associé à cette entrée en lecture.*/
kernel_pthread_t* owner_pthread_ptr_read;
/* Identifiant du pthread qui utilise le fichier
associé à cette entrée en écriture.*/
kernel_pthread_t* owner_pthread_ptr_write;
/* Descripteur courant associé à cette entrée
dans le table des fichiers ouverts.*/
desc_t desc;
/* Descripteurs des fichiers de périphérique
ou de module sur lesquels se trouve empilé le
fichier de périphérique ou du module associé
à cette entrée.*/
desc_t desc_nxt[2];
/*descripteur du fichier de périphérique ou
du module empilé sur le fichier de périphérique
ou du module associé à cette entrée.*/
desc_t desc_prv;
/*indique si cette entrée est utilisée
(1) ou libre (0).*/
int used;
/* Compteur de lecteurs. Ce compteur ne peut
être incrémenté que si les drapeaux O_RDONLY ou
O_RDWR sont levés dans le champs oflag de
cette structure.*/
char nb_reader;
/*compteur d’écrivains. Ce compteur ne peut
être incrémenté que si les drapeaux O_WRONLY
ou O_RDWR sont levés dans le champs oflag de cette
structure. Lorsque le fichier associé à cette
entrée est ouvert la première fois les compteurs
concernés sont incrémentés de 1. Les compteurs
concernés sont incrémentés de 1 pour tout héritage
du descripteur de cette entrée par un processus
(vfork()) ou duplication (dup(), dup2()). Les
compteurs concernés sont décrémentés de 1 lors
de chaque opération de fermeture close(),
_vfs_close() sur le descripteur de cette entrée.*/
char nb_writer;
/* Taille en octets du fichier associé à cette
entrée. int oflag; les drapeaux d’ouverture
passés en argument lors de l’utilisation de la
fonction _vfs_open() sont copiés dans ce champs.*/
int size;
/* Drapeaux d’attributs du fichier. Lors de
l’ouverture les attributs de l’inoeud associés
au fichier sont copiés dans ce champs. Ces
drapeaux permettent de déterminer le type de
fichier: régulier, spécial, répertoire.*/
int attr;
/* Position courante de la tête de lecture
dans le fichier associé à cette entrée. Ce
champs est modifié par l’utilisation des
fonctions read(), write(), lseek(), readdir(),
seekdir(), rewinddir() et les fonctions de
l’api vfs qui leur sont associées. La fonction
_vfs_open() agit sur ce champs en fonction de
la présence ou non du drapeau d’ouverture O_APPEND.*/
vfs_off_t offset;
/* Date de la dernière modification du fichier
associé à cette entrée. Ce champs est renseigné
par les fonctions _vfs_open().*/
time_t cmtime;
file_status_t status;
/* Numéro d’inoeud logique du répertoire dans
lequel se trouve le fichier associé à cette entrée.*/
inodenb_t dir_inodenb;
/* Numéro d’inoeud logique du fichier associé
à cette entrée. */
inodenb_t inodenb;
/* pointeur sur la structure d’informations
relatives au montage du système de fichiers
sur le se trouve le fichier associé à cette entrée.*/
mntdev_t* pmntdev;
/* Pointeur sur la structure d’informations
relative aux opérations supportées par le
fichier associé à cette entrée. Ces opérations
sont soit les pointeurs de fonction sur les
opérations dédiées aux pilotes de périphérique
soit les opérations dédiées aux systèmes de
fichiers sur lequel ce trouve le fichier associé
à cette entrée.*/
pfsop_t pfsop;
/* Pointeur non typé sur une structure
d’informations particulière. Ce pointeur est
utilisé pour la persistance des informations entre
les opérations réalisées sur le fichier associé
à cette entrée. Très utile pour les pilotes de
périphérique ou les module( voir les streams).*/
void* p;
/* Sémaphores permettant de protéger l’accès
concurrent aux opérations réalisées sur le fichiers
associé à cette entrée donc partageant le même
descripteur desc.  la protection n’est réalisée
que pour les pthreads accédant au même descripteur.
Si le même fichier est ouvert une seconde fois il
sera associé à une autre entrée dans la table des
fichiers ouverts et aura donc un autre descripteur.
Par conséquent les accès concurrents sur le même
fichier mais par deux descripteurs différent ne
pourront être interdits par ces mécanismes. Il
est alors à la charge du développeur du pilote
de périphériques de coder explicitement ses propres
mécanismes de protection. voir aussi les streams.*/
kernel_sem_t sem_read;
kernel_sem_t sem_write;
#ifdef KERNEL_PROFILER
/* Ce champs n’est utilisé que pour réaliser
les statistiques de performances en entrée/sortie
sur le fichier associé à cette entrée.*/
unsigned short _profile_counter;
#endif
}ofile_t;
Nous allons nous concentrer sur les champs qui nous seront utiles dans les chapitres suivants.
3.4 Appels système
Dans l'architecture de Lepton la communication entre les fonctions système nécessitant des services du noyau est réalisée par l'utilisation d'appels système. Ce ne sont pas des wrappers, c'est à dire une simple surcouche logicielle qui ferait l'interface entre les fonctions POSIX et les fonctions proposées par le noyau temps réel. L'objectif consiste à créer une frontière entre la partie applicative et la partie système. Cette séparation permettra, au moyen d'un mécanisme de protection mémoire matérielle disponible sur certains micro-contrôleurs (ex. la MPU), d'améliorer la sécurité de fonctionnement en sanctuarisant certaines portions de mémoire (RAM ou flash).
Les appels système permettent donc d'obtenir des services auprès du noyau du système d'exploitation au moyen, dans le cas de Lepton, d'une interruption logicielle. Dans d'autres types d'architecture, les requêtes sont envoyées dans des files de messages (exemple micro noyau). Les appels système sont exécutés dans le contexte d'un pthread appartenant à un processus.
Que se passe-t-il lors d'un appel système? Les fonctions de l'interface de programmation système, ici définie par le standard POSIX, vont cacher l'ensemble des mécanismes sous-jacent au programmeur. Ainsi, quel que soit le mécanisme implémenté ou son évolution, l'impact sur le programme qui l'utilise sera mineur. Il est à noter que le standard POSIX définit les fonctions, les paramètres et leur comportement, mais jamais il ne décrit ou impose leurs implémentations, même si les spécifications des interfaces et du comportement peuvent induire implicitement un certain tropisme quant aux mécanismes à implémenter.
Fig. 8 : Illustration pile utilisateur et pile noyau pour un pthread.
L'illustration du mécanisme d'appel système ne décrit ce dernier que synthétiquement. Le principe général c'est de voir le pthread comme un iceberg avec une partie émergée et une autre immergée. La partie émergée représentant l'espace utilisateur (l'application) et la partie immergée quant à elle représente l'espace noyau. Pour le système d’ordonnancement sous-jacent, il s'agit toujours du même fil d’exécution.
Fig. 9 : Illustration synthétique du mécanisme d'appel système dans lepton.
Les paramètres de la fonction sont placés dans la structure de contrôle (appelé aussi task control block TCB) du pthread courant. Le passage pthread de l'espace utilisateur à celui du noyau est réalisé par l'appel d'un vecteur d'interruption au moyen d'une interruption logicielle. L'interruption logicielle possède la caractéristique d'être générée par une instruction particulière qui lors de son exécution par le micro-contrôleur le fait basculer immédiatement sur le vecteur d'interruption préalablement fixé.
Le code de ce vecteur réalise alors les opérations suivantes :
1. Il récupère et sauvegarde dans la structure de contrôle du pthread le contexte d’exécution qui a été préalablement stockée par le micro-contrôleur sur la pile utilisateur avant de positionner le compteur programme sur le vecteur d'interruption. Dans le contexte d'exécution, on retrouve les valeurs des registres du micro-contrôleur et notamment le pointeur de pile et le compteur programme.
2. Ce sont ces deux valeurs correspondant aux registres, pointeur de pile et le compteur programme, qui vont être modifiés directement dans la pile, de telle manière que le pointeur de pile va prendre la valeur de l'adresse de la pile noyau réservé pour le pthread appelant et le compteur programme va prendre pour valeur l'adresse de la fonction noyau.
L'instruction de retour d'interruption est exécutée et entraîne la restauration des registres stockés (et modifiés dans l'étape précédente) dans la pile. Le compteur programme prend alors la valeur de l'adresse du code noyau et le pointeur de pile l’adresse de la pile noyau du thread appelant.
Le phread est passé dans l'espace noyau et le code de ce dernier est à présent exécuté, mais il est toujours dans le contexte du pthread appelant (c'est toujours la même unité d'exécution). Il récupère ainsi les paramètres dans la structure de contrôle du pthread afin de réaliser le service demandé et précisé par le numéro d'appel système. Les valeurs de retours sont aussi placées dans cette même structure de contrôle.
À présent il convient de revenir à l'espace utilisateur. On utilise alors le même mécanisme d'interruption logiciel. Le vecteur d'interruption correspondant à l'interruption logicielle est alors exécuté à nouveau, mais là c'est un retour d'appel système. On restaure le contexte d'exécution qui avait été sauvegardé dans l'opération 1 sur la pile noyau. L'instruction de retour d'interruption est exécutée et entraîne la restauration des registres stockés.
Le micro-contrôleur reprend l'exécution du code juste après l'instruction qui a généré l'interruption pour réaliser l'appel système. Nous sommes de retour dans l'espace utilisateur. Les valeurs de retour de l'appel système peuvent être récupérées dans la structure de contrôle du pthread.
Ce principe de fonctionnement nécessite d'avoir une pile utilisateur et une pile noyau pour chaque pthread. Le code noyau tourne dans le contexte du pthread appelant, plusieurs pthread peuvent basculer en mode noyau de manière concurrente. Si, dans le code du noyau, les chemins d'exécution sont différents, plusieurs pthread peuvent alors exécuter ce code concurremment. De plus ce même code noyau peut être préempté.
Bien sûr c'est ce scénario qui se déroule dans la majeure partie des appels système, mais quelques appels système ont des comportements bien plus contraignants. Ces appels système qui réalisent un déroutement du flux d'exécution, comme la réception de signaux standards ou temps réel (ex : kill()), la création de processus (ex : vfork()) ou encore le recouvrement de code (ex : execv()) sont un peu plus complexes à réaliser. Bien que ces appels système soient supportés par lepton, il faudrait un article bien plus long pour expliquer l'ensemble des mécanismes mis en place.
4. Lepton et la gestion de périphériques
Dans ce chapitre nous allons entrer dans le détail de la gestion de périphériques proposée par lepton. L'exemple utilisé est un pilote de périphérique pour une liaison série du type UART sur un micro-contrôleur Kinetis K60 de chez freescale. Il est doté d'un cœur ARM Cortex-M4.
4.1 Modèle de pilote de périphérique
Dans le système type UNIX on retrouve au moins deux types de périphériques : les périphériques de type bloc et les périphériques de type caractère.
Les pilotes de périphériques sous lepton exportent la structure de données suivante (sys/root/src/kernel/fs/vfs/vfsdev.h) et ce quelle que soit la nature du périphérique.
typedef struct{
/*nom du fichier périphérique.*/
const char* dev_name;
/*type de périphérique : S_IFCHR pour
les périphériques de type caractères ou
S_IFBLK pour ceux de type blocs.*/
const int dev_attr;
/*chargement du périphérique. Cette
fonction est appelée au démarrage du noyau.*/
fdev_load_t fdev_load;
/*ouverture du périphérique. Cette fonction
est appelée par open().*/
fdev_open_t fdev_open;
/*fermeture du périphérique. Cette fonction
est appelée par close().*/
fdev_close_t fdev_close;
/*des données sont-elles disponibles? Cette
fonction est utilisée par select(), read().*/
fdev_isset_read_t fdev_isset_read;
/*les données ont-elles été écrites? Cette
fonction est utilisée par select(), write().*/
fdev_isset_write_t fdev_isset_write;
/*lecture du périphérique. Cette fonction
est appelée par read().*/
fdev_read_t fdev_read;
/*écriture sur le périphérique. Cette fonction
est appelée par write().*/
fdev_write_t fdev_write;
/*accès aléatoire sur le périphérique. Cette
fonction est appelée par lseek().*/
fdev_seek_t fdev_seek;
/*configuration du périphérique. Cette fonction
est appelée par ioctl().*/
fdev_ioctl_t fdev_ioctl;
pfdev_ext_t pfdev_ext;
}fdev_map_t;
Nous allons poursuivre l'exploration du modèle de pilote de périphérique proposé par lepton en reprenant l'exemple de la liaison série UART. La liaison série est un périphérique auquel on ne peut pas accéder aux données aléatoirement. Elles sont reçues séquentiellement. Par conséquent il sera classifié comme un périphérique de type caractère.
Nous utiliserons les interruptions pour signaler la réception et la fin d'émission d'un caractère.
Pour commencer, la structure de données du pilote de périphérique de notre liaison série doit être renseignée.
Le premier champ const char* dev_name est un pointeur sur une chaîne de caractères. Cette chaîne de caractère contient le nom du pilote de périphérique tel qu’il sera visible dans le système de fichiers.
Le champ const int dev_attr contient le drapeau qui permet de définir le type de pilote de périphérique. Pour les périphériques de type caractère le drapeau S_IFCHR est utilisé, pour les périphériques de type bloc le drapeau S_IFBLK est requis. Ces drapeaux S_IFCHR et S_IFBLK sont exclusifs entre eux.
Dans notre exemple notre pilote de périphérique aura pour nom ttys3 et donc de type caractère S_IFCHR.
const char dev_k60n512_uart_3_name[]="ttys3\0\0";
static int dev_k60n512_uart_3_load(void);
static int dev_k60n512_uart_3_open(desc_t desc, int o_flag);
extern int dev_k60n512_uart_x_load(board_kinetis_uart_info_t * kinetis_uart_info);
extern int dev_k60n512_uart_x_open(desc_t desc, int o_flag,
board_kinetis_uart_info_t * kinetis_uart_info);
extern int dev_k60n512_uart_x_close(desc_t desc);
extern int dev_k60n512_uart_x_read(desc_t desc, char* buf,int cb);
extern int dev_k60n512_uart_x_write(desc_t desc, const char* buf,int cb);
extern int dev_k60n512_uart_x_ioctl(desc_t desc,int request,va_list ap);
extern int dev_k60n512_uart_x_isset_read(desc_t desc);
extern int dev_k60n512_uart_x_isset_write(desc_t desc);
extern int dev_k60n512_uart_x_seek(desc_t desc,int offset,int origin);
///
dev_map_t dev_k60n512_uart_s3_map={
dev_k60n512_uart_3_name,
S_IFCHR,
dev_k60n512_uart_3_load,
dev_k60n512_uart_3_open,
dev_k60n512_uart_x_close,
dev_k60n512_uart_x_isset_read,
dev_k60n512_uart_x_isset_write,
dev_k60n512_uart_x_read,
dev_k60n512_uart_x_write,
dev_k60n512_uart_x_seek,
dev_k60n512_uart_x_ioctl
};
Commençons par la première fonction qui sera appelée par le noyau de Lepton lors de son démarrage. En effet lors de la phase de mise en route le noyau de lepton parcourt la liste de tous les pilotes de périphériques qui ont été au préalable déclarés dans un fichier de configuration. Lors du parcours de cette liste, le système appellera les fonctions dev_xxxx_load().
Dans notre exemple, c'est la fonction dev_k60n512_uart_3_load() qui sera appelée. Le noyau utilisera le pointeur de fonction fdev_load() préalablement renseigné dans la structure dev_k60n512_uart_s3_map de type fdev_map_t. Cette fonction de chargement n'est donc appelée qu'une seule fois. Elle peut être utile pour configurer le matériel spécifique (les broches d'entrées et de sorties des micro-contrôleurs sont souvent multiplexées sur plusieurs périphériques) ou encore vérifier que le périphérique en question est bien présent (exemple une eeprom sur le bus i2c). Si la fonction retourne une valeur nulle, alors le périphérique apparaitra au sein du système de fichiers dans le répertoire « dev » sous le nom, ici ttys3, précisé par le champ const char* dev_name de la structure fdev_map_t. Dans le cas contraire si notre fonction dev_k60n512_uart_3_load() retourne une valeur négative, alors notre périphérique ne sera pas visible dans le système de fichiers.
Le code suivant décrit le chargement du pilote de périphérique d'une des liaisons série du micro-contrôleur kinetis K60 de chez freescale.
int dev_k60n512_uart_3_load(void) {
volatile unsigned int reg_val = 0;
/*configure PINS*/
hal_set_pin_function(UART3_RX);
hal_set_pin_function(UART3_TX);
/*enable clock gating (SIM->SCGC4 |= SIM_SCGC4_UART3_MASK)*/
HAL_READ_UINT32(REG_SIM_SCGC4_ADDR, reg_val);
reg_val |= REG_SIM_SCGC4_UART3_MASK;
HAL_WRITE_UINT32(REG_SIM_SCGC4_ADDR, reg_val);
return dev_k60n512_uart_x_load(&kinetis_uart_3);
}
Pour améliorer la réutilisation logicielle, les mécanismes qui permettent de gérer les UART sont identiques sur les trois périphériques. La seule différence est l'adresse de base des registres et les broches d'entrées et sorties. Cette partie spécifique est implémentée dans la fonction dev_k60n512_uart_3_load() le code générique au chargement est quant à lui disponible dans la fonction dev_k60n512_uart_x_load().
int dev_k60n512_uart_x_load(board_kinetis_uart_info_t * kinetis_uart_info){
volatile unsigned char regval;
pthread_mutexattr_t mutex_attr=0;
kernel_pthread_mutex_init(&kinetis_uart_info->mutex,&mutex_attr);
/*configure baud rate*/
hal_freescale_uart_setbaud(kinetis_uart_info->uart_base,
kinetis_uart_info->speed);
/*no parity + 8 bits data*/
regval = 0;
HAL_WRITE_UINT8(kinetis_uart_info->uart_base + REG_UART_C1, regval);
return 0;
}
Une fois cette étape franchie et que notre pilote de périphérique est à présent visible dans le système de fichiers, il est alors possible de l'ouvrir au moyen de la fonction POSIX open(). La fonction POSIX open() fait appel au noyau. Le noyau utilisera la fonction dev_k60n512_uart_3_open() exportée par le pilote de périphérique concerné en utilisant le pointeur de fonction fdev_open de la structure dev_k60n512_uart_s3_map de notre pilote de périphérique.
Fig. 10 : Illustration graphe d'appel des fonctions POSIX d'e/s vers les fonctions du pilote de périphérique.
La fonction dev_k60n512_uart_3_open()est appelée lors de l'ouverture du pilote de périphérique. Dans cette fonction, le mode d'ouverture (lecture seule, écriture seule , lecture et écriture) est passé en paramètre.
int dev_k60n512_uart_3_open(desc_t desc, int o_flag) {
return dev_k60n512_uart_x_open(desc, o_flag, &kinetis_uart_3);
}
Le mode d'ouverture permet de valider notamment les interruptions concernées (ex. : émission et/ou réception), les vecteurs d'interruptions sont renseignés et le périphérique est mis sous tension (dans de nombreuses architectures de micro-contrôleur, les périphériques peuvent être mis ou non sous tension dynamiquement permettant ainsi de faire de réduire la consommation d'énergie). Le pilote de périphérique doit sauvegarder le descripteur (descripteur dans la table des fichiers ouverts) qui est associé à chacun des modes d'ouverture, c'est à dire en lecture et en écriture. Dans notre exemple, ces descripteurs sont sauvegardés dans la structure de données board_kinetis_uart_info_t et respectivement dans les champs desc_rd pour le descripteur de lecture (réception) et desc_wr pour le descripteur d'écriture (émission).
int dev_k60n512_uart_x_open(desc_t desc, int o_flag,
board_kinetis_uart_info_t * kinetis_uart_info){
if(o_flag & O_RDONLY) {
if(kinetis_uart_info->desc_r<0) {
kinetis_uart_info->desc_r = desc;
}
else
return -1; /*already open in this mode*/
}
if(o_flag & O_WRONLY) {
if(kinetis_uart_info->desc_w<0) {
kinetis_uart_info->desc_w = desc;
kinetis_uart_info->output_r = -1;
}
else
return -1; /*already open in this mode*/
}
if(!ofile_lst[desc].p)
ofile_lst[desc].p=kinetis_uart_info;
/*unmask IRQ*/
if(kinetis_uart_info->desc_r>=0 && kinetis_uart_info->desc_w>=0) {
cyg_interrupt_create((cyg_vector_t)kinetis_uart_info->irq_no,
kinetis_uart_info->irq_prio,
// Data item passed to interrupt handler
(cyg_addrword_t)kinetis_uart_info,
_kinetis_uart_x_isr,
_kinetis_uart_x_dsr,
&kinetis_uart_info->irq_handle,
&kinetis_uart_info->irq_it);
cyg_interrupt_attach(kinetis_uart_info->irq_handle);
cyg_interrupt_unmask((cyg_vector_t)kinetis_uart_info->irq_no);
HAL_WRITE_UINT8(kinetis_uart_info->uart_base + REG_UART_C2, UART_X_ALLOWED_IRQS);
}
return 0;
}
Pour une liaison série, l'ouverture (du moins pour notre exemple) est exclusive pour un mode donné. Vous ne pouvez ouvrir qu'une seule fois le périphérique en lecture et une autre fois en écriture. Évidemment, plusieurs pthreads (ou processus) peuvent partager le périphérique.
La fonction de fermeture dev_k60n512_uart_s3_close() quant à elle assure les opérations inverses effectuées lors de l'ouverture. Elle invalide les interruptions en fonction du mode d'ouverture, positionne les vecteurs d'interruption vers le vecteur par défaut et éventuellement place le périphérique hors tension.
À présent, nous allons nous intéresser à la réception et l'émission de données sur le périphérique.
Fig. 11 : Illustration interaction entre le pilote de périphérique et la fonction POSIX read()
Commençons par la réception. Lorsqu'un caractère est reçu par le périphérique, le ou les caractères sont directement placés dans un tampon matériel. Parfois il n'y a qu'un registre d'un octet (là ce n’est vraiment pas de chance), mais le plus souvent il y a au moins une FIFO de quelques octets (8 octets voire 16, pour les architectures les plus généreuses). Ensuite, une interruption est générée. Le contrôleur d'interruption du microcontrôleur saute vers le vecteur d'interruption préalablement renseigné (voir ouverture du périphérique). Pour l'exemple nous utiliserons le périphérique de liaison série sans les mécanismes matériels comme la FIFO ou le DMA. Ce sera donc une réception simple caractère par caractère, certes pas très efficace, mais plus clair me semble-t-il, car tout devient nécessairement explicite. Pas de mécanismes cachés.
La gestion d'interruption d'eCos est réutilisée par lepton. Les interruptions sont traitées en deux temps.
cyg_uint32 _kinetis_uart_x_isr(cyg_vector_t vector, cyg_addrword_t data) {
cyg_interrupt_mask(vector);
cyg_interrupt_acknowledge(vector);
_kinetis_uart_stat.isr++;
return CYG_ISR_HANDLED | CYG_ISR_CALL_DSR;
}
Premier temps, le code de la fonction pointée par le vecteur va alors être exécuté (ici _kinetis_uart_isr() ).
Cette fonction va signaler à l'aide de primitives spécifiques au noyau temps réel eCos qu'il faut appeler la fonction de traitement d'interruption différée qui lui est associée (voir dans le code de la fonction dev_k60n512_uart_x_open() décrit plus haut l'utilisation de la primitive eCos cyg_interrupt_create() qui permet de réaliser cette association entre une interruption et les deux fonctions de traitement de l'interruption).
Dans le contexte de gestion d'interruption propre à eCos le traitement est réalisé dans la seconde partie de la gestion d'interruption. Dans notre exemple c'est la fonction _kinetis_uart_x_dsr(). La valeur du caractère reçu dans le registre de réception est placée dans un tampon de réception logiciel. Le mécanisme implémenté est à la liberté de chacun, ici il s'agit d'un simple petit tampon tournant. Lorsque ce tampon logiciel était auparavant vide, on signale alors que des données en lecture sont disponibles. C'est le rôle de la macro-fonction __fire_io_int(ofile_lst[desc_rd].owner_pthread_ptr_read). Cette macro-fonction permet de signaler le changement d'état dans le pilote de périphérique. Pour la réception, dès que le tampon de données est passé de l'état «données non disponibles» à l'état « données disponibles ». On considère le tampon de réception vide lorsque les pointeurs de lecture et d'écriture dans le tampon tournant sont égaux.
void _kinetis_uart_x_dsr(cyg_vector_t vector, cyg_ucount32 count, cyg_addrword_t data) {
board_kinetis_uart_info_t * p_uart_info = (board_kinetis_uart_info_t*)data;
volatile unsigned char uart_sr;
/*read status*/
HAL_READ_UINT8(p_uart_info->uart_base + REG_UART_S1, uart_sr);
/*transmit*/
if(p_uart_info->xmit && (uart_sr & REG_UART_S1_TDRE)) {
if(p_uart_info->output_r != p_uart_info->output_w) {
/*send data*/
HAL_WRITE_UINT8(p_uart_info->uart_base + REG_UART_D,
p_uart_info->output_buffer[p_uart_info->output_r++]);
}
else {
/*all data are sent*/
volatile unsigned char uart_reg;
HAL_READ_UINT8(p_uart_info->uart_base + REG_UART_C2, uart_reg);
uart_reg &= ~REG_UART_C2_TIE;
HAL_WRITE_UINT8(p_uart_info->uart_base + REG_UART_C2, uart_reg);
p_uart_info->xmit=0;
__fire_io_int(ofile_lst[p_uart_info->desc_w].owner_pthread_ptr_write);
}
_kinetis_uart_stat.dsr_tx++;
}
/*receive*/
else if(uart_sr & REG_UART_S1_RDRF) {
HAL_READ_UINT8(p_uart_info->uart_base + REG_UART_D,
p_uart_info->input_buffer[p_uart_info->input_w]);
/*receive buffer status:data available now ! signal pthread*/
if( p_uart_info->input_w== p_uart_info->input_r)
__fire_io_int(ofile_lst[p_uart_info->desc_r].owner_pthread_ptr_read);
p_uart_info->input_w = (p_uart_info->input_w+1) & (UART_RX_BUFFER_SIZE-1);
}
cyg_interrupt_unmask(vector);
}
Que se passe-t-il dans la fonction de lecture POSIX read() ? La première opération consiste à vérifier la disponibilité de données dans le tampon de réception. Pour cela, la fonction read() utilise le pointeur de fonction fdev_isset_read() de la structure dev_k60n512_uart_s3_map de notre pilote de périphérique. Ce pointeur de fonction a pour valeur l'adresse de la fonction dev_k60n512_uart_s3_isset_read(). Si des données sont disponibles, la fonction retourne 0. Dans le cas contraire, elle retourne -1. Ce comportement est générique quel que soit le pilote de périphérique de type caractère. Si des données sont disponibles alors c'est la fonction dev_k60n512_uart_s3_read() exportée par le pilote de périphérique qui est, à son tour, appelée par l'intermédiaire du pointeur de fonction générique fdev_read(). Dans le cas où des données ne sont pas disponibles et que le périphérique est utilisé en mode bloquant (pas de drapeau O_NONBLOCK), la macro fonction __wait_io_int() met le pthread courant en attente d'une signalisation en provenance du pilote de périphérique. L'automate d'état de la figure 11 illustre ces différentes transitions.
Fig. 12 : Illustration signalisation de la réception de données
Les macro-fonctions __wait_io_int() et __fire_io_int() utilisent respectivement les primitives kernel_sem_wait() et kernel_sem_post(). Ces fonctions permettent de s'abstraire des fonctions fournies par le noyau temps réels. kernel_sem_wait() utilise la fonction cyg_semaphore_wait() et kernel_sem_post() quant à elle utilise la fonction cyg_semaphore_post().
La fonction dev_k60n512_uart_s3_read() permet de copier les données disponibles du tampon de réception du pilote de périphérique dans le tampon de réception utilisateur. L'adresse de ce tampon utilisateur est passée en paramètre lors de l'appel de la fonction POSIX read(), ainsi que la taille de ce dernier. En sortie, la fonction retourne la quantité de données copiées dans le tampon utilisateur. Cette valeur est aussi retournée par la fonction read().
int dev_k60n512_uart_s3_read(desc_t desc, char* buf,int size){
board_kinetis_uart_info_t * p_uart_info = (board_kinetis_uart_info_t*)ofile_lst[desc].p;
int cb = 0;
int count = 0;
cb = (size>=INPUT_BUF_SIZE)?INPUT_BUF_SIZE:size;
cyg_interrupt_mask((cyg_vector_t)KINETIS_UART_IRQ_NO);
while(p_uart_info->_input_r != p_uart_info->_input_w && count < cb) {
buf[count++] = p_uart_info->_input_buf[p_uart_info->_input_r];
if(++p_uart_info->_input_r >= INPUT_BUF_SIZE) {
p_uart_info->_input_r = 0;
}
}
cyg_interrupt_unmask((cyg_vector_t)KINETIS_UART_IRQ_NO);
return count;
}
Pour l'émission de données sur la liaison série, le principe est le même. La fonction POSIX write() est alors utilisée. Le pthread utilisant cette fonction se met en attente de la fin d'émission. La signalisation du pilote de périphérique pour indiquer la fin de l'opération est effectuée lorsque le tampon d'émission du pilote de périphérique passe de l'état « données disponibles » à l'état « données non disponibles ». Donc, toujours sur cette transition d'état. Il est inutile de poster des évènements à chaque octet envoyé par le périphérique.
Fig. 13 : Illustration interaction entre le pilote de périphérique et la fonction POSIX write()
La fonction POSIX write() appelle la fonction dev_k60n512_uart_s3_write() par l'intermédiaire du pointeur de fonction fdev_write() de la structure dev_k60n512_uart_s3_map de notre pilote de périphérique. La fonction dev_k60n512_uart_s3_write() copie les données à transmettre dans le tampon d'émission du pilote de périphérique. Le premier octet est ici copié dans le registre d'émission. La fonction dev_k60n512_uart_s3_write() retourne la quantité de données effectivement copiées dans le tampon d'émission. La fonction write() met alors en attente le pthread courant de la fin de l'opération d'émission en appelant la macro fonction __wait_io_int().
Lorsque ce dernier est transmis, une interruption matérielle est générée et la routine associée est appelée. Dans l'architecture du Kinetis, le même vecteur d'interruption est utilisé pour l'émission et la réception. Pour discriminer si c’est une interruption due à une émission ou à une réception (voir les deux simultanément, ce n'est pas exclusif) on vient lire le registre de statuts du périphérique (voir plus haut le code de la fonction d'interruption _kinetis_uart_x_dsr()).
Fig. 14 : Illustration signalisation pour l'émission de données
Si des données sont encore disponibles dans le tampon d'émission du pilote de périphérique alors on copie l'octet suivant dans le registre d'émission du périphérique et on sort de la fonction d'interruption. Dans le cas contraire, c'est dire s'il n'y a plus de données disponibles dans le tampon d'émission, alors le pilote de périphérique signale le pthread en attente de la fin de l'opération d'émission en utilisant la macro-fonction __fire_io_int(ofile_lst[desc_wr].owner_pthread_ptr_write). Le pthread peut reprendre son fil d'exécution et sort de la fonction POSIX write(). Cette dernière retournera l'information sur la quantité de données effectivement transmises qui lui a été retournée auparavant par la fonction dev_k60n512_uart_s3_write() du pilote de périphérique.
Dans cet exemple d'émission de données, il est nécessaire d'attendre la fin de l'envoi complet des données pour que la fonction write() se termine en retournant le nombre d'octets envoyé. Mais il peut être judicieux de modifier ce comportement surtout si le périphérique peut gérer un tampon interne d'émission. La fonction write() peut être bloquante uniquement lorsque ce tampon est plein, ce qui évite d'attendre la fin de l'envoi de l'ensemble des données et ainsi de paralléliser un autre traitement de données durant ce temps d'émission. Ce fonctionnement en revanche peut être à éviter dans le cas d'écriture de données sur une périphérique de stockage. Il est peut être préférable dans un tel cas d'attendre la fin de complète de l'opération en cours avant d'en entamer une nouvelle. C'est le rôle du drapeau POSIX O_SYNC qui force ce dernier comportement. L'implémentation de ces comportements peut être réalisée dans le pilote de périphérique en fonction du positionnement de ce drapeau. C'est donc à la charge du développeur du pilote de périphérique. Le code nécessaire pourrait être aussi intégré dans la couche générique de gestion des pilotes de périphérique (voir la figure 2 du paragraphe 2.2).
4.2 Un peu plus loin avec la gestion de périphériques.
Lepton propose un autre type de périphérique. Il s'agit de modules logiciels que l'on peut monter au-dessus d'un pilote de périphérique matériel ou au-dessus d'un autre module logiciel. Ces modules respectent la même interface que celle utilisée par les pilotes de périphérique matériels.
Fig. 15 : Illustration superposition de modules logiciels.
Le module supérieur utilise l'interface standard de pilote de périphérique du module inférieur. La connexion entre les modules est réalisée via l'appel système POSIX ioctl() avec la commande I_LINK. L'opération inverse utilise la commande I_UNLINK. Les modules ne connaissent pas le type de périphérique ou de module avec lesquels ils sont interfacés. Tout comme l'application, ils ne manipulent que des flux de données entrantes ou sortantes en utilisant les fonctions génériques définies par l'interface de pilote de périphérique. Les modules logiciels sont totalement désolidarisés des contextes matériels et logiciels dans lesquels ils sont utilisés. Ils sont par conséquent portables d'une architecture matérielle à une autre sans modifications.
Comment sous lepton utiliser cette propriété ? Il y a deux possibilités, la première en utilisant directement les appels système POSIX et la seconde en utilisant l'application système mount par l'intermédiaire de l'interpréteur de commande. Cette application utilise bien évidemment les appels système POSIX ioctl() et fattach().
Voici deux exemples pour illustrer le fonctionnement de ce mécanisme. Le premier avec l'affichage de la sortie standard de l'interpréteur de commande sur un écran LCD graphique et le second avec une carte mémoire de type SDCARD accessible par le bus SPI.
Le premier exemple (figure 15.a) montre l'utilisation de deux modules logiciels consécutifs et d'un pilote de périphérique. Le premier module logiciel est un module « frame buffer », il permet de découper la mémoire physique du contrôleur LCD graphique en une ou plusieurs zones mémoire logiques. Le pilote de périphérique assure la communication et les réglages du contrôleur LCD graphique. Le second module logiciel convertit les caractères ASCII en une représentation graphique affichable sur un écran.
Comme expliqué précédemment l'appel système ioctl() avec la commande I_LINK permet de créer une liaison entre module ou pilote de périphérique. L'appel système fattach() permet de créer une entrée dans le système de fichiers et donc accessible sous la forme d'un fichier de type périphérique. Ce fichier spécial de type périphérique représente l'ensemble des modules et pilotes de périphérique interconnectés. Le code suivant décrit les étapes nécessaires pour obtenir l'empilement décrit dans la figure 14.a. Je l'ai un peu édulcoré en omettant les configurations du framebuffer et du contrôleur d'écran pour ne garder que le code permettant d'associer les modules et pilotes de périphérique entre eux.
int fd_lcd0=-1;
int fd_fb=-1;
int fd_fb0=-1;
int fd_tty=-1;
//ouverture de pilote de périphérique du contrôleur d'écran lcd graphique
fd_lcd0=open("/dev/lcd0",O_WRONLY,0);
//ouverture du module logiciel générique frame buffer
fd_fb=open("/dev/fb/fb", O_WRONLY,0);
//liaison entre le frame buffer fb0 et le pilote de périphérique
//du contrôleur d'écran lcd graphique /dev/lcd0
ioctl(fd_fb, I_LINK, fd_lcd0);
La liaison entre le frame buffer et le pilote de périphérique du contrôleur LCD graphique est réalisée. Cet ensemble pilote de périphérique et module logiciel va à présent être accessible dans le système de fichier au moyen de l'appel système fattach(). Ensuite cet ensemble sera ouvert comme un pilote de périphérique classique avec l'appel système open().
//ouverture
fd_fb0 = open("/dev/fb/fb0", O_WRONLY,0);
fd_tty = open("/dev/tty", O_WRONLY,0);
//liaison entre le module console et le frame buffer fb0
ioctl(fd_tty,I_LINK,fd_fb0);
Cette organisation est notamment utilisée dans l'application système initd. L'application initd est le premier processus lancé au démarrage de lepton, il permet de configurer les entrées et sorties standard de l'application systèmes lsh qui est l'interpréteur de commandes par défaut de lepton. De cette manière l'affichage de l'interpréteur de commandes est réalisé sur une partie de l'écran graphique.
Le second exemple présenté dans la figure 15.b est utilisé pour gérer un système de fichier sur un support de stockage de type SDCARD en utilisant une liaison série synchrone SPI.
Ici l'opération se fait encore plus naturellement en utilisant l'application système mount (/sys/root/src/sbin/mount.c). Il suffit d'appeler cette application depuis l'interpréteur de commande lsh de lepton avec la ligne de commande suivante :
mount /dev/sdcard /dev/spi0 /dev/sdcard0
cette commande crée une liaison entre le module de gestion de la SDCARD (/dev/sdcard) et le pilote de périphérique SPI (/dev/spi0). Le résultat de cette liaison est accessible au travers du fichier spécial de type périphérique (/dev/sdcard0).
Si le contenu de la SDCARD à été préalablement formaté pour un système de fichier type FAT il suffit alors de le monter avec la même application système mount.
mount -t fat /dev/sdcard0 /mnt
L'utilisation de ce concept de module logiciel est déjà étendue à de nombreuses situations similaires. On peut citer notamment pour le réseau, les piles protocolaires IP comme lwIP ou uIP sont disponibles dans lepton sous la forme de module logicielle ainsi que les protocoles comme PPP et SLIP. Les profils USB Unité de stockage de masse (Mass Storage Device) Ethernet et liaisons séries utilisent aussi ce modèle. D'autres fonctionnalités à venir le feront aussi.
Le livre « informatique répartie sous UNIX » de Michel Gabassi et Bertrand Dupouy [MGBSSI] explique très bien ces mécanismes.
6. Exemples
Quelques exemples de réutilisation et de portages réalisés assez naturellement grâce aux mécanismes décrits tout au long de cet article.
Je reprendrai le cas de notre interpréteur de commande lsh (/sys/root/src/sbin/lsh.c) qui est une application système proposée par lepton. Il est exploité dans de nombreux contextes d’exécution différents, et ce, parfois au sein du même exécutif temps-réel en cours d'utilisation.
Dans son utilisation classique ses entrées et sorties standards peuvent être redirigées sur une liaison série. Autre cas d'utilisation celle présenté dans le chapitre 7 ou la sortie standard et redirigée vers un écran graphique au travers de plusieurs modules logiciels et un pilote de périphérique. L'entrée standard pouvant quant à elle être dirigée sur un pilote de périphérique série, de clavier ou même d'un fichier de commande.
Autre exemple (figure 16), l'application lsh peut être lancée dans un nouveau processus par un serveur web embarqué pour l'exécution de script CGI. Dans cette situation à chaque nouvelle requête le script est interprété par l'application lsh dans un processus dont les entrées et sorties standards ont été redirigées sur des tubes FIFO.
Fig. 16 : Illustration serveur web exemple d'utilisation des propriétés des système UNIX et utilisation de l'interface POSIX
L'application lsh interprète alors le script et exécute les commandes. Ces commandes peuvent amener à la création d'un nouveau processus pour lancer une nouvelle application comme l'application système ls (/sys/root/src/sbin/ls.c). Plusieurs propriétés certes simples mais puissantes et que l'on retrouve sur les systèmes d'exploitation de type UNIX ont été utilisées : les entrées et sorties standards, l'interface générique d'accès au flux de données et rendu possible par le modèle du tout fichier, l'héritage de la table de descripteur de fichiers entre les processus père et fils.
Enfin, un dernier cas d'utilisation dans un appareil de mesure (figure 17). C'est le contexte pour lequel lepton fut initialement créé.
Fig. 17 : Illustration exemple d'utilisation pour un appareil de mesure
Ici la fonction de mesure est réalisée par l'application msr dans le processus numéro 5. Elle est lancé depuis l'interpréteur de commande de lepton lsh qui lui se trouve dans le processus numéro 2. L'interpréteur est strictement identique à celui utilisé dans l'exemple précédent, il est réutilisé. L'application msr reçoit les arguments de la ligne de commande et les interprète pour réaliser l'opération demandée. Les résultats de mesure sont écrits sur la sortie standard dans un format texte humainement compréhensible, avec par exemple la fonction printf() et au format binaire, la structure de résultat dans le fichier msr.o. Le processus 4 est utilisé par l'application ihm en charge de l'affichage sur un écran graphique des résultats de mesure contenu dans ce fichier msr.o. L'application msr dans cet exemple est lancé par l'utilisateur depuis un terminal connecté sur une liaison série et par l'intermédiaire de l'interpréteur de commande. Mais il pourrait tout aussi bien être lancé depuis l'application ihm ou encore, en réutilisant l'exemple précédent, par une requête HTTP demandant l’exécution d'un script CGI. Nous avons trois possibilités pour interagir avec l'application mesure en réutilisant totalement plusieurs applications.
Dans chacun de ces exemples, l'application lsh est indépendante du contexte d'utilisation, son code n'a jamais été modifié spécifiquement pour s'adapter à chaque situation. Le code est resté totalement générique.
Autres conséquences, la portabilité sur des architectures matérielles différentes. La capacité de portage s'est considérablement accrue notamment via l'utilisation de couche d'abstraction matériel et de la gestion de périphérique qui permettent un découplage entre les couches purement logicielles et celle en charge du matériels et des périphériques.
Conclusion
Il est difficile en quelques pages de faire le tour de lepton et encore plus des concepts utilisés sur les systèmes UNIX. J'espère toutefois avoir montré (peut-être démontré) l'intérêt de cette approche qui devient à présent, avec lepton, un peu plus accessible sur de petits systèmes embarqués. Il y a un petit côté retour vers le futur, puisque ce type d'architecture existe depuis 40 ans. Donc en ce qui nous concerne nous n'avons rien inventé. Juste transposé ce qui existait alors sur des architectures matérielles relativement puissantes vers des micro-contrôleurs récents avec des capacités mémoires de quelques dizaines de kilo-octets.
Lepton n'a pas pour objectif de devenirGNU/Linux. Il est développé pour les contextes matériels oùGNU/Linuxne peut pas trouver sa place. Lepton est àGNU/Linuxce que le karting est à la berline de luxe. Il est au ras de l'électronique tout en proposant des fonctionnalités avancées mais sans toutes les options. Les mécanismes évolués de gestion et protection mémoire comme la MMU ne sont pas implémentés. Il est par conséquent très léger et réactif mais toute erreur de programmation occasionnera une sortie route (crash du système).
C'est un découpage en fonctionnalités élémentaires qui autorise l'organisation logicielle modulaire de lepton. Ce découpage permet d'obtenir un code réutilisable, une meilleure portabilité et un développement, il me semble, plus agréable. Le couplage entre les modules qui le constitue est suffisamment faible pour autoriser des modifications et évolutions tout en limitant l'impact sur l'ensemble du système. Lepton peut être vu comme une sorte de structure d’accueil pour des briques logicielles et en facilité l'utilisation. C'était son objectif et il utilise déjà de nombreuses briques du monde open source comme les piles protocolaire IP des projets uIP et lwIP, le système de fichier yaffs, les librairies graphiques comme nanox et fltk, le serveur web mongoose.
La dimension environnement de développement n'a pu être réellement abordée dans cet article. C'est un aspect qui a été pris en compte dès le début de la conception de l'architecture. L'organisation des fichiers sources et des scripts de compilation permettent de se faire rapidement une idée du fonctionnement du système et de son orientation transversale. L'intégration de son développement dans l’arborescence de lepton se fait naturellement. L'article de Jean-Jacques Pitrolle sur la mise en place de l'environnement de développement explique comment réaliser cette opération.
Toute personne souhaitant contribuer peut rapidement intégrer sont projet dans lepton, que ce soit au niveau application, noyau, système de fichier, pilote de périphérique ou support de nouveau micro-contrôleur. Les contributions pourront être rapidement réutilisées et mises en oeuvre dans le cadre de nouveau développement de produit.
Actuellement le système lepton est utilisé sur environ une douzaine de modèles d'appareils de mesure différents. Leur application va du multimètre haut de gamme graphique à l'oscilloscope portable. De nouveaux projets sont en cours de développement. Chacun de ces projets a amené de nouvelles fonctionnalités qui ont pu être intégralement utilisées sur les autres. La fiabilité aussi s'est améliorée par une plus grande utilisation du système dans des situations différentes ce qui permet d'obtenir une plus grande couverture de code. Plusieurs équipes travaillent depuis une base centralisée et y remontent régulièrement les corrections d'anomalies et améliorations. C'est ce modèle intraentreprise que nous souhaitons exporter avec la mise à disposition de lepton en open sources.
Lepton supporte actuellement près de 150 fonctions et appels système du standard POSIX. Nous l'avons utilisé sur des micro-contrôleur 16 bits et 32 bits, avec des cœurs ARM7, ARM9 et plus récemment sur du Cortex M3 et M4.
Plusieurs améliorations sont envisagées. Notamment la gestion de la protection mémoire avec les dispositifs matérielle comme la MPU que l'on retrouve désormais sur de nombreux micro-contrôleur (notamment ARM Cortex-M4). Le développement de son propre noyau temps-réel. Un projet en ce sens est en cours d'investigation. Le support de l'interface POSIX pour la gestion asynchrone des entrées et sorties. Le code reloger dynamiquement qui permettrait d'obtenir des fichiers exécutables et de réels processus. L'utilisation de QEMU pour l'émulation.
Références
[ECOS] : eCos, eCos, , http://ecos.sourceware.org
[RTEMS] : RTEMS, RTEMS, , http://www.rtems.org
[POSIX] : THE OPEN GROUP, The Single UNIX Specification, Version 4, , http://www.unix.org/version4/
[ATNBM] : Andrew Tanenbaum, Systèmes d'exploitation,
[JMRFFLT] : Jean-Marie Rifflet, Jean-Baptiste Yunes, UNIX : Programmation et communication,
[CBLSS] : Christophe BLAESS, Programmation système en C sous Linux,
[LWIP] : lwIP, lwIP, , http://savannah.nongnu.org/projects/lwip/
[uIP] : Contiki, Contiki, , http://www.contiki-os.org
[MGBSSI] : Michel Gabassi, Bertrand Dupouy, L'Informatique répartie sous Unix,