Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

12.1 Rust on ESP32

ESP32 and Design Patterns for Edge Programming

Section titled “ESP32 and Design Patterns for Edge Programming”

In Edge programming, every decision you make about memory, concurrency, and code structure has a direct impact on how your device behaves in the real world. Small microcontrollers like the ESP32 must run multiple tasks, handle sensors and networking, and remain reliable for long periods, all with limited RAM, tight timing, and strict energy budgets. This chapter brings together the key ideas you’ve met throughout the module and shows how Rust helps you meet these challenges through safe resource management, efficient code, and clear design patterns tailored for embedded systems.

We start by comparing the two major development paths on ESP32: using std with ESP-IDF or using no_std with esp-hal, and we show how each choice affects performance, binary size, concurrency, and how you structure your program. We then introduce Embassy, a modern async framework that lets you write highly responsive, non-blocking tasks without relying on a heavy RTOS. You’ll also see how immediate-mode GUIs like egui fit naturally into both std and no_std environments, making it possible to build simple embedded user interfaces with the same tools you used on the desktop.

The second half of the chapter focuses on resource management and design patterns that make embedded Rust both safe and efficient. You’ll learn how ownership and borrowing prevent hardware-level data races, how zero-cost abstractions give you clean code with no runtime penalty, how heapless data structures remove the need for a traditional heap, and how common embedded design patterns (such as splitting peripherals, using channels for communication, and applying async executors) help you organise complex behaviour in a predictable and maintainable way.

By the end of the chapter, you will understand how Rust’s features, design patterns, and ecosystem work together to produce embedded software that is fast, safe, and reliable, which is exactly what edge devices demand.

The Rust Performance Book: https://nnethercote.github.io/perf-book/introduction.html

“This book is a focused guide dedicated to improving the performance-related aspects of Rust programs, covering runtime speed, memory usage, binary size, and compile time. While some techniques are Rust-specific, others are broadly applicable to programming in general. It emphasises practical, real-world techniques (often with links to pull requests and real examples) and is aimed at intermediate to advanced Rust users rather than beginners. The content is intentionally concise and intends to give breadth over depth, with pointers to external resources for deeper dives.”

The contents in this book go beyond what we need to cover in this module, but it may be helpful for Final Year or Masters project work.

Throughout this module, we have investigated C/C++ for edge devices, such as on Embedded Linux or ESP32 devices. We have looked at everything from printf debugging, manual memory management (new/delete), and the complexities of concurrent tasks. You’ve seen first-hand how powerful this platform is, but also how easy it is to introduce data races, buffer overflows, or NULL pointer dereferences that lead to a device crash.

You’ve also learned the fundamentals of Rust, a language that guarantees memory safety and data-race-free concurrency at compile time.

Figure 1. The typical I/O on an ESP32 development kit.

A typical ESP32 board, as illustrated in Figure 1, with its dual-core processor, decent RAM, and built-in Wi-Fi/Bluetooth, represents a good platform to compare these two worlds. It’s not a tiny, low-resource chip, but behaves more like a microcontroller-plus-computer, and the Rust community has built a production-ready ecosystem for it.

This discussion will synthesize everything you’ve learned by exploring the “two-and-a-half” primary paths for developing on the ESP32 with Rust.

