TP 3 — Communiquer entre tâches

Objectifs

  • faire passer une information d’une tâche à une autre par une file de messages (queue), sans variable globale partagée ;

  • réveiller une tâche depuis une interruption avec un sémaphore ;

  • savoir quelles fonctions FreeRTOS une interruption a le droit d’appeler.

Durée indicative : une séance de 3 h. Prérequis : TP 2 — Priorités et ordonnancement, et l’anti-rebond du TP 1 — Allumer une LED, lire un bouton.

Ce qu’il faut savoir

Pourquoi pas une variable globale ?

Une tâche qui attend une information d’une autre peut surveiller une variable globale… en la relisant sans cesse : elle consomme du processeur pour rien, et rate les changements trop rapides. Les outils de FreeRTOS bloquent la tâche qui attend, et la réveillent quand l’information arrive.

La file de messages

Une file est un tampon de taille fixe, géré par le système :

  • osMessageQueuePut y copie un message ; si la file est pleine, la tâche attend (ou abandonne, selon le délai donné) ;

  • osMessageQueueGet en retire le plus ancien ; si la file est vide, la tâche se bloque jusqu’à l’arrivée d’un message.

Les messages sont copiés : l’émetteur peut réutiliser sa variable aussitôt.

Le sémaphore

Un sémaphore binaire est un drapeau : osSemaphoreRelease le lève, osSemaphoreAcquire attend qu’il soit levé et le rabaisse. C’est l’outil classique pour qu’une interruption, qui doit être très courte, confie le travail à une tâche.

Important

Une interruption ne peut appeler que les fonctions FreeRTOS prévues pour cela, et jamais une fonction qui attend. Avec l’interface CMSIS_V2, osSemaphoreRelease et osMessageQueuePut (délai 0) sont permises. Il faut aussi que la priorité de l’interruption soit numériquement supérieure ou égale à 5 (donc moins urgente) : c’est la limite configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY.

Étape 1 — Bouton → file → LED

  1. Créez un projet rtos_tp03_communiquer comme au TP 2 (FreeRTOS CMSIS_V2, Timebase TIM6, UART5 et printf), avec LED_1 à LED_4 en sortie et SW1 à SW4 en entrée sans tirage.

  2. Dans l’onglet Tasks and Queues, créez deux tâches en priorité normale, buttonTask (StartButton) et ledTask (StartLed), et une file buttonQueue de 8 éléments de type uint8_t.

  3. StartButton lit les quatre boutons toutes les 10 ms. À chaque appui (front montant, anti-rebond compris), elle envoie le numéro du bouton (0 à 3) dans la file.

  4. StartLed attend un message avec osWaitForever, et fait basculer la LED de même numéro.

Questions

  1. Que fait ledTask entre deux appuis ? Combien de processeur consomme-t-elle ?

  2. Ajoutez un osDelay(2000) dans StartLed après chaque bascule, et appuyez dix fois très vite. Que se passe-t-il ? Combien d’appuis sont perdus, et pourquoi ce nombre ?

Solution de l’étape 1
/* Scrutation toutes les 10 ms ; anti-rebond : 3 lectures identiques. */
void StartButton(void *argument)
{
    static GPIO_TypeDef *const port[4] = { SW1_GPIO_Port, SW2_GPIO_Port,
                                           SW3_GPIO_Port, SW4_GPIO_Port };
    static const uint16_t      pin[4]  = { SW1_Pin, SW2_Pin, SW3_Pin, SW4_Pin };
    uint8_t stable[4] = { 0u, 0u, 0u, 0u };
    uint8_t count[4]  = { 0u, 0u, 0u, 0u };

    (void)argument;
    for (;;)
    {
        for (uint8_t b = 0u; b < 4u; b++)
        {
            uint8_t now = (HAL_GPIO_ReadPin(port[b], pin[b]) == GPIO_PIN_SET) ? 1u : 0u;

            if (now == stable[b]) { count[b] = 0u; continue; }   /* rien de neuf */
            if (++count[b] < 3u)  { continue; }                  /* pas encore sûr */
            stable[b] = now;                                     /* changement confirmé */
            count[b]  = 0u;
            if (now)                                             /* un appui */
            {
                osMessageQueuePut(buttonQueueHandle, &b, 0u, 0u);
            }
        }
        osDelay(10);
    }
}

