/blog/rpc_pc_microcontroller
2026-07-17 · 30 min · Embedded · Tutorial · Protocols · TAGS · RPC · Embedded · Microcontroller · USB · UART · Protocols

How RPC Calls Work: From Distributed Software to PC-MCU Communication

Abstract

While developing tools that control embedded devices from a PC, I have often asked myself the same question: how closely can communication with a microcontroller resemble a normal function call?

From the application's point of view, it would be convenient to write:

temperature = mcu.get_temperature(sensor_id=3)mcu.set_led(True)

The function, however, does not run inside the PC process. The request must cross a library, the operating system, a transport such as USB or UART, a firmware parser, and finally the hardware controlled by the MCU. A framing, serialization, or synchronization error can interrupt that path at any point.

Remote Procedure Calls, or RPCs, hide part of this complexity behind an interface that resembles a local function. The abstraction is useful as long as it does not make us forget that a distributed protocol remains underneath, complete with timeouts, partial failures, and limited resources.

Let us examine how an RPC works and what must be considered when designing robust communication between a PC and a microcontroller.

A call begins as a function on the PC, becomes a sequence of bytes, crosses the firmware parser, and returns as a response.

What Is an RPC?

An RPC is a procedure call that crosses a communication boundary.

That boundary may separate:

  • two processes;
  • two computers;
  • a client and a remote service;
  • two containers or virtual machines;
  • a PC and an embedded device.

When we call a local function, its code and data exist in the same execution environment. A remote function lives elsewhere: we cannot jump directly to its memory address, so the call must be represented as a message.

A local call remains within one process. An RPC crosses a transport, is interpreted by the remote system, and produces a response.

The purpose of RPC is to make this interaction easier. Code on the PC can resemble a normal call:

temperature = mcu.get_temperature(3)

At least four operations occur underneath that line:

  1. the parameters are serialized;
  2. the request is placed inside a frame;
  3. the frame is transmitted and interpreted by the firmware;
  4. the result follows the reverse path.

The local syntax is therefore an abstraction. The semantics remain those of remote communication, where the other side can reply late, return an error, restart, or become unreachable.

The Main Components

An RPC system normally includes a client, a server, a contract, a transport layer, and code that converts calls into messages.

Client and Server

The client sends the request. In the scenario covered by this article, it is usually the application running on the PC:

read temperature sensor 3set the motor PWM to 1200turn on the status LED

The server receives the request and performs the operation. Here, it is the firmware running on the MCU, which may expose functions such as:

float read_temperature(uint8_t sensor_id);void set_motor_pwm(uint16_t value);void set_led_state(bool on);

Client and server describe roles within an interaction rather than fixed categories of machine. The MCU can also send spontaneous events to the PC, as we will see later.

Contract

The contract defines which methods exist, which parameters they accept, and which results or errors they can return:

getTemperature(sensor_id: uint8) -> temperature: floatsetLed(state: bool) -> ok | errorgetFirmwareVersion() -> major, minor, patch

The PC and MCU must interpret this contract in exactly the same way. If the PC transmits one 32-bit integer while the MCU reads two 16-bit values, the communication may be structurally valid but semantically wrong.

The contract can be documented manually or described with an Interface Definition Language, commonly abbreviated as IDL. gRPC, for example, usually uses Protocol Buffers to define services and messages and to generate code for different languages.

Stub, Proxy, and Dispatcher

The client-side stub or proxy exposes the interface used by the application:

mcu.set_led(True)temperature = mcu.get_temperature(3)

Behind these calls, the library assigns an identifier, serializes the parameters, builds the frame, sends it, and waits for the response.

The MCU implements the corresponding component. Its dispatcher interprets the requested method and invokes the correct handler:

switch (request.method_id) {    case RPC_GET_TEMPERATURE:        handle_get_temperature(&request, &response);        break;    case RPC_SET_LED:        handle_set_led(&request, &response);        break;    default:        rpc_set_error(&response, RPC_ERR_UNKNOWN_METHOD);        break;}

Transport

The transport carries bytes from one side to the other. Distributed systems may use TCP, HTTP/2, or TLS. Communication between a PC and MCU may instead use:

  • UART;
  • USB CDC ACM;
  • a vendor-specific USB class;
  • USB HID;
  • Ethernet or Wi-Fi;
  • BLE;
  • CAN or RS-485;
  • SPI or I2C in suitable architectures.