Understanding the ESP32 Family Hardware
It’s important to know that “ESP32” is not a single chip, but a family. The board you use dictates your capabilities, and the esp-rs ecosystem supports most of them. Here’s a high-level breakdown (current as of 2025):

  • ESP32 (Classic): The original dual-core Xtensa-based workhorse. It features Wi-Fi and Bluetooth Classic/LE. This is an excellent all-rounder for general-purpose IoT projects, prototyping, and complex applications that benefit from two cores.
  • ESP32-S Series (S2 & S3):
    • ESP32-S2: A single-core Xtensa chip with Wi-Fi but no Bluetooth. Its key feature is native USB-OTG, making it perfect for devices that act as a USB peripheral (like a keyboard or MIDI controller).
    • ESP32-S3: The modern dual-core successor to the classic ESP32. It adds vector instructions for AI/ML acceleration, has Wi-Fi and Bluetooth 5 (LE), and more I/O. You would choose the S3 for ML on the edge (e.g., wake-word detection, simple image recognition) or for driving complex GUIs.
  • ESP32-C Series (C3 & C6):
    • RISC-V Architecture: The main difference is that these use the open-source RISC-V core instead of the proprietary Xtensa core.
    • ESP32-C3: A single-core RISC-V chip with Wi-Fi and Bluetooth 5 (LE). It’s designed as a low-cost, secure, and low-power chip, often seen as the modern successor to the famous ESP8266.
    • ESP32-C6: A single-core RISC-V chip that is a game-changer for smart home applications. It supports Wi-Fi 6, Bluetooth 5 (LE), Thread, and Zigbee. You would specifically choose this to build a device for the Matter standard, as it can act as a Thread border router or end device.
  • ESP32-H Series (H2):
    • A single-core RISC-V chip with no Wi-Fi. It is a dedicated Bluetooth 5 (LE) and Thread/Zigbee radio. You’d use this for ultra-low-power battery-operated mesh network devices, like a simple door sensor or light bulb that speaks Thread.

RISC-V is an important development for ESP32 devices because it replaces the older proprietary Xtensa architecture with an open, royalty-free instruction set that reduces licensing costs and gives Espressif greater flexibility to customise their designs. Its rapidly growing ecosystem (spanning compilers, operating systems [e.g., Zephyr, FreeRTOS, RTOS variants], debuggers, and especially strong Rust support) makes development more stable and future-proof. RISC-V’s modularity also enables more efficient, low-power designs and easier integration of modern features such as wireless coexistence and edge-AI acceleration, while aligning ESP32 devices with a global, open-source hardware community that improves tooling, documentation, and long-term sustainability.

In my experience, Rust’s official tooling (rustc, LLVM, esp-hal) works far more smoothly on RISC-V ESP32 chips (e.g., ESP32-C3, ESP32-C6, ESP32-H2), but I am only one sample point.

Your choice of chip depends entirely on your project’s needs: Do you need dual-core performance (ESP32, S3)? Native USB (S2)? AI acceleration (S3)? Or advanced wireless support (C6, H2)? Rust provides HALs for these different targets, allowing you to write similar code for very different hardware.

A Hardware Abstraction Layer (HAL) is a Rust crate that provides a safe, high-level, and platform-specific interface to the microcontroller’s hardware peripherals. Instead of writing to raw registers, you use Rust types and methods that enforce correctness at compile time. On the ESP32, the HAL (e.g., esp-hal or chip-specific versions like esp32-hal, esp32s3-hal) offers:

  • Safe wrappers around peripherals such as GPIO, UART, I²C, SPI, timers, ADC, and PWM. (See Figure 1.)
  • Type-checked configuration to prevent invalid states (e.g., misconfigured pins).
  • Zero-cost abstractions, meaning the compiled code should be as efficient as using registers directly.
  • Consistent APIs across different ESP32 variants, making code more portable. This is a hugely overlooked benefit, but in a commercial environment it’s often necessary to port code from one hardware platform to another, especially as new, more powerful, boards are released. Having an abstraction layer can greatly reduce the development time.

In short, the HAL lets you write safe, idiomatic Rust for ESP32 hardware without dealing with register-level code, while still producing fast and correct embedded applications.

So far in this module, we have used the Standard Library (std). As discussed in earlier chapters, this library provides Vec, String, Box (heap allocation), std::thread (OS threads), std::net (OS networking), and std::fs (OS file system). However, std assumes an Operating System is present to provide these services.

In traditional embedded development, there is typically also a “no OS” solution. This is the no_std world in Rust. For example:

  • You add #![no_std] to the top of your main.rs.
  • You lose the entire std library.
  • You lose heap allocation by default (e.g., there is no Vec, Box).
  • You gain access to the core library (which provides primitives like Option, Result, iterators) and, optionally, the alloc library (if you provide a heap allocator).

