Tuto pour code non bloquant et FreeRTOS

Quelques liens vers de bons tutos qui expliquent comment éviter d'utiliser 'delay' pour faire des codes non bloquants. Ajoutez d'autres liens si vous en trouvez, ça enrichira la base...

la programmation par machine à états permet cela aussi (cf mon tuto éventuellement)

Je viens de voir qu'une version de FreeRTOS existe pour les processeurs AVR qui équipent nos Arduinos. FreeRTOS est un "operating system" temps réel pour microcontrôleurs. Il est donc parfaitement adapté à des problématiques de gestion de tâches en fonction du temps ou d’occurrence d’événements.

Par contre, il ne faut pas attendre une précision à la milliseconde...

Pour l'utiliser sur Arduino, il faut d'abord installer la bibliothèque. Ça peut se faire via le gestionnaire de bibliothèques sous l'IDE. Le principe de fonctionnement est de créer des tâches indépendantes qui ont chacune une fonction. Le RTOS va allouer les ressources à la tâche qu'il juge prioritaire à chaque instant.

L'intérêt c'est qu'on peut ainsi découper son code en tâches décorrélées, qui gèrent chacune un capteur, un servo ou un moteur, un afficheur, etc et c'est FreeRTOS qui orchestrera l'ensemble. Chaque fonction est donc simplifiée car elle n'a pas à prendre en compte une contrainte ou une interaction avec une autre fonction (ou un autre périphérique).

Directement, voici un exemple de code, avec 2 tâches qui font clignoter des leds à des fréquences différentes. Le code est organisé en 4 parties :

  • La déclaration de la bibliothèque,
  • Le setup qui contient la déclaration des tâches,
  • La loop, qui peut rester vide, car le RTOS va gérer l'exécution des tâches tout seul,
  • Les tâches.

Voici le code :

#include <Arduino_FreeRTOS.h>
#ifndef LED_BUILTIN
#define LED_BUILTIN 13
#endif

// define two tasks for Blink & AnalogRead
void TaskBlink1 ( void *pvParameters );
void TaskBlink2( void *pvParameters );

unsigned long chrono;

void setup() {
  Serial.begin(115200);
  while (!Serial) ;

  // Now set up two tasks to run independently.
  xTaskCreate(
    TaskBlink1
    ,  (const portCHAR *)"Blink1"   // A name just for humans
    ,  128  // This stack size can be checked & adjusted by reading the Stack Highwater
    ,  NULL
    ,  2  // Priority, with 3 (configMAX_PRIORITIES - 1) being the highest, and 0 being the lowest.
    ,  NULL );

  xTaskCreate(
    TaskBlink2
    ,  (const portCHAR *) "Blink2"
    ,  128  // Stack size
    ,  NULL
    ,  1  // Priority
    ,  NULL );

  // Now the task scheduler, which takes over control of scheduling individual tasks, is automatically started.
  chrono = millis();
}

void loop()
{
  // Empty. Things are done in Tasks.
}

/*--------------------------------------------------*/
/*---------------------- Tasks ---------------------*/
/*--------------------------------------------------*/

void TaskBlink1(void *pvParameters)  // This is a task.
{
  (void) pvParameters;
  // initialize digital LED_BUILTIN on pin 13 as an output.
  pinMode(LED_BUILTIN, OUTPUT);

  for (;;) // A Task shall never return or exit.
  {
    digitalWrite(LED_BUILTIN, HIGH);   // turn the LED on (HIGH is the voltage level)
    Serial.println("Blink1 ON");
    vTaskDelay( 1000 / portTICK_PERIOD_MS ); // wait for one second
    digitalWrite(LED_BUILTIN, LOW);    // turn the LED off by making the voltage LOW
    Serial.println("Blink1 OFF");
    vTaskDelay( 1000 / portTICK_PERIOD_MS ); // wait for one second
  }
}

void TaskBlink2(void *pvParameters)  // This is a task.
{
  (void) pvParameters;
  // second LED attached to pin 10
  pinMode(10, OUTPUT);

  for (;;) // A Task shall never return or exit.
  {
    digitalWrite(10, HIGH);   // turn the LED on (HIGH is the voltage level)
    Serial.println("\tBlink2 ON");
    vTaskDelay( 2500 / portTICK_PERIOD_MS ); // wait for one second
    digitalWrite(10, LOW);    // turn the LED off by making the voltage LOW
    Serial.println("\tBlink2 OFF");
    vTaskDelay( 2500 / portTICK_PERIOD_MS ); // wait for one second
  }
}

