DMA circolare su STM32 con double buffering: il pattern che previene la perdita di dati

2026-05-29 · Davide Carrese
STM32 · DMA · Firmware Architecture
Commenti

Il DMA su STM32 è una di quelle periferiche che sembrano semplici in uno screenshot CubeMX ma causano corruzione dati sottile in produzione. La modalità di guasto più comune che vedo nei progetti clienti non è un errore nella struct di inizializzazione DMA. È l'assenza di una strategia di double buffering: l'applicazione legge da un buffer DMA mentre il controller DMA ci sta ancora scrivendo. Questo articolo mostra il pattern modo-circolare + interrupt di metà trasferimento che risolve il problema e spiega perché è importante sui Cortex-M7 con cache dati.

Il problema: buffer singolo DMA è una race condition per costruzione

Quando configuri uno stream DMA per riempire un buffer in modalità normale e poi elabori il buffer dopo l'interrupt di trasferimento completato, il firmware è corretto ma il throughput è limitato: la CPU aspetta l'intero buffer prima di fare qualsiasi lavoro. Va bene per trasferimenti occasionali, ma per campionamento ADC continuo a 100 kHz, uno stream UART a diversi megabaud o un generatore di forme d'onda DAC, la latenza dell'attesa del buffer completo è spreco. L'istinto naturale è usare la modalità circolare così il DMA continua a trasferire mentre l'applicazione legge dallo stesso buffer. Quell'istinto crea la race.

In modalità circolare, il controller DMA torna all'inizio del buffer quando raggiunge la fine. Se l'applicazione legge dal buffer in qualsiasi momento senza sincronizzazione, può leggere dati parzialmente aggiornati. Non c'è un mutex hardware. L'unica soluzione pulita su un STM32 single-core è dividere il buffer in due metà e lasciare che il controller DMA segnali ogni metà completata tramite un interrupt dedicato: l'interrupt di metà trasferimento e l'interrupt di trasferimento completato. L'applicazione elabora la metà appena completata mentre il controller DMA riempie l'altra metà. Questo è il double buffering, ed è il fondamento della maggior parte delle pipeline DMA affidabili su STM32.

Come il DMA STM32 segnala i trasferimenti a metà e completi

Ogni stream DMA STM32 può generare interrupt in tre punti: metà trasferimento (HT), trasferimento completato (TC) ed errore di trasferimento (TE). L'interrupt di metà trasferimento scatta quando il controller DMA ha trasferito esattamente la metà del numero di elementi configurato. In modalità circolare, HT scatta a metà del buffer, poi TC alla fine, poi il puntatore ricomincia, HT scatta di nuovo a metà del ciclo successivo, e così via.

Nell'HAL, questi interrupt corrispondono a HAL_ADC_ConvHalfCpltCallback() o al generico XferHalfCpltCallback() a seconda del driver periferico. L'intuizione chiave è che quando HT scatta, la prima metà del buffer è stabile e può essere elaborata. Quando TC scatta, la seconda metà è stabile. L'applicazione lavora sempre sulla metà che non viene scritta dal DMA.

Un pattern ADC double-buffer minimale

L'esempio seguente usa l'ADC STM32 con DMA in modalità circolare. Non utilizza un approccio bare-metal ma mantiene visibili le chiamate HAL perché la maggior parte dei codebase clienti che eredito sono basati su HAL, non su registri. Il buffer è il doppio della dimensione di un "frame" di campioni, così ogni metà è un frame completo.

#include "stm32f4xx_hal.h"
#include <stdbool.h>

#define ADC_FRAME_SAMPLES  64
#define ADC_BUF_SIZE       (ADC_FRAME_SAMPLES * 2)

static uint16_t adc_buf[ADC_BUF_SIZE];
static volatile uint8_t adc_frame_ready; /* 0=nessuno, 1=prima metà, 2=seconda metà */

static uint16_t adc_frame[ADC_FRAME_SAMPLES];

void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1) {
        memcpy(adc_frame, &adc_buf[0],
               ADC_FRAME_SAMPLES * sizeof(uint16_t));
        adc_frame_ready = 1;
    }
}

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1) {
        memcpy(adc_frame, &adc_buf[ADC_FRAME_SAMPLES],
               ADC_FRAME_SAMPLES * sizeof(uint16_t));
        adc_frame_ready = 2;
    }
}

