<!-- Canonical: https://lucioduran.com/es/blog/risc-v-embedded-systems-rust-firmware
Language: es
Content-SHA256: 9784c4b52ae47062ac0b5bae54e4ba7a15042e10043669c7e2986c78a0fff998
-->

Embedded

# RISC-V + Rust Firmware: Armando un Nodo IoT de Sensores con Embassy-rs en el ESP32-C6

2026-03-038 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware>)

## 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:

```bash
# 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:

```toml
[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:

```rust
#![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:

```rust
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:

```rust
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:

```rust
#[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.

## Preguntas frecuentes

### Por qué Rust en vez de C para firmware RISC-V?

Rust te da seguridad de memoria sin garbage collector, lo que elimina clases enteras de bugs (buffer overflows, use-after-free, data races) que son las principales causas de vulnerabilidades de seguridad en firmware. El sistema de tipos también atrapa mal uso de hardware en tiempo de compilación, como intentar usar un pin GPIO que ya fue configurado para otro periférico. El tradeoff es una curva de aprendizaje más empinada y un ecosistema más chico de librerías existentes comparado con C.

### Embassy-rs puede reemplazar un RTOS como FreeRTOS?

Para muchos case es de uso, sí. El executor async de Embassy provee multitasking cooperativo similar al scheduler de tareas de un RTOS, pero con garantías de seguridad en tiempo de compilación y abstracciones de costo cero. Sin embargo, embassy no provee garantías de tiempo real duro como los schedulers preemptivos de RTOS. Si se necesita tiempos de respuesta a interrupciones determinísticos de sub-microsegundo, puede que sigas necesitando FreeRTOS o interrupt handlers bare-metal.

### El core RISC-V del ESP32-C6 es comparable en rendimiento a los cores Xtensa del ESP32?

El ESP32-C6 tiene un solo core RISC-V a 160MHz, mientras que el ESP32 original tiene cores Xtensa duales a 240MHz. En cómputo crudo, el ESP32 gana. Pero el C6 tiene WiFi 6, BLE 5, 802.15.4 (Thread/Zigbee), y eficiencia energética significativamente mejor. Para nodos de sensores IoT donde mayormente dormís y ocasionalmente transmitís datos, el C6 es la mejor elección.

## Tips

1. **Tip:** Cuando se inicia con embassy-rs en ESP32-C6, utilizar ` esp-idf-template ` primero para validar que tu hardware funciona, después migrá a bare-metal ` esp-hal `. Es común pasar un día entero pensando que la placa está defectuosa cuando en realidad es un modo de flash mal configurado en ` .cargo/config.toml `, el template atrapa estos problemas automáticamente.
2. **Tip:** Siempre pineá tu versión de ` esp-hal ` en ` Cargo.toml ` con una versión exacta (` =0.23.1 ` no ` 0.23 `). El crate HAL sigue evolucionando rápido y un bump menor puede cambiar los type signatures de los pines GPIO, rompiendo toda tu configuración de pines. Es conocido que builds fallen en CI porque un patch release cambió un trait bound.
3. **Tip:** Para deployments sensibles a la energía, medir el consumo real de corriente con un ` PPK2 ` o herramienta similar, no confíes en los números del datasheet. Un ESP32-C6 puede tirar 15mA en 'light sleep' porque la radio WiFi no se está apagando del todo. Agregar ` esp_wifi::wifi::WifiController::stop() ` antes del sleep lo bajó a 130uA.
4. **Tip:** Utilizar ` defmt ` en vez de ` log ` para debugging en no\_std. Es 10x más rápido porque difiere el formateo de strings al lado del host vía ` probe-rs `. En un loop de sensor ajustado corriendo a 100Hz, las macros de ` log ` agregaban 800us por iteración mientras que ` defmt ` agregaba 12us.

- risc-v
- rust
- embedded
- firmware
- embassy-rs
- esp32-c6
- iot
- no-std
- hal
- async-embedded

[Volver al Blog](<https://lucioduran.com/es/blog>)