La déclaration des tâches, dans le setup, se fait avec cette instruction (et ses paramètres) :

xTaskCreate( vTaskCode, "NAME", STACK_SIZE, NULL, tskIDLE_PRIORITY, &xHandle );

Pour nous :

xTaskCreate(
    TaskBlink1
    ,  (const portCHAR *)"Blink1"   // A name just for humans
    ,  128  // This stack size can be checked & adjusted by reading the Stack Highwater
    ,  NULL
    ,  2  // Priority, with 3 (configMAX_PRIORITIES - 1) being the highest, and 0 being the lowest.
    ,  NULL );

C'est un cas parmi d'autres, mais c'est le plus simple : il existe aussi les tâches statiques.

La fonction qui crée la tâches est [url=https://www.freertos.org/a00125.html]xTaskCreate[/url], ses arguments sont :

  • Le nom de la fonction associée
  • Un intitulé pour des besoins de facilitation du débug (juste une chaîne de caractères pour nous autres, pauvre humains)
  • La taille de la pile associée à la tâche : c'est le nombre de variables que la tâche pourra créer (pas la taille mémoire, mais bien le nombre de variables : la taille s'obtient en multipliant ce nombre par la taille de la variable 'standard', en général un int soit 2 octets)
  • Un pointeur vers les paramètres que l'on veut passer à la tâche : ici NULL, on ne passe rien
  • La priorité : de 0 à 3, 0 la plus basse
  • Un 'handle', c'est à dire un petit nom pour que le code puisse manipuler la tâche, ici NULL : on ne fera rien avec cette tâche

Le code de la tâche est des plus simples :

void TaskBlink1(void *pvParameters)  // This is a task.
{
  (void) pvParameters;
  // initialize digital LED_BUILTIN on pin 13 as an output.
  pinMode(LED_BUILTIN, OUTPUT);

  for (;;) // A Task shall never return or exit.
  {
    digitalWrite(LED_BUILTIN, HIGH);   // turn the LED on (HIGH is the voltage level)
    Serial.println("Blink1 ON");
    vTaskDelay( 1000 / portTICK_PERIOD_MS ); // wait for one second
    digitalWrite(LED_BUILTIN, LOW);    // turn the LED off by making the voltage LOW
    Serial.println("Blink1 OFF");
    vTaskDelay( 1000 / portTICK_PERIOD_MS ); // wait for one second
  }
}

Il commence toujours par ces lignes, et contient une boucle infinie dans laquelle est exécuté son code. Le RTOS s'occupe de gérer cette boucle et les appels à ces instructions.
On voit que cette tâche est "autoporteuse" car elle définit le pinMode de la pin qu'elle utilise. C'est cohérent avec ce que je disais plus haut : les tâches sont indépendantes.

Notez qu'on ne met pas de delay dans une tâche (au sens Arduino du terme) mais qu'on peut utiliser vTaskDelay qui fera une attente de manière non bloquante pour le RTOS. La constante portTICK_PERIOD_MS est utilisée pour convertir des millisecondes en nombre de ticks d'horloge (horloge du RTOS, pas celle du µC). Je crois qu'une tick vaut 15 ms (à vérifier).

Il est aussi possible de créer une tâche dans une fonction :

void vOtherFunction( void )
 {
 static uint8_t ucParameterToPass;
 TaskHandle_t xHandle = NULL;
     // Create the task, storing the handle.  Note that the passed parameter ucParameterToPass
     // must exist for the lifetime of the task, so in this case is declared static. 
     xTaskCreate( vTaskCode, "NAME", STACK_SIZE, &ucParameterToPass, tskIDLE_PRIORITY, &xHandle );
     configASSERT( xHandle );
     // Use the handle to delete the task.
     if( xHandle != NULL )
     {
         vTaskDelete( xHandle );
     }
 }

Notez les différences avec le cas précédent:

  • On crée un handle pour agir sur la tâche
  • configASSERT : La sémantique de la macro configASSERT est la même que celle de la macro standard C assert. Une assertion est déclenchée si le paramètre passé dans configASSERT() est zéro.
  • vTaskDelete : on tue la tâche une fois qu'on n'en a plus besoin. Notez qu'elle est créée à chaque lancement de la fonction, donc il faut la tuer à la fin sinon elle sera créée plusieurs fois et on ira dans le décor...

