Skip to content
Home » Embedded Systems » Embedded C » Writing an SPI Driver in Embedded C: Complete Implementation

Writing an SPI Driver in Embedded C: Complete Implementation

Communication Protocols
Part 4 of 10 — View Full Path →

KEY TAKEAWAYS

  • A well-structured SPI driver separates bus operations (transfer bytes) from device-specific logic (register reads/writes) using a device handle with its own CS pin and configuration
  • CS pin management belongs in the driver, not the application — the driver must ensure CS is held for the entire transaction duration
  • Multiple SPI devices on the same bus can have different modes and speeds — the driver reconfigures the peripheral before each device transaction
  • Error handling in SPI focuses on overrun detection and timeout protection since the protocol itself has no ACK/NAK mechanism
  • Communication Interfaces: UART, SPI, and I2C
  • Polling vs Interrupts in Embedded Systems

SPI Driver Architecture

A production SPI driver manages two concerns: the SPI bus (hardware peripheral configuration, byte transfer) and SPI devices (CS pin, mode, speed, register protocol). Separating these allows multiple devices to share one SPI bus with different configurations. For design principles, see Driver Interface Design and Hardware Abstraction Layer.

The Header File: spi.h

The header file defines the public API — the types and functions that application code uses. Notice that the device structure holds the SPI bus identifier, clock mode, maximum speed, bit order, and CS pin for each connected device. This means the application code specifies what it needs (10 MHz, Mode 3, CS on PA4) and the driver handles all the register-level configuration internally. This is the hardware abstraction in action.

#ifndef SPI_H
#define SPI_H

#include <stdint.h>
#include <stddef.h>

/* SPI clock modes */
typedef enum {
    SPI_MODE_0 = 0,   /* CPOL=0, CPHA=0 */
    SPI_MODE_1 = 1,   /* CPOL=0, CPHA=1 */
    SPI_MODE_2 = 2,   /* CPOL=1, CPHA=0 */
    SPI_MODE_3 = 3    /* CPOL=1, CPHA=1 */
} spi_mode_t;

/* SPI bit order */
typedef enum {
    SPI_MSB_FIRST = 0,
    SPI_LSB_FIRST = 1
} spi_bit_order_t;

/* SPI bus instance identifier */
typedef enum {
    SPI_BUS_1 = 0,
    SPI_BUS_2,
    SPI_BUS_MAX
} spi_bus_t;

/* Error codes */
typedef enum {
    SPI_OK = 0,
    SPI_ERR_TIMEOUT,
    SPI_ERR_OVERRUN,
    SPI_ERR_INVALID_BUS,
    SPI_ERR_NOT_INITIALIZED
} spi_error_t;

/* GPIO pin descriptor for CS */
typedef struct {
    volatile uint32_t *port_bsrr;   /* GPIO BSRR register address */
    uint16_t pin_mask;               /* Bit mask for the pin */
} gpio_pin_t;

/* Per-device configuration */
typedef struct {
    spi_bus_t       bus;        /* Which SPI peripheral to use */
    spi_mode_t      mode;       /* Clock mode (0-3) */
    uint32_t        max_speed;  /* Maximum clock speed in Hz */
    spi_bit_order_t bit_order;  /* MSB or LSB first */
    gpio_pin_t      cs_pin;     /* Chip select GPIO */
} spi_device_t;

/* Bus-level functions */
spi_error_t spi_bus_init(spi_bus_t bus);
void        spi_bus_deinit(spi_bus_t bus);

/* Device-level functions */
spi_error_t spi_select(const spi_device_t *dev);
void        spi_deselect(const spi_device_t *dev);

uint8_t     spi_transfer_byte(spi_bus_t bus, uint8_t tx);
spi_error_t spi_transfer(const spi_device_t *dev,
                          const uint8_t *tx_buf, uint8_t *rx_buf,
                          uint16_t len);

/* Register-level convenience functions */
uint8_t     spi_read_reg(const spi_device_t *dev, uint8_t reg);
void        spi_write_reg(const spi_device_t *dev, uint8_t reg, uint8_t val);
spi_error_t spi_read_regs(const spi_device_t *dev, uint8_t start_reg,
                           uint8_t *buf, uint16_t len);

/* Diagnostics */
spi_error_t spi_loopback_test(spi_bus_t bus);

#endif /* SPI_H */

The Implementation: spi.c

The implementation file contains the register-level code. The key design decision is the spi_configure_for_device() function, which reconfigures the SPI peripheral’s mode and speed before every transaction. This allows an accelerometer running at 10 MHz in Mode 3 and a flash chip running at 50 MHz in Mode 0 to share the same SPI bus without conflict. The bus is reconfigured transparently when you call spi_select().

#include "spi.h"

/* ── Hardware Register Definitions ──────────────────── */
typedef struct {
    volatile uint32_t CR1;
    volatile uint32_t CR2;
    volatile uint32_t SR;
    volatile uint32_t DR;
} spi_regs_t;

static spi_regs_t * const spi_hw[SPI_BUS_MAX] = {
    (spi_regs_t *)0x40013000,   /* SPI1 on APB2 */
    (spi_regs_t *)0x40003800,   /* SPI2 on APB1 */
};

