TP 6 — Le DMA

Objectifs

  • transférer des données sans occuper le processeur, avec un contrôleur DMA ;

  • prévenir une tâche de la fin d’un transfert, par une interruption et un sémaphore ;

  • comprendre les deux pièges du DMA sur le STM32H7 : les mémoires qu’il ne peut pas atteindre, et le cache de données.

Durée indicative : deux séances de 3 h. Prérequis : TP 3 — Communiquer entre tâches, TP 5 — La liaison série avec le PC et TP 6 — Le niveau de batterie.

Ce qu’il faut savoir

Le DMA

Envoyer 2 000 caractères à 115 200 bit/s prend environ 170 ms. Avec HAL_UART_Transmit, le processeur attend chaque octet : 170 ms perdues. Un contrôleur DMA (Direct Memory Access) recopie lui-même les octets de la mémoire vers l’UART, au rythme où l’UART les demande. Le processeur lance le transfert, puis fait autre chose ; une interruption le prévient à la fin.

Le STM32H750 a plusieurs contrôleurs : DMA1 et DMA2 (ceux qu’on utilise ici), le BDMA et le MDMA. Un DMAMUX relie chaque requête de périphérique (« UART5 a besoin d’un octet ») à un canal DMA.

Les mémoires du STM32H750

Le microcontrôleur a plusieurs RAM, reliées différemment au cœur et aux contrôleurs DMA :

Mémoire

Adresse

Taille

Particularité

DTCM

0x20000000

128 Kio

couplée au cœur, la plus rapide ; DMA1 et DMA2 n’y ont pas accès

AXI SRAM

0x24000000

512 Kio

la grande mémoire principale, passe par le cache

SRAM D2

0x30000000

288 Kio

à côté de DMA1 et DMA2 ; son horloge doit être activée

SRAM D3

0x38000000

64 Kio

domaine basse consommation, pour le BDMA

Figure made with TikZ

Les mémoires du STM32H750 et leurs liaisons : le cœur atteint tout, le DMA pas la DTCM.

Selon le script d’édition de liens généré, les variables globales peuvent se retrouver en DTCM. Un transfert DMA depuis ou vers la DTCM échoue : le contrôleur signale une erreur de bus, et rien ne part.

Le cache de données

Le Cortex-M7 a un cache de données de 16 Kio : il garde une copie des données récemment lues ou écrites, pour ne pas retourner chaque fois en mémoire. Le DMA, lui, ne voit que la mémoire. Si le cache est actif :

  • après une écriture du processeur, la donnée peut rester dans le cache : le DMA envoie l”ancienne valeur ;

  • après une écriture du DMA, le processeur peut relire la copie périmée de son cache.

Trois solutions : nettoyer (clean) ou invalider (invalidate) le cache avant ou après chaque transfert, placer les tampons DMA dans une zone non cacheable grâce au MPU, ou laisser le cache de données désactivé.

Le firmware du robot place tous ses tampons DMA en SRAM D2, dans une zone déclarée non cacheable : sans cela, il émettait de temps en temps des trames périmées, une panne intermittente et silencieuse.

Étape 1 — Une émission bloquante, mesurée

  1. Créez un projet rtos_tp06_dma comme au TP 2 (FreeRTOS CMSIS_V2, Timebase TIM6, UART5 et printf, LED_1 à LED_4 en sortie). Deux tâches : blinkTask fait clignoter LED_1 toutes les 100 ms ; sendTask envoie un texte toutes les 2 s.

  2. Préparez un texte de 2 000 caractères dans un tableau global (40 lignes de 50 caractères, par exemple).

  3. Mesurez la durée de HAL_UART_Transmit en cycles du processeur, avec le compteur DWT->CYCCNT :

    /* une fois, au demarrage */
    CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
    DWT->CYCCNT = 0u;
    DWT->CTRL  |= DWT_CTRL_CYCCNTENA_Msk;
    
    uint32_t t0 = DWT->CYCCNT;
    HAL_UART_Transmit(&huart5, (uint8_t *)texte, len, 1000u);
    uint32_t cycles = DWT->CYCCNT - t0;
    

    Affichez la durée en microsecondes (64 cycles par µs).

Question

  1. Quelle durée mesurez-vous ? Comparez-la au calcul. Pendant ce temps, blinkTask clignote-t-elle ? Pourquoi, et à quel prix ?

Solution de l’étape 1
static char texte[2048];       /* 40 lignes de 50 caractères */

