FreeRTOS est le système temps réel au cœur de l’ESP32 : il permet de faire tourner plusieurs tâches « en parallèle », chacune écrite comme une petite boucle indépendante, que l’ordonnanceur (scheduler) entrelace sur le ou les processeurs. C’est la spécialité de la famille. L’ESP32 a deux cœurs et un RTOS natif. La loop() d’un sketch Arduino est elle-même une tâche FreeRTOS. Cette fiche montre comment créer et coordonner ses propres tâches. Le pourquoi d’un RTOS et sa place dans l’échelle des architectures sont traités dans firmware.

Ordonnancement préemptif : deux tâches FreeRTOS se partagent le processeur, l'ordonnanceur donnant la main à la tâche prête la plus prioritaire et interrompant celle en cours quand il le faut|640

À quoi ça sert ?

Le multitâche répond à un besoin récurrent : faire plusieurs choses à des rythmes différents sans les emmêler à la main.

  • Séparer les activités. Lire un capteur à 10 Hz, mettre à jour un afficheur à 2 Hz, gérer le Wi-Fi en arrière-plan : chaque activité devient une tâche autonome, au lieu d’un seul loop() qui jongle avec des compteurs.
  • Exploiter les deux cœurs. Une tâche de calcul lourd peut tourner sur un cœur pendant que l’autre gère la communication, sans se gêner.
  • Tenir des échéances. Une tâche prioritaire (régulation, sécurité) passe devant les autres quand elle est prête, et l’ordonnancement préemptif garantit sa réactivité.

C’est l’alternative structurée à la boucle coopérative non bloquante quand le nombre d’activités ou les contraintes de temps grandissent (cf. l’échelle de firmware).

Les concepts

  • Tâche — une fonction qui ne se termine jamais (for (;;) { ... }), exécutée comme une activité indépendante.
  • Scheduler préemptif. Il donne le processeur à la tâche prête la plus prioritaire. Si une tâche plus prioritaire devient prête, elle interrompt (préempte) celle en cours.
  • Priorité — un entier : plus il est élevé, plus la tâche passe devant.
  • vTaskDelay() — l’équivalent FreeRTOS de delay(), mais qui rend explicitement la main : pendant l’attente, les autres tâches s’exécutent. C’est ce qui permet la coexistence.
  • Deux cœurs. Sur l’ESP32 d’origine, on peut épingler une tâche à un cœur (xTaskCreatePinnedToCore) ou la laisser libre.

Créer une tâche

Une tâche est une fonction void f(void *param) contenant une boucle infinie. On la lance avec xTaskCreatePinnedToCore :

void tacheClignote(void *param) {
  pinMode(LED_BUILTIN, OUTPUT);
  for (;;) {                                  // boucle infinie : ne retourne jamais
    digitalWrite(LED_BUILTIN, !digitalRead(LED_BUILTIN));
    vTaskDelay(pdMS_TO_TICKS(500));           // cède le CPU 500 ms
  }
}
 
void setup() {
  xTaskCreatePinnedToCore(
    tacheClignote,   // fonction de la tâche
    "Clignote",      // nom (debug)
    2048,            // taille de pile en octets (ESP-IDF)
    NULL,            // paramètre passé à la tâche
    1,               // priorité
    NULL,            // handle (NULL si on n'en a pas besoin)
    1                // cœur (0 ou 1)
  );
}
 
void loop() {}       // loop() est elle-même une tâche, ici inutilisée

Tip

vTaskDelay, pas une boucle d’attente. Une tâche qui attend par while (millis() - t < d) {} monopolise le CPU et bloque les autres. vTaskDelay(pdMS_TO_TICKS(d)) met la tâche en sommeil et libère le processeur : c’est la clé de la coexistence.

Exemple — Deux tâches indépendantes

Deux activités à des rythmes différents, vraiment séparées : une tâche fait clignoter une LED toutes les 200 ms, l’autre lit un capteur et l’imprime toutes les secondes. Aucun compteur partagé, aucun entrelacement manuel.

Câblage : LED sur GPIO16 et capteur sur GPIO34 (voir les montages de configurer les GPIO).

const int LED = 16;
const int CAPTEUR = 34;          // ADC1
 
void tacheLed(void *param) {
  pinMode(LED, OUTPUT);
  for (;;) {
    digitalWrite(LED, !digitalRead(LED));
    vTaskDelay(pdMS_TO_TICKS(200));    // 200 ms
  }
}
 
void tacheCapteur(void *param) {
  for (;;) {
    int v = analogRead(CAPTEUR);
    Serial.print("Capteur : ");
    Serial.println(v);
    vTaskDelay(pdMS_TO_TICKS(1000));   // 1 s
  }
}
 
void setup() {
  Serial.begin(115200);
 
  xTaskCreatePinnedToCore(tacheLed,     "LED",     2048, NULL, 1, NULL, 1);
  xTaskCreatePinnedToCore(tacheCapteur, "Capteur", 2048, NULL, 1, NULL, 1);
}
 
void loop() {}

La LED clignote à son rythme, les mesures défilent au leur, sans que l’un ne perturbe l’autre. Chaque tâche est une boucle simple, lisible isolément. C’est l’intérêt structurant du RTOS face à un loop() unique qui devrait jongler avec deux temporisations.

