Skip to content
Home » Embedded Systems » Communication Protocols » SPI Protocol Deep Dive: Clock Polarity, Phase, and Multi-Slave Design

SPI Protocol Deep Dive: Clock Polarity, Phase, and Multi-Slave Design

Communication Protocols
Part 3 of 10View Full Path →

KEY TAKEAWAYS

  • SPI is a synchronous, full-duplex protocol using four signals (MOSI, MISO, SCLK, CS) — data is transmitted and received simultaneously via shift registers
  • Clock Polarity (CPOL) and Phase (CPHA) define four SPI modes — both master and slave must use the same mode or data will be sampled at the wrong edge
  • SPI has no acknowledgment mechanism — the master has no way to know if the slave received data correctly, unlike I2C
  • Multi-slave designs use either independent CS lines (one per device) or daisy-chaining (data passes through each slave in series)

📘 SPI drivers reward exactly the C skills this course builds: bits, registers, timing. My complete Master C & Embedded C course takes you from zero to hardware-ready code — free on YouTube (53 videos), or guided on Udemy with quizzes, certificate and my Q&A support.

What Is SPI?

SPI (Serial Peripheral Interface) is the fastest standard communication protocol in embedded systems. Where UART operates asynchronously and I2C is limited to 400 kHz in Fast mode, SPI routinely runs at 10-50 MHz and some devices support over 100 MHz. It is the protocol of choice for high-speed peripherals: flash memory, SD cards, display controllers, ADCs, DACs, and high-performance sensors.

SPI is synchronous (has a clock line), full-duplex (sends and receives simultaneously), and uses a master-slave architecture. The master controls the clock and selects which slave to communicate with. Unlike I2C, SPI does not use addresses — each slave has a dedicated chip select (CS) line.

The Four SPI Signals

SPI uses four signal lines:

  • SCLK (Serial Clock) — Generated by the master. All data transfers are synchronized to this clock. The slave never drives the clock.
  • MOSI (Master Out, Slave In) — Data from master to slave. The master drives this line; the slave reads it. Some datasheets call this SDI (Serial Data In) from the slave’s perspective.
  • MISO (Master In, Slave Out) — Data from slave to master. The slave drives this line; the master reads it. Also called SDO (Serial Data Out).
  • CS/SS (Chip Select / Slave Select) — Active low. The master pulls this line LOW to select a specific slave. When CS is HIGH, the slave ignores all activity on SCLK and MOSI, and tri-states its MISO output.

The CS line is critical for multi-slave systems. Each slave needs its own CS line, while SCLK, MOSI, and MISO are shared. For GPIO fundamentals, see GPIO in Embedded Systems.

How SPI Data Transfer Works

SPI uses shift registers on both the master and slave. When the master generates a clock cycle, one bit shifts out from the master’s shift register onto MOSI, and simultaneously one bit shifts in from the slave’s MISO into the master’s shift register. After 8 clock cycles, the master and slave have exchanged one byte completely.

This means every SPI transfer is bidirectional — you always send and receive simultaneously. If you only want to send data, you ignore the received byte. If you only want to receive data, you send a dummy byte (typically 0x00 or 0xFF) to generate the clock cycles needed for the slave to respond.

Clock Polarity and Phase: The Four SPI Modes

SPI timing is defined by two parameters that create four modes. Getting these wrong is the most common SPI configuration error.

CPOL (Clock Polarity) — The idle state of the clock line:

  • CPOL = 0: Clock idles LOW. The first edge is a rising edge.
  • CPOL = 1: Clock idles HIGH. The first edge is a falling edge.

CPHA (Clock Phase) — When data is sampled relative to the clock edge:

  • CPHA = 0: Data is sampled on the first (leading) edge, shifted out on the second (trailing) edge. Data must be set up before the first clock edge.
  • CPHA = 1: Data is shifted out on the first edge and sampled on the second edge.

The four combinations:

Mode 0 (CPOL=0, CPHA=0): Clock idles LOW,  sample on RISING edge
                         Most common mode. Used by most sensors and flash chips.

Mode 1 (CPOL=0, CPHA=1): Clock idles LOW,  sample on FALLING edge

Mode 2 (CPOL=1, CPHA=0): Clock idles HIGH, sample on FALLING edge

Mode 3 (CPOL=1, CPHA=1): Clock idles HIGH, sample on RISING edge
                         Second most common. Used by some ADCs and DACs.

