Skip to content
C++
Domain Deep-Dive
Intermediate

SPI and I2C in Embedded C++

Implementing SPI and I2C communication in bare-metal C++: register-level drivers, HAL abstraction, DMA transfers, and common peripheral patterns.

SPI vs I2C — quick comparison

PropertySPII2C
Wires4 (MOSI, MISO, CLK, CS)2 (SDA, SCL)
SpeedUp to 100+ MHz100 kHz / 400 kHz / 3.4 MHz
AddressingChip-select line per device7-bit address (127 devices)
Full-duplexYesNo
Multi-masterComplexBuilt-in arbitration
DistanceShort (PCB)Short (PCB, < 1m)
Best forDisplays, flash, ADCs, high-speedSensors, EEPROMs, small peripherals

SPI driver — bare-metal (STM32)

cpp
#include <cstdint>

// Register map for STM32F4 SPI1
struct SpiRegs {
    volatile uint32_t CR1;    // Control register 1
    volatile uint32_t CR2;    // Control register 2
    volatile uint32_t SR;     // Status register
    volatile uint32_t DR;     // Data register
    volatile uint32_t CRCPR;
    volatile uint32_t RXCRCR;
    volatile uint32_t TXCRCR;
    volatile uint32_t I2SCFGR;
    volatile uint32_t I2SPR;
};

// CR1 bits
namespace SpiCR1 {
    inline constexpr uint32_t CPHA    = 1u << 0;  // Clock phase
    inline constexpr uint32_t CPOL    = 1u << 1;  // Clock polarity
    inline constexpr uint32_t MSTR    = 1u << 2;  // Master mode
    inline constexpr uint32_t BR_DIV2 = 0u << 3;  // Baud = fPCLK/2
    inline constexpr uint32_t BR_DIV4 = 1u << 3;
    inline constexpr uint32_t BR_DIV8 = 2u << 3;
    inline constexpr uint32_t SPE     = 1u << 6;  // SPI enable
    inline constexpr uint32_t SSM     = 1u << 9;  // SW slave management
    inline constexpr uint32_t SSI     = 1u << 8;  // Internal SS
}

// SR bits
namespace SpiSR {
    inline constexpr uint32_t RXNE = 1u << 0;  // Rx buffer not empty
    inline constexpr uint32_t TXE  = 1u << 1;  // Tx buffer empty
    inline constexpr uint32_t BSY  = 1u << 7;  // Busy
}

class Spi {
    SpiRegs* const m_regs;
    volatile uint32_t* const m_cs_gpio;
    uint32_t m_cs_pin;

public:
    Spi(uint32_t base, volatile uint32_t* cs_odr, uint32_t cs_pin)
        : m_regs(reinterpret_cast<SpiRegs*>(base))
        , m_cs_gpio(cs_odr)
        , m_cs_pin(cs_pin) {}

    void Init() {
        m_regs->CR1 = SpiCR1::MSTR | SpiCR1::SSM | SpiCR1::SSI
                    | SpiCR1::BR_DIV8 | SpiCR1::SPE;
    }

    uint8_t Transfer(uint8_t data) {
        while (!(m_regs->SR & SpiSR::TXE)) {} // wait Tx empty
        m_regs->DR = data;
        while (!(m_regs->SR & SpiSR::RXNE)) {} // wait Rx ready
        return static_cast<uint8_t>(m_regs->DR);
    }

    void WriteBlock(const uint8_t* buf, size_t len) {
        Select();
        for (size_t i = 0; i < len; ++i) Transfer(buf[i]);
        Deselect();
    }

    void ReadBlock(uint8_t* buf, size_t len, uint8_t dummy = 0xFF) {
        Select();
        for (size_t i = 0; i < len; ++i) buf[i] = Transfer(dummy);
        Deselect();
    }

    void Select()   { *m_cs_gpio &= ~(1u << m_cs_pin); }
    void Deselect() { *m_cs_gpio |=  (1u << m_cs_pin); }
};

SPI with DMA (non-blocking)

For large transfers (display framebuffers, SD card), blocking SPI wastes CPU:

cpp
class SpiDma {
    SpiRegs* m_regs;
    DmaChannel* m_tx_dma;
    DmaChannel* m_rx_dma;
    std::function<void()> m_on_complete;

public:
    // Start DMA transfer — returns immediately
    void TransferAsync(const uint8_t* tx, uint8_t* rx, size_t len,
                       std::function<void()> callback) {
        m_on_complete = std::move(callback);

        // Configure TX DMA
        m_tx_dma->CMAR  = reinterpret_cast<uint32_t>(tx);
        m_tx_dma->CPAR  = reinterpret_cast<uint32_t>(&m_regs->DR);
        m_tx_dma->CNDTR = len;
        m_tx_dma->CCR   = DmaCCR::MEM2PER | DmaCCR::MINC | DmaCCR::EN;

        // Configure RX DMA (always needed to prevent Rx overflow)
        m_rx_dma->CMAR  = reinterpret_cast<uint32_t>(rx);
        m_rx_dma->CPAR  = reinterpret_cast<uint32_t>(&m_regs->DR);
        m_rx_dma->CNDTR = len;
        m_rx_dma->CCR   = DmaCCR::PER2MEM | DmaCCR::MINC | DmaCCR::EN
                        | DmaCCR::TCIE;  // transfer complete interrupt

        // Enable SPI DMA requests
        m_regs->CR2 |= SpiCR2::RXDMAEN | SpiCR2::TXDMAEN;
    }