Une ligne par seconde au moniteur, quand la LED, elle, bat cinq fois plus vite :

Capteur : 1874
Capteur : 1902
Capteur : 1866
Capteur : 1889

Les deux rythmes sont indépendants : ralentir tacheCapteur à 5 secondes ne changerait rien au clignotement, et inversement. C’est ce découplage qu’un loop() unique oblige à fabriquer à la main.

Communiquer entre tâches

Deux tâches ne doivent pas se partager une variable sans précaution (accès concurrents = données corrompues). FreeRTOS fournit deux outils :

  • File (queue). Une tâche productrice y dépose des valeurs, une tâche consommatrice les retire, dans l’ordre. C’est le canal recommandé pour transmettre des données.
  • Mutex (sémaphore) — un « jeton » qu’une tâche prend avant d’accéder à une ressource partagée et rend après, garantissant qu’une seule y touche à la fois.
QueueHandle_t file;
 
void producteur(void *param) {
  for (;;) {
    int mesure = analogRead(34);
    xQueueSend(file, &mesure, portMAX_DELAY);   // dépose dans la file
    vTaskDelay(pdMS_TO_TICKS(500));
  }
}
 
void consommateur(void *param) {
  int recu;
  for (;;) {
    if (xQueueReceive(file, &recu, portMAX_DELAY)) {  // attend une valeur
      Serial.print("Recu : ");
      Serial.println(recu);
    }
  }
}
 
void setup() {
  Serial.begin(115200);
  file = xQueueCreate(10, sizeof(int));   // 10 entiers
  xTaskCreatePinnedToCore(producteur,   "Prod", 2048, NULL, 1, NULL, 1);
  xTaskCreatePinnedToCore(consommateur, "Conso", 2048, NULL, 1, NULL, 1);
}
 
void loop() {}

Pièges

Tâche qui ne rend jamais la main. Une boucle de tâche sans vTaskDelay (ni attente bloquante FreeRTOS) affame les autres tâches et déclenche le task watchdog. Toute tâche doit céder le CPU régulièrement.

Variable partagée non protégée. Deux tâches qui écrivent la même variable sans mutex produisent des valeurs incohérentes (course critique). Passer par une file, ou protéger l’accès par un mutex.

Pile trop petite. Une tâche qui déborde sa pile plante (stack canary ou redémarrage). Augmenter la taille (le 3ᵉ argument, en octets) si la tâche utilise des buffers ou de la récursion.

Retour de la fonction de tâche. Une fonction de tâche ne doit jamais retourner : sans boucle infinie, la tâche se termine et peut planter le système. Toujours for (;;).

Confondre priorité et fréquence. Une priorité élevée ne fait pas tourner la tâche « plus souvent » : elle la fait passer devant quand elle est prête. Le rythme, c’est le vTaskDelay qui le fixe.

delay() vs vTaskDelay(). Sur l’Arduino-core ESP32, delay() cède aussi le CPU (il appelle vTaskDelay), donc reste acceptable dans une tâche. Mais utiliser vTaskDelay(pdMS_TO_TICKS(...)) explicite l’intention en contexte multitâche.

volatile n’est pas une protection. Le mot-clé empêche le compilateur d’optimiser les lectures d’une variable. Il ne garantit ni l’atomicité, ni l’exclusion mutuelle. Entre deux tâches, seule une file ou un mutex protège réellement. volatile a sa place ailleurs : pour une variable partagée avec une interruption (voir les interruptions), où il est nécessaire, et toujours pas suffisant si l’accès n’est pas atomique.

Exercices

Exercice 1 — Une tâche par cœur

Lancez deux tâches qui impriment chacune le numéro de cœur sur lequel elles tournent, l’une épinglée au cœur 0, l’autre au cœur 1. Vérifiez au moniteur série.

Sur une variante mono-cœur (C3, C6, H2), l’épinglage au cœur 1 échoue : passer tskNO_AFFINITY à la place du numéro de cœur.

Exercice 2 — Protéger un compteur partagé

Deux tâches incrémentent un même compteur global, chacune à son rythme. Protégez l’accès par un mutex pour éviter les incohérences.

Cas particulier — loop() est déjà une tâche

Sur l’Arduino-core ESP32, on n’est jamais « hors RTOS » : le cœur crée une tâche loopTask qui exécute setup() puis loop(). Créer ses propres tâches ne fait qu’ajouter des activités à côté de celle-là. On peut donc adopter le multitâche progressivement : garder loop() pour le principal, déporter en tâches les activités qui méritent leur propre rythme ou leur propre cœur. C’est l’application directe du dernier barreau de l’échelle de firmware.

Raccrochage projet

  • Étape 4 de la phase de preuve de concept. Dès que le prototype doit mener plusieurs activités à des cadences différentes (acquisition + communication + IHM), structurer en tâches lève l’enchevêtrement d’un loop() surchargé et clarifie le comportement.
  • Régulation et échéances. Une tâche de régulation prioritaire, cadencée par vTaskDelay, garantit une période d’asservissement régulière, isolée des autres traitements.

Choisir entre boucle non bloquante et RTOS au bon moment (cf. firmware) évite deux écueils : un loop() ingérable de compteurs, ou un RTOS introduit prématurément là où une simple boucle coopérative aurait suffi.

Aller plus loin

Voir aussi