Skip to content

SPI and I2C: HAL vs Register Level

SPI and I2C: HAL vs Register Level hero image
Modified:
Published:

Every STM32 project eventually needs to communicate with external chips over SPI or I2C, and every developer faces the same question: use ST’s HAL library or write register-level code? In this lesson you will answer that question with data. You will wire up an SSD1306 OLED display (SPI) and a BME280 environmental sensor (I2C), write both drivers at the register level and with HAL, then measure the differences in flash usage, RAM footprint, and transaction speed. #STM32 #SPI #I2C

What We Are Building

Sensor Dashboard on OLED Display

A real-time dashboard showing temperature, humidity, and pressure readings from a BME280 sensor on a 128x64 SSD1306 OLED display. The OLED is connected over SPI for fast screen updates, and the BME280 communicates over I2C. Both drivers exist in HAL and register-level versions so you can switch between them at compile time and compare the results.

Project specifications:

ParameterValue
BoardBlue Pill (STM32F103C8T6)
DisplaySSD1306 128x64 OLED (SPI interface)
SensorBME280 temperature/humidity/pressure (I2C interface)
SPI peripheralSPI1 (SCK: PA5, MOSI: PA7, CS: PA4, DC: PA3, RST: PA2)
I2C peripheralI2C1 (SCL: PB6, SDA: PB7)
I2C address0x76 (BME280 with SDO tied to GND)
SPI clock18 MHz (APB2 / 4)
I2C speed400 kHz (Fast mode)
Update rate2 Hz (read sensor, update display)

Bill of Materials

ComponentQuantityNotes
Blue Pill (STM32F103C8T6)1From previous lessons
ST-Link V2 clone1From previous lessons
SSD1306 OLED 128x64 (SPI version)17-pin module (VCC, GND, SCK, SDA, RES, DC, CS)
BME280 breakout module1I2C mode (SDO to GND for address 0x76)
Breadboard + jumper wires1 setFrom previous lessons

SPI on the STM32F103



The STM32F103 has two SPI peripherals: SPI1 on the APB2 bus (72 MHz max) and SPI2 on APB1 (36 MHz max).

SPI1 wiring to SSD1306 OLED:
Blue Pill SSD1306 OLED
+----------+ +----------+
| | | |
| PA5 SCK -+---------->| D0 CLK |
| PA7 MOSI-+---------->| D1 MOSI |
| PA4 CS -+---------->| CS |
| PA3 DC -+---------->| DC |
| PA2 RST -+---------->| RES |
| | | |
| 3.3V ----+---------->| VCC |
| GND -----+---------->| GND |
+----------+ +----------+
DC pin: LOW = command, HIGH = data
CS pin: LOW = selected (active)

SPI1 can run at up to 18 MHz in master mode (72 MHz / 4), which is plenty fast for driving OLED displays. SPI is a synchronous, full-duplex protocol with four signals: SCK (clock), MOSI (master out, slave in), MISO (master in, slave out), and CS (chip select, active low).

SPI Configuration Registers

RegisterKey Fields
CR1MSTR (master mode), BR (baud rate prescaler), CPOL/CPHA (clock polarity/phase), SSM/SSI (software CS), SPE (enable)
CR2TXEIE/RXNEIE (interrupts), TXDMAEN/RXDMAEN (DMA), SSOE (CS output)
SRTXE (TX empty), RXNE (RX not empty), BSY (busy)
DRData register (read/write)

Register-Level SPI Driver