The slave’s datasheet specifies which mode(s) it supports. If you use the wrong mode, the master and slave sample data at different times, resulting in corrupted reads. See How to Read a Datasheet for finding this information.

SPI Clock Speed

The maximum SPI clock speed is determined by the slowest device on the bus — usually the slave. Check the slave’s datasheet for “maximum SCLK frequency.” Common maximums are 1 MHz (slow sensors), 10 MHz (most sensors and EEPROMs), 25-50 MHz (flash memory), and 100+ MHz (high-speed ADCs).

The master generates the clock from its peripheral clock using a prescaler. The SPI clock must be less than or equal to the slave’s maximum. On a microcontroller with a 72 MHz peripheral clock and available prescalers of 2, 4, 8, 16, 32, 64, 128, 256:

Prescaler 2:   72 / 2   = 36 MHz
Prescaler 4:   72 / 4   = 18 MHz
Prescaler 8:   72 / 8   = 9 MHz
Prescaler 16:  72 / 16  = 4.5 MHz
Prescaler 32:  72 / 32  = 2.25 MHz
Prescaler 64:  72 / 64  = 1.125 MHz
Prescaler 128: 72 / 128 = 562.5 kHz
Prescaler 256: 72 / 256 = 281.25 kHz

For a slave with max 10 MHz: use prescaler 8 (9 MHz)

Multi-Slave Topologies

Independent CS Lines

The most common approach: each slave has its own CS line. Only one CS is active (LOW) at a time. The master uses GPIO pins for CS control. SCLK, MOSI, and MISO are shared by all slaves. This allows each slave to run at a different clock speed and mode — you reconfigure the SPI peripheral before talking to each device.

Daisy-Chain

In daisy-chain mode, the MOSI of the master connects to the first slave’s MOSI. That slave’s MISO connects to the next slave’s MOSI, and so on. The last slave’s MISO connects to the master’s MISO. All slaves share a single CS line. Data shifts through the entire chain — to reach slave 3, you must clock through slaves 1 and 2 first. This saves GPIO pins but is slower and more complex. Used primarily with LED drivers and shift registers.

SPI Register Configuration

A typical SPI peripheral has these registers (see Registers in Microcontrollers):

  • Control Register (CR1) — Clock prescaler, CPOL, CPHA, master/slave mode, data frame size (8 or 16 bit), bit order (MSB/LSB first), SPI enable
  • Status Register (SR) — TX buffer empty, RX buffer not empty, busy flag, overrun error, mode fault
  • Data Register (DR) — Write to send, read to receive. Writing triggers a transfer.

Complete SPI Implementation in C

SPI Initialization

Initializing an SPI peripheral involves three steps: enabling the peripheral clock, configuring the GPIO pins for their alternate functions (SCLK, MOSI as push-pull outputs, MISO as floating input, CS as general-purpose output), and setting the SPI control register with the desired mode, speed, and master/slave selection. The order matters — always configure GPIO before enabling the SPI peripheral, and always deselect all slaves (CS HIGH) before the first transfer.

#include <stdint.h>

/* SPI1 register definitions */
#define SPI1_BASE      0x40013000UL
#define SPI1_CR1       (*(volatile uint32_t *)(SPI1_BASE + 0x00))
#define SPI1_CR2       (*(volatile uint32_t *)(SPI1_BASE + 0x04))
#define SPI1_SR        (*(volatile uint32_t *)(SPI1_BASE + 0x08))
#define SPI1_DR        (*(volatile uint32_t *)(SPI1_BASE + 0x0C))

/* CR1 bit definitions */
#define SPI_CR1_CPHA     (1U << 0)
#define SPI_CR1_CPOL     (1U << 1)
#define SPI_CR1_MSTR     (1U << 2)    /* Master mode */
#define SPI_CR1_BR_DIV8  (2U << 3)    /* Baud rate: fPCLK/8 */
#define SPI_CR1_BR_DIV16 (3U << 3)    /* Baud rate: fPCLK/16 */
#define SPI_CR1_BR_DIV32 (4U << 3)    /* Baud rate: fPCLK/32 */
#define SPI_CR1_SPE      (1U << 6)    /* SPI enable */
#define SPI_CR1_LSBFIRST (1U << 7)    /* LSB first (0=MSB first) */
#define SPI_CR1_SSI      (1U << 8)    /* Internal slave select */
#define SPI_CR1_SSM      (1U << 9)    /* Software slave management */

