TP 10 — Des tâches pour les capteurs I²C

Objectifs

  • organiser la lecture de capteurs en tâches périodiques, chacune à son rythme ;

  • partager les dernières mesures entre tâches, proprement ;

  • distinguer un état (la dernière valeur, qu’on lit quand on veut) d’un événement (quelque chose qui vient d’arriver, et qu’on ne doit pas manquer) ;

  • savoir quand un bus partagé a besoin d’être protégé.

Durée indicative : deux séances de 3 h. Prérequis : TP 4 — Partager une ressource et TP 7 — Le bus I²C : température, accéléromètre et gyroscope.

Ce qu’il faut savoir

Deux capteurs, deux bus, deux rythmes

Le capteur de température TMP102 est sur le bus I2C2, la centrale inertielle LSM6DSOX sur le bus I2C3 (partie 1, TP 7). La température change lentement : une mesure par seconde suffit. Le gyroscope, lui, sert à suivre le cap du robot en intégrant sa vitesse de rotation : il faut le lire souvent, et à intervalle régulier.

Une tâche par rythme est la solution naturelle : une tâche à 1 Hz pour la température, une tâche à 50 Hz pour le gyroscope.

État ou événement ?

  • La température courante est un état : une tâche qui l’affiche veut la dernière valeur, pas l’historique. On la range dans une structure partagée, protégée par un mutex (TP 4).

  • Le dépassement d’un seuil est un événement : la tâche qui déclenche l’alarme ne doit pas le manquer, même si elle était occupée à ce moment. On l’envoie dans une file (TP 3).

Un bus, un propriétaire

Si deux tâches utilisent le même bus I²C, leurs transactions ne doivent pas s’entremêler : il faut un mutex autour de chaque transaction, ou une seule tâche propriétaire du bus, qui fait toutes les lectures. Le firmware du robot a choisi la seconde solution pour I2C2, où se trouvent le TMP102 et le capteur de distance : une seule tâche possède le bus.

Figure made with TikZ

L’organisation du TP : deux tâches de mesure, un état partagé sous mutex, une file d’événements.

Étape 1 — La tâche température

  1. Créez un projet rtos_tp10_capteurs comme au TP 2 (FreeRTOS CMSIS_V2, Timebase TIM6, UART5 et printf, LED_1 à LED_4), avec I2C2 sur PB10/PB11 et I2C3 sur PA8/PC9, en mode standard (100 kHz), comme au TP 7 de la partie 1.

  2. Déclarez une structure d’état partagée, et un mutex sensorsMutex :

    typedef struct
    {
        int32_t  temp_mC;       /* derniere temperature, en milliemes de degre */
        float    heading_deg;   /* cap integre, en degres */
        uint32_t i2c_errors;    /* echecs de lecture, tous capteurs */
    } sensors_t;
    
    static sensors_t g_sensors;
    
  3. tempTask (priorité normale) lit le TMP102 toutes les secondes, avec osDelayUntil, et range la valeur dans g_sensors sous le mutex. Une lecture qui échoue incrémente i2c_errors.

  4. logTask (priorité basse) affiche toutes les 2 s une copie de g_sensors, faite sous le mutex :

    sensors_t s;
    
    osMutexAcquire(sensorsMutexHandle, osWaitForever);
    s = g_sensors;
    osMutexRelease(sensorsMutexHandle);
    printf("T = %ld mC ...\r\n", (long)s.temp_mC);
    

Questions

  1. Pourquoi faire une copie, plutôt que d’appeler printf en tenant le mutex ?

  2. Le HAL_I2C_Mem_Read dure environ 0,4 ms à 100 kHz. Pendant ce temps, tempTask laisse-t-elle tourner les autres tâches ? (Indice : comment la HAL attend-elle la fin de la transaction ?)

Solution de l’étape 1
static sensors_t g_sensors;

void StartTemp(void *argument)                 /* osPriorityNormal, 1 Hz */
{
    uint32_t next = osKernelGetTickCount();

    (void)argument;
    for (;;)
    {
        int32_t t;

        if (tmp102_temp_mC(&t))                /* lecture du TP 7, partie 1 */
        {
            osMutexAcquire(sensorsMutexHandle, osWaitForever);
            g_sensors.temp_mC = t;
            osMutexRelease(sensorsMutexHandle);
        }
        else
        {
            osMutexAcquire(sensorsMutexHandle, osWaitForever);
            g_sensors.i2c_errors++;
            osMutexRelease(sensorsMutexHandle);
        }
        next += 1000u;
        osDelayUntil(next);
    }
}

