Skip to main content
Embedded

RISC-V + Rust Firmware: Building an IoT Sensor Node with Embassy-rs on the ESP32-C6

9 min read
LD
Lucio Durán
Engineering Manager & AI Solutions Architect
Also available in: Español, Italiano

The Toolchain: Getting Rust Talking to RISC-V

The first hurdle is the toolchain. Rust supports RISC-V targets through the riscv32imc-unknown-none-elf target triple (for the ESP32-C6's RV32IMC core). You need a few pieces:

# Install the RISC-V target
rustup target add riscv32imc-unknown-none-elf

# Install probe-rs for flashing and debugging
cargo install probe-rs-tools

# Install espflash for ESP-specific flash tool
cargo install espflash

The project structure:

sensor-node/
├── .cargo/
│ └── config.toml # Target and runner config
├── src/
│ ├── main.rs # Entry point and task spawning
│ ├── sensors.rs # Sensor reading tasks
│ ├── network.rs # WiFi and data transmission
│ ├── power.rs # Sleep and power management
│ └── config.rs # Compile-time configuration
├── Cargo.toml
├── build.rs
└── memory.x # Linker script (memory layout)

The .cargo/config.toml is critical:

[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"]

The build-std flag is necessary because it builds core (Rust's no_std standard library) from source with optimizations specific to the target. This enables certain RISC-V instruction patterns that the pre-built core library doesn't include.

Embassy-rs: Async Without an OS

Embassy is an async runtime for embedded Rust. Instead of an RTOS with threads and mutexes, you write async tasks that the embassy executor runs cooperatively. The key insight is that async/await in Rust compiles to state machines — no heap allocation, no dynamic dispatch, no runtime overhead beyond the state machine transitions.

Here's the main entry point:

#![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!("Sensor node booting...");

 let peripherals = esp_hal::init(esp_hal::Config::default().with_cpu_clock(CpuClock::max()));

 // Configure I2C for sensors (SHT40 temp/humidity, VEML7700 light)
 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 for soil moisture sensor
 let soil_pin = peripherals.GPIO4;

 // Status LED
 let led = Output::new(peripherals.GPIO8, Level::Low);

 // Spawn independent tasks
 spawner.spawn(sensor_reading_task(i2c, soil_pin)).unwrap();
 spawner.spawn(network_task()).unwrap();
 spawner.spawn(heartbeat_task(led)).unwrap();

 info!("All tasks spawned, entering main loop");
 loop {
 Timer::after(Duration::from_secs(300)).await; // 5-minute cycle
 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;
 }
}

Notice there's no std, no main in the traditional sense, no OS. The #[embassy_executor::main] macro sets up the executor and the interrupt handlers. Tasks are spawned with spawner.spawn() and they run cooperatively — when a task hits .await, the executor checks if another task is ready to run.

Reading Sensors: HAL Abstractions in Practice

The sensor reading task talks to an SHT40 (temperature/humidity) and VEML7700 (ambient light) over I2C, plus an analog soil moisture probe via ADC:

use embassy_time::{Duration, Timer};
use esp_hal::i2c::master::I2c;
use esp_hal::analog::adc::{Adc, AdcConfig, Attenuation};
use defmt::*;

// SHT40 I2C address and commands
const SHT40_ADDR: u8 = 0x44;
const SHT40_MEASURE_HIGH: u8 = 0xFD;

// VEML7700 I2C address
const VEML7700_ADDR: u8 = 0x10;

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>,
) {
 // Configure ADC for soil moisture
 let mut adc_config = AdcConfig::new();
 let mut soil_channel = adc_config.enable_pin(soil_pin, Attenuation::Attenuation11dB);
 let mut adc = Adc::new(unsafe { esp_hal::peripherals::ADC1::steal() }, adc_config);

 loop {
 let reading = read_all_sensors(&mut i2c, &mut adc, &mut soil_channel).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
 );
 // Push to shared channel for network task
 SENSOR_CHANNEL.send(data).await;
 }
 Err(e) => {
 error!("Sensor read failed: {:?}", e);
 }
 }

 Timer::after(Duration::from_secs(300)).await;
 }
}