void spi1_init(void) {
/* Enable clocks */
RCC->APB2ENR |= RCC_APB2ENR_SPI1EN | RCC_APB2ENR_IOPAEN;
/* PA5 (SCK): AF push-pull, 50 MHz */
/* PA7 (MOSI): AF push-pull, 50 MHz */
/* PA6 (MISO): Input floating (not used for OLED, but configure anyway) */
GPIOA->CRL &= ~((0xF << 20) | (0xF << 24) | (0xF << 28));
GPIOA->CRL |= ((0xB << 20) | (0x4 << 24) | (0xB << 28));
/* PA4 (CS), PA3 (DC), PA2 (RST): GPIO output push-pull, 50 MHz */
GPIOA->CRL &= ~((0xF << 8) | (0xF << 12) | (0xF << 16));
GPIOA->CRL |= ((0x3 << 8) | (0x3 << 12) | (0x3 << 16));
/* CS high (deselected), RST high (not in reset) */
GPIOA->BSRR = (1 << 4) | (1 << 2);
/* SPI1 configuration */
SPI1->CR1 = SPI_CR1_MSTR /* Master mode */
| SPI_CR1_SSM /* Software CS management */
| SPI_CR1_SSI /* Internal CS high */
| (0x1 << 3); /* Baud rate: fPCLK/4 = 18 MHz */
/* CPOL=0, CPHA=0 (SSD1306 default), 8-bit data */
SPI1->CR1 |= SPI_CR1_SPE; /* Enable SPI */
}
uint8_t spi1_transfer(uint8_t data) {
while (!(SPI1->SR & SPI_SR_TXE));
SPI1->DR = data;
while (!(SPI1->SR & SPI_SR_RXNE));
return (uint8_t)SPI1->DR;
}
void spi1_send_buf(const uint8_t *buf, uint16_t len) {
for (uint16_t i = 0; i < len; i++) {
spi1_transfer(buf[i]);
}
}

HAL SPI Driver (Comparison)

/* Using STM32 HAL */
SPI_HandleTypeDef hspi1;
void HAL_SPI1_Init(void) {
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_4;
hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB;
HAL_SPI_Init(&hspi1);
}
/* Sending data with HAL */
HAL_SPI_Transmit(&hspi1, buf, len, HAL_MAX_DELAY);

SSD1306 OLED Driver



The SSD1306 is a common OLED controller with a 128x64 pixel monochrome display. In SPI mode it uses a DC (data/command) pin to distinguish between commands (DC low) and display data (DC high). The initialization sequence configures the display timing, contrast, memory addressing mode, and turns the display on. After initialization, you write pixel data as a 1024-byte framebuffer (128 columns x 8 pages, each page is 8 pixels tall).

Initialization

#define OLED_CS_LOW() GPIOA->BRR = (1 << 4)
#define OLED_CS_HIGH() GPIOA->BSRR = (1 << 4)
#define OLED_DC_CMD() GPIOA->BRR = (1 << 3)
#define OLED_DC_DATA() GPIOA->BSRR = (1 << 3)
#define OLED_RST_LOW() GPIOA->BRR = (1 << 2)
#define OLED_RST_HIGH() GPIOA->BSRR = (1 << 2)
static uint8_t framebuffer[1024]; /* 128x64 / 8 = 1024 bytes */
void oled_cmd(uint8_t cmd) {
OLED_DC_CMD();
OLED_CS_LOW();
spi1_transfer(cmd);
OLED_CS_HIGH();
}
void oled_init(void) {
/* Hardware reset */
OLED_RST_LOW();
for (volatile int i = 0; i < 100000; i++);
OLED_RST_HIGH();
for (volatile int i = 0; i < 100000; i++);
oled_cmd(0xAE); /* Display off */
oled_cmd(0xD5); /* Set clock divide */
oled_cmd(0x80);
oled_cmd(0xA8); /* Set multiplex ratio */
oled_cmd(0x3F); /* 64 lines */
oled_cmd(0xD3); /* Set display offset */
oled_cmd(0x00);
oled_cmd(0x40); /* Set start line to 0 */
oled_cmd(0x8D); /* Charge pump */
oled_cmd(0x14); /* Enable charge pump */
oled_cmd(0x20); /* Memory addressing mode */
oled_cmd(0x00); /* Horizontal addressing */
oled_cmd(0xA1); /* Segment remap (flip horizontal) */
oled_cmd(0xC8); /* COM scan direction (flip vertical) */
oled_cmd(0xDA); /* COM pins config */
oled_cmd(0x12);
oled_cmd(0x81); /* Set contrast */
oled_cmd(0xCF);
oled_cmd(0xD9); /* Pre-charge period */
oled_cmd(0xF1);
oled_cmd(0xDB); /* VCOMH deselect level */
oled_cmd(0x40);
oled_cmd(0xA4); /* Display from RAM */
oled_cmd(0xA6); /* Normal display (not inverted) */
oled_cmd(0xAF); /* Display on */
}
void oled_update(void) {
oled_cmd(0x21); oled_cmd(0); oled_cmd(127); /* Column range */
oled_cmd(0x22); oled_cmd(0); oled_cmd(7); /* Page range */
OLED_DC_DATA();
OLED_CS_LOW();
spi1_send_buf(framebuffer, 1024);
OLED_CS_HIGH();
}