void StartLog(void *argument)                  /* osPriorityBelowNormal */
{
    (void)argument;
    for (;;)
    {
        sensors_t s;
        int32_t   h;

        osMutexAcquire(sensorsMutexHandle, osWaitForever);
        s = g_sensors;                         /* copie : quelques octets */
        osMutexRelease(sensorsMutexHandle);

        h = (int32_t)(s.heading_deg * 10.0f);  /* dixièmes de degré */
        printf("T = %ld mC  cap = %ld.%ld deg  erreurs I2C = %lu\r\n",
               (long)s.temp_mC, (long)(h / 10), (long)((h < 0 ? -h : h) % 10),
               (unsigned long)s.i2c_errors);
        osDelay(2000);
    }
}

La lecture se fait hors du mutex. La transaction I²C dure 0,4 ms : on la fait d’abord, dans une variable locale, et on ne prend le mutex que pour ranger le résultat. La section critique se réduit à une affectation.

Afficher un nombre à virgule sans %f. La bibliothèque C réduite de CubeIDE (newlib-nano) n’affiche pas les float par défaut. On convertit en dixièmes de degré (un entier), puis on affiche la partie entière et le chiffre des dixièmes. La valeur absolue évite d’afficher « -12.-3 ».

Réponses aux questions 1 et 2

1. Une copie plutôt que printf sous mutex. printf dure plusieurs millisecondes (il attend l’UART). Pendant tout ce temps, le mutex serait tenu, et imuTask, qui veut écrire le cap 50 fois par seconde, serait bloquée : sa période ne serait plus régulière, et l’intégration du cap fausse (question 4). La copie, elle, prend une fraction de microseconde. C’est la façon standard de partager un état : copier sous le verrou, travailler sur la copie. La copie garantit aussi la cohérence : la température, le cap et le compteur affichés datent du même instant.

2. Pendant HAL_I2C_Mem_Read. Non, tempTask ne libère pas le processeur. En mode bloquant, la HAL attend chaque étape de la transaction (départ, adresse, acquittement, données) en relisant en boucle les drapeaux du périphérique I²C. La tâche reste prête et consomme le processeur pendant 0,4 ms. Seules les tâches plus prioritaires (ici imuTask) peuvent la préempter ; les tâches de même priorité ne l’interrompent qu’au tic. Pour vraiment libérer le processeur, il faut la version par interruption (HAL_I2C_Mem_Read_IT) ou par DMA, et attendre la fin sur un sémaphore (dernier exercice).

Étape 2 — La tâche gyroscope

  1. Configurez la centrale comme au TP 7 de la partie 1 (CTRL3_C = 0x44, CTRL2_G = 0x40 : 104 Hz, ±250 °/s, soit 8,75 m°/s par unité).

  2. imuTask (priorité au-dessus de la normale) lit toutes les 20 ms la vitesse de rotation autour de l’axe Z (registres OUTZ_L_G et OUTZ_H_G, 0x26 et 0x27), et l’intègre :

    cap = cap + vitesse (°/s) × 0,020 s
    
  3. Au démarrage, robot immobile, mesurez le zéro du gyroscope : la moyenne de 100 lectures, à retrancher ensuite de chaque mesure.

  4. Rangez le cap dans g_sensors, sous le mutex, et affichez-le dans logTask.

Tournez la carte d’un quart de tour à la main, et vérifiez le cap affiché.

Questions

  1. Que devient le cap, carte immobile, sans correction du zéro ? Et avec ?

  2. Pourquoi osDelayUntil est-il indispensable ici ? Que se passerait-il avec osDelay(20) ?

  3. tempTask et imuTask font toutes deux de l’I²C : faut-il un mutex entre elles ? Et si l’on ajoutait une troisième tâche qui lise, sur I2C2, un autre capteur ?

  4. La centrale est montée à l’envers sur le robot (axe Z vers le bas). Quel signe faut-il appliquer pour que le cap augmente quand le robot tourne à gauche ?

Solution de l’étape 2
#define GYRO_MDPS_LSB  8.75f       /* ±250 °/s : 8,75 m°/s par pas */
#define IMU_PERIOD_MS  20u
#define GYRO_Z_SIGN    (-1.0f)     /* centrale à l'envers sur le robot */

