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

Embedded

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

2026-03-039 min read

[Lucio Durán](<https://lucioduran.com/>)
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/risc-v-embedded-systems-rust-firmware>)

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

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

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

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](<https://embassy.dev/>) 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:

```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!("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:

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

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

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

```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 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.

## Frequently asked questions

### Why Rust instead of C for RISC-V firmware?

Rust gives you memory safety without a garbage collector, which eliminates entire classes of bugs (buffer overflows, use-after-free, data races) that are the top causes of firmware security vulnerabilities. The type system also catches hardware misuse at compile time, like trying to use a GPIO pin that's already been configured for a different peripheral. The tradeoff is a steeper learning curve and a smaller ecosystem of existing libraries compared to C.

### Can embassy-rs replace an RTOS like FreeRTOS?

For many use cases, yes. Embassy's async executor provides cooperative multitasking similar to an RTOS's task scheduler, but with compile-time safety guarantees and zero-cost abstractions. However, embassy doesn't provide hard real-time guarantees like preemptive RTOS schedulers. If you need deterministic sub-microsecond interrupt response times, you may still need FreeRTOS or bare-metal interrupt handlers.

### Is the ESP32-C6 RISC-V core comparable in performance to the ESP32's Xtensa cores?

The ESP32-C6 has a single RISC-V core at 160MHz, while the original ESP32 has dual Xtensa cores at 240MHz. For raw compute, the ESP32 wins. But the C6 has WiFi 6, BLE 5, 802.15.4 (Thread/Zigbee), and significantly better power efficiency. For IoT sensor nodes where you're mostly sleeping and occasionally transmitting data, the C6 is the better choice.

## Tips

1. **Tip:** When starting with embassy-rs on ESP32-C6, use ` esp-idf-template ` first to validate your hardware works, then migrate to bare-metal ` esp-hal `. It is common to spend a full day thinking a board is defective when it is actually a misconfigured flash mode in ` .cargo/config.toml `, the template catches these issues automatically.
2. **Tip:** Always pin your ` esp-hal ` version in ` Cargo.toml ` with an exact version (` =0.23.1 ` not ` 0.23 `). The HAL crate is still evolving fast and a minor bump can change GPIO pin type signatures, breaking your entire pin configuration. Builds have been known to fail on CI because a patch release changed a trait bound.
3. **Tip:** For power-sensitive deployments, measure actual current draw with a ` PPK2 ` or similar tool, don't trust the datasheet numbers. An ESP32-C6 may pull 15mA in 'light sleep' because the WiFi radio is not fully shutting down. Adding ` esp_wifi::wifi::WifiController::stop() ` before sleep got it down to 130uA.
4. **Tip:** Use ` defmt ` instead of ` log ` for debugging in no\_std. It's 10x faster because it defers string formatting to the host side via ` probe-rs `. On a tight sensor loop running at 100Hz, ` log ` macros were adding 800us per iteration while ` defmt ` added 12us.

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

[Back to Blog](<https://lucioduran.com/blog>)