static const uint32_t spi_pclk[SPI_BUS_MAX] = {
    72000000,   /* SPI1: APB2 = 72 MHz */
    36000000,   /* SPI2: APB1 = 36 MHz */
};

/* Bit definitions */
#define CR1_CPHA     (1U << 0)
#define CR1_CPOL     (1U << 1)
#define CR1_MSTR     (1U << 2)
#define CR1_SPE      (1U << 6)
#define CR1_LSBFIRST (1U << 7)
#define CR1_SSI      (1U << 8)
#define CR1_SSM      (1U << 9)
#define SR_RXNE      (1U << 0)
#define SR_TXE       (1U << 1)
#define SR_OVR       (1U << 6)
#define SR_BSY       (1U << 7)

static int bus_initialized[SPI_BUS_MAX] = {0};

/* ── Calculate Prescaler ────────────────────────────── */
static uint32_t calc_prescaler_bits(spi_bus_t bus, uint32_t max_speed)
{
    uint32_t pclk = spi_pclk[bus];
    /* Prescaler values: 2,4,8,16,32,64,128,256 (encoded as 0-7 in bits [5:3]) */
    for (uint32_t br = 0; br < 8; br++) {
        uint32_t actual = pclk / (2U << br);
        if (actual <= max_speed) {
            return br << 3;
        }
    }
    return 7U << 3;   /* Slowest: pclk/256 */
}

/* ── Bus Init ───────────────────────────────────────── */
spi_error_t spi_bus_init(spi_bus_t bus)
{
    if (bus >= SPI_BUS_MAX) return SPI_ERR_INVALID_BUS;

    /* Enable peripheral clock (MCU-specific) */
    /* enable_spi_clock(bus); */
    /* configure_spi_gpio(bus); */

    /* Default: Mode 0, 1 MHz, MSB first — will be reconfigured per device */
    spi_regs_t *spi = spi_hw[bus];
    spi->CR1 = 0;
    spi->CR1 = CR1_MSTR | CR1_SSM | CR1_SSI | calc_prescaler_bits(bus, 1000000);
    spi->CR1 |= CR1_SPE;

    bus_initialized[bus] = 1;
    return SPI_OK;
}

/* ── Configure Bus for a Specific Device ────────────── */
static void spi_configure_for_device(const spi_device_t *dev)
{
    spi_regs_t *spi = spi_hw[dev->bus];

    /* Disable SPI to reconfigure */
    spi->CR1 &= ~CR1_SPE;

    uint32_t cr1 = CR1_MSTR | CR1_SSM | CR1_SSI;

    /* Clock mode */
    if (dev->mode & 0x01) cr1 |= CR1_CPHA;
    if (dev->mode & 0x02) cr1 |= CR1_CPOL;

    /* Bit order */
    if (dev->bit_order == SPI_LSB_FIRST) cr1 |= CR1_LSBFIRST;

    /* Clock speed */
    cr1 |= calc_prescaler_bits(dev->bus, dev->max_speed);

    spi->CR1 = cr1;
    spi->CR1 |= CR1_SPE;
}

/* ── CS Management ──────────────────────────────────── */
spi_error_t spi_select(const spi_device_t *dev)
{
    spi_configure_for_device(dev);
    /* CS LOW (active) — write to BSRR reset bits */
    *dev->cs_pin.port_bsrr = (uint32_t)dev->cs_pin.pin_mask << 16;
    return SPI_OK;
}

void spi_deselect(const spi_device_t *dev)
{
    /* Wait for last byte to finish */
    while (spi_hw[dev->bus]->SR & SR_BSY) ;
    /* CS HIGH (inactive) — write to BSRR set bits */
    *dev->cs_pin.port_bsrr = dev->cs_pin.pin_mask;
}

/* ── Byte Transfer ──────────────────────────────────── */
uint8_t spi_transfer_byte(spi_bus_t bus, uint8_t tx)
{
    spi_regs_t *spi = spi_hw[bus];
    while (!(spi->SR & SR_TXE)) ;
    spi->DR = tx;
    while (!(spi->SR & SR_RXNE)) ;
    return (uint8_t)spi->DR;
}

/* ── Multi-Byte Transfer with CS ────────────────────── */
spi_error_t spi_transfer(const spi_device_t *dev,
                          const uint8_t *tx_buf, uint8_t *rx_buf,
                          uint16_t len)
{
    spi_select(dev);

    for (uint16_t i = 0; i < len; i++) {
        uint8_t tx = tx_buf ? tx_buf[i] : 0xFF;
        uint8_t rx = spi_transfer_byte(dev->bus, tx);
        if (rx_buf) rx_buf[i] = rx;
    }

    spi_deselect(dev);

    /* Check for overrun */
    if (spi_hw[dev->bus]->SR & SR_OVR) {
        /* Clear overrun: read DR then SR */
        (void)spi_hw[dev->bus]->DR;
        (void)spi_hw[dev->bus]->SR;
        return SPI_ERR_OVERRUN;
    }

    return SPI_OK;
}