En plus des tâches que vous créez, il existe une autre tâche, de priorité basse : la tâche IDLE. C'est une tâche que le RTOS exécute lorsque les autres tâches ne sont pas actives. Et, tenez-vous bien : la tâche IDLE correspond à la loop !

L'ESP32 utilise FreeRTOS en standard, il n'est pas nécessaire d'utiliser cette bibliothèque. Les codes sont similaires, la seule différence est lié au fait que l'ESP32 a deux cœurs de processeur : on ajoute une dernier paramètre à xTaskCreate qui est le numéro du cœur auquel on veut attribuer la tâche (0 ou 1).

Voila ce que je sais de FreeRTOS pour l'instant.

J'ai joué un peu avec ce code, en ajoutant des Serial.print pour voir les états des leds

14:31:40.271 -> Blink1 ON
14:31:40.271 -> 	Blink2 ON
14:31:41.335 -> Blink1 OFF
14:31:42.363 -> Blink1 ON
14:31:42.910 -> 	Blink2 OFF
14:31:43.423 -> Blink1 OFF
14:31:44.484 -> Blink1 ON
14:31:45.542 -> Blink1 OFF
14:31:45.576 -> 	Blink2 ON
14:31:46.570 -> Blink1 ON
14:31:47.633 -> Blink1 OFF
14:31:48.217 -> 	Blink2 OFF
14:31:48.693 -> Blink1 ON
14:31:49.721 -> Blink1 OFF
14:31:50.784 -> Blink1 ON
14:31:50.852 -> 	Blink2 ON
14:31:51.847 -> Blink1 OFF
14:31:52.871 -> Blink1 ON
14:31:53.487 -> 	Blink2 OFF
14:31:53.932 -> Blink1 OFF
14:31:54.996 -> Blink1 ON
14:31:56.023 -> Blink1 OFF
14:31:56.124 -> 	Blink2 ON
14:31:57.082 -> Blink1 ON
14:31:58.139 -> Blink1 OFF
14:31:58.787 -> 	Blink2 OFF
14:31:59.201 -> Blink1 ON
14:32:00.258 -> Blink1 OFF
14:32:01.287 -> Blink1 ON
14:32:01.425 -> 	Blink2 ON

Les périodes étant de 1 et 2.5 secondes, on voit qu'il doit y avoir des conflits régulièrement pour l'accès aux ressources. Ils sont réglés par les priorités des tâches : 2 pour la LED1 et 3 pour la LED2. La LED1 est donc prioritaire. C'est ce qu'on voit ici par exemple :

14:31:45.542 -> Blink1 OFF
14:31:45.576 -> 	Blink2 ON

C'est bien la LED1 qui est activée en premier.

Pour un second test, je fais afficher le temps écoulé depuis le lancement du code dans la tâche 1 et rien dans la tâche 2 : je m'attends à voir un affichage toutes les secondes.

14:35:30.471 -> 1
14:35:31.527 -> 1055
14:35:32.588 -> 2108
14:35:33.646 -> 3162
14:35:34.708 -> 4216
14:35:35.770 -> 5268
14:35:36.796 -> 6322
14:35:37.861 -> 7376
14:35:38.926 -> 8429
14:35:39.957 -> 9483
14:35:41.022 -> 10537
14:35:42.085 -> 11591
14:35:43.143 -> 12644
14:35:44.168 -> 13698
14:35:45.254 -> 14752
14:35:46.277 -> 15805

On voit que ça dérive pas mal, de 50 ms par seconde ! C'est ce que je disais au début : ne pas attendre une précision à la milliseconde.

Je continue à découvrir FreeRTOS.

Quelques fonction utiles pour gérer le temps :

  • xTaskGetTickCount() : renvoie le nombre de ticks écoulés depuis le lancement du RTOS. C'est aussi un moyen de suivre l'exécution des tâches dans le temps.
  • vTaskDelayUntil : permet de délayer une tâche d'un nombre de ticks donné.

Le nouveau code : 2 tâches. La première n'a pas changé, elle fait clignoter la LED toutes les secondes. Elle affiche le temps écoulé en ms, en multipliantle nombre de ticks par la durée du tick. La seconde affiche aussi les ticks écoulés mais elle est délayée de 100 ticks à chaque exécution.