async fn read_sht40(i2c: &mut I2c<'static, esp_hal::Async>) -> Result<(f32, f32), i2c::Error> {
 // Send measurement command
 i2c.write(SHT40_ADDR, &[SHT40_MEASURE_HIGH]).await?;

 // SHT40 needs 8.2ms for high-precision measurement
 Timer::after(Duration::from_millis(10)).await;

 // Read 6 bytes: temp_msb, temp_lsb, temp_crc, hum_msb, hum_lsb, hum_crc
 let mut buf = [0u8; 6];
 i2c.read(SHT40_ADDR, &mut buf).await?;

 // Convert raw values (skip CRC check for brevity — don't do this in production)
 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)))
}

The I2C API here is async — i2c.write().await and i2c.read().await yield to the executor while the hardware peripheral handles the actual bus transaction. The CPU isn't busy-waiting during the I2C transfer. On a power-constrained device, this matters.

WiFi 6 and Data Transmission

The network task handles WiFi connection and data transmission. ESP32-C6's WiFi 6 support includes Target Wake Time (TWT), which lets the device negotiate specific wake intervals with the access point — critical for battery life:

use embassy_net::{Stack, Config as NetConfig};
use esp_wifi::wifi::{
 WifiController, WifiDevice, WifiEvent, WifiStaDevice, WifiState,
 Configuration, ClientConfiguration, AuthMethod,
};
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 {
 // Wait for sensor data
 let reading = SENSOR_CHANNEL.receive().await;

 // Connect to WiFi
 info!("Connecting to WiFi...");
 let config = Configuration::Client(ClientConfiguration {
 ssid: heapless::String::try_from(config::WIFI_SSID).unwrap(),
 password: heapless::String::try_from(config::WIFI_PASS).unwrap(),
 auth_method: AuthMethod::WPA2Personal,
 ..Default::default()
 });

 controller.set_configuration(&config).unwrap();
 controller.start_async().await.unwrap();
 controller.connect_async().await.unwrap();

 // Wait for DHCP with timeout
 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!("DHCP timeout, will retry next cycle");
 controller.stop_async().await.unwrap();
 continue;
 }

 // Send data to gateway
 match send_reading(&stack, &reading).await {
 Ok(_) => info!("Data sent successfully"),
 Err(e) => error!("Send failed: {:?}", e),
 }

 // Disconnect WiFi to save power
 controller.stop_async().await.unwrap();
 info!("WiFi stopped, radio off");
 }
}

async fn send_reading(
 stack: &Stack<'static>,
 reading: &SensorReading,
) -> Result<(), embassy_net::tcp::Error> {
 let mut rx_buf = [0u8; 256];
 let mut tx_buf = [0u8; 512];
 let mut socket = TcpSocket::new(stack, &mut rx_buf, &mut tx_buf);

 socket.set_timeout(Some(Duration::from_secs(5)));

 let gateway_addr = embassy_net::IpEndpoint::new(
 embassy_net::IpAddress::v4(192, 168, 1, 100),
 8080,
 );

 socket.connect(gateway_addr).await?;

 // Simple binary protocol: 4 floats, big-endian
 let mut payload = [0u8; 16];
 payload[0..4].copy_from_slice(&reading.temperature_c.to_be_bytes());
 payload[4..8].copy_from_slice(&reading.humidity_pct.to_be_bytes());
 payload[8..12].copy_from_slice(&reading.light_lux.to_be_bytes());
 payload[12..16].copy_from_slice(&reading.soil_moisture_pct.to_be_bytes());

 socket.write_all(&payload).await?;
 socket.flush().await?;
 socket.close();

 Ok(())
}

The critical pattern here is connect, transmit, disconnect. The WiFi radio is the biggest power consumer — about 120mA when active. By keeping it on only during data transmission (typically 200-400ms per cycle), average current draw is minimized dramatically.