void StartSend(void *argument)
{
    uint32_t len = 40u * 50u;

    (void)argument;
    for (uint32_t l = 0u; l < 40u; l++)            /* 48 caractères + \r\n */
    {
        snprintf(&texte[l * 50u], 51u,
                 "0 ligne %02lu .....................................\r\n",
                 (unsigned long)l);
    }
    for (;;)
    {
        uint32_t t0 = DWT->CYCCNT;
        HAL_UART_Transmit(&huart5, (uint8_t *)texte, (uint16_t)len, 1000u);
        uint32_t cycles = DWT->CYCCNT - t0;

        printf("%lu us dans l'appel\r\n", (unsigned long)(cycles / 64u));
        osDelay(2000);
    }
}

Le compteur de cycles. Le Cortex-M7 a un bloc de mise au point, le DWT (Data Watchpoint and Trace), qui contient un compteur de cycles d’horloge de 32 bits, CYCCNT. Il faut d’abord autoriser les blocs de trace (bit TRCENA du registre DEMCR), puis lancer le compteur (CYCCNTENA). À 64 MHz, un cycle dure 15,6 ns : c’est l’outil le plus précis pour mesurer une durée de code, sans aucun matériel. Il reboucle toutes les 67 s : une mesure plus courte reste juste grâce à la soustraction non signée.

snprintf et sa taille : chaque ligne fait 50 caractères, et snprintf écrit aussi le '\0' final : on lui donne 51 octets. Le '\0' de chaque ligne est écrasé par le premier caractère de la ligne suivante.

Réponse à la question 1

1. La durée mesurée. Environ 174 ms :

2 000 octets × 10 bits / 115 200 bit/s = 173,6 ms

blinkTask continue de clignoter : à priorité égale, l’ordonnanceur lui donne son tour à chaque tic où elle est prête (TP 1). Mais pendant ces 174 ms, sendTask occupe tout le reste du processeur à attendre l’UART : la HAL boucle sur le drapeau TXE, octet après octet. Le processeur est occupé à 100 % pour envoyer 11 520 octets par seconde, alors qu’il pourrait en copier des centaines de millions.

Étape 2 — La même émission par DMA

  1. Dans CubeMX, onglet DMA Settings de UART5 : Add, UART5_TX, DMA1 Stream 0, direction Memory To Peripheral, mode Normal, incrément de l’adresse mémoire coché, données en octets. Dans NVIC Settings, activez l’interruption UART5 global interrupt. Vérifiez dans System Core → NVIC que ces deux interruptions ont une priorité de 5 ou plus.

  2. Créez un sémaphore binaire txDoneSem, initialement indisponible.

  3. Remplacez l’appel par HAL_UART_Transmit_DMA, mesurez sa durée, puis attendez la fin du transfert :

    HAL_UART_Transmit_DMA(&huart5, (uint8_t *)texte, len);
    osSemaphoreAcquire(txDoneSemHandle, osWaitForever);
    
  4. La fin du transfert est signalée par le rappel :

    void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
    {
        if (huart == &huart5) { osSemaphoreRelease(txDoneSemHandle); }
    }
    

    et une erreur par HAL_UART_ErrorCallback : allumez-y LED_4.

Questions

  1. Le texte arrive-t-il au terminal ? LED_4 s’allume-t-elle ? Cherchez l’adresse de texte dans le fichier .map du projet (dossier Debug) : dans quelle mémoire est-il ?

  2. Si le texte n’arrive pas : pourquoi ? Si le texte arrive : dans quelle mémoire est-il, et pourquoi le DMA peut-il l’atteindre ?

Solution de l’étape 2
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart == &huart5) { osSemaphoreRelease(txDoneSemHandle); }
}

void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart)
{
    if (huart == &huart5)
    {
        HAL_GPIO_WritePin(LED_4_GPIO_Port, LED_4_Pin, GPIO_PIN_SET);
        osSemaphoreRelease(txDoneSemHandle);       /* ne pas bloquer sendTask */
    }
}

et dans la boucle de sendTask :

t0 = DWT->CYCCNT;
HAL_UART_Transmit_DMA(&huart5, (uint8_t *)texte, (uint16_t)len);
cycles = DWT->CYCCNT - t0;
osSemaphoreAcquire(txDoneSemHandle, osWaitForever);

Ce que fait HAL_UART_Transmit_DMA. Elle programme le flux DMA : adresse source (le tampon), adresse destination (le registre TDR de l’UART), nombre d’octets, incrément de l’adresse source seulement. Elle autorise l’UART à demander des octets au DMA (bit DMAT), et rend la main. Ensuite, chaque fois que l’UART peut accepter un octet, il envoie une requête ; le DMAMUX l’aiguille vers le flux 0 de DMA1, qui copie l’octet suivant. À la fin, le DMA lève une interruption, et la HAL appelle HAL_UART_TxCpltCallback.