    // Called from DMA RX complete interrupt
    void OnDmaComplete() {
        m_regs->CR2 &= ~(SpiCR2::RXDMAEN | SpiCR2::TXDMAEN);
        if (m_on_complete) m_on_complete();
    }
};

I2C driver — bare-metal

cpp
class I2c {
    I2cRegs* m_regs;

    bool WaitFlag(uint32_t flag, bool set, uint32_t timeout_us = 1000) {
        uint32_t start = GetMicros();
        while (((m_regs->SR1 & flag) != 0) != set) {
            if (GetMicros() - start > timeout_us) return false;
        }
        return true;
    }

public:
    // Write N bytes to I2C device
    bool Write(uint8_t addr, const uint8_t* data, size_t len) {
        // Generate START
        m_regs->CR1 |= I2cCR1::START;
        if (!WaitFlag(I2cSR1::SB, true)) return false; // start bit set

        // Send address (write mode, bit0 = 0)
        m_regs->DR = static_cast<uint8_t>(addr << 1);
        if (!WaitFlag(I2cSR1::ADDR, true)) return false;
        (void)m_regs->SR2; // clear ADDR by reading SR2

        // Send data bytes
        for (size_t i = 0; i < len; ++i) {
            if (!WaitFlag(I2cSR1::TXE, true)) return false;
            m_regs->DR = data[i];
        }

        // Wait for byte transfer finish
        if (!WaitFlag(I2cSR1::BTF, true)) return false;

        // Generate STOP
        m_regs->CR1 |= I2cCR1::STOP;
        return true;
    }

    // Read N bytes from I2C device
    bool Read(uint8_t addr, uint8_t* data, size_t len) {
        m_regs->CR1 |= I2cCR1::START | I2cCR1::ACK; // send start + enable ACK
        if (!WaitFlag(I2cSR1::SB, true)) return false;

        m_regs->DR = static_cast<uint8_t>((addr << 1) | 1); // read bit
        if (!WaitFlag(I2cSR1::ADDR, true)) return false;
        (void)m_regs->SR2;

        for (size_t i = 0; i < len; ++i) {
            if (i == len - 1) {
                m_regs->CR1 &= ~I2cCR1::ACK; // NACK on last byte
                m_regs->CR1 |= I2cCR1::STOP;
            }
            if (!WaitFlag(I2cSR1::RXNE, true)) return false;
            data[i] = static_cast<uint8_t>(m_regs->DR);
        }
        return true;
    }
};

Example: BME280 sensor driver using the I2C abstraction

cpp
class BME280 {
    static constexpr uint8_t ADDR = 0x76;
    static constexpr uint8_t REG_CTRL_MEAS = 0xF4;
    static constexpr uint8_t REG_PRESS_MSB = 0xF7;

    I2c& m_i2c;

    void WriteReg(uint8_t reg, uint8_t value) {
        uint8_t buf[2] = { reg, value };
        m_i2c.Write(ADDR, buf, 2);
    }

    uint8_t ReadReg(uint8_t reg) {
        m_i2c.Write(ADDR, &reg, 1); // write register address
        uint8_t val;
        m_i2c.Read(ADDR, &val, 1);
        return val;
    }

public:
    explicit BME280(I2c& i2c) : m_i2c(i2c) {}

    void Init() {
        // Mode: normal, oversampling x1 for both temp and pressure
        WriteReg(REG_CTRL_MEAS, 0b00100111);
    }

    struct Reading { int32_t pressure_pa; int32_t temp_centidegC; };

    Reading Measure() {
        uint8_t buf[6];
        uint8_t reg = REG_PRESS_MSB;
        m_i2c.Write(ADDR, &reg, 1);
        m_i2c.Read(ADDR, buf, 6);

        int32_t press_raw = (buf[0] << 12) | (buf[1] << 4) | (buf[2] >> 4);
        int32_t temp_raw  = (buf[3] << 12) | (buf[4] << 4) | (buf[5] >> 4);

        return { CompensatePressure(press_raw), CompensateTemp(temp_raw) };
    }
};
Edit on GitHubUpdated 2026-05-24T00:00:00.000Z