RPC does not identify the cable or peripheral. It describes how requests, responses, and errors are organized; USB, UART, and TCP are merely the means by which those messages travel.

The Path of a Call

Suppose that we want to expose this firmware function:

float get_temperature(uint8_t sensor_id);

On the PC, we write:

temperature = mcu.get_temperature(sensor_id=3)

The RPC library converts the call into a logical request:

method: get_temperaturesensor_id: 3request_id: 42

In a readable format, it could look like this:

{  "jsonrpc": "2.0",  "id": 42,  "method": "get_temperature",  "params": {    "sensor_id": 3  }}

JSON-RPC 2.0 uses fields such as method, params, and id. The identifier correlates the response with the correct request, while a notification without an id does not receive a response.

The logical document must then become a sequence of bytes. This transformation is called serialization and may use JSON, Protocol Buffers, CBOR, MessagePack, or a custom binary format.

Serialization alone does not necessarily establish where a message begins and ends on the transport. Framing adds the information needed to delimit and validate each packet.

The firmware receives the bytes, reconstructs the frame, verifies its integrity, decodes the payload, and invokes the handler. The result is serialized and sent back along the same path:

{  "jsonrpc": "2.0",  "id": 42,  "result": {    "temperature": 24.7  }}

When an error occurs, the response should include at least a category and a useful detail:

{  "jsonrpc": "2.0",  "id": 42,  "error": {    "code": -32001,    "message": "Sensor not available",    "data": {      "reason": "SENSOR_NOT_FOUND"    }  }}

Why the PC-MCU Case Is Different

Two servers usually have powerful processors, abundant memory, sockets, filesystems, and advanced diagnostic tools. An MCU may instead have:

  • only a few kilobytes of RAM;
  • limited flash and processing power;
  • small receive buffers;
  • real-time constraints;
  • interrupts and DMA to coordinate;
  • limited energy;
  • watchdogs and unexpected resets;
  • slow or noisy links.

Choosing a data format and sending it is therefore not enough. The design must establish what happens when half a frame arrives, a buffer fills, the MCU restarts, or a response is lost.

In embedded systems, the protocol is not an implementation detail. It is part of the architecture.

The Complete Stack

An RPC between a PC and an MCU can be divided into layers:

  • application: “read the temperature” or “set the PWM”;
  • RPC contract: methods, parameters, results, and errors;
  • serialization: conversion of structured data into bytes;
  • framing: delimiters, length, escaping, and integrity checks;
  • transport: UART, USB, TCP, CAN, or another channel;
  • driver and operating system: serial API, libusb, WinUSB, tty, or a COM port;
  • physical layer: cable, transceiver, and electrical signals.

The stack separates application intent from the contract, framing, transport, and firmware logic.

Each layer solves a different problem. The application expresses:

set_led(true)

The RPC layer represents it as:

method_id = 0x0003request_id = 42payload = 01

Framing adds the message length, type, and integrity check. The transport carries the frame to the MCU, where the firmware interprets it and finally calls:

gpio_write(LED_PIN, 1);

This separation makes it possible to replace USB CDC with UART, for example, without rewriting the application handlers.

Designing a Minimal Protocol

Suppose that we define a small binary protocol. A frame could contain:

+---------+---------+---------+------------+-----------+--------+| MAGIC   | LENGTH  | TYPE    | REQUEST_ID | PAYLOAD   | CRC16  |+---------+---------+---------+------------+-----------+--------+| 1 byte  | 2 bytes | 1 byte  | 2 bytes    | N bytes   | 2 bytes|+---------+---------+---------+------------+-----------+--------+

In this example:

  • MAGIC helps identify the start of a frame;
  • LENGTH is the number of bytes from TYPE through the end of PAYLOAD;
  • TYPE distinguishes requests, responses, errors, and events;
  • REQUEST_ID connects a response to its request;
  • PAYLOAD contains the method, parameters, or result;
  • CRC16 detects accidental alterations to the frame.

Message types can be defined as follows:

#define RPC_TYPE_REQUEST   0x01#define RPC_TYPE_RESPONSE  0x02#define RPC_TYPE_ERROR     0x03#define RPC_TYPE_EVENT     0x04

The request payload contains at least a method and its parameters:

+-----------+------------+| METHOD_ID | PARAMS     |+-----------+------------+| 2 bytes   | N bytes    |+-----------+------------+

