RISC-V + Rust Firmware: Costruire un Nodo IoT di Sensori con Embassy-rs sull'ESP32-C6
Il Toolchain: Far Parlare Rust con RISC-V
Il primo ostacolo è il toolchain. Rust supporta target RISC-V attraverso il target triple riscv32imc-unknown-none-elf (per il core RV32IMC dell'ESP32-C6). Servono alcuni pezzi:
# Installare il target RISC-V
rustup target add riscv32imc-unknown-none-elf
# Installare probe-rs per flashing e debugging
cargo install probe-rs-tools
# Installare espflash per lo strumento flash specifico ESP
cargo install espflash
La struttura del progetto:
sensor-node/
├── .cargo/
│ └── config.toml
├── src/
│ ├── main.rs
│ ├── sensors.rs
│ ├── network.rs
│ ├── power.rs
│ └── config.rs
├── Cargo.toml
├── build.rs
└── memory.x
Il .cargo/config.toml è critico:
[target.riscv32imc-unknown-none-elf]
runner = "espflash flash --monitor"
[build]
target = "riscv32imc-unknown-none-elf"
rustflags = [
"-C", "link-arg=-Tlinkall.x",
"-C", "link-arg=-Tdefmt.x",
]
[unstable]
build-std = ["core", "alloc"]
Embassy-rs: Async Senza un OS
Embassy è un runtime async per Rust embedded. Invece di un RTOS con thread e mutex, scrivi task async che l'executor di embassy esegue cooperativamente. L'insight chiave è che async/await in Rust compila a macchine a stati — nessuna allocazione heap, nessun dynamic dispatch, nessun overhead runtime oltre alle transizioni della macchina a stati.
Ecco l'entry point principale:
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use esp_hal::clock::CpuClock;
use esp_hal::gpio::{Input, Level, Output, Pull};
use esp_hal::i2c::master::I2c;
use esp_hal::peripherals::Peripherals;
use defmt::*;
use {defmt_rtt as _, esp_hal_embassy as _, panic_probe as _};
mod sensors;
mod network;
mod power;
mod config;
#[embassy_executor::main]
async fn main(spawner: Spawner) {
info!("Nodo sensore in avvio...");
let peripherals = esp_hal::init(esp_hal::Config::default().with_cpu_clock(CpuClock::max()));
// Configurare I2C per sensori (SHT40 temp/umidità, VEML7700 luce)
let i2c = I2c::new(
peripherals.I2C0,
esp_hal::i2c::master::Config::default()
.with_frequency(400_000),
)
.unwrap()
.with_sda(peripherals.GPIO6)
.with_scl(peripherals.GPIO7);
let soil_pin = peripherals.GPIO4;
let led = Output::new(peripherals.GPIO8, Level::Low);
spawner.spawn(sensor_reading_task(i2c, soil_pin)).unwrap();
spawner.spawn(network_task()).unwrap();
spawner.spawn(heartbeat_task(led)).unwrap();
info!("Tutti i task spawnati, entrata nel loop principale");
loop {
Timer::after(Duration::from_secs(300)).await;
power::enter_light_sleep().await;
}
}
#[embassy_executor::task]
async fn heartbeat_task(mut led: Output<'static>) {
loop {
led.set_high();
Timer::after(Duration::from_millis(50)).await;
led.set_low();
Timer::after(Duration::from_secs(10)).await;
}
}
Nota che non c'è std, nessun main nel senso tradizionale, nessun OS. La macro #[embassy_executor::main] configura l'executor e gli interrupt handler. I task vengono spawnati con spawner.spawn() e girano cooperativamente — quando un task incontra .await, l'executor controlla se un altro task è pronto.
Leggere Sensori: Astrazioni HAL nella Pratica
Il task di lettura sensori comunica con un SHT40 (temperatura/umidità) e VEML7700 (luce ambientale) via I2C, più una sonda analogica di umidità suolo via ADC:
use embassy_time::{Duration, Timer};
use esp_hal::i2c::master::I2c;
use defmt::*;
const SHT40_ADDR: u8 = 0x44;
const SHT40_MEASURE_HIGH: u8 = 0xFD;
pub struct SensorReading {
pub temperature_c: f32,
pub humidity_pct: f32,
pub light_lux: f32,
pub soil_moisture_pct: f32,
}
#[embassy_executor::task]
pub async fn sensor_reading_task(
mut i2c: I2c<'static, esp_hal::Async>,
soil_pin: esp_hal::gpio::GpioPin<4>,
) {
loop {
let reading = read_all_sensors(&mut i2c).await;
match reading {
Ok(data) => {
info!(
"T={:.1}C H={:.1}% L={:.0}lux S={:.1}%",
data.temperature_c,
data.humidity_pct,
data.light_lux,
data.soil_moisture_pct
);
SENSOR_CHANNEL.send(data).await;
}
Err(e) => {
error!("Lettura sensore fallita: {:?}", e);
}
}
Timer::after(Duration::from_secs(300)).await;
}
}
async fn read_sht40(i2c: &mut I2c<'static, esp_hal::Async>) -> Result<(f32, f32), i2c::Error> {
i2c.write(SHT40_ADDR, &[SHT40_MEASURE_HIGH]).await?;
Timer::after(Duration::from_millis(10)).await;
let mut buf = [0u8; 6];
i2c.read(SHT40_ADDR, &mut buf).await?;
let raw_temp = ((buf[0] as u16) << 8) | buf[1] as u16;
let raw_hum = ((buf[3] as u16) << 8) | buf[4] as u16;
let temperature = -45.0 + 175.0 * (raw_temp as f32 / 65535.0);
let humidity = -6.0 + 125.0 * (raw_hum as f32 / 65535.0);
Ok((temperature, humidity.clamp(0.0, 100.0)))
}
L'API I2C qui è async — i2c.write().await e i2c.read().await cedono all'executor mentre il periferico hardware gestisce la transazione bus effettiva. La CPU non fa busy-wait durante il trasferimento I2C. Su un dispositivo a energia limitata, questo conta.
WiFi 6 e Trasmissione Dati
Il task di rete gestisce la connessione WiFi e la trasmissione dati. Il supporto WiFi 6 dell'ESP32-C6 include Target Wake Time (TWT), che permette al dispositivo di negoziare intervalli di risveglio specifici con l'access point — critico per la durata della batteria:
use embassy_net::tcp::TcpSocket;
use embassy_time::{Duration, Timer, with_timeout};
use defmt::*;
#[embassy_executor::task]
pub async fn network_task(
mut controller: WifiController<'static>,
stack: Stack<'static>,
) {
loop {
let reading = SENSOR_CHANNEL.receive().await;
info!("Connessione WiFi...");
controller.start_async().await.unwrap();
controller.connect_async().await.unwrap();
let dhcp_result = with_timeout(Duration::from_secs(10), async {
loop {
if stack.is_config_up() { break; }
Timer::after(Duration::from_millis(100)).await;
}
}).await;
if dhcp_result.is_err() {
error!("Timeout DHCP, ritentare prossimo ciclo");
controller.stop_async().await.unwrap();
continue;
}
match send_reading(&stack, &reading).await {
Ok(_) => info!("Dati inviati con successo"),
Err(e) => error!("Invio fallito: {:?}", e),
}
controller.stop_async().await.unwrap();
info!("WiFi fermato, radio spenta");
}
}
Il pattern critico qui è connetti, trasmetti, disconnetti. La radio WiFi è il maggior consumatore di energia — circa 120mA quando attiva. Mantenendola accesa solo durante la trasmissione dati (tipicamente 200-400ms per ciclo), il consumo medio di corrente viene minimizzato drammaticamente.
Gestione Energia: La Matematica della Batteria
Il budget energetico per 6 mesi di durata batteria con due celle AA al litio (3.000mAh totali a 3V):
Obiettivo: 6 mesi = 4.320 ore
Budget: 3.000mAh / 4.320h = 0,694mA media
Dettaglio per ciclo di 5 minuti:
- Deep sleep (298 secondi): 10uA × 298s = 2.980 uA·s
- Risveglio + lettura sensore (1 secondo): 25mA × 1s = 25.000 uA·s
- Connessione WiFi + trasmissione (1 secondo): 120mA × 1s = 120.000 uA·s
- Totale per ciclo: 147.980 uA·s
- Corrente media: 147.980 / 300 = 493 uA = 0,493 mA
Questo ci dà circa 0,493mA media — ben dentro il budget di 0,694mA. In pratica, le misurazioni con un Nordic PPK2 mostrano 0,52mA media, contando la corrente quiescent del regolatore di tensione e i tempi reali di connessione WiFi.
Il deep sleep sull'ESP32-C6 resetta la CPU principale. Solo il dominio RTC e il coprocessore ULP sopravvivono. Al risveglio, il firmware parte dall'inizio. Questo significa che lo stato critico deve essere salvato in memoria RTC:
#[link_section = ".rtc.data"]
static mut BOOT_COUNT: u32 = 0;
#[link_section = ".rtc.data"]
static mut FAILED_TRANSMISSIONS: u32 = 0;
#[link_section = ".rtc.data"]
static mut CALIBRATION_OFFSET: f32 = 0.0;
Le variabili in .rtc.data persistono tra cicli di deep sleep. Questo traccia quanti fallimenti consecutivi di trasmissione sono occorsi — dopo 5 fallimenti, il nodo passa a un intervallo di sleep più lungo (30 minuti invece di 5) per conservare batteria mentre il problema di rete si risolve.
Lezioni Apprese
Usare esp-wifi dall'inizio, non esp-idf-svc: Il layer di compatibilità ESP-IDF può sembrare attraente inizialmente, che dà un'API WiFi più familiare tipo C. Ma tira dentro l'intero framework ESP-IDF come libreria statica, gonfiando il binario da 180KB a 1,2MB e rendendo il debugging più difficile. Il crate nativo esp-wifi è di più basso livello ma si integra molto meglio con embassy.
Aggiungere OTA dal giorno uno: Gli aggiornamenti firmware over-the-air sono essenziali per nodi di sensori deployati. Aggiungere OTA dopo il deployment iniziale significa visitare fisicamente ogni nodo per flashare il firmware con capacità OTA. Il crate esp-ota funziona bene ma deve far parte dell'architettura iniziale.
Usare strutture dati heapless ovunque: Usare alloc per alcuni usi di Vec e String può sembrare innocuo inizialmente. Su un dispositivo con 512KB di RAM, la frammentazione dell'heap dopo settimane di operazione ha causato un crash. Sostituire tutto con heapless::Vec e heapless::String a dimensione fissa ha eliminato il problema completamente.
L'ecosistema Rust + RISC-V embedded ha raggiunto il punto dove è un'alternativa praticabile a C per nuovi progetti. Non un sostituto — ci sono ancora gap nel supporto dei vendor e nella documentazione. Ma per progetti IoT greenfield dove l'affidabilità conta e sei disposto a investire nella curva di apprendimento, le garanzie a tempo di compilazione da sole giustificano il passaggio. Un deployment comparabile mostra zero crash legati alla memoria in quattro mesi di operazione sul campo su 12 nodi. Firmware C precedente su hardware simile mostrava in media un watchdog reset per nodo al mese da null pointer dereference e buffer overflow.
I sensori del vigneto sono ancora in funzione. All'uva non importa in che linguaggio è scritto il firmware, ma il sistema di tipi sorveglia la memoria 24 ore su 24 — un vantaggio operativo significativo.