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:
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:
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:
Mixing Protocols in Production Firmware
Real products often use all three. A typical design:
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.