Power Management: The Battery Life Math

The power budget for a 6-month battery life with two AA lithium cells (3,000mAh total at 3V):

Target: 6 months = 4,320 hours
Budget: 3,000mAh / 4,320h = 0.694mA average

Breakdown per 5-minute cycle:
- Deep sleep (298 seconds): 10uA × 298s = 2,980 uA·s
- Wake + sensor read (1 second): 25mA × 1s = 25,000 uA·s
- WiFi connect + transmit (1 second): 120mA × 1s = 120,000 uA·s
- Total per cycle: 147,980 uA·s
- Average current: 147,980 / 300 = 493 uA = 0.493 mA

That gives us about 0.493mA average — well within the 0.694mA budget. In practice, measurements with a Nordic PPK2 show 0.52mA average, accounting for voltage regulator quiescent current and real-world WiFi connection times (sometimes taking 1.5 seconds instead of 1).

The sleep implementation:

use esp_hal::rtc_cntl::{Rtc, SleepSource};
use esp_hal::timer::timg::TimerGroup;

pub async fn enter_deep_sleep(duration_secs: u64) {
 // Stop WiFi radio completely
 // (must be done before sleep — leftover radio state draws 15mA)

 let rtc = Rtc::new(unsafe { esp_hal::peripherals::LPWR::steal() });

 // Configure RTC timer wakeup
 rtc.sleep_deep(
 &[SleepSource::Timer],
 &esp_hal::rtc_cntl::SleepConfig {
 timer: Some(duration_secs * 1_000_000), // microseconds
 ..Default::default()
 },
 );

 // This never returns — device resets on wakeup
 unreachable!();
}

Deep sleep on the ESP32-C6 resets the main CPU. Only the RTC domain and ULP coprocessor survive. On wakeup, the firmware starts from the beginning. This means critical state must be stored (like sensor calibration offsets, transmission failure counts) in RTC memory:

#[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 in .rtc.data persist across deep sleep cycles. This tracks how many consecutive transmission failures have occurred — after 5 failures, the node switches to a longer sleep interval (30 minutes instead of 5) to conserve battery while the network issue is resolved.

Lessons Learned

Use esp-wifi from the start, not esp-idf-svc: The ESP-IDF compatibility layer may seem appealing initially, which gives you a more familiar C-like WiFi API. But it pulls in the entire ESP-IDF framework as a static library, bloating the binary from 180KB to 1.2MB and making debugging harder. The native esp-wifi crate is lower-level but integrates much better with embassy.

Add OTA from day one: Over-the-air firmware updates are essential for deployed sensor nodes. Adding OTA after the initial deployment means physically visiting every node to flash the OTA-capable firmware. The esp-ota crate works well but needs to be part of the initial architecture.

Use heapless data structures everywhere: Using alloc for a few Vec and String uses may seem harmless initially. On a device with 512KB of RAM, heap fragmentation after weeks of operation caused a crash. Replacing everything with fixed-size heapless::Vec and heapless::String eliminated the issue entirely.

The Rust + RISC-V embedded ecosystem has reached the point where it's a viable alternative to C for new projects. Not a replacement — there are still gaps in vendor support and documentation. But for greenfield IoT projects where reliability matters and you're willing to invest in the learning curve, the compile-time guarantees alone justify the switch. A comparable deployment shows zero memory-related crashes in four months of field operation across 12 nodes. Previous C firmware on similar hardware averaged one watchdog reset per node per month from null pointer dereferences and buffer overflows.

The vineyard sensors are still running. The grapes don't care what language the firmware is written in, but the type system watches the memory around the clock — a significant operational advantage.

risc-vrustembeddedfirmwareembassy-rsesp32-c6iotno-stdhalasync-embedded

Tools mentioned in this article

AWSTry AWS
DigitalOceanTry DigitalOcean
Disclosure: Some links in this article are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. I only recommend tools I personally use and trust.
Compartir
Seguime