How Does an Embedded System Handle Communication with Other Devices or Systems

How does an embedded system handle communication with other devices or systems

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

ProtocolWiresSpeedTopologyTypical Use
I2C2Up to ~1MHzMulti-drop, addressedSensors, EEPROM, low-speed peripherals
SPI4 (+CS per device)10-80+ MHzPoint-to-point (per CS)Displays, flash, radios, fast ADCs
UART2Up to a few MbpsPoint-to-pointDebug 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:

ProtocolRangePowerData RateTypical Use
Wi-Fi~50-100mHighHigh (Mbps)Smart home hubs, cloud-connected devices
Bluetooth LE~10-30mLowLow-MediumWearables, phone-connected devices
LoRa/LoRaWANKm-scaleVery lowVery low (kbps)Agricultural/industrial IoT, remote sensors
Zigbee/Thread~10-100m meshLowLow-MediumSmart 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:

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:

CriterionQuestions I Ask
RangeIs this on-board, in-room, in-building, or wide-area?
Power budgetIs the device battery-powered, and if so, for how long?
Data rateKilobytes per day, or continuous streaming?
TopologyPoint-to-point, star, or mesh network needed?
Latency requirementsDoes this feed a real-time control loop, or tolerate seconds of delay?
Existing infrastructureIs there already a Wi-Fi network, a CAN bus, an RS-485 network on site?
Security requirementsDoes the link need encryption/authentication, and does the chosen protocol support it natively?
InteroperabilityDoes 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:

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

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.

References

Exit mobile version