#include <Arduino_FreeRTOS.h>
#ifndef LED_BUILTIN
#define LED_BUILTIN 13
#endif

// define two tasks for Blink & AnalogRead
void TaskBlink1 ( void *pvParameters );
void PrintTask( void * );

void setup() {
  Serial.begin(115200);
  while (!Serial) ;

  // Now set up two tasks to run independently.
  xTaskCreate(
    TaskBlink1
    ,  (const portCHAR *)"Blink1"   // A name just for humans
    ,  128  // This stack size can be checked & adjusted by reading the Stack Highwater
    ,  NULL
    ,  2  // Priority, with 3 (configMAX_PRIORITIES - 1) being the highest, and 0 being the lowest.
    ,  NULL );

  xTaskCreate(
    PrintTask
    ,  (const portCHAR *) "Print"
    ,  128  // Stack size
    ,  NULL
    ,  1  // Priority
    ,  NULL );

  // Now the task scheduler, which takes over control of scheduling individual tasks, is automatically started.
}

void loop()
{
  // Empty. Things are done in Tasks.
}

/*--------------------------------------------------*/
/*---------------------- Tasks ---------------------*/
/*--------------------------------------------------*/

void TaskBlink1(void *pvParameters)  // This is a task.
{
  (void) pvParameters;
  // initialize digital LED_BUILTIN on pin 13 as an output.
  pinMode(LED_BUILTIN, OUTPUT);

  for (;;) // A Task shall never return or exit.
  {
    digitalWrite(LED_BUILTIN, HIGH);   // turn the LED on (HIGH is the voltage level)
    Serial.println(xTaskGetTickCount()*portTICK_PERIOD_MS);
    vTaskDelay( 1000 / portTICK_PERIOD_MS ); // wait for one second
    digitalWrite(LED_BUILTIN, LOW);    // turn the LED off by making the voltage LOW
    Serial.println(xTaskGetTickCount()*portTICK_PERIOD_MS);
    vTaskDelay( 1000 / portTICK_PERIOD_MS ); // wait for one second
  }
}

void PrintTask( void * )
{
    TickType_t xLastWakeTime = xTaskGetTickCount();

    for( ;; )
    {
        Serial.print("\tPrint : ");
        Serial.println(xTaskGetTickCount());
        vTaskDelayUntil(&xLastWakeTime,100);
    }
}

La console :

16:29:33.012 -> 0
16:29:33.012 -> 	Print : 0
16:29:34.071 -> 992
16:29:34.690 -> 	Print : 100
16:29:35.101 -> 1984
16:29:36.164 -> 2976
16:29:36.402 -> 	Print : 200
16:29:37.226 -> 3968
16:29:38.111 -> 	Print : 300
16:29:38.284 -> 4960
16:29:39.346 -> 5952
16:29:39.793 -> 	Print : 400
16:29:40.372 -> 6944
16:29:41.434 -> 7936
16:29:41.502 -> 	Print : 500
16:29:42.495 -> 8928
16:29:43.214 -> 	Print : 600
16:29:43.557 -> 9920
16:29:44.617 -> 10912
16:29:44.924 -> 	Print : 700
16:29:45.677 -> 11904
16:29:46.600 -> 	Print : 800
16:29:46.704 -> 12896
16:29:47.769 -> 13888
16:29:48.318 -> 	Print : 900
16:29:48.832 -> 14880

En vérité, les tâches ne sont pas totalement indépendantes, il est possible de les faire communiquer entre elles, et donc de lancer certaines tâches en fonction de résultats obtenus par d'autres tâches.

Je commence juste à découvrir ça : on parle des queues, des sémaphores, etc.

Une queue est un mécanisme de communication entre tâches. C'est une sorte de registre, de taille spécifiée lors de sa création, dans lequel on va pouvoir stocker des données pour les échanger entre tâches. Les principales fonctions concernées :

  • xQueueCreate permet de créer une queue. Arguments : le nombre de données et leur taille.
  • xQueueSend : permet de poster une valeur dans une queue. Arguments : la queue, un pointeur vers la donnée à poster, le temps d'attente maximum si la queue est pleine.
  • xQueueSendToBack : permet de poster une valeur à la fin d'une queue. Arguments : les mêmes.
  • xQueueReceive : permet de prendre une valeur dans une queue (mêmes arguments que send).
  • xQueuePeek : permet de prendre une valeur dans une queue, en le laissant dans la queue.
  • xQueueReset : permet de vider une queue (... :sweat_smile: ).