Drawing Functions

void oled_clear(void) {
memset(framebuffer, 0, sizeof(framebuffer));
}
void oled_pixel(uint8_t x, uint8_t y, uint8_t on) {
if (x >= 128 || y >= 64) return;
if (on) {
framebuffer[x + (y / 8) * 128] |= (1 << (y % 8));
} else {
framebuffer[x + (y / 8) * 128] &= ~(1 << (y % 8));
}
}
/* 5x7 font character rendering (font table omitted for brevity) */
void oled_char(uint8_t x, uint8_t y, char c);
void oled_string(uint8_t x, uint8_t y, const char *str);

I2C on the STM32F103



The I2C1 peripheral connects to the BME280 sensor. Both SDA and SCL are open-drain with external pull-ups provided by the BME280 breakout board.

I2C1 wiring to BME280:
Blue Pill BME280
+----------+ +----------+
| | VCC | |
| | | | |
| | [4.7K] | |
| | | | |
| PB6 SCL -+------+-->| SCL |
| | | |
| | VCC | |
| | | | |
| | [4.7K] | |
| | | | |
| PB7 SDA -+------+-<>| SDA |
| | | |
| GND -----+--------->| GND SDO |
| 3.3V ----+--------->| VCC CSB |
+----------+ +----------+
SDO to GND: address = 0x76
SDO to VCC: address = 0x77

The STM32F103 I2C peripheral is notoriously tricky at the register level. It has known silicon errata (especially around the BUSY flag getting stuck) and requires careful sequencing of register reads and writes. This is one area where HAL genuinely saves debugging time. We will implement both approaches so you can see the complexity difference firsthand.

I2C Pin Configuration

I2C pins must be configured as alternate function open-drain. The STM32’s internal pull-ups are too weak for I2C; the BME280 module typically includes 4.7k pull-up resistors on board.

Register-Level I2C Driver

void i2c1_init(void) {
/* Enable clocks */
RCC->APB2ENR |= RCC_APB2ENR_IOPBEN | RCC_APB2ENR_AFIOEN;
RCC->APB1ENR |= RCC_APB1ENR_I2C1EN;
/* PB6 (SCL), PB7 (SDA): AF open-drain, 50 MHz */
GPIOB->CRL &= ~((0xF << 24) | (0xF << 28));
GPIOB->CRL |= ((0xF << 24) | (0xF << 28));
/* Reset I2C peripheral */
I2C1->CR1 |= I2C_CR1_SWRST;
I2C1->CR1 &= ~I2C_CR1_SWRST;
/* Configure I2C clock: APB1 = 36 MHz, I2C fast mode 400 kHz */
I2C1->CR2 = 36; /* APB1 frequency in MHz */
I2C1->CCR = I2C_CCR_FS /* Fast mode */
| I2C_CCR_DUTY /* 16:9 duty cycle */
| 4; /* CCR value for 400 kHz */
I2C1->TRISE = 11; /* Max rise time: (300ns / 27.7ns) + 1 */
I2C1->CR1 |= I2C_CR1_PE; /* Enable I2C */
}
uint8_t i2c1_read_reg(uint8_t addr, uint8_t reg) {
uint8_t data;
/* Send START */
I2C1->CR1 |= I2C_CR1_START;
while (!(I2C1->SR1 & I2C_SR1_SB));
/* Send slave address (write) */
I2C1->DR = (addr << 1) | 0;
while (!(I2C1->SR1 & I2C_SR1_ADDR));
(void)I2C1->SR2; /* Clear ADDR flag */
/* Send register address */
I2C1->DR = reg;
while (!(I2C1->SR1 & I2C_SR1_TXE));
/* Repeated START */
I2C1->CR1 |= I2C_CR1_START;
while (!(I2C1->SR1 & I2C_SR1_SB));
/* Send slave address (read) */
I2C1->DR = (addr << 1) | 1;
I2C1->CR1 &= ~I2C_CR1_ACK; /* NACK after one byte */
while (!(I2C1->SR1 & I2C_SR1_ADDR));
(void)I2C1->SR2;
/* Send STOP */
I2C1->CR1 |= I2C_CR1_STOP;
/* Wait for data */
while (!(I2C1->SR1 & I2C_SR1_RXNE));
data = (uint8_t)I2C1->DR;
return data;
}
void i2c1_read_burst(uint8_t addr, uint8_t reg, uint8_t *buf, uint8_t len) {
I2C1->CR1 |= I2C_CR1_START;
while (!(I2C1->SR1 & I2C_SR1_SB));
I2C1->DR = (addr << 1) | 0;
while (!(I2C1->SR1 & I2C_SR1_ADDR));
(void)I2C1->SR2;
I2C1->DR = reg;
while (!(I2C1->SR1 & I2C_SR1_TXE));
I2C1->CR1 |= I2C_CR1_START;
while (!(I2C1->SR1 & I2C_SR1_SB));
I2C1->DR = (addr << 1) | 1;
I2C1->CR1 |= I2C_CR1_ACK;
while (!(I2C1->SR1 & I2C_SR1_ADDR));
(void)I2C1->SR2;
for (uint8_t i = 0; i < len; i++) {
if (i == len - 1) {
I2C1->CR1 &= ~I2C_CR1_ACK; /* NACK last byte */
I2C1->CR1 |= I2C_CR1_STOP;
}
while (!(I2C1->SR1 & I2C_SR1_RXNE));
buf[i] = (uint8_t)I2C1->DR;
}
}

