{"schemaVersion":1,"id":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware","url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware","title":"RISC-V + Rust Firmware: Building an IoT Sensor Node with Embassy-rs on the ESP32-C6","description":"A practical guide to writing no_std Rust firmware for RISC-V targets using embassy-rs async runtime, HAL abstractions, and a real IoT project with the ESP32-C6 running WiFi 6 and BLE 5.","language":"en","datePublished":"2026-03-03","dateModified":null,"contentFormat":"text/markdown","content":"Embedded\n\n# RISC-V + Rust Firmware: Building an IoT Sensor Node with Embassy-rs on the ESP32-C6\n\n2026-03-039 min read\n\n[Lucio Durán](<https://lucioduran.com/>)\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/risc-v-embedded-systems-rust-firmware>)\n\n## The Toolchain: Getting Rust Talking to RISC-V\n\nThe 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:\n\n```bash\n# Install the RISC-V target\nrustup target add riscv32imc-unknown-none-elf\n\n# Install probe-rs for flashing and debugging\ncargo install probe-rs-tools\n\n# Install espflash for ESP-specific flash tool\ncargo install espflash\n```\n\nThe project structure:\n\n```\nsensor-node/\n├── .cargo/\n│ └── config.toml # Target and runner config\n├── src/\n│ ├── main.rs # Entry point and task spawning\n│ ├── sensors.rs # Sensor reading tasks\n│ ├── network.rs # WiFi and data transmission\n│ ├── power.rs # Sleep and power management\n│ └── config.rs # Compile-time configuration\n├── Cargo.toml\n├── build.rs\n└── memory.x # Linker script (memory layout)\n```\n\nThe ` .cargo/config.toml ` is critical:\n\n```toml\n[target.riscv32imc-unknown-none-elf]\nrunner = \"espflash flash --monitor\"\n\n[build]\ntarget = \"riscv32imc-unknown-none-elf\"\nrustflags = [\n \"-C\", \"link-arg=-Tlinkall.x\",\n \"-C\", \"link-arg=-Tdefmt.x\",\n]\n\n[unstable]\nbuild-std = [\"core\", \"alloc\"]\n```\n\nThe ` 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.\n\n## Embassy-rs: Async Without an OS\n\n[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.\n\nHere's the main entry point:\n\n```rust\n#![no_std]\n#![no_main]\n\nuse embassy_executor::Spawner;\nuse embassy_time::{Duration, Timer};\nuse esp_hal::clock::CpuClock;\nuse esp_hal::gpio::{Input, Level, Output, Pull};\nuse esp_hal::i2c::master::I2c;\nuse esp_hal::peripherals::Peripherals;\nuse defmt::*;\nuse {defmt_rtt as _, esp_hal_embassy as _, panic_probe as _};\n\nmod sensors;\nmod network;\nmod power;\nmod config;\n\n#[embassy_executor::main]\nasync fn main(spawner: Spawner) {\n info!(\"Sensor node booting...\");\n\n let peripherals = esp_hal::init(esp_hal::Config::default().with_cpu_clock(CpuClock::max()));\n\n // Configure I2C for sensors (SHT40 temp/humidity, VEML7700 light)\n let i2c = I2c::new(\n peripherals.I2C0,\n esp_hal::i2c::master::Config::default()\n .with_frequency(400_000),\n )\n .unwrap()\n .with_sda(peripherals.GPIO6)\n .with_scl(peripherals.GPIO7);\n\n // ADC for soil moisture sensor\n let soil_pin = peripherals.GPIO4;\n\n // Status LED\n let led = Output::new(peripherals.GPIO8, Level::Low);\n\n // Spawn independent tasks\n spawner.spawn(sensor_reading_task(i2c, soil_pin)).unwrap();\n spawner.spawn(network_task()).unwrap();\n spawner.spawn(heartbeat_task(led)).unwrap();\n\n info!(\"All tasks spawned, entering main loop\");\n loop {\n Timer::after(Duration::from_secs(300)).await; // 5-minute cycle\n power::enter_light_sleep().await;\n }\n}\n\n#[embassy_executor::task]\nasync fn heartbeat_task(mut led: Output<'static>) {\n loop {\n led.set_high();\n Timer::after(Duration::from_millis(50)).await;\n led.set_low();\n Timer::after(Duration::from_secs(10)).await;\n }\n}\n```\n\nNotice 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.\n\n## Reading Sensors: HAL Abstractions in Practice\n\nThe sensor reading task talks to an SHT40 (temperature/humidity) and VEML7700 (ambient light) over I2C, plus an analog soil moisture probe via ADC:\n\n```rust\nuse embassy_time::{Duration, Timer};\nuse esp_hal::i2c::master::I2c;\nuse esp_hal::analog::adc::{Adc, AdcConfig, Attenuation};\nuse defmt::*;\n\n// SHT40 I2C address and commands\nconst SHT40_ADDR: u8 = 0x44;\nconst SHT40_MEASURE_HIGH: u8 = 0xFD;\n\n// VEML7700 I2C address\nconst VEML7700_ADDR: u8 = 0x10;\n\npub struct SensorReading {\n pub temperature_c: f32,\n pub humidity_pct: f32,\n pub light_lux: f32,\n pub soil_moisture_pct: f32,\n}\n\n#[embassy_executor::task]\npub async fn sensor_reading_task(\n mut i2c: I2c<'static, esp_hal::Async>,\n soil_pin: esp_hal::gpio::GpioPin<4>,\n) {\n // Configure ADC for soil moisture\n let mut adc_config = AdcConfig::new();\n let mut soil_channel = adc_config.enable_pin(soil_pin, Attenuation::Attenuation11dB);\n let mut adc = Adc::new(unsafe { esp_hal::peripherals::ADC1::steal() }, adc_config);\n\n loop {\n let reading = read_all_sensors(&mut i2c, &mut adc, &mut soil_channel).await;\n\n match reading {\n Ok(data) => {\n info!(\n \"T={:.1}C H={:.1}% L={:.0}lux S={:.1}%\",\n data.temperature_c,\n data.humidity_pct,\n data.light_lux,\n data.soil_moisture_pct\n );\n // Push to shared channel for network task\n SENSOR_CHANNEL.send(data).await;\n }\n Err(e) => {\n error!(\"Sensor read failed: {:?}\", e);\n }\n }\n\n Timer::after(Duration::from_secs(300)).await;\n }\n}\n\nasync fn read_sht40(i2c: &mut I2c<'static, esp_hal::Async>) -> Result<(f32, f32), i2c::Error> {\n // Send measurement command\n i2c.write(SHT40_ADDR, &[SHT40_MEASURE_HIGH]).await?;\n\n // SHT40 needs 8.2ms for high-precision measurement\n Timer::after(Duration::from_millis(10)).await;\n\n // Read 6 bytes: temp_msb, temp_lsb, temp_crc, hum_msb, hum_lsb, hum_crc\n let mut buf = [0u8; 6];\n i2c.read(SHT40_ADDR, &mut buf).await?;\n\n // Convert raw values (skip CRC check for brevity, don't do this in production)\n let raw_temp = ((buf[0] as u16) << 8) | buf[1] as u16;\n let raw_hum = ((buf[3] as u16) << 8) | buf[4] as u16;\n\n let temperature = -45.0 + 175.0 * (raw_temp as f32 / 65535.0);\n let humidity = -6.0 + 125.0 * (raw_hum as f32 / 65535.0);\n\n Ok((temperature, humidity.clamp(0.0, 100.0)))\n}\n```\n\nThe 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.\n\n## WiFi 6 and Data Transmission\n\nThe 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:\n\n```rust\nuse embassy_net::{Stack, Config as NetConfig};\nuse esp_wifi::wifi::{\n WifiController, WifiDevice, WifiEvent, WifiStaDevice, WifiState,\n Configuration, ClientConfiguration, AuthMethod,\n};\nuse embassy_net::tcp::TcpSocket;\nuse embassy_time::{Duration, Timer, with_timeout};\nuse defmt::*;\n\n#[embassy_executor::task]\npub async fn network_task(\n mut controller: WifiController<'static>,\n stack: Stack<'static>,\n) {\n loop {\n // Wait for sensor data\n let reading = SENSOR_CHANNEL.receive().await;\n\n // Connect to WiFi\n info!(\"Connecting to WiFi...\");\n let config = Configuration::Client(ClientConfiguration {\n ssid: heapless::String::try_from(config::WIFI_SSID).unwrap(),\n password: heapless::String::try_from(config::WIFI_PASS).unwrap(),\n auth_method: AuthMethod::WPA2Personal,\n ..Default::default()\n });\n\n controller.set_configuration(&config).unwrap();\n controller.start_async().await.unwrap();\n controller.connect_async().await.unwrap();\n\n // Wait for DHCP with timeout\n let dhcp_result = with_timeout(Duration::from_secs(10), async {\n loop {\n if stack.is_config_up() {\n break;\n }\n Timer::after(Duration::from_millis(100)).await;\n }\n }).await;\n\n if dhcp_result.is_err() {\n error!(\"DHCP timeout, will retry next cycle\");\n controller.stop_async().await.unwrap();\n continue;\n }\n\n // Send data to gateway\n match send_reading(&stack, &reading).await {\n Ok(_) => info!(\"Data sent successfully\"),\n Err(e) => error!(\"Send failed: {:?}\", e),\n }\n\n // Disconnect WiFi to save power\n controller.stop_async().await.unwrap();\n info!(\"WiFi stopped, radio off\");\n }\n}\n\nasync fn send_reading(\n stack: &Stack<'static>,\n reading: &SensorReading,\n) -> Result<(), embassy_net::tcp::Error> {\n let mut rx_buf = [0u8; 256];\n let mut tx_buf = [0u8; 512];\n let mut socket = TcpSocket::new(stack, &mut rx_buf, &mut tx_buf);\n\n socket.set_timeout(Some(Duration::from_secs(5)));\n\n let gateway_addr = embassy_net::IpEndpoint::new(\n embassy_net::IpAddress::v4(192, 168, 1, 100),\n 8080,\n );\n\n socket.connect(gateway_addr).await?;\n\n // Simple binary protocol: 4 floats, big-endian\n let mut payload = [0u8; 16];\n payload[0..4].copy_from_slice(&reading.temperature_c.to_be_bytes());\n payload[4..8].copy_from_slice(&reading.humidity_pct.to_be_bytes());\n payload[8..12].copy_from_slice(&reading.light_lux.to_be_bytes());\n payload[12..16].copy_from_slice(&reading.soil_moisture_pct.to_be_bytes());\n\n socket.write_all(&payload).await?;\n socket.flush().await?;\n socket.close();\n\n Ok(())\n}\n```\n\nThe 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.\n\n## Power Management: The Battery Life Math\n\nThe power budget for a 6-month battery life with two AA lithium cells (3,000mAh total at 3V):\n\n```\nTarget: 6 months = 4,320 hours\nBudget: 3,000mAh / 4,320h = 0.694mA average\n\nBreakdown per 5-minute cycle:\n- Deep sleep (298 seconds): 10uA × 298s = 2,980 uA·s\n- Wake + sensor read (1 second): 25mA × 1s = 25,000 uA·s\n- WiFi connect + transmit (1 second): 120mA × 1s = 120,000 uA·s\n- Total per cycle: 147,980 uA·s\n- Average current: 147,980 / 300 = 493 uA = 0.493 mA\n```\n\nThat 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).\n\nThe sleep implementation:\n\n```rust\nuse esp_hal::rtc_cntl::{Rtc, SleepSource};\nuse esp_hal::timer::timg::TimerGroup;\n\npub async fn enter_deep_sleep(duration_secs: u64) {\n // Stop WiFi radio completely\n // (must be done before sleep, leftover radio state draws 15mA)\n\n let rtc = Rtc::new(unsafe { esp_hal::peripherals::LPWR::steal() });\n\n // Configure RTC timer wakeup\n rtc.sleep_deep(\n &[SleepSource::Timer],\n &esp_hal::rtc_cntl::SleepConfig {\n timer: Some(duration_secs * 1_000_000), // microseconds\n ..Default::default()\n },\n );\n\n // This never returns, device resets on wakeup\n unreachable!();\n}\n```\n\nDeep 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:\n\n```rust\n#[link_section = \".rtc.data\"]\nstatic mut BOOT_COUNT: u32 = 0;\n\n#[link_section = \".rtc.data\"]\nstatic mut FAILED_TRANSMISSIONS: u32 = 0;\n\n#[link_section = \".rtc.data\"]\nstatic mut CALIBRATION_OFFSET: f32 = 0.0;\n```\n\nVariables 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.\n\n## Lessons Learned\n\n**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.\n\n**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.\n\n**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.\n\nThe 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.\n\nThe 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.\n\n## Frequently asked questions\n\n### Why Rust instead of C for RISC-V firmware?\n\nRust 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.\n\n### Can embassy-rs replace an RTOS like FreeRTOS?\n\nFor 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.\n\n### Is the ESP32-C6 RISC-V core comparable in performance to the ESP32's Xtensa cores?\n\nThe 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.\n\n## Tips\n\n1. **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.\n2. **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.\n3. **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.\n4. **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.\n\n- risc-v\n- rust\n- embedded\n- firmware\n- embassy-rs\n- esp32-c6\n- iot\n- no-std\n- hal\n- async-embedded\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"RISC-V + Rust Firmware: Building an IoT Sensor Node with Embassy-rs on the ESP32-C6","url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#title"},{"level":2,"text":"The Toolchain: Getting Rust Talking to RISC-V","url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#the-toolchain-getting-rust-talking-to-risc-v"},{"level":2,"text":"Embassy-rs: Async Without an OS","url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#embassy-rs-async-without-an-os"},{"level":2,"text":"Reading Sensors: HAL Abstractions in Practice","url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#reading-sensors-hal-abstractions-in-practice"},{"level":2,"text":"WiFi 6 and Data Transmission","url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#wifi-6-and-data-transmission"},{"level":2,"text":"Power Management: The Battery Life Math","url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#power-management-the-battery-life-math"},{"level":2,"text":"Lessons Learned","url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#lessons-learned"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#questions"},{"level":3,"text":"Why Rust instead of C for RISC-V firmware?","url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#question-1"},{"level":3,"text":"Can embassy-rs replace an RTOS like FreeRTOS?","url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#question-2"},{"level":3,"text":"Is the ESP32-C6 RISC-V core comparable in performance to the ESP32's Xtensa cores?","url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#article-tips"}],"sections":[{"heading":"RISC-V + Rust Firmware: Building an IoT Sensor Node with Embassy-rs on the ESP32-C6","level":1,"url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware","content":"Embedded"},{"heading":"RISC-V + Rust Firmware: Building an IoT Sensor Node with Embassy-rs on the ESP32-C6","level":1,"url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#title","content":"2026-03-039 min read\n\n[Lucio Durán](<https://lucioduran.com/>)\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/risc-v-embedded-systems-rust-firmware>)"},{"heading":"The Toolchain: Getting Rust Talking to RISC-V","level":2,"url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#the-toolchain-getting-rust-talking-to-risc-v","content":"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:\n\n```bash\n# Install the RISC-V target\nrustup target add riscv32imc-unknown-none-elf\n\n# Install probe-rs for flashing and debugging\ncargo install probe-rs-tools\n\n# Install espflash for ESP-specific flash tool\ncargo install espflash\n```\n\nThe project structure:\n\n```\nsensor-node/\n├── .cargo/\n│ └── config.toml # Target and runner config\n├── src/\n│ ├── main.rs # Entry point and task spawning\n│ ├── sensors.rs # Sensor reading tasks\n│ ├── network.rs # WiFi and data transmission\n│ ├── power.rs # Sleep and power management\n│ └── config.rs # Compile-time configuration\n├── Cargo.toml\n├── build.rs\n└── memory.x # Linker script (memory layout)\n```\n\nThe ` .cargo/config.toml ` is critical:\n\n```toml\n[target.riscv32imc-unknown-none-elf]\nrunner = \"espflash flash --monitor\"\n\n[build]\ntarget = \"riscv32imc-unknown-none-elf\"\nrustflags = [\n \"-C\", \"link-arg=-Tlinkall.x\",\n \"-C\", \"link-arg=-Tdefmt.x\",\n]\n\n[unstable]\nbuild-std = [\"core\", \"alloc\"]\n```\n\nThe ` 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."},{"heading":"Embassy-rs: Async Without an OS","level":2,"url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#embassy-rs-async-without-an-os","content":"[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.\n\nHere's the main entry point:\n\n```rust\n#![no_std]\n#![no_main]\n\nuse embassy_executor::Spawner;\nuse embassy_time::{Duration, Timer};\nuse esp_hal::clock::CpuClock;\nuse esp_hal::gpio::{Input, Level, Output, Pull};\nuse esp_hal::i2c::master::I2c;\nuse esp_hal::peripherals::Peripherals;\nuse defmt::*;\nuse {defmt_rtt as _, esp_hal_embassy as _, panic_probe as _};\n\nmod sensors;\nmod network;\nmod power;\nmod config;\n\n#[embassy_executor::main]\nasync fn main(spawner: Spawner) {\n info!(\"Sensor node booting...\");\n\n let peripherals = esp_hal::init(esp_hal::Config::default().with_cpu_clock(CpuClock::max()));\n\n // Configure I2C for sensors (SHT40 temp/humidity, VEML7700 light)\n let i2c = I2c::new(\n peripherals.I2C0,\n esp_hal::i2c::master::Config::default()\n .with_frequency(400_000),\n )\n .unwrap()\n .with_sda(peripherals.GPIO6)\n .with_scl(peripherals.GPIO7);\n\n // ADC for soil moisture sensor\n let soil_pin = peripherals.GPIO4;\n\n // Status LED\n let led = Output::new(peripherals.GPIO8, Level::Low);\n\n // Spawn independent tasks\n spawner.spawn(sensor_reading_task(i2c, soil_pin)).unwrap();\n spawner.spawn(network_task()).unwrap();\n spawner.spawn(heartbeat_task(led)).unwrap();\n\n info!(\"All tasks spawned, entering main loop\");\n loop {\n Timer::after(Duration::from_secs(300)).await; // 5-minute cycle\n power::enter_light_sleep().await;\n }\n}\n\n#[embassy_executor::task]\nasync fn heartbeat_task(mut led: Output<'static>) {\n loop {\n led.set_high();\n Timer::after(Duration::from_millis(50)).await;\n led.set_low();\n Timer::after(Duration::from_secs(10)).await;\n }\n}\n```\n\nNotice 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."},{"heading":"Reading Sensors: HAL Abstractions in Practice","level":2,"url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#reading-sensors-hal-abstractions-in-practice","content":"The sensor reading task talks to an SHT40 (temperature/humidity) and VEML7700 (ambient light) over I2C, plus an analog soil moisture probe via ADC:\n\n```rust\nuse embassy_time::{Duration, Timer};\nuse esp_hal::i2c::master::I2c;\nuse esp_hal::analog::adc::{Adc, AdcConfig, Attenuation};\nuse defmt::*;\n\n// SHT40 I2C address and commands\nconst SHT40_ADDR: u8 = 0x44;\nconst SHT40_MEASURE_HIGH: u8 = 0xFD;\n\n// VEML7700 I2C address\nconst VEML7700_ADDR: u8 = 0x10;\n\npub struct SensorReading {\n pub temperature_c: f32,\n pub humidity_pct: f32,\n pub light_lux: f32,\n pub soil_moisture_pct: f32,\n}\n\n#[embassy_executor::task]\npub async fn sensor_reading_task(\n mut i2c: I2c<'static, esp_hal::Async>,\n soil_pin: esp_hal::gpio::GpioPin<4>,\n) {\n // Configure ADC for soil moisture\n let mut adc_config = AdcConfig::new();\n let mut soil_channel = adc_config.enable_pin(soil_pin, Attenuation::Attenuation11dB);\n let mut adc = Adc::new(unsafe { esp_hal::peripherals::ADC1::steal() }, adc_config);\n\n loop {\n let reading = read_all_sensors(&mut i2c, &mut adc, &mut soil_channel).await;\n\n match reading {\n Ok(data) => {\n info!(\n \"T={:.1}C H={:.1}% L={:.0}lux S={:.1}%\",\n data.temperature_c,\n data.humidity_pct,\n data.light_lux,\n data.soil_moisture_pct\n );\n // Push to shared channel for network task\n SENSOR_CHANNEL.send(data).await;\n }\n Err(e) => {\n error!(\"Sensor read failed: {:?}\", e);\n }\n }\n\n Timer::after(Duration::from_secs(300)).await;\n }\n}\n\nasync fn read_sht40(i2c: &mut I2c<'static, esp_hal::Async>) -> Result<(f32, f32), i2c::Error> {\n // Send measurement command\n i2c.write(SHT40_ADDR, &[SHT40_MEASURE_HIGH]).await?;\n\n // SHT40 needs 8.2ms for high-precision measurement\n Timer::after(Duration::from_millis(10)).await;\n\n // Read 6 bytes: temp_msb, temp_lsb, temp_crc, hum_msb, hum_lsb, hum_crc\n let mut buf = [0u8; 6];\n i2c.read(SHT40_ADDR, &mut buf).await?;\n\n // Convert raw values (skip CRC check for brevity, don't do this in production)\n let raw_temp = ((buf[0] as u16) << 8) | buf[1] as u16;\n let raw_hum = ((buf[3] as u16) << 8) | buf[4] as u16;\n\n let temperature = -45.0 + 175.0 * (raw_temp as f32 / 65535.0);\n let humidity = -6.0 + 125.0 * (raw_hum as f32 / 65535.0);\n\n Ok((temperature, humidity.clamp(0.0, 100.0)))\n}\n```\n\nThe 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."},{"heading":"WiFi 6 and Data Transmission","level":2,"url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#wifi-6-and-data-transmission","content":"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:\n\n```rust\nuse embassy_net::{Stack, Config as NetConfig};\nuse esp_wifi::wifi::{\n WifiController, WifiDevice, WifiEvent, WifiStaDevice, WifiState,\n Configuration, ClientConfiguration, AuthMethod,\n};\nuse embassy_net::tcp::TcpSocket;\nuse embassy_time::{Duration, Timer, with_timeout};\nuse defmt::*;\n\n#[embassy_executor::task]\npub async fn network_task(\n mut controller: WifiController<'static>,\n stack: Stack<'static>,\n) {\n loop {\n // Wait for sensor data\n let reading = SENSOR_CHANNEL.receive().await;\n\n // Connect to WiFi\n info!(\"Connecting to WiFi...\");\n let config = Configuration::Client(ClientConfiguration {\n ssid: heapless::String::try_from(config::WIFI_SSID).unwrap(),\n password: heapless::String::try_from(config::WIFI_PASS).unwrap(),\n auth_method: AuthMethod::WPA2Personal,\n ..Default::default()\n });\n\n controller.set_configuration(&config).unwrap();\n controller.start_async().await.unwrap();\n controller.connect_async().await.unwrap();\n\n // Wait for DHCP with timeout\n let dhcp_result = with_timeout(Duration::from_secs(10), async {\n loop {\n if stack.is_config_up() {\n break;\n }\n Timer::after(Duration::from_millis(100)).await;\n }\n }).await;\n\n if dhcp_result.is_err() {\n error!(\"DHCP timeout, will retry next cycle\");\n controller.stop_async().await.unwrap();\n continue;\n }\n\n // Send data to gateway\n match send_reading(&stack, &reading).await {\n Ok(_) => info!(\"Data sent successfully\"),\n Err(e) => error!(\"Send failed: {:?}\", e),\n }\n\n // Disconnect WiFi to save power\n controller.stop_async().await.unwrap();\n info!(\"WiFi stopped, radio off\");\n }\n}\n\nasync fn send_reading(\n stack: &Stack<'static>,\n reading: &SensorReading,\n) -> Result<(), embassy_net::tcp::Error> {\n let mut rx_buf = [0u8; 256];\n let mut tx_buf = [0u8; 512];\n let mut socket = TcpSocket::new(stack, &mut rx_buf, &mut tx_buf);\n\n socket.set_timeout(Some(Duration::from_secs(5)));\n\n let gateway_addr = embassy_net::IpEndpoint::new(\n embassy_net::IpAddress::v4(192, 168, 1, 100),\n 8080,\n );\n\n socket.connect(gateway_addr).await?;\n\n // Simple binary protocol: 4 floats, big-endian\n let mut payload = [0u8; 16];\n payload[0..4].copy_from_slice(&reading.temperature_c.to_be_bytes());\n payload[4..8].copy_from_slice(&reading.humidity_pct.to_be_bytes());\n payload[8..12].copy_from_slice(&reading.light_lux.to_be_bytes());\n payload[12..16].copy_from_slice(&reading.soil_moisture_pct.to_be_bytes());\n\n socket.write_all(&payload).await?;\n socket.flush().await?;\n socket.close();\n\n Ok(())\n}\n```\n\nThe 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."},{"heading":"Power Management: The Battery Life Math","level":2,"url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#power-management-the-battery-life-math","content":"The power budget for a 6-month battery life with two AA lithium cells (3,000mAh total at 3V):\n\n```\nTarget: 6 months = 4,320 hours\nBudget: 3,000mAh / 4,320h = 0.694mA average\n\nBreakdown per 5-minute cycle:\n- Deep sleep (298 seconds): 10uA × 298s = 2,980 uA·s\n- Wake + sensor read (1 second): 25mA × 1s = 25,000 uA·s\n- WiFi connect + transmit (1 second): 120mA × 1s = 120,000 uA·s\n- Total per cycle: 147,980 uA·s\n- Average current: 147,980 / 300 = 493 uA = 0.493 mA\n```\n\nThat 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).\n\nThe sleep implementation:\n\n```rust\nuse esp_hal::rtc_cntl::{Rtc, SleepSource};\nuse esp_hal::timer::timg::TimerGroup;\n\npub async fn enter_deep_sleep(duration_secs: u64) {\n // Stop WiFi radio completely\n // (must be done before sleep, leftover radio state draws 15mA)\n\n let rtc = Rtc::new(unsafe { esp_hal::peripherals::LPWR::steal() });\n\n // Configure RTC timer wakeup\n rtc.sleep_deep(\n &[SleepSource::Timer],\n &esp_hal::rtc_cntl::SleepConfig {\n timer: Some(duration_secs * 1_000_000), // microseconds\n ..Default::default()\n },\n );\n\n // This never returns, device resets on wakeup\n unreachable!();\n}\n```\n\nDeep 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:\n\n```rust\n#[link_section = \".rtc.data\"]\nstatic mut BOOT_COUNT: u32 = 0;\n\n#[link_section = \".rtc.data\"]\nstatic mut FAILED_TRANSMISSIONS: u32 = 0;\n\n#[link_section = \".rtc.data\"]\nstatic mut CALIBRATION_OFFSET: f32 = 0.0;\n```\n\nVariables 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."},{"heading":"Lessons Learned","level":2,"url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#lessons-learned","content":"**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.\n\n**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.\n\n**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.\n\nThe 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.\n\nThe 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."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#questions","content":""},{"heading":"Why Rust instead of C for RISC-V firmware?","level":3,"url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#question-1","content":"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."},{"heading":"Can embassy-rs replace an RTOS like FreeRTOS?","level":3,"url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#question-2","content":"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."},{"heading":"Is the ESP32-C6 RISC-V core comparable in performance to the ESP32's Xtensa cores?","level":3,"url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#question-3","content":"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."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#article-tips","content":"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.\n2. **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.\n3. **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.\n4. **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.\n\n- risc-v\n- rust\n- embedded\n- firmware\n- embassy-rs\n- esp32-c6\n- iot\n- no-std\n- hal\n- async-embedded\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Lucio Durán","url":"https://lucioduran.com/"},{"text":"Español","url":"https://lucioduran.com/es/blog/risc-v-embedded-systems-rust-firmware"},{"text":"Embassy","url":"https://embassy.dev/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com/"},{"@type":"WebSite","@id":"https://lucioduran.com/#website","url":"https://lucioduran.com/","name":"Lucio Durán","inLanguage":["en","es"],"publisher":{"@id":"https://lucioduran.com/#person"}},{"@type":"WebPage","@id":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#page","url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware","name":"RISC-V + Rust Firmware: Building an IoT Sensor Node with Embassy-rs on the ESP32-C6 | Lucio Durán","description":"A practical guide to writing no_std Rust firmware for RISC-V targets using embassy-rs async runtime, HAL abstractions, and a real IoT project with the ESP32-C6 running WiFi 6 and BLE 5.","inLanguage":"en","author":{"@id":"https://lucioduran.com/#person"},"publisher":{"@id":"https://lucioduran.com/#person"},"isPartOf":{"@id":"https://lucioduran.com/#website"},"isAccessibleForFree":true,"datePublished":"2026-03-03","mainEntity":{"@id":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#article"},"breadcrumb":{"@id":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#breadcrumb"}},{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#article","mainEntityOfPage":{"@id":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#page"},"headline":"RISC-V + Rust Firmware: Building an IoT Sensor Node with Embassy-rs on the ESP32-C6","description":"A practical guide to writing no_std Rust firmware for RISC-V targets using embassy-rs async runtime, HAL abstractions, and a real IoT project with the ESP32-C6 running WiFi 6 and BLE 5.","datePublished":"2026-03-03","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@id":"https://lucioduran.com/#person"},"url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Embedded","keywords":"risc-v, rust, embedded, firmware, embassy-rs, esp32-c6, iot, no-std, hal, async-embedded","wordCount":1607,"timeRequired":"PT9M","workTranslation":[{"@type":"BlogPosting","@id":"https://lucioduran.com/es/blog/risc-v-embedded-systems-rust-firmware#article","inLanguage":"es","url":"https://lucioduran.com/es/blog/risc-v-embedded-systems-rust-firmware"}],"isAccessibleForFree":true},{"@type":"Blog","@id":"https://lucioduran.com/blog#blog","url":"https://lucioduran.com/blog","name":"Blog","inLanguage":"en","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com/"},"publisher":{"@id":"https://lucioduran.com/#person"},"isPartOf":{"@id":"https://lucioduran.com/#website"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"RISC-V + Rust Firmware: Building an IoT Sensor Node with Embassy-rs on the ESP32-C6","item":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware"}],"@id":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#breadcrumb"},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Why Rust instead of C for RISC-V firmware?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"Can embassy-rs replace an RTOS like FreeRTOS?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"Is the ESP32-C6 RISC-V core comparable in performance to the ESP32's Xtensa cores?","acceptedAnswer":{"@type":"Answer","text":"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."}}],"@id":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#questions","url":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#questions","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware#page"},"author":{"@id":"https://lucioduran.com/#person"}}]}],"contentHash":"ab9371b387b9ea514ea3abcb8ae904151842515e2e5673c1a23902558f047620","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/risc-v-embedded-systems-rust-firmware","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":75372,"contentBytes":16452}}