void StartLed(void *argument)
{
    static GPIO_TypeDef *const port[4] = { LED_1_GPIO_Port, LED_2_GPIO_Port,
                                           LED_3_GPIO_Port, LED_4_GPIO_Port };
    static const uint16_t      pin[4]  = { LED_1_Pin, LED_2_Pin, LED_3_Pin, LED_4_Pin };
    uint8_t b;

    (void)argument;
    for (;;)
    {
        if ((osMessageQueueGet(buttonQueueHandle, &b, NULL, osWaitForever) == osOK)
            && (b < 4u))
        {
            HAL_GPIO_TogglePin(port[b], pin[b]);
        }
    }
}

Le message est copié. osMessageQueuePut recopie l’octet b dans la file : la tâche des boutons peut modifier b aussitôt après. La file appartient au système, qui la protège : deux tâches peuvent y écrire en même temps sans précaution.

Les délais. osMessageQueuePut(…, 0u) : si la file est pleine, on n’attend pas, l’appui est perdu. Attendre bloquerait la tâche des boutons, qui ne verrait plus les autres boutons. osMessageQueueGet(…, osWaitForever) : la tâche des LED attend aussi longtemps qu’il faut.

La vérification b < 4 : une tâche qui reçoit des messages ne fait pas confiance à leur contenu. Ici, elle évite d’indexer hors des tableaux si un jour quelqu’un envoie autre chose.

Réponses aux questions 1 et 2

1. ledTask entre deux appuis. Elle est bloquée dans osMessageQueueGet : l’ordonnanceur l’a retirée de la liste des tâches prêtes, et l’a inscrite dans la liste d’attente de la file. Elle ne consomme rien. Quand un message arrive, osMessageQueuePut la remet elle-même dans la liste des tâches prêtes : elle se réveille aussitôt, sans scruter quoi que ce soit. C’est tout l’intérêt d’une file sur une variable globale relue en boucle.

2. Dix appuis rapides avec un osDelay(2000). Les LED basculent l’une après l’autre, une toutes les 2 s, bien après les appuis : les messages attendent dans la file. La file contient 8 éléments ; ledTask en a déjà retiré un, qu’elle traite. Elle absorbe donc 9 appuis ; au-delà, osMessageQueuePut échoue (délai 0) et les appuis sont perdus. Ici, dix appuis : un perdu. Une file absorbe les rafales, mais si le consommateur est durablement plus lent que le producteur, elle déborde toujours : sa taille se choisit d’après la plus longue rafale à absorber.

Étape 2 — Une interruption réveille une tâche

  1. Dans CubeMX, configurez SW1 (PE10) en GPIO_EXTI10, mode External Interrupt Mode with Rising edge trigger detection, sans tirage.

  2. Dans System Core → NVIC, activez EXTI line[15:10] interrupts, et donnez-lui une priorité de préemption de 5. Cochez la colonne Uses FreeRTOS functions si votre version de CubeMX l’affiche.

  3. Dans l’onglet Timers and Semaphores de FREERTOS, créez un sémaphore binaire sw1Sem, et une tâche sw1Task (StartSw1) en priorité osPriorityAboveNormal.

  4. Dans le rappel d’interruption, levez le sémaphore :

    void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
    {
        if (GPIO_Pin == SW1_Pin)
        {
            osSemaphoreRelease(sw1SemHandle);
        }
    }
    
  5. StartSw1 attend le sémaphore, fait basculer LED_4, compte les appuis et affiche le compteur au terminal.

Retirez SW1 de la lecture par scrutation de StartButton : il est désormais géré par interruption.

Questions

  1. Le sémaphore est créé avec une valeur initiale. Que se passe-t-il au démarrage s’il vaut 1 ?

  2. Appuyez une fois sur SW1. Le compteur augmente-t-il parfois de plus d’une unité ? D’où viennent les appuis en trop ?

  3. Proposez un anti-rebond dans la tâche (pas dans l’interruption), et appliquez-le.

  4. Pourquoi ne pas faire basculer la LED et appeler printf directement dans HAL_GPIO_EXTI_Callback ?

Solution de l’étape 2

Le rappel d’interruption (USER CODE BEGIN 0, ou 4) :

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    if (GPIO_Pin == SW1_Pin)
    {
        osSemaphoreRelease(sw1SemHandle);
    }
}

et la tâche, avec l’anti-rebond de la question 5 :

void StartSw1(void *argument)
{
    uint32_t presses = 0u;

    (void)argument;
    for (;;)
    {
        osSemaphoreAcquire(sw1SemHandle, osWaitForever);  /* attend un front */
        osDelay(50);                                      /* rebonds finis */
        (void)osSemaphoreAcquire(sw1SemHandle, 0u);       /* oublie ceux levés entre-temps */
        if (HAL_GPIO_ReadPin(SW1_GPIO_Port, SW1_Pin) != GPIO_PIN_SET) { continue; }

        HAL_GPIO_TogglePin(LED_4_GPIO_Port, LED_4_Pin);
        presses++;
        printf("SW1 : %lu appuis\r\n", (unsigned long)presses);
    }
}