void adc_dma_init(void)
{
    __HAL_RCC_DMA2_CLK_ENABLE();

    ADC_ChannelConfTypeDef sConfig = {0};
    sConfig.Channel = ADC_CHANNEL_0;
    sConfig.Rank = 1;
    sConfig.SamplingTime = ADC_SAMPLETIME_3CYCLES;
    HAL_ADC_ConfigChannel(&hadc1, &sConfig);

    HAL_ADC_Start_DMA(&hadc1,
        (uint32_t *)adc_buf, ADC_BUF_SIZE);
}

void adc_process_loop(void)
{
    for (;;) {
        if (adc_frame_ready) {
            uint8_t frame = adc_frame_ready;
            adc_frame_ready = 0;

            /* Elabora adc_frame[] qui.
               Il DMA sta scrivendo l'altra metà. */
            (void)frame;

            /* Manutenzione cache SCB su Cortex-M7 (vedi sotto) */
        }
        __WFI();
    }
}

Questo pattern funziona su STM32F4, F7, H7, G0, G4, L4, L5 e U5. I nomi delle callback HAL variano leggermente tra le famiglie ma il concetto è identico. Su parti F0/F1/L0/L1, verifica se il controller DMA supporta gli interrupt di metà trasferimento; la maggior parte lo fa, ma alcune sotto-famiglie F0 più vecchie richiedono l'interrupt di trasferimento completato più un timer software come fallback.

UART RX con DMA circolare: l'overflow silenzioso

La ricezione UART con DMA in modalità circolare è un altro caso in cui l'interrupt di metà trasferimento risolve un problema reale. Senza di esso, l'applicazione deve interrogare il registro NDTR del DMA per scoprire quanti byte sono arrivati dall'ultimo controllo. Su un sistema carico, tra due controlli il DMA può avvolgere l'intero buffer, sovrascrivere i vecchi dati e l'applicazione non lo saprà mai perché NDTR è cambiato correttamente ma i dati sono spariti.

Il pattern standard di rilevamento idle UART più DMA circolare con HT/TC è l'approccio affidabile. La callback HT elabora la prima metà, la callback TC elabora la seconda metà, e l'interrupt di idle UART elabora i byte parziali rimanenti nella metà corrente dall'ultimo evento DMA. Combinato con un'astrazione ring-buffer nel livello applicativo, questo dà una ricezione UART zero-copy che tollera i burst.

Su parti STM32G0/G4/U5 che hanno un DMA mux (DMAMUX), devi mappare la richiesta UART RX al canale DMA corretto attraverso il registro DMAMUX. CubeMX lo genera di default, ma quando vedo un'inizializzazione DMA scritta a mano che dimentica il DMAMUX, lo stream non parte mai e il primo passo di debug è fissare il registro di stato DMA che mostra zero trasferimenti.

Cache dati Cortex-M7: la fonte nascosta di corruzione DMA

Su parti STM32F7 e STM32H7 con core Cortex-M7, la cache dati aggiunge un ulteriore strato a questo problema anche quando la logica di double-buffer è corretta. Il controller DMA scrive direttamente in SRAM. La CPU legge dagli stessi indirizzi SRAM, ma se quegli indirizzi sono in cache, la CPU può leggere una linea di cache stantia invece dei dati appena scritti dal DMA.

La soluzione è la manutenzione esplicita della cache nelle callback HT e TC prima che l'applicazione legga la metà del buffer appena completata. Per un buffer allocato in una regione non-cacheable (tramite MPU), non serve manutenzione ma la penalità di prestazioni degli accessi non-cacheable può essere inaccettabile per il loop di elaborazione. L'approccio pragmatico è mantenere il buffer DMA in una regione cacheable e pulire/invalidare le linee di cache rilevanti prima di ogni lettura.

/* Invalidazione cache Cortex-M7 per metà buffer DMA */
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1) {
#if defined(__DCACHE_PRESENT) && (__DCACHE_PRESENT == 1U)
        SCB_InvalidateDCache_by_Addr(
            (uint32_t *)&adc_buf[0],
            ADC_FRAME_SAMPLES * sizeof(uint16_t));
#endif
        memcpy(adc_frame, &adc_buf[0],
               ADC_FRAME_SAMPLES * sizeof(uint16_t));
        adc_frame_ready = 1;
    }
}

