Chapter 23: Embedded HAL - Hardware Register Access & Volatile Memory
This chapter covers hardware abstraction in embedded Rust, focusing on memory-mapped I/O, volatile access patterns, and the embedded-hal ecosystem. These concepts are essential for writing safe, portable embedded code.
Part 1: Why Volatile Access Matters
The Compiler Optimization Problem
When accessing regular memory, the compiler assumes it has complete control and can optimize away “redundant” operations:
#![allow(unused)] fn main() { // Regular memory access - compiler can optimize fn regular_memory() { let mut value = 0u32; value = 1; // Compiler might optimize away value = 2; // Only this write matters value = 3; // And this one let x = value; // Reads 3 let y = value; // Compiler might reuse x instead of reading again } }
Hardware registers are different - they’re windows into hardware state that can change independently:
#![allow(unused)] fn main() { // Hardware register at address 0x4000_0000 const GPIO_OUT: *mut u32 = 0x4000_0000 as *mut u32; unsafe fn bad_gpio_control() { // ❌ WRONG: Compiler might optimize these away! *GPIO_OUT = 0b0001; // Turn on LED 1 *GPIO_OUT = 0b0010; // Turn on LED 2 *GPIO_OUT = 0b0100; // Turn on LED 3 // Compiler might only emit the last write! } unsafe fn good_gpio_control() { use core::ptr; // ✅ CORRECT: Volatile writes are never optimized away ptr::write_volatile(GPIO_OUT, 0b0001); // Turn on LED 1 ptr::write_volatile(GPIO_OUT, 0b0010); // Turn on LED 2 ptr::write_volatile(GPIO_OUT, 0b0100); // Turn on LED 3 // All three writes will happen! } }
Memory-Mapped I/O Fundamentals
In embedded systems, hardware peripherals appear as memory addresses:
#![allow(unused)] fn main() { // ESP32-C3 GPIO registers (simplified) const GPIO_BASE: usize = 0x6000_4000; // GPIO output registers const GPIO_OUT_W1TS: *mut u32 = (GPIO_BASE + 0x0008) as *mut u32; // Set bits const GPIO_OUT_W1TC: *mut u32 = (GPIO_BASE + 0x000C) as *mut u32; // Clear bits const GPIO_OUT: *mut u32 = (GPIO_BASE + 0x0004) as *mut u32; // Direct write const GPIO_IN: *const u32 = (GPIO_BASE + 0x003C) as *const u32; // Read input unsafe fn control_gpio() { use core::ptr; // Set pin 5 high (write 1 to set) ptr::write_volatile(GPIO_OUT_W1TS, 1 << 5); // Clear pin 5 (write 1 to clear - yes, really!) ptr::write_volatile(GPIO_OUT_W1TC, 1 << 5); // Read current pin states let pins = ptr::read_volatile(GPIO_IN); let pin5_state = (pins >> 5) & 1; } }
Why Each Access Must Be Volatile
Hardware registers can change at any time due to:
- External signals (button presses, sensor readings)
- Hardware state machines (timers, DMA completion)
- Interrupt handlers modifying registers
- Peripheral operations completing
#![allow(unused)] fn main() { // Timer register that counts up automatically const TIMER_COUNTER: *const u32 = 0x6002_0000 as *const u32; unsafe fn wait_for_timeout() { use core::ptr; // ❌ WRONG: Compiler might read once and cache while *TIMER_COUNTER < 1000 { // Infinite loop - compiler assumes value never changes! } // ✅ CORRECT: Each read goes to hardware while ptr::read_volatile(TIMER_COUNTER) < 1000 { // Works correctly - reads actual hardware value } } }
Part 2: Safe Register Abstractions
Building Type-Safe Register Access
#![allow(unused)] fn main() { use core::marker::PhantomData; /// Type-safe register wrapper pub struct Register<T> { address: *mut T, } impl<T> Register<T> { pub const fn new(address: usize) -> Self { Self { address: address as *mut T, } } pub fn read(&self) -> T where T: Copy, { unsafe { core::ptr::read_volatile(self.address) } } pub fn write(&self, value: T) { unsafe { core::ptr::write_volatile(self.address, value) } } pub fn modify<F>(&self, f: F) where T: Copy, F: FnOnce(T) -> T, { self.write(f(self.read())); } } // Usage const GPIO_OUT: Register<u32> = Register::new(0x4000_0004); fn toggle_led() { GPIO_OUT.modify(|val| val ^ (1 << 5)); // Toggle bit 5 } }
Field Access with Bitfields
#![allow(unused)] fn main() { use modular_bitfield::prelude::*; #[bitfield] #[derive(Clone, Copy)] pub struct TimerControl { pub enable: bool, // Bit 0 pub interrupt: bool, // Bit 1 pub mode: B2, // Bits 2-3 #[skip] __: B4, // Bits 4-7 reserved pub prescaler: B8, // Bits 8-15 pub reload: B16, // Bits 16-31 } pub struct TimerPeripheral { control: Register<TimerControl>, counter: Register<u32>, } impl TimerPeripheral { pub fn configure(&self, prescaler: u8, reload: u16) { let mut ctrl = self.control.read(); ctrl.set_prescaler(prescaler); ctrl.set_reload(reload); ctrl.set_enable(true); self.control.write(ctrl); } } }
Part 3: PAC Generation with svd2rust
What is an SVD File?
System View Description (SVD) files describe microcontroller peripherals in XML format. The svd2rust tool generates Rust code from these descriptions.
Generated PAC Structure
#![allow(unused)] fn main() { // Generated by svd2rust from manufacturer SVD pub mod gpio { use core::ptr; pub struct RegisterBlock { pub moder: MODER, // Mode register pub otyper: OTYPER, // Output type register pub ospeedr: OSPEEDR, // Output speed register pub pupdr: PUPDR, // Pull-up/pull-down register pub idr: IDR, // Input data register pub odr: ODR, // Output data register pub bsrr: BSRR, // Bit set/reset register } pub struct MODER { register: vcell::VolatileCell<u32>, } impl MODER { pub fn read(&self) -> u32 { self.register.get() } pub fn write(&self, value: u32) { self.register.set(value) } pub fn modify<F>(&self, f: F) where F: FnOnce(u32) -> u32, { self.write(f(self.read())); } } } // Safe peripheral access pub struct Peripherals { pub GPIO: gpio::RegisterBlock, } impl Peripherals { pub fn take() -> Option<Self> { // Ensure single instance (singleton pattern) static mut TAKEN: bool = false; cortex_m::interrupt::free(|_| unsafe { if TAKEN { None } else { TAKEN = true; Some(Peripherals { GPIO: gpio::RegisterBlock { // Initialize with hardware addresses }, }) } }) } } }
Using a PAC
#![allow(unused)] fn main() { use esp32c3_pac::{Peripherals, GPIO}; fn configure_gpio() { let peripherals = Peripherals::take().unwrap(); let gpio = peripherals.GPIO; // Configure pin as output gpio.enable_w1ts.write(|w| w.bits(1 << 5)); gpio.func5_out_sel_cfg.write(|w| w.out_sel().bits(0x80)); // Set pin high gpio.out_w1ts.write(|w| w.bits(1 << 5)); } }
Modern Alternatives (2024): While svd2rust remains popular, newer tools like chiptool and metapac offer alternative approaches. Metapac provides additional metadata (memory layout, interrupt tables) alongside register access, useful for HAL frameworks like Embassy.
Part 4: The Embedded HAL Traits
Core Traits
The embedded-hal provides standard traits for common peripherals:
#![allow(unused)] fn main() { use embedded_hal::digital::v2::{OutputPin, InputPin}; use embedded_hal::blocking::delay::DelayMs; use embedded_hal::blocking::spi::{Write, Transfer}; use embedded_hal::blocking::i2c::{Read, Write as I2cWrite}; // GPIO traits pub trait OutputPin { type Error; fn set_low(&mut self) -> Result<(), Self::Error>; fn set_high(&mut self) -> Result<(), Self::Error>; } pub trait InputPin { type Error; fn is_high(&self) -> Result<bool, Self::Error>; fn is_low(&self) -> Result<bool, Self::Error>; } }
Implementing HAL Traits
#![allow(unused)] fn main() { use embedded_hal::digital::v2::OutputPin; use core::convert::Infallible; pub struct GpioPin { pin_number: u8, gpio_out: &'static Register<u32>, } impl OutputPin for GpioPin { type Error = Infallible; fn set_high(&mut self) -> Result<(), Self::Error> { self.gpio_out.modify(|val| val | (1 << self.pin_number)); Ok(()) } fn set_low(&mut self) -> Result<(), Self::Error> { self.gpio_out.modify(|val| val & !(1 << self.pin_number)); Ok(()) } } }
Driver Portability
Write drivers that work with any HAL implementation:
#![allow(unused)] fn main() { use embedded_hal::blocking::delay::DelayMs; use embedded_hal::digital::v2::OutputPin; pub struct Led<P: OutputPin> { pin: P, } impl<P: OutputPin> Led<P> { pub fn new(pin: P) -> Self { Led { pin } } pub fn on(&mut self) -> Result<(), P::Error> { self.pin.set_high() } pub fn off(&mut self) -> Result<(), P::Error> { self.pin.set_low() } pub fn blink<D: DelayMs<u32>>( &mut self, delay: &mut D, ms: u32, ) -> Result<(), P::Error> { self.on()?; delay.delay_ms(ms); self.off()?; delay.delay_ms(ms); Ok(()) } } }
Part 5: Real-World Example - SPI Display Driver
Portable Display Driver
#![allow(unused)] fn main() { use embedded_hal::blocking::spi::Write; use embedded_hal::digital::v2::OutputPin; use embedded_hal::blocking::delay::DelayMs; pub struct ST7789<SPI, DC, RST, DELAY> { spi: SPI, dc: DC, rst: RST, delay: DELAY, } impl<SPI, DC, RST, DELAY> ST7789<SPI, DC, RST, DELAY> where SPI: Write<u8>, DC: OutputPin, RST: OutputPin, DELAY: DelayMs<u32>, { pub fn new(spi: SPI, dc: DC, rst: RST, delay: DELAY) -> Self { ST7789 { spi, dc, rst, delay } } pub fn init(&mut self) -> Result<(), Error> { // Reset sequence self.rst.set_low().map_err(|_| Error::Gpio)?; self.delay.delay_ms(10); self.rst.set_high().map_err(|_| Error::Gpio)?; self.delay.delay_ms(120); // Initialization commands self.command(0x01)?; // Software reset self.delay.delay_ms(150); self.command(0x11)?; // Sleep out self.delay.delay_ms(10); self.command(0x3A)?; // Pixel format self.data(&[0x55])?; // 16-bit color self.command(0x29)?; // Display on Ok(()) } fn command(&mut self, cmd: u8) -> Result<(), Error> { self.dc.set_low().map_err(|_| Error::Gpio)?; self.spi.write(&[cmd]).map_err(|_| Error::Spi)?; Ok(()) } fn data(&mut self, data: &[u8]) -> Result<(), Error> { self.dc.set_high().map_err(|_| Error::Gpio)?; self.spi.write(data).map_err(|_| Error::Spi)?; Ok(()) } pub fn draw_pixel(&mut self, x: u16, y: u16, color: u16) -> Result<(), Error> { self.set_window(x, y, x, y)?; self.command(0x2C)?; // Memory write self.data(&color.to_be_bytes())?; Ok(()) } fn set_window(&mut self, x0: u16, y0: u16, x1: u16, y1: u16) -> Result<(), Error> { self.command(0x2A)?; // Column address set self.data(&x0.to_be_bytes())?; self.data(&x1.to_be_bytes())?; self.command(0x2B)?; // Row address set self.data(&y0.to_be_bytes())?; self.data(&y1.to_be_bytes())?; Ok(()) } } #[derive(Debug)] pub enum Error { Spi, Gpio, } }
Part 6: Interrupt Handling
Critical Sections and Atomics
use cortex_m::interrupt; use core::cell::RefCell; use cortex_m::interrupt::Mutex; // Shared state between interrupt and main static COUNTER: Mutex<RefCell<u32>> = Mutex::new(RefCell::new(0)); #[interrupt] fn TIMER0() { interrupt::free(|cs| { let mut counter = COUNTER.borrow(cs).borrow_mut(); *counter += 1; }); } fn main() { // Access shared state safely let count = interrupt::free(|cs| { *COUNTER.borrow(cs).borrow() }); }
DMA with Volatile Buffers
#![allow(unused)] fn main() { use core::sync::atomic::{AtomicBool, Ordering}; #[repr(C, align(4))] struct DmaBuffer { data: [u8; 1024], } static mut DMA_BUFFER: DmaBuffer = DmaBuffer { data: [0; 1024] }; static DMA_COMPLETE: AtomicBool = AtomicBool::new(false); fn start_dma_transfer() { unsafe { // Configure DMA to write to DMA_BUFFER let buffer_addr = &DMA_BUFFER as *const _ as u32; // Set up DMA registers (hardware-specific) const DMA_SRC: *mut u32 = 0x4002_0000 as *mut u32; const DMA_DST: *mut u32 = 0x4002_0004 as *mut u32; const DMA_LEN: *mut u32 = 0x4002_0008 as *mut u32; const DMA_CTRL: *mut u32 = 0x4002_000C as *mut u32; core::ptr::write_volatile(DMA_SRC, 0x2000_0000); // Source address core::ptr::write_volatile(DMA_DST, buffer_addr); // Destination core::ptr::write_volatile(DMA_LEN, 1024); // Transfer length core::ptr::write_volatile(DMA_CTRL, 0x01); // Start transfer } } #[interrupt] fn DMA_DONE() { DMA_COMPLETE.store(true, Ordering::Release); } fn wait_for_dma() { while !DMA_COMPLETE.load(Ordering::Acquire) { cortex_m::asm::wfi(); // Wait for interrupt } // DMA complete - buffer contents are valid unsafe { // Must use volatile reads since DMA wrote the data let first_byte = core::ptr::read_volatile(&DMA_BUFFER.data[0]); } } }
Part 7: Power Management
Low-Power Modes
#![allow(unused)] fn main() { pub enum PowerMode { Active, Sleep, DeepSleep, Hibernate, } pub struct PowerController { pwr_ctrl: &'static Register<u32>, } impl PowerController { pub fn set_mode(&self, mode: PowerMode) { let ctrl_value = match mode { PowerMode::Active => 0x00, PowerMode::Sleep => 0x01, PowerMode::DeepSleep => 0x02, PowerMode::Hibernate => 0x03, }; self.pwr_ctrl.write(ctrl_value); // Execute wait-for-interrupt to enter low-power mode cortex_m::asm::wfi(); } pub fn configure_wakeup_sources(&self, sources: u32) { const WAKEUP_EN: Register<u32> = Register::new(0x4000_1000); WAKEUP_EN.write(sources); } } }
Part 8: Real Hardware Example - ESP32-C3
Complete Blinky Example
#![no_std] #![no_main] use esp32c3_hal::{clock::ClockControl, pac::Peripherals, prelude::*, timer::TimerGroup, Rtc}; use esp_backtrace as _; use riscv_rt::entry; #[entry] fn main() -> ! { let peripherals = Peripherals::take().unwrap(); let system = peripherals.SYSTEM.split(); let clocks = ClockControl::boot_defaults(system.clock_control).freeze(); let mut rtc = Rtc::new(peripherals.RTC_CNTL); let timer_group0 = TimerGroup::new(peripherals.TIMG0, &clocks); let mut wdt0 = timer_group0.wdt; let timer_group1 = TimerGroup::new(peripherals.TIMG1, &clocks); let mut wdt1 = timer_group1.wdt; // Disable watchdogs rtc.rwdt.disable(); wdt0.disable(); wdt1.disable(); // Configure GPIO let io = IO::new(peripherals.GPIO, peripherals.IO_MUX); let mut led = io.pins.gpio7.into_push_pull_output(); // Main loop loop { led.toggle().unwrap(); delay(500_000); } } fn delay(cycles: u32) { for _ in 0..cycles { unsafe { riscv::asm::nop() }; } }
Best Practices
- Always Use Volatile: Hardware registers require volatile access
- Type Safety: Use strong types to prevent register misuse
- Singleton Pattern: Ensure single ownership of peripherals
- Critical Sections: Protect shared state in interrupts
- Zero-Cost Abstractions: HAL traits compile to direct register access
- Test on Hardware: Emulators may not match real hardware behavior
Common Pitfalls
- Forgetting Volatile: Regular access leads to optimization bugs
- Race Conditions: Unprotected access from interrupts
- Alignment Issues: DMA buffers need proper alignment
- Clock Configuration: Wrong clock setup causes timing issues
- Power States: Peripherals may need re-initialization after sleep
Summary
Embedded HAL in Rust provides:
- Volatile access patterns for hardware registers
- Type-safe abstractions over raw memory access
- PAC generation from SVD files
- Portable drivers via HAL traits
- Memory safety in embedded contexts
The embedded-hal ecosystem enables writing portable, reusable drivers while maintaining the performance of direct hardware access.