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 : TP 10 — Des tâches pour les capteurs I²C (on en reprend le projet), TP 6 — Le DMA et TP 8 — Le bus SPI : l’écran OLED.
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 copieg_sensorssous 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).
Questions
Pourquoi
displayTaskest-elle en priorité basse ? Que risqueraitimuTasksi elle était au-dessus ?Mesurez avec
DWT->CYCCNTla durée deflush. Quelle part du temps processeur l’affichage prend-il à 5 images par seconde ?
Solution de l’étape 1
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 :
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.
Réponses aux questions 1 et 2
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 avecHAL_SPI_Transmit_DMA, et attendez un sémaphorespiDoneSemlevé parHAL_SPI_TxCpltCallback.SCREEN_CSdoit rester à 0 pendant tout le transfert : remontez-le après le sémaphore, pas après l’appel.
Questions
Que vaut maintenant le temps processeur de
flush?Que se passe-t-il si l’on remonte
SCREEN_CSjuste aprèsHAL_SPI_Transmit_DMA?Si le cache de données est actif, que faut-il ajouter avant l’envoi ?
Solution de l’étape 2
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.
Réponses aux questions 3 à 5
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 :
displayTaskdessine 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.
Questions
Avec un seul tampon, et sans attendre la fin du transfert avant de redessiner, que verrait-on ?
Quelle est la cadence maximale théorique de l’écran, à 4 MHz ? Comment l’augmenter ?
Solution de l’étape 3
Le tracé de ligne (algorithme de Bresenham, tous octants) :
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) :
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 :
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).
Réponses aux questions 6 et 7
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
0x21et0x22) 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.
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.
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 à
displayTaskpar 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 Remettre la carte en état RatBot.