Outils pour utilisateurs

Outils du site


cours:informatique:iot:iot_par_la_pratique_inria:330_architecture_riot

Architecture du système Riot

Le noyau RIOT et son initialisation

Le noyau de RIOT contient toutes les fonctionnalités de base d'un système d'exploitation:

  • l'ordonnanceur;
  • la gestion du multi-tâches (ou multi-thread);
  • la synchronisation entre threads;
  • la gestion des interruptions.

Tout le code qui implémente les fonctionnalités du noyau se trouve dans le dossier core. Ce code est indépendant du matériel: le principe est d'avoir un noyau fonctionnant de la même manière, sur tous les types d'architectures supportés.

L'initialisation du noyau intervient donc après les initialisations essentielles du matériel. Le schéma ci-dessous présente la séquence d'initialisation complète d'une application RIOT.

Cette séquence d'initialisation intervient pendant l'exécution du point d'entrée du CPU et est donc divisée en 2 phases :

  1. L'initialisation du matériel dans la fonction board_init(). Cette fonction est généralement implémentée, pour chaque carte, dans le fichier boards/<board name>/board.c. La fonction board_init() se charge d'abord d'initialiser le cpu par un appel à cpu_init(): cette dernière initialise le cpu, ses horloges internes, les priorités entre interruptions. Ensuite, s'ils sont nécessaires pour l'application, les périphériques (UART, RTC, etc) sont initialisés.
  2. Une fois le matériel prêt, le noyau peut donc être initialisé à son tour. L'implémentation de l'initialisation du noyau se trouve dans le fichier core/kernel_init.c et elle consiste simplement à créer 2 threads, idle et main, et à “sauter” dans le thread main qui sera donc le point de départ de l'application puisqu'il lance la fonction principale main() de l'application.

L'application elle-même ou des modules chargés dans l'application peuvent également démarrer des threads. Pendant l'éxecution de l'application, l'ordonnanceur du noyau se charge de basculer d'un thread à l'autre en fonction de son état et de sa priorité.

Ordonnancement et threads

L'ordonnancement des threads dans le noyau de RIOT est de type préemptif avec priorités. C'est un ordonnancement dit statique où:

  • le thread non bloqué et non terminé, ayant la plus haute priorité, est actif, i.e. il s'exécute sur le CPU;
  • tout thread en cours d'exécution peut être interrompu par un autre thread de priorité plus élévée;
  • en cas d'inactivité, i.e lorsque tous les threads sont bloqués ou terminés, le système bascule automatiquement sur un thread particulier, le thread d'attente, dit idle (inactif);
  • une interruption peut préempter n'importe quel thread à n'importe quel moment et exécuter la sous-routine d'interruption ISR1).

L'ordonnancement utilise une politique tick-less c'est-à-dire que la sélection du thread actif ne se fait pas par vérifications périodiques de l'état des threads mais par évènements (typiquement des interruptions internes). Le temps de basculement entre threads est constant quel que soit le nombre de threads donc la complexité algorithmique de l'ordonnanceur RIOT est O(1).

Le noyau RIOT supporte 16 niveaux de priorité différents dont les valeurs vont de 0 à 15. Plus la valeur d'une priorité est faible, plus la priorité est élevée. Les threads par défaut d'une application, idle et main ont respectivement les priorités 15 et 7. Le thread idle se voit donc attribuer la priorité la plus faible possible : c'est donc pour cela qu'il n'obtient l'accès au CPU que lorsque tous les autres threads sont bloqués ou terminés.

Un autre aspect important des threads dans RIOT est qu'ils disposent de leur propre espace mémoire (i.e. leur propre stack, voir https://en.wikipedia.org/wiki/Stack-based_memory_allocation). Cette propriété est très importante pour assurer un certain cloisonnement entre les différents contextes d'exécution des threads : un thread ne peut pas accéder directement (et donc modifier) une variable locale d'un autre thread.

Concrètement, la manipulation des threads dans RIOT s'effectue grâce à une API très simple :

  • Tout d'abord, dans le code, un thread se présente sous la forme d'une fonction ayant la signature suivante:
void *thread_handler(void *arg);

Le paramètre arg peut être utilisé pour partager du contexte entre le thread créateur et le thread créé.

  • Ensuite, un thread est créé à l'aide de la fonction thread_create(), définie dans l'entête thread.h:
kernel_pid_t pid;
pid = thread_create(stack,  /* stack array pointer */
                    sizeof(stack), /* stack size */
                    THREAD_PRIORITY_MAIN - 1, /* thread priority*/
                    flag, /* thread configuration flag, usually*/
                    thread_handler, /* thread handler function */
                    NULL, /* argument of thread_handler function*/
                    "thread name");

Dans l'exemple, ci-dessus, le thread est initialisé avec l'espace mémoire disponible dans le tableau stack, avec une priorité de 6 (donc plus prioritaire que main), le pointeur vers la fonction à exécuter, pas de contexte et un nom. L'espace mémoire du thread est déclaré globalement sous la forme d'un tableau statique:

