Series / Working with STM32 Peripherals / SPI on STM32 for Real Modules

SPI on STM32 for Real Modules

Bring up STM32 SPI for real modules by checking SCK, MOSI, MISO, chip select, mode, baud prescaler, GPIO alternate functions, and transaction debugging.

On this page

The previous article brought up I2C for real modules. SPI is the next external-module bus, but it has a different shape.

I2C uses shared SDA and SCL lines plus device addresses. SPI usually uses separate clock and data lines plus an explicit chip-select signal for each device. That makes many SPI transactions simpler, but it also means firmware must handle more board-level wiring details directly.

This article brings up STM32 SPI with a practical, blocking HAL workflow.

What This Article Covers

This article covers:

  • SPI signal roles: SCK, MOSI, MISO, and chip select.
  • SPI mode settings: clock polarity and clock phase.
  • GPIO alternate-function setup for SPI pins.
  • Manual chip-select handling with a GPIO output.
  • Blocking transmit and transmit/receive calls with STM32 HAL.
  • UART and logic-analyzer debugging checks.

It does not build a complete display, flash memory, sensor, or MAX7219 driver. The goal is to prove that the STM32 can generate a clean SPI transaction to a real module before adding module-specific command sequences.

SPI Mental Model

SPI is usually a master-controlled synchronous serial bus.

The common signals are:

  • SCK: serial clock generated by the master.
  • MOSI: master-out, slave-in data.
  • MISO: master-in, slave-out data.
  • CS, NSS, or SS: chip select.

The STM32 is the master in this article. It controls the clock and chooses when a module is selected.

Unlike I2C, SPI does not use shared bus addresses. If several SPI devices share SCK, MOSI, and MISO, each device normally needs its own chip-select signal. Firmware selects one device by driving its chip-select line active while leaving the others inactive.

Many modules only need transmit for first bring-up. For example, shift registers, LED drivers, and some display controllers may accept command bytes over MOSI without returning useful data on MISO.

Hardware Checklist

Before debugging code, check the wiring.

Confirm:

  • The module uses 3.3 V-compatible logic.
  • The STM32 board and module share ground.
  • SCK is connected to the selected SPI clock pin.
  • MOSI is connected to the module data input.
  • MISO is connected only if the module returns data.
  • Chip select is connected to a GPIO output you control.
  • The selected STM32 pins support the SPI alternate function for the chosen SPI instance.
  • Any module-specific reset, data/command, enable, or latch pins are handled.

Start with one SPI module. Multiple devices add chip-select and bus-sharing problems that are easier to debug after one module works.

Mode and Speed Checklist

SPI has mode settings that must match the module datasheet.

The two key mode fields are:

  • CPOL: clock polarity, the idle level of SCK.
  • CPHA: clock phase, which clock edge samples data.

The common modes are usually described as mode 0, 1, 2, or 3. STM32 HAL expresses these as polarity and phase settings.

For first bring-up:

  • Use the mode required by the module datasheet.
  • Start with a slow baud prescaler.
  • Use 8-bit data size unless the module requires something else.
  • Use MSB-first unless the module requires LSB-first.

Starting too fast can make a wiring or mode problem look like a software problem.

Minimal HAL SPI Setup

The example below uses SPI1 and a manually controlled chip-select GPIO as concrete examples. Adjust the instance, pins, alternate function, and chip-select pin for your board and module.

#include "stm32l4xx_hal.h"
#include <stdint.h>
#include <string.h>

SPI_HandleTypeDef hspi1;
extern UART_HandleTypeDef huart2;

#define SPI_CS_GPIO_PORT GPIOB
#define SPI_CS_PIN GPIO_PIN_6

static void log_write(const char *message)
{
    HAL_UART_Transmit(
        &huart2,
        (uint8_t *)message,
        (uint16_t)strlen(message),
        HAL_MAX_DELAY);
}

static void spi_cs_high(void)
{
    HAL_GPIO_WritePin(SPI_CS_GPIO_PORT, SPI_CS_PIN, GPIO_PIN_SET);
}

static void spi_cs_low(void)
{
    HAL_GPIO_WritePin(SPI_CS_GPIO_PORT, SPI_CS_PIN, GPIO_PIN_RESET);
}