Dans StartButton, la boucle commence à b = 1 : SW1 n’est plus scruté.

Le chemin de l’interruption. Un front montant sur PE10 lève le drapeau de la ligne 10 de l”EXTI (le contrôleur d’interruptions externes). Les lignes 10 à 15 partagent une seule interruption, EXTI15_10_IRQHandler (dans stm32h7xx_it.c), qui appelle HAL_GPIO_EXTI_IRQHandler, qui efface le drapeau et appelle notre rappel avec le numéro de broche. D’où le test GPIO_Pin == SW1_Pin : le même rappel sert à toutes les lignes.

Pourquoi la priorité 5. FreeRTOS protège ses structures en masquant les interruptions de priorité numérique 5 à 15 pendant ses sections critiques (configMAX_SYSCALL_INTERRUPT_PRIORITY). Une interruption plus urgente (0 à 4) n’est jamais masquée : excellente pour réagir vite, mais elle pourrait interrompre FreeRTOS au milieu d’une mise à jour de ses listes, et osSemaphoreRelease y trouverait un état incohérent. Une telle interruption ne doit appeler aucune fonction de FreeRTOS. Avec le contrôle de configuration actif (configASSERT), FreeRTOS arrête le programme si la règle est violée — sinon, c’est un plantage rare et inexplicable.

osSemaphoreRelease dans une interruption. En CMSIS-RTOS2, la même fonction sert aux tâches et aux interruptions : elle détecte qu’elle est en interruption et appelle la version FromISR de FreeRTOS, qui ne bloque jamais. Si la tâche réveillée est plus prioritaire que celle qui a été interrompue, le changement de tâche se fait dès la sortie de l’interruption : sw1Task réagit en quelques microsecondes.

Réponses aux questions 3 à 6

3. La valeur initiale. S’il vaut 1, le sémaphore est déjà levé au démarrage : sw1Task le prend aussitôt, croit à un appui, bascule LED_4 et affiche « 1 appui » sans qu’on ait rien touché. Selon la version, CubeMX crée un sémaphore binaire disponible (1) par défaut : il faut choisir l’état initial Depleted (0), ou le vider au démarrage de la tâche. Un sémaphore qui signale un événement doit partir à 0 ; un sémaphore qui garde une ressource part à 1 (la ressource est libre).

4. Plusieurs appuis comptés. Oui, souvent. Le contact rebondit (partie 1, TP 1) : un appui produit plusieurs fronts montants en quelques millisecondes, donc plusieurs interruptions. Le sémaphore étant binaire, les fronts qui arrivent pendant que la tâche n’a pas encore pris le sémaphore se fondent en un seul (on lève un drapeau déjà levé). Mais ceux qui arrivent après qu’elle l’a pris le relèvent : la tâche repasse une seconde fois.

5. Un anti-rebond dans la tâche : voir la solution ci-dessus. Après le premier front, la tâche attend 50 ms (les rebonds sont finis), vide le sémaphore levé par les rebonds (un osSemaphoreAcquire à délai nul, qui réussit ou échoue sans attendre), puis vérifie que le bouton est bien enfoncé (un parasite bref sur la ligne serait écarté). L’interruption, elle, reste réduite au minimum.

6. Pourquoi pas tout dans l’interruption. Une interruption doit être très courte : pendant qu’elle s’exécute, les tâches sont arrêtées, et les interruptions de priorité égale ou inférieure attendent. printf sur la liaison série dure plusieurs millisecondes, et attend l’UART en tournant. De plus, printf n’est pas fait pour être appelé en interruption : il peut utiliser des verrous de la bibliothèque C, qu’une tâche interrompue tient peut-être déjà — blocage assuré. La règle : dans l’interruption, constater et signaler ; dans une tâche, agir.

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.

  • Messages structurés : la file transporte une structure {numéro du bouton, instant de l'appui} ; la tâche réceptrice affiche le temps écoulé entre deux appuis.

  • Durée d’appui : l’interruption se déclenche sur les deux fronts, et place l’instant dans une file (osMessageQueuePut avec un délai 0) ; une tâche calcule et affiche la durée de chaque appui.

  • Sémaphore à compteur : remplacez le sémaphore binaire par un sémaphore à compteur (osSemaphoreNew(10, 0, NULL)) et comparez le nombre d’appuis comptés quand la tâche est lente.

  • Drapeaux de tâche : refaites l’étape 2 avec osThreadFlagsSet et osThreadFlagsWait, plus légers qu’un sémaphore.

Après le TP

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