STM32 SPI Master Full-Duplex — Configurazione a registri su STM32F4
Il modulo SPI su STM32F4 è semplice da inizializzare con CubeMX ma pieno di insidie quando lo si configura a livello di registri: mismatch di polarità clock, NSS non gestita, problemi CRC e condizioni di underrun silenzioso. Ecco esattamente quali bit impostare, e perché.
Ogni contractor che ha debuggato un sensore o un display che "ogni tanto funziona" via SPI conosce la sensazione: il primo byte è spazzatura, il clock si ferma a metà transazione, o lo slave ignora tutto perché CPOL/CPHA non combaciano. Quando si scrive codice HAL, questi problemi sono nascosti dietro strati di struct di configurazione e loop di timeout. A livello di registri, nulla è nascosto — ed è esattamente questo il punto.
Questo articolo esamina ogni registro di configurazione SPI su STM32F401/STM32F4, costruisce un'inizializzazione master full-duplex da zero, e mostra uno scambio dati robusto via interrupt che gestisce l'hardware correttamente. Niente HAL, niente CubeMX — solo il Reference Manual (RM0368).
Mappa della memoria SPI su STM32F4
Lo STM32F401 ha fino a tre periferiche SPI (SPI1 su APB2, SPI2/SPI3 su APB1). Ognuna espone la stessa mappa di registri al proprio indirizzo di base. I registri chiave per la configurazione in modalità master:
| Offset | Registro | Scopo |
|---|---|---|
| 0x00 | CR1 | Controllo 1 — baud rate, CPOL/CPHA, master/slave, ordine dati, CRC, SSI/SSM, SPE (enable) |
| 0x04 | CR2 | Controllo 2 — abilitazioni interrupt, DMA, SSOE, formato frame, dimensione dati (8/16-bit su F4) |
| 0x08 | SR | Stato — TXE, RXNE, BSY, OVR, CRCERR, MODF, FRE |
| 0x0C | DR | Dati — scrivi TX, leggi RX (stesso indirizzo, percorso di shift separato) |
| 0x10 | CRCPR | Polinomio CRC |
| 0x14 | RXCRCR | Valore CRC RX |
| 0x18 | TXCRCR | Valore CRC TX |
| 0x1C | I2SCFGR | Config I2S (modalità SPI resetta questo) |
Inizializzazione a registri, passo dopo passo
Supponiamo di targettare SPI1 su STM32F401, usando PA5 (SCK), PA6 (MISO), PA7 (MOSI) con NSS hardware su PA4. APB2 a 84 MHz, vogliamo 5.25 MHz SCK, CPOL=0 CPHA=0 (modo 0), dati a 8 bit, MSB-first.
Passo 1: Configurazione GPIO
I pin SPI devono essere configurati come alternate function (AF5 per SPI1 su STM32F401). Un errore comune è lasciare la velocità GPIO su low — su SPI a velocità MHz, serve almeno SPEED_HIGH (100 MHz) per avere fronti puliti. Push-pull, nessun pull-up/pull-down su SCK e MOSI (li pilota il master), e pull-up su MISO per integrità del segnale:
/* Abilita clock GPIOA */ RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN; /* PA5 (SCK), PA7 (MOSI): AF5, push-pull, alta velocità */ GPIOA->MODER &= ~(GPIO_MODER_MODER5_Msk | GPIO_MODER_MODER7_Msk); GPIOA->MODER |= (GPIO_MODER_MODER5_AF | GPIO_MODER_MODER7_AF); GPIOA->AFR[0] |= (5 << GPIO_AFRL_AFSEL5_Pos) | (5 << GPIO_AFRL_AFSEL7_Pos); GPIOA->OSPEEDR |= (GPIO_OSPEEDER_OSPEEDR5 | GPIO_OSPEEDER_OSPEEDR7); GPIOA->PUPDR &= ~(GPIO_PUPDR_PUPDR5_Msk | GPIO_PUPDR_PUPDR7_Msk); /* nessun pull */ /* PA6 (MISO): AF5, input, pull-up */ GPIOA->MODER &= ~GPIO_MODER_MODER6_Msk; GPIOA->MODER |= GPIO_MODER_MODER6_AF; GPIOA->AFR[0] |= (5 << GPIO_AFRL_AFSEL6_Pos); GPIOA->PUPDR &= ~GPIO_PUPDR_PUPDR6_Msk; GPIOA->PUPDR |= (GPIO_PUPDR_PUPDR6_PullUp);
Passo 2: CR1 — clock, polarità, fase, master mode e gestione bus
Qui vive la maggior parte della configurazione. CR1 è protetto in scrittura mentre SPE (bit 6) è attivo — devi configurare tutto prima di abilitare la periferica, o disabilitarla per cambiare le impostazioni.
/* Disabilita SPI1 prima della configurazione */
SPI1->CR1 &= ~SPI_CR1_SPE;
/* Baud rate: 84 MHz / 16 = 5.25 MHz → BR[2:0] = 011 */
/* CPOL=0, CPHA=0 (SPI mode 0) */
/* MSTR=1 (master mode), SSI=1 (NSS interna high per master) */
/* SSM=1 (gestione software NSS — evita MODE fault) */
/* DFF=0 (dati a 8 bit), LSBFIRST=0 (MSB first) */
SPI1->CR1 = (0x3 << SPI_CR1_BR_Pos)
| SPI_CR1_MSTR
| SPI_CR1_SSI
| SPI_CR1_SSM;
Il prescaler BR è il campo più frainteso della mappa dei registri. BR divide il clock APB, non il clock di sistema. Su SPI1 (APB2, 84 MHz):
| BR[2:0] | Divisore | SCK a 84 MHz | SCK a 42 MHz (SPI2/3) |
|---|---|---|---|
| 000 | 2 | 42.00 MHz | 21.00 MHz |
| 001 | 4 | 21.00 MHz | 10.50 MHz |
| 010 | 8 | 10.50 MHz | 5.25 MHz |
| 011 | 16 | 5.25 MHz | 2.625 MHz |
| 100 | 32 | 2.625 MHz | 1.3125 MHz |
| 101 | 64 | 1.3125 MHz | 656.25 kHz |
| 110 | 128 | 656.25 kHz | 328.125 kHz |
| 111 | 256 | 328.125 kHz | 164.0625 kHz |
A 84 MHz, il clock SPI minimo è 84 MHz / 256 ≈ 328 kHz. Se serve più lento (es. per una pista PCB lunga o uno slave datato), devi ridurre ulteriormente il clock APB2 o accettare il minimo. Non c'è un divisore aggiuntivo — pianifica l'albero dei clock di conseguenza.
Passo 3: CR2 — abilitazioni interrupt e SSOE
CR2 è piccolo su STM32F4 (niente campo DS per la dimensione dati come sui più recenti STM32G4/H5 — qui DFF in CR1 controlla 8 vs 16 bit). Per uno scambio via interrupt:
/* Abilita interrupt RX buffer not empty */ /* TX buffer empty ed error interrupt sono opzionali */ SPI1->CR2 = SPI_CR2_RXNEIE; /* Opzionale: imposta SSOE per pilotare automaticamente NSS */ /* Solo se usi gestione hardware NSS (SSM=0) */ /* Con SSM=1 (software), SSOE è irrilevante */
Se usi NSS hardware (SSM=0, SSOE=1), la periferica SPI pilota il pin NSS basso automaticamente quando SPI è abilitato e lo riporta alto dopo ogni transazione (a patto di impostare anche NSSP in CR2 per la modalità pulse). Con la gestione software NSS (SSM=1, come sopra), controlli NSS tramite il bit SSI in CR1 — il pin è disponibile come GPIO e lo piloti manualmente. L'approccio software evita completamente il MODF fault ed è la mia scelta predefinita per bus multi-slave.
Passo 4: Abilita SPI e verifica
SPI1->CR1 |= SPI_CR1_SPE; /* Abilita SPI1 */ /* Verifica leggendo SR — SPE deve diventare alto */ volatile uint32_t sr = SPI1->SR; (void)sr; /* scarta, usato solo per l'effetto collaterale della lettura */
Scambio dati full-duplex: il protocollo TXE/RXNE
Lo SPI full-duplex su STM32 funziona con un singolo registro dati: scrivere in DR fa uscire i dati su MOSI mentre contemporaneamente entra il dato su MISO. Il clock di shift viene generato solo quando scrivi. Questo significa che ogni byte che invii forza la ricezione di un byte, e viceversa.
La sequenza corretta per un master è:
- Attendi TXE (transmit buffer empty) in SR.
- Scrivi il byte in uscita in DR.
- Attendi RXNE (receive buffer not empty) in SR.
- Leggi DR per ottenere il byte in entrata.
- Ripeti per il byte successivo.
Una versione bloccante che si comporta correttamente:
static void spi_write_read(SPI_TypeDef *spi, uint8_t *tx, uint8_t *rx, uint32_t len) {
for (uint32_t i = 0; i < len; i++) {
/* Aspetta TXE */
while (!(spi->SR & SPI_SR_TXE));
/* Scrivi byte TX */
*(volatile uint8_t *)&spi->DR = tx[i];
/* Aspetta RXNE */
while (!(spi->SR & SPI_SR_RXNE));
/* Leggi byte RX */
rx[i] = *(volatile uint8_t *)&spi->DR;
}
/* Aspetta che BSY si azzeri — garantisce che l'ultimo byte sia stato spedito */
while (spi->SR & SPI_SR_BSY);
}
Nota il trucco dell'accesso volatile uint8_t per DR. Il registro dati SPI su STM32F4 è a 16 bit all'indirizzo. Se scrivi a 16 bit ((uint16_t)), invii due byte anche con DFF=0 — la periferica impacchetta due frame da 8 bit. Usando un cast a volatile uint8_t *, effettui un accesso a byte che l'hardware interpreta come un singolo frame a 8 bit. Questo è documentato nel reference manual (RM0368, sezione 24.5.1): "quando DFF=0, serve solo un accesso a byte".
Cosa succede se ignori questo dettaglio?
Se scrivi SPI1->DR = (uint16_t)dato con DFF=0, la periferica vede una scrittura a 16 bit e trasmette due byte consecutivi — il secondo è il byte superiore della tua word (probabilmente zero). Lo slave clocka un byte, il secondo è spazzatura che corrompe la transazione in corso, e la macchina a stati dello slave si desincronizza. Questo è uno dei bug SPI più comuni che trovo nei codebase che controllo.
Scambio dati via interrupt
I loop bloccanti con while(!(SR & RXNE)) tengono occupata la CPU per l'intera durata della transazione, che su un bus SPI a 5 MHz che muove 1 KB è circa 1.6 ms. Su molti progetti embedded va bene, ma per loop di controllo in tempo reale una versione via interrupt è preferibile.
#define SPI_TX_BUF_SIZE 256
#define SPI_RX_BUF_SIZE 256
static volatile uint8_t spi_tx_buf[SPI_TX_BUF_SIZE];
static volatile uint8_t spi_rx_buf[SPI_RX_BUF_SIZE];
static volatile uint32_t spi_tx_idx, spi_rx_idx, spi_xfer_len;
static volatile uint8_t spi_busy;
void spi_start_transfer(SPI_TypeDef *spi,
uint8_t *tx, uint8_t *rx, uint32_t len) {
/* Previeni rientro */
if (spi_busy) while(spi_busy);
/* Copia puntatori (o usa double-buffer) */
for (uint32_t i = 0; i < len; i++) {
spi_tx_buf[i] = tx[i];
spi_rx_buf[i] = 0;
}
spi_tx_idx = 0;
spi_rx_idx = 0;
spi_xfer_len = len;
spi_busy = 1;
/* Scrivi il primo byte per avviare il clock */
while (!(spi->SR & SPI_SR_TXE));
*(volatile uint8_t *)&spi->DR = spi_tx_buf[spi_tx_idx++];
/* Abilita TXEIE per interrupt sui byte successivi */
spi->CR2 |= SPI_CR2_TXEIE;
}
void SPI1_IRQHandler(void) {
uint32_t sr = SPI1->SR;
if (sr & SPI_SR_RXNE) {
/* Leggi byte ricevuto */
spi_rx_buf[spi_rx_idx++] = *(volatile uint8_t *)&SPI1->DR;
}
if (sr & SPI_SR_TXE) {
if (spi_tx_idx < spi_xfer_len) {
*(volatile uint8_t *)&SPI1->DR = spi_tx_buf[spi_tx_idx++];
} else {
/* TX finito — disabilita TXEIE */
SPI1->CR2 &= ~SPI_CR2_TXEIE;
}
}
if (sr & (SPI_SR_OVR | SPI_SR_MODF | SPI_SR_CRCERR | SPI_SR_FRE)) {
/* Gestione errori — almeno logga e resetta */
SPI1->CR1 &= ~SPI_CR1_SPE; /* Disabilita SPI */
SPI1->SR; /* Legge SR poi DR pulisce OVR */
(void)*(volatile uint8_t *)&SPI1->DR;
SPI1->CR1 |= SPI_CR1_SPE; /* Ri-abiltia */
spi_busy = 0;
return;
}
/* Controlla se la transazione è completa */
if (spi_rx_idx == spi_xfer_len) {
while (SPI1->SR & SPI_SR_BSY); /* Aspetta ultimo shift */
SPI1->CR2 &= ~(SPI_CR2_RXNEIE | SPI_CR2_TXEIE);
spi_busy = 0;
/* Opzionale: chiama callback di completamento */
}
}
Un dettaglio critico: devi scrivere il primo byte prima di abilitare TXEIE. Il flag TXE viene impostato subito dopo che una scrittura in DR ha svuotato il buffer, ma è anche impostato su una SPI appena abilitata. Se abiliti TXEIE prima di scrivere il primo byte, l'interrupt scatta immediatamente con TXE asserito ma nessun dato da inviare — e se il driver interpreta male questo, può inviare uno zero extra all'inizio di ogni transazione.
Gestione errori: OVR, MODF e CRCERR
Tre flag di errore SPI in SR sono rilevanti per un master:
- OVR (Overrun, bit 6): il buffer RX non è stato letto prima dell'arrivo del byte successivo. Succede quando la latenza del tuo interrupt o loop di polling è troppo alta per la frequenza SPI. Recupero: leggi SR, poi leggi DR per pulire la condizione di overrun. Dopo la pulizia, la SPI continua normalmente — i dati persi non sono recuperabili.
- MODF (Mode Fault, bit 5): il pin NSS è andato basso mentre la periferica era configurata come master con gestione hardware NSS (SSM=0). Tipicamente indica una collisione multi-master o un pin NSS flottante. Se usi SSM=1 (software NSS), questo flag non può verificarsi.
- CRCERR (bit 4): rilevante solo quando CRC è abilitato (CRCEN in CR1). Nella maggior parte delle applicazioni SPI con sensori/display il CRC non viene usato; tieni CR1.CRCEN = 0.
Esempio pratico: leggere un sensore temperatura SPI su STM32F401
Immagina un progetto cliente con un STM32F401 che legge un convertitore RTD MAX31865 via SPI. Il MAX31865 si aspetta un comando a 16 bit: il primo byte è l'indirizzo del registro + bit R/W, il secondo byte sono i dati. Il chip restituisce 16 bit per una lettura.
Usando la nostra inizializzazione a registri:
static void max31865_init(void) {
spi_init(); /* Configura SPI1 come sopra, mode 0, 5.25 MHz */
/* Abbassa CS manualmente (PA4 come GPIO) */
GPIOA->BSRR = GPIO_BSRR_BR4; /* CS low */
}
static uint16_t max31865_read_reg(uint8_t reg_addr) {
uint8_t tx[2], rx[2];
uint16_t result;
reg_addr &= 0x7F; /* Pulisci bit R/W per lettura */
tx[0] = reg_addr;
tx[1] = 0; /* Byte dummy per generare clock per la risposta */
GPIOA->BSRR = GPIO_BSRR_BR4; /* CS low */
spi_write_read(SPI1, tx, rx, 2);
GPIOA->BSRR = GPIO_BSRR_BS4; /* CS high */
result = ((uint16_t)rx[0] << 8) | rx[1];
return result;
}
Questo è l'approccio che ho usato in produzione su almeno quattro diversi progetti STM32F4 con sensori SPI (MAX31865, ADXL345, e FPGA custom). La funzione spi_write_read è identica in tutti — cambia solo il pin CS e la configurazione SPI.
Checklist pratica per progetti cliente
- Verifica CPOL/CPHA contro il datasheet dello slave — il bug SPI più comune. STM32 defaulta al modo 0. Se il tuo slave richiede il modo 3 (CPOL=1, CPHA=1), imposta il bit 1 (CPOL) e il bit 0 (CPHA) in CR1.
- Usa NSS software (SSM=1, SSI=1) per tutti i bus multi-slave. Ti evita i fault MODF e dà il controllo indipendente di ogni pin CS come GPIO.
- Usa accesso a byte su DR quando DFF=0. Una scrittura a 16 bit invia due frame. Se usi la macro CMSIS core
LL_SPI_TransmitData8, lo fa correttamente — controlla l'implementazione. - Aspetta sempre che BSY si azzeri dopo l'ultima lettura RXNE prima di rilasciare CS. Senza questo, tagli l'ultimo ciclo di clock e lo slave potrebbe registrare un frame incompleto.
- Aggiungi un gestore OVR anche in modalità bloccante. Un interrupt transiente (es. USB o tick FreeRTOS) può ritardare il tuo loop di polling abbastanza da perdere un byte. Controlla SR.OVR dopo ogni byte e ritenta la transazione se scatta.
- Verifica SCK con un oscilloscopio su ogni nuova revisione PCB. Un CPOL scambiato o un divisore BR inaspettatamente lento non si vedono leggendo i registri — solo sul filo.
Come lo affronterei su un progetto cliente
Quando inizio un progetto con un nuovo dispositivo SPI, non uso mai HAL SPI_TransmitReceive. L'astrazione nasconde tre cose che voglio verificare: (1) la frequenza SCK esatta, (2) la temporizzazione di NSS rispetto al primo fronte di clock, e (3) la larghezza di accesso a DR. Scrivo sempre prima un'inizializzazione a livello di registri, verifico con un oscilloscopio o analizzatore logico, e solo dopo valuto se incapsularla in un'interfaccia in stile HAL per il resto del team.
Su un progetto, il contractor precedente aveva configurato SPI2 a BR=000 (prescaler 2 = 21 MHz) per un sensore che poteva gestire al massimo 1 MHz. L'oscilloscopio mostrava un perfetto clock a 21 MHz e l'uscita del sensore era puro rumore. La soluzione è stata cambiare BR a 111 (256) per portarlo a 164 kHz — il RM mostra chiaramente il divisore, ma la mentalità da documentazione "più alto = meglio" porta fuori strada. Per SPI, la risposta giusta è "abbastanza veloce per la banda che ti serve, e mai sopra il limite dello slave".
L'approccio a registri rende anche banale scambiare le periferiche SPI quando il routing del PCB impone un cambio di pin: cambi il puntatore alla base e il divisore BR per il nuovo clock APB. Nessuna re-inizializzazione HAL, nessuna rigenerazione del progetto CubeMX, nessun misterioso "SPI ha smesso di funzionare" dopo uno scambio di pin.
Fonti e approfondimenti
- STM32F401 Reference Manual (RM0368) — Capitolo 24: Serial Peripheral Interface (SPI)
- ST Application Note AN3029 — Protocollo e configurazione bus SPI su STM32
- Maxim Integrated — Ottimizzare le Performance SPI
- Embedded Artistry — Architettura SPI a 3 e 4 Fili
- ST Community — Come Configurare SPI con i Registri su STM32

Commenti
Hai commenti? Scrivimi un'email.