| name | esp32-rust-embedded |
| description | Expert embedded Rust development for ESP32 microcontrollers using no-std, Embassy async framework, and the ESP-RS ecosystem (esp-hal, esp-rtos, esp-radio). Use when building, debugging, flashing, or adding features to ESP32 projects. Covers sensor integration (ADC, GPIO, I2C, SPI), power management (deep sleep, RTC memory), WiFi networking, MQTT clients, display drivers, async task patterns, memory allocation, error handling, and dependency management. Ideal for LilyGO boards, Espressif chips (ESP32, ESP32-S3, ESP32-C3), and any no-std Xtensa/RISC-V embedded development. |
| compatibility | Requires ESP Rust toolchain (espup), cargo-espflash, and ESP32/ESP32-S3/ESP32-C3 hardware. Designed for Claude Code and similar AI coding assistants. |
ESP32 Embedded Rust Specialist
Expert guidance for no-std Rust development on ESP32 microcontrollers using the ESP-RS ecosystem and Embassy async framework.
ESP-RS Ecosystem Stack
Core Dependencies
esp-hal = { version = "~1.1.0", features = ["esp32s3", "log-04", "unstable"] }
esp-rtos = { version = "0.3.0", features = ["embassy", "esp-alloc", "esp-radio", "esp32s3", "log-04"] }
esp-radio = { version = "0.18.0", features = ["esp-alloc", "esp32s3", "log-04", "unstable", "wifi"] }
esp-bootloader-esp-idf = { version = "0.5.0", features = ["esp32s3", "log-04"] }
esp-alloc = "0.10.0"
esp-println = { version = "0.17.0", features = ["esp32s3", "log-04"] }
smoltcp = { version = "0.13.0", default-features = false, features = [
"log", "medium-ethernet", "multicast",
"proto-dhcpv4", "proto-dns", "proto-ipv4",
"socket-dns", "socket-icmp", "socket-raw", "socket-tcp", "socket-udp",
] }
Embassy Framework
embassy-executor = { version = "0.10.0", features = ["log"] }
embassy-time = { version = "0.5.0", features = ["log"] }
embassy-net = { version = "0.9.1", features = ["dhcpv4", "log", "medium-ethernet", "tcp", "udp"] }
embassy-sync = { version = "0.7.2" }
Dependency Hierarchy
esp-radio (WiFi) -> esp-rtos (scheduler) -> esp-hal (HAL) -> esp-phy (PHY)
embassy-executor -> embassy-time -> embassy-sync -> embassy-net
Rust Edition & MSRV
- Edition: 2024 (
edition = "2024" in Cargo.toml)
- MSRV: 1.88 (
rust-version = "1.88")
- Binary location:
src/bin/main.rs (not src/main.rs) — [[bin]] entry required in Cargo.toml
Build & Flash
Environment Setup
espup install
source $HOME/export-esp.sh
cp .env.dist .env
Build Commands
./run.sh
cargo run --release
cargo run
Cargo Profile Optimization
[profile.dev]
opt-level = "s"
[profile.release]
lto = 'fat'
opt-level = 's'
codegen-units = 1
Common Build Errors
Linker error: undefined symbol _stack_start
- Check
build.rs has linkall.x configuration
- Verify esp-hal version compatibility
undefined symbol: esp_rtos_* ("esp-radio has no scheduler enabled")
- Ensure esp-rtos is started with BOTH a timer AND a software interrupt (changed in 0.3.0):
let timg0 = TimerGroup::new(peripherals.TIMG0);
let sw_interrupt = esp_hal::interrupt::software::SoftwareInterruptControl::new(
peripherals.SW_INTERRUPT
);
esp_rtos::start(timg0.timer0, sw_interrupt.software_interrupt0);
Environment variable errors
- Variables are compile-time via
env!() macro
- Changes require full rebuild
No-Std Patterns
Application Entry
#![no_std]
#![no_main]
#![deny(clippy::mem_forget)]
#![deny(clippy::large_stack_frames)]
use embassy_executor::Spawner;
use esp_hal::{clock::CpuClock, timer::timg::TimerGroup};
esp_bootloader_esp_idf::esp_app_desc!();
#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
esp_println::logger::init_logger_from_env();
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let peripherals = esp_hal::init(config);
esp_alloc::heap_allocator!(#[esp_hal::ram(reclaimed)] size: 73744);
let timg0 = TimerGroup::new(peripherals.TIMG0);
let sw_interrupt = esp_hal::interrupt::software::SoftwareInterruptControl::new(
peripherals.SW_INTERRUPT
);
esp_rtos::start(timg0.timer0, sw_interrupt.software_interrupt0);
let _ = spawner;
loop {}
}
Memory Management
- Use
esp-alloc for dynamic allocation
- Prefer
heapless collections with compile-time capacity
- Use
static_cell::StaticCell for 'static lifetime requirements
String Handling
use alloc::string::String;
use heapless::String;
let s: heapless::String<64> = heapless::String::new();
Avoid cloning when possible.
StaticCell Pattern
static CHANNEL: StaticCell<Channel<NoopRawMutex, Data, 3>> = StaticCell::new();
let channel: &'static mut _ = CHANNEL.init(Channel::new());
let (sender, receiver) = (channel.sender(), channel.receiver());
Hardware Patterns
GPIO Configuration
use esp_hal::gpio::{Level, Output, OutputConfig, Pull, DriveMode};
let pin = Output::new(peripherals.GPIO2, Level::Low, OutputConfig::default());
let pin = Output::new(
peripherals.GPIO1,
Level::High,
OutputConfig::default()
.with_drive_mode(DriveMode::OpenDrain)
.with_pull(Pull::None),
).into_flex();
ADC Reading with Calibration
use esp_hal::analog::adc::{Adc, AdcConfig, AdcCalCurve, Attenuation};
let mut adc_config = AdcConfig::new();
let pin = adc_config.enable_pin_with_cal::<_, AdcCalCurve<ADC2>>(
peripherals.GPIO11,
Attenuation::_11dB
);
let adc = Adc::new(peripherals.ADC2, adc_config);
let value = nb::block!(adc.read_oneshot(&mut pin))?;
Peripheral Bundles Pattern
pub struct SensorPeripherals {
pub dht11_pin: GPIO1<'static>,
pub moisture_pin: GPIO11<'static>,
pub power_pin: GPIO16<'static>,
pub adc2: ADC2<'static>,
}
Async Task Architecture
Task Definition
#[embassy_executor::task]
pub async fn my_task(sender: Sender<'static, NoopRawMutex, Data, 3>) {
loop {
sender.send(data).await;
Timer::after(Duration::from_secs(5)).await;
}
}
Task Spawning
spawner.spawn(sensor_task(sender, peripherals)).ok();
spawner.spawn(update_task(stack, display, receiver)).ok();
Inter-Task Communication
Channel (multiple values)
use embassy_sync::{blocking_mutex::raw::NoopRawMutex, channel::Channel};
static CHANNEL: StaticCell<Channel<NoopRawMutex, Data, 3>> = StaticCell::new();
Signal (single notification)
use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, signal::Signal};
static SIGNAL: Signal<CriticalSectionRawMutex, ()> = Signal::new();
Reconnection Loop Pattern
'reconnect: loop {
let mut client = initialize_client().await?;
loop {
match client.process().await {
Ok(_) => { }
Err(e) => {
println!("Error: {:?}", e);
continue 'reconnect;
}
}
}
}
Power Management
Deep Sleep Configuration
use esp_hal::rtc_cntl::{Rtc, sleep::{RtcSleepConfig, TimerWakeupSource, RtcioWakeupSource, WakeupLevel}};
pub fn enter_deep(wakeup_pin: &mut dyn RtcPin, rtc_cntl: LPWR, duration: Duration) -> ! {
let wakeup_pins: &mut [(&mut dyn RtcPin, WakeupLevel)] = &mut [(wakeup_pin, WakeupLevel::Low)];
let ext0 = RtcioWakeupSource::new(wakeup_pins);
let timer = TimerWakeupSource::new(duration.into());
let mut rtc = Rtc::new(rtc_cntl);
let mut config = RtcSleepConfig::deep();
config.set_rtc_fastmem_pd_en(false);
rtc.sleep(&config, &[&ext0, &timer]);
unreachable!();
}
RTC Fast Memory Persistence
use esp_hal::ram;
#[ram(unstable(rtc_fast))]
pub static BOOT_COUNT: RtcCell<u32> = RtcCell::new(0);
let count = BOOT_COUNT.get();
BOOT_COUNT.set(count + 1);
Power Optimization
- Toggle sensor power pins only during reads
- Use power save mode on displays
- Gracefully disconnect WiFi before sleep
- Keep awake duration minimal
WiFi Networking
Connection Setup
use esp_radio::wifi::{self, ClientConfig, ModeConfig, WifiController};
let (mut controller, interfaces) =
esp_radio::wifi::new(peripherals.WIFI, Default::default())
.expect("Failed to initialize Wi-Fi controller");
let client_config = ModeConfig::Client(
ClientConfig::default()
.with_ssid(env!("WIFI_SSID").try_into().unwrap())
.with_password(env!("WIFI_PSK").try_into().unwrap()),
);
controller.set_config(&client_config)?;
controller.start_async().await?;
controller.connect_async().await?;
Embassy-Net Stack
use embassy_net::{Config, Stack, StackResources};
let config = Config::dhcpv4(DhcpConfig::default());
let (stack, runner) = embassy_net::new(wifi_interface, config, stack_resources, seed);
loop {
if stack.is_link_up() { break; }
Timer::after(Duration::from_millis(500)).await;
}
loop {
if let Some(config) = stack.config_v4() {
println!("IP: {}", config.address);
break;
}
Timer::after(Duration::from_millis(500)).await;
}
Graceful WiFi Shutdown
pub static WIFI_SIGNAL: Signal<CriticalSectionRawMutex, ()> = Signal::new();
WIFI_SIGNAL.wait().await;
controller.stop_async().await?;
WIFI_SIGNAL.signal(());
Sensor Patterns
ADC Sampling with Warmup
async fn sample_adc_with_warmup<PIN, ADC>(
adc: &mut Adc<ADC, Blocking>,
pin: &mut AdcPin<PIN, ADC>,
warmup_ms: u64,
) -> Option<u16> {
Timer::after(Duration::from_millis(warmup_ms)).await;
nb::block!(adc.read_oneshot(pin)).ok()
}
Power-Controlled Sensor Read
async fn read_sensor(adc: &mut Adc, pin: &mut AdcPin, power: &mut Output) -> Option<u16> {
power.set_high();
let result = sample_adc_with_warmup(adc, pin, 50).await;
power.set_low();
result
}
Outlier-Resistant Averaging
fn calculate_average<T: Copy + Ord + Into<u32>>(samples: &mut [T]) -> Option<T> {
if samples.len() <= 2 { return None; }
samples.sort_unstable();
let trimmed = &samples[1..samples.len() - 1];
let sum: u32 = trimmed.iter().map(|&x| x.into()).sum();
(sum / trimmed.len() as u32).try_into().ok()
}
Display Integration
ST7789 Parallel Interface
use mipidsi::{Builder, options::ColorInversion};
let di = display_interface_parallel_gpio::Generic8BitBus::new();
let mut display = Builder::new(ST7789, di)
.display_size(320, 170)
.invert_colors(ColorInversion::Inverted)
.init(&mut delay)?;
Power Save Mode
display.set_display_on(false)?;
power_pin.set_low();
Error Handling
Module Error Pattern
#[derive(Debug)]
pub enum Error {
Wifi(WifiError),
Display(display::Error),
Mqtt(MqttError),
}
impl From<WifiError> for Error {
fn from(e: WifiError) -> Self { Self::Wifi(e) }
}
Fallible Main Pattern
#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
if let Err(error) = main_fallible(spawner).await {
log::error!("Fatal: {:?}", error);
esp_hal::system::software_reset();
}
unreachable!()
}
async fn main_fallible(spawner: Spawner) -> Result<(), Error> {
}
Dependency Updates
Safe Update Process
cargo outdated
cargo update -p esp-hal
cargo build --release
cargo clippy -- -D warnings
Breaking Change Patterns
- GPIO API changes frequently (OutputConfig)
- Timer initialization changes
- Feature flag renames
- esp-rtos 0.3.0:
esp_rtos::start() now requires 2 args (timer + software interrupt)
- esp-radio 0.18.0:
esp_radio::init() removed; call esp_radio::wifi::new() directly
- smoltcp: now a direct dependency, not a feature of esp-radio
- heap_allocator!: use
#[esp_hal::ram(reclaimed)] instead of #[unsafe(link_section = ".dram2_uninit")]
- Always check esp-hal release notes and migrate with esp-generate's generated templates as reference
Version Alignment
Update Embassy crates together:
cargo update -p embassy-executor -p embassy-time -p embassy-sync -p embassy-net
esp-generate (project scaffold tool)
cargo install esp-generate --locked
esp-generate --chip esp32s3 -o unstable-hal -o embassy -o alloc -o wifi -o log my-project
Note: wifi option now requires both unstable-hal and alloc.
Note: embassy option requires unstable-hal.
Debugging
Serial Logging
use esp_println::println;
esp_println::logger::init_logger_from_env();
log::info!("Debug: value = {}", value);
println!("Debug: value = {}", value);
Common Runtime Issues
- WiFi fails: Check 2.4GHz network, signal strength
- MQTT fails: Verify DNS resolution, broker credentials
- Sensors fail: Check warmup delays, power pin toggling
- Display blank: Ensure GPIO15 is HIGH (power enable)
- Sleep wake fails: Verify RTC fast memory config
Software Reset
use esp_hal::system::software_reset;
software_reset();