Abstract
Durante lo sviluppo di strumenti per controllare dispositivi embedded da PC mi sono posto più volte la stessa domanda: quanto può somigliare la comunicazione con un microcontrollore a una normale chiamata di funzione?
Dal punto di vista dell'applicazione sarebbe comodo poter scrivere semplicemente:
temperature = mcu.get_temperature(sensor_id=3)mcu.set_led(True)La funzione, tuttavia, non viene eseguita nel processo del PC. La richiesta deve attraversare una libreria, il sistema operativo, un trasporto come USB o UART, un parser firmware e infine l'hardware controllato dalla MCU. Qualunque errore di framing, serializzazione o sincronizzazione può interrompere questo percorso.
Le Remote Procedure Call, o RPC, permettono di nascondere parte di questa complessità dietro un'interfaccia simile a quella di una funzione locale. L'astrazione è utile, purché non faccia dimenticare che sotto rimane un protocollo distribuito, con timeout, errori parziali e risorse limitate.
Vediamo dunque come funziona una RPC e quali aspetti bisogna considerare per progettare una comunicazione robusta tra PC e microcontrollore.
Una chiamata parte come funzione sul PC, diventa una sequenza di byte, attraversa il parser firmware e ritorna sotto forma di risposta.
Che cos'è una RPC?
Una RPC è una chiamata a procedura che attraversa un confine di comunicazione.
Il confine può separare:
- due processi;
- due computer;
- un client e un servizio remoto;
- due container o macchine virtuali;
- un PC e un dispositivo embedded.
Quando chiamiamo una funzione locale, il codice e i relativi dati si trovano nello stesso spazio di esecuzione. Una funzione remota vive invece altrove: non possiamo saltare direttamente al suo indirizzo di memoria, quindi dobbiamo rappresentare la chiamata con un messaggio.
Una chiamata locale resta nello stesso processo. Una RPC attraversa un trasporto, viene interpretata dal sistema remoto e produce una risposta.
L'obiettivo dell'RPC è rendere semplice questa interazione. Il codice sul PC può apparire simile a una normale chiamata:
temperature = mcu.get_temperature(3)Sotto questa riga, però, avvengono almeno quattro operazioni:
- i parametri vengono serializzati;
- la richiesta viene inserita in un frame;
- il frame viene trasmesso e interpretato dal firmware;
- il risultato percorre il tragitto inverso.
La sintassi locale è dunque un'astrazione. La semantica rimane quella di una comunicazione remota, nella quale la controparte può rispondere in ritardo, restituire un errore, riavviarsi o non essere raggiungibile.
Gli elementi principali
Una comunicazione RPC comprende normalmente un client, un server, un contratto, uno strato di trasporto e del codice che converte le chiamate in messaggi.
Client e server
Il client invia la richiesta. Nello scenario considerato in questo articolo è normalmente l'applicazione eseguita sul PC:
leggi la temperatura del sensore 3imposta il PWM del motore a 1200accendi il LED di statoIl server riceve la richiesta ed esegue l'operazione. In questo caso coincide con il firmware della MCU, che espone funzioni simili alle seguenti:
float read_temperature(uint8_t sensor_id);void set_motor_pwm(uint16_t value);void set_led_state(bool on);I termini client e server descrivono i ruoli della singola interazione, non necessariamente il tipo di macchina. La MCU può infatti produrre anche eventi spontanei diretti al PC, come vedremo più avanti.
Contratto
Il contratto stabilisce quali metodi esistono, quali parametri accettano e quali risultati o errori possono restituire:
getTemperature(sensor_id: uint8) -> temperature: floatsetLed(state: bool) -> ok | errorgetFirmwareVersion() -> major, minor, patchPC e MCU devono interpretare questo contratto nello stesso modo. Se il PC trasmette un intero a 32 bit e la MCU legge due valori a 16 bit, la comunicazione può essere formalmente valida ma semanticamente errata.
Il contratto può essere documentato manualmente oppure descritto tramite una Interface Definition Language, spesso abbreviata in IDL. gRPC, ad esempio, utilizza comunemente Protocol Buffers per definire servizi e messaggi e per generare codice in diversi linguaggi.
Stub, proxy e dispatcher
Lo stub o proxy lato PC espone l'interfaccia utilizzata dall'applicazione:
mcu.set_led(True)temperature = mcu.get_temperature(3)Dietro queste chiamate, la libreria assegna un identificatore, serializza i parametri, costruisce il frame, lo invia e attende la risposta.
Sulla MCU opera il componente speculare. Il dispatcher interpreta il metodo richiesto e invoca l'handler corrispondente:
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;}Trasporto
Il trasporto porta i byte da un'estremità all'altra. Nei sistemi distribuiti può essere basato su TCP, HTTP/2 o TLS. Tra PC e MCU può invece utilizzare:
- UART;
- USB CDC ACM;
- una classe USB vendor-specific;
- USB HID;
- Ethernet o Wi-Fi;
- BLE;
- CAN o RS-485;
- SPI o I2C, in architetture adatte.
RPC non identifica il cavo o la periferica. Descrive l'organizzazione delle richieste, delle risposte e degli errori; USB, UART o TCP sono i mezzi sui quali questi messaggi viaggiano.
Il percorso di una chiamata
Supponiamo di voler esporre questa funzione firmware:
float get_temperature(uint8_t sensor_id);Sul PC scriviamo:
temperature = mcu.get_temperature(sensor_id=3)La libreria RPC converte la chiamata in una richiesta logica:
method: get_temperaturesensor_id: 3request_id: 42In un formato leggibile potrebbe assumere questa forma:
{ "jsonrpc": "2.0", "id": 42, "method": "get_temperature", "params": { "sensor_id": 3 }}JSON-RPC 2.0 utilizza proprio campi come method, params e id. L'identificatore permette di associare la risposta alla richiesta corretta, mentre una notifica priva di id non prevede una risposta.
Il documento logico deve poi diventare una sequenza di byte. Questa trasformazione si chiama serializzazione e può utilizzare JSON, Protocol Buffers, CBOR, MessagePack o un formato binario proprietario.
La serializzazione, da sola, non stabilisce necessariamente dove inizi e finisca un messaggio sul trasporto. Per questo interviene il framing, che aggiunge le informazioni necessarie a delimitare e validare ogni pacchetto.
Il firmware riceve i byte, ricostruisce il frame, verifica la sua integrità, decodifica il payload e invoca l'handler. Il risultato viene quindi serializzato e rispedito usando lo stesso percorso:
{ "jsonrpc": "2.0", "id": 42, "result": { "temperature": 24.7 }}In caso di errore, la risposta dovrebbe distinguere almeno la categoria e il dettaglio utile al chiamante:
{ "jsonrpc": "2.0", "id": 42, "error": { "code": -32001, "message": "Sensore non disponibile", "data": { "reason": "SENSOR_NOT_FOUND" } }}Cosa viaggia davvero sul canale
Quando scriviamo:
value = mcu.get_temperature(sensor_id=3)non stiamo trasferendo una funzione. Stiamo trasferendo una descrizione della chiamata.
Nel caso di JSON-RPC 2.0, una richiesta valida contiene almeno jsonrpc e method. Il campo params è opzionale e può contenere un array posizionale oppure un oggetto con parametri nominati. Il campo id è presente soltanto quando il chiamante desidera una risposta:
{ "jsonrpc": "2.0", "id": "pc-42", "method": "get_temperature", "params": { "sensor_id": 3 }}Una risposta positiva contiene lo stesso id e un campo result:
{ "jsonrpc": "2.0", "id": "pc-42", "result": { "temperature": 23.7, "unit": "C" }}Una risposta negativa contiene invece error. In JSON-RPC result ed error sono mutuamente esclusivi e error.code deve essere un intero. I nomi dei metodi che iniziano con rpc. sono riservati allo standard e alle sue estensioni. Un nome simbolico come SENSOR_UNAVAILABLE può essere mantenuto dentro error.data:
{ "jsonrpc": "2.0", "id": "pc-42", "error": { "code": -32001, "message": "Sensore non disponibile", "data": { "reason": "SENSOR_UNAVAILABLE", "sensor_id": 3 } }}È importante distinguere questo formato standard da un protocollo personalizzato. Un messaggio come:
{ "type": "request", "id": "pc-42", "method": "get_temperature", "params": {}}può essere perfettamente valido per il nostro progetto, ma il campo type non appartiene al nucleo di JSON-RPC 2.0. In quel caso stiamo progettando una busta RPC ispirata a JSON-RPC, non necessariamente un'implementazione strettamente interoperabile dello standard.
Questa distinzione evita diversi equivoci. Lo standard definisce la semantica dei documenti RPC; il nostro protocollo deve ancora stabilire framing, trasporto, limiti, autenticazione, sessioni e comportamento dopo una disconnessione.
RPC bidirezionale: PC e MCU possono chiamarsi
Nel caso più semplice il PC invia richieste e la MCU risponde. Il canale può però essere bidirezionale: anche la MCU può iniziare una chiamata diretta al PC.
Supponiamo che il firmware debba chiedere all'applicazione di salvare una misura:
Anche il microcontrollore può aprire una chiamata: il PC esegue l'handler e risponde con lo stesso identificatore.
La richiesta può assumere questa forma:
{ "jsonrpc": "2.0", "id": "esp-105", "method": "save_measurement", "params": { "temperature": 23.7, "unit": "C" }}Il PC risponde riutilizzando lo stesso identificatore:
{ "jsonrpc": "2.0", "id": "esp-105", "result": { "saved": true }}I termini client e server diventano quindi ruoli della singola chiamata. Quando il PC invia set_led è client e la MCU è server; quando la MCU invia save_measurement, i ruoli si invertono.
Per supportare davvero questa architettura, entrambe le estremità necessitano di:
- un ricevitore sempre attivo;
- un decoder e un classificatore dei messaggi;
- un dispatcher dei metodi esposti localmente;
- una tabella delle richieste pendenti iniziate localmente;
- un generatore di identificatori;
- timeout, errori e pulizia dello stato;
- una coda di trasmissione che serializzi l'accesso al trasporto.
Evitare deadlock e rientranza
La bidirezionalità introduce un caso limite poco evidente.
Supponiamo che il PC invii start_calibration. L'handler sulla MCU, durante l'esecuzione, chiede al PC confirm_user_present e attende sincronicamente la risposta. Se il thread del PC che dovrebbe leggere e smistare i messaggi è già bloccato in attesa del completamento di start_calibration, nessuno elaborerà la richiesta inversa proveniente dalla MCU.
Il risultato è un deadlock distribuito:
Se un handler attende la risposta a una richiesta che ha generato lui stesso, entrambi i lati restano bloccati.
La soluzione consiste nel separare il reader loop dalle chiamate applicative. Il task che riceve i frame deve continuare a leggere e classificare messaggi anche mentre altre parti del programma attendono una risposta. Gli handler lunghi devono inoltre essere spostati su una coda o su task dedicati, anziché essere eseguiti direttamente nel callback di ricezione.
In sostanza, un canale full-duplex non è sufficiente. Anche l'architettura software deve essere realmente concorrente.
Perché il caso PC–MCU è particolare
Due server dispongono normalmente di CPU potenti, memoria abbondante, socket, filesystem e strumenti di diagnostica evoluti. Una MCU può invece avere:
- pochi kilobyte di RAM;
- flash e potenza di calcolo limitate;
- buffer di ricezione ridotti;
- vincoli real-time;
- interrupt e DMA da coordinare;
- un consumo energetico da contenere;
- reset improvvisi o watchdog;
- collegamenti lenti o soggetti a disturbi.
Di conseguenza, non basta scegliere un formato e inviare dati. Bisogna stabilire cosa accade quando arriva mezzo frame, il buffer si riempie, la MCU si riavvia o una risposta viene persa.
Nel mondo embedded il protocollo non è un dettaglio di implementazione: è parte dell'architettura del sistema.
La pila completa
Una RPC tra PC e MCU può essere suddivisa in livelli:
- applicazione: “leggi la temperatura” o “imposta il PWM”;
- contratto RPC: metodi, parametri, risultati ed errori;
- serializzazione: conversione dei dati in byte;
- framing: delimitazione, lunghezza, escaping e controllo d'integrità;
- trasporto: UART, USB, TCP, CAN o altro canale;
- driver e sistema operativo: porta seriale,
libusb, WinUSB,ttyo porta COM; - livello fisico: cavo, transceiver e segnali elettrici.
La pila separa l'intenzione applicativa dal contratto, dal framing, dal trasporto e dalla logica firmware.
Ogni livello risolve un problema differente. L'applicazione esprime:
set_led(true)Il livello RPC lo rappresenta come:
method_id = 0x0003request_id = 42payload = 01Il framing aggiunge lunghezza, tipo di messaggio e controllo d'integrità. Il trasporto porta il frame alla MCU, mentre il firmware lo interpreta e chiama infine:
gpio_write(LED_PIN, 1);Questa separazione permette di sostituire, ad esempio, USB CDC con UART senza riscrivere gli handler applicativi.
Progettare un protocollo minimo
Supponiamo di definire un protocollo binario semplice. Un frame potrebbe contenere:
| Campo | Dimensione | Ruolo |
|---|---|---|
| MAGIC | 1 byte | marca l'inizio di un frame |
| LENGTH | 2 byte | lunghezza del payload che segue |
| TYPE | 1 byte | richiesta, risposta, notifica o evento |
| CORRELATION_ID | 2 byte | lega una risposta alla sua richiesta |
| PAYLOAD | N byte | i dati veri e propri |
| CRC16 | 2 byte | validazione dell'intero frame |
In questo esempio:
- MAGIC aiuta a riconoscere l'inizio di un frame;
- LENGTH indica il numero di byte compresi tra
TYPEe la fine delPAYLOAD; - TYPE distingue richieste, risposte, errori ed eventi;
- CORRELATION_ID collega una risposta alla relativa richiesta; nei messaggi non correlati può valere zero o seguire una regola specifica del protocollo;
- PAYLOAD contiene metodo, parametri o risultato;
- CRC16 rileva alterazioni accidentali del frame.
I tipi di messaggio possono essere definiti in questo modo:
#define RPC_TYPE_REQUEST 0x01#define RPC_TYPE_RESPONSE 0x02#define RPC_TYPE_ERROR 0x03#define RPC_TYPE_EVENT 0x04Il payload di una richiesta contiene almeno il metodo e i relativi parametri:
| Campo | Dimensione | Ruolo |
|---|---|---|
| METHOD_ID | 2 byte | quale metodo invocare |
| PARAMS | N byte | i parametri serializzati |
Ad esempio:
#define RPC_GET_VERSION 0x0001#define RPC_GET_TEMPERATURE 0x0002#define RPC_SET_LED 0x0003#define RPC_SET_PWM 0x0004#define RPC_REBOOT 0x0005Una richiesta e la relativa risposta potrebbero essere rappresentate così:
REQUEST request_id = 42 method_id = RPC_SET_LED params = 01RESPONSE request_id = 42 status = OKIl formato reale deve inoltre definire l'endianness dei campi, l'algoritmo CRC, i byte coperti dal calcolo, la dimensione massima del payload e il comportamento in presenza di dati non validi.
Request ID, correlazione e ciclo di vita
Il request_id permette di associare ogni risposta alla chiamata che l'ha generata.
Supponiamo che il PC invii tre richieste:
id=10 -> leggi temperaturaid=11 -> leggi tensioneid=12 -> leggi versione firmwareLa MCU può completarle in un ordine differente:
id=12 -> versione firmwareid=10 -> temperaturaid=11 -> tensioneSenza identificatore, il PC non potrebbe correlare le risposte in presenza di concorrenza.
Quanto deve essere unico un ID?
Un identificatore non deve essere necessariamente unico per tutta la vita del prodotto. Deve però essere non ambiguo nell'ambito in cui viene utilizzato.
La regola pratica è:
nessun ID può essere riutilizzato mentre esiste ancorauna richiesta pendente con lo stesso ID nella stessa sessioneDopo il completamento della chiamata, l'identificatore può essere riutilizzato. Tuttavia un riutilizzo troppo rapido può confondere una risposta tardiva con una richiesta nuova. Per questo motivo conviene:
- mantenere un contatore abbastanza ampio, ad esempio a 32 o 64 bit;
- non riutilizzare immediatamente gli ID scaduti;
- scartare e registrare le risposte relative a ID sconosciuti;
- azzerare la tabella delle richieste pendenti quando cambia sessione;
- associare la connessione a un
session_ido a unboot_idquando i reset sono frequenti.
Nel caso di un contatore a 16 bit, il wrap-around non è di per sé un errore. Lo diventa se il nuovo valore coincide con una richiesta ancora pendente o con una risposta tardiva che può ancora arrivare.
ID numerici o stringhe?
JSON-RPC permette identificatori stringa o numerici e scoraggia normalmente null. Per un protocollo binario, un intero è più compatto. In fase di debug, una stringa può risultare più leggibile:
{ "id": "pc-42", "method": "get_status"}In un sistema bidirezionale si possono usare prefissi distinti:
PC: pc-1, pc-2, pc-3ESP32: esp-1, esp-2, esp-3I prefissi non sono obbligatori. Se ogni endpoint mantiene una tabella separata per le richieste che ha iniziato e il tipo di messaggio distingue chiaramente richiesta e risposta, 42 inviato dal PC e 42 inviato dalla MCU possono coesistere senza conflitto.
I namespace separati rimangono comunque utili per:
- leggere i log più facilmente;
- attraversare bridge o router che unificano più canali;
- diagnosticare messaggi classificati in modo errato;
- evitare ambiguità in implementazioni personalizzate.
Tabella delle richieste pendenti
Quando il PC invia una richiesta, memorizza almeno:
ID Metodo Inviata Scadenza Statopc-41 get_version 14:30:01.100 14:30:01.600 waitingpc-42 read_temperature 14:30:02.000 14:30:02.200 waitingAll'arrivo di questa risposta:
{ "jsonrpc": "2.0", "id": "pc-42", "result": { "temperature": 23.7 }}il ricevitore:
- cerca
pc-42nella tabella; - verifica che la sessione sia ancora valida;
- consegna il risultato alla chiamata in attesa;
- marca la richiesta come completata;
- rimuove o archivia l'entry.
Se la risposta non arriva entro il timeout, il chiamante riceve un errore e l'entry viene rimossa. Un'eventuale risposta successiva deve essere trattata come late response: normalmente viene registrata e scartata, non associata a una richiesta nuova.
Sessione, boot ID e risposte obsolete
Il solo request_id non risolve il caso in cui una delle due estremità si riavvii.
Supponiamo che il PC invii id=17, la MCU si resetti e il PC riconnetta la porta. Se il firmware riparte dal contatore 1, il valore 17 potrà ricomparire. Un frame rimasto in una coda o una risposta proveniente dalla sessione precedente non deve essere accettato come corrente.
Un handshake iniziale può quindi restituire:
{ "protocol_version": 2, "session_id": "7f4a9c21", "boot_id": 184, "max_pending_requests": 4, "max_payload_size": 256}Il session_id identifica la connessione logica corrente; il boot_id cambia a ogni avvio della MCU. Questi campi non fanno parte di JSON-RPC 2.0, ma sono spesso utili nella busta applicativa o nella fase di negoziazione.
Stato di una richiesta
Una richiesta reale attraversa più stati di quanto suggerisca una semplice funzione:
Il ciclo di vita di una richiesta in attesa: ogni stato terminale libera la voce dalla tabella dei pending.
Questa macchina a stati aiuta a chiarire dove siano avvenuti gli errori. Un timeout non equivale a un rifiuto del comando e una scrittura completata sul PC non prova che l'handler firmware sia stato eseguito.
Framing: ricostruire i messaggi
UART, TCP e l'interfaccia seriale offerta da USB CDC espongono all'applicazione un flusso di byte. Una singola read() non corrisponde necessariamente a un frame completo:
PC invia:[frame A][frame B][frame C]MCU riceve:prima lettura: metà frame Aseconda lettura: fine frame A + inizio frame Bterza lettura: fine frame B + frame CIl parser deve quindi accumulare i byte e ricostruire i messaggi. Esistono diverse strategie.
Prefisso di lunghezza
Il frame dichiara la propria dimensione all'inizio:
[LENGTH][PAYLOAD][CRC]La soluzione è compatta ed efficiente. Una lunghezza corrotta o non validata può però causare perdita di sincronizzazione o richieste di memoria eccessive. Il parser deve sempre rifiutare valori superiori alla dimensione massima prevista.
Delimitatore ed escaping
Un valore riservato marca la fine del frame:
[PAYLOAD_ESCAPED][END]Se il delimitatore compare nei dati, deve essere codificato tramite escaping. SLIP utilizza, ad esempio, i caratteri speciali END ed ESC. Offre il framing, ma non definisce da solo indirizzamento, tipo del messaggio o controllo d'integrità.
JSON delimitato da newline
Per un protocollo leggibile si può terminare ogni documento JSON con un carattere newline:
{"jsonrpc":"2.0","id":"pc-1","method":"get_status","params":{}}\n{"jsonrpc":"2.0","method":"button_pressed","params":{"button":1}}\nIl ricevitore accumula i byte finché incontra \n. La soluzione è semplice e comoda per prototipi, terminali e log.
Bisogna comunque definire:
- una lunghezza massima della riga;
- cosa fare con UTF-8 non valido;
- se accettare
\r\noltre a\n; - come recuperare dopo una riga malformata;
- se gli spazi o le righe vuote siano validi.
Le newline contenute in una stringa JSON vengono codificate come \\n, quindi non coincidono con il delimitatore fisico della riga. Il problema principale rimane una riga senza terminatore che continui a riempire il buffer: il parser deve interromperla quando supera max_message_size.
Prefisso di lunghezza binario
In produzione è comune anteporre una lunghezza a dimensione fissa:
[uint32 length][payload di length byte]Il campo può indicare il solo payload oppure l'intero frame. La scelta è secondaria; è essenziale documentarla insieme all'endianness.
Il parser segue una logica semplice:
1. accumula 4 byte2. decodifica length3. rifiuta length > MAX_PAYLOAD4. accumula esattamente length byte5. consegna il payload al decoderUn prefisso di lunghezza non sostituisce la validazione. Se il campo viene corrotto, il ricevitore può perdere la sincronizzazione; un magic iniziale, un CRC e una strategia di resync aiutano a recuperare.
COBS
Consistent Overhead Byte Stuffing, o COBS, trasforma una sequenza affinché non contenga un determinato valore, spesso 0x00. Quel valore può quindi essere utilizzato come delimitatore affidabile.
COBS elimina dal payload il valore riservato e permette di utilizzare, ad esempio, 0x00 come separatore tra frame.
COBS è interessante nei protocolli embedded perché ha un overhead limitato e permette di ritrovare il confine successivo dopo un errore.
Framing in stile HDLC
Un'altra soluzione usa un flag di delimitazione, escaping e controllo d'integrità:
0x7E payload_escaped crc 0x7ESe 0x7E o il byte di escape compaiono nel contenuto, vengono sostituiti con sequenze codificate. Anche in questo caso bisogna documentare esattamente quali byte siano soggetti a escaping e quali siano coperti dal CRC.
WebSocket: confini di messaggio e frammentazione
WebSocket introduce il concetto di messaggio, quindi non espone semplicemente un flusso di byte come TCP. È dunque possibile trasportare un documento RPC per messaggio WebSocket.
Tenete però presente una distinzione: un messaggio WebSocket può essere composto da più frame. Alcune API ricompongono tutto prima di consegnarlo all'applicazione; altre espongono porzioni successive. Il client WebSocket di Espressif, ad esempio, può generare più eventi quando il messaggio supera la dimensione del buffer.
Di conseguenza, non bisogna assumere che ogni callback contenga sempre un documento JSON completo. L'implementazione deve verificare almeno:
- offset del frammento;
- lunghezza totale prevista;
- opcode iniziale;
- flag di fine messaggio;
- dimensione massima accettata.
WebSocket risolve parte del framing applicativo, ma non elimina i limiti di memoria né la necessità di ricomporre dati frammentati secondo l'API utilizzata.
CRC e validazione del frame
Rumore elettrico, byte persi, buffer overflow e desincronizzazioni possono alterare un frame. Un controllo d'integrità permette di rilevare molte di queste condizioni.
Su UART o RS-485 un CRC applicativo è spesso molto utile. Su USB e TCP esistono già controlli e ritrasmissioni ai livelli inferiori, quindi un secondo CRC non è automaticamente necessario. Può comunque servire per rilevare errori introdotti dal framing, dalla memoria, da bridge intermedi o dalla memorizzazione del payload. La scelta dipende dunque dal modello di errore reale, non dalla semplice presenza di un cavo.
[HEADER][PAYLOAD][CRC16]Il ricevente ricalcola il CRC sui byte definiti dal protocollo e confronta il risultato con quello trasmesso:
if (crc_received != crc_calculated) { discard_packet(); rpc_record_transport_error(RPC_ERR_BAD_CRC);}In generale è più prudente scartare un frame corrotto senza tentare di interpretarne i campi. Inviare una risposta di errore può essere sensato solo quando indirizzo e correlazione rimangono affidabili e il protocollo definisce esplicitamente tale comportamento.
Un checksum semplice può essere sufficiente durante un prototipo, mentre un protocollo destinato a operare su collegamenti reali beneficia spesso di un CRC scelto e documentato correttamente.
Il CRC non offre sicurezza crittografica: rileva errori accidentali, ma chi modifica volontariamente un messaggio può ricalcolare anche il CRC.
Scegliere la serializzazione
La scelta fra JSON, Protocol Buffers, CBOR e un formato proprietario incide su memoria, banda, strumenti di sviluppo e compatibilità futura.
JSON
JSON è leggibile e semplice da ispezionare:
{ "id": 12, "method": "set_pwm", "params": { "channel": 1, "value": 1200 }}È adatto ai prototipi e alle MCU con risorse sufficienti. È però verboso, richiede conversioni testuali e può comportare parser, memoria temporanea e tempi di elaborazione non trascurabili.
Protocol Buffers
Protocol Buffers utilizza uno schema e produce messaggi binari compatti. Permette inoltre di generare codice per linguaggi differenti e, se progettato correttamente, di evolvere il formato mantenendo una certa compatibilità.
message RpcRequest { uint32 id = 1; uint32 method = 2; bytes payload = 3;}message RpcResponse { uint32 id = 1; uint32 status = 2; bytes payload = 3;}Su microcontrollori si può utilizzare un'implementazione come nanopb, progettata per sistemi con vincoli di RAM e ROM. Il costo è una maggiore dipendenza dagli strumenti di generazione e da una libreria di runtime.
CBOR
CBOR offre un modello dati ricco e binario, con obiettivi che includono messaggi compatti, implementazioni contenute ed estensibilità. Può essere una scelta utile quando si desidera maggiore flessibilità rispetto a uno schema rigido senza pagare tutta la verbosità di JSON.
Rimane necessario scegliere una libreria affidabile e definire un profilo del formato: accettare qualunque rappresentazione valida di CBOR può aumentare inutilmente la complessità del parser.
Formato binario proprietario
Un formato progettato su misura consente parser piccoli e controllo completo:
method_id: 0x0004channel: 0x01value: 0x04B0È spesso adatto a MCU molto limitate, ma richiede una specifica rigorosa, strumenti di debug dedicati e un piano di versionamento. L'efficienza iniziale non compensa un protocollo ambiguo o impossibile da evolvere.
In sostanza, non esiste una scelta universalmente migliore. JSON privilegia ispezionabilità e rapidità di sviluppo; Protocol Buffers e CBOR offrono formati più compatti; il binario proprietario massimizza il controllo al prezzo di un maggiore lavoro progettuale.
Endianness, allineamento e tipi
Un protocollo binario deve definire esplicitamente:
- l'endianness degli interi multibyte;
- la rappresentazione dei numeri con segno;
- il formato dei valori in virgola mobile;
- le unità di misura;
- i limiti validi di ogni campo;
- la gestione di stringhe e lunghezze.
Inviare direttamente una struttura C è fragile:
typedef struct { uint8_t id; uint32_t value;} packet_t;uart_write((uint8_t*)&packet, sizeof(packet));Il compilatore può inserire padding, l'allineamento può cambiare tra architetture e l'endianness può differire. Anche eventuali byte non inizializzati rischiano di essere trasmessi.
È preferibile serializzare ogni campo secondo regole esplicite:
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);In alternativa si può adottare un formato che definisca già tali convenzioni.
Architettura del firmware
Sul microcontrollore conviene separare ricezione, framing, serializzazione e logica applicativa.
Driver, trasporto, parser, dispatcher e handler rimangono separati, così ogni livello può essere verificato e sostituito senza coinvolgere gli altri.
Ricezione e buffer
I byte possono arrivare tramite interrupt, DMA o callback dello stack USB. Questi contesti dovrebbero svolgere il minimo lavoro necessario: acquisire i dati, aggiornare gli indici e segnalare che sono disponibili nuovi byte.
Il parsing e l'esecuzione degli handler possono avvenire nel main loop o in un task RTOS. Un ring buffer permette di separare il ritmo della periferica da quello del parser, purché siano definite una dimensione massima e una politica di overflow.
Parser a stati
Una macchina a stati si adatta bene a un flusso che può arrivare in frammenti:
typedef enum { WAIT_MAGIC, READ_LENGTH, READ_BODY, READ_CRC} parser_state_t;Il parser avanza solo quando dispone dei byte necessari:
WAIT_MAGIC -> cerca l'inizio del frameREAD_LENGTH -> legge e valida la lunghezzaREAD_BODY -> accumula il numero previsto di byteREAD_CRC -> verifica l'integrità
La ricezione resta leggera; il parser ricostruisce e valida un frame completo prima di consegnarlo al dispatcher.
Il parser deve controllare ogni lunghezza prima di riservare o copiare memoria e deve potersi risincronizzare dopo dati corrotti. Solo un frame completo e valido può raggiungere il dispatcher.
Dispatcher e handler
Il dispatcher associa il method_id a una funzione applicativa:
void rpc_dispatch(rpc_request_t const* 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; }}Gli handler non dovrebbero conoscere il trasporto. handle_set_led() deve ricevere parametri già validati e produrre un risultato logico, senza leggere direttamente dalla UART o costruire pacchetti USB.
Questa separazione rende il firmware più testabile e permette di riutilizzare lo stesso contratto su trasporti differenti.
La libreria sul PC
Anche sul PC conviene isolare il protocollo in una libreria:
mcu = McuRpcClient(port="/dev/ttyACM0")version = mcu.get_version()temperature = mcu.get_temperature(3)mcu.set_led(True)La libreria si occupa di:
- assegnare il
request_id; - validare e serializzare i parametri;
- costruire e inviare il frame;
- accumulare i byte ricevuti;
- correlare risposta e richiesta;
- applicare timeout e politiche di retry;
- tradurre gli errori del protocollo;
- gestire disconnessione e riconnessione.
L'applicazione può così lavorare con un'interfaccia pulita senza duplicare continuamente chiamate a read(), calcoli CRC e gestione dei buffer.
Questa è la principale utilità pratica dell'RPC: la complessità della comunicazione rimane confinata in componenti dedicati, mentre il codice applicativo esprime l'operazione da compiere.
Il motore RPC dentro le due estremità
A questo punto possiamo descrivere il comportamento reale del motore RPC. Ogni messaggio completo viene prima classificato e solo dopo consegnato al componente corretto.
In un protocollo con tipo esplicito:
void rpc_on_message(rpc_message_t const* msg) { switch (msg->type) { case RPC_MSG_RESPONSE: case RPC_MSG_ERROR: rpc_complete_pending(msg); break; case RPC_MSG_REQUEST: rpc_enqueue_request(msg); break; case RPC_MSG_NOTIFICATION: rpc_enqueue_notification(msg); break; case RPC_MSG_NOTIFICATION_ACK: rpc_complete_event_delivery(msg); break; default: rpc_record_protocol_error(RPC_ERR_UNKNOWN_TYPE); break; }}Il callback di ricezione non esegue direttamente un handler lento. Inserisce invece la richiesta in una coda. Un task dedicato valida i parametri, esegue il metodo e costruisce la risposta.
Tabella fissa sul microcontrollore
Sul PC una mappa dinamica è naturale. Su una MCU può essere preferibile una tabella a dimensione fissa:
#define RPC_MAX_PENDING 4typedef enum { RPC_PENDING_FREE, RPC_PENDING_WAITING, RPC_PENDING_COMPLETED, RPC_PENDING_TIMED_OUT} rpc_pending_state_t;typedef struct { uint32_t id; uint32_t deadline_ms; uint16_t method_id; rpc_pending_state_t state; rpc_callback_t callback; void* user_data;} rpc_pending_t;static rpc_pending_t pending[RPC_MAX_PENDING];Quando la MCU chiama il PC:
rpc_status_t rpc_call_pc( uint16_t method_id, uint8_t const* payload, size_t payload_len, uint32_t timeout_ms, rpc_callback_t callback, void* user_data) { rpc_pending_t* slot = rpc_pending_allocate(); if (slot == NULL) { return RPC_ERR_TOO_MANY_PENDING; } slot->id = rpc_next_request_id(); slot->method_id = method_id; slot->deadline_ms = monotonic_ms() + timeout_ms; slot->state = RPC_PENDING_WAITING; slot->callback = callback; slot->user_data = user_data; if (!rpc_send_request(slot->id, method_id, payload, payload_len)) { rpc_pending_release(slot); return RPC_ERR_TRANSPORT; } return RPC_OK;}Questa funzione non blocca. La risposta verrà consegnata dal reader task al callback associato. Il main loop può inoltre scandire la tabella e marcare come scadute le entry oltre la relativa deadline. Nel codice reale il confronto fra timestamp a 32 bit deve essere eseguito in modo sicuro rispetto al wrap-around, ad esempio confrontando la differenza come intero con segno anziché usando un semplice now > deadline.
Pending map sul PC
Una versione asincrona semplificata in Python può essere:
import asyncioimport itertoolsclass RpcPeer: def __init__(self, transport): self.transport = transport self.pending = {} self.ids = itertools.count(1) async def call(self, method, params, timeout=0.5): request_id = f"pc-{next(self.ids)}" loop = asyncio.get_running_loop() future = loop.create_future() self.pending[request_id] = future request = { "jsonrpc": "2.0", "id": request_id, "method": method, "params": params, } try: await self.transport.send_message(request) return await asyncio.wait_for(future, timeout) finally: self.pending.pop(request_id, None) def handle_response(self, message): request_id = message.get("id") future = self.pending.get(request_id) if future is None: self.log_late_or_unknown_response(message) return if "error" in message: future.set_exception(RpcRemoteError(message["error"])) else: future.set_result(message["result"])Il metodo call() non deve essere anche responsabile di leggere il trasporto. Un reader task separato continua a ricevere messaggi e chiama handle_response() oppure il dispatcher delle richieste inverse.
Una sola coda di trasmissione
Anche quando più task generano risposte, eventi e chiamate, è prudente avere un solo punto che scriva sul trasporto:
Tutti i produttori passano dalla stessa coda di trasmissione, altrimenti due task potrebbero intrecciare i byte di due frame.
In questo modo due frame non vengono interlacciati a livello di byte e le priorità possono essere gestite esplicitamente. Per messaggi urgenti si possono usare code separate o una coda con priorità, purché non si causi starvation dei messaggi ordinari.
Richieste, risposte, notifiche ed eventi
La comunicazione non è sempre una semplice sequenza richiesta-risposta.
Una richiesta prevede una risposta correlata:
{ "jsonrpc": "2.0", "id": "pc-7", "method": "set_led", "params": { "state": true }}Risposta:
{ "jsonrpc": "2.0", "id": "pc-7", "result": { "ok": true }}Una notifica JSON-RPC è invece una richiesta priva di id:
{ "jsonrpc": "2.0", "method": "button_pressed", "params": { "button": 1 }}L'assenza di id comunica che il mittente non attende una risposta. Il ricevente non deve rispondere nemmeno quando il metodo o i parametri sono errati.
Una notifica non prevede risposta: chi la invia non sa se sia arrivata.
Questo comportamento rende le notifiche semplici, ma ha una conseguenza: il mittente non può sapere se il messaggio sia stato elaborato correttamente.
Evento applicativo e notifica RPC non sono sinonimi perfetti
Un evento descrive normalmente un fatto avvenuto:
button_pressedtemperature_threshold_exceededmotor_fault_detectedLa notifica descrive invece il pattern di comunicazione: un messaggio unidirezionale senza risposta RPC.
Un evento può dunque essere trasportato come notifica, ma può anche utilizzare un protocollo affidabile con ACK, persistenza o replay.
Sul PC un evento semplice può diventare un callback:
def on_button_pressed(event): print("Pulsante premuto:", event["button"])mcu.on("button_pressed", on_button_pressed)Sequence number, notify ID e ACK
Alcuni protocolli personalizzati aggiungono un identificatore all'evento:
{ "type": "notification", "notify_id": 81, "method": "motion_detected", "params": { "zone": 2 }}Questo notify_id non è un request ID JSON-RPC. Può servire per:
- rilevare duplicati;
- preservare o verificare l'ordine;
- confermare la consegna;
- riprendere un flusso dopo una riconnessione;
- correlare log e metriche.
Il PC può rispondere con un ACK personalizzato:
{ "type": "notification_ack", "notify_id": 81}Il flusso diventa:
L'ack rende verificabile la consegna: il mittente può ritrasmettere e il destinatario può scartare i duplicati.
Se l'ACK non arriva, la MCU può ritrasmettere. Tuttavia il PC deve deduplicare, perché l'evento originale potrebbe essere stato elaborato e soltanto l'ACK potrebbe essere andato perso.
Questo modello offre una consegna almeno una volta a livello di tentativi: l'evento arriverà nuovamente finché non viene confermato, ma può essere osservato più di una volta. Per avvicinarsi a una semantica al massimo una volta serve una cache degli identificatori già elaborati. Una garanzia di esattamente una volta attraverso reset, perdita di alimentazione e memoria non volatile richiede invece una progettazione transazionale molto più complessa.
Stream e numeri di sequenza
Per uno stream è utile introdurre un numero di sequenza:
sample_seq = 1001sample_seq = 1002sample_seq = 1005Il salto permette al PC di rilevare la perdita dei campioni 1003 e 1004. Un numero di sequenza non prova però che i campioni mancanti siano recuperabili: il protocollo deve stabilire se conservarli, ritrasmetterli o accettarne la perdita.
Per ogni stream conviene definire:
- identificatore dello stream;
- formato e unità dei campioni;
- frequenza nominale e massima;
- numero di sequenza e comportamento al wrap-around;
- timestamp e dominio temporale;
- politica di overflow;
- modalità di avvio, arresto e ripresa.
Heartbeat e liveness
Un heartbeat può indicare che il firmware continua a funzionare:
{ "type": "heartbeat", "sequence": 440, "uptime_ms": 913442, "boot_id": 184}La sua assenza per un certo intervallo suggerisce una disconnessione o un blocco, ma non identifica necessariamente la causa. Inoltre un collegamento TCP stabilito non garantisce da solo che l'applicazione remota stia ancora elaborando messaggi. Heartbeat, timeout applicativi e meccanismi di keepalive risolvono problemi differenti e non vanno confusi.
USB nel collegamento PC–MCU
Prima di parlare del protocollo è utile chiarire che “ESP32 collegata tramite USB” può indicare architetture differenti. Molte schede basate sull'ESP32 originale utilizzano un bridge esterno USB–UART: il PC comunica con il bridge, mentre la MCU vede una normale UART. Alcuni chip della famiglia integrano invece una periferica USB Serial/JTAG a funzione fissa; altri espongono USB OTG e possono utilizzare uno stack device come TinyUSB per implementare CDC, HID o classi vendor-specific.
Queste soluzioni possono tutte apparire come una porta seriale sul PC, ma non hanno lo stesso comportamento. Cambiano la gestione del reset, i buffer, i descrittori, gli endpoint disponibili e la possibilità di personalizzare il device. Il livello RPC dovrebbe quindi dipendere da un backend di trasporto astratto, non dalla supposizione che qualunque connettore USB corrisponda alla stessa periferica firmware.
In una connessione USB tradizionale il PC opera normalmente come host e la MCU come device. È l'host a pianificare le transazioni sul bus; il device rende disponibili dati ed endpoint secondo le regole del protocollo USB.
Gli endpoint sono punti logici del dispositivo attraverso i quali vengono trasferiti i dati. Sul lato host vengono rappresentati tramite pipe gestite dallo stack USB.
Con USB CDC l'applicazione vede spesso una porta seriale virtuale, mentre sotto operano driver, stack host e device, endpoint e firmware.
Enumerazione ed endpoint di controllo
Ogni dispositivo espone l'endpoint di controllo 0. Durante l'enumerazione:
- l'host rileva e resetta il device;
- interroga l'endpoint 0 e assegna un indirizzo;
- legge i descrittori USB;
- identifica classe, configurazioni, interfacce ed endpoint;
- seleziona il driver appropriato;
- rende il dispositivo disponibile alle applicazioni.
Una MCU configurata come USB CDC ACM appare spesso come porta seriale virtuale:
COM7su Windows;/dev/ttyACM0su GNU/Linux;/dev/cu.usbmodem...su macOS.
Questa soluzione semplifica l'integrazione perché nella maggior parte dei casi utilizza driver già presenti nel sistema operativo. Non elimina però la necessità di framing, timeout e gestione delle disconnessioni.
UART e USB CDC come flussi di byte
Sia una UART sia l'interfaccia seriale di USB CDC richiedono che l'applicazione ricostruisca i messaggi. Non bisogna assumere che una write() corrisponda a una singola read() dall'altra parte.
È utile anche evitare un'ambiguità terminologica. La UART utilizza già un frame fisico composto da start bit, bit dati, eventuale parità e stop bit. Quel frame rappresenta normalmente un singolo carattere, non il messaggio RPC applicativo. Il framing descritto in questo articolo opera quindi a un livello superiore.
Con UART bisogna inoltre definire:
- baud rate;
- parità e stop bit;
- livelli logici e transceiver;
- controllo di flusso;
- tolleranza degli oscillatori;
- dimensione dei buffer;
- comportamento in caso di overflow.
RTS/CTS o un controllo di flusso software possono impedire che il trasmettitore sovraccarichi un ricevente più lento. Con USB CDC cambiano i dettagli elettrici e di trasporto, ma rimangono buffer finiti, latenza, timeout, reset della porta e disconnessioni del device.
Timeout, deadline, retry e idempotenza
Una chiamata può fallire perché la MCU è occupata, il cavo è stato scollegato, il firmware si è riavviato o la risposta è andata persa. Il client deve quindi applicare un timeout:
invio request id=42attendo al massimo 200 msnessuna risposta -> timeoutIl valore corretto dipende dal trasporto e dal tempo massimo previsto per l'operazione. Un timeout unico per qualunque metodo è semplice, ma può risultare troppo aggressivo per i comandi lenti e troppo permissivo per quelli immediati.
Per misurare le scadenze conviene usare un clock monotono. L'orologio civile può cambiare a causa di sincronizzazione NTP, fuso orario o regolazioni manuali e non è adatto a calcolare la durata di una chiamata.
Un timeout non dice se il comando sia stato eseguito
Questa è una delle proprietà più importanti delle RPC.
Supponiamo che il PC invii dispense_liquid(10). La MCU esegue il comando, ma la risposta viene persa. Dal punto di vista del PC la chiamata va in timeout:
Il timeout dice solo che non è arrivato nulla, non se il comando sia stato eseguito.
Il timeout significa soltanto: il chiamante non ha ricevuto una risposta entro il limite. Non significa automaticamente che il metodo non sia iniziato o non sia terminato.
Questo distingue una chiamata remota da una funzione locale. Dopo un errore di rete, lo stato reale può essere incerto.
Retry sicuri e non sicuri
Dopo un timeout si può tentare un retry, ma non tutte le operazioni sono sicure da ripetere.
Un comando è idempotente quando più esecuzioni producono lo stesso stato finale:
set_led(true) -> idempotentetoggle_led() -> non idempotenteset_motor_speed(1200) -> idempotenteincrease_counter() -> non idempotentedispense_liquid(10ml) -> non idempotenteUna politica pratica può essere:
| Categoria | Retry automatico | Nota |
|---|---|---|
| lettura | spesso sì | verificare costo e freschezza del dato |
| impostazione di uno stato | spesso sì | preferire metodi idempotenti |
| incremento o toggle | normalmente no | può applicare due volte la trasformazione |
| azione fisica irreversibile | no, senza dedupe | richiede token e controllo lato MCU |
| operazione lunga | dipende | usare operation_id e interrogazione |
Anche per le operazioni idempotenti bisogna limitare tentativi e frequenza. Un retry aggressivo può saturare una MCU già in difficoltà. Backoff, jitter e un numero massimo di tentativi evitano una tempesta di richieste.
Deduplica tramite operation token
Per una chiamata non idempotente il chiamante può generare un token stabile e riutilizzarlo in ogni tentativo:
{ "jsonrpc": "2.0", "id": "pc-44", "method": "dispense_liquid", "params": { "milliliters": 10, "operation_token": "op-8f91a2" }}La MCU conserva temporaneamente il risultato associato a op-8f91a2. Se riceve nuovamente lo stesso token, non ripete l'azione e restituisce il risultato precedente:
prima ricezione op-8f91a2 -> esegue, salva risultatoseconda ricezione -> non esegue, restituisce cacheLa cache deve avere limiti chiari:
- durata delle entry;
- numero massimo di token;
- comportamento dopo un reboot;
- eventuale persistenza in flash;
- coerenza con l'azione fisica o transazionale.
Un token mantenuto soltanto in RAM protegge dai retry nella stessa sessione, ma non da un reset avvenuto dopo l'esecuzione e prima della risposta.
Cancellazione
Un timeout lato PC non interrompe automaticamente l'handler firmware. Per le operazioni cancellabili si può definire un messaggio esplicito:
{ "jsonrpc": "2.0", "id": "pc-51", "method": "cancel_operation", "params": { "operation_id": 7 }}La cancellazione è normalmente best effort. Il comando può arrivare quando l'operazione è già terminata o quando ha superato un punto non più reversibile. L'handler deve quindi controllare periodicamente un flag di cancellazione e restituire uno stato preciso:
{ "jsonrpc": "2.0", "id": "pc-51", "result": { "status": "CANCELLED" }}Gli stati possibili possono includere CANCELLED, ALREADY_COMPLETED, NOT_CANCELLABLE e UNKNOWN_OPERATION. Se il chiamante non desidera alcuna conferma, cancel_operation può essere inviato come notifica priva di id, accettando però di non conoscere l'esito.
Request ID e operation ID
Il request_id correla la risposta immediata a una chiamata. L'operation_id identifica invece un'attività che continua nel tempo:
REQUEST id=pc-50 start_calibration()RESPONSE id=pc-50 accepted operation_id=7EVENT operation_id=7 progress=25EVENT operation_id=7 progress=80EVENT operation_id=7 completed=OKI due identificatori non sono intercambiabili. Il primo vive per la durata della richiesta; il secondo può rimanere valido per minuti, attraversare più messaggi e supportare query come get_operation_status(7).
Quando possibile, è preferibile esporre operazioni che descrivano lo stato desiderato, come set_motor_speed(1200), anziché trasformazioni relative come increase_motor_speed(100).
Errori applicativi e di trasporto
“Operazione fallita” non contiene informazioni sufficienti per diagnosticare un protocollo. Conviene distinguere almeno:
#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 0x09Gli errori non appartengono tutti allo stesso livello:
| Errore | Livello | Significato |
|---|---|---|
| BAD_CRC | trasporto | frame alterato, normalmente da scartare |
| UNKNOWN_METHOD | contratto | metodo non esposto dal firmware |
| INVALID_ARGUMENT | applicazione | parametri non validi |
| BUSY | applicazione | richiesta valida, risorsa non disponibile |
| NOT_ALLOWED | stato/sicurezza | comando vietato nello stato corrente |
La libreria sul PC può tradurre questi codici in eccezioni o risultati tipizzati, mantenendo comunque disponibile il dettaglio originale per i log.
Versionamento e capability discovery
L'applicazione e il firmware possono evolvere in momenti differenti. Il PC deve quindi scoprire la versione del protocollo e le funzionalità realmente disponibili.
Una chiamata iniziale può restituire:
{ "protocol_version": 2, "firmware_version": "1.3.0", "device": "motor-controller-x1", "capabilities": [ "get_temperature", "set_pwm", "stream_logs" ], "max_payload_size": 256}È utile distinguere la versione del firmware da quella del protocollo. Un aggiornamento interno non implica necessariamente un cambiamento del contratto.
Nel caso di JSON-RPC, il campo jsonrpc con valore 2.0 identifica la versione dello standard JSON-RPC, non la versione dei metodi della nostra applicazione. Per il contratto embedded servono campi separati come protocol_version, schema_version o capabilities.
Alcune regole pratiche:
- non cambiare il significato di un
method_idesistente; - non riutilizzare immediatamente identificatori o campi rimossi;
- aggiungere campi opzionali quando il formato lo consente;
- definire il comportamento per i campi sconosciuti;
- negoziare esplicitamente le modifiche incompatibili;
- dichiarare limiti come
max_payload_sizee numero massimo di richieste pendenti.
Controllo di flusso e backpressure
Il PC può produrre dati molto più rapidamente di quanto la MCU riesca a elaborarli. Senza una strategia di controllo, i buffer si riempiono e i frame vengono persi.
Le soluzioni possibili includono:
- una sola richiesta pendente;
- una finestra massima di
Nrichieste; - risposte
BUSYo crediti espliciti; - ACK e NACK;
- RTS/CTS su UART;
- rate limiting sul PC;
- code TX/RX con overflow rilevabile;
- limiti separati per comandi ed eventi.
Per un primo protocollo embedded, la sequenza più semplice è spesso sufficiente:
il PC invia una richiestaattende la rispostainvia la richiesta successivaIl throughput è inferiore, ma il numero di stati e casi limite rimane contenuto. Il pipelining può essere introdotto quando misure reali dimostrano che è necessario.
Anche gli stream richiedono backpressure. Se il PC non legge abbastanza rapidamente, il protocollo deve stabilire se perdere i campioni più vecchi, fermare lo stream o segnalare l'overflow.
Operazioni lunghe e real-time
Il parser RPC non deve compromettere attività come controllo motore, acquisizione ADC, comunicazione CAN o gestione del watchdog.
Un handler bloccante di diversi secondi è problematico:
void handle_rpc_request(void) { perform_slow_measurement(); wait_until_complete();}Per le operazioni lunghe è spesso preferibile un modello asincrono:
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
La MCU accetta il comando, restituisce un identificatore e comunica l'avanzamento senza bloccare l'intero sistema.
L'operation_id non sostituisce il request_id: il primo identifica un'attività che prosegue nel tempo, il secondo correla la risposta immediata alla chiamata.
Sicurezza
Un collegamento locale non è automaticamente affidabile. La protezione necessaria dipende dal prodotto e dalle conseguenze dei comandi esposti.
Bisogna chiedersi:
- quali processi possono aprire il dispositivo;
- se esistono operazioni che muovono attuatori o modificano calibrazioni;
- se il protocollo permette letture di memoria o aggiornamenti firmware;
- quale stato deve avere la macchina prima di accettare un comando;
- se un attaccante può osservare o alterare il canale.
Le contromisure possono comprendere:
- permessi del sistema operativo sul device;
- whitelist dei metodi disponibili in produzione;
- validazione rigorosa dei parametri e dello stato;
- rate limiting;
- autenticazione crittografica per i comandi critici;
- aggiornamenti firmware firmati e verificati;
- disabilitazione delle interfacce di debug;
- separazione fra modalità operative e di manutenzione.
Un generico challenge-response non è sufficiente se non è costruito con primitive crittografiche e una gestione sicura delle chiavi. Allo stesso modo, un CRC non autentica il mittente e non protegge dalle modifiche intenzionali.
Per uno strumento interno da laboratorio possono essere accettabili misure limitate. Un prodotto distribuito che controlla potenza, motori o dati sensibili richiede invece un'analisi delle minacce esplicita.
Sistema operativo e driver
L'applicazione sul PC passa normalmente attraverso API del sistema operativo, driver e stack di comunicazione.
Il programma utilizza API e driver del sistema operativo prima di raggiungere la periferica e il firmware della MCU.
Su GNU/Linux una porta seriale viene generalmente esposta come file speciale. Chiamate come read() e write() trasferiscono i dati, mentre ioctl() o API dedicate consentono di configurare proprietà del dispositivo.
Su Windows, una classe USB CDC appare spesso come porta COM. Una classe vendor-specific può invece utilizzare WinUSB, libusb o un driver dedicato. DeviceIoControl() permette inoltre alle applicazioni di inviare codici di controllo a un driver quando l'architettura lo richiede.
Questi dettagli dovrebbero rimanere confinati nel backend di trasporto della libreria, così l'interfaccia RPC non dipende dalla piattaforma.
RPC non significa necessariamente gRPC
gRPC è un framework RPC moderno, diffuso nei sistemi distribuiti. Utilizza HTTP/2 e spesso Protocol Buffers, offrendo generazione del codice, streaming e un contratto tipizzato.
Non è però l'unica forma possibile di RPC e non è sempre adatto a una MCU piccola. Uno stack HTTP/2 completo, l'eventuale TLS e le relative dipendenze possono richiedere più RAM, flash e complessità di quanto il progetto giustifichi.
Su un collegamento UART o USB CDC, RPC può significare semplicemente:
- un insieme documentato di metodi;
- richieste con parametri;
- risposte correlate tramite identificatore;
- errori, timeout e versionamento;
- eventi asincroni quando necessari.
Sono quindi possibili RPC proprietarie su UART, JSON-RPC su seriale, messaggi Protocol Buffers su USB o CBOR su RS-485. Il concetto rimane lo stesso anche senza gRPC.
RPC, REST ed eventi
RPC descrive normalmente azioni:
get_temperature()set_led(true)start_motor()calibrate_sensor()REST modella soprattutto risorse e operazioni HTTP:
GET /sensors/3/temperaturePUT /leds/1/statePOST /calibrationsUn sistema a eventi descrive invece fatti già avvenuti:
button_pressedtemperature_threshold_exceededmotor_fault_detectedNel collegamento PC–MCU l'approccio RPC è naturale per impartire comandi al firmware, mentre gli eventi si adattano meglio alle notifiche spontanee della scheda. Un protocollo reale può quindi combinare richieste e risposte, eventi e stream senza dover forzare tutto nello stesso modello.
Un esempio bidirezionale completo
Supponiamo che una ESP32 controlli un LED, un sensore di temperatura, un motore PWM e un pulsante. Il PC espone inoltre alcuni metodi che il firmware può invocare.
Metodi esposti dalla MCU:
get_protocol_info() -> versione e capabilityget_temperature(sensor_id) -> valoreset_led(state) -> okset_pwm(channel, value) -> okstart_log_stream(level) -> stream_idstop_log_stream(stream_id) -> okstart_calibration() -> operation_idget_operation_status(operation_id) -> statoreboot(mode) -> okMetodi esposti dal PC:
save_measurement(value, unit, timestamp) -> savedget_current_time() -> unix_timeconfirm_user_present(message) -> confirmedwrite_log(level, message) -> okEventi prodotti dalla MCU:
button_pressed(button_id, sequence)temperature_alarm(sensor_id, value, sequence)motor_fault(code, sequence)log_line(stream_id, sequence, level, message)operation_progress(operation_id, progress)heartbeat(sequence, uptime_ms, boot_id)1. Apertura e negoziazione
Il PC apre la connessione e interroga il dispositivo:
{ "jsonrpc": "2.0", "id": "pc-1", "method": "get_protocol_info", "params": {}}La MCU risponde:
{ "jsonrpc": "2.0", "id": "pc-1", "result": { "protocol_version": 2, "firmware_version": "1.5.0", "device_id": "CTRL-BOARD-A", "session_id": "7f4a9c21", "boot_id": 184, "max_payload": 256, "max_pending_requests": 4, "capabilities": [ "temperature", "pwm", "bidirectional_rpc", "reliable_events", "log_stream", "operation_cancel" ] }}Il PC verifica la compatibilità prima di inviare altri comandi.
2. Il PC accende il LED
Richiesta:
{ "jsonrpc": "2.0", "id": "pc-2", "method": "set_led", "params": { "state": true }}Risposta:
{ "jsonrpc": "2.0", "id": "pc-2", "result": { "ok": true, "state": true }}3. Il pulsante genera un evento
Per un evento non affidabile, la MCU può inviare una notifica JSON-RPC:
{ "jsonrpc": "2.0", "method": "button_pressed", "params": { "button_id": 1, "sequence": 293 }}Il PC non risponde.
Per un evento che non deve essere perso, il protocollo personalizzato può invece usare notify_id e ACK:
{ "type": "notification", "notify_id": 294, "method": "motor_fault", "params": { "code": "OVERCURRENT" }}{ "type": "notification_ack", "notify_id": 294}4. La MCU chiede l'ora al PC
La MCU inizia una nuova RPC sullo stesso canale:
{ "jsonrpc": "2.0", "id": "esp-27", "method": "get_current_time", "params": {}}Il PC risponde:
{ "jsonrpc": "2.0", "id": "esp-27", "result": { "unix_time": 1785330000, "utc_offset_minutes": 120 }}5. Operazione lunga
Il PC avvia una calibrazione:
{ "jsonrpc": "2.0", "id": "pc-3", "method": "start_calibration", "params": {}}La MCU accetta rapidamente la richiesta:
{ "jsonrpc": "2.0", "id": "pc-3", "result": { "accepted": true, "operation_id": 7 }}L'avanzamento arriva tramite eventi:
{ "jsonrpc": "2.0", "method": "operation_progress", "params": { "operation_id": 7, "progress": 25 }}{ "jsonrpc": "2.0", "method": "operation_completed", "params": { "operation_id": 7, "result": "OK" }}6. Risposta tardiva
Il PC invia get_temperature con timeout di 200 ms. La risposta arriva dopo 350 ms:
TX pc-4 get_temperature200 ms -> timeout, pending entry rimossa350 ms -> RX response pc-4, classificata come late responseLa libreria registra il dato per il debug, ma non lo consegna a una nuova chiamata che abbia eventualmente riutilizzato l'ID.
Questo esempio contiene i modelli principali senza confonderli:
- richieste PC → MCU;
- richieste MCU → PC;
- risposte correlate;
- notifiche senza risposta;
- eventi affidabili con ACK personalizzato;
- operazioni lunghe identificate separatamente;
- sessioni, timeout e risposte tardive.
Payload grandi e frammentazione
Un frame può avere una dimensione massima ridotta, ad esempio 256 byte, mentre una configurazione o una tabella di calibrazione può essere più grande.
In questo caso serve un protocollo di trasferimento a blocchi:
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=...Bisogna definire:
- dimensione e ordine dei blocchi;
- gestione di duplicati e blocchi mancanti;
- timeout e annullamento;
- controllo d'integrità di ogni frame e dell'oggetto completo;
- eventuale ripresa del trasferimento;
- limiti di memoria e scrittura.
Per un aggiornamento firmware si aggiungono requisiti di sicurezza: autenticità, firma dell'immagine, protezione dal rollback quando necessaria e recupero sicuro dopo un'interruzione.
Debug e osservabilità
Un protocollo binario è difficile da diagnosticare senza strumenti dedicati. Conviene predisporre log sul PC:
TX id=42 method=SET_LED len=4RX id=42 status=OK time=3.2mse, quando possibile, log sul firmware:
rpc: received method=0x0003 id=42rpc: set_led state=1rpc: response ok id=42Un dump esadecimale aiuta a verificare i byte effettivamente trasmessi:
TX: 7E 01 00 08 2A 00 03 01 91 C4RX: 7E 02 00 07 2A 00 00 44 12Ancora più utile è un decoder che traduca il dump secondo la specifica:
Frame: type: REQUEST id: 42 method: SET_LED state: true crc: okI log non dovrebbero però modificare in modo significativo le tempistiche real-time né esporre segreti o dati sensibili in produzione.
Testing
Il protocollo deve essere verificato anche nei casi non nominali. Tra i test più utili:
- frame valido;
- CRC errato;
- frame troncato o concatenato;
- lunghezza superiore al massimo;
method_idsconosciuto;- parametro fuori intervallo;
- timeout e risposta tardiva;
- invio duplicato della stessa richiesta;
- reset della MCU durante una chiamata;
- evento ricevuto mentre il PC attende una risposta;
- stream più rapido del consumatore;
- disconnessione e riconnessione USB;
- versione del protocollo non supportata;
- due richieste simultanee con lo stesso ID nella stessa direzione;
- stesso ID utilizzato contemporaneamente nelle due direzioni;
- wrap-around del contatore degli ID;
- risposta tardiva dopo il riutilizzo potenziale dell'ID;
- cambio di
session_idoboot_iddurante una chiamata; - richiesta inversa MCU → PC mentre il PC attende una risposta;
- ACK perso e ritrasmissione dello stesso evento;
- duplicato con lo stesso
operation_token; - frammentazione di un messaggio WebSocket in più callback;
- cancellazione ricevuta prima, durante e dopo il punto non reversibile.
Il parser merita test specifici perché elabora dati esterni. Un fuzzing mirato può inviare sequenze casuali, lunghezze estreme e frame malformati verificando che il firmware non vada in crash, non legga fuori dai buffer e riesca a risincronizzarsi.
È utile testare il parser anche sul PC, dove sanitizer e strumenti di analisi sono più comodi, se l'implementazione può essere condivisa o compilata per entrambe le piattaforme.
Errori comuni
Confondere una lettura con un messaggio
Questo codice presume che read() restituisca esattamente un frame:
data = serial.read(64)parse_rpc(data)Il client deve invece accumulare i byte, estrarre tutti i frame completi disponibili e conservare l'eventuale frammento finale per la lettura successiva.
Trasmettere strutture C grezze
Padding, endianness, allineamento e campi non inizializzati rendono fragile l'invio diretto della memoria di una struttura. I campi devono essere serializzati secondo una rappresentazione stabile.
Fidarsi della lunghezza ricevuta
Una lunghezza dichiarata dal frame non deve mai superare i limiti del buffer o del protocollo. La validazione deve precedere qualunque copia o allocazione.
Non prevedere timeout
Senza timeout una chiamata può bloccare indefinitamente l'applicazione. Il timeout deve inoltre liberare lo stato associato alla richiesta senza confondere eventuali risposte tardive.
Ripetere automaticamente operazioni non idempotenti
La perdita della risposta non implica che la MCU non abbia eseguito il comando. Un retry automatico può duplicare l'operazione.
Fare troppo lavoro in interrupt
Parsing, serializzazione e handler applicativi dovrebbero essere eseguiti fuori dall'interrupt, salvo requisiti eccezionali e accuratamente analizzati.
Ignorare eventi e versionamento
Un protocollo limitato alla sola risposta immediata diventa difficile da estendere con notifiche e operazioni lunghe. Allo stesso modo, l'assenza di una versione rende fragile ogni aggiornamento del firmware.
Confondere request ID, operation ID e sequence number
Il request_id correla una risposta, l'operation_id identifica un'attività lunga e il numero di sequenza ordina eventi o campioni. Riutilizzare un solo campo per tutti questi scopi rende ambiguo il ciclo di vita dei messaggi.
Trattare il timeout come un annullamento
Quando il PC smette di attendere, la MCU può continuare a eseguire l'handler. Se l'operazione deve essere interrotta serve un meccanismo di cancellazione esplicito e cooperativo.
Bloccare il reader loop
Un reader che esegue handler lenti o attende sincronicamente altre RPC non può più smistare risposte, eventi e richieste inverse. La ricezione deve rimanere indipendente dall'esecuzione applicativa.
Assumere che una callback WebSocket contenga un messaggio completo
WebSocket definisce messaggi, ma l'API concreta può consegnarli in più frammenti. Il codice deve seguire offset, lunghezza totale e indicazione di fine messaggio invece di passare ogni callback direttamente al decoder JSON.
Una configurazione pratica consigliata
Per un primo protocollo tra PC ed ESP32 sceglierei una struttura volutamente semplice:
Ogni livello conosce solo quello sotto di sé, così il trasporto può cambiare senza toccare gli handler.
Una busta personalizzata potrebbe contenere:
{ "protocol_version": 2, "session_id": "7f4a9c21", "type": "request", "id": "pc-100", "method": "set_led", "params": { "state": true }}Se si decide invece di adottare JSON-RPC 2.0, manterrei la busta standard e sposterei protocol_version e session_id nell'handshake o in un livello esterno, evitando di mescolare accidentalmente standard e formato proprietario.
Le regole minime che documenterei sono:
- massimo numero di byte per frame e payload;
- massimo numero di richieste pendenti per direzione;
- timeout specifico o classe di timeout per metodo;
- comportamento degli ID dopo timeout, wrap-around e reconnect;
- distinzione tra richiesta, risposta, notifica, evento affidabile e stream;
- politica di retry per ogni metodo;
- deduplica per le operazioni non idempotenti;
- sessione e
boot_id; - endianness, unità e intervalli dei valori;
- comportamento per campi, metodi e versioni sconosciute;
- priorità, backpressure e politica di overflow;
- autenticazione e autorizzazione dei comandi critici;
- modalità di debug e redazione dei dati sensibili.
Per una prima implementazione concreta:
- una richiesta pendente per direzione è sufficiente;
- JSON delimitato da newline è ottimo durante il prototipo;
- COBS o length-prefix sono più adatti a un protocollo binario;
- il reader task deve rimanere sempre attivo;
- gli handler non devono bloccare il parser;
- tutti i timeout devono usare un clock monotono;
- le risposte sconosciute o tardive vanno registrate e scartate;
- le azioni fisiche non idempotenti non devono essere ritentate senza token di deduplica.
Solo dopo aver misurato un limite reale introdurrei pipelining esteso, batch, compressione o più livelli di priorità. Ogni funzione aggiunta aumenta gli stati da verificare e i modi nei quali le due estremità possono divergere.
Conclusione
Abbiamo visto come una RPC trasformi una chiamata apparentemente locale in una comunicazione strutturata. Nel caso PC–MCU, il percorso attraversa libreria, sistema operativo, driver, trasporto, parser, dispatcher e infine l'hardware controllato dal firmware.
La qualità dell'interfaccia esposta all'applicazione dipende dalla precisione del protocollo sottostante. Framing, serializzazione, limiti dei buffer, timeout, identificatori, versionamento ed errori non sono dettagli secondari: definiscono il comportamento reale del sistema quando qualcosa non procede come previsto.
Una prima implementazione dovrebbe rimanere semplice. Una richiesta alla volta, frame con dimensione massima, parser a stati, codici di errore chiari e metodi prevalentemente idempotenti costituiscono una base più facile da verificare. Concorrenza, streaming e frammentazione possono essere introdotti quando il caso d'uso li richiede davvero.
La considerazione più importante rimane dunque questa: una RPC può sembrare una funzione locale, ma deve essere progettata come una comunicazione remota.
In particolare, un ID correla messaggi ma non garantisce l'esecuzione; un timeout interrompe l'attesa ma non annulla automaticamente il comando; una notifica evita la risposta ma non offre conferma; un retry aumenta la disponibilità ma può duplicare gli effetti. Sono queste differenze, più del formato JSON o binario, a definire il comportamento reale del sistema.
Quando il confine viene trattato con la dovuta attenzione, il PC può controllare una scheda embedded tramite un'interfaccia pulita, mentre la MCU continua a gestire il mondo fisico con vincoli e responsabilità ben definiti. In superficie rimane una funzione; sotto, esiste un protocollo preciso.
Fonti consultate
- JSON-RPC 2.0 Specification
- gRPC introduction
- gRPC core concepts
- Protocol Buffers overview
- RFC 8949: Concise Binary Object Representation
- RFC 9113: HTTP/2
- RFC 1055: Serial Line IP
- Consistent Overhead Byte Stuffing
- nanopb documentation
- Silicon Labs USB CDC ACM documentation
- Microsoft: USB endpoints and pipes
- Microsoft: DeviceIoControl
- Linux ioctl manual page
- Silicon Labs AN0059.0: UART flow control
- Microchip AN1148: Cyclic Redundancy Check
- RFC 9293: Transmission Control Protocol
- RFC 6455: The WebSocket Protocol
- gRPC deadlines
- gRPC retry
- Espressif ESP WebSocket Client documentation
- ESP-IDF UART driver documentation
- ESP-IDF USB Device Stack and CDC-ACM documentation
- ESP-IDF: serial connection through an external USB-to-UART bridge
- ESP-IDF: USB Serial/JTAG Controller Console
- ESP-IDF: ESP32-S3 USB Device Stack
- Wikimedia Commons: Raspberry Pi 3 connected to Lenovo ThinkPad T430 via DSD TECH UART to USB adapter.jpg: immagine di Cirosantilli2, CC BY-SA 4.0, utilizzata come copertina dell'articolo.
Ultimo aggiornamento
2026-06-09.
Sorgente dell’articolo content/blog/rpc_pc_microcontroller.
