Articles › IoT and embedded

ESP32 in Rust with esp-hal 1.0: what is stable, and your first blink

esp-hal 1.0 is the vendor-backed Rust SDK for ESP32. GPIO, UART, SPI and I2C are stable; Wi-Fi is not yet. Toolchain setup, a project from esp-generate, and a blink.

Intermediate 6 min read October 23, 2026

What you will learn
  • esp-hal 1.0 shipped on 30 October 2025 as a no_std Rust SDK backed by Espressif itself, no longer a purely community project
  • Stable: init, GPIO, UART, SPI, I2C (blocking and async), the time module and the #[main] macro; everything else sits behind the unstable feature
  • Wi-Fi and Bluetooth live in esp-radio, still 1.0.0-beta as of October 2026, so connected projects must accept API changes
  • Generate a project with esp-generate, then cargo run --release builds, flashes and opens the serial monitor in one go

Writing ESP32 firmware in Rust has been possible for years, but the APIs used to change with nearly every release. esp-hal 1.0 changes that: for the first time the chip vendor backs a Rust SDK and promises that the parts declared stable will not break until 2.0. This article covers what works today, what is still waiting, and gets you to a blinking LED.

Facts and code checked against the esp-hal 1.0.0 docs (docs.espressif.com), the esp-generate 1.4.0 template and crates.io as of October 2026 (latest esp-hal 1.2.2, espflash 4.6.0). The example targets an ESP32-C6.

What esp-hal is, and what 1.0 changed

esp-hal is a no_std hardware abstraction layer that runs directly on the chip, with no FreeRTOS or ESP-IDF underneath. The timeline:

DateEvent
24 Feb 20251.0.0-beta.0; Espressif forms an official Rust team and calls it the first vendor-backed Rust SDK
30 Oct 2025esp-hal 1.0.0
Sep 2026esp-hal 1.2.2 and esp-radio 1.0.0-beta.2

The beta announcement also said the std crates (esp-idf-sys, esp-idf-hal, esp-idf-svc) are now community-supported. Anyone who wants Espressif’s support should move to esp-hal.

Stable (semver 1.x)init and configGPIO UART SPI I2Ctime and #[main]Not yet stableother drivers: unstableesp-radio (Wi-Fi/BLE)still 1.0.0-beta
Wired jobs are ready today; networked ones wait on esp-radio

What is stable and what is not

PartStatus as of October 2026
esp_hal::init and its configstable
GPIO, UART, SPI, I2C (blocking and async)stable
time module (Instant, Duration, Rate)stable
#[main] macro, esp-config, some system APIs such as SoC resetstable
every other driverbehind the unstable feature
Wi-Fi, Bluetooth, ESP-NOW, 802.15.4 (esp-radio)1.0.0-beta, not yet 1.0

“Unstable” here means API stability, not driver quality. The team says unstable drivers suit most common use cases; the next minor release may simply rename or reshape them. The async drivers work with several executors, Embassy among them.

A simple rule: if the job ends at a wire (I2C sensors, SPI displays, UART modules), esp-hal is ready today. If networking is the heart of it, accept that esp-radio is still beta.

Supported chips

esp-hal 1.0 offers the chip features esp32, esp32c2, esp32c3, esp32c6, esp32h2, esp32s2 and esp32s3; enable exactly one. The chips come in two architectures, which decides how you install the toolchain.

ChipArchitectureRust target
ESP32-C2, C3RISC-Vriscv32imc-unknown-none-elf
ESP32-C6, H2RISC-Vriscv32imac-unknown-none-elf
ESP32, S2, S3Xtensaxtensa-esp32-none-elf, xtensa-esp32s2-none-elf, xtensa-esp32s3-none-elf

Installing the tools

Run these on your development machine (Linux, macOS or Windows with Rust via rustup).

# RISC-V chips (this example uses the ESP32-C6)
rustup toolchain install stable --component rust-src
rustup target add riscv32imac-unknown-none-elf

# Xtensa chips (ESP32, S2, S3) need Espressif's toolchain instead
cargo install espup --locked
espup install

# Flashing tool and project generator
cargo install espflash --locked
cargo install esp-generate --locked

On Linux and macOS, after espup install you must set the environment variables as the espup README describes, or Xtensa builds fail.

esp-generate -o esp32c6 blinky
cd blinky

