TP 2 — Priorités et ordonnancement

Objectifs

  • comprendre la préemption : une tâche plus prioritaire prend la main dès qu’elle est prête ;

  • provoquer une famine (starvation), puis la corriger ;

  • écrire une tâche périodique exacte, avec osDelayUntil ;

  • mesurer la pile réellement utilisée par une tâche.

Durée indicative : une séance de 3 h. Prérequis : TP 1 — Premières tâches, et la liaison série de TP 5 — La liaison série avec le PC pour afficher les mesures.

Ce qu’il faut savoir

La règle de l’ordonnanceur

FreeRTOS applique une règle simple : la tâche prête de plus haute priorité s’exécute. Si plusieurs tâches prêtes ont la même priorité, elles se partagent le processeur à chaque tic (time slicing).

Conséquence : une tâche de haute priorité qui ne se bloque jamais empêche toutes les tâches de priorité inférieure de s’exécuter. Elles ne sont pas plantées, elles sont affamées.

Délai relatif et délai absolu

osDelay(100) attend 100 tics à partir de maintenant. Si le travail de la tâche dure lui-même 30 ms, la période réelle est de 130 ms, et elle varie avec la durée du travail.

osDelayUntil(t) attend jusqu’à l”instant absolu t (en tics). En ajoutant la période à t à chaque tour, la tâche se réveille toutes les 100 ms exactement, quelle que soit la durée du travail — tant qu’elle reste plus courte que la période.

Figure made with TikZ

Délai relatif et délai absolu : avec osDelayUntil, la période ne dépend plus du travail.

Étape 1 — Le projet

  1. Créez un projet rtos_tp02_priorites comme au TP 1 : FREERTOS en CMSIS_V2, Timebase Source sur TIM6, LED_1 à LED_4 en sortie.

  2. Ajoutez UART5 et printf comme au TP 5 de la partie 1. Si CubeMX propose d’activer USE_NEWLIB_REENTRANT, acceptez : printf sera appelé depuis plusieurs tâches, et la bibliothèque C doit le savoir.

  3. Créez trois tâches, toutes en osPriorityNormal, pile 512 mots :

    • blinkTask (StartBlink) : fait clignoter LED_1 toutes les 250 ms ;

    • workTask (StartWork) : simule un calcul qui ne se bloque jamais — une boucle qui fait clignoter LED_2 en attendant avec HAL_Delay(50) ;

    • periodTask (StartPeriod) : on l’écrira à l’étape 3.

Étape 2 — La famine

  1. Lancez le programme : à priorité égale, LED_1 et LED_2 clignotent toutes les deux.

  2. Passez workTask en osPriorityAboveNormal et relancez.

  3. Remettez-la en priorité normale, et passez cette fois blinkTask en osPriorityAboveNormal.

  4. Enfin, gardez workTask au-dessus, mais remplacez son HAL_Delay(50) par osDelay(50).

Questions

  1. Au deuxième essai, LED_1 s’arrête. La tâche blinkTask est-elle plantée ? Dans quel état est-elle ?

  2. Au troisième essai, les deux LED clignotent. Pourquoi la tâche la plus prioritaire ne gêne-t-elle pas l’autre, cette fois ?

  3. Énoncez la règle : à quelle condition une tâche de haute priorité laisse-t-elle vivre les autres ?

Solution de l’étape 2
void StartBlink(void *argument)
{
    (void)argument;
    for (;;)
    {
        HAL_GPIO_TogglePin(LED_1_GPIO_Port, LED_1_Pin);
        osDelay(250);
    }
}

void StartWork(void *argument)
{
    (void)argument;
    for (;;)
    {
        HAL_GPIO_TogglePin(LED_2_GPIO_Port, LED_2_Pin);
        HAL_Delay(50);         /* le « calcul » : ne se bloque jamais.
                                  Dernier essai : osDelay(50) */
    }
}

Les priorités se changent dans l’onglet Tasks and Queues de CubeMX ; il faut régénérer le code à chaque fois. (On peut aussi les changer en cours de route avec osThreadSetPriority, c’est un exercice.)

Réponses aux questions 1 à 3

1. La tâche blinkTask est-elle plantée ? Non. Elle est dans l’état prête (ready) : son délai de 250 ms est écoulé, elle attend le processeur. Mais workTask, plus prioritaire, est elle aussi toujours prête (elle attend en tournant dans HAL_Delay, sans jamais se bloquer), et l’ordonnanceur donne toujours le processeur à la tâche prête la plus prioritaire. blinkTask est affamée. Dans le débogueur (fenêtre FreeRTOS Task List de CubeIDE), on la voit dans l’état Ready, jamais Running.