/* SR bit definitions */
#define SPI_SR_RXNE      (1U << 0)    /* RX buffer not empty */
#define SPI_SR_TXE       (1U << 1)    /* TX buffer empty */
#define SPI_SR_BSY       (1U << 7)    /* Busy flag */
#define SPI_SR_OVR       (1U << 6)    /* Overrun flag */

/* GPIO and RCC */
#define RCC_APB2ENR     (*(volatile uint32_t *)(0x40021000UL + 0x18))
#define GPIOA_CRL       (*(volatile uint32_t *)(0x40010800UL + 0x00))
#define GPIOA_ODR       (*(volatile uint32_t *)(0x40010800UL + 0x0C))
#define GPIOA_BSRR      (*(volatile uint32_t *)(0x40010800UL + 0x10))

/* CS pin: PA4 (manual GPIO control) */
#define CS_PIN          (1U << 4)
#define CS_LOW()        (GPIOA_BSRR = (CS_PIN << 16))
#define CS_HIGH()       (GPIOA_BSRR = CS_PIN)

typedef enum {
    SPI_MODE_0 = 0,                          /* CPOL=0, CPHA=0 */
    SPI_MODE_1 = SPI_CR1_CPHA,              /* CPOL=0, CPHA=1 */
    SPI_MODE_2 = SPI_CR1_CPOL,              /* CPOL=1, CPHA=0 */
    SPI_MODE_3 = SPI_CR1_CPOL | SPI_CR1_CPHA /* CPOL=1, CPHA=1 */
} spi_mode_t;

void spi1_init(spi_mode_t mode, uint32_t prescaler_bits)
{
    /* Enable clocks: SPI1, GPIOA, AFIO */
    RCC_APB2ENR |= (1U << 12) | (1U << 2) | (1U << 0);

    /* Configure GPIO pins:
     * PA5 (SCLK) = AF Push-Pull, 50 MHz
     * PA7 (MOSI) = AF Push-Pull, 50 MHz
     * PA6 (MISO) = Floating Input
     * PA4 (CS)   = General Purpose Output Push-Pull, 50 MHz
     */
    uint32_t crl = GPIOA_CRL;
    crl &= ~(0xFU << 16);  /* PA4: clear */
    crl |=  (0x3U << 16);   /* PA4: Output PP, 50 MHz */
    crl &= ~(0xFU << 20);  /* PA5: clear */
    crl |=  (0xBU << 20);   /* PA5: AF PP, 50 MHz */
    crl &= ~(0xFU << 24);  /* PA6: clear */
    crl |=  (0x4U << 24);   /* PA6: Floating input */
    crl &= ~(0xFU << 28);  /* PA7: clear */
    crl |=  (0xBU << 28);   /* PA7: AF PP, 50 MHz */
    GPIOA_CRL = crl;

    CS_HIGH();  /* Deselect slave */

    /* Configure SPI1 */
    SPI1_CR1 = 0;  /* Reset */
    SPI1_CR1 = SPI_CR1_MSTR        /* Master mode */
             | SPI_CR1_SSM         /* Software CS management */
             | SPI_CR1_SSI         /* Internal slave select high */
             | prescaler_bits      /* Clock prescaler */
             | (uint32_t)mode;     /* CPOL + CPHA */

    SPI1_CR1 |= SPI_CR1_SPE;      /* Enable SPI */
}

SPI Transfer — Send and Receive Simultaneously

The transfer function is the core of any SPI driver. Because SPI is full-duplex, every send is also a receive — the hardware shifts one bit out on MOSI while simultaneously shifting one bit in from MISO. The code waits for the TX buffer to empty (meaning the previous byte has moved to the shift register), writes the next byte, then waits for the RX buffer to fill (meaning a complete byte has been shifted in). This interplay between the TX and RX flags is the key to understanding SPI at the register level.

/*
 * Transfer one byte over SPI.
 * Sends tx_byte on MOSI, returns the byte received on MISO.
 * This is the fundamental SPI operation — everything builds on this.
 */
uint8_t spi1_transfer_byte(uint8_t tx_byte)
{
    /* Wait until TX buffer is empty */
    while (!(SPI1_SR & SPI_SR_TXE))
        ;

    /* Write byte to data register — this starts the transfer */
    SPI1_DR = tx_byte;

    /* Wait until RX buffer has data (transfer complete) */
    while (!(SPI1_SR & SPI_SR_RXNE))
        ;

    /* Read and return received byte */
    return (uint8_t)SPI1_DR;
}

