component: molybdos (rust arduino-inspired library)

2024-03-14 / updated 2024-03-21

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:

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

[dependencies]
molybdos = { version = "...", features = ["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 = &PathBuf::from(env::var_os("OUT_DIR").unwrap());
File::create(out.join("memory.x"))
    .unwrap()
    .write_all(include_bytes!("memory.x"))
    .unwrap();
println!("cargo:rustc-link-search={}", out.display());

println!("cargo:rerun-if-changed=memory.x");

println!("cargo:rustc-link-arg-bins=--nmagic");
println!("cargo:rustc-link-arg-bins=-Tlink.x");
println!("cargo:rustc-link-arg-bins=-Tdefmt.x");

This task is still in-progress.

peripheral bringup

ontology

The embedded ontology de-facto settled upon by Rust embedded projects is very generic:

// Pull in the HAL GPIO types.
use embassy_nrf::gpio::*;

let p: embassy_nrf::Peripherals = embassy_nrf::init(Default::default());

// Construct a new HAL gpio::Output type (which has e.g.
// Output::{set_high, set_low, toggle})
let mut led = Output::new(
    // gpio::Output requires a handle to a pin, which we can only
    // get from an embassy_nrf::Peripherals
    p.P0_13,
    Level::Low,
    OutputDrive::Standard,
);

// SPI buses, I2C, USB, timers, ADCs, etc. are all constructed
// following a similar pattern.

loop {
    led.toggle();
    Timer::after_millis(300).await;
}

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:

#[molybdos::main]
async fn main(
    mut m: molybdos::Molybdos,
) -> molybdos::Result<()> {
    molybdos::info!("started up");

    let spi = m.spi[0].init(molybdos::SPIConfig {
        mosi: 1,
        miso: 2,
        sck: "P0.12".pin()?, // Supported by a trait -- molybdos::PinLookup.
        cs: 4,

        ..Default::default()
    })?;

    // peripheral lookup by string name -- could support BSPs as well as IC pinout names
    m.gpio.named("P0.13")?.toggle();

    // resolve pin name to id to avoid repeated lookup
    let pin_id = molybdos::platform::lookup_pin("P0.13")?;
    m.gpio[pin_id].toggle();

    // "take" a pin from the molybdos::Molybdos instance to reserve it exclusively.
    // When exclusive handle is dropped, becomes available to take again.
    let mut pin = m.gpio.take(pin_id)?.into_output(Level::Low, DriveStrength::Standard);
    pin.set_high();

    loop {
        // numeric indexing if already known
        m.gpio[1].toggle();
        embassy_time::Timer::after_millis(300).await;
    }
}

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

[dependencies]
molybdos = { version = "...", features = ["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).