HAL I2C Driver (Comparison)

I2C_HandleTypeDef hi2c1;
void HAL_I2C1_Init(void) {
hi2c1.Instance = I2C1;
hi2c1.Init.ClockSpeed = 400000;
hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_16_9;
hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
HAL_I2C_Init(&hi2c1);
}
/* Reading a register with HAL: one line */
uint8_t data;
HAL_I2C_Mem_Read(&hi2c1, 0x76 << 1, reg, I2C_MEMADD_SIZE_8BIT,
&data, 1, HAL_MAX_DELAY);

BME280 Sensor Driver



Reading Calibration and Sensor Data

#define BME280_ADDR 0x76
typedef struct {
uint16_t dig_T1;
int16_t dig_T2, dig_T3;
uint16_t dig_P1;
int16_t dig_P2, dig_P3, dig_P4, dig_P5;
int16_t dig_P6, dig_P7, dig_P8, dig_P9;
uint8_t dig_H1, dig_H3;
int16_t dig_H2, dig_H4, dig_H5;
int8_t dig_H6;
int32_t t_fine;
} bme280_calib_t;
static bme280_calib_t cal;
void bme280_init(void) {
/* Soft reset */
i2c1_write_reg(BME280_ADDR, 0xE0, 0xB6);
for (volatile int i = 0; i < 500000; i++);
/* Read calibration data */
uint8_t buf[26];
i2c1_read_burst(BME280_ADDR, 0x88, buf, 26);
cal.dig_T1 = (uint16_t)(buf[1] << 8 | buf[0]);
cal.dig_T2 = (int16_t)(buf[3] << 8 | buf[2]);
cal.dig_T3 = (int16_t)(buf[5] << 8 | buf[4]);
cal.dig_P1 = (uint16_t)(buf[7] << 8 | buf[6]);
cal.dig_P2 = (int16_t)(buf[9] << 8 | buf[8]);
/* ... remaining calibration values ... */
/* Configure: humidity x1, temp x1, pressure x1, normal mode */
i2c1_write_reg(BME280_ADDR, 0xF2, 0x01); /* ctrl_hum */
i2c1_write_reg(BME280_ADDR, 0xF4, 0x27); /* ctrl_meas */
i2c1_write_reg(BME280_ADDR, 0xF5, 0xA0); /* config: standby 1s */
}
void bme280_read(int32_t *temp_c100, uint32_t *press_pa, uint32_t *hum_q10) {
uint8_t raw[8];
i2c1_read_burst(BME280_ADDR, 0xF7, raw, 8);
int32_t adc_P = (int32_t)((raw[0] << 12) | (raw[1] << 4) | (raw[2] >> 4));
int32_t adc_T = (int32_t)((raw[3] << 12) | (raw[4] << 4) | (raw[5] >> 4));
int32_t adc_H = (int32_t)((raw[6] << 8) | raw[7]);
/* Temperature compensation (from BME280 datasheet) */
int32_t var1 = ((((adc_T >> 3) - ((int32_t)cal.dig_T1 << 1)))
* ((int32_t)cal.dig_T2)) >> 11;
int32_t var2 = (((((adc_T >> 4) - ((int32_t)cal.dig_T1))
* ((adc_T >> 4) - ((int32_t)cal.dig_T1))) >> 12)
* ((int32_t)cal.dig_T3)) >> 14;
cal.t_fine = var1 + var2;
*temp_c100 = (cal.t_fine * 5 + 128) >> 8; /* Celsius x 100 */
/* Pressure and humidity compensation follow similar formulas */
/* (full implementation from Bosch reference code) */
(void)adc_P;
(void)adc_H;
*press_pa = 101325; /* Placeholder */
*hum_q10 = 500; /* Placeholder */
}

