Outils pour utilisateurs

Outils du site


cours:informatique:iot:iot_par_la_pratique_inria:340_api_interactions_materiel

Riot: Les APIs matérielles

Couche d'abstraction matérielle

Rappel du concept

Comme nous l'avons vu précédemment, le concept d'abstraction matérielle dans RIOT repose sur 4 blocs (voir figure 1):

  • les CPUs;
  • les boards;
  • les APIs des périphériques
  • et les drivers de haut niveau.

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 niveau carte

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 niveau CPU

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 :

  • au plus haut niveau, on distingue l'architecture, comme par exemple les architectures de type ARM, AVR, MIPS, RISC-V, etc. A ce niveau se trouvent les séquences d'initialisation relative à chaque architecture : point d'entrée exécuté après le redémarrage système, gestion des interruptions, etc. Ces fonctions sont en général spécifiques à une architecture.
  • Ensuite on distingue les spécialisations pour chaque famille dans une architecture donnée. Par exemple dans l'architecture ARM, on retrouve les familles STM32 (STMicroelectronics), SAM (Microchip), Kinetis (NXP). En effet, en fonction du fabricant, l'organisation du silicium varie fortement et il faut donc une implémentation/configuration spécifique.
  • Puis chaque fabricant définit des types comme stm32l0, stm32f7 pour STM32 ou encore sam0, sam3 pour SAM.
  • Enfin, au niveau le plus bas et le plus spécifique de la hiérarchie, on a l'indication du modèle de microcontrôleur comme stm32l072cz ou samd21g18a. C'est le modèle qui est renseigné dans la configuration de la carte. La résolution des dépendances hiérarchiques associées (type → famille → architecture) est ensuite realisée par le système de compilation.

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.

Les APIs des périphériques du CPU

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:

  • Le module periph_timer permet de faire fonctionner les compteurs internes du CPU. Pour cela, il faut:
    • dans le Makefile de l'application, charger ce module dans la liste des fonctionnalités requises:
FEATURES_REQUIRED += periph_timer
  • dans le fichier .c de l'application, inclure l'entête periph/timer.h:
#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
  • Le module periph_i2c fournit le support pour les périphériques I2C. De manière similaire à periph_timer, il faut ajouter le module periph_i2c dans la liste des fonctionnalités requises (en s'assurant que la carte fournit le support) et ensuite inclure l'entête periph/i2c.h dans son ficher .c. Le support I2C dans RIOT ne supporte actuellement que le mode master, il n'est donc pas possible d'implémenter un module qui se comporterait comme un slave I2C (comme un capteur par exemple).
  • Sur le même modèle, on aura les modules periph_uart, periph_spi, periph_pwm, periph_adc, periph_rtc (Real-Time Clock), periph_rtt (Real-Time Ticker).

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!

Les compteurs

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:

  • le démarrage et l'arrêt des compteurs avec les fonctions timer_start()/timer_stop();
  • la lecture de la valeur courante avec timer_read();
  • ou encore la configuration d'une callback appelée après un certain délai via les fonctions timer_set() ou timer_set_absolute().

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 :

  • obtenir le temps système en microsecondes :
xtimer_ticks32_t now = xtimer_now();
  • bloquer l'exécution du code pendant un délai de sec secondes :
xtimer_sleep(sec);

Pendant ce temps, le système peut passer la main à un autre thread pour effectuer d'autres tâches.

  • bloquer l'exécution du code pendant un délai de microsec microsecondes:
xtimer_usleep(microsec);
  • appeler une fonction de callback après un délai de offset microsecondes.

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);

Interagir avec les GPIO

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 haut niveau

Concept

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 !

Initialisation

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 :

  • le premier paramètre est le pointeur vers le descripteur du pilote qui contiendra l'état du périphérique pendant l'exécution de l'application. Généralement, le type est nommé <driver name>_t.
  • le second paramètre est le pointeur vers la structure contenant les paramètres d'initialisation du pilote pour ce périphérique. Chaque implémentation de pilote fournit une configuration d'initialisation avec des paramètres par défaut (généralement ils sont adaptés aux cartes les plus répandues). Ces paramètres sont définis dans le fichier d'entête drivers/<driver name>/include/<driver name>_params.h. Comme les paramètres par défaut utilisent des macros, ils sont facilement surchargeables soit depuis le code de l'application, soit depuis un support de carte. Dans ce second cas, les macros sont définies dans le fichier board.h du support de la carte. Ce mécanisme mêlant fichiers d'entête et macros permet de spécialiser la configuration initiale du driver en fonction des besoins de son application et/ou de la configuration de la carte.

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.

Quiz

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?

  • xtimer_usleep
  • xtimer_usleep64

D'après la documentation en ligne de l'API GPIO de RIOT, quels sont les modes usuels de configuration des GPIOs?

  • GPIO_IN
  • GPIO_IN_PD
  • GPIO_IN_PU
  • GPIO_OUT
  • GPIO_OD
  • GPIO_OD_PU
cours/informatique/iot/iot_par_la_pratique_inria/340_api_interactions_materiel.txt · Dernière modification : 2023/03/20 22:44 de yoann