Introduction: Why Embedded Rust HAL Crates Matter
When I first started experimenting with Rust on microcontrollers, the biggest hurdle wasn’t the language itself – it was finding the right hardware abstraction layer (HAL) to build on. In no_std environments, embedded Rust HAL crates are the glue between safe, modern Rust code and very real, very unforgiving hardware.
A HAL crate defines a consistent, type-safe API for peripherals like GPIO, timers, SPI, I2C, and UART across a family of MCUs. Instead of juggling vendor-specific register definitions and brittle C headers, I can write high-level Rust that still compiles down to tight, predictable machine code. This has made my projects easier to port between boards and far less error-prone during bring-up and debugging.
Choosing the right HAL crate is crucial for three reasons:
- Reliability: A well-designed HAL encodes hardware constraints in the type system, catching misconfigurations at compile time instead of in the field.
- Portability: By targeting a common embedded Rust HAL crate, I’ve been able to move the same application logic between evaluation boards with minimal changes.
- Long-term maintenance: Active, well-structured HALs make it far easier to upgrade toolchains, add new features, or switch MCUs without rewriting everything from scratch.
In my experience, projects that start with a solid HAL choice stay maintainable as they grow, while projects built directly on raw registers tend to accumulate fragile, board-specific hacks. The rest of this article focuses on the embedded Rust HAL crates that have proven themselves in real-world no_std work.
1. embedded-hal: The Foundation of Embedded Rust HAL Crates
Whenever I evaluate new embedded Rust HAL crates, I start by checking how closely they follow embedded-hal. This crate doesn’t target a specific microcontroller; instead, it defines a set of common traits for things like GPIO, SPI, I2C, timers, and serial ports. Concrete HALs for STM32, nRF, RP2040, and others implement these traits, which lets my application code stay portable and focused on behavior rather than registers.
In practice, that means I can write a driver against the embedded-hal traits once, then reuse it across different boards as long as their HAL implements the same traits. This has saved me days of rewrite time when switching prototypes from one MCU family to another. Understanding the embedded-hal traits also makes it much easier to judge whether a given HAL feels idiomatic, complete, and future-proof.
Here’s a small, realistic example of what this looks like in Rust using embedded-hal traits for a simple SPI transaction:
use embedded_hal::blocking::spi::Transfer; use embedded_hal::digital::v2::OutputPin; fn read_register(spi: &mut SPI, cs: &mut CS, reg: u8) -> Result<u8, SPI::Error> where SPI: Transfer<u8>, CS: OutputPin, { let mut buf = [reg | 0x80, 0x00]; // set read bit cs.set_low().ok(); spi.transfer(&mut buf)?; cs.set_high().ok(); Ok(buf[1]) }
In my own projects, this function has compiled unchanged for multiple MCUs: only the board-specific HAL and pin setup differed. When I’m choosing between embedded Rust HAL crates, I now look for three things: solid embedded-hal coverage, clear documentation around which trait versions they support, and examples that show how to wire those traits into real applications. Understanding embedded-hal turns all the other HALs from opaque wrappers into predictable building blocks. embedded-hal – GitHub repository with documentation
2. stm32-rs and stm32 HAL Crates: The Powerhouse for Cortex-M
When people ask me where to start with embedded Rust HAL crates on Cortex-M, I almost always point them to the stm32-rs ecosystem. It combines a massive range of STM32 parts, solid community backing, and practical examples that work in real no_std environments. If you’re building anything from a sensor node to a serious industrial controller, chances are there’s an STM32 variant and a matching Rust HAL ready to go.
STM32 PACs vs. HALs: How the Stack Fits Together
At the bottom of the stack, stm32-rs provides Peripheral Access Crates (PACs) generated from ST’s SVD files. These crates give you safe(ish) register-level access for each STM32 family (F0, F1, F3, F4, G0, G4, H7, L0, L4, and more). On top of that, family-specific HAL crates such as stm32f1xx-hal, stm32f4xx-hal, and stm32g0xx-hal wrap those registers in higher-level, embedded-hal-compatible APIs.
In my own projects, I reach for the HAL layer first and only drop down to the PACs when I need a niche peripheral feature or a very specific timing tweak. This approach keeps most of my code readable and portable, while still letting me reach under the hood when required.
Why STM32 HALs Work So Well in Real Projects
The stm32 HAL crates shine in real-world no_std work for a few reasons:
- Broad device coverage: From tiny F0/L0 parts to heavy-duty F4/H7 MCUs, I can usually find a Rust HAL that at least covers GPIO, timers, SPI, I2C, and UART.
- embedded-hal support: Most STM32 HALs implement the core embedded-hal traits, which lets me reuse drivers and libraries across different STM32 boards.
- Active community: Issues get noticed, examples are updated, and it’s easy to find snippets for common tasks like PWM, DMA, or low-power modes.
- Good fit for industry and hobby: I’ve seen the same crates used in lab prototypes, 3D printer controllers, robotics projects, and small production runs.
Here’s a small example I’ve used as a starting point on multiple STM32 boards: toggling an LED using stm32f4xx-hal and the embedded-hal digital traits.
#![no_std]
#![no_main]
use cortex_m_rt::entry;
use panic_halt as _;
use stm32f4xx_hal as hal;
use hal::{
pac,
prelude::*,
};
#[entry]
fn main() -> ! {
let dp = pac::Peripherals::take().unwrap();
let rcc = dp.RCC.constrain();
let clocks = rcc.cfgr.sysclk(84.mhz()).freeze();
let gpiod = dp.GPIOD.split();
let mut led = gpiod.pd12.into_push_pull_output();
loop {
led.toggle().ok();
hal::delay::Delay::new(core::cell::RefCell::new(dp.SYST), &clocks)
.delay_ms(500_u16);
}
}
Once this pattern is familiar, I can switch to a different STM32 family by changing the HAL crate and pin names, while most of the application logic stays intact. That’s exactly the kind of portability that makes embedded Rust HAL crates worth the investment.
Typical Use Cases: From Prototypes to Production
In my experience, STM32 HALs are especially strong when:
- You’re migrating an existing C-based STM32 design to Rust and need a gentle, incremental path.
- You want to quickly prototype with cheap dev boards (like Nucleo or Discovery) but keep the codebase ready for a custom PCB later.
- You’re building industrial or robotics gear where long-term maintenability and vendor availability of MCUs matter.
Before I commit to a specific STM32 part, I now always scan the Rust HAL docs and examples to see how mature the support is for the peripherals I care about (DMA, USB, CAN, etc.). That simple check has saved me from picking parts that looked perfect on paper but lacked the HAL coverage I needed in practice. stm32-rs GitHub organization
3. nrf-hal: Rust HAL Crates for Low-Power Wireless and BLE
Whenever I work on Bluetooth Low Energy or battery-powered sensors, I keep coming back to the nrf-hal ecosystem. Nordic’s nRF52 and nRF53 chips are already favorites for low-power wireless designs, and the Rust HALs around them make it surprisingly pleasant to build robust no_std firmware that still plays nicely with radio stacks and deep sleep modes.
Why nrf-hal Works So Well for BLE and Low-Power
The nrf-hal crates sit on top of Nordic PACs and expose peripherals like GPIO, timers, RTC, SPI, I2C (TWI), and PPI in an embedded-hal-friendly way. In my own BLE sensor nodes, that’s meant I can focus on power budgeting and radio behavior rather than raw register twiddling. Key strengths I’ve relied on include:
- Low-power aware APIs: It’s straightforward to configure clocks, use low-power timers, and drop into sleep while peripherals keep running.
- BLE-friendly design: HAL patterns fit naturally with existing Rust BLE stacks for the nRF5x family.
- Good examples: The examples repo has been invaluable when I needed a quick reference for PPI channels or using the RTC as a low-power ticker.
Using embedded-hal Traits with nrf-hal
The real win with nrf-hal is how cleanly it implements embedded-hal traits, especially for things like SPI and I2C sensors. Here’s a trimmed-down example of talking to a sensor over I2C using the nrf52 HAL and embedded-hal traits:
use nrf52832_hal as hal;
use hal::{pac, prelude::*};
use embedded_hal::blocking::i2c::{Write, WriteRead};
fn read_temperature(i2c: &mut I2C) -> Result<i16, I2C::Error>
where
I2C: WriteRead + Write,
{
const SENSOR_ADDR: u8 = 0x48;
const TEMP_REG: u8 = 0x00;
let mut buf = [0u8; 2];
i2c.write_read(SENSOR_ADDR, &[TEMP_REG], &mut buf)?;
Ok(i16::from_be_bytes(buf))
}
In one of my battery-powered beacon projects, I reused this exact pattern across multiple sensors and boards; only the pin mapping and I2C instance setup changed. That kind of portability is exactly why I consider nrf-hal one of the most practical embedded Rust HAL crates for low-power wireless designs.
4. rp-hal: Rust on the Raspberry Pi Pico and RP2040
The RP2040 and Raspberry Pi Pico were my personal tipping point for doing more serious work with embedded Rust HAL crates. The rp-hal crate wraps the RP2040’s dual-core M0+, PIO state machines, and flexible peripherals in a clean, embedded-hal-based API, and the community around it has grown at an impressive pace.
Why rp-hal Works So Well on the Pico
In practice, rp-hal hits a sweet spot between approachability and capability. It provides idiomatic Rust access to GPIO, UART, SPI, I2C, timers, and PIO, so I can start with simple LED blink examples and scale up to more complex protocols. The official and community examples have saved me multiple evenings of guessing how to wire clocks, USB, or PIO correctly.
What stands out most for me is how easy it is to reuse code across different RP2040 boards: once I’ve written a driver against the rp-hal and embedded-hal traits, moving from a Pico to a custom RP2040 design mostly comes down to pin mapping and clock configuration.
Async-Friendly Peripherals and Real-World Patterns
The RP2040’s hardware lends itself to concurrency, and rp-hal leans into that with good support for timer-based delays, interrupts, and integrations with async runtimes. I’ve used rp-hal with async executors to run USB, serial logging, and sensor polling concurrently without resorting to an RTOS. A simple pattern I keep returning to is setting up a UART logger plus a periodic timer tick for housekeeping tasks.
#![no_std]
#![no_main]
use rp_pico as bsp;
use bsp::hal::{self, pac, prelude::*};
use cortex_m_rt::entry;
use panic_halt as _;
#[entry]
fn main() -> ! {
let mut pac = pac::Peripherals::take().unwrap();
let mut watchdog = hal::Watchdog::new(pac.WATCHDOG);
let clocks = hal::clocks::init_clocks_and_plls(
bsp::XOSC_CRYSTAL_FREQ,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
).ok().unwrap();
let sio = hal::Sio::new(pac.SIO);
let pins = bsp::Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mut pac.RESETS,
);
// UART over USB-UART bridge on Pico
let uart_pins = (
pins.gpio0.into_function_uart(),
pins.gpio1.into_function_uart(),
);
let mut uart = hal::uart::UartPeripheral::new(pac.UART0, uart_pins, &mut pac.RESETS)
.enable(hal::uart::common_configs::_115200_8_N_1(), clocks.peripheral_clock.freq())
.unwrap();
writeln!(uart, "rp-hal is up and running!\r").ok();
loop {
// Main loop or async executor hook
}
}
After a few projects, rp-hal has become my default choice for quick prototypes, education, and even more ambitious USB or PIO-heavy designs. The combination of clear APIs, strong examples, and async-friendly patterns makes it one of the most practical HALs in the current embedded Rust ecosystem. rp-rs/rp-hal: A Rust Embedded-HAL for the rp series microcontrollers
5. esp-hal: Embedded Rust HAL Crates for Wi‑Fi and IoT
For Wi‑Fi-enabled projects, the esp-hal family is what finally made ESP32 development in Rust feel first-class for me. These embedded Rust HAL crates target chips like the ESP32, ESP32-C3, and ESP32-S3, wrapping their rich peripherals and radio blocks in a no_std-friendly, mostly embedded-hal-style API. If you’re building connected sensors, small web services, or custom IoT nodes, esp-hal is quickly becoming a serious alternative to the usual C/C++ SDKs.
Rust on ESP32: Wi‑Fi, Peripherals, and Mixed Workflows
Under the hood, esp-hal crates sit on top of vendor PACs and the ESP-IDF/ROM functionality, exposing GPIO, UART, SPI, I2C, timers, and more in a safe Rust layer. In my own experiments, what stood out is how naturally you can combine Wi‑Fi and traditional bare-metal code: run a small async executor for networking, handle GPIO interrupts for sensors, and still stay in a mostly no_std world.
On some projects I’ve mixed pure bare-metal esp-hal with an RTOS-backed Wi‑Fi stack, treating Rust as the application layer while the lower-level radio details stayed in the vendor libraries. That hybrid model is messy in C, but Rust’s type-safety and esp-hal’s clear ownership of peripherals helped keep things manageable.
Using esp-hal with embedded-hal Traits
While not every peripheral is perfectly modeled yet, the esp-hal crates increasingly implement embedded-hal traits, especially for common buses. Here’s a simplified example I’ve used on an ESP32-C3 board to drive an SPI peripheral using the HAL, while keeping the higher-level code written against embedded-hal traits:
#![no_std]
#![no_main]
use esp32c3_hal as hal;
use hal::{clock::ClockControl, pac, prelude::*, spi};
use embedded_hal::blocking::spi::Transfer;
use riscv_rt::entry;
use panic_halt as _;
#[entry]
fn main() -> ! {
let mut pac = pac::Peripherals::take().unwrap();
let system = pac.SYSTEM.split();
let clocks = ClockControl::boot_defaults(system.clock_control).freeze();
let io = hal::IO::new(pac.GPIO, pac.IO_MUX);
let sck = io.pins.gpio6;
let miso = io.pins.gpio7;
let mosi = io.pins.gpio8;
let mut spi = spi::Spi::new(
pac.SPI2,
sck,
mosi,
miso,
8u32.MHz(),
spi::SpiMode::Mode0,
&clocks,
);
let mut buf = [0x9F, 0x00, 0x00, 0x00]; // JEDEC ID command
spi.transfer(&mut buf).ok();
loop {}
}
Patterns like this have let me reuse sensor and display drivers across STM32, RP2040, and ESP32 boards with only minimal glue changes. For connected devices where Wi‑Fi is non-negotiable, esp-hal has become my go-to way to keep the benefits of Rust and embedded-hal without giving up the ESP32 ecosystem.
6. avr-hal: Bringing Embedded Rust to 8-bit Classics
Even in 2025, I still see 8-bit AVR parts like the ATmega328P and ATmega32U4 everywhere: legacy boards, cheap modules, and educational kits. The avr-hal crate brings these classics into the world of embedded Rust HAL crates, giving you type-safe access to GPIO, timers, UART, SPI, and I2C while targeting tiny no_std environments. If you’ve ever written Arduino or bare-metal AVR C, avr-hal feels like a modern, safer take on a very familiar platform.
Why avr-hal Still Matters Today
In my own work, avr-hal has been particularly useful when I’m modernising an old design or teaching low-level concepts on inexpensive hardware. AVRs remain attractive because they’re cheap, well-understood, and have a huge existing ecosystem. With avr-hal, I can keep those hardware advantages but gain Rust’s strong typing, ownership model, and clearer abstractions around peripherals.
The crate exposes per-device modules — for example, atmega328p-hal — and maps peripherals into a Rust API that broadly follows embedded-hal where it makes sense. I’ve found this especially handy for reusing simple sensor or display drivers between AVR boards and more modern MCUs like STM32 or RP2040.
Rust vs. Traditional AVR C Workflows
Compared to the classic avr-gcc flow, Rust with avr-hal changes how I structure code:
- Safer GPIO and peripheral setup: Pin modes and ownership are enforced at compile time instead of relying on comments and discipline.
- No_std from day one: The toolchain and crate layout push you toward predictable, freestanding binaries.
- Shared drivers via embedded-hal: I can write a driver once and use it on AVR, then later move it to a 32-bit MCU with minimal changes.
Here’s a small example I’ve used on an ATmega328P-based board to blink an LED using avr-hal and embedded-hal-style traits:
#![no_std]
#![no_main]
use arduino_hal::prelude::*;
use panic_halt as _;
use arduino_hal::delay_ms;
#[arduino_hal::entry]
fn main() -> ! {
let dp = arduino_hal::Peripherals::take().unwrap();
let pins = arduino_hal::pins!(dp);
let mut led = pins.d13.into_output();
loop {
led.toggle();
delay_ms(500);
}
}
When I first rewrote some old C examples into Rust like this, the biggest win wasn’t raw performance — it was how much harder it became to accidentally misconfigure pins or forget volatile qualifiers. For legacy hardware and ultra-low-cost boards, avr-hal is a great way to get modern Rust discipline onto classic 8-bit silicon.
7. cross-platform HALs and driver crates built on embedded-hal
Once I had a few projects under my belt with concrete embedded Rust HAL crates, the real power move was stepping up to cross-platform drivers that only depend on embedded-hal traits. Instead of rewriting sensor or display code for every MCU family, I can plug the same driver into stm32f4xx-hal, rp-hal, nrf-hal, or esp-hal with just a bit of setup glue.
How cross-platform drivers sit on top of embedded-hal
Most reusable drivers in the Rust ecosystem are written purely against traits like embedded_hal::blocking::i2c::WriteRead or embedded_hal::digital::v2::OutputPin. In my own code, that means the application logic never needs to know if it’s talking to an STM32, RP2040, or ESP32 — the concrete HAL stays at the edge, and everything above runs on traits.
Here’s a simplified pattern I use a lot: wiring up an I2C temperature sensor driver that doesn’t care which MCU is underneath, as long as the HAL implements the right embedded-hal traits.
use embedded_hal::blocking::i2c::WriteRead; pub struct TempSensor{ i2c: I2C, addr: u8, } impl<I2C, E> TempSensor<I2C> where I2C: WriteRead<Error = E>, { pub fn new(i2c: I2C, addr: u8) -> Self { Self { i2c, addr } } pub fn read_celsius(&mut self) -> Result<f32, E> { let mut buf = [0u8; 2]; self.i2c.write_read(self.addr, &[0x00], &mut buf)?; let raw = i16::from_be_bytes(buf); Ok(raw as f32 / 100.0) } }
On one project I ran this same driver on a Pico (rp-hal), then later on an STM32 board, just by swapping out the I2C type at construction. That kind of reuse is what sold me on taking embedded-hal abstractions seriously instead of tightly coupling everything to one MCU family.
ecosystem examples: displays, sensors, and async-friendly crates
There’s a growing set of cross-platform crates that build on embedded-hal in exactly this way. I’ve had good experiences with display drivers, motion sensors, and flash memory chips that all compile unchanged across multiple boards. For async work, newer crates are starting to target embedded-hal-async, which pairs nicely with async-friendly HALs like rp-hal and esp-hal.
My usual pattern now is:
- Pick the concrete HAL for the MCU (e.g., stm32, rp-hal, nrf-hal).
- Expose only the embedded-hal traits to the rest of the code.
- Pull in driver crates that speak those traits and stay completely portable.
Once you start thinking this way, embedded Rust HAL crates turn into a thin, swappable hardware layer, and most of your application logic becomes MCU-agnostic. awesome-embedded-rust: Curated list of embedded Rust crates and drivers
How to Choose the Right Embedded Rust HAL Crate for Your Board
When I’m starting a new no_std project, I don’t ask “which MCU is best?” in the abstract; I ask “which embedded Rust HAL crates already work well for the hardware and features I need.” Working backward from the board, ecosystem, and roadmap usually leads to a much better choice than just chasing specs.
Start from your hardware and connectivity needs
My first filter is always the hardware:
- Board you already have: If there’s a mature HAL for it (e.g., rp-hal for the Pico, esp-hal for ESP32, nrf-hal for nRF52), that’s often the lowest-friction path.
- Connectivity: If I need Wi‑Fi, I’ll lean toward esp-hal; if I need BLE and ultra-low power, nrf-hal usually wins.
- Cost and availability: For very cheap or legacy boards, avr-hal or smaller STM32 families can still be the right answer.
In my experience, picking a chip with a weaker HAL just because it’s “faster” usually backfires; I end up spending that performance margin on debugging and missing features.
Check ecosystem maturity, examples, and your future plans
Once I’ve narrowed down the hardware, I look at ecosystem quality:
- Examples and docs: I scan the HAL’s examples folder and README. If there are solid demos for GPIO, timers, I2C, SPI, and maybe USB or networking, that’s a good sign.
- Driver compatibility: I check whether common drivers I care about (displays, sensors, flash) already work with that HAL via embedded-hal traits.
- Roadmap and maintenance: Active commits, recent releases, and open issues tell me whether I’m betting on a living project.
I also try to be honest about my roadmap. If I might later port the project to another board, I’ll architect it around embedded-hal traits from day one and treat the concrete HAL as a thin layer. That way I can switch between rp-hal, nrf-hal, esp-hal, or stm32 HALs with minimal pain when requirements change.
Conclusion: Building Sustainable no_std Systems with Embedded Rust HAL Crates
After a few years of experimenting across boards, I’ve found that picking the right embedded Rust HAL crates upfront is one of the biggest levers for building sustainable no_std systems. A solid HAL plus the embedded-hal trait ecosystem lets you write application and driver code once, then carry it from AVR to STM32, RP2040, nRF, and ESP32 with only thin hardware glue.
The crates I’ve covered here aren’t just abstractions; they’re the practical entry points into real projects: low-power sensors with nrf-hal, Wi‑Fi IoT nodes with esp-hal, 8-bit refreshes with avr-hal, and hobby-to-production paths with rp-hal and STM32 HALs. My suggestion is simple: pick one board you own, grab the matching HAL, and wire up a cross-platform driver or two. Once you see how easily that code can move to a second target, the long-term value of Rust’s HAL story really clicks into place.

Hi, I’m Cary Huang — a tech enthusiast based in Canada. I’ve spent years working with complex production systems and open-source software. Through TechBuddies.io, my team and I share practical engineering insights, curate relevant tech news, and recommend useful tools and products to help developers learn and work more effectively.