static void spi1_gpio_init(void)
{
    __HAL_RCC_GPIOA_CLK_ENABLE();
    __HAL_RCC_GPIOB_CLK_ENABLE();

    GPIO_InitTypeDef gpio = {0};
    gpio.Pin = GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7;
    gpio.Mode = GPIO_MODE_AF_PP;
    gpio.Pull = GPIO_NOPULL;
    gpio.Speed = GPIO_SPEED_FREQ_HIGH;
    gpio.Alternate = GPIO_AF5_SPI1;
    HAL_GPIO_Init(GPIOA, &gpio);

    GPIO_InitTypeDef cs = {0};
    cs.Pin = SPI_CS_PIN;
    cs.Mode = GPIO_MODE_OUTPUT_PP;
    cs.Pull = GPIO_NOPULL;
    cs.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(SPI_CS_GPIO_PORT, &cs);

    spi_cs_high();
}

static void spi1_init(void)
{
    __HAL_RCC_SPI1_CLK_ENABLE();
    spi1_gpio_init();

    hspi1.Instance = SPI1;
    hspi1.Init.Mode = SPI_MODE_MASTER;
    hspi1.Init.Direction = SPI_DIRECTION_2LINES;
    hspi1.Init.DataSize = SPI_DATASIZE_8BIT;
    hspi1.Init.CLKPolarity = SPI_POLARITY_LOW;
    hspi1.Init.CLKPhase = SPI_PHASE_1EDGE;
    hspi1.Init.NSS = SPI_NSS_SOFT;
    hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_64;
    hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB;
    hspi1.Init.TIMode = SPI_TIMODE_DISABLE;
    hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE;
    hspi1.Init.CRCPolynomial = 7;

    if (HAL_SPI_Init(&hspi1) != HAL_OK)
    {
        while (1)
        {
        }
    }
}

The SPI pin choices and chip-select pin are examples. Use the alternate-function table and board documentation for your actual wiring.

Send a Simple Transaction

A basic transmit-only transaction looks like this:

static HAL_StatusTypeDef spi_send_bytes(const uint8_t *data, uint16_t length)
{
    spi_cs_low();
    HAL_StatusTypeDef status = HAL_SPI_Transmit(&hspi1, (uint8_t *)data, length, 100);
    spi_cs_high();

    return status;
}

Call it from main after UART and SPI initialization:

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    uart2_init();
    spi1_init();

    uint8_t tx[] = { 0x00 };

    while (1)
    {
        HAL_StatusTypeDef status = spi_send_bytes(tx, sizeof(tx));

        if (status == HAL_OK)
        {
            log_write("SPI transmit OK\r\n");
        }
        else
        {
            log_write("SPI transmit failed\r\n");
        }

        HAL_Delay(1000);
    }
}

The byte value 0x00 is only a bus transaction placeholder. A real module expects module-specific command bytes from its datasheet.

Transmit and Receive

SPI is often full duplex. While the master transmits a byte, it can receive a byte at the same time.

For modules that return data, use:

uint8_t tx[] = { 0x9F, 0x00, 0x00, 0x00 };
uint8_t rx[sizeof(tx)] = {0};

spi_cs_low();
HAL_StatusTypeDef status = HAL_SPI_TransmitReceive(
    &hspi1,
    tx,
    rx,
    sizeof(tx),
    100);
spi_cs_high();

The meaning of returned bytes depends entirely on the module. SPI only moves bits; the device protocol defines what those bits mean.

CubeMX-Generated SPI Setup

If CubeMX generated the SPI setup, you may already have:

  • MX_SPI1_Init() or a similar function.
  • A global SPI_HandleTypeDef hspi1.
  • SPI GPIO alternate-function setup in HAL_SPI_MspInit.
  • SPI peripheral clock enable code in the MSP file.

Use the generated setup rather than defining the same pieces twice:

HAL_Init();
SystemClock_Config();
MX_USART2_UART_Init();
MX_SPI1_Init();

You may still want to control chip select manually in hand-written board code. Many projects use software-controlled chip select because it makes transaction boundaries obvious.