/*
 * Transfer multiple bytes with CS management.
 * tx_buf: data to send (can be NULL to send zeros)
 * rx_buf: buffer for received data (can be NULL to discard)
 * len: number of bytes to transfer
 */
void spi1_transfer(const uint8_t *tx_buf, uint8_t *rx_buf, uint16_t len)
{
    CS_LOW();

    for (uint16_t i = 0; i < len; i++) {
        uint8_t tx = tx_buf ? tx_buf[i] : 0xFF;
        uint8_t rx = spi1_transfer_byte(tx);
        if (rx_buf) rx_buf[i] = rx;
    }

    /* Wait for SPI to finish (BSY flag clear) before releasing CS */
    while (SPI1_SR & SPI_SR_BSY)
        ;

    CS_HIGH();
}

Reading a Sensor Register via SPI

Most SPI sensors use a register-based protocol: send the register address, then read the response. For read operations, bit 7 of the address is typically set to 1 (read flag). This convention varies — always check the slave’s datasheet.

#define SPI_READ_FLAG  0x80   /* Bit 7 set = read operation */

/*
 * Read a single register from an SPI device.
 * Most sensors: send (address | 0x80), then clock out one dummy byte
 * to receive the register value.
 */
uint8_t spi_read_register(uint8_t reg_addr)
{
    uint8_t value;

    CS_LOW();

    spi1_transfer_byte(reg_addr | SPI_READ_FLAG);  /* Send read command */
    value = spi1_transfer_byte(0xFF);               /* Read response */

    while (SPI1_SR & SPI_SR_BSY) ;
    CS_HIGH();

    return value;
}

/*
 * Write a single register to an SPI device.
 * Send the address (bit 7 = 0 for write), then the value.
 */
void spi_write_register(uint8_t reg_addr, uint8_t value)
{
    CS_LOW();

    spi1_transfer_byte(reg_addr & 0x7F);  /* Address with write flag */
    spi1_transfer_byte(value);

    while (SPI1_SR & SPI_SR_BSY) ;
    CS_HIGH();
}

/*
 * Read multiple consecutive registers (auto-increment address).
 * Many sensors support this — send address once, then clock out N bytes.
 */
void spi_read_registers(uint8_t start_addr, uint8_t *buf, uint16_t len)
{
    CS_LOW();

    /* Some sensors require bit 6 set for multi-byte read */
    spi1_transfer_byte(start_addr | SPI_READ_FLAG | 0x40);

    for (uint16_t i = 0; i < len; i++) {
        buf[i] = spi1_transfer_byte(0xFF);
    }

    while (SPI1_SR & SPI_SR_BSY) ;
    CS_HIGH();
}

/* Example: Read WHO_AM_I register from an accelerometer */
void verify_sensor_id(void)
{
    uint8_t who_am_i = spi_read_register(0x0F);  /* WHO_AM_I at 0x0F */

    if (who_am_i == 0x33) {
        /* LIS3DH detected */
        printf("Sensor detected: LIS3DH (0x%02X)\n", who_am_i);
    } else if (who_am_i == 0x69) {
        /* LSM6DSO detected */
        printf("Sensor detected: LSM6DSO (0x%02X)\n", who_am_i);
    } else {
        printf("Unknown device: 0x%02X (expected 0x33 or 0x69)\n", who_am_i);
    }
}

SPI Flash Memory Operations

SPI flash memory chips (W25Q series, AT25SF, IS25LP, etc.) are one of the most common SPI peripherals in embedded systems. They provide non-volatile storage for firmware images, configuration data, log files, and assets. All SPI flash chips follow a similar command protocol: you send a command byte, optionally followed by a 24-bit address, then read or write data bytes. The critical detail is that flash memory requires sector erasure before writing — you cannot overwrite individual bytes like SRAM.

/* Common SPI Flash commands (W25Q series, most flash chips use these) */
#define FLASH_CMD_WRITE_ENABLE   0x06
#define FLASH_CMD_WRITE_DISABLE  0x04
#define FLASH_CMD_READ_STATUS    0x05
#define FLASH_CMD_READ_DATA      0x03
#define FLASH_CMD_PAGE_PROGRAM   0x02
#define FLASH_CMD_SECTOR_ERASE   0x20
#define FLASH_CMD_READ_ID        0x9F

#define FLASH_STATUS_BUSY        0x01
#define FLASH_STATUS_WEL         0x02   /* Write Enable Latch */

/* CS pin for flash (separate from sensor CS) */
#define FLASH_CS_LOW()   gpio_write(FLASH_CS_PIN, 0)
#define FLASH_CS_HIGH()  gpio_write(FLASH_CS_PIN, 1)

