Comme nous l'avons vu précédemment, le concept d'abstraction matérielle dans RIOT repose sur 4 blocs (voir figure 1):
Sur le principe général, un objet cible pour une application RIOT est vu comme un microcontrôleur monté sur une carte, celle-ci exposant certaines des broches du microcontrôleur, en y connectant éventuellement directement des périphériques externes comme des capteurs, des actionneurs ou des radios.
Il faut donc définir, lors de la compilation, quelle sera la cible matérielle (par exemple iotlab-m3, samr21-xpro, etc). Ensuite, le système de compilation se charge de choisir les parties du code à compiler (dans les dossiers boards, cpu, drivers) et les outils de compilation à utiliser.
L'abstraction matérielle permet de compiler une application RIOT vers différentes cibles matérielles sans en changer le code source. Le résultat de la compilation pour chaque cible se trouve dans un sous-dossier différent dans le dossier de l'application:
<application-dir>/bin/<board-name>
L'abstraction au niveau carte se trouve dans le dossier boards du code source de RIOT.
Dans ce dossier, mis à part common, chaque sous dossier correspond à une carte supportée par le système d'exploitation. Le dossier common contient des parties communes à plusieurs cartes - configurations de périphériques, outils, etc. - et évite ainsi d'avoir trop de duplication de code pour certaines cartes très similaires.
Les noms de dossiers spécifiques à chaque carte correspondent au nom utilisé pour indiquer au système de compilation la cible matérielle à utiliser lors de la génération d'un firmware. Pour simplifier, ces noms correspondent à la valeur affectée à la variable BOARD lors de l'appel à make. Du point de vue du système de compilation, chaque dossier dans board définit son propre module et spécifier BOARD=<nom de board> revient à charger le module correspondant à la carte dans son application RIOT. Pour savoir quelle chaîne d'outils utilisée pour produire le firmware (i.e. quel compilateur, linker, quelle architecture CPU), chaque support de carte doit définir la famille et le modèle de CPU. Cela se fait dans le fichier Makefile.features. Ce fichier Makefile liste également les fonctionnalités (i.e. les pilotes de périphériques en particulier) disponibles pour cette carte. Les configurations des horloges internes et des périphériques sont définis dans le fichier d'entête periph_conf.h. Ce choix est dû au fait que la plupart des périphériques, comme l'UART, l'I2C, le SPI, dépendent de la façon dont ils sont branchés sur la carte (sur quelle broche est connecté RX pour l'UART, etc.). Les structures de configuration sont spécifiques à l'architecture du CPU utilisé sur la carte mais elles permettent d'être utilisées de façon uniforme dans les APIs communes : en effet, ce n'est pas le type de la structure qui est utilisé mais son indice dans la liste des périphériques configurés. Nous reviendrons sur cet aspect plus loin dans cette partie.
Pour terminer, le support d'une carte définit des macros pour manipuler des LEDs, des boutons, éventuellement pour spécifier des configurations de broches différentes pour des capteurs ou radio. Le nom de ces macros est uniforme sur toutes les cartes, ce qui garantit leur portabilité. Toutes ces macros sont définies dans le fichier d'entête board.h.
L'abstraction au niveau CPU se trouve dans le dossier cpus du code source de RIOT.
Pour chaque type de CPU, l'abstraction matérielle suit une approche hiérarchique :
Cette approche hiérarchique permet de minimiser la duplication du code et donc de maximiser sa réutilisation ainsi que de simplifier sa maintenance sur le long terme.
L'objectif des APIs des périphériques/fonctionnalités internes des CPUs est de fournir une interface commune au-dessus d'un maximum d'architectures/familles/types différents. De cette manière, le même code peut s'exécuter quel que soit le matériel sous-jacent. Cela garantit donc la portabilité des applications.
Pour arriver à cette portabilité, RIOT définit des API communes dans des fichiers d'entête se trouvant dans drivers/include/periph. Les implémentations concrètes, dépendantes des CPU, se trouvent dans un sous-dossier periph du code des CPUs. Pour une cible matérielle donnée, l'implémentation correspondante sera choisie par le système de compilation mais la définition des fonctions ne change pas.
Un point important à noter dans RIOT : les implémentations concrètes sont écrites from scratch, en utilisant au minimum les bibliothèques des fabricants. Cela demande beaucoup plus de travail à la communauté, mais cela permet aussi une meilleure efficacité de l'implémentation en général : moins de duplication de code, une taille mémoire réduite au maximum, un code plus lisible. Cela garantit également une indépendance vis-à-vis des fabricants : le seul fichier nécessaire est le fichier d'entête CMSIS définissant la liste des interruptions, les adresses mémoires, les macros spécifiques aux modèles de CPUs.
Pour ajouter le support d'une fonctionnalité interne d'un CPU, c'est-à-dire pour compiler le module correspondant, dans son application, le dévelopeur doit modifier le contenu de la variable FEATURES_REQUIRED dans le Makefile de l'application.
Voici quelques modules (ou features) fournis par ces APIs:
FEATURES_REQUIRED += periph_timer
#include "periph/timer.h"
Il faut également s'assurer que la carte fournit le support pour ce périphérique (ce qui est le cas en général, sinon la carte serait quasiment inutilisable…). Cela se vérifie dans le fichier boards/<cible>/Makefile.features qui doit contenir la ligne:
FEATURES_PROVIDED += periph_timer
La liste complète de ces fonctionnalités est disponible dans la documentation en ligne de RIOT à l'adresse http://doc.riot-os.org/group__drivers__periph.html.
Les 2 parties suivantes présentent en détails l'utilisation des API pour manipuler les timers et les GPIOS mais si vous souhaitez aller plus loin dans l'utilisation de ces APIs matérielles ou découvrir comment utiliser les autres fonctionnalités (RTC, UART, SPI, I2C, ADC, DAC, EEPROM), vous trouverez dans le code source de RIOT des exemples d'applications placés dans le dossier tests. Le nom des applications testant ces APIs commence par periph_<nom du péripherique>. Et même si elles servent de test, ce sont de très bons exemples d'utilisation!
La gestion uniforme des compteurs sur des plateformes hétérogènes est en général un problème complexe. En effet, la plupart des CPUs fournissent plusieurs compteurs internes pouvant compter en parallèle, à des vitesses différentes et dans des conditions différentes (certains peuvent continuer à fonctionner pendant que le CPU dort, alors que d'autres non).
Cela dit, les compteurs sont l'une des fonctionnalités les plus importantes car ils permettent de fournir une base de temps et de générer des évènements (comme des interruptions) à un instant donné ou à différents intervalles de temps.
Dans un microcontrôleur, un compteur ne peut pas compter jusqu'à l'infini car il a un nombre de cycles maximal prédéfini par les spécifications du constructeur. Ce nombre de cycles dépend de la longueur du compteur qui peut être de 8, 16 ou 32 bits suivant les cas. Quand le compteur atteint sa valeur maximale (on dit qu'il overflow), il repart ensuite à zéro.
L'API de gestion des périphériques de type compteurs de RIOT permet de manipuler les compteurs de façon portable sur un grand nombre d'architectures. Les actions possibles sont:
Cette API est documentée ici.
Cette API est assez utile mais s'avère assez limitée pour tirer pleinement parti des fonctions multi-tâches d'un système d'exploitation.
C'est pour remédier à ces limitations que le module système xtimer a été développé. L'idée de ce module est de proposer un mécanisme de multiplexage des compteurs matériels : une seule API permettant de gérer plusieurs temporisations à partir d'un compteur matériel. Le module xtimer offre des temporisations ayant une précision de l'ordre de la microseconde. Comme le module xtimer est implémenté au-dessus de l'API periph_timer, il est totalement portable.
L'API du module est très simple à utiliser. Voici quelques exemples d'utilisation des fonctions de ce module parmi les plus importantes pour :
xtimer_ticks32_t now = xtimer_now();
xtimer_sleep(sec);
Pendant ce temps, le système peut passer la main à un autre thread pour effectuer d'autres tâches.
xtimer_usleep(microsec);
Utiliser une variable de type xtimer_t pour exécuter une fonction de callback à un instant donné :
static void cb(void) { /\* code executed in callback \*/ } [...] xtimer_t timer; timer.callback = cb; xtimer_set(&timer, offset);
L'API du module periph_gpio offre une interface unifiée pour interagir avec les broches d'entrée/sortie du micro-contrôleur. Les fonctions de l'API peuvent être utilisées après avoir inclus l'entête periph/gpio.h.
Cette API est documentée ici.
Les GPIO sont généralement regroupées par port sur un microcontrôleur et l'API utilise la macro GPIO_PIN(<port>, <pin>) pour obtenir l'adresse mémoire du périphérique dans le microcontrôleur. La valeur retournée par cette macro dépend donc de l'architecture du microcontrôleur.
Pour commencer à utiliser une broche GPIO, il faut d'abord l'initialiser avec le bon mode en utilisant la fonction gpio_init(). Tous les modes usuels (INPUT, INPUT avec pull-down, INPUT avec pull-up, OUTPUT, etc) de manipulation des GPIOS peuvent être utilisés, à condition qu'ils soient supportés par la cible matérielle.
gpio_init(GPIO_PIN(0, 5), GPIO_OUT);
Ensuite les fonctions gpio_set et gpio_clear permettent de passer la GPIO respectivement à l'état haut et à l'état bas:
gpio_clear(GPIO_PIN(0, 5)); gpio_set(GPIO_PIN(0, 5));
La gestion des interruptions par les GPIO est désactivée par défaut mais peut être ajoutée avec le module periph_gpio_irq. Cette fonctionnalité sert à optimiser la taille du code lorsque l'utilisation des interruptions GPIO n'est pas nécessaire. L'initialisation d'une GPIO avec interruption s'effectue avec la fonction gpio_init_int().
Voici un exemple simple:
static void gpio_cb(void *arg) { (void) arg; /\* manage interrupt \*/ } int main() { gpio_init_int(GPIO_PIN(PA, 0), GPIO_IN, GPIO_RISING, gpio_cb, NULL); }
Les pilotes de périphériques permettent d'utiliser toutes sortes de capteurs, d'actionneurs, de radios branchés au microcontrôleur sur ses GPIOs ou sur ses bus de données comme l'UART, l'I2C ou le SPI.
Pour garantir leur portabilité sur toute les architectures supportées par RIOT, ces pilotes de haut niveau s'appuient sur les APIs uniformisées vues précédemment pour interagir avec le microcontrôleur.
Le fait que tous ces pilotes soient directement implémentés dans le code source de RIOT facilite le développement d'application et garantit leur maintenance sur le long terme par la communauté.
Le principe utilisé par RIOT pour implémenter ces pilotes de périphériques offre également la possibilité d'utiliser plusieurs périphériques du même type en même temps sur la même application : par exemple, grâce à ce principe, il est possible d'avoir plusieurs capteurs sur le même bus I2C, puisqu'ils utilisent des adresses différentes. Le pilote saura gérer cela car, en mémoire, il utilise un descripteur contenant l'état de chaque périphérique pendant l'exécution d'une application. Cette caractéristique permet de mieux gérer les problèmes de concurrence (accès depuis plusieurs thread au même périphérique) ou de facilement utiliser ces périphériques dans des thread/contextes séparés (puisque chaque thread possède sa propre stack mémoire).
Tous ces pilotes de haut niveau sont implémentés dans le dossier drivers de RIOT et à chaque nom de dossier correspond un module qui pourra être importé dans le Makefile d'une application pour compiler le pilote correspondant.
Il reste ensuite à inclure le fichier d'entête définissant l'interface (#include <driver name>.h).
Comme pour les périphériques internes du CPU, il existe une application de test dans le dossier tests pour chaque pilote. Ces applications s'appellent toutes driver_<driver name> et encore une fois, même si elles servent de test, ce sont de très bons exemples d'utilisation de ces pilotes !
Un aspect important à considérer pour utiliser correctement les pilotes de haut niveau est leur procédure d'initialisation. Chaque pilote dans RIOT définit une fonction d'initialisation de type <driver name>_init() prenant 2 paramètres :
Une séquence typique d'initialisation d'un pilote s'effectuera donc comme dans l'exemple suivant:
#include "driver_name.h" #include "driver_name_params.h" static driver_name_t dev; int main() { [...] driver_name_init(&dev, &driver_name_params[0]); [...] }
Le design des drivers dans RIOT est très bien documenté ici.
En se référant à la documentation en ligne du module xtimer de RIOT, quelles sont les fonctions qui permettent de bloquer un thread dans une durée donnée en microsecondes?
D'après la documentation en ligne de l'API GPIO de RIOT, quels sont les modes usuels de configuration des GPIOs?