static int gyro_z_raw(int16_t *out)
{
    uint8_t b[2];

    if (HAL_I2C_Mem_Read(&hi2c3, g_imu_addr, 0x26u, I2C_MEMADD_SIZE_8BIT,
                         b, 2u, 5u) != HAL_OK) { return 0; }
    *out = (int16_t)(((uint16_t)b[1] << 8) | b[0]);    /* poids faible d'abord */
    return 1;
}

void StartImu(void *argument)                  /* osPriorityAboveNormal, 50 Hz */
{
    float    bias = 0.0f, heading = 0.0f;
    uint32_t next;

    (void)argument;
    if (!imu_init()) { osThreadExit(); }       /* pas de centrale */
    osDelay(100);                              /* premières mesures prêtes */

    {   /* le zéro : moyenne de 100 lectures, carte immobile */
        int32_t  sum = 0;
        uint32_t n   = 0u;

        for (uint32_t k = 0u; k < 100u; k++)
        {
            int16_t r;
            if (gyro_z_raw(&r)) { sum += r; n++; }
            osDelay(IMU_PERIOD_MS);
        }
        if (n > 0u) { bias = (float)sum / (float)n; }
    }

    next = osKernelGetTickCount();
    for (;;)
    {
        int16_t r;

        if (gyro_z_raw(&r))
        {
            float rate_dps = ((float)r - bias) * GYRO_MDPS_LSB / 1000.0f;

            heading += GYRO_Z_SIGN * rate_dps * ((float)IMU_PERIOD_MS / 1000.0f);
            osMutexAcquire(sensorsMutexHandle, osWaitForever);
            g_sensors.heading_deg = heading;
            osMutexRelease(sensorsMutexHandle);
        }
        next += IMU_PERIOD_MS;
        osDelayUntil(next);
    }
}

(imu_init reprend l’initialisation du TP 7 de la partie 1, avec CTRL3_C = 0x44 et CTRL2_G = 0x40.)

L’intégration. La vitesse de rotation ω (en °/s), multipliée par la durée Δt de la période (0,020 s), donne l’angle tourné pendant la période. On les additionne : c’est l”intégration numérique (méthode des rectangles), qui fait passer d’une vitesse à une position.

Le flottant sur ce processeur. Le Cortex-M7 du H750 a une unité de calcul flottant (FPU) en double précision : une multiplication en float coûte un cycle, comme en entier. Le suffixe f (1000.0f) garde les calculs en simple précision ; sans lui, 1000.0 est un double, plus lent et inutile ici.

osThreadExit termine proprement la tâche si la centrale est absente, au lieu de la laisser tourner sur des lectures en échec.

Réponses aux questions 3 à 6

3. Le cap, carte immobile. Sans correction, il dérive régulièrement : quelques degrés par minute, ou plus, selon le biais du composant (TP 7 de la partie 1). Le biais est une vitesse fausse, constante : intégrée, elle devient un angle qui grandit sans fin. Avec la correction, la dérive devient faible, mais pas nulle : le biais varie avec la température, et la moyenne de départ n’est pas parfaite. Toute position obtenue en intégrant une vitesse finit par dériver : c’est pour cela que le robot recale sa position avec le lidar (partie 3, TP 5).

4. osDelayUntil indispensable. L’intégration suppose que chaque mesure représente exactement 20 ms de rotation. Avec osDelay(20), la période réelle vaut 20 ms plus la durée de la lecture (0,4 ms) plus les éventuelles préemptions par d’autres tâches ou interruptions : 21 ms ou davantage, et variable. On compterait 20 ms de rotation pour 21 ms réelles : le cap serait sous-estimé d’environ 5 %, d’autant plus que la carte est chargée. Mieux encore que osDelayUntil : mesurer le temps réellement écoulé entre deux lectures, et l’utiliser comme Δt.

5. Un mutex entre les deux bus ? Non. I2C2 et I2C3 sont deux périphériques distincts, avec leurs registres, leurs broches et leurs structures HAL (hi2c2, hi2c3). Chacun n’est utilisé que par une tâche : aucune ressource n’est partagée.

Une troisième tâche sur I2C2 changerait tout : deux tâches qui lancent des transactions en même temps sur le même contrôleur les mélangeraient (la HAL renverrait HAL_BUSY, ou pire). Il faudrait alors un mutex autour de chaque transaction sur I2C2, ou bien confier toutes les lectures d’I2C2 à une seule tâche propriétaire du bus. Le firmware du robot a choisi la seconde solution.

