15 Sensor Interfacing Protocols
The mathematical gist. I2C rise time is . With the chapter’s 4.7 kΩ pull-up and 400 pF ceiling, s and s, too slow even for the 1,000 ns Standard-mode limit. At the realistic 60 pF chapter case, ns and ns, inside the 300 ns Fast-mode limit; at 400 pF, Fast mode instead needs .
Start with I2C (Inter-Integrated Circuit): A two-wire serial protocol (SDA data, SCL clock) supporting multiple devices on one bus using 7-bit addresses; standard speed 100 kHz, fast mode 400 kHz, fast-plus 1 MHz. Then SPI (Serial Peripheral Interface): A four-wire full-duplex protocol (MOSI, MISO, SCK, CS) offering higher speed than I2C (up to tens of MHz) but requiring a dedicated chip-select line per device. Next UART (Universal Asynchronous Receiver/Transmitter): An asynchronous two-wire protocol (TX, RX) using agreed baud rates; simple and universal but limited to point-to-point connections with no inherent bus topology. After that 1-Wire: A single-wire protocol (plus ground) supporting multiple addressable devices; commonly used by DS18B20 temperature sensors; slow but minimal wiring. Continue by Pull-Up Resistors on I2C: I2C SDA and SCL lines use open-drain signaling and require external pull-up resistors (typically 4.7 kohm at 100 kHz) to define the HIGH state between transactions. Continue by Address Conflicts: Two I2C devices with the same address on the same bus cause data corruption. Check all sensor addresses before designing a multi-sensor board; some sensors offer address-select pins. Continue by Clock Stretching: An I2C feature where a slow slave holds SCL low to pause the master while preparing data; not all masters support it — check the microcontroller documentation before relying on this feature. Finally Logic Level Compatibility: Mixing 3.3 V and 5 V devices on the same I2C or SPI bus can damage 3.3 V inputs. Use bidirectional level shifters or verified series-resistor approaches when mixing voltage domains.
15.2 Learning Objectives
By the end of this chapter, you will be able to:
- Differentiate between I2C, SPI, and UART communication protocols based on wiring, speed, and topology
- Configure an I2C bus with correct pull-up resistors and address assignments for multi-sensor applications
- Implement SPI communication with proper mode selection for high-speed sensor data transfer
- Diagnose common protocol issues including address conflicts, missing pull-ups, and SPI mode mismatches
- Evaluate and justify the appropriate protocol for specific sensor requirements and constraints
15.3 Introduction
Sensor communication protocols are the foundation of IoT data acquisition. Every sensor reading must travel from the physical sensor to your microcontroller through a defined communication interface. Understanding these protocols enables you to design reliable, efficient sensor networks.
Think of communication protocols like different languages. Just as people need to speak the same language to understand each other, sensors and microcontrollers need to “speak” the same protocol to exchange data. The most common “languages” are I2C (pronounced “eye-squared-see” or “eye-two-see”), SPI (“spy”), and UART (“you-art”). Each has different rules about how many wires to use, how fast to talk, and how to address specific devices.
15.4 I2C Communication Protocol
I2C (Inter-Integrated Circuit) is a two-wire synchronous protocol perfect for connecting multiple sensors using minimal GPIO pins. It uses:
Start with SDA (Serial Data): Bidirectional data line. Then SCL (Serial Clock): Clock signal from master. Next 7-bit addressing: Up to 112 usable device addresses (128 total, 16 reserved). Finally Pull-up resistors: Required on both lines.
15.4.1 I2C Protocol Sequence
Before writing bus code, separate transaction order from physical topology. Start with Figure 15.1 and trace the exact control sequence used to read a register, paying particular attention to who drives each phase and where the direction changes.
Read Figure 15.1 from START to STOP. The controller addresses the sensor for writing, selects a register, issues a repeated START, addresses it for reading, accepts the returned byte, and ends the transfer with NACK and STOP. The repeated START preserves one logical transaction while reversing direction; this sequence is the protocol meaning behind the API calls used later.
Now use Figure 15.2 to place that transaction on the wires. Look first at the shared SDA and SCL rails, then the pull-ups, and finally the uniquely addressed devices attached in parallel.
Read Figure 15.2 across the shared conductors: every device uses both, so identity comes from the address rather than a dedicated chip-select wire. The pull-ups establish the idle-high state because participants pull the open-drain lines low rather than driving them high. That shared electrical boundary explains why duplicate addresses, excessive bus capacitance, or missing pull-ups can defeat an otherwise correct transaction sequence.
15.4.2 Key I2C Protocol Elements
Start with START Condition: SDA goes LOW while SCL is HIGH (signals transaction start). Then STOP Condition: SDA goes HIGH while SCL is HIGH (signals transaction end). Next ACK (Acknowledge): Receiver pulls SDA LOW during 9th clock pulse (data received successfully). After that NACK (Not Acknowledge): Receiver leaves SDA HIGH (last byte or error). Finally Repeated START: START without preceding STOP (direction change from write to read).
15.4.3 I2C Implementation Example
Reading sensor data over I2C follows a two-phase pattern: first write the register address you want to read, then request the data bytes. The endTransmission(false) sends a Repeated START instead of a STOP, keeping the bus locked for the subsequent read.
For learning, trace the transaction before reading code:
Start by Send START. Then Send the sensor address with the write bit. Next Send the register address you want to read. After that Send REPEATED START. Continue by Send the sensor address with the read bit. Continue by Read the data bytes. Finally Send STOP.
15.4.4 Optional C++ Pattern
#include <Wire.h>
#define I2C_SDA 21
#define I2C_SCL 22
#define BMP280_ADDR 0x76
#define BMP280_CHIP_ID_REG 0xD0
void setup() {
Serial.begin(115200);
Wire.begin(I2C_SDA, I2C_SCL);
// Read chip ID register to verify communication
uint8_t chipId;
readI2CRegister(BMP280_ADDR, BMP280_CHIP_ID_REG, &chipId, 1);
Serial.print("BMP280 Chip ID: 0x");
Serial.println(chipId, HEX); // Should print 0x58 for BMP280
}
void readI2CRegister(uint8_t addr, uint8_t reg, uint8_t* data, uint8_t len) {
Wire.beginTransmission(addr);
Wire.write(reg); // Phase 1: Write register address
Wire.endTransmission(false); // Repeated START (no STOP)
Wire.requestFrom(addr, len); // Phase 2: Read data bytes
for(int i = 0; i < len; i++) {
if(Wire.available()) {
data[i] = Wire.read();
}
}
}
void loop() { }
Estimate total bus capacitance to determine if your I2C bus will work reliably. The I2C specification limits bus capacitance to 400 pF for standard mode and fast mode.
This simulation demonstrates how I2C bus scanning works. Add devices to the bus, then click “Scan Bus” to see the master controller query each address and detect ACK/NACK responses.
Interactive element unavailable — mutable cell
OJS `mutable` requires the Observable reactive runtime
Show source
// Mutable state for devices on bus
mutable busDevices = []Interactive element unavailable — parse error
Unexpected token (8:31)
Show source
// Handle adding sensor (guard prevents auto-execute on page load)
{
const clicks = addSensorBtn;
if (clicks > 0) {
const sensor = i2cSensorDatabase.find(s => s.name === selectedSensor);
if (sensor) {
const addr = useAltAddress && sensor.altAddr ? sensor.altAddr : sensor.addr;
// Check if address already exists
const existing = mutable busDevices.find(d => d.addr === addr);
if (!existing) {
mutable busDevices = [...mutable busDevices, {
name: sensor.name,
addr: addr,
category: sensor.category
}];
}
}
}
}Interactive element unavailable — parse error
Unexpected token (4:12)
Show source
// Handle clearing bus (guard prevents auto-execute on page load)
{
const clicks = clearBusBtn;
if (clicks > 0) {
mutable busDevices = [];
mutable scanResults = null;
mutable scanInProgress = false;
mutable currentScanAddr = 0;
}
}Interactive element unavailable — mutable cell
OJS `mutable` requires the Observable reactive runtime
Show source
// Scan state
mutable scanResults = nullInteractive element unavailable — mutable cell
OJS `mutable` requires the Observable reactive runtime
Show source
mutable scanInProgress = falseInteractive element unavailable — mutable cell
OJS `mutable` requires the Observable reactive runtime
Show source
mutable currentScanAddr = 0Interactive element unavailable — mutable cell
OJS `mutable` requires the Observable reactive runtime
Show source
mutable scanLog = []Interactive element unavailable — parse error
Unexpected token (3:28)
Show source
// Handle scan (guard prevents auto-execute on page load)
{
const clicks = scanBusBtn;
if (clicks > 0 && mutable busDevices.length > 0) {
mutable scanInProgress = true;
mutable scanResults = null;
mutable scanLog = [];
mutable currentScanAddr = 0;
}
}Interactive element unavailable — parse error
Unexpected token (2:14)
Show source
// Simulate scanning animation
scanAnimation = {
if (mutable scanInProgress) {
const addresses = [0x20, 0x23, 0x27, 0x29, 0x3C, 0x3D, 0x40, 0x41, 0x44, 0x45, 0x48, 0x49, 0x53, 0x60, 0x68, 0x69, 0x76, 0x77];
let found = [];
let log = [];
for (let addr of addresses) {
const device = mutable busDevices.find(d => d.addr === addr);
if (device) {
found.push({ addr, name: device.name, ack: true });
log.push({ addr, ack: true, name: device.name });
} else {
log.push({ addr, ack: false, name: null });
}
}
mutable scanResults = found;
mutable scanLog = log;
mutable scanInProgress = false;
return found;
}
return null;
}Interactive element unavailable — parse error
Unexpected token (32:14)
Show source
// Visual I2C Bus Display
html`
<div style="background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); border-radius: 12px; padding: 20px; margin: 15px 0; font-family: 'Courier New', monospace;">
<!-- Bus Lines Header -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<div style="color: #16A085; font-weight: bold; font-size: 16px;">I2C Bus</div>
<div style="display: flex; gap: 20px;">
<div style="display: flex; align-items: center; gap: 5px;">
<div style="width: 40px; height: 4px; background: #E67E22; border-radius: 2px;"></div>
<span style="color: #E67E22; font-size: 12px;">SDA (Data)</span>
</div>
<div style="display: flex; align-items: center; gap: 5px;">
<div style="width: 40px; height: 4px; background: #3498DB; border-radius: 2px;"></div>
<span style="color: #3498DB; font-size: 12px;">SCL (Clock)</span>
</div>
</div>
</div>
<!-- Bus Lines -->
<div style="position: relative; height: 60px; margin: 20px 0;">
<div style="position: absolute; left: 0; right: 0; top: 15px; height: 4px; background: #E67E22; border-radius: 2px;"></div>
<div style="position: absolute; left: 0; right: 0; top: 40px; height: 4px; background: #3498DB; border-radius: 2px;"></div>
<!-- Master Controller -->
<div style="position: absolute; left: 10px; top: -20px; background: #2C3E50; border: 2px solid #16A085; border-radius: 8px; padding: 8px 12px; color: white; font-size: 11px; text-align: center;">
<div style="font-weight: bold;">ESP32</div>
<div style="color: #16A085; font-size: 10px;">Master</div>
</div>
<!-- Connected Devices -->
${mutable busDevices.map((device, i) => html`
<div style="position: absolute; left: ${120 + i * 100}px; top: -25px; background: #2C3E50; border: 2px solid #E67E22; border-radius: 8px; padding: 6px 10px; color: white; font-size: 10px; text-align: center; min-width: 80px;">
<div style="font-weight: bold; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 75px;">${device.name.split(' ')[0]}</div>
<div style="color: #E67E22; font-family: monospace;">0x${device.addr.toString(16).toUpperCase().padStart(2, '0')}</div>
</div>
`)}
</div>
<!-- Pull-up Resistors -->
<div style="display: flex; justify-content: flex-end; gap: 30px; margin: 30px 20px 10px 0; color: #7F8C8D; font-size: 11px;">
<div style="text-align: center;">
<div style="width: 20px; height: 30px; background: repeating-linear-gradient(0deg, transparent, transparent 2px, #7F8C8D 2px, #7F8C8D 4px); margin: 0 auto;"></div>
<div>4.7k</div>
<div style="color: #E67E22;">to VCC</div>
</div>
<div style="text-align: center;">
<div style="width: 20px; height: 30px; background: repeating-linear-gradient(0deg, transparent, transparent 2px, #7F8C8D 2px, #7F8C8D 4px); margin: 0 auto;"></div>
<div>4.7k</div>
<div style="color: #3498DB;">to VCC</div>
</div>
</div>
${mutable busDevices.length === 0 ? html`
<div style="text-align: center; color: #7F8C8D; padding: 20px; font-style: italic;">
No devices on bus. Add sensors using the dropdown above.
</div>
` : ''}
</div>
`Interactive element unavailable — parse error
Unexpected token (6:12)
Show source
// Scan Results Display
html`
<div style="background: #f8f9fa; border-radius: 8px; padding: 15px; margin: 15px 0; border-left: 4px solid #16A085;">
<div style="font-weight: bold; color: #2C3E50; margin-bottom: 10px;">Scan Results</div>
${mutable scanResults === null ? html`
<div style="color: #7F8C8D; font-style: italic;">Click "Scan I2C Bus" to detect devices...</div>
` : html`
<div style="font-family: 'Courier New', monospace; font-size: 13px;">
<div style="color: #2C3E50; margin-bottom: 10px;">Scanning I2C bus...</div>
${mutable scanLog.slice(0, 8).map(entry => html`
<div style="display: flex; align-items: center; gap: 10px; padding: 3px 0; ${entry.ack ? 'color: #16A085; font-weight: bold;' : 'color: #BDC3C7;'}">
<span style="width: 60px;">0x${entry.addr.toString(16).toUpperCase().padStart(2, '0')}</span>
<span style="width: 50px;">${entry.ack ? 'ACK' : '-- NACK'}</span>
<span>${entry.ack ? entry.name : ''}</span>
</div>
`)}
${mutable scanLog.length > 8 ? html`<div style="color: #7F8C8D;">... (${mutable scanLog.length - 8} more addresses checked)</div>` : ''}
<div style="margin-top: 15px; padding-top: 10px; border-top: 1px solid #dee2e6;">
<strong style="color: #16A085;">Found ${mutable scanResults.length} device(s):</strong>
${mutable scanResults.map(d => html`
<div style="margin-left: 10px; color: #2C3E50;">
- <code style="background: #e9ecef; padding: 2px 6px; border-radius: 3px;">0x${d.addr.toString(16).toUpperCase().padStart(2, '0')}</code> - ${d.name}
</div>
`)}
</div>
</div>
`}
</div>
`15.5 SPI Communication Protocol
SPI (Serial Peripheral Interface) is a synchronous, full-duplex communication protocol using four lines: MISO (Master In Slave Out), MOSI (Master Out Slave In), SCK (Serial Clock), and CS (Chip Select). Unlike I2C, SPI supports simultaneous bidirectional data transfer.
Before comparing SPI with I2C, inspect Figure 15.3 to assign every wire a direction and timing role. The transaction is easiest to understand when chip selection and clocking are separated from the two data paths.
Read Figure 15.3, begin with chip select, follow the clock edges, then trace MOSI from controller to peripheral and MISO in the reverse direction. Their simultaneous directions explain full-duplex transfer, while the dedicated select line connects the bus diagram to firmware transaction boundaries.
15.5.1 SPI vs I2C Comparison
| Feature | I2C | SPI |
|---|---|---|
| Wires | 2 (SDA, SCL) | 4+ (MISO, MOSI, SCK, CS per device) |
| Speed | 100 kHz - 3.4 MHz (standard/fast/high-speed) | 1-100 MHz |
| Topology | Multi-master, multi-slave | Single master (typical), multi-slave |
| Addressing | 7-bit addresses (112 usable) | Hardware CS pins (limited by GPIO) |
| Data Transfer | Half-duplex (sequential) | Full-duplex (simultaneous) |
| Protocol Overhead | START, STOP, ACK/NACK | Minimal (just CS selection) |
| Typical Use | Multiple sensors, displays | High-speed: SD cards, displays, ADCs |
| GPIO Efficiency | High (2 pins for many devices) | Low (3 shared + 1 CS per device) |
Calculate the effective data throughput for your SPI configuration based on clock speed and transaction overhead.
15.5.2 When to Choose Each Protocol
Choose I2C when:
Start by Multiple sensors needed (temperature, pressure, IMU, light). Then Limited GPIO pins available. Next Moderate data rates sufficient (<50 kB/s). Finally Moderate cable length (limited by 400 pF bus capacitance; typically 0.5-2 meters depending on number of devices and wire type).
Choose SPI when:
Start by High-speed data transfer required (>1 MB/s). Then Large data blocks (SD cards, displays). Next Real-time requirements (<1 ms latency). Finally Plenty of GPIO pins available.
Option A: I2C bus (BME280 + BH1750 + MPU6050): Wire count 2 (SDA, SCL shared), GPIO usage 2 pins total for 3+ sensors, max speed 400kHz (~44kB/s), read latency ~100-200us per sensor at 400kHz (address + register overhead), power during transfer ~1mA, cable length up to 1-3 meters with 4.7k pull-ups
Option B: SPI bus (BME280 + SD card + TFT display): Wire count 3 shared + 1 CS per device = 6 pins for 3 devices, max speed 10-40MHz (1-5MB/s), read latency ~10us per sensor (direct register access), power during transfer ~5-10mA (higher clock), cable length 10-30cm max at high speeds
Decision Factors: For battery-powered environmental monitoring nodes with 3-5 slow sensors, I2C saves pins and power while 400kHz is adequate for 100Hz sensor reads. For data logging to SD card (500kB/s sustained), displays (30fps video), or high-speed ADCs (1MSPS), SPI is mandatory. Hybrid approach: use I2C for slow sensors, SPI for SD/display. Watch for I2C address conflicts - BME280 has only 2 addresses (0x76, 0x77), limiting you to 2 per bus without multiplexer.
15.6 UART Serial Communication
UART (Universal Asynchronous Receiver/Transmitter) is the simplest serial protocol, using just two wires for point-to-point communication without a clock signal. Unlike I2C and SPI, UART is asynchronous — both sides must agree on the baud rate (bits per second) beforehand.
15.6.1 UART Key Characteristics
Start with TX (Transmit) and RX (Receive): Two unidirectional lines (cross-connected between devices). Then Asynchronous: No shared clock; both devices must use the same baud rate. Next Point-to-point only: Connects exactly two devices (no bus topology). After that Common baud rates: 9600, 19200, 38400, 57600, 115200 bps. Finally Frame format: Start bit + 8 data bits + optional parity + 1-2 stop bits.
15.6.2 When to Use UART
UART is commonly used for GPS modules (NMEA sentences at 9600 bps), Bluetooth modules (HC-05 AT commands), GSM/cellular modems, and debug/logging output. Many sensors provide UART as a simpler alternative to I2C/SPI, though at lower data rates.
15.6.3 Optional ESP32 UART Example
Open this only after you understand the wiring rule: sensor TX connects to MCU RX, sensor RX connects to MCU TX, and both devices must use the same baud rate.
// Reading GPS data over UART (Serial2 on ESP32)
#define GPS_RX 16
#define GPS_TX 17
void setup() {
Serial.begin(115200); // USB debug output
Serial2.begin(9600, SERIAL_8N1, GPS_RX, GPS_TX); // GPS at 9600 baud
}
void loop() {
while (Serial2.available()) {
char c = Serial2.read();
Serial.print(c); // Forward GPS NMEA sentences to USB
}
}
Calculate the effective data throughput for a UART connection based on baud rate and frame configuration.
15.6.4 I2C vs SPI vs UART Quick Reference
| Feature | I2C | SPI | UART |
|---|---|---|---|
| Wires | 2 | 3 + 1/device | 2 |
| Topology | Bus (multi-device) | Bus (multi-device) | Point-to-point |
| Clock | Synchronous (shared) | Synchronous (shared) | Asynchronous (agreed) |
| Speed | 100 kHz - 3.4 MHz | 1-100 MHz | 9600 - 921600 bps |
| Duplex | Half-duplex | Full-duplex | Full-duplex |
| Best For | Many slow sensors | High-speed data | Simple serial devices |
15.7 Bus Protocol Diagnostics
The troubleshooting, bus-budget, and worked-example material is now a focused child chapter instead of a second treatment inside this page.
- Protocol diagnostics and bus design: I2C pull-ups, SPI timing, address conflicts, multi-sensor bus design, and practice activities.
15.8 Summary
Key protocol takeaways:
Start with I2C saves pins - Use it when many low-to-moderate-speed sensors can share SDA and SCL. Then SPI buys speed and determinism - Use it when bandwidth, full-duplex transfer, or device-specific chip-select timing matters. Next UART is point-to-point and simple - Use it for modules that stream text or binary frames over TX/RX. Finally Diagnostics deserve their own pass - Move to the child chapter when the design depends on pull-up sizing, bus capacitance, SPI modes, or address conflicts.