Most microcontrollers (like a small ARM Cortex-M0) are strictly no_std. The ESP32, however, is special. The official Espressif IoT Development Framework (ESP-IDF) is an OS (FreeRTOS). This unique fact creates two completely different ways to use Rust on the ESP32.

The std Approach (via esp-idf-hal)
This is the easiest and most familiar way to write Rust for the ESP32, especially if you already know desktop Rust or have used ESP-IDF in C/C++. The esp-idf-hal crate acts as a bridge: it gives you normal Rust std features, but underneath it relies on the ESP-IDF system (FreeRTOS, lwIP, SPIFFS/FatFS, etc.).

In practice, this means that when you write standard Rust code, it quietly calls the ESP32’s native systems behind the scenes:

  • std::thread::spawn(...) → creates a FreeRTOS task on the ESP32.
  • std::net::TcpStream::connect(...) → uses the lwIP networking stack provided by ESP-IDF.
  • std::fs::File::create(...) → uses the ESP-IDF’s SPIFFS or FatFS file system implementation.

So although you write normal Rust std code, everything is actually powered by ESP-IDF’s proven C libraries, just wrapped safely and ergonomically for Rust.

Pros:

  • Familiar standard library: You can use Vec, String, Box, std::thread, std::net, and other std features exactly as you would on a desktop system, making the learning curve much gentler.
  • Excellent crate compatibility: Many popular crates from crates.io that require std (such as HTTP clients like reqwest) work without modification.
  • Full access to ESP-IDF: Because you sit on top of the ESP-IDF, you can directly call its entire C API for peripherals, networking, storage, and system features that may not yet be available in Rust.

Cons:

  • Large binaries: Your program must compile and link the full ESP-IDF and FreeRTOS stack, which means significantly larger firmware sizes compared to pure Rust no_std approaches.
  • Not pure Rust: You are effectively running “Rust on top of a big C framework,” meaning you inherit ESP-IDF’s behaviours, limitations, and bugs. You rely on the safety of ESP-IDF rather than benefiting fully from Rust’s memory-safety guarantees.

The no_std Approach (using esp-hal)
This is the true “bare-metal” path, and the one most commonly used across the wider embedded Rust ecosystem. The esp-hal crate provides a Hardware Abstraction Layer written entirely in Rust, with no ESP-IDF and no FreeRTOS underneath — you are interacting with the hardware directly.

In practice, this means the HAL exposes Rust types and traits that control the peripherals without relying on any C code. It follows the embedded-hal trait ecosystem, which defines standard interfaces such as SpiDevice, I2cDevice, and digital::OutputPin. Because of this, any no_std Rust driver written for embedded-hal (e.g. for sensors like the BME280) will work on the ESP32 as long as esp-hal implements the required traits.

Pros:

  • Pure Rust: The entire stack (from application code down to peripheral control) is written in Rust, giving you full safety benefits and avoiding C-based frameworks.
  • Small and efficient binaries: With no ESP-IDF and no FreeRTOS, firmware is tiny and performance characteristics are predictable and low-overhead.
  • Ecosystem portability: Because esp-hal follows embedded-hal, you gain access to a wide ecosystem of reusable, cross-platform drivers.

Cons:

  • No standard library: Without std, you must rely on alternatives such as heapless for collections, or manually add a custom allocator if you need dynamic memory.
  • Limited concurrency model: There is no std::thread, so concurrency must be handled through interrupts, timers, and callbacks — often leading to complex, callback-driven designs. This motivates the need for more modern async-friendly approaches used in newer embedded Rust frameworks.

Here is an example program, so show you the type of Rust code necessary to drive hardware using no_std with an ESP32:

