TP 4 — Partager une ressource

Objectifs

  • constater qu’une ressource utilisée par deux tâches à la fois produit des résultats faux ;

  • la protéger avec un mutex ;

  • provoquer une inversion de priorité, la mesurer, et voir comment le mutex la corrige.

Durée indicative : une séance de 3 h. Prérequis : TP 3 — Communiquer entre tâches.

Ce qu’il faut savoir

La section critique

Une liaison série, un bus I²C, une structure de données : si une tâche est interrompue au milieu de son utilisation, et qu’une autre s’en sert à son tour, le résultat est mélangé. Le passage où la ressource doit être utilisée d’un seul tenant s’appelle une section critique.

Le mutex

Un mutex (mutual exclusion) est un verrou : osMutexAcquire le prend, ou attend qu’il soit libre ; osMutexRelease le rend. Seule la tâche qui l’a pris peut le rendre.

Il ressemble à un sémaphore binaire, avec une différence importante : l”héritage de priorité. Si une tâche prioritaire attend un mutex tenu par une tâche moins prioritaire, celle-ci prend temporairement la priorité de la première, le temps de finir sa section critique.

L’inversion de priorité

Trois tâches : haute, moyenne et basse. Basse prend la ressource. Haute la demande et attend. Moyenne, qui n’a rien à voir avec la ressource, devient prête : plus prioritaire que basse, elle prend le processeur… et haute attend la fin de moyenne. Une tâche de priorité moyenne retarde la plus prioritaire : c’est l”inversion de priorité.

Figure made with TikZ

L’inversion de priorité : avec un sémaphore, mid retarde high ; le mutex l’empêche.

Étape 1 — Deux tâches, une liaison série

  1. Créez un projet rtos_tp04_partager comme au TP 2 (FreeRTOS CMSIS_V2, Timebase TIM6, UART5 et printf, LED_1 à LED_4 en sortie).

  2. Créez deux tâches de même priorité, aTask et bTask. Chacune, en boucle et sans attente, écrit une ligne au terminal :

    AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
    BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
    

    puis attend 1 ms avec osDelay.

  3. Observez le terminal.

  4. Dans l’onglet Timers and Semaphores, créez un mutex uartMutex. Encadrez chaque printf par osMutexAcquire(uartMutexHandle, osWaitForever) et osMutexRelease(uartMutexHandle).

Questions

  1. Sans mutex, que voit-on au terminal ? Expliquez avec l’ordonnancement à priorité égale vu au TP 1.

  2. Avec le mutex, les lignes sont-elles intactes ? Une tâche peut-elle encore être interrompue au milieu de son printf ?

  3. Pourquoi faut-il garder la section critique courte ?

Solution de l’étape 1
void StartA(void *argument)
{
    (void)argument;
    for (;;)
    {
        osMutexAcquire(uartMutexHandle, osWaitForever);    /* entrée */
        printf("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\r\n");
        osMutexRelease(uartMutexHandle);                   /* sortie */
        osDelay(1);
    }
}

et StartB identique avec des B.

La paire acquire / release encadre exactement la section critique : rien de plus. Chaque osMutexAcquire doit avoir son osMutexRelease sur tous les chemins : un return ou un continue oublié entre les deux laisse le mutex pris pour toujours, et toutes les tâches qui l’attendent se figent.

Réponses aux questions 1 à 3

1. Sans mutex. Des lignes mélangées : AAAAAABBBBBBBAAAA…. Un printf de 42 caractères dure environ 3,6 ms à 115 200 bit/s, car il attend l’UART caractère par caractère. Les deux tâches ont la même priorité, et elles sont prêtes en même temps : l’ordonnanceur les fait alterner à chaque tic (1 ms). Chacune envoie donc quelques caractères, puis l’autre prend la main au milieu de sa ligne. La liaison série est une ressource partagée, utilisée par deux tâches sans coordination.

2. Avec le mutex. Les lignes sont intactes. La tâche qui écrit peut toujours être interrompue au milieu de son printf — le mutex n’empêche pas l’ordonnancement ! — mais quand l’autre tâche prend la main et veut écrire, osMutexAcquire la bloque : elle rend aussitôt le processeur, et la première termine sa ligne. Le mutex ne rend pas le code ininterruptible ; il garantit qu”une seule tâche à la fois est dans la section critique.