Nota che SCB_InvalidateDCache_by_Addr() opera su confini di 32 byte delle linee di cache. L'implementazione CMSIS arrotonda l'indirizzo verso il basso e la dimensione verso l'alto automaticamente, ma se il tuo buffer non è allineato a un confine di 32 byte, potresti invalidare dati adiacenti usati da altro codice. Allinea i buffer DMA a 32 byte su Cortex-M7. L'MPU può anche essere configurato per marcare la regione SRAM specifica usata dal DMA come non-cacheable, shared o strongly-ordered. Questo rimuove la necessità di manutenzione manuale della cache ma rende ogni accesso CPU a quella regione più lento. Scegli in base al throughput di elaborazione di cui l'applicazione ha bisogno.

Conflitti di priorità degli stream DMA su parti più complesse

Su parti come STM32F4 con due controller DMA e otto stream ciascuno, o STM32H7 con istanze DMA multiple distribuite su bus di dominio, la priorità degli stream è reale e poco compresa. Quando due stream competono per lo stesso bus o la stessa porta della matrice AHB, lo stream a priorità più bassa si ferma. Se quello stream alimenta un DAC o una FIFO UART TX, lo stallo può causare un vuoto visibile nell'output.

Per ADC o UART RX con double buffering, la priorità dello stream raramente conta perché il produttore (DMA) è davanti al consumatore (CPU). Ma per DAC pilotato da DMA, SPI TX o output SAI/I2S, mappa lo stream time-critical su un canale a priorità più alta ed evita di condividere lo stesso controller DMA tra uno stream di output ad alta velocità e una copia bulk memory-to-memory. Su H7, preferisci BDMA per periferiche a bassa velocità e MDMA per memory-to-memory, così i controller DMA1/DMA2 principali sono liberi per stream in tempo reale.

Esempio pratico: un datalogger ADC a 4 canali con esportazione UART

Considera un prodotto che campiona quattro canali ADC a 50 kHz ciascuno, bufferizza un secondo di dati e li esporta su UART a 921600 baud. L'approccio ingenuo avvia un buffer circolare DMA per canale ed elabora ciascuno nella propria callback HT/TC. A 50 kHz × 4 canali × 2 byte per campione, il throughput DMA è 400 KB/s. È ben dentro le capacità di qualsiasi controller DMA STM32F4, ma quattro stream concorrenti in lotta per la matrice AHB più quattro set di callback che scattano a cadenze diverse rendono il firmware difficile da ragionare e debuggare.

Io scansionerei tutti e quattro i canali in una singola sequenza ADC injected o regular con uno stream DMA, producendo un buffer multiplato dove ogni quattro campioni corrispondono a una scansione dei quattro canali. Le callback HT/TC poi demultiplano il frame in quattro array separati nella fase di elaborazione. Il risultato è uno stream DMA, un set di interrupt, temporizzazione prevedibile, e lo stream UART TX ha il proprio canale DMA su un controller diverso così le due pipeline non interferiscono.

Checklist pratica

Come lo affronterei su un progetto cliente

Inizio elencando ogni consumatore DMA nel sistema: stream ADC, UART RX/TX, transazioni SPI, output DAC, catture triggerate da timer. Assegno a ciascuno un design di buffer esplicito: buffer singolo con stop/restart, double-buffer con HT/TC, o circolare con consumo ring-buffer. Poi disegno l'allocazione dei controller DMA sulle istanze e canali disponibili, evitando conflitti tra stream di output time-critical e operazioni bulk di memoria. La manutenzione della cache va in un header centrale con guardie #if defined(__DCACHE_PRESENT) così compila pulitamente su tutte le famiglie STM32. Infine, strumento ogni callback HT/TC con una traccia GPIO durante il primo sprint di integrazione. Senza quella traccia, la latenza reale degli interrupt sul PCB target è invisibile.

In code review, la prima cosa che cerco in una callback DMA è una memcpy senza un'invalidazione della cache precedente su parti M7. La seconda cosa che cerco è un DMA UART RX senza interrupt di idle. La terza è un buffer in modalità circolare dove l'applicazione legge senza alcuna sincronizzazione con il confine metà/completo. Questi tre pattern rappresentano la maggior parte dei bug di campo legati al DMA che ho debuggato.

Fonti consultate

Commenti

Hai un caso specifico di double buffering DMA su STM32 o una storia di debug sulla coerenza cache? Scrivimi una nota breve via email.