For example:

#define RPC_GET_VERSION       0x0001#define RPC_GET_TEMPERATURE   0x0002#define RPC_SET_LED           0x0003#define RPC_SET_PWM           0x0004#define RPC_REBOOT            0x0005

A request and its response could be represented as:

REQUEST  request_id = 42  method_id  = RPC_SET_LED  params     = 01RESPONSE  request_id = 42  status     = OK

The real specification must also define field endianness, the CRC algorithm, the bytes covered by the calculation, the maximum payload size, and behavior in the presence of invalid data.

Request IDs and Correlation

The request_id associates each response with the call that generated it.

Suppose that the PC sends three requests:

id=10 -> read temperatureid=11 -> read voltageid=12 -> read firmware version

The MCU might complete them in a different order:

id=12 -> firmware versionid=10 -> temperatureid=11 -> voltage

Without an identifier, the PC could not correlate concurrent responses.

In a strictly synchronous protocol that allows only one outstanding request, this field may not be essential. It remains useful for logs, timeouts, late-response detection, deduplication, and the future introduction of pipelining.

The scope of identifiers must also be defined. A new session after an MCU restart should not confuse an old response with a current request.

Framing: Reconstructing Messages

UART, TCP, and the serial interface exposed by USB CDC present a byte stream to the application. A single read() does not necessarily correspond to one complete frame:

PC sends:[frame A][frame B][frame C]MCU receives:first read:  half of frame Asecond read: end of frame A + start of frame Bthird read:  end of frame B + frame C

The parser must accumulate bytes and reconstruct messages. Several strategies are available.

Length Prefix

The frame declares its size at the beginning:

[LENGTH][PAYLOAD][CRC]

This approach is compact and efficient. A corrupted or unvalidated length can, however, cause loss of synchronization or excessive memory requests. The parser must always reject values larger than the protocol maximum.

Delimiter and Escaping

A reserved value marks the end of the frame:

[PAYLOAD_ESCAPED][END]

If the delimiter appears in the data, it must be escaped. SLIP, for example, uses the special values END and ESC. It provides framing but does not define addressing, message types, or an integrity check by itself.

COBS

Consistent Overhead Byte Stuffing, or COBS, transforms a sequence so that it no longer contains a selected value, usually 0x00. That value can then be used as an unambiguous delimiter.

COBS removes the reserved value from the encoded payload, allowing 0x00 to separate frames reliably.

COBS is useful in embedded protocols because it has tightly bounded overhead and allows the receiver to find the next frame boundary after an error.

HDLC-Like Framing

Another option combines a boundary flag, escaping, and an integrity check:

0x7E payload_escaped crc 0x7E

If 0x7E or the escape byte appears in the content, it is replaced with an encoded sequence. The specification must define exactly which bytes are escaped and which are covered by the CRC.

CRC and Frame Validation

Electrical noise, dropped bytes, buffer overflows, and parser desynchronization can alter a frame. An integrity check can detect many of these conditions.

[HEADER][PAYLOAD][CRC16]

The receiver recalculates the CRC over the bytes defined by the protocol and compares it with the transmitted value:

if (crc_received != crc_calculated) {    discard_packet();    rpc_record_transport_error(RPC_ERR_BAD_CRC);}

It is generally safer to discard a corrupted frame without interpreting its fields. Sending an error response makes sense only when addressing and correlation remain trustworthy and the protocol defines that behavior explicitly.

A simple checksum may be sufficient for a prototype, while a protocol intended for real links often benefits from a correctly selected and documented CRC.

A CRC does not provide cryptographic security. It detects accidental errors, but anyone who deliberately modifies a message can also recalculate the CRC.

Choosing a Serialization Format

The choice between JSON, Protocol Buffers, CBOR, and a custom format affects memory usage, bandwidth, development tools, and future compatibility.

JSON

JSON is readable and easy to inspect:

{  "id": 12,  "method": "set_pwm",  "params": {    "channel": 1,    "value": 1200  }}

It works well for prototypes and MCUs with sufficient resources. It is verbose, however, and text conversion can require non-trivial parser code, temporary memory, and processing time.

Protocol Buffers

Protocol Buffers uses a schema and produces compact binary messages. It can generate code for different languages and, when designed carefully, allows the format to evolve while retaining a degree of compatibility.

