Home Wiki Electricity & Electrons Communication Protocols: UART, SPI, and I2C — How Components Talk
Electricity & Electrons

Communication Protocols: UART, SPI, and I2C — How Components Talk

UART: The Simplest Serial Protocol

UART (Universal Asynchronous Receiver/Transmitter) is the most straightforward serial communication protocol and the backbone of industrial Modbus RTU. It uses two wires — TX and RX — to send data one bit at a time without a shared clock signal.

Both devices must agree on baud rate, data bits, parity, and stop bits. The industrial standard is 9600 baud, 8N1 for Modbus, though debug consoles often use 115200.

// STM32 HAL: Transmit and receive over UART
void uart_send(UART_HandleTypeDef *huart, const char *msg) {
    HAL_UART_Transmit(huart, (uint8_t*)msg, strlen(msg), 1000);
}

uint8_t rx_buffer[64];
void uart_receive(UART_HandleTypeDef *huart) {
    HAL_UART_Receive(huart, rx_buffer, sizeof(rx_buffer), 500);
}

UART is point-to-point by default. For multi-device networks, RS-485 transceivers convert UART to a differential pair supporting up to 32 devices on a single bus — exactly how Modbus RTU networks operate in factories.

SPI: Fast Communication With Multiple Devices

SPI (Serial Peripheral Interface) is a synchronous protocol using four wires: MOSI, MISO, SCK, and CS (chip select). The master controls the clock, so both devices stay perfectly synchronized.

SPI reaches speeds of 10-50 MHz, making it ideal for TFT displays, SD cards, and high-resolution ADCs.

// STM32 HAL: Read a register from an SPI sensor
uint8_t spi_read_register(SPI_HandleTypeDef *hspi, uint8_t reg) {
    uint8_t tx[2] = {reg | 0x80, 0x00};  // Read bit + address
    uint8_t rx[2] = {0};

    HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET);
    HAL_SPI_TransmitReceive(hspi, tx, rx, 2, 100);
    HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET);

    return rx[1];
}

Each slave needs its own CS line, so N devices require N+3 wires — limiting scalability but preventing bus conflicts.

I2C: A Two-Wire Bus for Dozens of Components

I2C uses just two wires — SDA (data) and SCL (clock) — to connect up to 127 devices. Each device has a unique 7-bit address, and the master selects which device to communicate with.

I2C is slower than SPI (100 kHz to 1 MHz) but its low pin count makes it the default for temperature sensors, EEPROMs, and real-time clocks.

// STM32 HAL: Read 2 bytes from an I2C sensor at address 0x76
uint8_t data[2];
HAL_I2C_Mem_Read(&hi2c1, 0x76 << 1, 0xFA,
    I2C_MEMADD_SIZE_8BIT, data, 2, 100);

Rust with embedded-hal

use embedded_hal::i2c::I2c;

fn read_sensor_id<I: I2c>(i2c: &mut I, addr: u8) -> Result<u8, I::Error> {
    let mut buf = [0u8; 1];
    i2c.write_read(addr, &[0xD0], &mut buf)?;
    Ok(buf[0])
}

I2C was designed for short PCB traces. In factories, cable lengths over 50 cm cause signal integrity issues — use bus extenders, lower clock speeds, or stronger pull-ups (2.2K instead of 10K).

When to Use Each Protocol

Criteria UART SPI I2C
Wires needed 2 (TX/RX) 3 + 1 per device 2 (SDA/SCL)
Maximum speed ~1 Mbps 10-50 MHz 100 kHz - 1 MHz
Device count 1 (or 32 via RS-485) Limited by CS pins Up to 127
Best for Modbus, debug, GPS Displays, fast ADC Sensors, EEPROM, RTC

In a typical industrial system, all three run simultaneously: UART for Modbus, SPI for a display, and I2C for environmental sensors.

HAL Libraries: Simplifying Protocol Handling

STM32 HAL provides complete peripheral drivers generated through STM32CubeMX. Rust embedded-hal defines traits that any MCU must satisfy, making sensor driver code portable across STM32, ESP32, and RP2040.

use embedded_hal::i2c::I2c;

struct Bme280<I> { i2c: I, address: u8 }

impl<I: I2c> Bme280<I> {
    pub fn new(i2c: I, address: u8) -> Self { Self { i2c, address } }

    pub fn read_chip_id(&mut self) -> Result<u8, I::Error> {
        let mut buf = [0u8; 1];
        self.i2c.write_read(self.address, &[0xD0], &mut buf)?;
        Ok(buf[0])
    }
}

Practical Example: Reading a BME280 via I2C and Sending Data via UART

The BME280 measures temperature, humidity, and pressure — ideal for factory environmental monitoring. This example reads values over I2C and transmits them as formatted text via UART.

#include "stm32f4xx_hal.h"
#include <stdio.h>
#include <string.h>

#define BME280_ADDR  (0x76 << 1)

int32_t read_raw_temperature(I2C_HandleTypeDef *hi2c) {
    uint8_t data[3];
    HAL_I2C_Mem_Read(hi2c, BME280_ADDR, 0xFA,
        I2C_MEMADD_SIZE_8BIT, data, 3, 100);
    return ((int32_t)data[0] << 12) |
           ((int32_t)data[1] << 4)  |
           ((int32_t)data[2] >> 4);
}

void send_sensor_data(UART_HandleTypeDef *huart,
                      I2C_HandleTypeDef  *hi2c) {
    int32_t raw = read_raw_temperature(hi2c);
    float temp_c = (float)raw / 16384.0f * 10.0f;  // Simplified

    char buf[80];
    snprintf(buf, sizeof(buf), "SENSOR|temp=%.1f\r\n", temp_c);
    HAL_UART_Transmit(huart, (uint8_t*)buf, strlen(buf), 200);
}

int main(void) {
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_I2C1_Init();
    MX_USART2_UART_Init();

    while (1) {
        send_sensor_data(&huart2, &hi2c1);
        HAL_Delay(1000);
    }
}

Summary

UART, SPI, and I2C are the three essential serial protocols in embedded systems. UART forms the basis of Modbus RTU industrial networks. SPI offers the highest speed for displays and fast ADCs. I2C connects dozens of sensors on just two wires. In industrial practice, all three are used together. HAL libraries abstract hardware details and make code portable across MCU families. The next lesson covers timers and PWM — peripherals that generate precise timing and control motor speeds.

UART SPI I2C serial protocol bus الاتصال التسلسلي بروتوكول الناقل المستشعرات الشاشة الذاكرة