Domain Deep-Dive
IntermediateSPI 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
| Property | SPI | I2C |
|---|---|---|
| Wires | 4 (MOSI, MISO, CLK, CS) | 2 (SDA, SCL) |
| Speed | Up to 100+ MHz | 100 kHz / 400 kHz / 3.4 MHz |
| Addressing | Chip-select line per device | 7-bit address (127 devices) |
| Full-duplex | Yes | No |
| Multi-master | Complex | Built-in arbitration |
| Distance | Short (PCB) | Short (PCB, < 1m) |
| Best for | Displays, flash, ADCs, high-speed | Sensors, 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, ®, 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, ®, 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