embassy-mcxa/DEVGUIDE.md
This document is intended to assist developers of the embassy-mcxa crate.
As of 2026-01-29, there is currently no "how to write/maintain a HAL" guide for embassy, so we intend to write up and explain why the embassy-mcxa crate was implemented the way it was, and to serve as a reference for people incrementally building out more features in the future. We also hope to "upstream" these docs when possible, to assist with better consistency among embassy HALs in the future.
This document will be written incrementally. If you see something missing: please do one of the following:
If you have an example that is configured to the DeepSleep state, it will sever the debugger connection once it enters deep sleep. This can mean it will be hard to re-flash since the debugging core is disabled.
To recover from this state, you can use the ISP mode, which triggers the ROM bootloader:
You probably want to recover the device by flashing a simple example like the blinky example which doesn't attempt to go to deep sleep.
Cargo.toml fileThis section describes the notable components of the Cargo.toml package manifest.
package.metadataAs an embassy crate, we have a couple of embassy-specific metadata sections.
package.metadata.embassy
package.metadata.embassy_docs
We have a couple of features/kinds of features exposed as part of the crate. For general features, see the Cargo.toml docs for what features are activated by default, and what these features do.
Notable features/groupings of features are discussed below.
...-as-gpio featuresSome pins can operate EITHER for GPIO/peripheral use, OR for some kind of dedicated feature, such as SWD/JTAG debugging, external oscillator, etc. Since it is difficult to expose this conditionally in the Peripherals struct returned by hal::init(), we make this a compile-time feature decision. This is generally reasonable, because when pins are dedicated to a use (or not), this requires board-level electrical wiring, which is not typically reconfigured at runtime.
For pins covered by ...-as-gpio features, they are typically in their dedicated feature mode at boot. When an ...-as-gpio feature is active, the relevant pins will be moved back to the "disabled" state at boot, rather than remaining in their default dedicated feature state.
For example, the swd-swo-as-gpio feature is on by default. When this feature is NOT enabled, the pin is used as SWO by default. On the FRDM development board, this causes issues, as this pin is NOT wired up to SWO, and is instead wired up to the I2C/I3C circuit, preventing normal operation.
lib.rsThe lib.rs is the top level API of the embassy-mcxa crate.
embassy_hal_internal::peripherals!The embassy_hal_internal::peripherals! macro is used to create the list of peripherals available to users of the HAL after calling hal::init(). Each item generates a Peri<'static, T>, which is a zero-sized type "token", which is used to prove exclusive access to a peripheral. These are often referred to as "singletons", as these tokens can only (safely) be created once. For more information on how these tokens are used, see the "Peripheral Drivers" section below.
In this list, we include:
The generated Peripherals struct always creates all items, which means it's not generally possible for functions like hal::init() to say "depending on config, we MIGHT not give you back some pins/peripherals". For this reason, we make any of these conditionally-returned tokens a crate feature. See the Cargo.toml section above for more details.
embassy_hal_internal::interrupt_mod!The embassy_hal_internal::interrupt_mod! macro is used to generate a number of helper functions, types, and marker traits for each hardware interrupt signal on the chip.
All interrupts available for a chip should be listed in this macro.
init functionThis function is also referred to as hal::init() in these docs.
This function is typically one of the first functions called by the user. It takes all configuration values relevant for the lifetime of the firmware, including:
embassy-time-driver impl)This function then performs important "boot up" work, including:
Finally, when setup is complete, The init function returns the Peripherals struct, created by the embassy_hal_internal::peripherals! macro, containing one Peri<'static, T> token for each peripheral.
Some modules of the HAL do not map 1:1 with the memory mapped peripherals of the system. These components are discussed here.
The clocks module is responsible for setting up the system clock and power configuration of the device. This functionality spans across a few peripherals (SCG, SYSCON, VBAT, MRCC, etc.).
See the doc comments of src/clocks/mod.rs for more details regarding the architectural choices of this module.
The majority of embassy-mcxa handles high-level drivers for hardware peripherals of the MCXA. These sections discuss "best practices" or "notable oddities" for these hardware drivers
This section regards patterns that are used for all or most peripheral drivers.
In order to prevent "monomorphization bloat", as well as "cognitive overload" for HAL users, each peripheral driver should strive to MINIMIZE the number of lifetimes and generics present on the driver. For example, for an I2c peripheral with two GPIO pins, we DO NOT want:
struct<'p, 'c, 'd, P, SCL, SDA, MODE> I2c { /* ... */ }
type Example = I2c<
'periph, // lifetimes
'scl, // lifetimes
'sda, // lifetimes
Peri<'periph, I2C0>, // peripheral instance generic
Peri<'scl, P0_2>, // gpio pin instance generic
Peri<'sda, P0_3>, // gpio pin instance generic
Async, // operational mode
>;
Instead, we want to:
Blocking or Async, where the latter is often interrupt-enabled and has async methods, while the former doesn't.This allows us to create a type that looks as follows:
struct<'a, MODE> I2c { /* ... */ }
type Example = I2C<'a, Async>;
In order to retain type safety functionality, we do still use the per-instance and per-peripheral generics, but ONLY at the constructor. This means that constructors will end up looking something like:
impl<'a> I2c<'a, Blocking> {
pub fn new<T: Instance>(
peri: Peri<'a, T>,
scl: Peri<'a, impl SclPin<T>>,
sda: Peri<'a, impl SdaPin<T>>,
config: Config,
) -> Result<I2c<'a, Blocking>, Error> {
// get information like references/pointers to the specific
// instance of the peripherals, or per-instance specific setup
//
// Get pointers for this instance of I2C
let info = T::info();
// Perform GPIO-specific setup
scl.setup_scl();
sda.setup_sda();
// If we needed to enable interrupts, this is likely bound to the generic
// instance:
//
// T::Interrupt::unpend();
// ...
Ok(I2c {
info, // hold on to for later!
// ...
})
}
}
When checking errors, ensure that ALL errors are cleared before returning. Otherwise early returns can lead to "stuck" errors. Instead of this:
fn check_and_clear_rx_errors(info: &'static Info) -> Result<()> {
let stat = info.regs().stat().read();
if stat.or() {
info.regs().stat().write(|w| w.set_or(true));
Err(Error::Overrun)
} else if stat.pf() {
info.regs().stat().write(|w| w.set_pf(true));
Err(Error::Parity)
} else if stat.fe() {
info.regs().stat().write(|w| w.set_fe(true));
return Err(Error::Framing);
} else if stat.nf() {
info.regs().stat().write(|w| w.set_nf(true));
return Err(Error::Noise);
} else {
Ok(())
}
}
Ensure that all errors are cleared:
fn check_and_clear_rx_errors(info: &'static Info) -> Result<()> {
let stat = info.regs().stat().read();
// Check for overrun first - other error flags are prevented when OR is set
let or_set = stat.or();
let pf_set = stat.pf();
let fe_set = stat.fe();
let nf_set = stat.nf();
// Clear all errors before returning
info.regs().stat().write(|w| {
w.set_or(or_set);
w.set_pf(pf_set);
w.set_fe(fe_set);
w.set_nf(nf_set);
});
// Return error source
if or_set {
Err(Error::Overrun)
} else if pf_set {
Err(Error::Parity)
} else if fe_set {
Err(Error::Framing)
} else if nf_set {
Err(Error::Noise)
} else {
Ok(())
}
}
When creating Error types for each peripheral, consider the following high level guidance:
Instead of making one top-level Error for the entire peripheral, it it often useful to create multiple error enums. For example, instead of:
enum Error {
Clocks(ClockError),
BadConfig,
Timeout,
TransferTooLarge,
}
impl Example {
// Can return `Err(Clocks)` or `Err(BadConfig)`
pub fn new(config: Config) -> Result<Self, Error> { /* ... */ }
// Can return `Err(BadConfig)` or `Err(TransferTooLarge)`
pub fn send_u8s(&mut self, mode: Mode, data: &[u8]) -> Result<(), Error> { /* ... */ }
// Can return `Err(BadConfig)` or `Err(TransferTooLarge)`
pub fn send_u16s(&mut self, mode: Mode, data: &[u16]) -> Result<(), Error> { /* ... */ }
// Can return `Err(Timeout)` or `Err(TransferTooLarge)`
pub fn recv(&mut self, data: &mut [u8]) -> Result<usize, Error> { /* ... */ }
}
If the same Error type is used, the user may need to match on errors that are "impossible", e.g. a new() function returning Error::Timeout.
Instead, it might be worth splitting this into three errors:
enum CreateError {
Clocks(ClockError),
BadConfig,
}
enum SendError {
BadConfig,
TransferTooLarge,
}
enum RecvError {
Timeout,
TransferTooLarge,
}
impl Example {
pub fn new(config: Config) -> Result<Self, CreateError> { /* ... */ }
pub fn send_u8s(&mut self, mode: Mode, data: &[u8]) -> Result<(), SendError> { /* ... */ }
pub fn send_u16s(&mut self, mode: Mode, data: &[u16]) -> Result<(), SendError> { /* ... */ }
pub fn recv(&mut self, data: &mut [u8]) -> Result<usize, RecvError> { /* ... */ }
}
Result aliasIt used to be common to see module specific aliases for Results, e.g.:
pub type Result<T> = Result<T, Error>;
However:
Results in scopeError per module", which is the opposite of what is described above#[non_exhaustive]Unless we are definitely sure that we have covered all possible kinds of errors for a HAL driver, we should mark the Error type(s) as #[non_exhaustive], to prevent making a breaking change when adding a new error type.
For example:
#[non_exhaustive]
enum RecvError {
Timeout,
TransferTooLarge,
}
We generally want to avoid the use of wildcard/glob imports, like:
use super::*;
use other_module::*;
This can cause surprising semver breakage, and make the code harder to read.