RISC-V + Rust Firmware: Armando un Nodo IoT de Sensores con Embassy-rs en el ESP32-C6
El Toolchain: Haciendo que Rust Hable con RISC-V
El primer obstáculo es el toolchain. Rust soporta targets RISC-V a través del target triple riscv32imc-unknown-none-elf (para el core RV32IMC del ESP32-C6). Se necesitan algunas piezas:
# Instalar el target RISC-V
rustup target add riscv32imc-unknown-none-elf
# Instalar probe-rs para flashear y debuggear
cargo install probe-rs-tools
# Instalar espflash para la herramienta de flash específica de ESP
cargo install espflash
La estructura del proyecto:
sensor-node/
├── .cargo/
│ └── config.toml # Config de target y runner
├── src/
│ ├── main.rs # Entry point y spawn de tareas
│ ├── sensors.rs # Tareas de lectura de sensores
│ ├── network.rs # WiFi y transmisión de datos
│ ├── power.rs # Sleep y gestión de energía
│ └── config.rs # Configuración en tiempo de compilación
├── Cargo.toml
├── build.rs
└── memory.x # Script de linker (layout de memoria)
El .cargo/config.toml es crítico:
[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"]
El flag build-std es necesario porque se construye core (la librería estándar no_std de Rust) desde el fuente con optimizaciones específicas para el target.
Embassy-rs: Async Sin un OS
Embassy es un runtime async para Rust embebido. En vez de un RTOS con threads y mutexes, escribirs tareas async que el executor de embassy corre cooperativamente. El insight clave es que async/await en Rust compila a máquinas de estado — sin alocación en el heap, sin dynamic dispatch, sin overhead de runtime más allá de las transiciones de la máquina de estados.
Acá va el entry point principal:
#![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 sensor arrancando...");
let peripherals = esp_hal::init(esp_hal::Config::default().with_cpu_clock(CpuClock::max()));
// Configurar I2C para sensores (SHT40 temp/humedad, VEML7700 luz)
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);
// ADC para sensor de humedad del suelo
let soil_pin = peripherals.GPIO4;
// LED de estado
let led = Output::new(peripherals.GPIO8, Level::Low);
// Spawnear tareas independientes
spawner.spawn(sensor_reading_task(i2c, soil_pin)).unwrap();
spawner.spawn(network_task()).unwrap();
spawner.spawn(heartbeat_task(led)).unwrap();
info!("Todas las tareas spawneadas, entrando al loop principal");
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;
}
}
Observar que no hay std, no hay main en el sentido tradicional, no hay OS. La macro #[embassy_executor::main] configura el executor y los interrupt handlers. Las tareas se spawnean con spawner.spawn() y corren cooperativamente — cuando una tarea pega un .await, el executor chequea si otra tarea está lista para correr.
Leyendo Sensores: Abstracciones HAL en la Práctica
La tarea de lectura de sensores habla con un SHT40 (temperatura/humedad) y VEML7700 (luz ambiental) por I2C, más una sonda analógica de humedad del suelo vía 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!("Lectura de sensor falló: {:?}", 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)))
}
La API de I2C aquí es async — i2c.write().await e i2c.read().await ceden al executor mientras el periférico de hardware maneja la transacción real del bus. La CPU no está haciendo busy-wait durante la transferencia I2C. En un dispositivo limitado en energía, esto importa.
WiFi 6 y Transmisión de Datos
La tarea de red maneja la conexión WiFi y la transmisión de datos. El soporte de WiFi 6 del ESP32-C6 incluye Target Wake Time (TWT), que le permite al dispositivo negociar intervalos específicos de despertar con el access point — crítico para la vida de batería:
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!("Conectando a 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 de DHCP, reintentando próximo ciclo");
controller.stop_async().await.unwrap();
continue;
}
match send_reading(&stack, &reading).await {
Ok(_) => info!("Datos enviados exitosamente"),
Err(e) => error!("Envío falló: {:?}", e),
}
controller.stop_async().await.unwrap();
info!("WiFi detenido, radio apagada");
}
}
El patrón crítico aquí es conectar, transmitir, desconectar. La radio WiFi es el mayor consumidor de energía — alrededor de 120mA cuando está activa. Manteniéndola encendida solo durante la transmisión de datos (típicamente 200-400ms por ciclo), el consumo promedio de corriente se minimiza dramáticamente.
Gestión de Energía: La Matemática de la Batería
El presupuesto de energía para 6 meses de vida de batería con dos celdas AA de litio (3.000mAh total a 3V):
Objetivo: 6 meses = 4.320 horas
Presupuesto: 3.000mAh / 4.320h = 0,694mA promedio
Desglose por ciclo de 5 minutos:
- Deep sleep (298 segundos): 10uA × 298s = 2.980 uA·s
- Despertar + lectura de sensor (1 segundo): 25mA × 1s = 25.000 uA·s
- Conexión WiFi + transmisión (1 segundo): 120mA × 1s = 120.000 uA·s
- Total por ciclo: 147.980 uA·s
- Corriente promedio: 147.980 / 300 = 493 uA = 0,493 mA
Eso nos da alrededor de 0,493mA promedio — bien dentro del presupuesto de 0,694mA. En la práctica, las mediciones con un Nordic PPK2 muestran 0,52mA promedio, contando la corriente quiescent del regulador de voltaje y los tiempos reales de conexión WiFi (a veces tomando 1,5 segundos en vez de 1).
El deep sleep en el ESP32-C6 resetea la CPU principal. Solo el dominio RTC y el coprocesador ULP sobreviven. Al despertar, el firmware arranca desde el principio. Esto significa que el estado crítico debe guardarse (como offsets de calibración de sensores, contadores de fallas de transmisión) en 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;
Variables en .rtc.data persisten entre ciclos de deep sleep. Esto trackea cuántas fallas consecutivas de transmisión ocurrieron — después de 5 fallas, el nodo cambia a un intervalo de sleep más largo (30 minutos en vez de 5) para conservar batería mientras el problema de red se resuelve.
Lecciones Aprendidas
Usar esp-wifi desde el arranque, no esp-idf-svc: La capa de compatibilidad de ESP-IDF puede parecer atractiva inicialmente, que te da una API WiFi más familiar tipo C. Pero tira de todo el framework ESP-IDF como librería estática, inflando el binario de 180KB a 1,2MB y haciendo el debugging más difícil. El crate nativo esp-wifi es de más bajo nivel pero se integra mucho mejor con embassy.
Agregar OTA desde el día uno: Las actualizaciones de firmware over-the-air son esenciales para nodos de sensores deployados. Agregar OTA después del deploy inicial significa visitar físicamente cada nodo para flashear el firmware con capacidad OTA. El crate esp-ota funciona bien pero tiene que ser parte de la arquitectura inicial.
Usar estructuras de datos heapless en todos lados: Usar alloc para algunos use es de Vec y String puede parecer inofensivo inicialmente. En un dispositivo con 512KB de RAM, la fragmentación del heap después de semanas de operación causó un crash. Reemplazar todo con heapless::Vec y heapless::String de tamaño fijo eliminó el problema completamente.
El ecosistema de Rust + RISC-V embebido llegó al punto donde es una alternativa viable a C para proyectos nuevos. No un reemplazo — todavía hay gaps en soporte de vendors y documentación. Pero para proyectos IoT greenfield donde la confiabilidad importa y se está dispuesto a invertir en la curva de aprendizaje, las garantías en tiempo de compilación solas justifican el cambio. Un deployment comparable muestra cero crashes relacionados con memoria en cuatro meses de operación en campo en 12 nodos. Firmware previo en C en hardware similar promediaba un watchdog reset por nodo por mes por null pointer dereferences y buffer overflows.
Los sensores de la viña siguen corriendo. A las uvas no les importa en qué lenguaje está escrito el firmware, pero el sistema de tipos vigila la memoria las 24 horas — una ventaja operacional significativa.