STM32 SPI Master Full-Duplex — Register-Level Configuration on STM32F4
The SPI peripheral on STM32F4 is simple to initialise with CubeMX but notoriously full of subtle traps when done at register level: clock polarity mismatches, missing NSS management, CRC issues, and silent underrun conditions. Here is exactly which bits to set, and why.
Every contractor who has debugged a sensor or display that "sometimes works" on SPI knows the feeling: the first byte is garbage, the clock stops mid-transaction, or the slave ignores everything because CPOL/CPHA do not match. When you write HAL-based code, these issues are hidden behind layers of configuration structs and timeout loops. At register level, nothing is hidden — and that is precisely the point.
This article walks through every SPI configuration register on STM32F401/STM32F4, builds a full-duplex master initialisation from scratch, and shows a robust interrupt-driven exchange that handles the hardware correctly. No HAL, no CubeMX — just the Reference Manual (RM0368) register map.
SPI peripheral memory map on STM32F4
The STM32F401 has up to three SPI peripherals (SPI1 on APB2, SPI2/SPI3 on APB1). Each exposes the same register layout at its base address. The key registers for master-mode configuration:
| Offset | Register | Purpose |
|---|---|---|
| 0x00 | CR1 | Control 1 — baud rate, CPOL/CPHA, master/slave, data order, CRC enable, SSI/SSM, SPE (enable) |
| 0x04 | CR2 | Control 2 — interrupt enables, DMA enables, SSOE, frame format (TI/SPI Motorola), NSS pulse mode, data size (8/16-bit on F4) |
| 0x08 | SR | Status — TXE, RXNE, BSY, OVR, CRCERR, MODF, FRE |
| 0x0C | DR | Data register — write TX, read RX (same address, separate shift path) |
| 0x10 | CRCPR | CRC polynomial |
| 0x14 | RXCRCR | RX CRC value |
| 0x18 | TXCRCR | TX CRC value |
| 0x1C | I2SCFGR | I2S config (SPI-only mode resets this) |
Register-level initialisation, step by step
Assume we target SPI1 on STM32F401, using PA5 (SCK), PA6 (MISO), PA7 (MOSI) with hardware NSS on PA4. APB2 runs at 84 MHz, we want 5.25 MHz SCK, CPOL=0 CPHA=0 (mode 0), 8-bit data, MSB-first.
Step 1: GPIO configuration
SPI pins must be configured as alternate function (AF5 for SPI1 on STM32F401). A common mistake is leaving the GPIO speed on low — on SPI at MHz rates, you need at least SPEED_HIGH (100 MHz) to get clean edges. Push-pull, no pull-up/pull-down on SCK and MOSI (the master drives these), and a pull-up on MISO for signal integrity:
/* Enable GPIOA clock */ RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN; /* PA5 (SCK), PA7 (MOSI): AF5, push-pull, high speed */ 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); /* no 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);
Step 2: CR1 — clock, polarity, phase, master mode, and bus management
This is where most configuration lives. CR1 is write-protected while SPE (bit 6) is set — you must configure everything before enabling the peripheral, or disable it first to change settings.
/* Disable SPI1 before config */
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 (internal NSS high for master) */
/* SSM=1 (software NSS management — avoids MODE fault) */
/* DFF=0 (8-bit data), LSBFIRST=0 (MSB first) */
SPI1->CR1 = (0x3 << SPI_CR1_BR_Pos)
| SPI_CR1_MSTR
| SPI_CR1_SSI
| SPI_CR1_SSM;
The BR prescaler is the most misread field in the register map. BR divides the APB clock, not the system clock. On SPI1 (APB2, 84 MHz):
| BR[2:0] | Divider | SCK at 84 MHz | SCK at 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 |
At 84 MHz, the minimum SPI clock is 84 MHz / 256 ≈ 328 kHz. If you need slower (e.g., for a long PCB trace or an older slave), you must further reduce the APB2 clock or tolerate the minimum. There is no additional divider — plan your peripheral clock tree accordingly.
Step 3: CR2 — interrupt enables and SSOE
CR2 is small on STM32F4 (no DS field for data size like on newer STM32G4/H5 — here DFF in CR1 controls 8 vs 16 bit). For an interrupt-driven exchange:
/* Enable RX buffer not empty interrupt */ /* TX buffer empty and error interrupts are optional */ SPI1->CR2 = SPI_CR2_RXNEIE; /* Optional: set SSOE to auto-drive hardware NSS pin low */ /* Only if you use hardware NSS management (SSM=0) */ /* With SSM=1 (software), SSOE is irrelevant */
If you use hardware NSS (SSM=0, SSOE=1), the SPI peripheral drives the NSS pin low automatically when SPI is enabled and pulls it high after each transaction (provided you also set NSSP in CR2 for pulse mode). With software NSS management (SSM=1, as above), you control NSS via the SSI bit in CR1 — the pin is available as a GPIO, and you toggle it manually. The software approach avoids the MODF fault entirely and is my default for multi-slave buses.
Step 4: Enable SPI and verify
SPI1->CR1 |= SPI_CR1_SPE; /* Enable SPI1 */ /* Verify by reading SR — SPE should go high */ volatile uint32_t sr = SPI1->SR; (void)sr; /* discard, used for read side-effect */
Full-duplex data exchange: the TXE/RXNE protocol
SPI full-duplex on STM32 works with a single data register: writing to DR shifts data out on MOSI while simultaneously shifting in data on MISO. The shift clock runs only while you write. This means every byte you send forces a byte to be received, and vice versa.
The correct exchange sequence for a master is:
- Wait for TXE (transmit buffer empty) in SR.
- Write the outgoing byte to DR.
- Wait for RXNE (receive buffer not empty) in SR.
- Read DR to get the incoming byte.
- Repeat for the next byte.
A blocking version that behaves correctly:
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++) {
/* Wait for TXE */
while (!(spi->SR & SPI_SR_TXE));
/* Write TX byte */
*(volatile uint8_t *)&spi->DR = tx[i];
/* Wait for RXNE */
while (!(spi->SR & SPI_SR_RXNE));
/* Read RX byte */
rx[i] = *(volatile uint8_t *)&spi->DR;
}
/* Wait for BSY to clear — ensures last byte finished shifting */
while (spi->SR & SPI_SR_BSY);
}
Note the volatile uint8_t access trick for the DR. The SPI data register on STM32F4 is 16-bit at address. If you write 16-bit ((uint16_t)), you send two bytes even with DFF=0 — the peripheral packs two 8-bit frames. By casting to volatile uint8_t *, you perform a byte access that the hardware interprets as a single 8-bit frame. This is documented in the reference manual (RM0368, section 24.5.1): "when DFF=0, only a byte access is needed".
What happens if you ignore this?
If you write SPI1->DR = (uint16_t)data with DFF=0, the peripheral sees a 16-bit write and transmits two consecutive bytes — the second being whatever was in the upper byte of your data word (likely zero). The slave clocks out one byte, the second byte is garbage that corrupts the ongoing transaction, and the slave state machine desynchronises. This is one of the most common SPI bugs I find in codebases I audit.
Interrupt-driven exchange
Blocking loops with while(!(SR & RXNE)) waste the CPU for the entire transaction length, which on a 5 MHz SPI bus moving 1 KB is about 1.6 ms. On many embedded projects that is fine, but for real-time control loops, an interrupt-driven version is better.
#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) {
/* Prevent re-entry */
if (spi_busy) while(spi_busy);
/* Copy pointers (or use 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;
/* Write first byte to kick off the clock */
while (!(spi->SR & SPI_SR_TXE));
*(volatile uint8_t *)&spi->DR = spi_tx_buf[spi_tx_idx++];
/* Enable TXEIE to get interrupt for subsequent bytes */
spi->CR2 |= SPI_CR2_TXEIE;
}
void SPI1_IRQHandler(void) {
uint32_t sr = SPI1->SR;
if (sr & SPI_SR_RXNE) {
/* Read received byte */
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 {
/* All TX done — disable TXEIE */
SPI1->CR2 &= ~SPI_CR2_TXEIE;
}
}
if (sr & (SPI_SR_OVR | SPI_SR_MODF | SPI_SR_CRCERR | SPI_SR_FRE)) {
/* Error handling — at minimum log and reset */
SPI1->CR1 &= ~SPI_CR1_SPE; /* Disable SPI */
SPI1->SR; /* Read SR then DR clears OVR */
(void)*(volatile uint8_t *)&SPI1->DR;
SPI1->CR1 |= SPI_CR1_SPE; /* Re-enable */
spi_busy = 0;
return;
}
/* Check if transaction is complete */
if (spi_rx_idx == spi_xfer_len) {
while (SPI1->SR & SPI_SR_BSY); /* Wait final shift */
SPI1->CR2 &= ~(SPI_CR2_RXNEIE | SPI_CR2_TXEIE);
spi_busy = 0;
/* Optional: call completion callback */
}
}
A critical detail: you must write the first byte before enabling TXEIE. The TXE flag is set immediately after a DR write clears the previous one, but it is also set on a freshly-enabled SPI. If you enable TXEIE before writing the first byte, the interrupt fires instantly with TXE asserted but no data to send — and if your driver misinterprets this, you can send an extra zero byte at the start of every transaction.
Error handling: OVR, MODF, and CRCERR
Three SPI error flags in SR are relevant for a master:
- OVR (Overrun, bit 6): the RX buffer was not read before the next byte arrived. This happens when your interrupt latency or polling loop is too slow for the SPI clock rate. Recovery: read SR, then read DR to clear the overrun condition. After clearing, the SPI continues normally — the lost data is unrecoverable.
- MODF (Mode Fault, bit 5): the NSS pin went low while the peripheral was configured as master with hardware NSS management (SSM=0). This typically indicates a multi-master collision or a floating NSS pin. If you use SSM=1 (software NSS), this flag cannot occur. If you use hardware NSS and hit MODF, clear it by writing to CR1 (reset SPE, then reconfigure).
- CRCERR (bit 4): only relevant when CRC is enabled (CRCEN in CR1). In most SPI sensor/display applications CRC is not used; keep CR1.CRCEN = 0.
Practical example: reading an SPI temperature sensor on STM32F401
Imagine a client project with an STM32F401 reading a MAX31865 RTD-to-digital converter over SPI. The MAX31865 expects a 16-bit command frame: first byte is the register address + R/W bit, second byte is the data. The chip returns 16 bits for a read.
Using our register-level initialisation:
static void max31865_init(void) {
spi_init(); /* Configure SPI1 as above, mode 0, 5.25 MHz */
/* Assert CS manually (PA4 as 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; /* Clear R/W bit for read */
tx[0] = reg_addr;
tx[1] = 0; /* Dummy byte to generate clocks for response */
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;
}
This is the approach I have used in production on at least four different STM32F4 projects with SPI sensors (MAX31865, ADXL345, and custom FPGAs). The spi_write_read function is identical across all of them — only the CS pin and the SPI configuration change.
Practical checklist for client projects
- Verify CPOL/CPHA against the slave datasheet — the most common SPI bug. STM32 defaults to mode 0. If your slave expects mode 3 (CPOL=1, CPHA=1), set bit 1 (CPOL) and bit 0 (CPHA) in CR1 accordingly.
- Use software NSS (SSM=1, SSI=1) for all multi-slave buses. It saves you from MODF faults and gives independent control of each CS pin as a GPIO.
- Use byte access on DR when DFF=0. A 16-bit write sends two frames. If you use the CMSIS core macro
LL_SPI_TransmitData8, it does this correctly — check the implementation. - Always wait for BSY to clear after the final RXNE read before de-asserting CS. Without this, you cut the last clock cycle and the slave may register an incomplete frame.
- Add an OVR handler even in blocking mode. A transient interrupt (e.g., USB or FreeRTOS tick) can delay your polling loop long enough to lose a byte. Check SR.OVR after each byte and retry the transaction if it fires.
- Verify SCK on an oscilloscope on every new PCB revision. A swapped CPOL or an unexpectedly slow BR divider will not show up in register reads — only on the wire.
How I would approach this on a client project
When I start a project with a new SPI device, I never use HAL SPI_TransmitReceive. The abstraction hides three things I want to verify: (1) the exact SCK frequency, (2) the NSS timing relative to the first clock edge, and (3) the DR access width. I always write a register-level initialisation first, verify with a scope or logic analyser, and only then consider whether to wrap it in a HAL-style interface for the rest of the team.
On one project, the previous contractor had configured SPI2 at BR=000 (2 MHz prescaler = 21 MHz) for a sensor that could only handle 1 MHz. The scope showed a perfect 21 MHz clock and the sensor's output was pure noise. The fix was changing BR to 111 (256) to bring it down to 164 kHz — the RM clearly shows the divider but the default documentation-focused mindset assumes "higher = better". For SPI, the correct answer is "just fast enough for the bandwidth you need, and never above the slave's limit".
The register-level approach also makes it trivial to swap SPI peripherals when the PCB routing forces a pin change: you change the base pointer and the BR divisor for the new APB clock. No HAL re-init, no CubeMX project regeneration, no mysterious "SPI stopped working" after a pin swap.
Sources and further reading
- STM32F401 Reference Manual (RM0368) — Chapter 24: Serial Peripheral Interface (SPI)
- ST Application Note AN3029 — SPI protocol and bus configuration on STM32
- Maxim Integrated — Optimizing SPI Performance
- Embedded Artistry — 3-Wire and 4-Wire SPI Architecture
- ST Community — How to Configure SPI with Registers on STM32

Comments
Have comments? Send me an email.