message RpcRequest {  uint32 id = 1;  uint32 method = 2;  bytes payload = 3;}message RpcResponse {  uint32 id = 1;  uint32 status = 2;  bytes payload = 3;}

Microcontrollers can use an implementation such as nanopb, which is designed for systems with tight RAM and ROM constraints. The tradeoff is greater dependence on code-generation tools and a runtime library.

CBOR

CBOR provides a rich binary data model with goals that include compact messages, small implementations, and extensibility. It can be useful when more flexibility than a rigid schema is needed without paying the full verbosity cost of JSON.

A reliable library and a clearly defined CBOR profile are still necessary. Accepting every valid representation can add complexity that an embedded parser does not need.

Custom Binary Format

A purpose-built binary format allows small parsers and complete control:

method_id: 0x0004channel:   0x01value:     0x04B0

It is often suitable for highly constrained MCUs, but it requires a rigorous specification, dedicated debugging tools, and a versioning plan. Initial efficiency does not compensate for an ambiguous protocol that cannot evolve.

There is no universally correct choice. JSON favors readability and rapid development; Protocol Buffers and CBOR provide more compact representations; a custom binary format maximizes control at the cost of additional design work.

Endianness, Alignment, and Types

A binary protocol must define explicitly:

  • the endianness of multibyte integers;
  • the representation of signed values;
  • the format of floating-point values;
  • units of measurement;
  • valid limits for every field;
  • the handling of strings and lengths.

Sending a C structure directly is fragile:

typedef struct {    uint8_t id;    uint32_t value;} packet_t;uart_write((uint8_t *)&packet, sizeof(packet));

The compiler may insert padding, alignment can change between architectures, and byte order may differ. Uninitialized bytes can also be transmitted accidentally.

It is better to serialize each field according to explicit rules:

buffer[0] = id;buffer[1] = (uint8_t)(value & 0xFFu);buffer[2] = (uint8_t)((value >> 8) & 0xFFu);buffer[3] = (uint8_t)((value >> 16) & 0xFFu);buffer[4] = (uint8_t)((value >> 24) & 0xFFu);

Alternatively, a serialization format that already defines these conventions can be used.

Firmware Architecture

The microcontroller firmware should keep reception, framing, serialization, and application logic separate.

Drivers, transport, parser, dispatcher, and handlers remain separate so that each layer can be tested and replaced independently.

Reception and Buffers

Bytes may arrive through an interrupt, DMA, or a USB-stack callback. These contexts should do as little work as possible: capture the data, update indices, and signal that bytes are available.

Parsing and handler execution can take place in the main loop or an RTOS task. A ring buffer decouples the peripheral's pace from the parser, provided that its maximum size and overflow policy are defined.

State-Machine Parser

A state machine is well suited to a stream that may arrive in fragments:

typedef enum {    WAIT_MAGIC,    READ_LENGTH,    READ_BODY,    READ_CRC} parser_state_t;

The parser advances only when the required bytes are available:

WAIT_MAGIC   -> search for the beginning of a frameREAD_LENGTH  -> read and validate the lengthREAD_BODY    -> collect the expected number of bytesREAD_CRC     -> verify integrity

Reception remains lightweight; the parser reconstructs and validates a complete frame before delivering it to the dispatcher.

Every length must be validated before memory is reserved or copied, and the parser must be able to resynchronize after corrupted input. Only complete, valid frames should reach the dispatcher.

Dispatcher and Handlers

The dispatcher maps a method_id to an application function:

void rpc_dispatch(const rpc_request_t *req, rpc_response_t *res){    switch (req->method_id) {        case RPC_GET_VERSION:            handle_get_version(req, res);            break;        case RPC_GET_TEMPERATURE:            handle_get_temperature(req, res);            break;        case RPC_SET_LED:            handle_set_led(req, res);            break;        default:            rpc_set_error(res, RPC_ERR_UNKNOWN_METHOD);            break;    }}

Handlers should not know which transport delivered the request. handle_set_led() should receive validated parameters and produce a logical result without reading from UART or constructing USB packets directly.

This separation makes the firmware easier to test and allows the same contract to be reused over different transports.

The PC-Side Library

The protocol should also be isolated behind a library on the PC:

mcu = McuRpcClient(port="/dev/ttyACM0")version = mcu.get_version()temperature = mcu.get_temperature(3)mcu.set_led(True)