uint8_t flash_read_status(void)
{
    uint8_t status;
    FLASH_CS_LOW();
    spi1_transfer_byte(FLASH_CMD_READ_STATUS);
    status = spi1_transfer_byte(0xFF);
    while (SPI1_SR & SPI_SR_BSY) ;
    FLASH_CS_HIGH();
    return status;
}

void flash_wait_ready(void)
{
    while (flash_read_status() & FLASH_STATUS_BUSY)
        ;
}

void flash_write_enable(void)
{
    FLASH_CS_LOW();
    spi1_transfer_byte(FLASH_CMD_WRITE_ENABLE);
    while (SPI1_SR & SPI_SR_BSY) ;
    FLASH_CS_HIGH();
}

/* Read data from flash at a 24-bit address */
void flash_read(uint32_t address, uint8_t *buf, uint16_t len)
{
    FLASH_CS_LOW();

    spi1_transfer_byte(FLASH_CMD_READ_DATA);
    spi1_transfer_byte((address >> 16) & 0xFF);
    spi1_transfer_byte((address >> 8) & 0xFF);
    spi1_transfer_byte(address & 0xFF);

    for (uint16_t i = 0; i < len; i++) {
        buf[i] = spi1_transfer_byte(0xFF);
    }

    while (SPI1_SR & SPI_SR_BSY) ;
    FLASH_CS_HIGH();
}

/* Write data to flash (max 256 bytes per page program) */
int flash_page_program(uint32_t address, const uint8_t *data, uint16_t len)
{
    if (len > 256) return -1;  /* Page program max is 256 bytes */

    flash_write_enable();

    FLASH_CS_LOW();

    spi1_transfer_byte(FLASH_CMD_PAGE_PROGRAM);
    spi1_transfer_byte((address >> 16) & 0xFF);
    spi1_transfer_byte((address >> 8) & 0xFF);
    spi1_transfer_byte(address & 0xFF);

    for (uint16_t i = 0; i < len; i++) {
        spi1_transfer_byte(data[i]);
    }

    while (SPI1_SR & SPI_SR_BSY) ;
    FLASH_CS_HIGH();

    flash_wait_ready();  /* Typically 1-3 ms */
    return 0;
}

SPI Advantages and Disadvantages

Advantages over UART and I2C:

  • Fastest of the three — easily 10-50 MHz
  • Full-duplex — send and receive at the same time
  • Simple protocol — no addressing, no acknowledgment overhead
  • No pull-up resistors needed (push-pull outputs)
  • Slaves do not need unique addresses

Disadvantages:

  • More pins — 3 shared + 1 CS per slave (4+ total)
  • No acknowledgment — master cannot detect if slave received data
  • No built-in error detection (no parity, no CRC in protocol itself)
  • Short distance only — not suitable for cables over ~30 cm due to high-speed signal integrity
  • No standard — each slave defines its own command set

Common SPI Pitfalls

  • Wrong CPOL/CPHA mode — Data appears shifted or garbled. Always check the slave’s datasheet. Use the SPI mode scanner to find the right mode.
  • CS not managed properly — CS must go LOW before the transfer and HIGH after. Some slaves require CS to toggle between bytes; others need it held low for the entire multi-byte transfer.
  • Clock too fast — Exceeding the slave’s max SCLK causes data corruption. Start slow (1 MHz) and increase once communication works.
  • MISO floating — When no slave is selected, MISO is tri-stated (high-impedance). Reading it gives random data. Always check that CS is low before trusting MISO data.
  • Overrun errors — If the master clocks in data faster than software reads the DR register, old data is overwritten. Always read DR before starting the next transfer.
  • Forgetting to wait for BSY — Releasing CS while the SPI peripheral is still clocking out the last bit corrupts the transfer. Always wait for the BSY flag to clear before raising CS.
  • Interfacing Sensors with Microcontrollers
  • Bitwise Operators in C

Summary

SPI is the go-to protocol when you need speed. Its simplicity makes it easy to implement at the register level, and its full-duplex nature makes it efficient for high-throughput transfers. The main challenge is getting the clock mode right and managing CS pins correctly.

Next, read Writing an SPI Driver in Embedded C for a complete, reusable driver implementation. For protocol comparison and selection guidance, see Communication Interfaces: UART, SPI, I2C. For debugging SPI problems, see Debugging Communication Protocols.

Related on this site

Leave a Reply

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