Un exemple : 3 tâches

  • Lecture d'une valeur sur une entrée analogique
  • Affichage de la valeur lue
  • Clignotement d'une LED.
    Les deux premières tâches communiquent via une queue.
// Include Arduino FreeRTOS library
#include <Arduino_FreeRTOS.h>
#include <queue.h>
QueueHandle_t integerQueue;

void setup() {

  integerQueue = xQueueCreate(10, // Queue length
                              sizeof(int) // Queue item size
                              );
  if (integerQueue != NULL) {
    // Create task that consumes the queue if it was created.
    xTaskCreate(TaskSerial, // Task function
                "Serial", // A name just for humans
                128,  // This stack size can be checked & adjusted by reading the Stack Highwater
                NULL, 
                2, // Priority, with 3 (configMAX_PRIORITIES - 1) being the highest, and 0 being the lowest.
                NULL);

    // Create task that publish data in the queue if it was created.
    xTaskCreate(TaskAnalogRead, // Task function
                "AnalogRead", // Task name
                128,  // Stack size
                NULL, 
                1, // Priority
                NULL);
    }

  xTaskCreate(TaskBlink, // Task function
              "Blink", // Task name
              128, // Stack size 
              NULL, 
              0, // Priority
              NULL );
}

void loop() {}


/**
 * Analog read task
 * Reads an analog input on pin 0 and send the readed value through the queue.
 */
void TaskAnalogRead(void *pvParameters)
{
  (void) pvParameters;
  for (;;)
  {
    int sensorValue = analogRead(A0);
    xQueueSend(integerQueue, &sensorValue, portMAX_DELAY);
    vTaskDelay(1);
  }
}

/**
 * Serial task.
 * Prints the received items from the queue to the serial monitor.
 */
void TaskSerial(void * pvParameters) {
  (void) pvParameters;
  Serial.begin(9600);
  while (!Serial) vTaskDelay(1);
  int valueFromQueue = 0;

  for (;;) 
  {
    if (xQueueReceive(integerQueue, &valueFromQueue, portMAX_DELAY) == pdPASS) 
      Serial.println(valueFromQueue);
  }
}

/* 
 * Blink task. 
 */
void TaskBlink(void *pvParameters)
{
  (void) pvParameters;
  pinMode(LED_BUILTIN, OUTPUT);
  for (;;)
  {
    digitalWrite(LED_BUILTIN, HIGH);
    vTaskDelay( 250 / portTICK_PERIOD_MS );
    digitalWrite(LED_BUILTIN, LOW);
    vTaskDelay( 250 / portTICK_PERIOD_MS );
  }
}

A noter:

  • Un include spécifique pour la gestion des queues.
  • Les tâches qui utilisent la queue ne sont créées que si la queue a été correctement créée.
  • La tâche qui affiche les valeurs étant auto-porteuse, elle doit faire l'initialisation de la liaison série (Serial.begin). De même, celle qui clignote la LED fait le pinMode.

Bonjour,

Pour les francophiles et anglophobes j'ai trouvé ça.

Ne pas se décourager.

Attention ne vous emballez pas trop vite en ce qui concerne l'ARDUINO et le multi-tâche.
Il vaut mieux éviter de multiplier les tâches, car pour chaque tâche il vous faudra une pile d'appel.
Avec 2.5K de RAM cela risque de poser un problème de ... taille :grin:

Une pile doit avoir une certaine dimension qui dépend des appels de fonctions (éventuellement imbriqués), des paramètres que l'on passe à ces fonctions et de leurs variables locales.
Il est assez difficile de déterminer la taille d'une pile, à moins d'avoir les outils de test pour vérifier les débordements de celle-ci.
Par exemple : Stacks-and-stack-overflow-checking

Étant donné le nombre de discussions sur le sujet "freertos stack size calculation", je vous recommande la prudence.

Les ESP8266 ou ESP32 ou STM32 poseront moins de problème.
Donc réservez plutôt FreeRtos aux processeurs ayant une taille de RAM confortable.

C'est vrai que mon but ici est d'apprendre freertos pour l'utiliser sur un ESP32, mais n'en disposant pas pour l'instant, je le teste sur un micro-duino qui dispose de 32ko de flash et 2.5 ko de RAM (ATMEGA32U4)