Programmation RTOS¶
Orchestrer plusieurs tâches sur un microcontrôleur sans fil d'Ariane — les primitives de concurrence qui évitent les deadlocks et les race conditions.
Pourquoi un RTOS ?¶
Un firmware sans RTOS s'écrit souvent comme une grande boucle infinie avec des vérifications de flags et des machines à états imbriquées. Ça fonctionne jusqu'à ce que le nombre de tâches concurrent augmente, que les délais deviennent critiques, ou qu'un périphérique lent bloque toute la boucle.
Un RTOS (Real-Time Operating System) donne l'illusion de la concurrence sur un cœur unique — ou exploite vraiment plusieurs cœurs — en multiplexant le temps CPU entre des tâches indépendantes. Chaque tâche a sa propre pile, sa priorité, et son état. Le scheduler décide quelle tâche s'exécute à chaque instant.
Concepts fondamentaux¶
Tasks (tâches)¶
Une tâche est une fonction C qui s'exécute comme si elle était seule sur le processeur. Elle a :
- Une pile dédiée (taille fixée à la création — critique en RAM contrainte).
- Une priorité numérique (plus le chiffre est élevé, plus la tâche est prioritaire dans FreeRTOS).
- Un état : Ready, Running, Blocked, Suspended.
// Création d'une tâche FreeRTOS
void tacheAcquisition(void *pvParameters) {
TickType_t xLastWakeTime = xTaskGetTickCount();
for (;;) {
lireCapteur(); // lecture bloquante brève
vTaskDelayUntil(&xLastWakeTime, pdMS_TO_TICKS(100)); // 10 Hz exact
}
}
// Dans main() ou app_main() :
xTaskCreate(
tacheAcquisition, // fonction
"Acquisition", // nom debug
2048, // taille pile en mots (8 Ko sur ARM 32 bits)
NULL, // paramètre
5, // priorité
NULL // handle (facultatif)
);
Scheduling préemptif vs coopératif¶
| Mode | Fonctionnement | Avantage | Risque |
|---|---|---|---|
| Préemptif | Le scheduler interrompt une tâche à chaque tick (typiquement 1 ms) | Déterminisme, latence bornée | Race conditions si ressources partagées non protégées |
| Coopératif | Une tâche cède volontairement le CPU (taskYIELD(), vTaskDelay()) | Simplicité, pas de préemption intempestive | Une tâche bloquée bloque tout |
| Hybride | Préemptif avec niveaux de priorité identiques tournant en round-robin | Compromis | Complexité intermédiaire |
FreeRTOS et Zephyr fonctionnent en mode préemptif par défaut. C'est le mode standard pour les systèmes industriels.
Primitives de synchronisation¶
Queues — communication inter-tâches¶
Une queue est un buffer FIFO thread-safe. Elle permet à une tâche de produire des données et à une autre de les consommer, sans partager de variable globale.
// Déclaration
QueueHandle_t xQueueMesures;
// Création (5 éléments de type MesureCapteur)
xQueueMesures = xQueueCreate(5, sizeof(MesureCapteur));
// Producteur (tâche d'acquisition)
MesureCapteur m = {.temperature = 23.4f, .timestamp = xTaskGetTickCount()};
xQueueSend(xQueueMesures, &m, pdMS_TO_TICKS(10)); // timeout 10 ms
// Consommateur (tâche de traitement)
MesureCapteur mRecue;
if (xQueueReceive(xQueueMesures, &mRecue, portMAX_DELAY) == pdTRUE) {
traiter(mRecue);
}
La queue bloque le consommateur si vide, et peut bloquer le producteur si pleine — ce comportement borné est une protection contre les débordements mémoire.
Sémaphores — synchronisation d'événements¶
Un sémaphore binaire signale qu'un événement s'est produit. Typiquement utilise depuis une ISR (Interrupt Service Routine) pour réveiller une tâche.
SemaphoreHandle_t xSemGPIO;
xSemGPIO = xSemaphoreCreateBinary();
// Dans l'ISR (interruption GPIO)
void IRAM_ATTR gpioISR(void *arg) {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xSemaphoreGiveFromISR(xSemGPIO, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken); // préempte si nécessaire
}
// Dans la tâche de traitement
xSemaphoreTake(xSemGPIO, portMAX_DELAY); // attend l'événement
traiterEvenementGPIO();
Mutexes — exclusion mutuelle¶
Un mutex protège une ressource partagée (buffer SPI, variable globale, handle I2C) contre les accès simultanés. Contrairement au sémaphore, il a une notion de propriété : seule la tâche qui a pris le mutex peut le rendre.
SemaphoreHandle_t xMutexI2C;
xMutexI2C = xSemaphoreCreateMutex();
// Accès protégé au bus I2C partagé
if (xSemaphoreTake(xMutexI2C, pdMS_TO_TICKS(50)) == pdTRUE) {
i2c_write(addr, data, len);
xSemaphoreGive(xMutexI2C);
} else {
// timeout — bus occupé, gérer l'erreur
}
FreeRTOS supporte les mutexes à héritage de priorité (xSemaphoreCreateMutex) qui évitent l'inversion de priorité — un problème classique où une tâche haute priorité est bloquée par une tâche basse priorité.
Timers software¶
Les timers software exécutent une callback depuis le contexte d'une tâche dédiée du RTOS (timer daemon). Ils ne sont pas temps-réel dur, mais suffisent pour les timeouts applicatifs.
TimerHandle_t xTimerHeartbeat;
xTimerHeartbeat = xTimerCreate(
"Heartbeat",
pdMS_TO_TICKS(1000), // période : 1 seconde
pdTRUE, // auto-reload
NULL,
callbackHeartbeat // fonction appelée à chaque expiration
);
xTimerStart(xTimerHeartbeat, 0);
Patterns concurrents embarqués¶
Producteur / Consommateur¶
Le pattern le plus fréquent en IoT : une tâche d'acquisition produit des mesures, une tâche de communication les consomme. Le découplage via queue permet à chacune de tourner à sa fréquence propre.
graph LR
A["Tâche Acquisition<br/>10 Hz"] -->|"Queue<MesureCapteur, 8>"| B["Tâche MQTT<br/>1 Hz"] La queue joue le rôle de buffer elastique. Si la tâche MQTT est temporairement bloquée (reconnexion réseau), les mesures s'accumulent dans la queue jusqu'à sa taille maximale.
Watchdog task¶
Une tâche dédiée surveille que les autres tâches sont vivantes en attendant leur "ping" périodique. Si une tâche ne donne plus signe de vie dans le délai imparti, la tâche watchdog déclenche un reset système contrôlé.
void tacheWatchdog(void *pvParameters) {
uint32_t ulNotifValeur;
for (;;) {
ulTaskNotifyWait(0, 0xFFFFFFFF, &ulNotifValeur, pdMS_TO_TICKS(5000));
if (!(ulNotifValeur & NOTIF_ACQUISITION) || !(ulNotifValeur & NOTIF_COMM)) {
ESP_LOGE(TAG, "Tâche morte détectée, reset...");
esp_restart();
}
}
}
Idle hook¶
La fonction vApplicationIdleHook() est appelée par FreeRTOS quand aucune tâche n'est prête à s'exécuter. C'est le bon endroit pour entrer en mode basse consommation (__WFI() sur ARM — Wait For Interrupt).
FreeRTOS vs Zephyr¶
| Critère | FreeRTOS | Zephyr RTOS |
|---|---|---|
| Fondation | Amazon (open source MIT) | Linux Foundation |
| Footprint minimal | ~6 Ko flash, ~4 Ko RAM | ~20 Ko flash, ~8 Ko RAM |
| API | Spécifique FreeRTOS | POSIX + API native Zephyr |
| Drivers intégrés | Limités (dépend du portage) | +300 drivers en upstream |
| Certification | IEC 61508 SIL 3 disponible (SafeRTOS) | En cours (PSA Certified) |
| Bluetooth / WiFi | Via composants tiers | Zephyr BT, WiFi intégrés |
| DeviceTree | Non | Oui (Kconfig + DTS) |
| CI native | Via outils tiers | West + Twister intégrés |
| Communauté | Très large, historique | En forte croissance |
| Meilleur pour | Projets simples à moyens, STM32 | Projets complexes, multi-protocoles |
Zephyr prend de l'ampleur dans l'industrie (utilisé par Nordic Semiconductor, Intel, NXP) mais sa courbe d'apprentissage est plus raide. FreeRTOS reste le choix par défaut sur STM32 et ESP32.
Diagramme de scheduling¶
Visualisation du scheduling préemptif de trois tâches sur un seul cœur :
gantt
title Scheduling préemptif — 3 tâches sur 1 cœur (priorité : T1 > T2 > T3)
dateFormat x
axisFormat %L ms
section T1 (priorité 5)
Exécution T1 :active, t1a, 0, 10
Bloquée (delay) :done, t1b, 10, 110
Exécution T1 :active, t1c, 110, 120
Bloquée (delay) :done, t1d, 120, 220
section T2 (priorité 3)
Bloquée par T1 :done, t2a, 0, 10
Exécution T2 :active, t2b, 10, 30
Bloquée (delay) :done, t2c, 30, 80
Exécution T2 :active, t2d, 80, 110
Bloquée par T1 :done, t2e, 110, 120
section T3 (priorité 1)
Bloquée :done, t3a, 0, 30
Exécution T3 :active, t3b, 30, 50
Bloquée :done, t3c, 50, 110
Bloquée par T1 :done, t3d, 110, 120
Exécution T3 :active, t3e, 120, 160 Lecture : T1 (priorité la plus haute) préempte T2 et T3 dès qu'elle devient Ready. Pendant que T1 est bloquée sur vTaskDelayUntil, T2 s'exécute en priorité sur T3. T3 ne tourne que quand T1 et T2 sont toutes deux bloquées.
Pièges classiques¶
Inversion de priorité : T3 (basse) prend un mutex, T1 (haute) est bloquée sur ce mutex, T2 (moyenne) préempte T3 et empêche T1 de progresser. Solution : mutex à héritage de priorité.
Stack overflow : la pile d'une tâche est trop petite, elle écrase les données voisines. FreeRTOS offre uxTaskGetStackHighWaterMark() pour mesurer le watermark. Activer configCHECK_FOR_STACK_OVERFLOW en développement.
Deadlock : T1 tient mutex A et attend mutex B, T2 tient mutex B et attend mutex A. Prévention : ordonner les acquisitions de mutex de manière cohérente dans toutes les tâches.
ISR et API RTOS : toutes les fonctions RTOS ne sont pas appelables depuis une ISR. Utiliser les variantes FromISR (xQueueSendFromISR, xSemaphoreGiveFromISR).
Ce qu'il faut retenir¶
- Une queue est le mécanisme privilégié pour échanger des données entre tâches — pas de variable globale partagée.
- Un mutex protège une ressource partagée ; un sémaphore signale un événement.
- Le stack overflow est la première cause de bugs inexpliqués en RTOS — mesurer le watermark en développement.
- FreeRTOS domine STM32 et ESP32 ; Zephyr monte en puissance pour les projets complexes multi-protocoles.
- La tâche watchdog est indispensable en production industrielle.
Chapitre suivant : Linux embarqué — Yocto, Buildroot et device tree pour les cibles Linux temps réel.