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 : :doc:`tp04_partager` et :doc:`../sans_os/tp07_i2c`. 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. .. tikz:: L'organisation du TP : deux tâches de mesure, un état partagé sous mutex, une file d'événements. \tikzset{t/.style={draw, rounded corners, fill=blue!7, align=center, font=\small, minimum width=27mm, minimum height=10mm}, hw/.style={draw, fill=black!5, align=center, font=\scriptsize, minimum width=20mm, minimum height=7mm}, ar/.style={-{Stealth}, thick}, lb/.style={font=\scriptsize, align=center}} \node[hw] (tmp) at (0,2.2) {TMP102\\(I2C2)}; \node[hw] (imu) at (4.2,2.2) {LSM6DSOX\\(I2C3)}; \node[t] (tt) at (0,0.6) {tempTask\\[-2pt]\scriptsize 1 Hz, normale}; \node[t] (it) at (4.2,0.6) {imuTask\\[-2pt]\scriptsize 50 Hz, haute}; \node[draw, thick, fill=yellow!20, align=center, font=\small, minimum width=40mm, minimum height=11mm] (st) at (2.1,-1.4) {\textbf{g\_sensors} (état)\\[-2pt]\scriptsize protégé par sensorsMutex}; \node[t] (lt) at (2.1,-3.4) {logTask\\[-2pt]\scriptsize 0,5 Hz, basse}; \node[draw, fill=green!12, align=center, font=\small, minimum width=24mm, minimum height=9mm] (q) at (-3.4,-1.4) {alarmQueue\\[-2pt]\scriptsize (événements)}; \node[t] (at) at (-3.4,-3.4) {alarmTask\\[-2pt]\scriptsize normale}; \draw[ar] (tmp) -- (tt); \draw[ar] (imu) -- (it); \draw[ar] (tt) -- node[lb, left]{écrit} (st.north -| tt) ; \draw[ar] (it) -- node[lb, right]{écrit} (st.north -| it); \draw[ar] (st) -- node[lb, right]{copie} (lt); \draw[ar] (tt.west) -| node[lb, left, pos=0.7]{franchissement\\de seuil} (q.north); \draw[ar] (q) -- (at); \node[lb, anchor=north] at (lt.south) {UART5, printf}; \node[lb, anchor=north] at (at.south) {LED\_4}; Étape 1 — La tâche température ------------------------------ #. 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. #. Déclarez une structure d'état partagée, et un mutex ``sensorsMutex`` : .. code-block:: c 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; #. ``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``. #. ``logTask`` (priorité basse) affiche toutes les 2 s une **copie** de ``g_sensors``, faite sous le mutex : .. code-block:: c sensors_t s; osMutexAcquire(sensorsMutexHandle, osWaitForever); s = g_sensors; osMutexRelease(sensorsMutexHandle); printf("T = %ld mC ...\r\n", (long)s.temp_mC); .. admonition:: 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 ?) .. dropdown:: Solution de l'étape 1 :icon: code :color: secondary .. code-block:: c 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 ». .. dropdown:: Réponses aux questions 1 et 2 :icon: light-bulb :color: info **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 ---------------------------- #. 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é). #. ``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 : .. code-block:: text cap = cap + vitesse (°/s) × 0,020 s #. Au démarrage, robot **immobile**, mesurez le **zéro** du gyroscope : la moyenne de 100 lectures, à retrancher ensuite de chaque mesure. #. 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é. .. Capture d'écran à insérer : le terminal : température, cap et erreurs I2C, pendant un quart de tour de la carte. Déposez _static/captures/rtos/tp10-terminal-cap.png puis décommentez le bloc ci-dessous (retirez le préfixe « .. » devant chaque ligne) : .. .. image:: ../_static/captures/rtos/tp10-terminal-cap.png .. :align: center .. :width: 90% .. admonition:: Questions 3. Que devient le cap, carte immobile, sans correction du zéro ? Et avec ? 4. Pourquoi ``osDelayUntil`` est-il indispensable ici ? Que se passerait-il avec ``osDelay(20)`` ? 5. ``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 ? 6. 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 ? .. dropdown:: Solution de l'étape 2 :icon: code :color: secondary .. code-block:: c #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. .. dropdown:: Réponses aux questions 3 à 6 :icon: light-bulb :color: info **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 ------------------------------------------------ #. Créez une file ``alarmQueue`` de 4 éléments ``uint8_t``. #. 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. #. ``alarmTask`` attend les messages : en alarme, elle fait clignoter LED_4 rapidement ; sinon, elle l'éteint. Chauffez le TMP102 du bout du doigt. .. admonition:: Questions 7. Pourquoi ne pas faire relire ``g_sensors.temp_mC`` en boucle à ``alarmTask`` ? 8. ``alarmTask`` doit clignoter **et** attendre un message. Comment faire avec un seul appel ? (Indice : le délai d'attente de ``osMessageQueueGet``.) .. dropdown:: Solution de l'étape 3 :icon: code :color: secondary Dans ``StartTemp``, après avoir rangé la température : .. code-block:: c #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 : .. code-block:: c 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. .. dropdown:: Réponses aux questions 7 et 8 :icon: light-bulb :color: info **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. .. dropdown:: Tout le code de la correction :icon: download :color: secondary :download:`taches.c <../../corrections/rtos/tp10/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 :ref:`etat-ratbot`.