Embedded Abstractions
~20 min
A Generic Peripheral Abstraction
A peripheral abstraction, once created, needs methods to configure it and control it. The PAC provides memory-mapped interfaces for different peripherals to control pins — but this requires low-level knowledge and datasheet access. HALs take in the PAC abstractions and wrap them with user-friendly methods to perform the same operations without needing to manipulate registers. Note that this removes the need for datasheets to look at control and configuration specifics, but not the knowledge of how a peripheral works. You may still need to refer to a datasheet to understand certain properties of a peripheral for a particular controller.
The Full Rust "no-std" Stack
As shown in the figure below, the Rust no-std stack can comprise even more layers beyond just the PAC and HAL. Let's walk through each layer.
Micro Architecture Crate
Provides low-level access to CPU-specific features: interrupt handling, system timer, core registers. For RISC-V (ESP32-C3), this is the riscv crate.
Not something you interact with directly in most application code, but it's the foundation everything else builds on.
#![allow(unused)] fn main() { // Example: enabling interrupts at the CPU level unsafe { riscv::interrupt::enable() }; }
The Peripheral Access Crate
Auto-generated from SVD files. A Rust representation of every register in the chip. Type-safe but low-level. You can use it directly, but you almost never need to.
For ESP32-C3: the esp32c3 crate.
#![allow(unused)] fn main() { // PAC-level: writing directly to a register peripherals.GPIO.out_w1ts .write(|w| unsafe { w.bits(1 << 5) }); }
The Hardware Abstraction Layer
Safe, ergonomic Rust APIs on top of the PAC. This is where we'll spend most of our time. For ESP32 chips: esp-hal.
Provides the Instantiate → Configure → Control pattern for every peripheral.
#![allow(unused)] fn main() { // HAL-level: safe, readable, type-checked let mut led = Output::new( peripherals.GPIO5, Level::Low, OutputConfig::default(), ); led.set_high(); }
embedded-hal Traits
The key to portability. These traits define a standard interface that any HAL can implement.
This is the contract between HALs and driver crates.
#![allow(unused)] fn main() { // Works on ANY microcontroller fn blink( pin: &mut impl OutputPin, delay: &mut impl DelayNs, ) { pin.set_high().unwrap(); delay.delay_ms(500); pin.set_low().unwrap(); delay.delay_ms(500); } }
Driver Crates
Built on top of embedded-hal traits. A driver crate provides a high-level API for a specific sensor or device. Ideally drivers would all use embedded-hal traits but they don't need to. A driver that does use the traits though, is what makes it hardware-agnostic.
#![allow(unused)] fn main() { // This driver doesn't know about ESP32. // It just needs something that implements I2c. let mut imu = Icm42670::new( i2c, Address::Primary, ); let accel = imu.accel_norm().unwrap(); }
Board Support Packages
The highest layer. Pre-configures everything for a specific board: pin assignments, peripheral setup, default configurations. Instead of remembering pin numbers, you use meaningful names.
#![allow(unused)] fn main() { // Without BSP: let led = Output::new( peripherals.GPIO5, Level::Low, OutputConfig::default(), ); // With BSP: the board knows let led = board.led(); }