CMake Support

If SPI HAL code was not already compiled, add the SPI HAL source file to the firmware target:

generated/Drivers/STM32L4xx_HAL_Driver/Src/stm32l4xx_hal_spi.c

If CubeMX owns the SPI GPIO setup, make sure the MSP source file is compiled too:

generated/Core/Src/stm32l4xx_hal_msp.c

Missing SPI HAL sources usually show up as linker errors for functions such as HAL_SPI_Init, HAL_SPI_Transmit, or HAL_SPI_TransmitReceive.

Build and Test

Build and flash the firmware:

cmake --build build
openocd -f interface/stlink.cfg -f target/stm32l4x.cfg -c "program build/firmware.elf verify reset exit"

Open the UART terminal and watch for transmit status messages.

UART logs can confirm that the firmware reaches the SPI call and that HAL reports success, but they cannot prove the physical signals are correct. For SPI, a logic analyzer is often the fastest way to confirm the bus.

Debug with Hardware

Use a scope or logic analyzer on CS, SCK, MOSI, and optionally MISO.

Check:

  • CS goes low before clock pulses begin.
  • CS returns high after the transaction ends.
  • SCK produces clock pulses during the transfer.
  • MOSI changes with the expected bit pattern.
  • Clock idle level matches the selected polarity.
  • Data appears stable on the edge the module expects.
  • MISO changes only if the module returns data.

If CS never changes, debug the GPIO chip-select pin.

If SCK never toggles, debug SPI initialization, peripheral clock enable, alternate-function setup, and whether the transmit call is reached.

If MOSI changes but the module does not respond, suspect mode, chip select, reset/data-command pins, or module-specific command bytes.

Debug with UART and VSCode

UART logs are still useful during SPI bring-up.

Log:

  • Whether HAL_SPI_Init succeeded.
  • Whether HAL_SPI_Transmit returned HAL_OK.
  • Whether repeated transactions are happening.
  • Any bytes received with HAL_SPI_TransmitReceive.

In VSCode, set breakpoints around chip-select changes and transmit calls. Confirm the status value and inspect any receive buffer.

If HAL reports success but the module does nothing, remember that HAL success only means the STM32 peripheral completed the transfer. It does not mean the module understood the bytes.

Common Mistakes

The first common mistake is forgetting chip select. SPI modules usually ignore bus traffic unless their chip-select line is active.

The second is using the wrong SPI mode. CPOL and CPHA must match the module datasheet.

The third is configuring the pins as ordinary GPIO instead of SPI alternate-function pins.

The fourth is choosing the wrong alternate-function mapping for the selected pins.

The fifth is swapping MOSI and MISO.

The sixth is forgetting shared ground between the STM32 board and external module.

The seventh is starting with the SPI clock too fast. Use a slow prescaler until the transaction is visible and reliable.

The eighth is treating SPI like I2C and looking for device addresses. SPI device selection is usually physical chip select, not an address byte.

The ninth is missing stm32l4xx_hal_spi.c from the CMake source list.

The tenth is ignoring extra module pins such as reset, data/command, enable, latch, or backlight control.

What This Proves

A visible SPI transaction proves several layers are working:

  • SPI peripheral clocking and initialization.
  • GPIO alternate-function routing.
  • Manual chip-select control.
  • Clock polarity, phase, and speed settings at least plausible enough to produce traffic.
  • Firmware can move bytes from STM32 to an external module.

That is the right milestone before writing a module-specific SPI driver.

What This Does Not Prove

This does not prove a display, sensor, memory chip, or LED driver protocol is correct.

It does not prove maximum bus speed, signal integrity with long wires, DMA transfers, interrupt-driven transfers, or multi-device bus behavior. It also does not prove that received bytes are meaningful unless the module datasheet says what response to expect.

The point is to bring up the SPI bus honestly: pins, chip select, mode, clock, and a visible transaction first; driver behavior second.

Next Steps

The next article organizes a small STM32 firmware project.

At this point, the series has touched GPIO, clocks, timers, UART, I2C, and SPI. The next step is to keep that code maintainable by separating board support, peripheral setup, simple drivers, and application behavior.