2026-07-02 · Davide Carrese

STM32 SPI Master Full-Duplex — Register-Level Configuration on STM32F4

STM32 · SPI · Register-Level · STM32F4 · Embedded

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:

OffsetRegisterPurpose
0x00CR1Control 1 — baud rate, CPOL/CPHA, master/slave, data order, CRC enable, SSI/SSM, SPE (enable)
0x04CR2Control 2 — interrupt enables, DMA enables, SSOE, frame format (TI/SPI Motorola), NSS pulse mode, data size (8/16-bit on F4)
0x08SRStatus — TXE, RXNE, BSY, OVR, CRCERR, MODF, FRE
0x0CDRData register — write TX, read RX (same address, separate shift path)
0x10CRCPRCRC polynomial
0x14RXCRCRRX CRC value
0x18TXCRCRTX CRC value
0x1CI2SCFGRI2S 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]DividerSCK at 84 MHzSCK at 42 MHz (SPI2/3)
000242.00 MHz21.00 MHz
001421.00 MHz10.50 MHz
010810.50 MHz5.25 MHz
011165.25 MHz2.625 MHz
100322.625 MHz1.3125 MHz
101641.3125 MHz656.25 kHz
110128656.25 kHz328.125 kHz
111256328.125 kHz164.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:

  1. Wait for TXE (transmit buffer empty) in SR.
  2. Write the outgoing byte to DR.
  3. Wait for RXNE (receive buffer not empty) in SR.
  4. Read DR to get the incoming byte.
  5. 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:

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

Comments

Have comments? Send me an email.