static char stack[THREAD_STACKSIZE_MAIN];

L'API de gestion des threads propose également la fonction thread_getpid() qui renvoie le numéro du thread courant.

Pour plus de détails, vous pouvez consulter la documentation en ligne des threads de RIOT.

Utilisation avancée des threads

Gérer la concurrence entre threads

Comme nous l'avons vu dans le paragraphe précédent, les threads dans RIOT disposent de leur propre espace mémoire. Cependant, il peut quand même arriver que les threads partagent des variables, par exemple via la variable contextuelle arg de la fonction thread_create(), ou accèdent à des variables globales de l'application ou même du système.

Pour éviter les problèmes d'accès concurrents liés à ces cas de figure, le noyau RIOT fournit des mécanismes de synchronisation entre thread :

  • Les mutex permettent de gérer les problèmes d'exclusion mutuelle tels que le blocage d'accès à une variable par le thread actif. Aucun autre thread ne pourra alors accéder à cette variable tant qu'elle n'est pas relâchée. L'API pour manipuler les mutex est définie dans le fichier mutex.h. Son utilisation est très simple:
mutex_t lock;
mutex_lock(&lock);
/* portion de code protégée par le mutex */
mutex_unlock(&lock);
  • Les sémaphores permettent de gérer des problèmes de synchronisation plus complexes que les mutex comme le problème du “rendez-vous” ou le problème des “producteurs-consommateur”. ( The Little Book of Semaphores, Allen B. Downey) L'API pour utiliser des sémaphores dans RIOT est définie dans le fichier semaphores.h.

Pour plus de détails, vous pouvez consulter la documentation en ligne des mutex.

Communication entre threads

Les threads dans RIOT peuvent également communiquer entre eux, à l'intérieur d'une même application. C'est ce que l'on appelle généralement la communication inter-processus, ou IPC2).

Les messages IPC peuvent être échangés soit entre 2 threads, soit entre le contexte d'interruption via la sous-routine d'interruption (ISR) et un thread.

L'IPC peut avoir 2 modes :

  • synchrone: dans ce mode, le thread qui a envoyé un message est bloqué tant que le thread destinataire n'a pas reçu le message;
  • asynchrone: dans ce mode, l'envoi du message n'est pas bloquant. Cela est possible grâce à l'utilisation d'une queue de messages dans laquelle est posté le message. Le message sera traité lorsque le destinataire sera prêt.
L'envoi d'un message depuis une sous-routine d'interruption est toujours asynchrone. Dans ce cas, il faudra toujours penser à initialiser une file de messages dans le thread destinataire.

L'API de communication inter-processus (IPC) est définie dans l'entête msg.h (qui se trouve dans core). Cette API est documentée dans la documentation en ligne. Le type d'une variable contenant un message est msg_t et possède 2 attributs :

  • type qui permet d'identifier le type de message et donc de le traiter différemment en fonction,
  • content (de type union) peut contenir soit une valeur entière non signée sur 32 bits (de type unit32_t) soit l' addresse d'une variable (i.e un pointeur).

Le code ci-dessous montre comment définir et remplir un message :

msg_t msg;
msg.type = MSG_TYPE;
msg.content.value = 42; /* content can be a value */
msg.content.ptr = address; /* or content can be a pointer */

La macro MSG_TYPE serait dans ce cas définie globalement dans un fichier d'en-tête :

#define MSG_TYPE  (1234)

L'envoi de message peut se faire à l'aide de plusieurs fonctions, chacune ayant un fonctionnement différent :

  • Un envoi bloquant (synchrone) vers un thread ayant pour numéro pid se fait avec l'appel suivant:
    msg_send(&msg, pid);
si cet appel se fait depuis une sous-routine d'interruption, l'envoi est non bloquant (asynchrone).
  • Un envoi non bloquant se fait avec l'appel :
msg_try_send(&msg, pid);
  • Il est aussi possible d'envoyer un message et de bloquer le thread émetteur en attente d'une réponse. Dans ce cas, il faut utiliser l'appel :
msg_send_receive(&msg, &msg_reply, pid);

Le paramètre msg_reply contient alors le message de réponse du thread destinataire de l'envoi initial.

L'envoi de la réponse depuis le destinataire se fait avec l'appel:

    msg_reply(&msg, &msg_reply);

Pour recevoir un message depuis un thread, le noyau RIOT met à disposition 2 fonctions:

  • un appel à msg_receive bloque le thread courant en attente d'un message:
msg_receive(&msg);
  • un appel à msg_try_receive vérifie si un message a été reçu et retourne 1 dans ce cas:
    int ret = msg_try_receive(&msg);
    if (ret > 0) {
        /* message received */
    }
    else {
        /* no message received */
    }

L'utilisation typique des fonctions de réception est une boucle d'attente de messages venant d'un autre thread ou d'une sous-routine d'interruption et s'exécutant dans un thread:

