Back to Blog
Embedded Systems

UART vs SPI vs I2C: Choosing the Right Protocol for Sensor Integration

UART, SPI, and I2C each have distinct strengths and weaknesses. Choosing the wrong protocol leads to wiring complexity, throughput bottlenecks, or noise issues. Here is a practical breakdown with ESP32 code examples for each.

April 5, 2024
13 min read
UARTSPII2CESP32

UART vs SPI vs I2C: Choosing the Right Protocol for Sensor Integration

Every embedded project involves wiring sensors and peripherals to a microcontroller. The protocol you choose determines wiring complexity, data throughput, maximum device count, cable length tolerance, and firmware complexity. Choosing wrong costs you debugging time and sometimes a PCB respin.

Here is the definitive comparison with ESP32 implementation examples.

Protocol Comparison Matrix

| Feature | UART | SPI | I2C | |---|---|---|---| | Wiring (per device) | 2 wires (TX/RX) | 4 wires (MOSI/MISO/CLK/CS) | 2 wires (SDA/SCL) | | Max speed | 5 Mbps typical | 80 MHz (ESP32) | 400 kHz (fast mode), 1 MHz (fast+) | | Multi-device | Difficult (need mux) | Yes (one CS per device) | Yes (up to 127 devices, address-based) | | Duplex | Full duplex | Full duplex | Half duplex | | Cable length | 15m (RS-232), 1200m (RS-485) | <0.5m | <1m (standard) | | CPU overhead | Low (hardware FIFO) | Low (hardware DMA) | Medium (ACK polling) | | Common use | GPS, GSM modems, debug | ADCs, displays, flash | IMUs, temperature, pressure sensors |

UART: Long Distance, Simple Point-to-Point

UART is the right choice for GPS modules, cellular modems (SIM800/SIM7600), and debug consoles. It has no clock line — both devices agree on baud rate in advance. This makes it tolerant of long cables, especially when combined with RS-485 differential signaling.

#include "driver/uart.h"

#define GPS_UART_NUM UART_NUM_1 #define GPS_TX_PIN GPIO_NUM_17 #define GPS_RX_PIN GPIO_NUM_16 #define GPS_BAUD_RATE 9600

void uart_gps_init(void) { const uart_config_t uart_config = { .baud_rate = GPS_BAUD_RATE, .data_bits = UART_DATA_8_BITS, .parity = UART_PARITY_DISABLE, .stop_bits = UART_STOP_BITS_1, .flow_ctrl = UART_HW_FLOWCTRL_DISABLE, .source_clk = UART_SCLK_APB, };

uart_driver_install(GPS_UART_NUM, 1024, 0, 0, NULL, 0); uart_param_config(GPS_UART_NUM, &uart_config); uart_set_pin(GPS_UART_NUM, GPS_TX_PIN, GPS_RX_PIN, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE); }

