component: molybdos (rust arduino-inspired library)
batteries-included, arduino-inspired library for easy rust embedded bringup
https://public.git.npry.dev/molybdos
I want more people to use Rust on embedded. It's a great language, it has a package manager, the library support is great, and it encourages people to write better, clearer, more correct code, while not reinventing the wheel.
current shortcomings / improvement areas
Unfortunately, the story for Rust on embedded currently involves a lot of boilerplate and pitfalls. Once you have your environment set up properly, it's great, but for instance:
- Common, quasi-standard library needs to be re-added to every project
heapless
embassy_*
embedded_*
defmt
panic_*
cortex_m{,_rt}
/riscv{,_rt}
- etc.
- Common build scripts need to be added to every project
- Shared features (
defmt
/log
,alloc
,serde
,default_features = false
) are tedious to add to every project - Often need to write a linker file for your chip
- Some libraries use weak linking for dependency management — they need linker definitions added in each project
- Boilerplate peripheral bringup is kind of annoying to write
- E.g. no USB-CDC by default (typical Arduino
Serial
implementation) - Pins are unique type-level handles to register values. This provides type-safety but means you
can't access them generically (e.g. put them into an array) without
.downgrade()
ing them individually
- E.g. no USB-CDC by default (typical Arduino
embedded_*
interfaces use AFIT (async-function-in-trait) rather than a polling interface, making them not easily compatible with e.g.futures::{Stream, Sink}
- No-op imports for lang items:
use panic_abort as _;
use defmt_rtt as _;
All of this is pretty straightforwardly mitigated by writing a library crate, which is what I'm working on.
progress
linker file generation
One of the first tasks I'm tackling is a linker file generator that runs at build-time. Intended interface:
# Cargo.toml
[]
= { = "...", = ["linker_gen"] }
This will cause a molybdos
' build.rs
to generate a linker file
(e.g.) for the selected
chip and emit the typical Cargo instructions (example below from
here
(nRF51*, but meaningfully the same for at least most Cortex-M MCUs with defmt
enabled)):
let out = & from;
create
.unwrap
.write_all
.unwrap;
println!;
println!;
println!;
println!;
println!;
This task is still in-progress.
peripheral bringup
ontology
The embedded ontology de-facto settled upon by Rust embedded projects is very generic:
- A peripheral access crate (aka "pac", e.g. for
nRF52833)
is written (usually generated using
svd2rust
). This is a register-level interface to the MCU. - A hardware abstraction layer (aka "hal", e.g. for
nRF)
is (usually hand-) written that wraps the pac and makes it ergonomic to use — this usually
means adapting it to Rust embedded conventions and interfaces. The pac will contain your SPI
control register definition and access, the hal will make it look like an
embedded_hal_async::spi::SpiBus
.- A common convention in hals is providing a
Peripherals
struct that contains unique handles to the pac registers. On startup, you run aninit
function exactly once to receive aPeripherals
instance, and then destructure it into relevant fields that you can use to construct the hal types you need. E.g.:
- A common convention in hals is providing a
// Pull in the HAL GPIO types.
use *;
let p: Peripherals = init;
// Construct a new HAL gpio::Output type (which has e.g.
// Output::{set_high, set_low, toggle})
let mut led = new;
// SPI buses, I2C, USB, timers, ADCs, etc. are all constructed
// following a similar pattern.
loop
- Interface crates are a level higher and what the hals are written against, and typically only
contain trait definitions and common helper functions. They exist as common definitions to decouple
dependencies and improve compatibility. (Examples:
embedded_hal{,_async}
,embedded_io{,_async}
,embedded_nal{,_async}
, etc., which provide bus definitions, an embedded version ofio::{Read, Write, Seek}
, network interfaces, and more) - Bus peripheral drivers and applications are ideally written on top of the interface crates for
portability, compatibility, and decoupling. E.g. the
sx127x
LoRa radio driver is written against the genericembedded_hal::spi::SpiBus
definition and can run on anything that can provide one (including Linux! as their examples point out — there is anembedded_hal
interface for Linux userspace).
While this stack is well-designed and great to use once you understand it, it can take quite a while to understand how it wants to be used and configured. It also requires repetitive setup of the same things, when across most projects you just want a quick, low-overhead way to e.g. get access to the SPI bus.
hiding the stack
By contrast to the above, Arduino doesn't tell us anything about the abstraction stack or require
us to configure it beyond selecting our board. Your interface to Arduino as a user is Wire
,
Serial
, SPI
, {analog,digital}{Write,Read}
, etc. I want to make it similarly easy to access
Rust peripherals without requiring this explicit understanding or direct bringup of the stack.
However, I want to strike a good balance re: not going too far in the Arduino direction and making assumptions willy-nilly. A nice interface looks like this to me:
async
This interface implies type-erased peripherals — I want to present peripherals with duplicates uniformly rather than specialized to their pac type as the normal Rust stack does.
The configuration interface I'm aiming for would be as follows:
# Cargo.toml
[]
= { = "...", = ["nrf52833"] } # only need to specify MCU
Still working on this — focusing on developing the nRF platform to completion currently.
aside: curse of abstraction / zero-cost abstraction
There's a "curse of abstraction" in many programming environments, where people accept abstractions having a cost / this is in the culture and basic assumptions. But if every library has a 2% cost to your expressive capability, and your transitive dependency graph has 50 libraries in it, all of a sudden you have 36% of your original capabilities (assuming that 2% is multiplicative, which it probably isn't), because everything's making tiny "probably true" assumptions for "convenience", not all of which are actually true, but you're stuck with them unless you actually fork the repo, go read the datasheet in detail to make the thing do what you want, and basically redo all the work you were trying to avoid by using a library. Ask me how I know.
One of Rust's mantras, borrowed from C++ with some additional flavor, is that of zero-cost abstraction:
if you don't use it, it doesn't cost you. Illustratively, Java does not have zero-cost abstractions.
You pay for the hundreds of megabytes of runtime loading on every process startup. If you want to
run a program to add two integers and print the result, the whole GC still loads, the whole class loading
mechanism, etc. But if you run a {Rust, C, C++} program, it just runs and does exactly what you
want. The class
mechanism in C++ is relatively minimal and doesn't depend on any runtime components,
as a specific example. An enhancement of the zero-cost principle is that even abstractions that you
do use shouldn't impose any additional cost over hand-writing the concretized version yourself.
This is what Rust aims for. See the Godbolt embedded below for an example — in Rust, functional combinators
are equivalent to for loops after compiler optimization.
(Had to disable loop autovectorization to make the equivalence legible.)
You'll notice that there's only one function in the disassembly, sum_of_squares
. The compiler optimized
the combinator loop to the exact same assembly as the for loop. (On a newer Rust compiler, it was actually
able to unroll the combinator loop (and not the for loop), so I had to roll it back to an older
version to get this result.)
In this spirit, having all the knobs available is absolutely, 100% the right thing for the embedded ecosystem in Rust to have done. None of the libraries make assumptions for you, at least not that you can't reconfigure. However, we want to end up with high-level wrapper libraries that do make those assumptions for you — they can let you get started quickly, and you can sub them out for the lower- level interfaces as needed. Rust doesn't currently have these, and that's what I'm aiming to write.
etymology: μόλυβδος
μόλυβδος (molybdos) is Greek for lead. "Batteries-included" -> lead-acid batteries -> lead. I just like the word (and the evident but unexpected connection to molybdenum).