Buffer Circolare USART su STM32F4: RX/TX Interrupt-Driven senza Lock
Ogni progetto embedded ha bisogno di I/O seriale. Log di debug, comandi CLI, polling sensori, protocolli bootloader — la USART è il backchannel universale. L'approccio ingenuo (HAL_UART_Transmit/Receive bloccante) funziona per i prototipi, ma appena servono path di esecuzione concorrenti — un loop di controllo che continua mentre stampi, o un comando che arriva mentre è in corso un'acquisizione — la UART bloccante diventa un collo di bottiglia. Un buffer circolare interrupt-driven disaccoppia l'ISR dal contesto applicativo senza un singolo lock, senza malloc, e con latenza limitata. Ecco come lo implemento su STM32F4 a livello di registro.
Perché un buffer circolare, e perché lock-free
Un ring buffer (buffer circolare) è una FIFO a dimensione fissa basata su un array lineare. Due puntatori tracciano lo stato: head (dove viene scritto il prossimo byte) e tail (dove viene letto il prossimo byte). Quando un puntatore raggiunge la fine del buffer, torna all'indice zero.
La particolarità di un buffer circolare SPSC (single-producer, single-consumer) è che non servono operazioni atomiche né mutex — a patto di rispettare la seguente regola:
- Il produttore (l'ISR di RX, in questo caso) scrive in
heade lo avanza. Non tocca maitail. - Il consumatore (il codice applicativo, o il main loop) legge da
taile lo avanza. Non tocca maihead.
Poiché ogni indice è scritto da esattamente un contesto, non c'è mai una race condition sui puntatori stessi. Per i byte dati, il produttore scrive in uno slot dopo aver avanzato head, e il consumatore legge da uno slot prima di avanzare tail. Con una struttura condivisa qualificata volatile e __DSB() dove necessario, questo è corretto su Cortex-M4 senza atomica esplicita.
Configurazione USART a registro su STM32F4
Prima di parlare del buffer, la USART va configurata. Su STM32F401RE (USART2 su PA2-TX, PA3-RX tramite il connettore Nucleo, oppure la porta COM virtuale su PA2/PA3 instradata attraverso ST-Link), i registri del periferico sono:
- USART_BRR: divisore del baud rate. Per 115200 baud con clock APB a 42 MHz (tipico per USART2 su APB1):
USART_BRR = 42000000 / 115200 = 365 = 0x16D. - USART_CR1: UE (enable), TE (transmitter enable), RE (receiver enable), RXNEIE (RX not empty interrupt enable), TCIE (transmit complete interrupt enable).
- USART_CR2: stop bit (default 1 stop bit è 0b00).
- USART_CR3: nessun controllo di flusso di default, nessun oversampling a 8.
L'inizializzazione si riduce ad abilitare il clock sul bus APB giusto, configurare i pin GPIO in modalità alternate function, poi scrivere i registri del periferico. Ecco la configurazione completa per USART2 a 115200 8N1:
void usart_init(void)
{
// Abilita clock: USART2 su APB1, GPIOA su AHB1
RCC->APB1ENR |= RCC_APB1ENR_USART2EN;
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
__DSB();
// PA2 = TX (AF7), PA3 = RX (AF7)
GPIOA->MODER &= ~(GPIO_MODER_MODER2 | GPIO_MODER_MODER3);
GPIOA->MODER |= (2 << GPIO_MODER_MODER2_Pos | 2 << GPIO_MODER_MODER3_Pos);
GPIOA->AFR[0] &= ~(0xF << (2 * 4) | 0xF << (3 * 4));
GPIOA->AFR[0] |= (7 << (2 * 4) | 7 << (3 * 4)); // AF7 = USART2
// 115200 baud @ 42 MHz APB1
USART2->BRR = 42000000 / 115200; // = 365 -> 0x16D
// Abilita USART, TX, RX, interrupt RXNE
USART2->CR1 = USART_CR1_UE | USART_CR1_TE | USART_CR1_RE |
USART_CR1_RXNEIE;
// Niente interrupt TX in init — abilitiamo TCIE on demand
}
Nota che non abilitiamo gli interrupt di TX complete (TCIE) in init. Vengono attivati solo quando ci sono dati da inviare, e disabilitati quando il buffer di TX è vuoto. Questo evita che l'ISR di TX scatti continuamente su una linea inattiva.
Struttura dati del buffer circolare
Il ring buffer usa una dimensione potenza di 2 in modo che l'avvolgimento possa essere fatto con una mascheratura bitwise invece di un modulo. Su Cortex-M4, una AND a ciclo singolo è misurabilmente più veloce della divisione basata su modulo che i compilatori emettono per dimensioni arbitrarie.
#define UART_BUF_SIZE 256 // deve essere potenza di 2
#define UART_BUF_MASK (UART_BUF_SIZE - 1)
typedef struct {
volatile uint16_t head; // indice produttore (ISR scrive qui)
volatile uint16_t tail; // indice consumatore (main legge qui)
uint8_t buf[UART_BUF_SIZE];
} ringbuf_t;
static ringbuf_t rx_buf;
static ringbuf_t tx_buf;
Il qualificatore volatile dice al compilatore che head e tail possono cambiare al di fuori del contesto di esecuzione corrente — cioè nell'ISR. Il buffer buf[] stesso non ha bisogno di volatile perché viene acceduto indirettamente tramite questi indici.
Operazioni di push e pop
Per il buffer RX, l'ISR fa push dei byte e il main loop fa pop:
static inline bool ringbuf_push(ringbuf_t *rb, uint8_t byte)
{
uint16_t next = (rb->head + 1) & UART_BUF_MASK;
if (next == rb->tail) // pieno
return false;
rb->buf[rb->head] = byte;
__DMB(); // assicura che il byte sia scritto prima di head
rb->head = next;
return true;
}
static inline bool ringbuf_pop(ringbuf_t *rb, uint8_t *byte)
{
if (rb->tail == rb->head) // vuoto
return false;
*byte = rb->buf[rb->tail];
__DMB();
rb->tail = (rb->tail + 1) & UART_BUF_MASK;
return true;
}
static inline uint16_t ringbuf_avail(const ringbuf_t *rb)
{
return (rb->head - rb->tail) & UART_BUF_MASK;
}
La memory barrier (__DMB()) assicura che la CPU non riordini la scrittura del dato prima dell'aggiornamento di head (in push) o la lettura del dato prima dell'aggiornamento di tail (in pop). Su Cortex-M4 senza cache tecnicamente non serve per la correttezza su singolo core, ma la tengo come documentazione e per portabilità su Cortex-M7 con cache.
Gestore interrupt RX
Il flag RXNE viene impostato non appena un byte viene ricevuto. L'ISR legge USART_DR (che pulisce RXNE) e inserisce il byte nel ring buffer.
void USART2_IRQHandler(void)
{
uint32_t sr = USART2->SR;
if (sr & USART_SR_RXNE) {
uint8_t byte = (uint8_t)(USART2->DR & 0xFF);
if (!ringbuf_push(&rx_buf, byte)) {
// Buffer pieno — byte perso. Opzionalmente imposta un flag.
rx_overflow = 1;
}
}
if (sr & USART_SR_TC) {
// TX completo: se ci sono altri byte in tx_buf, invia il prossimo
uint8_t byte;
if (ringbuf_pop(&tx_buf, &byte)) {
USART2->DR = byte;
} else {
// Nessun altro dato: disabilita interrupt TC
USART2->CR1 &= ~USART_CR1_TCIE;
}
}
}
L'ISR RX deve essere veloce. Fare push in un ring buffer da 256 byte sono 6 load/store e un'operazione ALU — circa 12 cicli CPU a 84 MHz, ovvero 140 ns. Ben dentro il budget temporale per 115200 baud (un byte ogni 86.8 µs).
TX con interrupt TC su richiesta
La trasmissione è più delicata perché non vogliamo che l'ISR di TX scatti quando non c'è niente da inviare. La strategia:
- Una funzione
uart_write(uint8_t byte)inserisce il byte nel ring buffer di TX. - Se l'interrupt TC non è già abilitato, lo abilitiamo ora. Il flag TC pendente da una trasmissione precedente innesca una chiamata ISR immediata, che preleva il primo byte e lo invia.
- L'ISR TC preleva i byte successivi. Quando il buffer TX è vuoto, disabilita TCIE.
void uart_write(uint8_t byte)
{
// Attendi se buffer TX pieno (back-pressure)
while (ringbuf_avail(&tx_buf) == UART_BUF_SIZE - 1) {
if (!(USART2->CR1 & USART_CR1_TCIE)) {
USART2->CR1 |= USART_CR1_TCIE;
}
}
ringbuf_push(&tx_buf, byte);
// Avvia la pompa TC se inattiva
USART2->CR1 |= USART_CR1_TCIE;
}
void uart_write_str(const char *s)
{
while (*s) uart_write((uint8_t)*s++);
}
int uart_read(uint8_t *byte)
{
return ringbuf_pop(&rx_buf, byte) ? 0 : -1;
}
uint16_t uart_available(void)
{
return ringbuf_avail(&rx_buf);
}
Questo schema è strettamente single-producer, single-consumer: il contesto principale chiama uart_write e l'ISR consuma dal buffer TX. Nessun lock necessario.
Esempio pratico: echo server con parser comandi
Ecco come il ring buffer si integra in un vero main loop. L'applicazione rimanda indietro ogni carattere ricevuto e riconosce un comando led\n che commuta un pin di uscita:
static char line_buf[64];
static uint8_t line_pos = 0;
void process_line(const char *line)
{
if (strcmp(line, "led") == 0) {
GPIOA->ODR ^= GPIO_ODR_OD5; // commuta PA5 (LED Nucleo)
uart_write_str("LED toggled\r\n");
} else if (strcmp(line, "help") == 0) {
uart_write_str("Commands: led, help, hello\r\n");
} else if (strncmp(line, "hello ", 6) == 0) {
uart_write_str("Ciao, ");
uart_write_str(line + 6);
uart_write_str("!\r\n");
} else {
uart_write_str("Sconosciuto: ");
uart_write_str(line);
uart_write_str("\r\n");
}
}
void main_loop(void)
{
while (1) {
uint8_t c;
if (uart_read(&c) == 0) {
uart_write(c); // echo
if (c == '\r' || c == '\n') {
uart_write_str("\r\n");
line_buf[line_pos] = '\0';
if (line_pos > 0)
process_line(line_buf);
line_pos = 0;
} else if (line_pos < sizeof(line_buf) - 1) {
line_buf[line_pos++] = c;
}
}
// Codice applicativo qui — polling sensori, loop di controllo, ecc.
}
}
La proprietà fondamentale: ogni chiamata a uart_read e uart_write è non bloccante (o brevemente in attesa solo in caso di buffer TX pieno). Questo significa che il tuo loop di controllo continua a eseguire anche quando il terminale seriale non ha niente da dire.
Checklist pratica
- Dimensione buffer potenza di 2 — Permette
idx & maskinvece diidx % size. Per 256 byte l'overhead è ~60 ns per push vs ~180 ns con modulo. A 115200 baud la differenza è trascurabile, ma a 921600 conta. - Flag overflow RX — Aggiungi sempre un contatore o un flag sticky. Senza, un byte perso è invisibile durante il debug.
- TC vs TXE — TC scatta quando lo shift register si svuota (tutti i bit inviati sul filo). TXE scatta quando DR è vuoto (pronto per il prossimo byte). Usare TC per il controllo di flusso dà back-pressure: sai che il byte è uscito dal chip. Per output di debug, TC è più sicuro perché previene la perdita di byte quando la linea è rallentata da un terminale lento.
- Priorità NVIC — Imposta la priorità dell'interrupt USART abbastanza alta da poter preemptare task a priorità inferiore ma non SysTick se lo usi per temporizzazione. Una priorità di 5 su STM32F4 (NVIC a 4 bit, numero più basso = priorità più alta) è un buon compromesso.
- Errore di overrun — Controlla
USART_SR_OREnell'ISR. Se ORE è impostato, la USART si è bloccata e necessita di una reinit o almeno una lettura di DR e poi SR per pulire il flag. - Nessuna interoperabilità HAL — Se usi questo approccio a ring buffer, non chiamare
HAL_UART_Receive_IToHAL_UART_IRQHandlerdopo. L'HAL possiede gli stessi vettori di interrupt. Devi o usare registri nudi ovunque o implementare l'ISR nella callback dell'HAL.
Come lo affronterei su un progetto cliente
Su un progetto firmware commerciale, non spedirei il ring buffer in questa forma grezza direttamente. Lo incapsulerei in un modulo che fornisce due canali seriali indipendenti: uno per debug/CLI (interfaccia umana, basso throughput, bisogno di echo e line editing) e uno per un protocollo binario (interfaccia macchina, alto throughput, framing raw). Entrambi condividono le stesse primitive di ring buffer ma con politiche ISR diverse — il canale debug usa controllo di flusso basato su TC, il canale binario usa streaming basato su TXE con DMA per trasferimenti bulk oltre 64 byte.
Aggiungerei anche un helper uart_flush() che aspetta in busy-loop finché il buffer TX è vuoto e il flag TC è impostato — fondamentale prima di entrare in modalità STOP, dove una trasmissione UART incompleta verrebbe troncata. E instraderei tutto l'output di debug in stile printf attraverso il ring buffer implementando _write() (Newlib-nano / syscall stub), così ogni printf("sensor=%d", val) passa automaticamente attraverso il percorso interrupt-driven senza bloccare.
Infine, farei test sotto carico: due porte USART collegate in loopback a 921600 baud, inviando 1 MB di dati casuali, con verifica CRC-32 sul lato ricevente e zero byte persi. Questo è il criterio di accettazione che uso. Se il ring buffer perde un singolo byte a 921600 con RX e TX entrambi attivi, il design non è abbastanza robusto per la produzione.
Fonti e approfondimenti
- STMicroelectronics, RM0368 — STM32F401 Reference Manual: mappa dei registri USART, capitoli 20–21.
- STMicroelectronics, AN3109 — STM32 USART communication: nota applicativa su configurazione e gestione errori USART.
- STMicroelectronics, STM32CubeF4 Firmware Package —
Projects/STM32F401RE-Nucleo/Examples/UART: esempi HAL e LL di riferimento. - Memfault Blog — serie "Ring Buffer Basics" sui pattern di buffer circolare in sistemi embedded.
- ARM, CMSIS-Core (Cortex-M4): funzioni intrinseche per memory barrier (
__DMB,__DSB).

Comments
Have comments? Send me an email.