Pourquoi un rappel d’erreur qui libère aussi le sémaphore. Sans lui, en cas d’erreur, la tâche attendrait un sémaphore qui ne viendrait jamais : bloquée pour toujours. Toute attente d’un événement matériel doit prévoir le cas où il n’arrive pas (rappel d’erreur, ou délai d’attente).

Réponses aux questions 2 et 3

2. et 3. Le résultat dépend du script d’édition de liens généré par votre version de CubeIDE : c’est lui qui décide où vont les variables globales.

  • Si texte est en DTCM (une adresse en 0x2000xxxx dans le .map) : rien ne part, et LED_4 s’allume. La DTCM est reliée directement au cœur, par un bus privé ; DMA1 et DMA2 sont sur la matrice de bus AXI/AHB, qui n’a pas de chemin vers elle. Le DMA tente de lire à cette adresse, reçoit une erreur de bus, et s’arrête en levant son drapeau d’erreur de transfert ; la HAL appelle le rappel d’erreur.

  • Si texte est en AXI SRAM (0x2400xxxx) : le texte part. DMA1 atteint l’AXI SRAM par la matrice d’interconnexion, en traversant un pont entre les domaines D2 et D1.

Le fichier .map, produit par l’éditeur de liens dans le dossier Debug, donne l’adresse et la taille de chaque symbole : c’est le premier réflexe quand on se demande où vit une variable.

Étape 3 — Placer le tampon où le DMA peut l’atteindre

Quel qu’ait été le résultat de l’étape 2, placez le texte explicitement en SRAM D2 :

  1. Dans le fichier STM32H750VBTX_FLASH.ld du projet, ajoutez la région RAM_D2 si elle n’existe pas, puis une section, juste avant /DISCARD/ :

    .dma_buffer (NOLOAD) :
    {
      . = ALIGN(32);
      *(.dma_buffer)
      . = ALIGN(32);
    } >RAM_D2
    
  2. Déclarez le tampon dans cette section :

    static char texte[2048] __attribute__((section(".dma_buffer")));
    

    NOLOAD : la section n’est pas initialisée au démarrage. Remplissez le texte au début de sendTask, avec snprintf par exemple.

  3. Activez l’horloge de la SRAM D2 au début de main (USER CODE BEGIN 2) :

    __HAL_RCC_D2SRAM1_CLK_ENABLE();
    __HAL_RCC_D2SRAM2_CLK_ENABLE();
    

Questions

  1. Que vaut maintenant la durée de l’appel HAL_UART_Transmit_DMA ? Combien de fois plus courte que l’émission bloquante ?

  2. Pendant les 170 ms du transfert, que fait sendTask ? et le processeur ?

Solution de l’étape 3

Dans le script d’édition de liens, la région (si elle manque) :

RAM_D2 (xrw) : ORIGIN = 0x30000000, LENGTH = 288K

et la section, avant /DISCARD/ :

.dma_buffer (NOLOAD) :
{
  . = ALIGN(32);
  *(.dma_buffer)
  . = ALIGN(32);
} >RAM_D2

Dans le code :

static char texte[2048] __attribute__((section(".dma_buffer"), aligned(32)));

et, dans USER CODE BEGIN 2 :

__HAL_RCC_D2SRAM1_CLK_ENABLE();
__HAL_RCC_D2SRAM2_CLK_ENABLE();

L’attribut section demande au compilateur de ranger la variable dans une section nommée, au lieu de .bss ; le script d’édition de liens place cette section en SRAM D2. NOLOAD : rien n’est recopié depuis la flash au démarrage (le tampon n’a pas de valeur initiale), ce qui évite de gaspiller la flash.

L’horloge des SRAM D2. Sur le H7, chaque mémoire a son horloge, et celles de la SRAM D2 sont éteintes au démarrage. Sans les activer, les écritures partent dans le vide sans aucune erreur : le tampon se relit plein de zéros. C’est un piège classique de ce microcontrôleur, que le firmware du robot a rencontré.

Réponses aux questions 4 et 5

4. La durée de l’appel. Quelques microsecondes : le temps de programmer les registres du flux DMA. Contre 174 ms en mode bloquant : plusieurs dizaines de milliers de fois plus court.

5. Pendant le transfert. sendTask est bloquée sur le sémaphore : elle ne consomme rien. Le processeur exécute les autres tâches, ou la tâche d’inactivité. Le DMA, lui, partage le bus avec le processeur : il prend un cycle de bus par octet, toutes les 87 µs — une charge négligeable. Les 174 ms de processeur sont rendues au système : c’est tout l’intérêt du DMA.