The library handles:

  • assigning the request_id;
  • validating and serializing parameters;
  • building and sending frames;
  • accumulating received bytes;
  • correlating responses with requests;
  • applying timeouts and retry policies;
  • translating protocol errors;
  • handling disconnection and reconnection.

The application can then use a clean interface instead of duplicating read() calls, CRC calculations, and buffer management.

This is the main practical benefit of RPC: communication complexity remains inside dedicated components while the application code expresses the desired operation.

Responses, Events, and Streams

Communication is not always a simple request-response sequence.

A response is correlated with a request:

REQUEST  id=42 method=GET_TEMPERATURERESPONSE id=42 result=24.7

An event instead originates on the MCU without an immediate request from the PC:

EVENT event_id=BUTTON_PRESSED payload={button_id:1}

On the PC, it may be exposed as a callback:

def on_button_pressed(event):    print("Button pressed:", event.button_id)mcu.on("button_pressed", on_button_pressed)

The same channel can carry responses, spontaneous events, and stream samples as long as the message type is identified explicitly.

A protocol may therefore define:

  • REQUEST and RESPONSE;
  • ERROR;
  • EVENT;
  • commands without a response;
  • ACK and NACK;
  • HEARTBEAT;
  • LOG and STREAM_DATA.

Not every project needs all of these categories. Adding them without a concrete use case increases the state space that must be implemented and tested.

Streams often benefit from a sequence number:

sample_seq = 1001sample_seq = 1002sample_seq = 1005

The gap tells the PC that samples 1003 and 1004 were lost. Timestamps, maximum frequency, priority, and overflow policy should be defined according to the application.

USB in a PC-MCU Connection

In a traditional USB connection, the PC normally acts as the host and the MCU as the device. The host schedules transactions on the bus, while the device exposes data and endpoints according to the USB protocol.

Endpoints are logical points on the device through which data is transferred. On the host side, they are represented by pipes managed by the USB stack.

With USB CDC, the application often sees a virtual serial port, while drivers, host and device stacks, endpoints, and firmware operate underneath.

Enumeration and the Control Endpoint

Every USB device exposes control endpoint 0. During enumeration:

  1. the host detects and resets the device;
  2. it communicates through endpoint 0 and assigns an address;
  3. it reads the USB descriptors;
  4. it identifies the class, configurations, interfaces, and endpoints;
  5. it selects the appropriate driver;
  6. it makes the device available to applications.

An MCU configured as USB CDC ACM often appears as a virtual serial port:

  • COM7 on Windows;
  • /dev/ttyACM0 on GNU/Linux;
  • /dev/cu.usbmodem... on macOS.

This approach simplifies integration because it normally uses drivers already included with the operating system. It does not remove the need for framing, timeouts, and disconnection handling.

UART and USB CDC as Byte Streams

Both UART and the serial interface exposed by USB CDC require the application to reconstruct messages. A write() on one side must not be assumed to correspond to one read() on the other.

UART also requires the design to specify:

  • baud rate;
  • parity and stop bits;
  • logic levels and transceivers;
  • flow control;
  • oscillator tolerance;
  • buffer sizes;
  • overflow behavior.

RTS/CTS or software flow control can prevent a sender from overrunning a slower receiver. USB CDC changes the electrical and transport details, but finite buffers, latency, timeouts, port resets, and device disconnections still remain.

Timeouts, Retries, and Idempotency

A call can fail because the MCU is busy, the cable has been disconnected, the firmware has restarted, or the response was lost. The client therefore needs a timeout:

send request id=42wait at most 200 msno response -> timeout

The correct value depends on the transport and the maximum expected duration of the operation. One timeout for every method is simple, but it may be too aggressive for slow commands and too permissive for immediate ones.

A timeout may trigger a retry, but not every operation is safe to repeat.

A command is idempotent when executing it multiple times produces the same final state:

set_led(true)          -> idempotenttoggle_led()           -> not idempotentset_motor_speed(1200)  -> idempotentdispense_liquid(10ml)  -> not idempotent

Non-idempotent operations may require stable operation identifiers, MCU-side deduplication, transactional state, or explicit outcome verification. Blindly retrying after a timeout can execute a command twice when the original operation succeeded but its response was lost.

Whenever possible, methods should describe the desired state, such as set_motor_speed(1200), rather than a relative change such as increase_motor_speed(100).

Transport and Application Errors