/* ── Register Access ────────────────────────────────── */
uint8_t spi_read_reg(const spi_device_t *dev, uint8_t reg)
{
    spi_select(dev);
    spi_transfer_byte(dev->bus, reg | 0x80);   /* Read flag */
    uint8_t val = spi_transfer_byte(dev->bus, 0xFF);
    spi_deselect(dev);
    return val;
}

void spi_write_reg(const spi_device_t *dev, uint8_t reg, uint8_t val)
{
    spi_select(dev);
    spi_transfer_byte(dev->bus, reg & 0x7F);   /* Write flag */
    spi_transfer_byte(dev->bus, val);
    spi_deselect(dev);
}

spi_error_t spi_read_regs(const spi_device_t *dev, uint8_t start_reg,
                           uint8_t *buf, uint16_t len)
{
    spi_select(dev);
    spi_transfer_byte(dev->bus, start_reg | 0x80 | 0x40);  /* Read + auto-increment */
    for (uint16_t i = 0; i < len; i++) {
        buf[i] = spi_transfer_byte(dev->bus, 0xFF);
    }
    spi_deselect(dev);
    return SPI_OK;
}

/* ── Loopback Test ──────────────────────────────────── */
spi_error_t spi_loopback_test(spi_bus_t bus)
{
    /* Connect MOSI to MISO physically for this test */
    const uint8_t test[] = {0x00, 0x55, 0xAA, 0xFF, 0x42};
    int errors = 0;

    for (int i = 0; i < (int)sizeof(test); i++) {
        uint8_t rx = spi_transfer_byte(bus, test[i]);
        if (rx != test[i]) {
            errors++;
        }
    }

    return (errors == 0) ? SPI_OK : SPI_ERR_OVERRUN;
}

Usage Example: Multiple Devices on One Bus

This example demonstrates the driver’s key feature: multiple SPI devices with different configurations sharing a single bus. The accelerometer uses Mode 3 at 10 MHz while the flash chip uses Mode 0 at 50 MHz. The application code simply declares each device’s requirements as a const struct, and the driver handles the rest. Notice how clean the application code is — no register manipulation, no mode switching, just high-level read and write calls.

#include "spi.h"

/* Define two devices on SPI1 with different modes and speeds */
static const spi_device_t accel = {
    .bus       = SPI_BUS_1,
    .mode      = SPI_MODE_3,        /* LIS3DH uses Mode 3 */
    .max_speed = 10000000,           /* 10 MHz max */
    .bit_order = SPI_MSB_FIRST,
    .cs_pin    = { .port_bsrr = (volatile uint32_t *)0x40010810,
                   .pin_mask = (1U << 4) }   /* PA4 */
};

static const spi_device_t flash = {
    .bus       = SPI_BUS_1,
    .mode      = SPI_MODE_0,        /* W25Q uses Mode 0 */
    .max_speed = 50000000,           /* 50 MHz max */
    .bit_order = SPI_MSB_FIRST,
    .cs_pin    = { .port_bsrr = (volatile uint32_t *)0x40010C10,
                   .pin_mask = (1U << 0) }   /* PB0 */
};

int main(void)
{
    spi_bus_init(SPI_BUS_1);

    /* Read accelerometer — bus reconfigures to Mode 3, 10 MHz */
    uint8_t who = spi_read_reg(&accel, 0x0F);
    printf("Accel ID: 0x%02X\n", who);

    /* Read flash — bus reconfigures to Mode 0, 36 MHz */
    uint8_t flash_id[3];
    spi_select(&flash);
    spi_transfer_byte(SPI_BUS_1, 0x9F);  /* READ ID command */
    for (int i = 0; i < 3; i++) {
        flash_id[i] = spi_transfer_byte(SPI_BUS_1, 0xFF);
    }
    spi_deselect(&flash);
    printf("Flash: Mfr=0x%02X, Type=0x%02X, Cap=0x%02X\n",
           flash_id[0], flash_id[1], flash_id[2]);

    /* Both devices share the same MOSI/MISO/SCLK lines,
     * but each has its own CS and the driver auto-reconfigures
     * mode and speed for each transaction. */
    while (1) {
        /* Application loop */
    }
}

Making It Portable

To port this driver to a different MCU, change only:

  1. The spi_hw[] register base addresses
  2. The spi_pclk[] frequencies
  3. The clock enable and GPIO configuration in spi_bus_init()
  4. The bit definitions if the register layout differs

The header file, device structures, and all application code remain unchanged. This is the same portability strategy used in the UART driver. For the design principles behind this architecture, see Module Design in Embedded C and Separation of Concerns.

Summary

A well-structured SPI driver makes working with SPI devices effortless. The key design decisions — separating bus from device, auto-reconfiguring mode and speed per device, and managing CS within the driver — result in clean application code that reads like high-level API calls while running at bare-metal speed.

For the protocol fundamentals, read SPI Protocol Deep Dive. For the equivalent I2C implementation, see the I2C driver articles. For error handling strategies, see Error Handling Patterns in C.

Leave a Reply

Your email address will not be published. Required fields are marked *