2. Le troisième essai. blinkTask est maintenant la plus prioritaire, mais elle passe presque tout son temps bloquée dans osDelay(250) : à chaque tic, l’ordonnanceur constate qu’elle n’est pas prête, et donne le processeur à workTask. Quand son délai expire, elle préempte workTask immédiatement (elle prend la main au tic, même si workTask était au milieu de son calcul), bascule sa LED en une microseconde, et se rebloque. workTask reprend là où elle était.

3. La règle. Une tâche de haute priorité ne laisse vivre les tâches moins prioritaires que si elle se bloque : attente d’un délai, d’un message, d’un sémaphore. Une tâche prioritaire doit donc être courte : elle réagit vite, travaille peu, et rend la main. Le travail long et continu va dans les tâches de faible priorité. C’est la base de la conception d’un système temps réel : les priorités se choisissent selon l”urgence, pas selon l’importance.

Étape 3 — Une tâche périodique

Dans StartPeriod, à chaque tour :

  1. faites basculer LED_3 ;

  2. simulez un travail de 30 ms avec HAL_Delay(30) ;

  3. attendez avec osDelay(100).

Comptez les tours pendant 10 s (avec osKernelGetTickCount()), puis affichez la période moyenne au terminal.

Remplacez ensuite l’attente par une attente absolue :

uint32_t next = osKernelGetTickCount();
for (;;)
{
    next += 100u;
    /* ... travail ... */
    osDelayUntil(next);
}

Questions

  1. Quelle période mesurez-vous avec osDelay(100) ? avec osDelayUntil ?

  2. Que se passe-t-il avec osDelayUntil si le travail dure 150 ms ?

  3. Pour échantillonner un capteur à 100 Hz, laquelle des deux attentes faut-il utiliser ?

Solution de l’étape 3
void StartPeriod(void *argument)
{
    uint32_t start = osKernelGetTickCount();
    uint32_t turns = 0u;
    uint32_t next  = start;

    (void)argument;
    for (;;)
    {
        HAL_GPIO_TogglePin(LED_3_GPIO_Port, LED_3_Pin);
        HAL_Delay(30);                          /* le travail : 30 ms */
        turns++;

        if (osKernelGetTickCount() - start >= 10000u)   /* toutes les 10 s */
        {
            printf("periode moyenne : %lu ms\r\n",
                   (unsigned long)((osKernelGetTickCount() - start) / turns));
            start = osKernelGetTickCount();
            turns = 0u;
            next  = start;
        }

        /* version 1 : osDelay(100);   délai relatif */
        next += 100u;                           /* version 2 : délai absolu */
        osDelayUntil(next);
    }
}

osKernelGetTickCount() donne le nombre de tics depuis le démarrage de l’ordonnanceur : des millisecondes ici, puisque le tic est à 1 kHz.

next += 100 : on calcule l’instant du prochain réveil à partir de l’instant prévu du réveil précédent, pas de l’instant présent. Le travail et les retards éventuels ne s’accumulent pas : la tâche reste calée sur une grille de 100 ms, comme une horloge. On réinitialise next après le printf (qui prend quelques millisecondes) pour repartir sur une grille propre.

Réponses aux questions 4 à 6

4. Les périodes mesurées.

  • avec osDelay(100) : environ 130 ms. Chaque tour dure le travail (30 ms) plus l’attente (100 ms), comptée à partir de la fin du travail ; s’y ajoute le temps où d’autres tâches ont pris le processeur. La période dépend du travail : elle change si le travail change.

  • avec osDelayUntil : 100 ms, exactement en moyenne. L’attente s’ajuste toute seule : 70 ms si le travail dure 30 ms, 40 ms s’il en dure 60.

5. Si le travail dure 150 ms. L’instant visé (next) est déjà passé quand la tâche demande à attendre. osDelayUntil ne peut pas attendre dans le passé : elle rend la main aussitôt (en renvoyant une erreur, osErrorParameter dans cette version de CMSIS-RTOS2). La tâche tourne alors en continu, toujours en retard, et next prend de plus en plus de retard sur l’heure réelle. La période n’est plus tenue : c’est un dépassement d’échéance. Un système temps réel sérieux le détecte (en comparant next à l’heure courante) et le signale.

6. Pour échantillonner à 100 Hz : osDelayUntil. Un filtre, une intégration (le cap du gyroscope, TP 10), une dérivée supposent un pas de temps constant ; avec osDelay, il varie avec la charge du processeur, et le résultat se dégrade sans qu’on comprenne pourquoi.

Étape 4 — La pile

