Aller au contenu

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&lt;MesureCapteur, 8&gt;"| 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.