3. Une section critique courte. Pendant qu’une tâche tient le mutex, toutes les autres tâches qui en ont besoin attendent, quelle que soit leur priorité. Une section critique longue transforme une tâche urgente en tâche lente. On n’y met que l’accès à la ressource : préparer le texte avant (snprintf dans un tampon local), puis ne protéger que l’envoi.

Étape 2 — L’inversion de priorité

Pour simuler du travail, HAL_Delay ne convient plus : il mesure le temps écoulé, pas le temps passé à calculer, et une tâche préemptée pendant son HAL_Delay(200) le trouverait terminé à son retour. Cette fonction ne compte que les millisecondes où la tâche a effectivement tourné :

static void busy_ms(uint32_t ms)
{
    uint32_t last = HAL_GetTick();

    while (ms > 0u)
    {
        uint32_t now = HAL_GetTick();

        if (now != last) { last = now; ms--; }   /* un tic vu = 1 ms de calcul */
    }
}

Créez trois tâches et une ressource partagée, protégée d’abord par un sémaphore binaire resSem (état initial : disponible) :

Tâche

Priorité

Comportement, à chaque tour

lowTask

osPriorityBelowNormal

prend la ressource, la garde le temps de 200 ms de calcul (busy_ms(200)), la rend

midTask

osPriorityNormal

démarre 20 ms après lowTask, et calcule 800 ms sans se bloquer (busy_ms(800))

highTask

osPriorityAboveNormal

démarre 10 ms après lowTask : prend la ressource, la rend, et affiche le temps d’attente

Les trois tâches sont périodiques, de période 1 000 ms, avec osDelayUntil (TP 2). Pour obtenir les décalages, midTask et highTask commencent par osDelay(20) et osDelay(10) avant leur boucle.

Allumez une LED par tâche pendant qu’elle travaille : LED_1 pour basse, LED_2 pour moyenne, LED_3 pour haute qui attend.

  1. Mesurez le temps d’attente de highTask avec le sémaphore.

  2. Remplacez le sémaphore par un mutex resMutex et mesurez de nouveau.

Questions

  1. Quel temps d’attente mesurez-vous avec le sémaphore ? Décomposez-le.

  2. Et avec le mutex ? Quelle tâche a eu le processeur pendant que highTask attendait ?

  3. Pourquoi un sémaphore binaire ne peut-il pas faire d’héritage de priorité ? (Indice : qui « possède » un sémaphore ?)

Solution de l’étape 2

Deux fonctions cachent le choix sémaphore / mutex :

#define USE_MUTEX 1      /* 0 : sémaphore binaire ; 1 : mutex */

static void res_take(void)
{
#if USE_MUTEX
    osMutexAcquire(resMutexHandle, osWaitForever);
#else
    osSemaphoreAcquire(resSemHandle, osWaitForever);
#endif
}

static void res_give(void)
{
#if USE_MUTEX
    osMutexRelease(resMutexHandle);
#else
    osSemaphoreRelease(resSemHandle);
#endif
}

Les trois tâches :

void StartLow(void *argument)                /* osPriorityBelowNormal */
{
    uint32_t next = osKernelGetTickCount();

    (void)argument;
    for (;;)
    {
        res_take();
        HAL_GPIO_WritePin(LED_1_GPIO_Port, LED_1_Pin, GPIO_PIN_SET);
        busy_ms(200);                        /* 200 ms de calcul, ressource tenue */
        HAL_GPIO_WritePin(LED_1_GPIO_Port, LED_1_Pin, GPIO_PIN_RESET);
        res_give();
        next += 1000u;
        osDelayUntil(next);
    }
}

void StartMid(void *argument)                /* osPriorityNormal */
{
    uint32_t next;

    (void)argument;
    osDelay(20);                             /* décalage de 20 ms */
    next = osKernelGetTickCount();
    for (;;)
    {
        HAL_GPIO_WritePin(LED_2_GPIO_Port, LED_2_Pin, GPIO_PIN_SET);
        busy_ms(800);                        /* n'utilise pas la ressource */
        HAL_GPIO_WritePin(LED_2_GPIO_Port, LED_2_Pin, GPIO_PIN_RESET);
        next += 1000u;
        osDelayUntil(next);
    }
}

