Cohérence de cache et DMA sur ARM : pourquoi vos tampons se corrompent
Le symptôme est toujours le même. Le transfert DMA se termine, le drapeau de fin est levé, et pourtant le tampon contient les données du transfert précédent, ou un mélange des deux. On soupçonne le périphérique, on relit la fiche technique, on ajoute un volatile. Le coupable est presque toujours le cache, et le remède dépend du sens du transfert.
Le problème en une phrase
Le processeur voit la mémoire à travers son cache de données. Le contrôleur DMA, sauf mention contraire, écrit directement en DRAM. Ce sont deux vues d'une même adresse, et rien ne les synchronise automatiquement.
Il en découle deux fautes symétriques, qu'il faut absolument distinguer parce qu'elles n'ont pas le même correctif.
Périphérique vers mémoire. Le DMA écrit en DRAM. Le processeur, lui, possède peut-être encore des lignes de cache correspondant à ces adresses, chargées avant le transfert. Il lit alors du cache : des données périmées. Le remède est d'invalider le cache sur la plage, après la fin du transfert et avant la lecture.
Mémoire vers périphérique. Le processeur écrit dans le tampon. Avec un cache en écriture différée, ce qui est le cas par défaut sur Cortex-A, les écritures restent dans le cache. Le DMA lit la DRAM, qui n'a pas été mise à jour : il envoie des octets périmés. Le remède est de nettoyer le cache, c'est-à-dire de forcer l'écriture des lignes sales vers la mémoire, avant de démarrer le transfert.
Inverser les deux est l'erreur la plus fréquente. Invalider avant un transfert sortant jette vos données ; nettoyer après un transfert entrant réécrit les données périmées par-dessus ce que le DMA venait de déposer.
Le piège des lignes partagées
C'est le défaut que l'on ne trouve qu'après plusieurs jours, parce qu'il ne se manifeste qu'occasionnellement.
Les opérations de cache s'appliquent à des lignes entières, typiquement 32 ou 64 octets selon le cœur. Si votre tampon DMA ne commence pas sur une frontière de ligne, ou si sa taille n'est pas un multiple de la ligne, la première et la dernière ligne contiennent aussi des données voisines qui ne font pas partie du transfert.
Invalider ces lignes détruit les écritures en attente sur ces voisines. La structure adjacente perd un champ, de façon non déterministe, en fonction de ce que l'ordonnanceur faisait à ce moment.
La règle est donc absolue : alignez le tampon sur la taille de ligne de cache, et arrondissez sa taille au multiple supérieur.
#define CACHE_LINE 64
static uint8_t rx_buf[512] __attribute__((aligned(CACHE_LINE)));
Et ne placez jamais un tampon DMA dans une structure à côté d'autres champs, ni sur la pile.
Les barrières, et ce qu'elles ne font pas
Les barrières mémoire ARM sont régulièrement invoquées comme remède au problème de cache. Elles ne le résolvent pas. Ce sont deux mécanismes distincts.
DMB garantit l'ordre d'observation des accès mémoire : les accès avant la barrière sont observés avant ceux qui la suivent. DSB est plus fort : il attend l'achèvement effectif de tous les accès en cours, y compris les opérations de maintenance de cache. ISB vide le pipeline d'instructions et force la relecture des instructions suivantes, nécessaire après avoir modifié la configuration du système, par exemple les tables de pages ou l'activation du cache.
L'ordre correct pour lancer un transfert sortant est donc :
1. écrire les données dans le tampon
2. nettoyer le cache sur la plage du tampon
3. DSB (attendre la fin réelle du nettoyage)
4. écrire le registre de démarrage du DMA
Le DSB à l'étape 3 est indispensable. Sans lui, l'écriture du registre de commande peut être observée par le périphérique avant que le nettoyage soit terminé, et le DMA démarre sur une mémoire encore incohérente.
Cortex-M : le même problème, une couche en moins
Sur Cortex-M0 à M3, il n'y a pas de cache de données : le problème n'existe pas. C'est ce qui explique qu'une base de code éprouvée sur M4 casse en passant sur M7.
Sur Cortex-M7, il y a un cache de données, et le remède passe par les fonctions CMSIS :
SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, sizeof(tx_buf)); /* avant émission */
SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, sizeof(rx_buf)); /* après réception */
Ces fonctions exigent une adresse alignée sur 32 octets, la taille de ligne du M7, exposée par CMSIS sous le nom __SCB_DCACHE_LINE_SIZE. Une adresse non alignée est silencieusement arrondie vers le bas, ce qui ramène exactement le piège des lignes partagées.
L'alternative propre sur Cortex-M est le MPU : déclarez la région du tampon DMA en Device ou en Normal, non cacheable. Vous perdez en débit d'accès processeur, mais le problème disparaît par construction, ce qui vaut mieux qu'une invalidation oubliée dans un chemin d'erreur rare.
Sous Linux, ne faites rien de tout cela
Si vous écrivez un pilote Linux, l'API DMA fait le travail, et la contourner est une faute.
dma_alloc_coherent() retourne un tampon non caché, ou cohérent par le matériel selon la plateforme : aucune maintenance à faire. C'est le bon choix pour les structures de contrôle, les anneaux de descripteurs, tout ce qui est accédé souvent par les deux côtés.
Pour les tampons de données, dma_map_single() et dma_unmap_single() avec la bonne direction, DMA_TO_DEVICE ou DMA_FROM_DEVICE, déclenchent les bonnes opérations de cache pour l'architecture cible. Le point à retenir : entre le map et le unmap, le tampon appartient au périphérique. Y toucher est un bug, même en lecture. Si vous devez y accéder entre les deux, c'est dma_sync_single_for_cpu() puis dma_sync_single_for_device().
Écrire soi-même des opérations de cache dans un pilote Linux signale presque toujours une mauvaise utilisation de l'API.
Ce qui rend le diagnostic difficile
Ces défauts ne sont pas reproductibles à volonté, et c'est ce qui coûte du temps.
Le transfert fonctionne quand le tampon n'était pas en cache, par exemple au premier passage. Il échoue quand une autre partie du code vient de le lire. Sur un système multicœur, il dépend du cœur qui exécute. Le déboguer avec un point d'arrêt le fait disparaître, parce que l'arrêt laisse le temps aux écritures différées de partir.
Un réflexe utile : si un bug de DMA disparaît en désactivant le cache de données, la cause est établie, et il reste à trouver quelle plage n'est pas correctement maintenue.
Références
- Arm, CMSIS-Core, D-Cache Functions,
SCB_CleanDCache_by_Addr,__SCB_DCACHE_LINE_SIZE - Arm, Armv8-A memory model, barriers, sémantique de
DMB,DSBetISB - Kernel.org, Dynamic DMA mapping guide,
dma_map_single,dma_sync_single_for_cpu - STMicroelectronics, Maintaining cache coherence for DMA buffers
Pour aller plus loin
Ces mécanismes deviennent clairs une fois qu'on a lu la description d'un cache en écriture différée, suivi le trajet d'un accès à travers la MMU, et manipulé les opérations de maintenance sur une carte réelle. C'est ce que couvrent nos formations sur l'architecture ARM Cortex-A et R, Cortex-M, et sur les pilotes Linux.