Driver I2C master in ESP-IDF 6.x: migrare senza nascondere i guasti di bus
ESP-IDF 6.x è un buon momento per smettere di trattare I2C come una sequenza di chiamate copiate intorno a i2c_cmd_link_create(). Il driver master attuale modella il bus e ogni periferica come handle espliciti, e le note di migrazione alla 6.0 rendono più precise anche alcune semantiche di errore sui NACK. In un prodotto questo conta: una lettura sensore che ogni tanto restituisce un valore vecchio spesso costa più di una lettura che fallisce in modo chiaro.
Perché vale la pena intervenire
I guasti I2C raramente sono eleganti. Su un gateway ESP32, un termostato, un prodotto a batteria o un nodo sensore industriale, la causa può essere un connettore non perfetto, una rail di alimentazione lenta, un dispositivo ancora in uscita dal reset, una capacità di bus leggermente oltre le ipotesi di progetto, o un task firmware che allunga una transazione dietro un interrupt più prioritario. Il sintomo arriva spesso come “ogni tanto il prodotto va riavviato”.
Il driver ESP-IDF più recente spinge il firmware verso un'architettura più chiara. Si crea un bus master con GPIO, sorgente di clock, pull-up, filtro glitch ed eventuale comportamento di power management. Poi si collegano i dispositivi con indirizzo e velocità propri. Una lettura di registro non è più una command list costruita a mano nel codice applicativo; è una transazione su un device handle, per esempio con i2c_master_transmit_receive(), che esegue volutamente una scrittura seguita da una lettura con repeated start, senza inserire una condizione di STOP in mezzo.
Questa separazione è utile oltre allo stile. Offre un punto unico per la configurazione elettrica e di bus, e un punto dedicato per le assunzioni di protocollo di ogni dispositivo. In una codebase cliente è la differenza tra “tutti i driver sanno qualcosa di I2C0” e “il board support layer possiede il bus; i driver dei dispositivi possiedono il comportamento del dispositivo”.
La trappola della migrazione: conservare cattive assunzioni
Una migrazione meccanica può compilare e mantenere comunque il bug originale. Il codice legacy spesso nasconde tre assunzioni: un timeout fisso copiato da un esempio, nessuna distinzione tra NACK e altri errori, e nessuna policy di recovery dopo una transazione bloccata. Le note di migrazione di ESP-IDF 6.0 indicano che diverse API I2C master ora restituiscono ESP_ERR_INVALID_RESPONSE invece di ESP_ERR_INVALID_STATE quando viene rilevato un NACK. Non è solo un cambio cosmetico di enum. È l'occasione per decidere che cosa significa NACK per ogni dispositivo.
Un NACK durante l'avvio può essere normale per un sensore la cui alimentazione sta ancora salendo. Un NACK a regime può indicare scheda scollegata, conflitto di indirizzo, brownout o reset interno del sensore. Un timeout può suggerire clock stretching, uno slave che tiene SDA basso, carico interrupt troppo aggressivo o problemi fisici del bus. Trattare ogni errore diverso da ESP_OK come “riprova tre volte e poi riavvia” rende il debug di campo inutilmente cieco.
Una forma di driver che scala
L'esempio seguente mostra la struttura che preferisco per un sensore semplice a registri. Il bus viene creato una volta, il device handle resta nel driver del sensore, e ogni lettura restituisce uno stato tipizzato che il prodotto può loggare e usare.
#include "driver/i2c_master.h"
#include "esp_err.h"
#include <stdint.h>
#define I2C_PORT I2C_NUM_0
#define I2C_SCL_GPIO 22
#define I2C_SDA_GPIO 21
#define SENSOR_ADDR 0x48
#define SENSOR_SPEED_HZ 400000
#define I2C_TIMEOUT_MS 20
typedef enum {
SENSOR_IO_OK = 0,
SENSOR_IO_NACK,
SENSOR_IO_TIMEOUT,
SENSOR_IO_DRIVER_ERROR,
} sensor_io_status_t;
typedef struct {
i2c_master_bus_handle_t bus;
i2c_master_dev_handle_t dev;
} board_i2c_sensor_t;
static sensor_io_status_t map_i2c_error(esp_err_t err)
{
switch (err) {
case ESP_OK:
return SENSOR_IO_OK;
case ESP_ERR_INVALID_RESPONSE:
return SENSOR_IO_NACK;
case ESP_ERR_TIMEOUT:
return SENSOR_IO_TIMEOUT;
default:
return SENSOR_IO_DRIVER_ERROR;
}
}
esp_err_t board_sensor_i2c_init(board_i2c_sensor_t *s)
{
i2c_master_bus_config_t bus_cfg = {
.clk_source = I2C_CLK_SRC_DEFAULT,
.i2c_port = I2C_PORT,
.scl_io_num = I2C_SCL_GPIO,
.sda_io_num = I2C_SDA_GPIO,
.glitch_ignore_cnt = 7,
.flags.enable_internal_pullup = true,
};
ESP_RETURN_ON_ERROR(i2c_new_master_bus(&bus_cfg, &s->bus), "i2c", "create bus failed");
i2c_device_config_t dev_cfg = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = SENSOR_ADDR,
.scl_speed_hz = SENSOR_SPEED_HZ,
};
return i2c_master_bus_add_device(s->bus, &dev_cfg, &s->dev);
}
sensor_io_status_t sensor_read_u16(board_i2c_sensor_t *s, uint8_t reg, uint16_t *value)
{
uint8_t rx[2] = {0};
esp_err_t err = i2c_master_transmit_receive(
s->dev, ®, 1, rx, sizeof(rx), pdMS_TO_TICKS(I2C_TIMEOUT_MS));
sensor_io_status_t st = map_i2c_error(err);
if (st != SENSOR_IO_OK) return st;
*value = ((uint16_t)rx[0] << 8) | rx[1];
return SENSOR_IO_OK;
}
Non è un driver di produzione completo. Serve a mostrare i confini: setup della scheda, configurazione del device, transazione e mapping degli errori. Quando questi confini esistono, aggiungere metriche, budget di retry, hook di reset bus e comportamento specifico di warm-up diventa molto più semplice.
Esempio pratico: scheda sensori ambientali su gateway ESP32
Supponiamo che un gateway ESP32 legga un sensore temperatura/umidità e un ADC esterno sullo stesso bus I2C. Il prodotto pubblica misure ogni secondo ed è installato in un quadro dove sostituire hardware è costoso. In questo scenario non migrerei cambiando le chiamate API in-place. Introdurrei un modulo I2C di scheda che crea il bus una sola volta al boot, collega entrambi i dispositivi ed espone i device handle solo ai rispettivi driver.
Nei primi secondi dopo il boot, ogni driver può trattare NACK come “non pronto” e riprovare con backoff limitato. Dopo l'ingresso in funzionamento normale, NACK ripetuti dovrebbero diventare un fault di salute del dispositivo, non una lettura zero silenziosa. I timeout vanno contati separatamente dai NACK perché spesso indicano cause diverse. Se l'ADC allunga occasionalmente il clock più del previsto, il budget di timeout potrebbe dover essere rivisto. Se il bus va in timeout e resta bloccato, il layer di scheda può tentare un recovery controllato o marcare il sottosistema misura come degradato.
L'applicazione dovrebbe pubblicare non solo i valori dei sensori, ma anche uno stato di salute compatto: età dell'ultima lettura valida, contatore NACK, contatore timeout e stato corrente di degradazione. Questi quattro campi rendono il supporto remoto molto più efficace. Evitano anche l'anti-pattern in cui una dashboard cloud mostra una temperatura pulita ma vecchia mentre il device embedded combatte con il bus da ore.
Timeout, pull-up e concorrenza
I timeout fanno parte della specifica di prodotto
Il timeout passato a una transazione non è un numero arbitrario. Deve essere più lungo della transazione attesa alla velocità scelta, più clock stretching e latenza di scheduling nota, ma abbastanza corto da impedire a una periferica guasta di bloccare indefinitamente il task di misura. Se il valore è copiato da una demo, documentarlo come debito tecnico non verificato.
I pull-up interni non sono un progetto di scheda
Gli esempi ESP-IDF spesso abilitano i pull-up interni perché rendono comode le evaluation board. In un prodotto reale va calcolato o misurato il rise time con capacità, tensione e velocità effettive. I pull-up interni possono bastare per un bus corto e lento; non sostituiscono la validazione elettrica.
Un bus, un proprietario
Se più task parlano con dispositivi sullo stesso bus, l'accesso va centralizzato o protetto esplicitamente. L'API a handle rende più chiara la proprietà, ma non elimina la decisione architetturale. Polling sensori, comandi di configurazione e letture diagnostiche non dovrebbero interleavarsi in modo imprevedibile solo perché due task condividono un bus periferico.
Checklist pratica
- Crea il bus I2C master una sola volta nel board support layer; non duplicare GPIO e pull-up in ogni driver.
- Collega ogni device con indirizzo, lunghezza indirizzo e velocità propri.
- Usa
i2c_master_transmit_receive()per letture di registro che richiedono repeated start. - Mappa
ESP_ERR_INVALID_RESPONSE,ESP_ERR_TIMEOUTe altri errori in stati di prodotto. - Separa retry di startup e gestione fault a regime.
- Registra contatori per letture riuscite, NACK, timeout ed età dell'ultimo campione valido.
- Valida pull-up e rise time sulla PCB reale, non solo su dev kit.
- Definisci una policy di recovery: retry, reinizializzazione device, recovery bus, feature degradata o restart di sistema.
Come lo affronterei su un progetto cliente
Partirei inventariando ogni dispositivo I2C: indirizzo, velocità massima, tempi di reset, comportamento di clock stretching e rilevanza per la sicurezza o disponibilità. Poi migrerei un bus alla volta dietro una piccola astrazione di scheda, con test o script da banco che rimuovono deliberatamente un dispositivo, tengono il reset attivo, rallentano il power-up e forzano letture ripetute sotto carico RTOS. Il deliverable importante non è solo “il codice compila con ESP-IDF 6.x”. È un contratto di bus: chi lo possiede, che cosa significano gli errori, come vengono riportati e quali guasti possono degradare una funzione invece di riavviare il prodotto.
Per un prodotto maturo confronterei anche i log di campo prima e dopo la migrazione. Se il nuovo driver cambia solo i nomi delle API, la migrazione è soprattutto manutenzione. Se trasforma “freeze casuali” intermittenti in metriche specifiche di NACK, timeout e dati stale, allora ha migliorato il prodotto.
Commenti
Hai un caso firmware concreto o una modalità di guasto diversa? Scrivimi una nota via email.