Rust

Install from https://rustup.rs/.

Rust is a modern, practical, performant systems programming language.

It fits the same niche as C++ — nearly as fast as C but more expressive. However, not an abomination of infinite language “features” and implicit gotchas.

High points

  • Cross-compilation support built into tooling — just works:
$ rustup target add thumbv7em-none-eabihf
$ cargo build --target thumbv7em-none-eabihf
# now compiling for thumbv7 on any host platform
  • Great package manager with a wealth of excellent libraries
    • Long list support #![no_std] (no OS support, no heap allocator assumed)
    • Easy to use private packages
  • Embedded ecosystem
    • Excellent support for:
      • RP2040
      • nRF{51,52,53,91}
      • STM32
      • ESP32 (idf or native rust)
      • I believe atsamd? Haven’t tried it
    • Most embedded programs can run on Linux with minimal modifications — there are Linux HAL trait implementations
  • Catches many mistakes at compile-time
    • No memory bugs
      • Use-after-free
      • Double-free
      • Memory leaks
    • No data races
    • No accidental multiple bus access
  • State-machine-based, executor-agnostic asynchrony
    • Futures/async on embedded, no dynamic allocation required

some great embedded crates (crate = package)

  • *-pac, svd2rust: pac = peripheral access crate
    • Autogenerate Rust MCU register-interface crates (pacs) from SVD (vendor-provided register description) files
    • HALs generally written on top of pacs
  • embassy: embedded async project
    • Extensive examples for all in-tree platforms
    • Embedded async executor
      • Can spawn multiple at different software interrupt priorities
      • Practically speaking, can be used for soft-realtime. Not heavily battle-tested, but stable in my experience.
    • Synchronization primities
      • Mutex<T>
      • Channel<T>, Sender<T>, Receiver<T>, no void* garbage
      • pubsub — producer-side decides whether it accepts backpressure, so no lockups if not intended
      • Pipe (byte stream, implements generic async io::{Read, Write} interfaces)
      • Signal<T>
    • Time interface, driver (alarms, uptime) from e.g. systick (pluggable implementation)
      • Timer::after(Duration::from_secs(1)).await
      • let mut tick = Ticker::every(Duration::from_micros(100)); loop { tick.await; }
    • Networking (commmon interfaces, ppp, tun/tap, esp-hosted)
      • Wifi, Bluetooth, 802.15.4, ethernet implementations per-HAL made against this interface
    • USB interface definition, implementations for common classes
      • CDC-ACM
      • CDC-NDC
      • HID
      • MIDI
      • DFU
    • HAL crates for rp2040, nRF, stm32
    • Bootloader implementations for contained HAL^ crates with DFU partition/failback
  • embedded-*: official Rust working-group; standard platform-agnostic interface definitions and utilities
  • rtic: Rust RTOS
    • Can be used alongside parts of embassy
  • smoltcp: portable async networking stack
    • Drives embassy networking
    • Ethernet, 802.15.4, IP media
    • 6LoWPAN
    • TCP/IPv{4, 6}, UDP, ICMP, DHCP, DNS, IGMP
    • Non-allocating
  • heapless: constant-max-size stack-allocated data structures mirroring heap-allocated variants

  • defmt: logging library
    • No strings in flashed binary, just indexes
    • Strings are in special section in ELF
    • Logs also gzip compressed
    • Host-side log decoder program assembles messages from ELF, interpolates arguments
  • rtt: segger tech, a bunch of crates support this
    • basically MCU creates a region of RAM it will write to as a ringbuffer, debugger’s responsibility to watch for messages and pull
    • extremely low-overhead logging, just RAM write
    • no-op if no debugger attached
    • panic_rtt: panic implementation to rtt
    • defmt_rtt: defmt over rtt
  • vergen
    • Compile-in build time, git hash, build system info, compiler version (with hash), ~10-20 lines of code
  • prost: protobuf support
    • 10 lines to automatically compile protobuf definitions to rust during build
    • MessageType::encode, MessageType::decode
  • corncobs: cobs implementation