6. Le signe. Vu de dessus, une rotation à gauche (sens trigonométrique) est une rotation positive autour d’un axe Z dirigé vers le haut : c’est la convention de ROS, et celle du cap. La centrale du robot a son axe Z dirigé vers le bas : elle mesure la même rotation avec le signe opposé. On multiplie donc par −1. Carte seule sur la table, composants vers le haut, le signe serait +1 : d’où la constante, qu’on change selon le montage.

Étape 3 — Un événement : l’alarme de température

  1. Créez une file alarmQueue de 4 éléments uint8_t.

  2. Dans tempTask, détectez le franchissement de 35 °C à la montée, et de 32 °C à la descente (hystérésis, TP 4 de la partie 1). À chaque franchissement — et seulement à ce moment — envoyez 1 (alarme) ou 0 (fin d’alarme) dans la file.

  3. alarmTask attend les messages : en alarme, elle fait clignoter LED_4 rapidement ; sinon, elle l’éteint.

Chauffez le TMP102 du bout du doigt.

Questions

  1. Pourquoi ne pas faire relire g_sensors.temp_mC en boucle à alarmTask ?

  2. alarmTask doit clignoter et attendre un message. Comment faire avec un seul appel ? (Indice : le délai d’attente de osMessageQueueGet.)

Solution de l’étape 3

Dans StartTemp, après avoir rangé la température :

#define ALARM_ON_mC   35000
#define ALARM_OFF_mC  32000

static uint8_t alarm = 0u;                 /* état de l'alarme, propre à la tâche */

if (!alarm && (t >= ALARM_ON_mC))
{
    alarm = 1u;
    osMessageQueuePut(alarmQueueHandle, &alarm, 0u, 0u);   /* événement : début */
}
else if (alarm && (t <= ALARM_OFF_mC))
{
    alarm = 0u;
    osMessageQueuePut(alarmQueueHandle, &alarm, 0u, 0u);   /* événement : fin */
}

et la tâche d’alarme :

void StartAlarm(void *argument)
{
    uint8_t alarm = 0u;

    (void)argument;
    for (;;)
    {
        uint8_t msg;

        if (osMessageQueueGet(alarmQueueHandle, &msg, NULL,
                              alarm ? 100u : osWaitForever) == osOK)
        {
            alarm = msg;                                   /* un événement */
            if (!alarm)
            {
                HAL_GPIO_WritePin(LED_4_GPIO_Port, LED_4_Pin, GPIO_PIN_RESET);
            }
        }
        else
        {
            HAL_GPIO_TogglePin(LED_4_GPIO_Port, LED_4_Pin); /* 100 ms écoulées */
        }
    }
}

On n’envoie un message qu’au franchissement d’un seuil, pas à chaque mesure : la file ne transporte que des événements, et ne déborde jamais.

Réponses aux questions 7 et 8

7. Pourquoi pas relire g_sensors.temp_mC en boucle. D’abord, la tâche consommerait du processeur à relire sans cesse une valeur qui change une fois par seconde. Ensuite, et surtout, elle pourrait manquer un événement : si la température passe brièvement au-dessus du seuil entre deux relectures, puis redescend, l’alarme ne se déclenche jamais. Un message dans une file, lui, attend d’être lu : l’événement n’est jamais perdu (tant que la file n’est pas pleine). La distinction état / événement est l’une des plus utiles en conception de systèmes : un état se lit quand on veut, un événement se transmet et se consomme.

8. Clignoter et attendre en un seul appel. On utilise le délai d’attente de osMessageQueueGet comme horloge : en alarme, on attend un message au plus 100 ms. Si un message arrive, on le traite ; si le délai expire (la fonction renvoie osErrorTimeout), c’est qu’il faut faire basculer la LED. Hors alarme, on attend indéfiniment : la tâche ne consomme rien. C’est un schéma très courant : une seule attente, deux raisons de se réveiller.

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.

  • Le ventilateur : une tâche pilote le ventilateur (TP 4 de la partie 1) à partir des événements d’alarme.

  • L’accéléromètre : imuTask lit aussi l’accélération et détecte une carte retournée (Z dans le mauvais sens pendant plus d’une seconde) : un nouvel événement.

  • La qualité de service : comptez, dans imuTask, les tours où la lecture a pris plus de 2 ms, ou a échoué ; affichez ce compteur.

  • Lecture par interruption : remplacez HAL_I2C_Mem_Read par HAL_I2C_Mem_Read_IT et un sémaphore levé par HAL_I2C_MemRxCpltCallback. Que gagne-t-on ?

Après le TP

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