“Operation failed” does not provide enough information to diagnose a protocol. It is useful to distinguish at least:

#define RPC_OK                     0x00#define RPC_ERR_BAD_FRAME          0x01#define RPC_ERR_BAD_CRC            0x02#define RPC_ERR_UNKNOWN_METHOD     0x03#define RPC_ERR_INVALID_ARGUMENT   0x04#define RPC_ERR_BUSY               0x05#define RPC_ERR_TIMEOUT            0x06#define RPC_ERR_NOT_ALLOWED        0x07#define RPC_ERR_UNSUPPORTED_VER    0x08#define RPC_ERR_INTERNAL           0x09

These errors do not all belong to the same layer:

ErrorLayerMeaning
BAD_CRCtransportaltered frame, normally discarded
UNKNOWN_METHODcontractmethod not exposed by this firmware
INVALID_ARGUMENTapplicationinvalid parameters
BUSYapplicationvalid request, resource not available
NOT_ALLOWEDstate/securitycommand forbidden in the current state

The PC library can translate these codes into exceptions or typed results while retaining the original detail for diagnostic logs.

Versioning and Capability Discovery

The application and firmware may evolve at different times. The PC must therefore discover the protocol version and the features actually available.

An initial call can return:

{  "protocol_version": 2,  "firmware_version": "1.3.0",  "device": "motor-controller-x1",  "capabilities": [    "get_temperature",    "set_pwm",    "stream_logs"  ],  "max_payload_size": 256}

The firmware version should be kept separate from the protocol version. An internal firmware update does not necessarily change the contract.

Some practical rules are:

  • never change the meaning of an existing method_id;
  • do not immediately reuse removed identifiers or fields;
  • add optional fields when the format allows it;
  • define how unknown fields are handled;
  • negotiate incompatible changes explicitly;
  • report limits such as max_payload_size and the maximum number of outstanding requests.

Flow Control and Backpressure

The PC can produce data much faster than the MCU can process it. Without a control strategy, buffers fill and frames are lost.

Possible approaches include:

  • allowing only one outstanding request;
  • defining a maximum window of N requests;
  • returning BUSY or explicit credits;
  • using ACK and NACK messages;
  • enabling RTS/CTS on UART;
  • rate limiting on the PC;
  • maintaining TX/RX queues with detectable overflow;
  • applying separate limits to commands and events.

For an initial embedded protocol, the simplest sequence is often enough:

the PC sends one requestit waits for the responseit sends the next request

Throughput is lower, but the number of states and edge cases remains manageable. Pipelining can be introduced when measurements show that it is actually required.

Streams need backpressure as well. If the PC cannot read quickly enough, the protocol must define whether to drop the oldest samples, stop the stream, or report an overflow.

Long-Running and Real-Time Operations

The RPC parser must not interfere with motor control, ADC acquisition, CAN communication, or watchdog handling.

A handler that blocks for several seconds is problematic:

void handle_rpc_request(void){    perform_slow_measurement();    wait_until_complete();}

Long-running operations are often better represented asynchronously:

REQUEST  start_calibration()RESPONSE accepted operation_id=7EVENT    progress operation_id=7 value=25%EVENT    progress operation_id=7 value=80%EVENT    completed operation_id=7 result=OK

The MCU accepts the command, returns an identifier, and reports progress without blocking the entire system.

The operation_id does not replace the request_id. The first identifies an activity that continues over time; the second correlates the immediate response with the call.

Security

A local connection is not automatically trustworthy. The protection required depends on the product and on the consequences of the exposed commands.

The design should consider:

  • which processes can open the device;
  • whether commands can move actuators or modify calibration;
  • whether the protocol supports memory access or firmware updates;
  • which machine state is required before a command is accepted;
  • whether an attacker can observe or alter the channel.

Possible safeguards include:

  • operating-system permissions on the device;
  • a whitelist of methods available in production;
  • strict validation of parameters and machine state;
  • rate limiting;
  • cryptographic authentication for critical commands;
  • signed and verified firmware updates;
  • disabling debug interfaces;
  • separating operating and maintenance modes.

A generic challenge-response mechanism is not sufficient unless it is built with cryptographic primitives and secure key management. Similarly, a CRC does not authenticate the sender or protect against deliberate modification.

Limited controls may be acceptable for an internal laboratory tool. A distributed product that controls power, motors, or sensitive data instead requires an explicit threat analysis.