#![no_std]
#![no_main]
use esp_backtrace as _;
use esp_hal::{
delay::Delay,
gpio::{Level, Output},
prelude::*,
};
use log::info;
#[entry]
fn main() -> ! {
let peripherals = esp_hal::init({
let mut config = esp_hal::Config::default();
config.cpu_clock = CpuClock::max();
config
});
// Set up the built-in LED pin as an output
let mut led = Output::new(peripherals.GPIO8, Level::High);
esp_println::logger::init_logger_from_env();
let delay = Delay::new();
loop {
info!("T");
// Turn LED on
led.set_high();
delay.delay(500.millis());
// Turn LED off
led.set_low();
delay.delay(500.millis());
}
}

Deploying this to the board has the following form:

Terminal window
PS C:\Users\Derek Molloy\Rust\hello-world-esp> cargo build --release
Finished `release` profile [optimized + debuginfo] target(s) in 0.15s
PS C:\Users\Derek Molloy\Rust\hello-world-esp> cargo run --release
Finished `release` profile [optimized + debuginfo] target(s) in 0.09s
Running `espflash flash --monitor target\riscv32imc-unknown-none-elf\release\main`
✔ Use serial port 'COM10' - USB Serial Device (COM10)? · yes
✔ Remember this serial port for future use? · yes
[2024-12-06T19:11:08Z INFO ] Serial port: 'COM10'
[2024-12-06T19:11:08Z INFO ] Connecting...
[2024-12-06T19:11:08Z INFO ] Using flash stub
Chip type: esp32c3 (revision v0.4)
Crystal frequency: 40 MHz
Flash size: 4MB
Features: WiFi, BLE
MAC address: 98:3d:ae:52:bd:78
App/part. size: 78,880/4,128,768 bytes, 1.91%
[2024-12-06T19:11:09Z INFO ] Segment at address '0x0' has not changed, skipping write
[2024-12-06T19:11:09Z INFO ] Segment at address '0x8000' has not changed, skipping write
[00:00:00] [========================================] 14/14 0x10000
[2024-12-06T19:11:10Z INFO ] Flashing has completed!
Commands:
CTRL+R Reset chip
CTRL+C Exit
ESP-ROM:esp32c3-api1-20210207
Build:Feb 7 2021
rst:0x15 (USB_UART_CHIP_RESET),boot:0xf (SPI_FAST_FLASH_BOOT)
Saved PC:0x4038055a
0x4038055a - core::ptr::write_volatile
at ??:??
SPIWP:0xee
mode:DIO, clock div:2
load:0x3fcd5820,len:0x1714
load:0x403cc710,len:0x968
load:0x403ce710,len:0x2f9c
entry 0x403cc710
I (24) boot: ESP-IDF v5.1.2-342-gbcf1645e44 2nd stage bootloader
I (24) boot: compile time Dec 12 2023 10:50:58
I (25) boot: chip revision: v0.4
I (29) boot.esp32c3: SPI Speed : 40MHz
I (34) boot.esp32c3: SPI Mode : DIO
I (38) boot.esp32c3: SPI Flash Size : 4MB
I (43) boot: Enabling RNG early entropy source...
I (48) boot: Partition Table:
I (52) boot: ## Label Usage Type ST Offset Length
I (59) boot: 0 nvs WiFi data 01 02 00009000 00006000
I (67) boot: 1 phy_init RF data 01 01 0000f000 00001000
I (74) boot: 2 factory factory app 00 00 00010000 003f0000
I (82) boot: End of partition table
I (86) esp_image: segment 0: paddr=00010020 vaddr=3c010020 size=01268h ( 4712) map
I (95) esp_image: segment 1: paddr=00011290 vaddr=3fc8088c size=003e8h ( 1000) load
I (103) esp_image: segment 2: paddr=00011680 vaddr=40380000 size=0088ch ( 2188) load
I (112) esp_image: segment 3: paddr=00011f14 vaddr=00000000 size=0e104h ( 57604)
I (132) esp_image: segment 4: paddr=00020020 vaddr=42000020 size=033dch ( 13276) map
I (136) boot: Loaded app from partition at offset 0x10000
I (136) boot: Disabling RNG early entropy source...
INFO - T
INFO - T
INFO - T