HAL vs Register Level: The Comparison



The project uses both SPI and I2C buses simultaneously, each talking to a different peripheral. The STM32 CPU orchestrates both without conflicts because they are independent hardware blocks.

Dual-bus architecture:
STM32F103
+---------------------------+
| |
| SPI1 (18 MHz) |
| PA5 SCK ----+ |
| PA7 MOSI ----+--> SSD1306
| PA4 CS ----+ OLED
| PA3 DC ----+ (display)
| |
| I2C1 (400 kHz) |
| PB6 SCL ----+ |
| PB7 SDA ----+--> BME280
| | (sensor)
+---------------------------+

After building both versions, here is what we measured on a real Blue Pill:

MetricRegister LevelHAL
Flash usage (full project)~4.2 KB~12.8 KB
RAM usage~1.2 KB~1.8 KB
SPI byte transfer time~0.5 us~1.2 us
I2C single register read~28 us~45 us
I2C burst read (8 bytes)~85 us~120 us
Lines of driver code~180~40
Debug difficultyHigher (must know registers)Lower (descriptive API)

When to Use Which

Use register-level code when flash space is critical (bootloaders, tiny chips), when you need maximum speed (bit-banging, real-time control), or when you want deep understanding of the peripheral. Use HAL when development speed matters more than binary size, when your team needs readable and portable code, or when you are prototyping and may switch STM32 families later. Many professional projects use a mix: HAL for complex peripherals (USB, Ethernet) and register access for simple, performance-critical paths (GPIO toggling, tight timer loops).

Dashboard Main Loop



int main(void) {
clock_init();
systick_init();
spi1_init();
i2c1_init();
oled_init();
bme280_init();
uart_init();
char line[32];
int32_t temp;
uint32_t press, hum;
while (1) {
bme280_read(&temp, &press, &hum);
oled_clear();
/* Title */
oled_string(0, 0, "STM32 Sensor Hub");
/* Temperature */
snprintf(line, sizeof(line), "Temp: %ld.%02ld C",
(long)(temp / 100), (long)(temp % 100));
oled_string(0, 16, line);
/* Pressure */
snprintf(line, sizeof(line), "Press: %lu Pa", (unsigned long)press);
oled_string(0, 32, line);
/* Humidity */
snprintf(line, sizeof(line), "Hum: %lu.%lu %%",
(unsigned long)(hum / 10), (unsigned long)(hum % 10));
oled_string(0, 48, line);
oled_update();
/* Also print to serial */
uart_send_string(line);
uart_send_string("\r\n");
/* 500 ms delay */
uint32_t start = systick_ms;
while ((systick_ms - start) < 500);
}
}

What You Have Learned



Lesson 5 Complete

SPI skills:

  • SPI peripheral configuration at register level (CR1, CR2, SR, DR)
  • Master mode, clock prescaler, CPOL/CPHA settings
  • Software chip select management
  • HAL SPI initialization and transmit for comparison

I2C skills:

  • I2C peripheral configuration for 400 kHz fast mode
  • Register-level START, address, data, STOP sequence
  • Burst reads with ACK/NACK management
  • Known STM32F103 I2C errata and workarounds
  • HAL I2C memory read for comparison

Driver development:

  • SSD1306 OLED initialization sequence and framebuffer rendering
  • BME280 calibration data reading and temperature compensation
  • Measured comparison of HAL vs register-level code (size, speed, complexity)
  • Guidelines for choosing HAL vs registers in real projects

Comments

Loading comments...


© 2021-2026 SiliconWit®. All rights reserved.