Vai al contenuto principale
Embedded

RISC-V + Rust Firmware: Costruire un Nodo IoT di Sensori con Embassy-rs sull'ESP32-C6

7 min lettura
LD
Lucio Durán
Engineering Manager & AI Solutions Architect
Disponibile anche in: English, Español

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.

risc-vrustembeddedfirmwareembassy-rsesp32-c6iotno-stdhalasync-embedded

Strumenti menzionati in questo articolo

AWSProva AWS
DigitalOceanProva DigitalOcean
Divulgazione: Alcuni link in questo articolo sono link di affiliazione. Se ti registri tramite questi, potrei guadagnare una commissione senza costi aggiuntivi per te. Raccomando solo strumenti che uso e di cui mi fido personalmente.
Compartir
Seguime