The Modern Synthesis: no_std + Embassy
While the pure no_std approach offers strong performance and full control, it quickly becomes difficult when your application needs concurrency with multiple things happening “at once”, such as reading sensors, handling network traffic, updating displays, or managing timers. This is exactly where Embassy enters the frame.

Embassy (https://embassy.dev/) is a modern, no_std, async-first framework designed specifically for embedded systems. It provides an efficient executor, async-friendly device drivers, and a cooperative multitasking model using Rust’s async/await. For many embedded tasks (especially tasks involving waiting for I/O) it offers cleaner, more scalable concurrency than either blocking loops or manual interrupt juggling.

Figure 2. Wokwi for VS Code for ESP32 Emulation

For your applications of network clients, sensor-driven systems, and IoT devices, Embassy is arguably the most important concept to understand. It links directly to your knowledge of concurrency, futures, async I/O, and event-driven programming. It enables embedded Rust code to behave in a structured, non-blocking way without the heavy machinery of an RTOS. Wokwi, as illustrated in Figure 2 is a great learning platform. Unfortunately (in late 2025) website emulation of Rust has been paused, you can still emulate ESP32 Rust from within VSCode using an extension (however, you will need to sign up for a free open-source licence).

The Problem with naïve no_std code:
A simple blocking loop in no_std looks innocent, but it leads to terrible behaviour:

// DON'T DO THIS -- completely blocks the CPU
loop {
let sensor_data = sensor.read(); // Blocks for 50 ms
http_client.post(sensor_data); // Blocks for 2 seconds!
led.toggle(); // Only toggles every 2+ seconds
}

Each step blocks until it is finished. While the HTTP request is in progress, nothing else can run. With just a few blocking operations, the whole system becomes unresponsive.

The Traditional C/C++ Fix: FreeRTOS:
In ESP-IDF (in C/C++) you would solve this by creating multiple FreeRTOS tasks where: one task reads the sensor, one task uploads the data, and a queue or semaphore moves data between them. This is preemptive multitasking, which works well, but has well-known downsides:

  • Each task needs its own large stack
  • Context switching adds overhead
  • The system tick timer drives scheduling
  • More tasks means more complexity, more memory usage
  • Safety is entirely the responsibility of the programmer This is powerful, but heavyweight — especially for small embedded devices.

The Embassy Approach: async Rust for embedded systems:
Embassy solves the concurrency problem using cooperative multitasking with async/await. Tasks yield control only at .await points, which are explicitly non-blocking I/O operations. This keeps memory usage extremely low and execution highly predictable. For example:

#[embassy_executor::task]
async fn sensor_task(mut sensor: Sensor) {
loop {
let data = sensor.read_async().await; // Non-blocking!
DATA_CHANNEL.send(data).await; // Non-blocking!
}
}
#[embassy_executor::task]
async fn network_task(mut client: HttpClient) {
loop {
let data = DATA_CHANNEL.receive().await; // Non-blocking!
client.post_async(data).await; // Non-blocking!
}
}

Here:

  • Neither task blocks the CPU
  • Both tasks run concurrently using async scheduling
  • The executor only polls tasks when needed (no constant time slicing)
  • Stack usage is tiny, as each async task needs only enough space for its state machine
  • No RTOS, no separate stacks, no context-switching overhead

This gives you the concurrency benefits of FreeRTOS with the efficiency and safety guarantees of Rust.

Pros of no_std + Embassy:

  • Modern concurrency: async/.await makes concurrent code readable, scalable, and non-blocking.
  • Tiny memory footprint: cooperative multitasking avoids the cost of multiple large stacks.
  • Pure Rust, no RTOS: No dependency on FreeRTOS or ESP-IDF as everything is safe, efficient Rust.
  • Excellent for I/O-heavy tasks: Perfect for networking, sensors, timers, and asynchronous peripherals.

Cons of no_std + Embassy:

  • Async complexity: Requires understanding async executors, lifetimes, and embedded async patterns.
  • Ecosystem still growing: Not every driver supports async yet (though most major ones do).
  • Some hardware peripherals need async-safe wrappers: The HAL and Embassy must work closely together, which continues to evolve.

This “modern synthesis” is increasingly seen as the future of embedded Rust on RISC-V ESP32 devices, bringing together performance, safety, and elegant concurrency in a way that outclasses traditional C-based approaches.

All three paths are production-ready. The table below summarises the key trade-offs to guide your architectural decision before starting a project:

Characteristicstd + esp-idfno_std + esp-halno_std + Embassy
Underlying runtimeFreeRTOS (C library)None — bare metalAsync executor (pure Rust)
std availableYesNoNo
Heap / dynamic allocationYes (FreeRTOS heap)No (static/stack only)Optional (custom allocator)
Typical firmware sizeLarge (~500 KB+)Tiny (~10–80 KB)Small (~50–150 KB)
Concurrency modelOS threadsInterrupts / blockingasync/await tasks
Power managementManual (FreeRTOS idle)ManualAutomatic (WFI on idle)
Async supportPartial (via tokio)MinimalFull (native)
Crate ecosystemHigh (most crates.io)Moderate (no_std only)Growing rapidly
Best forRapid prototyping, HTTP appsStrict size/power budgetsI/O-heavy, modern IoT

The general rule of thumb: start with std + esp-idf if you need network connectivity quickly; move to no_std + Embassy when binary size, power, or concurrency complexity demands it.

Concept Match

Match the ESP32 Rust Ecosystem Concepts

Quiz
Select 0/1

What is a significant drawback of using the pure `no_std` approach without a framework like Embassy?

Quiz
Select 0/1

How does Embassy solve the concurrency problem differently than FreeRTOS?

You have already learned to build graphical interfaces using egui, an immediate-mode GUI framework. The key idea behind immediate-mode GUIs is simple: every frame, the UI library needs only two things: input (mouse/touch events) and a place to draw (a framebuffer). Because it has no internal widget tree or retained state, egui is particularly portable and lightweight. This makes it a good fit for embedded systems, even including ESP32 devices.

At its core, egui produces a set of triangles every frame that represent the rendered UI. Everything you see (text, rectangles, rounded corners, icons, shadows etc.) is tessellated into triangles, often via triangle strips or fans. Because triangles are the common denominator across graphics systems, egui can run anywhere as long as you provide two things: input events and a way to draw triangles to a framebuffer or display. This is why the same egui code works on a desktop (via wgpu or OpenGL), on the web (via WebGL through WASM — as seen previously in the demo https://www.egui.rs/#demo), and even on small microcontrollers, where you can rasterise the triangles yourself or feed them to an embedded display driver.

One of egui’s biggest strengths is its flexibility: it can operate cleanly in each of the embedded Rust paths just described.

In the std + ESP-IDF environment: Because you have the full Rust standard library, you can simply run egui in its own dedicated thread: A std::thread handles the UI loop; it blocks on touch/mouse input; and, it renders its output each frame. This mirrors how egui behaves on desktop systems, making it the most familiar approach.

In a no_std bare-metal loop: In pure no_std, you drive egui manually: Your main loop polls the touchscreen; You feed the events into egui’s input system, and egui generates triangles that you push to the display (e.g., through embedded-graphics over SPI). Despite running without an operating system or standard library, egui works naturally because it requires so little infrastructure.

In an Embassy async system: With Embassy, egui becomes even more structured: A high-priority async task waits for a “new frame” trigger; it gathers input, runs egui, renders the UI; and it then awaits, yielding control back to lower-priority tasks (networking, sensors, etc.). This allows the UI to remain smooth while background tasks run efficiently during the idle time between frames.

The fact that egui runs across std, no_std, and no_std + Embassy demonstrates the power and portability of Rust’s abstractions. You can apply the exact same GUI knowledge (from layout to event handling) on a desktop or directly on a microcontroller. The rendering backend changes, but your egui code remains the same.