The Datasheet Wall
You bought an ESP32-C3 development board. You downloaded the datasheet. It is 800 pages of register maps, bit fields, clock trees, and errata. You want to blink an LED. You could write raw hex values to memory addresses, but that is error-prone, boring, and likely to brick your device if you miss a clock enable bit.
Rust offers a better path. The ecosystem splits the problem into two layers. The Peripheral Access Crate gives you a generated map of the chip's registers. The Board Support Package gives you the wiring diagram for your specific board. Together, they let you control hardware with compiler guarantees, not just hope.
PAC vs BSP: The Map and the Territory
The PAC stands for Peripheral Access Crate. It is a Rust crate that mirrors the microcontroller's hardware registers. Every register becomes a struct. Every bit field becomes an enum or a method. The PAC does not know about LEDs, buttons, or USB cables. It only knows about the silicon.
The BSP stands for Board Support Package. It is a crate tailored to a specific development board. It knows which pin drives the green LED. It knows which button is wired to interrupt 5. It provides helpers to initialize the board's unique wiring. The BSP sits on top of the PAC and adds board-specific context.
Think of the PAC as the blueprint for a building's electrical system. It shows every wire, every breaker, and every outlet. The BSP is the lease agreement for your specific apartment. It tells you which breaker controls the kitchen and which outlet is near the door. You need the blueprint to understand the wiring, but you need the lease to know how to use the space.
The Embedded Stack: SVD, PAC, HAL, BSP
Rust embedded code follows a hierarchy. Understanding the layers helps you know where to look when things go wrong.
At the bottom is the SVD file. The System View Description is an XML file provided by the chip vendor. It describes every register address and bit field. The PAC is generated automatically from this file. You are not reading hand-written logic. You are reading a machine translation of the datasheet. This is why PACs are comprehensive. If the register exists, the PAC has a struct for it.
Above the PAC sits the HAL, or Hardware Abstraction Layer. The HAL provides safe, ergonomic APIs for common peripherals. It handles clock configuration, pin multiplexing, and error checking. The HAL uses the PAC under the hood but hides the raw register access.
The BSP sits on top of the HAL. It re-exports the HAL and adds board-specific definitions. When you import esp32c3_bsp, you get access to the HAL and the PAC through convenient aliases. The BSP is your entry point.
Minimal Example: Blinking with BSP
Start with the BSP. It handles the heavy lifting of initialization. You do not need to touch the PAC to blink an LED.
Add the BSP to your Cargo.toml. The PAC comes along as a transitive dependency.
[dependencies]
esp32c3-bsp = "0.1"
Write your main function. The BSP provides a Peripherals struct that represents all hardware resources.
use esp32c3_bsp::Peripherals;
use esp32c3_bsp::prelude::*;
fn main() -> ! {
// Take ownership of the board's peripherals.
// This returns Option<Peripherals> because it can only be called once.
// The compiler ensures no other code can grab the peripherals.
let peripherals = Peripherals::take().unwrap();
// Access GPIO via the BSP's abstraction.
// The BSP handles clock enabling and pin muxing automatically.
let mut io = peripherals.GPIO;
// Configure pin 0 as output.
// This uses the HAL layer under the hood.
io.pin[0].set_output();
loop {
io.pin[0].set_high();
// Delay would go here in real code.
io.pin[0].set_low();
}
}
The Peripherals::take() call is the gateway. It returns Some(Peripherals) on the first call and None on subsequent calls. This enforces single ownership of the hardware. You cannot accidentally initialize the GPIO twice. If you try to call take() again, the program panics at runtime. The compiler forces you to handle the Option, making the singleton pattern explicit.
Convention aside: The community prefers Peripherals::take() over Peripherals::steal(). The steal() method exists for testing or bootloaders, but it bypasses the singleton check. Using steal() in normal code is a fast track to undefined behavior. Stick to take().
Trust the BSP for standard tasks. It saves you from configuring clock trees and pin matrices manually.
Dropping to the PAC: When the BSP Isn't Enough
The BSP covers common use cases. Sometimes you need access to a register that the BSP or HAL does not expose. Maybe you are tweaking a low-power mode bit. Maybe you are debugging a clock issue. Maybe you are writing a driver for a peripheral that the HAL has not implemented yet.
That is when you reach for the PAC. The PAC gives you direct access to the registers. You trade ergonomics for control.
Accessing the PAC usually requires an unsafe block. The PAC provides pointers to memory-mapped I/O regions. Rust cannot prove these pointers are valid at compile time. You have to promise they are.
use esp32c3_bsp::Peripherals;
use esp32c3_pac::RTC_CNTL;
fn main() -> ! {
let peripherals = Peripherals::take().unwrap();
let mut io = peripherals.GPIO;
io.pin[0].set_output();
// Access the RTC control register via the PAC.
// SAFETY: RTC_CNTL::ptr() returns a valid pointer to the memory-mapped
// register block. The ESP32-C3 datasheet guarantees this address range.
// We are only reading/writing valid fields defined in the PAC.
let rtc = unsafe { &*RTC_CNTL::ptr() };
// Enable a specific sleep configuration bit.
// The PAC provides type-safe access to the bit field.
rtc.sleep_conf0().modify(|_, w| w.sleep_en().set_bit());
loop {
io.pin[0].toggle();
}
}
The modify method is a PAC pattern. It reads the current register value, passes it to a closure, and writes the result back. This prevents race conditions where a concurrent interrupt might change the register between read and write. The closure gives you a writer object w with methods for each bit field.
Convention aside: Keep your unsafe blocks smaller than a tweet. If you need more than three lines, you are doing too much. Extract the unsafe operation into a small helper function. The rest of your code stays safe. The community calls this the minimum unsafe surface rule.
Treat the unsafe block as a proof. If you cannot write a // SAFETY: comment that lists the invariants, you do not have a proof.
The Borrow Checker in Embedded: Ownership of Hardware
Rust's borrow checker applies to hardware just as it does to memory. Peripherals are resources. You can only have one mutable reference to a peripheral at a time. This prevents data races and configuration conflicts.
If you pass a peripheral to a function, you move ownership. You cannot use it again in the calling scope.
fn setup_led(gpio: esp32c3_pac::GPIO) {
// Configure LED...
}
fn main() -> ! {
let peripherals = Peripherals::take().unwrap();
// This moves the GPIO peripheral into setup_led.
setup_led(peripherals.GPIO);
// Error: E0382 use of moved value `peripherals.GPIO`.
// peripherals.GPIO is no longer available here.
// peripherals.GPIO.pin[0].set_output();
}
The compiler rejects this with E0382 (use of moved value). You cannot use the GPIO after passing it away. The solution is to split the peripherals struct.
fn main() -> ! {
let peripherals = Peripherals::take().unwrap();
// Destructure to extract GPIO and keep other peripherals.
let gpio = peripherals.GPIO;
let other = peripherals;
setup_led(gpio);
// You can still use other peripherals.
// other.SPI2 is available.
}
Destructuring lets you distribute ownership across your codebase. Each function owns the peripherals it needs. The compiler guarantees no two functions will conflict.
Don't fight the borrow checker. Pass the peripheral to the function that owns it. If you need shared access, reach for RefCell or Mutex, but prefer ownership transfer whenever possible.
Pitfalls and Errors
Embedded Rust has unique pitfalls. Knowing them saves hours of debugging.
Forgetting clock enables. The PAC writes to registers, but it does not enable clocks. If you configure a peripheral without enabling its clock, nothing happens. The hardware is asleep. Check the clock tree in the datasheet. The HAL usually handles this, but the PAC does not.
Using the wrong pin mux. Pins can often serve multiple functions. Pin 0 might be GPIO, SPI, or UART. The BSP configures the mux for you. If you use the PAC directly, you must configure the mux register manually. If you miss this step, the pin will not respond.
Dereferencing raw pointers. If you try to access a PAC pointer without unsafe, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe block). This is a feature. The compiler forces you to acknowledge the risk.
Double initialization. If you call Peripherals::take() twice, the second call returns None. Calling unwrap() on None panics. This happens if you accidentally structure your code to initialize the board twice. Use if let Some(peripherals) = Peripherals::take() to handle this gracefully, or ensure take() is called exactly once at the entry point.
Mismatched types. The PAC uses enums for bit fields. If you try to write a raw integer to a register field, you get E0308 (mismatched types). The PAC enforces type safety. You must use the provided enum variants. This prevents writing invalid values to control registers.
Decision Matrix
Choose the right layer for your task.
Use BSP when you want to get a project running fast and do not need to tweak low-level registers. Use BSP when you need board-specific helpers like LED aliases, button mappings, or USB initialization. Use BSP when you are prototyping and want to avoid clock configuration.
Use PAC when the BSP lacks support for a specific peripheral or register field. Use PAC when you are writing a HAL or a driver that needs to be portable across boards using the same chip. Use PAC when you need to access registers that the HAL abstracts away, such as clock configuration bits or power management controls. Use PAC when you are debugging and need to inspect raw register values.
Reach for plain HAL abstractions when the peripheral is standard and the BSP re-exports them. The HAL provides safety and ergonomics without the overhead of raw register access.