Chaque tâche a sa propre pile, réservée à sa création : 512 mots, soit 2 Kio. Trop petite, elle déborde sur la mémoire voisine ; trop grande, elle gaspille la RAM.

  1. Toutes les 2 s, dans StartBlink, affichez la marge de pile de chaque tâche, c’est-à-dire le plus petit nombre de mots restés libres depuis le démarrage :

    printf("blink %lu  work %lu  period %lu mots libres\r\n",
           (unsigned long)uxTaskGetStackHighWaterMark(blinkTaskHandle),
           (unsigned long)uxTaskGetStackHighWaterMark(workTaskHandle),
           (unsigned long)uxTaskGetStackHighWaterMark(periodTaskHandle));
    

    La fonction est déclarée dans task.h (#include "task.h").

  2. Ajoutez dans StartWork un tableau local de 300 entiers, remplissez-le, et relevez la marge de workTask.

Questions

  1. Quelle tâche utilise le plus sa pile ? Pourquoi celle qui appelle printf ?

  2. De combien la marge de workTask a-t-elle baissé avec le tableau ? Est-ce le résultat attendu ?

Solution de l’étape 4

Dans StartBlink, tous les 8 tours (2 s) :

if (++n % 8u == 0u)
{
    printf("blink %lu  work %lu  period %lu mots libres\r\n",
           (unsigned long)uxTaskGetStackHighWaterMark(blinkTaskHandle),
           (unsigned long)uxTaskGetStackHighWaterMark(workTaskHandle),
           (unsigned long)uxTaskGetStackHighWaterMark(periodTaskHandle));
}

et dans StartWork, le tableau :

volatile uint32_t tab[300];

for (uint32_t i = 0u; i < 300u; i++) { tab[i] = i; }

Comment FreeRTOS mesure la pile. À la création d’une tâche, il remplit toute sa pile d’un motif connu (l’octet 0xA5). La pile se remplit ensuite depuis le haut ; uxTaskGetStackHighWaterMark compte combien de mots, en bas, portent encore le motif : jamais touchés depuis le démarrage. C’est la marge minimale — la « laisse de haute mer » (high water mark). Elle ne remonte jamais : elle garde le souvenir du pire moment.

Pourquoi volatile sur le tableau. Le compilateur voit que le tableau est rempli mais jamais lu : il a le droit de supprimer la boucle, et même le tableau. volatile l’oblige à faire chaque écriture, donc à réserver le tableau sur la pile.

Réponses aux questions 7 et 8

7. La tâche qui utilise le plus sa pile est blinkTask, celle qui appelle printf. printf est une fonction lourde : elle analyse le format, convertit les nombres en texte dans des tampons locaux, et appelle plusieurs niveaux de fonctions de la bibliothèque C (_vfprintf_r, _write…), chacun avec ses variables locales. Plusieurs centaines d’octets de pile, contre quelques dizaines pour une tâche qui ne fait que basculer une LED. Toute tâche qui appelle printf doit avoir une pile généreuse.

8. L’effet du tableau. La marge de workTask baisse d’environ 300 mots, c’est-à-dire 300 × 4 = 1 200 octets : le tableau tient 300 entiers de 32 bits. FreeRTOS compte la pile en mots de 32 bits, pas en octets : les 512 de CubeMX font 2 Kio. Avec une pile de 128 mots, le tableau ne tiendrait pas : il déborderait sur la mémoire voisine (une autre pile, une structure du système), sans erreur immédiate — d’où la vérification de l’exercice supplémentaire.

Tout le code de la correction

taches.c — compilé sans avertissement contre la HAL et le FreeRTOS 10.6.2 du firmware RatBot.

Le fichier contient les fonctions des tâches, qui remplacent les squelettes générés par CubeMX (dans main.c ou freertos.c selon la version), et les extraits à placer dans les blocs USER CODE signalés en commentaire. La configuration CubeMX attendue est rappelée en tête du fichier, et les réponses aux questions à la fin.

Exercices supplémentaires

Ces exercices n’ont pas de correction.

  • Gigue : mesurez, sur 1 000 tours, l’écart maximal entre la période voulue et la période réelle de periodTask, avec osDelay puis avec osDelayUntil.

  • Débordement : ramenez la pile de workTask à 128 mots en gardant le tableau. Le projet CubeMX active une vérification (configCHECK_FOR_STACK_OVERFLOW) : écrivez la fonction vApplicationStackOverflowHook pour allumer LED_4 quand elle se déclenche.

  • Priorités dynamiques : un appui sur SW1 fait passer workTask au-dessus des autres (osThreadSetPriority), un second appui la remet à sa place.

Après le TP

Pour rendre la carte au robot, voir Remettre la carte en état RatBot.