Operating System and Drivers

The PC application normally communicates through operating-system APIs, drivers, and communication stacks.

The application passes through operating-system APIs and drivers before reaching the MCU peripheral and firmware.

On GNU/Linux, a serial port is generally exposed as a special file. Calls such as read() and write() transfer data, while ioctl() or dedicated APIs configure device properties.

On Windows, a USB CDC device often appears as a COM port. A vendor-specific USB class may instead use WinUSB, libusb, or a dedicated driver. DeviceIoControl() also allows applications to send control codes to a driver when the architecture requires it.

These platform-specific details should remain inside the library's transport backend so that the RPC interface does not depend on the operating system.

RPC Does Not Necessarily Mean gRPC

gRPC is a modern RPC framework widely used in distributed systems. It uses HTTP/2 and commonly uses Protocol Buffers, providing code generation, streaming, and typed service contracts.

It is not the only form of RPC and is not always suitable for a small MCU. A complete HTTP/2 stack, optional TLS, and their dependencies may require more RAM, flash, and complexity than the project justifies.

Over UART or USB CDC, RPC may simply mean:

  • a documented set of methods;
  • requests with parameters;
  • responses correlated by an identifier;
  • errors, timeouts, and versioning;
  • asynchronous events when needed.

Custom RPC over UART, JSON-RPC over serial, Protocol Buffers over USB, and CBOR over RS-485 are all possible. The concept remains RPC even when gRPC is not involved.

RPC, REST, and Events

RPC normally describes actions:

get_temperature()set_led(true)start_motor()calibrate_sensor()

REST primarily models resources and HTTP operations:

GET  /sensors/3/temperaturePUT  /leds/1/statePOST /calibrations

An event-driven system instead describes things that have already happened:

button_pressedtemperature_threshold_exceededmotor_fault_detected

RPC is a natural fit for sending commands from a PC to firmware, while events are better suited to spontaneous notifications from the board. A real protocol can combine requests and responses, events, and streams without forcing every interaction into the same model.

A Complete Example

Suppose that an MCU controls an LED, a temperature sensor, a PWM motor, and a button. Its contract exposes:

get_protocol_info() -> version and capabilitiesget_temperature(sensor_id) -> valueset_led(state) -> okset_pwm(channel, value) -> okstart_log_stream(level) -> okstop_log_stream() -> okreboot(mode) -> ok

The MCU can also produce these events:

button_pressed(button_id)temperature_alarm(sensor_id, value)motor_fault(code)log_line(sequence, level, message)heartbeat(uptime_ms)

The PC opens the connection and queries the device:

mcu = McuRpcClient("/dev/ttyACM0")info = mcu.get_protocol_info()

The response describes the available contract:

{  "protocol_version": 1,  "firmware_version": "1.4.2",  "device_id": "CTRL-BOARD-A",  "max_payload": 256,  "capabilities": [    "temperature",    "pwm",    "events",    "log_stream"  ]}

A synchronous call is correlated through its identifier:

REQUEST  id=1 method=SET_LED params={state:true}RESPONSE id=1 status=OKREQUEST  id=2 method=GET_TEMPERATURE params={sensor_id:0}RESPONSE id=2 status=OK result={value:24.7, unit:"C"}

When the user presses the button, the MCU sends:

EVENT event=BUTTON_PRESSED payload={button_id:1}

If the PC enables logging, the same channel can carry a numbered stream:

EVENT LOG_LINE seq=1 message="init ok"EVENT LOG_LINE seq=2 message="adc ready"EVENT LOG_LINE seq=3 message="motor enabled"

This example keeps the three main interaction models distinct: correlated calls, spontaneous events, and continuous data.

Large Payloads and Fragmentation

A frame may have a small maximum size, such as 256 bytes, while a configuration or calibration table may be much larger.

This requires a block-transfer protocol:

TRANSFER_START total_size=2048 transfer_id=5TRANSFER_CHUNK id=5 offset=0   data=...TRANSFER_CHUNK id=5 offset=256 data=...TRANSFER_CHUNK id=5 offset=512 data=...TRANSFER_END   id=5 crc32=...

The protocol must define:

  • block size and ordering;
  • handling of duplicate and missing blocks;
  • timeouts and cancellation;
  • integrity checks for each frame and the complete object;
  • optional transfer resumption;
  • memory and write limits.

