No embedded device I’ve built in the last decade has been an island. Even the simplest sensor node eventually needs to talk to something — another chip on the same board, a gateway across the room, or a cloud server across the world. What surprised me early in my career is just how many different communication layers are usually stacked on top of each other to make that happen reliably. In this article I’ll walk through the major communication protocols embedded systems use, how they’re implemented at the hardware and firmware level, and how to choose between them.
Layers of Embedded Communication
graph TD
A[Application Layer - MQTT, HTTP, Modbus] --> B[Transport Layer - TCP/UDP or protocol-specific framing]
B --> C[Network Layer - IP, LoRaWAN, Thread]
C --> D[Physical/Link Layer - UART, SPI, I2C, CAN, Wi-Fi, BLE, LoRa radio]
Not every embedded system needs all four layers — a simple sensor talking to its own MCU over I2C only needs the physical/link layer. A cloud-connected IoT device needs the full stack.
On-Board Communication: I2C, SPI, UART
These three protocols handle chip-to-chip communication within a single PCB, and I use them constantly.
I2C (Inter-Integrated Circuit)
Two-wire (SDA/SCL) multi-drop bus, allowing many devices to share the same two lines using addressing.
sequenceDiagram
participant MCU as Microcontroller (Master)
participant S1 as Sensor 1 (Addr 0x44)
participant S2 as Sensor 2 (Addr 0x68)
MCU->>S1: START + Address 0x44 + Write
S1-->>MCU: ACK
MCU->>S1: Register Address
S1-->>MCU: ACK
MCU->>S1: START (repeated) + Address 0x44 + Read
S1-->>MCU: Data bytes
MCU->>S2: START + Address 0x68 + Write
Note over MCU,S2: Same two wires, different address
/* Simplified I2C register write, common HAL pattern */
HAL_StatusTypeDef I2C_WriteRegister(uint8_t dev_addr, uint8_t reg, uint8_t value)
{
uint8_t buf[2] = {reg, value};
return HAL_I2C_Master_Transmit(&hi2c1, dev_addr << 1, buf, 2, HAL_MAX_DELAY);
}
I2C is great for low-speed sensors (accelerometers, temperature sensors, EEPROMs) where wire count matters more than speed. It tops out around 400kHz-1MHz (Fast/Fast+ mode) in most practical designs.
SPI (Serial Peripheral Interface)
Four-wire, full-duplex, much faster than I2C (often 10-50+ MHz), used for displays, external flash, ADCs, and radios.
uint8_t SPI_ReadRegister(uint8_t reg)
{
uint8_t tx[2] = {reg | 0x80, 0x00}; /* MSB set = read command, protocol-specific */
uint8_t rx[2];
HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); /* assert chip select */
HAL_SPI_TransmitReceive(&hspi1, tx, rx, 2, HAL_MAX_DELAY);
HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); /* deassert chip select */
return rx[1];
}
UART (Universal Asynchronous Receiver-Transmitter)
Simple point-to-point serial link, no shared clock line, commonly used for debug consoles, GPS modules, and communication with other MCUs or modems.
void UART_SendString(const char *str)
{
HAL_UART_Transmit(&huart2, (uint8_t *)str, strlen(str), HAL_MAX_DELAY);
}
/* Interrupt-driven receive is far more common in real firmware than polling */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART2) {
process_received_byte(rx_byte);
HAL_UART_Receive_IT(&huart2, &rx_byte, 1); /* re-arm for next byte */
}
}
Comparison Table
| Protocol | Wires | Speed | Topology | Typical Use |
|---|---|---|---|---|
| I2C | 2 | Up to ~1MHz | Multi-drop, addressed | Sensors, EEPROM, low-speed peripherals |
| SPI | 4 (+CS per device) | 10-80+ MHz | Point-to-point (per CS) | Displays, flash, radios, fast ADCs |
| UART | 2 | Up to a few Mbps | Point-to-point | Debug console, GPS, modem, MCU-to-MCU |
Inter-Device/Inter-System Networks
CAN Bus (Controller Area Network)
The backbone of automotive and industrial systems — multi-master, differential signaling for noise immunity, built-in arbitration so the highest-priority message always wins without collision.
graph LR
ECU1[Engine ECU] ---|CAN High/Low| BUS((CAN Bus))
ECU2[Brake ECU] --- BUS
ECU3[Infotainment ECU] --- BUS
ECU4[Body Control ECU] --- BUS
/* Sending a CAN frame, typical HAL pattern */
CAN_TxHeaderTypeDef txHeader;
uint8_t txData[8] = {0};
uint32_t txMailbox;
void CAN_SendSpeed(uint16_t speed_kph)
{
txHeader.StdId = 0x123;
txHeader.IDE = CAN_ID_STD;
txHeader.RTR = CAN_RTR_DATA;
txHeader.DLC = 2;
txData[0] = (speed_kph >> 8) & 0xFF;
txData[1] = speed_kph & 0xFF;
HAL_CAN_AddTxMessage(&hcan1, &txHeader, txData, &txMailbox);
}
Wireless: Wi-Fi, BLE, LoRa, Zigbee
For devices that need to leave the physical enclosure entirely, wireless protocols dominate:
| Protocol | Range | Power | Data Rate | Typical Use |
|---|---|---|---|---|
| Wi-Fi | ~50-100m | High | High (Mbps) | Smart home hubs, cloud-connected devices |
| Bluetooth LE | ~10-30m | Low | Low-Medium | Wearables, phone-connected devices |
| LoRa/LoRaWAN | Km-scale | Very low | Very low (kbps) | Agricultural/industrial IoT, remote sensors |
| Zigbee/Thread | ~10-100m mesh | Low | Low-Medium | Smart home mesh networks |
/* Example: publishing sensor data over MQTT (application layer, over Wi-Fi/TCP) */
void Publish_Temperature(float temp_c)
{
char payload[64];
snprintf(payload, sizeof(payload), "{\"temp_c\": %.2f}", temp_c);
mqtt_publish(&mqtt_client, "devices/sensor01/temperature", payload, strlen(payload));
}
Industrial Protocols
Modbus (RTU over RS-485, or TCP over Ethernet) remains extremely common in industrial automation because of its simplicity and decades of installed equipment support.
sequenceDiagram
participant Master as PLC/HMI (Master)
participant Slave as Sensor Module (Slave, Addr 5)
Master->>Slave: Read Holding Registers (Addr 5, Reg 0x0000, Count 2)
Slave-->>Master: Response with register values + CRC
Handling Communication Reliability
Real communication links fail — noise, collisions, disconnected cables, dropped packets. I build in:
- CRC/checksum validation on every frame (CAN and Modbus have this built in; I add it explicitly for custom UART protocols)
- Timeouts and retries with exponential backoff for request/response protocols
- Sequence numbers to detect and discard duplicate or out-of-order packets
- Watchdog-triggered reconnect logic if a link stays down too long
typedef struct {
uint8_t retry_count;
uint32_t last_attempt_ms;
} comm_retry_state_t;
bool Should_Retry(comm_retry_state_t *state, uint32_t now_ms)
{
uint32_t backoff_ms = 100 * (1 << state->retry_count); /* exponential backoff */
if (state->retry_count < 5 && (now_ms - state->last_attempt_ms) > backoff_ms) {
state->retry_count++;
state->last_attempt_ms = now_ms;
return true;
}
return false;
}
Interrupt-Driven and DMA-Based Communication
Blocking communication calls (like HAL_UART_Transmit with a long timeout) waste CPU cycles a real-time system can’t afford. In production firmware, I favor interrupt-driven or DMA-based transfers so the CPU is free to do other work while data moves in the background — critical when a communication task shares the system with real-time control loops.
Structuring a Communication Stack with an RTOS
In a professional firmware architecture, I rarely let application code touch a communication peripheral directly. Instead, I structure communication around dedicated tasks and queues, decoupling data producers from the actual transmission timing:
graph TD
A[Sensor Task] -->|enqueue message| B[Comm Queue]
C[Alarm/Event Logic] -->|enqueue message| B
B --> D[Communication Task]
D --> E[UART/SPI/Radio Driver]
D --> F[Retry/Timeout Logic]
/* FreeRTOS pattern: producer tasks never block on the radio directly;
they just enqueue messages, decoupling their timing from
however long the actual transmission takes */
QueueHandle_t commQueue;
typedef struct {
uint8_t type;
uint8_t payload[32];
uint8_t length;
} comm_message_t;
void SensorTask(void *pv)
{
for (;;) {
comm_message_t msg;
msg.type = MSG_TYPE_SENSOR_DATA;
msg.length = Build_Sensor_Payload(msg.payload);
xQueueSend(commQueue, &msg, pdMS_TO_TICKS(100));
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void CommunicationTask(void *pv)
{
comm_message_t msg;
for (;;) {
if (xQueueReceive(commQueue, &msg, portMAX_DELAY) == pdTRUE) {
Transmit_With_Retry(&msg);
}
}
}
This decoupling matters enormously when combining a hard real-time sensor task with a soft real-time communication task (echoing the distinction from the second article in this series) — the sensor task’s timing is never at the mercy of however long a retry-laden radio transmission takes.
Protocol Selection Framework
When starting a new project, I evaluate communication protocol choices against a consistent set of criteria rather than defaulting to whatever’s familiar:
| Criterion | Questions I Ask |
|---|---|
| Range | Is this on-board, in-room, in-building, or wide-area? |
| Power budget | Is the device battery-powered, and if so, for how long? |
| Data rate | Kilobytes per day, or continuous streaming? |
| Topology | Point-to-point, star, or mesh network needed? |
| Latency requirements | Does this feed a real-time control loop, or tolerate seconds of delay? |
| Existing infrastructure | Is there already a Wi-Fi network, a CAN bus, an RS-485 network on site? |
| Security requirements | Does the link need encryption/authentication, and does the chosen protocol support it natively? |
| Interoperability | Does this need to talk to third-party equipment using a standard protocol like Modbus? |
Debugging Communication Issues
Communication bugs are notoriously difficult because they’re often intermittent and timing-dependent. My standard debugging toolkit:
- Logic analyzers to capture and decode I2C/SPI/UART traffic at the electrical level, verifying actual bus behavior against what firmware believes it sent
- Bus protocol analyzers for CAN and higher-level protocols, showing message timing, arbitration, and error frame counts
- Loopback testing — looping a UART’s TX back to its own RX to isolate whether an issue is in the local transmit/receive logic or genuinely on the far end
- Injected fault testing — deliberately disconnecting a bus mid-transaction or introducing noise to verify the firmware’s timeout and retry logic actually recovers as designed, rather than assuming it does
- Statistical monitoring in production — logging CRC error rates, retry counts, and reconnect events over the field lifetime of a product to catch marginal signal integrity issues that don’t show up on the bench
IoT Protocol Stacks and Interoperability
For internet-connected devices, communication typically runs through several stacked layers before ever reaching an application server, and I make deliberate choices at each layer:
graph TD
A[Application: MQTT / CoAP / HTTP] --> B[Security: TLS/DTLS]
B --> C[Transport: TCP/UDP]
C --> D[Network: IPv4/IPv6, 6LoWPAN for constrained links]
D --> E[Link: Wi-Fi / Cellular / Thread / LoRaWAN]
MQTT (a lightweight publish-subscribe protocol) has become the de facto standard for cloud-connected IoT devices because of its small message overhead and built-in support for quality-of-service levels (at-most-once, at-least-once, exactly-once delivery), which matters when balancing reliability against limited bandwidth. CoAP (Constrained Application Protocol) fills a similar role for very constrained devices, offering a RESTful model over UDP with much lower overhead than HTTP. For interoperability with existing infrastructure — particularly in industrial and building automation contexts — Modbus and BACnet remain deeply entrenched despite their age, simply because so much existing equipment already speaks them.
/* Example: MQTT connection with QoS 1 (at-least-once delivery) --
a common choice balancing reliability against the overhead of
full exactly-once (QoS 2) handshaking on a constrained device */
mqtt_client_config_t config = {
.broker_uri = "mqtts://broker.example.com:8883",
.client_id = "sensor-node-01",
.keepalive_s = 60,
};
void Publish_With_QoS1(const char *topic, const char *payload)
{
mqtt_publish(&mqtt_client, topic, payload, strlen(payload), MQTT_QOS_1);
}
Custom Protocol Framing for Point-to-Point Links
When two custom devices need to talk over a raw UART link with no existing standard protocol, I design a simple, robust framing scheme rather than sending bare data bytes, since a single dropped or corrupted byte can otherwise desynchronize the receiver indefinitely.
/* Simple framed protocol: START byte, length, payload, CRC, END byte --
the receiver can resynchronize on the next START byte even
after a corrupted or dropped frame */
#define FRAME_START 0x7E
#define FRAME_END 0x7F
typedef struct {
uint8_t start;
uint8_t length;
uint8_t payload[32];
uint16_t crc;
uint8_t end;
} frame_t;
void Send_Frame(uint8_t *data, uint8_t len)
{
frame_t frame;
frame.start = FRAME_START;
frame.length = len;
memcpy(frame.payload, data, len);
frame.crc = crc16(data, len);
frame.end = FRAME_END;
UART_Transmit((uint8_t *)&frame, offsetof(frame_t, payload) + len + 3);
}
This pattern — explicit start/end delimiters, a length field, and a checksum — recurs constantly across custom embedded protocols precisely because it lets a receiver recover gracefully from noise or a partial transmission, rather than silently misinterpreting corrupted data as valid.
Gateway Devices and Protocol Translation
A very common role I design for is a gateway device that bridges two different communication worlds — for example, translating between a local Zigbee mesh of sensors and an MQTT connection to the cloud, or between legacy Modbus RTU field devices and a modern Modbus TCP/Ethernet network. These designs need to manage two entirely separate communication stacks concurrently, typically using an RTOS with dedicated tasks per interface so a slowdown on one side (like a temporary cloud outage) doesn’t stall the other (the local sensor network should keep collecting data regardless).
graph LR
A[Zigbee Sensor Mesh] --> B[Gateway Device]
B --> C[Local Buffer/Queue]
C --> D[MQTT over Wi-Fi/Ethernet]
D --> E[Cloud Broker]
F[Cloud Outage] -.->|buffer absorbs backlog| C
The local buffer shown here is a deliberate design choice: if the cloud connection drops, sensor data keeps accumulating locally rather than being lost, and gets flushed to the cloud once connectivity returns — a resilience pattern that shows up constantly in gateway and edge-computing designs.
Real-World Applications
- Automotive: dozens of ECUs coordinating over CAN/CAN-FD for everything from engine control to infotainment
- Industrial automation: PLCs and sensors communicating over Modbus/Profibus/EtherCAT on the factory floor
- Smart home: Zigbee/Thread mesh networks letting hundreds of low-power devices relay data to a central hub
- Agricultural IoT: LoRaWAN sensor networks spanning kilometers of farmland with years of battery life
- Wearables: BLE connecting a fitness tracker to a smartphone app with minimal power draw
Performance and Reliability Trade-offs
Every protocol trades off range, power, speed, and complexity. I choose wired protocols (I2C/SPI/CAN) when devices are physically close and reliability/determinism matters most, and wireless protocols when physical connection isn’t feasible — accepting the added complexity of encryption, retries, and variable latency that comes with radio links.
Frequently Asked Questions
Why use SPI instead of I2C if I2C uses fewer wires? SPI is significantly faster and simpler to implement in hardware (no arbitration or open-drain pull-ups needed), which matters for high-bandwidth peripherals like displays and flash memory, at the cost of needing an extra chip-select wire per device.
Is CAN bus encrypted? No — classic CAN has no built-in encryption or authentication, which is why modern automotive security adds message authentication codes (MACs) or moves to CAN-FD with additional security layers, as discussed in the security article of this series.
When should I choose LoRa over Wi-Fi or BLE? When the application needs very long range (kilometers) and can tolerate very low data rates and infrequent transmissions, such as remote agricultural or environmental sensors running on a small battery for years.
What’s the difference between UART and USART? UART is purely asynchronous. USART (Universal Synchronous/Asynchronous Receiver-Transmitter) can operate in either synchronous mode (with a shared clock line) or asynchronous mode, giving more flexibility for certain peripherals.
Summary
Embedded communication spans a huge range — from two wires connecting a temperature sensor to an MCU, to a global MQTT connection carrying that sensor’s data to a cloud dashboard. Choosing the right protocol at each layer, implementing it with interrupt-driven or DMA-based transfers rather than blocking calls, and building in checksums, timeouts, and retries for reliability are what separate a demo that works on the bench from a product that survives years in the field.