static void *thread_handler(void *arg)
{
    /* endless loop */
    while (1) {
        msg_t msg;
        msg_receive(&msg);
        printf("Message received: %s\n", (char *)msg.content.ptr);
    }
    return NULL;
}

Dans le cas où les messages sont envoyés depuis le contexte d'une interruption, l'envoi de message est asynchrone et il faut donc initialiser une file de messages au début de la fonction du thread :

static void *thread_handler(void *arg)
{
    msg_t msg_queue[8];
    msg_init_queue(msg_queue, 8);
 
    while (1) {
        /* Wait for messages and process them */
    }
 
    return NULL;
}

La gestion d'énergie

Les mécanismes de gestion d'énergie dans RIOT s'appuient sur la capacité des microcontrôleurs de basculer dans des modes de fonctionnement à très basse consommation. Dans RIOT, le principe est de basculer automatiquement le microcontrôleur dans l'un de ces modes lorsque toutes les tâches sont terminées ou bloquées en attente d'un évènement extérieur.

Or nous avons vu plus tôt qu'une application RIOT démarrait par défaut avec 2 threads : main et idle. De plus, une application RIOT bascule dans le thread idle lorsque toutes les tâches sont terminées ou bloquées.

Note: dans le cas des microcontrôleurs ARM Cortex-M, il n'y a plus de thread idle, c'est donc le scheduler de RIOT qui se charge d'activer le mode de gestion d'énergie.

Dans RIOT, c'est donc le thread idle (ou le scheduler) qui a la charge de basculer le microcontrôleur dans le mode de fonctionnement de plus basse consommation possible. On parle dans ce cas d'endormir le microcontrôleur.

RIOT définit au maximum 4 niveaux de fonctionnement basse consommation, allant de 3 à 0. Cela dit, en fonction des architectures ou familles de microcontrôleurs, en pratique il peut y en avoir moins.

Par exemple, pour les microcontrôleurs ST Microelectronics, RIOT définit 2 modes de basse consommation alors que pour les Kinetis de NXP, il n'y en a qu'un seul.

Dans tous les cas, le mode de fonctionnement avec la plus faible consommation est 0.

Le basculement vers le mode de plus faible consommation possible se fait en utilisant un algorithme de type cascade :

  1. En partant du nombre de modes définis pour une architecture donnée (par exemple 2 pour STM32), RIOT regarde si le mode le plus élevé est débloqué.
  2. Si ce mode est bloqué, il reste dans le mode courant (idle).
  3. Si le mode est débloqué, il regarde si un mode inférieur est débloqué et ainsi de suite.

Cette méthode garantit que le système sélectionnera le mode de plus basse consommation automatiquement.

Par défaut, aucun des modes de fonctionnement basse consommation n'est débloqué.

Le déblocage des modes de basse consommation à utiliser est laissé au choix du développeur de l'application car en fonction du mode, certaines fonctionnalités ne seront pas disponibles (comme la rétention de la RAM par exemple) ou certains périphériques seront désactivés. La sélection se fait en définissant la constante PM_BLOCKER_INITIAL lors de la compilation, via la variable CFLAGS. La constante PM_BLOCKER_INITIAL doit contenir une valeur sur 32bits exprimée en représentation hexadécimal. Chacun des 4 octets de cette valeur correspond à l'état d'un des 4 modes de consommation: 0x<mode 3><mode 2><mode 1><mode 0> Pour chaque mode, si la valeur est 0x00, le mode est débloqué et si la valeur est 0x01, le mode est bloqué.

Par exemple, pour débloquer tous les modes sauf le mode 0 par défaut, il suffira d'ajouter au Makefile d'une application:

CFLAGS += '-DPM_BLOCKER_INITIAL=0x00000001'

Il est également possible de bloquer/débloquer manuellement un mode pendant l'exécution d'une application à l'aide des fonctions pm_block et pm_unblock. Ces fonctions sont définies dans l'entête pm_layered.h.

Les fonctions de gestion de l'énergie sont brièvement documentées en ligne.

Quiz

Combien y a-t-il de niveaux de priorité différents dans le noyau de RIOT?

  • 16

En considérant 2 threads :

  • thread_1 avec un niveau de priorité de 7;
  • thread_2 avec un niveau de priorité de 10;

Lequel de ces 2 threads sera prioritaire?

  • thread_1

Donner le nom d'un mécanisme de RIOT permettant la synchronisation entre threads:

  • mutex

Quelle fonction de l'API de communication inter-processus faut-il utiliser pour bloquer un thread en attente d'un message?

  • msg_receive

Quel est le niveau de fonctionnement consommant le moins d'énergie?

  • niveau 0
1)
Interruption Sub-Routine
2)
Inter-Process Communication
cours/informatique/iot/iot_par_la_pratique_inria/330_architecture_riot.txt · Dernière modification : 2023/03/20 22:44 de yoann