tools

  • probe-rs: debugging toolset for rust
    • cargo run -> build + probe-rs run (flash and attach to rtt channel)
    • automatically discovers attached debuggers
  • espflash: dedicated flasher for esp32
    • Optional alternative to probe-rs for esp32, no rtt
    • Can be configured for cargo run above
  • cargo-binutils: run binutils on any host platform, with cross-platform support
    • cargo objcopy --target thumbv7em-none-eabihf --bin my_project --release -- -O ihex my_project.hex
      • Automatically compiles the project in release mode for the thumbv7em-none-eabihf target and uses objcopy to produce a hex file
      • That^ works on Windows without msys/mingw/cygwin

Downsides

  • Initial learning curve is steep
    • Ownership rules, lifetimes
    • Nathan is happy to give pointers :)
  • Embedded ecosystem is still developing
    • Functionality is great / haven’t had problems, but a fair amount of boilerplate to stand everything up
    • Nathan is working on this — putting together a crate to make bringup easier / closer to an Arduino-like experience
  • Compile times are slow-ish
    • ~On par with C++, I feel it’s not as bad with bigger projects though — incremental compilation is pretty good
    • Error messages are much much better!
  • (Not really a downside) No escape hatches, just do it right
    • If you’re used to being able to be able to cheat / tell the compiler you know what you’re doing (even when it’s UB or just wrong), that doesn’t exist in Rust
    • The compiler won’t compile if your program is wrong in this way — usually that’s because there’s a correct way to do it. Rust gives you tools to make this easy.
    • Representative example: integer over-/underflow disallowed for normal addition according to spec. In development mode, overflow is checked and panics. In release mode, silently overflows. So, specify what kind of addition you want. Rather than x += 1, x = x.saturating_add(1) or x = x.wrapping_add(1).
    • Or Mutex<T>. Types can be Sync or not — in order to share a value that is not Sync to another thread (or task) (capture it in the thread::spawn(|| { /* closure in thread */}) closure, share a global), it needs to be contained in a mutex. Then accessing is as easy as:
struct Val { inner: usize };
impl !Sync for Val {} // Assert val is not safe to access across threads.

let m: Mutex::<Val> = Mutex::new(Val { inner: 4 });

// Increase `inner` by 2 every second and print the value.
thread::spawn(|| {
    loop {
        {
            let guard = m.lock();
            *guard.inner += 2;

            println!("count: {}", guard.inner);

            // `guard` provides access to the inner value (witnesses that the lock was acquired).
            // When it is dropped at the end of this block, the lock is automatically released.
        }

        thread::sleep(Duration::seconds(1));
    }
});
#[global_allocator]
static HEAP: embedded_alloc::Heap = embedded_alloc::Heap::new();

// RawMutex type specifies where it's appropriate to use this value.
// Select CriticalSectionRawMutex instead to make it usable from within ISRs.
static CHANNEL: embassy_sync::Channel<ThreadModeRawMutex, (), 32> = embassy_sync::Channel::new();

#[embassy_executor::main]
async fn main(spawner: embassy_executor::Spawner) -> anyhow::Result<()> {
    defmt::info!("boot");

    init_heap();

    // Contains unique handles to all register blocks. HAL types (e.g. Output below) wrap these
    // handles to provide a nice interface.
    let periphs = embassy_nrf::init(Default::default());

    let led = Output::new(periphs.P0_01.degrade(), Level::Low(), OutputDrive::Standard);
    spawner.must_spawn(indicate_traffic(led));
    spawner.must_spawn(simulate_traffic());
}

/// Toggle LED when "traffic" (e.g. TX/RX on some peripheral) is detected.
#[embassy_executor::task]
async fn indicate_traffic(led: Output<'static, AnyPin>) -> ! {
    loop {
        CHANNEL.receive().await;
        led.toggle();

        defmt::trace!("led toggled"):
    }
}

/// Simulate traffic to drive the LED to toggle.
#[embassy_executor::task]
async fn simulate_traffic() -> ! {
    let mut tick = Ticker::every(Duration::from_millis(100));

    loop {
        defmt::trace!("ping");

        CHANNEL.send(()).await;
        tick.await;
    }
}

/// Boilerplate heap init.
fn init_heap() {
    // Grab symbols from linker.
    extern "C" {
        static __sheap: u32;
        static __eheap: u32;
    }

    let (sheap, eheap) = unsafe {
        let sheap = &__sheap as *const _ as usize;
        let eheap = &__eheap as *const _ as usize;

        (sheap, eheap)
    };

    HEAP.init(sheap, eheap - sheap);
    defmt::info!("heap initialized");
}