TP 11 — Une tâche pour l'écran SPI ================================== Objectifs --------- - confier l'écran à **une seule tâche**, qui affiche les mesures des autres ; - envoyer l'image par **DMA**, sans bloquer le processeur pendant le transfert ; - dessiner pendant que l'image précédente part : le **double tampon**. Durée indicative : deux séances de 3 h. Prérequis : :doc:`tp10_capteurs_i2c` (on en reprend le projet), :doc:`tp06_dma` et :doc:`../sans_os/tp08_spi_ecran`. Ce qu'il faut savoir -------------------- Une tâche d'affichage ~~~~~~~~~~~~~~~~~~~~~ L'écran est une ressource : si deux tâches y écrivent, les images se mélangent. Plutôt qu'un mutex, on confie l'écran à **une tâche d'affichage**, la seule à l'utiliser. Elle lit l'état partagé (les mesures du TP 10), dessine l'image en mémoire, et l'envoie, à un rythme fixe : 5 images par seconde suffisent pour des chiffres. Le temps d'une image ~~~~~~~~~~~~~~~~~~~~ Une image de l'écran fait 128 × 64 pixels, soit **1 024 octets**. À 4 MHz, leur envoi dure environ **2 ms**, pendant lesquels ``HAL_SPI_Transmit`` occupe le processeur. Par DMA, l'envoi se fait seul ; la tâche attend la fin du transfert sur un sémaphore, comme au TP 6. Le double tampon ~~~~~~~~~~~~~~~~ Pendant que le DMA lit une image, il ne faut pas la modifier : le haut de l'écran montrerait l'ancienne, le bas la nouvelle. Avec **deux tampons**, on dessine dans l'un pendant que le DMA envoie l'autre, puis on échange leurs rôles. Étape 1 — L'écran dans sa tâche ------------------------------- #. Reprenez le projet du TP 10. Ajoutez l'écran comme au TP 8 de la partie 1 : **SPI3** en *Transmit Only Master* (PC10, PC12), prescaler 16, ``SCREEN_CS`` (PD14, niveau de départ haut), ``SCREEN_DC`` (PD15), ``SCREEN_RST`` (PC8). #. Reprenez les fonctions d'affichage du TP 8 (``screen_init``, ``flush``, ``fill``, ``number``). #. Créez ``displayTask`` (priorité **basse**) : elle initialise l'écran, puis **toutes les 200 ms** copie ``g_sensors`` sous le mutex, dessine la température et le cap, et envoie l'image. .. important:: ``screen_init`` utilise ``HAL_Delay``. Dans une tâche, remplacez-le par ``osDelay`` (TP 1). .. admonition:: Questions 1. Pourquoi ``displayTask`` est-elle en priorité basse ? Que risquerait ``imuTask`` si elle était au-dessus ? 2. Mesurez avec ``DWT->CYCCNT`` la durée de ``flush``. Quelle part du temps processeur l'affichage prend-il à 5 images par seconde ? .. dropdown:: Solution de l'étape 1 :icon: code :color: secondary Les fonctions de dessin sont celles du TP 8 de la partie 1, avec ``osDelay`` à la place de ``HAL_Delay`` dans ``screen_init``. La tâche : .. code-block:: c void StartDisplay(void *argument) /* osPriorityBelowNormal */ { uint32_t next; (void)argument; screen_init(); next = osKernelGetTickCount(); for (;;) { sensors_t s; int32_t t_dC; osMutexAcquire(sensorsMutexHandle, osWaitForever); s = g_sensors; /* copie, comme au TP 10 */ osMutexRelease(sensorsMutexHandle); fill(0x00u); t_dC = s.temp_mC / 100; /* dixièmes de degré */ number(4u, 1u, (uint32_t)((t_dC < 0) ? 0 : t_dC)); number(4u, 5u, (uint32_t)(((int32_t)lroundf(s.heading_deg) % 360 + 360) % 360)); flush(); next += 200u; /* 5 images par seconde */ osDelayUntil(next); } } **Le cap ramené entre 0 et 359.** L'intégration peut donner −30° ou 400°. ``% 360`` en C garde le signe : −30 % 360 = −30. Ajouter 360 puis reprendre le modulo donne toujours un résultat entre 0 et 359 : (−30 + 360) % 360 = 330. **Une seule tâche touche l'écran.** Aucune autre tâche n'appelle ``flush`` ni ne modifie ``fb`` : pas besoin de mutex sur l'écran. Les autres tâches publient leurs mesures dans l'état partagé ; l'affichage va les chercher. .. dropdown:: Réponses aux questions 1 et 2 :icon: light-bulb :color: info **1. Priorité basse.** L'affichage n'est **pas urgent** : une image qui arrive 20 ms plus tard ne se voit pas. Placée au-dessus d'``imuTask``, ``displayTask`` la retarderait à chaque image du temps de dessin et d'envoi (plus de 2 ms en SPI bloquant) : la lecture du gyroscope, qui doit tomber toutes les 20 ms exactement, deviendrait irrégulière, et le cap faux (TP 10, question 4). Les priorités vont aux tâches dont le **retard coûte**, pas aux plus visibles. **2. La durée de flush.** Environ **2,1 ms** : 1 024 octets × 8 bits / 4 MHz = 2,05 ms, plus les 6 octets de commande et les instructions de la HAL entre les octets. À 5 images par seconde : 10,5 ms par seconde, soit **≈ 1 %** du processeur. À 25 images par seconde, 5 %. Peu, mais ce temps est passé à attendre le SPI, sans rien faire d'utile. Étape 2 — L'envoi par DMA ------------------------- #. Dans CubeMX, **DMA Settings** de SPI3 : ``SPI3_TX``, *Memory To Peripheral*, mode **Normal**, octets. Activez **SPI3 global interrupt** (priorité 5 ou plus). #. Placez le tampon de l'image en SRAM D2 (section ``.dma_buffer``, TP 6), aligné sur 32 octets. #. Dans ``flush``, envoyez les commandes de fenêtre en mode bloquant (6 octets), puis l'image avec ``HAL_SPI_Transmit_DMA``, et attendez un sémaphore ``spiDoneSem`` levé par ``HAL_SPI_TxCpltCallback``. #. ``SCREEN_CS`` doit rester à 0 **pendant tout le transfert** : remontez-le après le sémaphore, pas après l'appel. .. admonition:: Questions 3. Que vaut maintenant le temps processeur de ``flush`` ? 4. Que se passe-t-il si l'on remonte ``SCREEN_CS`` juste après ``HAL_SPI_Transmit_DMA`` ? 5. Si le cache de données est actif, que faut-il ajouter avant l'envoi ? .. dropdown:: Solution de l'étape 2 :icon: code :color: secondary .. code-block:: c static uint8_t g_fb[1024] __attribute__((section(".dma_buffer"), aligned(32))); static uint8_t *fb = g_fb; void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi == &hspi3) { osSemaphoreRelease(spiDoneSemHandle); } } static void flush(void) { send_commands(fenetre, (uint16_t)sizeof(fenetre)); /* bloquant : 6 octets */ SCB_CleanDCache_by_Addr((uint32_t *)fb, 1024); /* si le D-cache est actif */ HAL_GPIO_WritePin(SCREEN_DC_GPIO_Port, SCREEN_DC_Pin, GPIO_PIN_SET); HAL_GPIO_WritePin(SCREEN_CS_GPIO_Port, SCREEN_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit_DMA(&hspi3, fb, 1024u); osSemaphoreAcquire(spiDoneSemHandle, osWaitForever); /* fin du transfert */ HAL_GPIO_WritePin(SCREEN_CS_GPIO_Port, SCREEN_CS_Pin, GPIO_PIN_SET); } **Les commandes en bloquant, l'image en DMA.** Six octets de commande ne valent pas la peine d'un transfert DMA : programmer le flux prendrait plus longtemps que de les envoyer. On réserve le DMA aux gros volumes. **Ce qui compte vraiment** : la tâche est **bloquée** sur le sémaphore pendant les 2 ms du transfert, et les autres tâches ont le processeur. .. dropdown:: Réponses aux questions 3 à 5 :icon: light-bulb :color: info **3. Le temps processeur de flush.** Quelques **dizaines de microsecondes** : l'envoi des 6 octets de commande, et la programmation du flux DMA. Les 2 ms d'envoi de l'image ne coûtent plus rien au processeur. La **durée** de ``flush``, mesurée du point de vue de la tâche, reste 2 ms ; c'est le **temps processeur** qui a fondu : il ne faut pas confondre les deux. **4. CS remonté trop tôt.** ``HAL_SPI_Transmit_DMA`` rend la main dès que le transfert est **lancé**, pas terminé. Remonter CS à ce moment-là dit à l'écran que l'échange est fini, alors que le DMA envoie encore 1 024 octets : l'écran les **ignore** (il n'écoute plus), et l'image n'est pas mise à jour, ou seulement son début. Tout ce qui doit se passer « après l'envoi » se fait après le **rappel de fin**, pas après l'appel. **5. Avec le D-cache.** Il faut **nettoyer** le cache sur le tampon avant de lancer le DMA (``SCB_CleanDCache_by_Addr``), pour que les pixels dessinés, encore dans le cache, soient écrits en mémoire où le DMA les lit (TP 6). Le tampon doit être aligné sur 32 octets et sa taille multiple de 32 : 1 024 l'est. Autre solution : placer le tampon dans une zone non cacheable déclarée par le MPU. Étape 3 — Une boussole et un double tampon ------------------------------------------ #. Écrivez ``line(x0, y0, x1, y1)`` (algorithme de **Bresenham**), et dessinez une **aiguille** : un cercle de rayon 20 pixels, et un trait du centre vers le cap. #. Mettez en place deux tampons : ``displayTask`` dessine dans le tampon libre, lance son envoi, puis, **sans attendre la fin**, commence à dessiner l'image suivante dans l'autre. Elle n'attend le sémaphore qu'au moment de relancer un envoi. #. Montez la cadence à 25 images par seconde et tournez la carte. .. Capture d'écran à insérer : photo de l'écran : la température, le cap et la boussole. Déposez _static/captures/rtos/tp11-boussole.png puis décommentez le bloc ci-dessous (retirez le préfixe « .. » devant chaque ligne) : .. .. image:: ../_static/captures/rtos/tp11-boussole.png .. :align: center .. :width: 90% .. admonition:: Questions 6. Avec un seul tampon, et sans attendre la fin du transfert avant de redessiner, que verrait-on ? 7. Quelle est la cadence maximale théorique de l'écran, à 4 MHz ? Comment l'augmenter ? .. dropdown:: Solution de l'étape 3 :icon: code :color: secondary Le tracé de ligne (algorithme de Bresenham, tous octants) : .. code-block:: c static void line(int32_t x0, int32_t y0, int32_t x1, int32_t y1) { int32_t dx = (x1 > x0) ? (x1 - x0) : (x0 - x1); /* |dx| */ int32_t dy = (y1 > y0) ? (y0 - y1) : (y1 - y0); /* -|dy| */ int32_t sx = (x0 < x1) ? 1 : -1; int32_t sy = (y0 < y1) ? 1 : -1; int32_t err = dx + dy; for (;;) { pixel(x0, y0); if ((x0 == x1) && (y0 == y1)) { break; } int32_t e2 = 2 * err; if (e2 >= dy) { err += dy; x0 += sx; } /* un pas en x */ if (e2 <= dx) { err += dx; y0 += sy; } /* un pas en y */ } } L'aiguille, du centre vers le cap (0° vers le haut, le cap croît vers la gauche) : .. code-block:: c float a = s.heading_deg * 3.1415927f / 180.0f; /* en radians */ circle(CX, CY, RADIUS); line(CX, CY, CX - (int32_t)lroundf((float)(RADIUS - 2) * sinf(a)), CY - (int32_t)lroundf((float)(RADIUS - 2) * cosf(a))); Le double tampon : .. code-block:: c static uint8_t g_fb[2][1024] __attribute__((section(".dma_buffer"), aligned(32))); static uint8_t *fb = g_fb[0]; /* le tampon où l'on dessine */ static volatile uint8_t g_spi_busy; static void wait_previous(void) { if (g_spi_busy) { osSemaphoreAcquire(spiDoneSemHandle, osWaitForever); HAL_GPIO_WritePin(SCREEN_CS_GPIO_Port, SCREEN_CS_Pin, GPIO_PIN_SET); g_spi_busy = 0u; } } static void flush(void) { wait_previous(); /* l'image précédente est partie */ send_commands(fenetre, (uint16_t)sizeof(fenetre)); SCB_CleanDCache_by_Addr((uint32_t *)fb, 1024); HAL_GPIO_WritePin(SCREEN_DC_GPIO_Port, SCREEN_DC_Pin, GPIO_PIN_SET); HAL_GPIO_WritePin(SCREEN_CS_GPIO_Port, SCREEN_CS_Pin, GPIO_PIN_RESET); g_spi_busy = 1u; HAL_SPI_Transmit_DMA(&hspi3, fb, 1024u); fb = (fb == g_fb[0]) ? g_fb[1] : g_fb[0]; /* on dessinera dans l'autre */ } **Bresenham en deux mots.** Pour tracer une droite sur une grille de points, il faut choisir, à chaque pas, d'avancer en x, en y, ou les deux. Bresenham tient à jour une **erreur** : l'écart, à un facteur près, entre la droite idéale et le dernier point tracé. Selon son signe, on avance dans un sens ou dans l'autre, et on la corrige. Uniquement des additions et des comparaisons d'entiers : c'est pour cela qu'il est utilisé depuis 1962 sur tous les écrans. **Le sens de l'aiguille.** Sur l'écran, y croît **vers le bas** : un point « en haut » a un y plus petit, d'où les signes moins. Un cap de 90° (un quart de tour à gauche) doit mettre l'aiguille à gauche : x diminue, d'où ``CX − r·sin(a)``. .. dropdown:: Réponses aux questions 6 et 7 :icon: light-bulb :color: info **6. Un seul tampon, sans attendre.** Le DMA lirait le tampon **pendant** qu'on le redessine : ``fill(0)`` d'abord, puis les chiffres et l'aiguille. Selon le moment, l'écran recevrait une image mélangée : le haut neuf et le bas ancien, ou une image presque vide (le ``fill`` est passé, pas encore le dessin). À 25 images par seconde, cela se voit comme un **scintillement** ou des **déchirures** (*tearing*). Avec deux tampons, le DMA lit toujours une image **complète**, pendant qu'on dessine dans l'autre. **7. La cadence maximale.** À 4 MHz : 4 000 000 / (1 024 × 8) = **488 images par seconde** en théorie, un peu moins avec les commandes. Pour aller plus vite : - **augmenter l'horloge du SPI** : prédiviseur 8 (8 MHz), dans les limites du SSD1309 (de l'ordre de 10 MHz d'après sa fiche technique) ; - **n'envoyer que ce qui change** : régler la fenêtre (commandes ``0x21`` et ``0x22``) sur les seules pages modifiées. Pour un chiffre qui change, une page de 128 octets au lieu de 1 024. Mais 25 images par seconde suffisent à l'œil : la vraie question n'est pas la cadence maximale, c'est le **coût processeur** de chaque image, que le DMA a rendu presque nul. .. dropdown:: Tout le code de la correction :icon: download :color: secondary :download:`taches.c <../../corrections/rtos/tp11/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. - **Des pages** : SW1 fait défiler plusieurs pages (capteurs, charge du processeur du TP 9, marges de pile des tâches). La tâche des boutons envoie un événement à ``displayTask`` par une file. - **Du texte** : complétez la police avec les lettres majuscules, et écrivez ``text(x, page, "TEMP")``. - **Ne rien envoyer pour rien** : n'envoyez l'image que si elle a changé (comparez-la à la précédente). Après le TP ----------- Pour rendre la carte au robot, voir :ref:`etat-ratbot`.