void StartHigh(void *argument)               /* osPriorityAboveNormal */
{
    uint32_t next, t0;

    (void)argument;
    osDelay(10);                             /* décalage de 10 ms */
    next = osKernelGetTickCount();
    for (;;)
    {
        HAL_GPIO_WritePin(LED_3_GPIO_Port, LED_3_Pin, GPIO_PIN_SET);
        t0 = osKernelGetTickCount();
        res_take();                          /* combien de temps ici ? */
        res_give();
        HAL_GPIO_WritePin(LED_3_GPIO_Port, LED_3_Pin, GPIO_PIN_RESET);
        printf("high a attendu %lu ms\r\n", (unsigned long)(osKernelGetTickCount() - t0));
        next += 1000u;
        osDelayUntil(next);
    }
}

Les LED rendent le scénario visible : LED_3 reste allumée tout le temps où highTask attend.

Réponses aux questions 4 à 6

4. Avec le sémaphore : environ 990 ms. Le déroulement, à partir de l’instant 0 où lowTask prend la ressource :

0 ms     low prend la ressource, commence ses 200 ms de calcul
10 ms    high se réveille, préempte low, demande la ressource : bloquée
         low reprend (10 ms de calcul faits)
20 ms    mid se réveille, préempte low (mid > low), calcule 800 ms
820 ms   mid se rebloque ; low reprend ses 180 ms restantes
1000 ms  low rend la ressource ; high la prend aussitôt
         → high a attendu 1000 − 10 = 990 ms

La tâche la plus prioritaire a attendu 800 ms de mid, une tâche moins prioritaire qui ne se sert même pas de la ressource. C’est l’inversion de priorité : en pratique, mid est plus prioritaire que high. Et le délai n’est pas borné : avec plusieurs tâches moyennes, il pourrait s’allonger sans limite.

5. Avec le mutex : environ 190 ms. Quand high se bloque sur le mutex tenu par low, FreeRTOS élève temporairement la priorité de low à celle de high : c’est l”héritage de priorité. À 20 ms, mid se réveille, mais low est maintenant plus prioritaire qu’elle : mid attend. low finit sa section critique à 200 ms, rend le mutex, retrouve sa priorité d’origine ; high prend le mutex et termine. mid ne s’exécute qu’ensuite. L’attente de high est bornée par la durée de la section critique : c’est exactement ce qu’on veut.

(Le fameux exemple historique : en 1997, le robot Mars Pathfinder redémarrait sans cesse sur Mars à cause d’une inversion de priorité. Le correctif, envoyé depuis la Terre, a activé l’héritage de priorité sur un mutex.)

6. Pourquoi pas avec un sémaphore. L’héritage suppose de savoir à quelle tâche prêter la priorité. Un mutex a un propriétaire : la tâche qui l’a pris, et seule elle peut le rendre. Un sémaphore n’en a pas : n’importe quelle tâche, ou une interruption, peut le libérer (c’est même son usage normal, TP 3). FreeRTOS ne sait donc pas qui « tient » un sémaphore. D’où la règle : mutex pour protéger une ressource, sémaphore pour signaler un événement.

Pourquoi busy_ms et pas HAL_Delay. HAL_Delay compte le temps écoulé, pas le temps de calcul : préemptée pendant 800 ms au milieu de son HAL_Delay(200), lowTask le trouverait terminé à son retour, et la démonstration serait faussée. busy_ms ne compte que les millisecondes où la tâche a vraiment tourné : elle simule un calcul.

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.

  • L’étreinte fatale (deadlock) : deux tâches, deux mutex, pris dans l’ordre inverse. Provoquez le blocage, puis corrigez-le en imposant le même ordre partout.

  • Une fonction protégée : écrivez log_printf(fmt, ...) qui prend le mutex, formate avec vsnprintf dans un tampon local et envoie la ligne. Ainsi aucune tâche ne peut oublier le mutex.

  • Délai d’attente : highTask abandonne si elle n’obtient pas la ressource en 100 ms, et allume LED_4. Avec le sémaphore, puis avec le mutex.

Après le TP

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