9. Embedded System Interfaces
Learning Objectives
- Compare UART, SPI, and I2C on wiring, speed, and multi-device support
- Explain how SPI's clock and chip-select lines coordinate data transfer, with a working code example
- Explain how I2C addresses multiple devices on just two wires
- Describe CAN bus arbitration and why it's used in automotive systems instead of simpler serial protocols
- Distinguish digital I/O from analog I/O and explain when each is appropriate
- Analyze a wiring/protocol mismatch scenario and identify the likely communication failure
Quick Answer
Embedded system interfaces are the physical and protocol-level connections that let a microcontroller exchange data with sensors, actuators, other chips, and networks. The choice of interface is rarely arbitrary: UART is simple and asynchronous but limited to point-to-point links; SPI is fast and simple to implement but needs a dedicated chip-select wire per device; I2C uses only two wires and supports many devices via addressing but is slower; and CAN bus adds robust multi-master arbitration for noisy, safety-relevant environments like automotive networks. Choosing the wrong interface for a given constraint — say, I2C when you need CAN's electrical robustness on a noisy factory floor — causes real, hard-to-diagnose communication failures, which is why understanding each protocol's actual mechanics (not just its name) matters for real design work.
UART: Asynchronous Point-to-Point
UART (Universal Asynchronous Receiver/Transmitter) sends data one bit at a time over a single wire (per direction), with no shared clock signal — both sides must independently agree on the timing (the baud rate) in advance.
// Arduino/AVR UART example
void setup() {
Serial.begin(9600); // both sides MUST agree on 9600 baud
}
void loop() {
Serial.println("Sensor OK");
delay(1000);
}
Because there's no shared clock, if one side is configured for 9600 baud and the other for 19200, the receiver misinterprets bit timing and receives garbage characters — a classic first debugging step whenever UART output looks like noise on a terminal. UART is simple and universally supported but strictly point-to-point: connecting three devices on one UART bus doesn't work without additional multiplexing hardware.
SPI: Fast Synchronous Bus with Chip Select
SPI (Serial Peripheral Interface) uses four signals: MOSI (Master Out Slave In), MISO (Master In Slave Out), SCK (shared clock), and CS/SS (chip select, one per slave device). Because the clock is shared, both sides know exactly when to sample each bit — this is what makes SPI faster than UART, often reaching tens of MHz.
#include <SPI.h>
#define CS_PIN 10
void setup() {
pinMode(CS_PIN, OUTPUT);
digitalWrite(CS_PIN, HIGH); // deselect device by default (active-low CS)
SPI.begin();
}
uint8_t read_register(uint8_t reg_address) {
digitalWrite(CS_PIN, LOW); // select this specific device
SPI.transfer(reg_address | 0x80); // send read command (MSB set = read, by convention)
uint8_t value = SPI.transfer(0x00); // clock out dummy byte to receive response
digitalWrite(CS_PIN, HIGH); // deselect
return value;
}
Each additional SPI device needs its own dedicated CS pin — with five sensors, you need five CS lines, even though MOSI/MISO/SCK are shared by all of them. This is SPI's main scaling limitation: pin count grows linearly with device count, which is why I2C is often preferred once you have many devices.
I2C: Two-Wire Multi-Device Bus
I2C (Inter-Integrated Circuit) uses just two wires — SDA (data) and SCL (clock) — and supports multiple devices on the same bus by giving each device a unique 7-bit (or 10-bit) address. The master specifies which device it wants to talk to as part of every transaction.
#include <Wire.h>
#define SENSOR_ADDRESS 0x48 // device's I2C address, from its datasheet
void setup() {
Wire.begin(); // join I2C bus as master
}
int16_t read_temperature(void) {
Wire.beginTransmission(SENSOR_ADDRESS);
Wire.write(0x00); // pointer to temperature register
Wire.endTransmission(false); // repeated start, keep bus held
Wire.requestFrom(SENSOR_ADDRESS, 2); // request 2 bytes
int16_t raw = (Wire.read() << 8) | Wire.read();
return raw >> 4; // this sensor returns a 12-bit value left-justified
}
Both SDA and SCL require pull-up resistors (typically 4.7kΩ) because I2C devices only ever pull the line low, never drive it high — without pull-ups, the bus never returns to a valid HIGH state and communication fails entirely. This is one of the most common I2C wiring mistakes: a breadboard I2C sensor that "doesn't work" is very often simply missing its pull-up resistors (many breakout boards include them, but bare sensor modules frequently don't).
CAN Bus: Arbitration for Noisy, Safety-Critical Networks
CAN (Controller Area Network) is a multi-master bus designed for automotive and industrial environments where electrical noise and reliability requirements exceed what UART/SPI/I2C are built for. Its defining feature is bitwise arbitration: when two nodes transmit simultaneously, each monitors the bus while sending; a node sending a "recessive" bit (1) that sees a "dominant" bit (0) actually on the bus immediately stops transmitting and backs off, letting the higher-priority message (lower numeric ID) win without any collision or data loss — unlike Ethernet, where a collision requires both sides to retransmit.
This is precisely why CAN is the standard for automotive ECUs: a safety-critical message (e.g., from the airbag ECU) can be given a low numeric ID, guaranteeing it always wins arbitration over a lower-priority message (e.g., a periodic dashboard update), with zero risk of a collision corrupting either message.
Digital I/O vs. Analog I/O
Not every interface is a multi-wire protocol — the simplest interfaces are single-pin digital and analog I/O:
- Digital I/O: reads or drives a pin as one of exactly two states (HIGH/LOW). Used for buttons, LEDs, relays, and any signal that is inherently binary.
- Analog I/O: an ADC (Analog-to-Digital Converter) converts a continuously variable voltage (e.g., from a potentiometer or analog temperature sensor) into a digital number; a DAC does the reverse.
int raw = analogRead(A0); // 0-1023 on a 10-bit ADC (Arduino Uno)
float voltage = raw * (5.0 / 1023.0); // convert ADC count to actual voltage
A common beginner mistake is treating an analog sensor's raw ADC reading as a physical unit directly — the raw value must always be converted using the ADC's reference voltage and resolution before it means anything (as shown above), and further converted using the specific sensor's datasheet formula to get a real-world unit like temperature or distance.
Why It Matters
Picking the wrong interface for the constraints causes real, often confusing failures: I2C without pull-up resistors simply doesn't work; SPI without a unique CS pin per device causes bus contention where multiple devices try to respond simultaneously; UART with mismatched baud rates produces garbled characters; and using I2C in an electrically noisy automotive environment instead of CAN risks corrupted safety-critical messages with no arbitration protection. Interface selection is a design decision with real electrical and protocol consequences, not an interchangeable implementation detail.
Key Terms
| Term | Definition | Related Concept |
|---|---|---|
| Baud rate | The agreed-upon bit transmission speed for asynchronous protocols like UART | UART |
| MOSI/MISO/SCK/CS | The four SPI signal lines: data out, data in, shared clock, and per-device chip select | SPI |
| I2C address | A unique 7-bit (or 10-bit) identifier letting a master select one of many devices on a shared 2-wire bus | I2C, SDA/SCL |
| Pull-up resistor | A resistor ensuring a line returns to a defined HIGH state when not actively pulled low; required on I2C | I2C, open-drain |
| CAN arbitration | The bitwise process by which the message with the lowest numeric ID wins bus access with zero collision | CAN bus |
| ADC | Analog-to-Digital Converter — converts a continuous voltage into a digital number | Analog I/O |
| DAC | Digital-to-Analog Converter — converts a digital number into a continuous voltage | Analog I/O |
Common Mistakes
Misconception: I2C doesn't need pull-up resistors because it's a "digital" protocol like SPI. Why it's wrong: I2C devices are open-drain — they can only pull SDA/SCL low, never drive them high — so without external pull-up resistors, the bus never returns to a valid HIGH logic level and communication fails entirely, regardless of correct software. Correct understanding: I2C absolutely requires pull-up resistors (typically 4.7kΩ) on both SDA and SCL; many breakout boards include them onboard, but bare sensor ICs typically do not.
Misconception: SPI is strictly "better" than I2C because it's faster, so it should always be preferred. Why it's wrong: SPI requires a dedicated chip-select pin per device, so connecting many devices consumes many GPIO pins; I2C uses just two wires regardless of device count, at the cost of lower speed. Correct understanding: SPI wins when speed matters and device count is small; I2C wins when pin count is constrained and many devices need to share a bus — the "better" choice depends entirely on the specific constraints.
Misconception: A raw analogRead() value directly represents the physical quantity being measured (e.g., temperature in degrees).
Why it's wrong: The raw ADC value is just a count between 0 and the ADC's maximum resolution (e.g., 0-1023 for 10 bits), representing a fraction of the reference voltage — it must be converted to an actual voltage first, and then converted again using the specific sensor's datasheet formula to yield a real physical unit.
Correct understanding: Always convert: raw ADC count → voltage (using reference voltage and resolution) → physical quantity (using the sensor's specific conversion formula).
Comparison and Connections
| Aspect | UART | SPI | I2C | CAN |
|---|---|---|---|---|
| Wire count | 2 (Tx/Rx) | 4 + 1 per device (CS) | 2 (shared, any device count) | 2 (differential pair) |
| Max devices per bus | 2 (point-to-point) | Limited by available CS pins | 112+ (7-bit addressing) | Many (arbitration-based) |
| Typical speed | Up to ~1 Mbps | Up to tens of MHz | 100kHz–3.4MHz | Up to 1 Mbps (classic CAN) |
| Collision handling | None needed (point-to-point) | None needed (dedicated CS) | Simple clock stretching | Bitwise arbitration, zero data loss |
| Noise robustness | Low | Low | Moderate | High (differential signaling) |
| Typical use | Debug console, GPS module | Flash memory, display, high-speed sensors | Multiple low-speed sensors on limited pins | Automotive/industrial networks |
Practice Questions
Recall
-
Name the four SPI signal lines and what each carries. Answer guidance: MOSI (master to slave data), MISO (slave to master data), SCK (shared clock), CS/SS (chip select, one per device).
-
Why does I2C require pull-up resistors on SDA and SCL? Answer guidance: I2C devices are open-drain and can only actively pull the lines low; pull-up resistors are needed to return the lines to a valid HIGH state when no device is pulling them low.
Understanding
-
Explain why SPI can achieve much higher speeds than UART. Answer guidance: SPI has a shared clock line (SCK) so both sides know exactly when to sample each bit, eliminating the timing-agreement uncertainty of UART's asynchronous approach, which limits how fast bits can be reliably distinguished without a shared clock reference.
-
Explain how CAN bus arbitration allows two nodes to transmit "simultaneously" without a collision corrupting either message. Answer guidance: Each transmitting node monitors the actual bus level while sending; if a node sends a recessive bit (1) but observes a dominant bit (0) actually on the bus, it knows a higher-priority (lower ID) message is being sent and immediately stops transmitting, so only the highest-priority message continues uninterrupted — no data is corrupted.
Application
-
You need to connect eight temperature sensors to an MCU that only has six free GPIO pins to spare beyond power/ground. Which interface should you choose, and why? Answer guidance: I2C — it needs only two shared wires (SDA, SCL) regardless of how many sensors are attached, as long as each sensor has (or can be configured with) a unique address, fitting easily within six spare pins; SPI would need eight dedicated CS pins alone, exceeding what's available.
-
A student wires an I2C temperature sensor directly to an Arduino with no additional components and gets no response. Diagnose the most likely issue and the fix. Answer guidance: Missing pull-up resistors on SDA and SCL — without them the bus lines float and never reach a valid HIGH state. Fix: add 4.7kΩ pull-up resistors from each line to the supply voltage (or confirm the Arduino's internal pull-ups are enabled if the library supports it).
Analysis
-
Compare why an automotive airbag system uses CAN bus rather than I2C for inter-ECU communication, considering both the electrical environment and the message priority requirements. Answer guidance: A car's electrical environment is electrically noisy (motors, ignition system), and CAN's differential signaling is far more robust to this than I2C's single-ended lines. Additionally, CAN's arbitration guarantees a safety-critical airbag message (given a low numeric ID) always wins bus access instantly without collision, whereas I2C has no equivalent priority mechanism for guaranteeing time-critical message delivery under contention.
-
A design uses UART to connect three sensors to one MCU UART port using a simple wired-together connection. Analyze why this doesn't work and what interface or hardware change would fix it. Answer guidance: UART is inherently point-to-point — with three devices wired to the same Rx/Tx lines, all three would attempt to drive the line simultaneously, corrupting data with no arbitration or addressing mechanism to resolve the conflict. Fix: switch to I2C (built-in addressing) or SPI (separate CS lines), or add a UART multiplexer/hub if UART must be retained.
FAQ
Why does my I2C device work on a breakout board but not when I wire the bare chip myself? Breakout boards frequently include the required 4.7kΩ pull-up resistors on SDA and SCL onboard; wiring a bare IC directly without adding these resistors leaves the bus lines floating and non-functional. Always check the bare chip's datasheet for pull-up requirements.
Can I connect an SPI device and an I2C device to the same microcontroller at the same time? Yes — most MCUs have dedicated SPI and I2C peripheral hardware (often multiple instances of each) that operate independently and can run simultaneously on different pins, as long as you initialize and use each peripheral's own pins and library calls correctly.
Why is CAN used in cars but not in typical consumer electronics like a smart thermostat? CAN's strengths — noise immunity via differential signaling and priority-based arbitration — solve problems specific to automotive environments (many ECUs, electrical noise, safety-critical timing). A thermostat has none of these requirements, so the added cost and complexity of CAN transceivers isn't justified when I2C or a simple wireless protocol suffices.
What determines the maximum I2C bus speed I can actually use? The slowest device on the shared bus sets the practical ceiling — if one sensor only supports 100kHz standard mode, the entire bus (all devices sharing those same SDA/SCL lines) must be configured at 100kHz, even if other devices on the bus support faster modes.
Why do ADC readings sometimes seem noisy or jump around even when the sensor's physical value is stable? Common causes include electrical noise on the analog input line, an unstable reference voltage, or inadequate decoupling capacitors near the ADC's supply pins. Averaging multiple consecutive readings in software is a common and effective mitigation for this kind of small-amplitude noise.
Quick Revision
- UART: point-to-point, asynchronous, both sides must agree on baud rate in advance
- SPI: fast, synchronous (shared clock), needs one dedicated chip-select pin per device
- I2C: two shared wires (SDA/SCL) support many devices via unique addresses; requires pull-up resistors
- CAN: multi-master bus with bitwise arbitration; lowest numeric ID always wins with zero collision or data loss
- I2C pull-up resistors are mandatory because I2C devices are open-drain (can only pull low, never drive high)
- SPI pin count scales with device count (one CS each); I2C pin count stays fixed at two regardless of device count
- CAN's differential signaling and arbitration make it the standard for noisy, safety-critical automotive/industrial networks
- Digital I/O reads/drives binary HIGH/LOW states; analog I/O uses ADC/DAC to handle continuously variable voltages
- Raw ADC values must be converted through voltage, then through a sensor-specific formula, to get a real physical unit
- Choosing the wrong interface for wire-count, speed, or noise-robustness constraints causes real, diagnosable communication failures
Related Topics
Prerequisites: Introduction to Embedded Systems, Embedded System Programming
Related Topics: Embedded System Architecture, Debugging Embedded Systems
Next Topics: Future Trends in Embedded Systems, Embedded System Applications