The chip is one of the -o option values. esp-generate opens a TUI for the remaining options (such as unstable-hal, Embassy, logging); add --headless to skip it, and list everything with esp-generate list-options. A blink needs nothing extra. It writes Cargo.toml, rust-toolchain.toml and a .cargo/config.toml whose runner is espflash flash --monitor.

Wire an LED with a 330 Ω resistor from GPIO5 to GND, then edit src/bin/main.rs:

#![no_std]
#![no_main]

use esp_hal::{
    clock::CpuClock,
    gpio::{Level, Output, OutputConfig},
    main,
    time::{Duration, Instant},
};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// App descriptor required by the ESP-IDF bootloader (esp-generate adds it)
esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let mut led = Output::new(peripherals.GPIO5, Level::High, OutputConfig::default());

    loop {
        led.toggle();
        let start = Instant::now();
        while start.elapsed() < Duration::from_millis(500) {}
    }
}
# with the board plugged in over USB
cargo run --release

cargo run builds, flashes through espflash and opens the serial monitor in one command.

Three things to notice. peripherals are singletons handed out once by init; use GPIO5 in two places and the compiler refuses, so Rust ownership rules out “two pieces of code fighting over one pin” at compile time. Second, everything here is in the stable surface; no unstable needed. Third, this loop busy-waits with the CPU flat out. Fine for a demo; battery-powered work should use async with Embassy, or sleep.

Worked example: UART is slower than you think

Add a UART that prints every loop (also stable):

use esp_hal::uart::{Config, Uart};

let mut uart = Uart::new(peripherals.UART1, Config::default().with_baudrate(115_200))
    .unwrap()
    .with_rx(peripherals.GPIO4)
    .with_tx(peripherals.GPIO6);

uart.write(b"tick: led toggled, sensor ok, temp=23.5C, rssi=n/a\r\n").unwrap();

Do the timing. At 115,200 baud with 8N1 framing, each byte costs 10 bits (1 start + 8 data + 1 stop).

  1. 115,200 ÷ 10 = 11,520 bytes per second
  2. A 64-byte message → 64 ÷ 11,520 = 5.56 ms
  3. In a 500 ms loop, 64 bytes uses 5.56 ÷ 500 ≈ 1.1% of the line. Comfortable
  4. Shrink the loop to 5 ms and send 64 bytes every pass, and the message needs 5.56 ms, longer than the loop itself. A blocking write stretches every iteration and your timing drifts

The lesson: logging in a tight loop is real load. Shorten messages, log less often, or raise the baud rate; never assume it is “just a print”.

Common mistakes

  • Trying the on-board LED and seeing nothing. Many DevKit boards use an addressable RGB LED (WS2812), which plain GPIO cannot drive; it needs a specific timed signal. Check the board schematic, or use an external LED as above
  • Picking a strapping pin or a flash pin. Some pins have boot-time roles. Check the chip datasheet before choosing
  • Enabling unstable in a library crate. The docs say libraries should use requires-unstable and leave unstable to the final application
  • Forgetting the espup environment. Xtensa builds fail with errors about a missing target or linker
  • Blanket cargo update without reading changelogs. The stable surface is safe under semver, but esp-radio and unstable APIs can change. Pin versions in Cargo.toml
  • Expecting the same OTA story after moving to Rust. The two-slot updates with rollback in ESP32 OTA updates are built on ESP-IDF and need Wi-Fi, which is still beta on the Rust side. If devices in the field must update over the network, check the crates you would rely on before you switch

Sources

FAQ

Should I pick esp-hal or ESP-IDF (C) for real work?

If the job depends on Wi-Fi, BLE, OTA or the full set of ESP-IDF components today, ESP-IDF is more complete. For sensors or wired protocols (UART/SPI/I2C), esp-hal 1.x is ready, and you get Rust's safety plus APIs promised not to break until 2.0.

Is esp-idf-hal (Rust with std) still an option?

It works, but since the 2025 beta announcement Espressif lists esp-idf-sys, esp-idf-hal and esp-idf-svc as community-supported. The path Espressif officially supports is the no_std esp-hal.

Can I use the unstable feature in production?

Yes. Unstable refers to API stability, so names and shapes may change in minor releases; it does not mean the drivers are broken, and the team calls them suitable for most common use cases. Pin versions and read the changelog before upgrading. The docs say library crates must not enable unstable themselves.

Commands and settings were tested on sample systems. Try them on a test machine before production.