Firmware updates introduce additional security requirements: image authenticity, signature verification, rollback protection when necessary, and safe recovery after interruption.

Debugging and Observability

A binary protocol is difficult to diagnose without dedicated tools. The PC should provide logs such as:

TX id=42 method=SET_LED len=4RX id=42 status=OK time=3.2ms

The firmware can expose corresponding diagnostics when appropriate:

rpc: received method=0x0003 id=42rpc: set_led state=1rpc: response ok id=42

A hexadecimal dump shows the bytes actually transmitted:

TX: 7E 01 00 08 2A 00 03 01 91 C4RX: 7E 02 00 07 2A 00 00 44 12

A decoder that interprets the dump according to the specification is even more useful:

Frame:  type: REQUEST  id: 42  method: SET_LED  state: true  crc: ok

Logging should not significantly affect real-time behavior or expose secrets and sensitive data in production.

Testing

The protocol must be tested beyond its nominal path. Useful cases include:

  • a valid frame;
  • an invalid CRC;
  • truncated or concatenated frames;
  • a length larger than the maximum;
  • an unknown method_id;
  • an out-of-range parameter;
  • a timeout and a late response;
  • duplicate delivery of the same request;
  • an MCU reset during a call;
  • an event received while the PC waits for a response;
  • a stream faster than its consumer;
  • USB disconnection and reconnection;
  • an unsupported protocol version.

The parser deserves dedicated testing because it consumes external input. Targeted fuzzing can send random sequences, extreme lengths, and malformed frames while verifying that the firmware does not crash, read outside its buffers, or lose the ability to resynchronize.

If the parser can be shared or compiled for both targets, testing it on the PC also makes sanitizers and analysis tools easier to use.

Common Mistakes

Treating One Read as One Message

This code assumes that read() returns exactly one frame:

data = serial.read(64)parse_rpc(data)

The client must instead accumulate bytes, extract every complete frame available, and preserve the final partial fragment for the next read.

Sending Raw C Structures

Padding, endianness, alignment, and uninitialized fields make direct transmission of a structure's memory fragile. Fields should be serialized into a stable representation.

Trusting the Received Length

A frame length must never exceed the buffer or protocol limits. Validation must happen before any allocation or copy.

Omitting Timeouts

Without a timeout, a call can block the application indefinitely. The timeout path must also release request state without confusing a late response with a newer call.

Retrying Non-Idempotent Operations Automatically

A lost response does not imply that the MCU failed to execute the command. An automatic retry can duplicate an operation that already succeeded.

Doing Too Much Work in an Interrupt

Parsing, serialization, and application handlers should run outside interrupt context unless exceptional, carefully analyzed requirements dictate otherwise.

Ignoring Events and Versioning

A protocol designed only for immediate responses becomes difficult to extend with notifications and long-running operations. Similarly, a protocol without a version is fragile as soon as the firmware changes.

Conclusion

We have seen how an RPC turns an apparently local call into structured communication. In a PC-MCU system, the path crosses a library, the operating system, drivers, a transport, a parser, a dispatcher, and finally the hardware controlled by the firmware.

The quality of the application interface depends on the precision of the underlying protocol. Framing, serialization, buffer limits, timeouts, identifiers, versioning, and error handling are not secondary details. They define how the system behaves when something does not proceed as expected.

An initial implementation should remain simple. One outstanding request, bounded frames, a state-machine parser, clear error codes, and mostly idempotent methods provide a foundation that is easier to verify. Concurrency, streaming, and fragmentation can be added when the use case truly requires them.

The most important consideration is therefore this: an RPC may look like a local function, but it must be designed as remote communication.

When that boundary is treated carefully, the PC can control an embedded board through a clean interface while the MCU continues to manage the physical world under clearly defined constraints and responsibilities. On the surface, it remains a function; underneath, there is a precise protocol.

Sources Consulted

Last updated 2026-07-17.
Article source content/blog/rpc_pc_microcontroller.

Author

Nicolò is a software architect based in Bergamo. He works on ESP32 firmware, HMI, native Android apps, backends, software libraries and system integrations.

Next entry

2026-07-15
MAX77972: charger, fuel gauge, and field debugging

A practical MAX77972 guide: buck charger, ModelGauge m5, USB-C, AICL, THM, initialization, wrong SOC, debugging, and firmware workarounds.