Le noyau de RIOT contient toutes les fonctionnalités de base d'un système d'exploitation:
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 :
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é.
L'ordonnancement des threads dans le noyau de RIOT est de type préemptif avec priorités. C'est un ordonnancement dit statique où:
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 :
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éé.
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.
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 :
mutex_t lock; mutex_lock(&lock); /* portion de code protégée par le mutex */ mutex_unlock(&lock);
Pour plus de détails, vous pouvez consulter la documentation en ligne des mutex.
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 :
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 :
msg_send(&msg, pid);
msg_try_send(&msg, pid);
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:
msg_receive
bloque le thread courant en attente d'un message:msg_receive(&msg);
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; }
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 :
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.
Combien y a-t-il de niveaux de priorité différents dans le noyau de RIOT?
En considérant 2 threads :
Lequel de ces 2 threads sera prioritaire?
Donner le nom d'un mécanisme de RIOT permettant la synchronisation entre threads:
Quelle fonction de l'API de communication inter-processus faut-il utiliser pour bloquer un thread en attente d'un message?
Quel est le niveau de fonctionnement consommant le moins d'énergie?