void uart_gps_read_nmea(char *buf, size_t len) { // Read until newline (NMEA sentences end with \r\n) int bytes = uart_read_bytes(GPS_UART_NUM, (uint8_t *)buf, len - 1, pdMS_TO_TICKS(1000)); if (bytes > 0) buf[bytes] = '\0'; }

When to use UART:

  • Single peripheral, long cable run
  • Cellular modem or GPS receiver
  • Debug/logging output
  • When you need RS-485 multi-drop (multiple nodes on one bus, half-duplex)
  • SPI: High Speed, Multiple Slaves

    SPI is a synchronous protocol with a dedicated clock line. The master generates the clock; slaves only respond when their chip select (CS) is driven low. Speed can reach tens of MHz, limited only by trace capacitance and your MCU's SPI peripheral.

    #include "driver/spi_master.h"

    #define SPI_MOSI_PIN GPIO_NUM_23 #define SPI_MISO_PIN GPIO_NUM_19 #define SPI_CLK_PIN GPIO_NUM_18 #define ADC_CS_PIN GPIO_NUM_5

    spi_device_handle_t adc_handle;

    void spi_adc_init(void) { spi_bus_config_t bus_cfg = { .mosi_io_num = SPI_MOSI_PIN, .miso_io_num = SPI_MISO_PIN, .sclk_io_num = SPI_CLK_PIN, .quadwp_io_num = -1, .quadhd_io_num = -1, .max_transfer_sz = 32, }; spi_bus_initialize(SPI2_HOST, &bus_cfg, SPI_DMA_CH_AUTO);

    spi_device_interface_config_t dev_cfg = { .clock_speed_hz = 1 * 1000 * 1000, // 1 MHz for MCP3204 .mode = 0, // CPOL=0, CPHA=0 .spics_io_num = ADC_CS_PIN, .queue_size = 4, }; spi_bus_add_device(SPI2_HOST, &dev_cfg, &adc_handle); }

    uint16_t spi_adc_read_channel(uint8_t channel) { // MCP3204 12-bit ADC protocol: start bit + SGL/DIFF + channel uint8_t tx[3] = {0x06 | (channel >> 2), (channel & 0x03) << 6, 0x00}; uint8_t rx[3] = {0};

    spi_transaction_t t = { .length = 24, // bits .tx_buffer = tx, .rx_buffer = rx, }; spi_device_transmit(adc_handle, &t);

    // MCP3204 result is in bits 11:0 of the last two bytes return ((rx[1] & 0x0F) << 8) | rx[2]; }

    When to use SPI:

  • High-speed ADC, DAC, or display (TFT, e-ink)
  • SPI flash/SRAM for data logging
  • Anything needing >400 kHz throughput
  • When you have dedicated GPIO pins for CS per device
  • I2C: Many Devices, Two Wires

    I2C shines when you have a cluster of sensors — IMU + barometer + humidity + light sensor — all on two shared wires. Each device has a 7-bit address baked into the hardware. The master sends an address with every transaction; only the addressed device responds.

    #include "driver/i2c.h"

    #define I2C_PORT I2C_NUM_0 #define SDA_PIN GPIO_NUM_21 #define SCL_PIN GPIO_NUM_22 #define I2C_FREQ_HZ 400000 // 400 kHz fast mode

    #define BME280_ADDR 0x76

    void i2c_bus_init(void) { i2c_config_t conf = { .mode = I2C_MODE_MASTER, .sda_io_num = SDA_PIN, .scl_io_num = SCL_PIN, .sda_pullup_en = GPIO_PULLUP_ENABLE, .scl_pullup_en = GPIO_PULLUP_ENABLE, .master.clk_speed = I2C_FREQ_HZ, }; i2c_param_config(I2C_PORT, &conf); i2c_driver_install(I2C_PORT, conf.mode, 0, 0, 0); }

    esp_err_t i2c_read_register(uint8_t dev_addr, uint8_t reg_addr, uint8_t *data, size_t len) { i2c_cmd_handle_t cmd = i2c_cmd_link_create();

    // Write register address i2c_master_start(cmd); i2c_master_write_byte(cmd, (dev_addr << 1) | I2C_MASTER_WRITE, true); i2c_master_write_byte(cmd, reg_addr, true);

    // Repeated start, then read i2c_master_start(cmd); i2c_master_write_byte(cmd, (dev_addr << 1) | I2C_MASTER_READ, true); i2c_master_read(cmd, data, len, I2C_MASTER_LAST_NACK); i2c_master_stop(cmd);

    esp_err_t ret = i2c_master_cmd_begin(I2C_PORT, cmd, pdMS_TO_TICKS(100)); i2c_cmd_link_delete(cmd); return ret; }

    Common I2C pitfall: Pull-up resistors are mandatory. The bus lines are open-drain. Without pull-ups, the bus floats. Use 4.7 kΩ for 100 kHz, 2.2 kΩ for 400 kHz. If you have many devices on the bus (high capacitance), you may need an I2C bus expander with active pull-ups.

    When to use I2C:

  • Multiple sensors sharing the same two wires
  • Short distances (<1m on PCB or internal connectors)
  • Low to medium data rates (sensor readings, not streaming data)
  • When GPIO pins are scarce
  • Mixing Protocols in Production Firmware

    Real products often use all three. A typical design:

  • I2C: BME280 (temperature/humidity/pressure), LSM6DSO (IMU), BH1750 (light) — all on one bus
  • SPI: W25Q128 SPI flash for data logging, ILI9341 TFT display — separate CS pins
  • UART1: SIM7600 LTE modem for cellular connectivity
  • UART2: Debug console / firmware update over serial
  • Pair your protocol choice with the right DMA configuration. For SPI and UART at high throughput, always use DMA transfers to avoid blocking the CPU during data movement. See our guide on [FreeRTOS task scheduling](/freertos-esp32-task-scheduling) for how to structure protocol drivers as separate tasks.

    [Contact Code Caracal](/contact) — we build production firmware for clients across 15+ countries.

    Written by CodeCaracal Engineering

    We write from production experience — every technique in our articles has been deployed to real clients. No academic theory.

    More Articles

    Business · 12 min read

    IoT Device Compliance: FCC, CE, and Product Certification Guide for Hardware Startups

    Business · 11 min read

    What to Look for When Hiring an IoT Development Partner: 8 Critical Criteria

    Business · 11 min read

    IoT MVP to Production: Realistic Timeline and Budget for Hardware Startups

    Business · 11 min read

    IoT Development Agency vs Building In-House: A Decision Framework for Founders

    IoT Dashboard · 13 min read

    Next.js IoT Analytics Dashboard: From Sensor Data to Production App

    Business · 11 min read

    How Much Does It Cost to Build an IoT Product in 2024? A Realistic Breakdown

    IoT Dashboard · 11 min read

    IoT Dashboard UX: Design Principles for Industrial Monitoring Interfaces

    IoT Dashboard · 12 min read

    Node.js WebSocket Server: The Real-Time Backend for IoT Dashboards

    Cloud & DevOps · 12 min read

    Containerizing IoT Backend Services with Docker: From Dev to Production

    IoT Dashboard · 14 min read

    Grafana + InfluxDB IoT Monitoring: Complete Production Setup Guide

    IoT Dashboard · 12 min read

    Building Real-Time IoT Dashboards with React and Recharts

    Cloud & DevOps · 13 min read

    CI/CD for Embedded Firmware: Automated Build, Test, and OTA Release Pipeline

    Mobile Development · 12 min read

    Flutter Offline-First IoT Apps: Hive + Sync Architecture That Works in the Field

    Cloud & DevOps · 14 min read

    Terraform for IoT Infrastructure: Provisioning AWS IoT Core, Lambda, and InfluxDB as Code

    Mobile Development · 10 min read

    Flutter IoT Alerts: Firebase Push Notifications for Device Events

    Cloud & DevOps · 12 min read

    Deploying IoT Backends on AWS: ECS Fargate vs Lambda vs EC2 Decision Guide

    Mobile Development · 11 min read

    Flutter + MQTT: Building Production IoT Mobile Apps That Scale

    Mobile Development · 13 min read

    Flutter BLE: Building a Bluetooth IoT Controller App from Scratch

    Cloud & DevOps · 13 min read

    AWS IoT Core vs Azure IoT Hub vs Google Cloud IoT: 2024 Honest Comparison

    IoT Engineering · 13 min read

    Kafka vs RabbitMQ for IoT: Choosing the Right Message Queue for High-Volume Telemetry

    IoT Engineering · 14 min read

    IoT System Testing: Unit, Integration, Hardware-in-the-Loop, and End-to-End

    IoT Engineering · 14 min read

    Predictive Maintenance with IoT Sensor Data: From Threshold to Machine Learning

    Embedded Systems · 14 min read

    IoT Bootloader Design: Secure Boot, A/B Partitions, and Reliable OTA Recovery

    IoT Engineering · 14 min read

    Multi-Tenant IoT Platform Architecture: Isolation, Scaling, and Data Partitioning

    Embedded Systems · 14 min read

    Memory Management in Embedded Firmware: Avoiding Heap Fragmentation and Stack Overflows

    IoT Engineering · 13 min read

    IoT Cost Optimization: How We Cut AWS IoT Bills by 60% Without Sacrificing Reliability

    IoT Engineering · 12 min read

    Edge Computing in IoT: When to Process On-Device vs In the Cloud

    IoT Engineering · 13 min read

    Digital Twins for IoT: Building a Virtual Mirror of Your Physical Devices

    Embedded Systems · 14 min read

    ESP32 Deep Sleep Mastery: Cutting Power Consumption from 240mA to 10µA

    IoT Engineering · 10 min read

    MQTT QoS 0, 1, and 2 Explained: Choosing the Right Level for IoT

    IoT Engineering · 14 min read

    IoT Monitoring and Observability: Metrics, Logs, and Distributed Tracing

    Embedded Systems · 14 min read

    Debugging Embedded Firmware: JTAG, GDB, Logic Analyzers, and Serial Tracing

    IoT Engineering · 12 min read

    WebSocket vs MQTT vs Server-Sent Events: Real-Time IoT Protocol Deep Dive

    Embedded Systems · 13 min read

    STM32 HAL vs Low-Level Drivers: When the Abstraction Costs You Too Much

    IoT Engineering · 13 min read

    IoT Data Pipeline: From Raw Sensor Reading to Live Dashboard in Under 100ms

    IoT Engineering · 13 min read

    Zero-Touch IoT Device Provisioning: Scaling from 10 to 100,000 Devices

    IoT Engineering · 12 min read

    Real-Time IoT Alerting: From Simple Thresholds to ML Anomaly Detection

    Embedded Systems · 12 min read

    ESP32 Partition Table: Designing Flash Layout for Production Firmware

    IoT Engineering · 12 min read

    IoT Architecture Patterns: Hub-and-Spoke, Mesh, and Edge-Cloud Hybrid

    Embedded Systems · 13 min read

    IoT Battery Life Optimization: Engineering Devices That Last Years on a Single Charge

    IoT Engineering · 13 min read

    Time-Series Databases for IoT: InfluxDB vs TimescaleDB vs AWS Timestream

    Security · 14 min read

    Zero-Trust Security for Embedded IoT: Why Your Devices Are Probably Vulnerable

    Embedded Systems · 14 min read

    FreeRTOS on ESP32: Task Scheduling, Queues, and Resource Management for IoT

    IoT Engineering · 12 min read

    Building a Production IoT Gateway with Raspberry Pi and Node.js

    Embedded Systems · 13 min read

    ESP32 vs STM32: Choosing the Right Microcontroller for Your IoT Project

    Mobile Development · 10 min read

    Flutter + WebSocket: Building Real-Time IoT Dashboards That Don't Stutter

    IoT Engineering · 13 min read

    IoT Fleet Management at Scale: AWS IoT Core Device Registry and Provisioning

    IoT Engineering · 11 min read

    MQTT vs HTTP for IoT: Which Protocol Wins in Production?

    IoT Engineering · 12 min read

    ESP32 → MQTT → AWS IoT Core: The Production-Grade Architecture Guide

    Let's Build Together

    Got an IoT challenge?
    We've shipped it.

    Whether you need a fleet to track, a factory to monitor, or a farm to automate — our team has done it before and we'd love to build it with you. Typical response time: under 24 hours.

    No upfront commitment99.9% uptime SLANDA on requestFixed-price options