Étape 4 — Le cache de données

  1. Dans System Core → CORTEX_M7, activez CPU ICache et CPU DCache. Régénérez.

  2. Dans sendTask, à chaque envoi, changez le texte : remplacez le premier caractère de chaque ligne par un compteur qui augmente ('0' à '9').

  3. Observez le terminal sur plusieurs envois.

  4. Corrigez avec un nettoyage du cache juste avant de lancer le DMA :

    SCB_CleanDCache_by_Addr((uint32_t *)texte, sizeof texte);
    

Questions

  1. Avec le cache actif et sans nettoyage, les compteurs affichés suivent-ils ? Expliquez avec le schéma cache / mémoire / DMA.

  2. Pourquoi le tampon est-il aligné sur 32 octets, et sa taille un multiple de 32 ? (Indice : la taille d’une ligne de cache.)

  3. Pour une réception par DMA (le DMA écrit, le processeur lit), faut-il nettoyer ou invalider le cache ? Avant ou après le transfert ?

Solution de l’étape 4

À chaque envoi, le compteur change le premier caractère de chaque ligne :

for (uint32_t l = 0u; l < 40u; l++)
{
    texte[l * 50u] = (char)('0' + (n % 10u));
}
SCB_CleanDCache_by_Addr((uint32_t *)texte, (int32_t)sizeof texte);   /* la correction */
HAL_UART_Transmit_DMA(&huart5, (uint8_t *)texte, (uint16_t)len);

Ce que fait SCB_CleanDCache_by_Addr. Le cache de données fonctionne en write-back : quand le processeur écrit, la donnée est modifiée dans le cache, et la ligne est marquée « sale » ; elle n’est recopiée en mémoire que plus tard, quand la ligne est évincée pour faire de la place. Nettoyer (clean) une zone, c’est forcer la recopie en mémoire des lignes sales de cette zone. Après le nettoyage, mémoire et cache sont identiques, et le DMA lit les bonnes valeurs.

Réponses aux questions 6 à 8

6. Sans nettoyage. Les compteurs affichés ne suivent pas : ils restent figés, ou changent en retard, parfois d’une ligne à l’autre de façon incohérente.

Figure made with TikZ

Le DMA ne lit que la mémoire : ce qui est resté dans le cache ne part pas.

Le processeur écrit les nouveaux caractères dans le cache. Le DMA ne voit que la mémoire, où restent les anciens. Il envoie donc l’ancien texte, sauf pour les lignes de cache qui ont été évincées entre-temps : d’où un résultat à moitié juste, qui change selon ce que fait le reste du programme. C’est le défaut le plus sournois du DMA sur Cortex-M7 : il ne se voit pas toujours, ni de la même façon d’un essai à l’autre.

7. L’alignement sur 32 octets. Le cache travaille par lignes de 32 octets : il charge, nettoie et invalide toujours des lignes entières. Si le tampon ne commence pas sur une frontière de ligne, sa première ligne de cache contient aussi la fin d’une autre variable. Nettoyer est sans danger ; mais invalider cette ligne (question 8) jetterait aussi les modifications récentes de l’autre variable, qui seraient perdues. Un tampon DMA aligné, de taille multiple de 32, n’a aucune ligne commune avec ses voisins.

8. Pour une réception par DMA. Il faut invalider le cache (SCB_InvalidateDCache_by_Addr), après le transfert, avant que le processeur lise le tampon : on jette les copies du cache, qui datent d’avant le transfert, pour forcer une relecture en mémoire, où le DMA a écrit. On invalide aussi avant de lancer la réception : sinon, une ligne sale du cache pourrait être recopiée en mémoire pendant le transfert, et écraser ce que le DMA vient d’écrire.

La solution du firmware du robot évite tout cela : ses tampons DMA sont dans une région déclarée non cacheable par le MPU (exercice supplémentaire). Plus de nettoyage ni d’invalidation à oublier.

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.

  • La batterie en continu : la tension de la batterie (TP 6 de la partie 1) est convertie en permanence par l’ADC, en mode circulaire par DMA, dans un tableau de 16 mesures ; une tâche calcule la moyenne une fois par seconde sans jamais démarrer de conversion.

  • Zone non cacheable : au lieu de nettoyer le cache, déclarez la section .dma_buffer non cacheable avec une région du MPU (CubeMX, CORTEX_M7 → MPU), et vérifiez que le texte reste juste sans SCB_CleanDCache_by_Addr.

  • Double tampon : préparez le texte suivant dans un second tampon pendant que le DMA envoie le premier, et alternez.

Après le TP

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