Rust in embedded systems

Are we there yet?

About me

  • Alex Badics
  • Developer and PM
  • Opinions are my employer's
admin@stickman.hu
alex@voidcomputing.hu

Sources

  • Rust embedded book
  • Rust for Embedded Systems:
    Current State, Challenges and Open Problems
    (Sharma et. al.)
  • Embedded Rust setup explained
    (The Rusty Bits youtube channel)
  • Awesome Embedded Rust
    (on github)

Should you use Rust in your commercial embedded project?

The syntax


        pub fn read<P: AsRef<Path>>(path: P)
          -> io::Result<Vec<u8>> {
          fn inner(path: &Path) -> io::Result<Vec<u8>> {
            let mut file = File::open(path)?;
            let mut bytes = Vec::new();
            file.read_to_end(&mut bytes)?;
            Ok(bytes)
          }
          inner(path.as_ref())
        }
    
    

(Pic unrelated.)

Developers

  • Not too many
  • Expensive
  • Usually blockchain-oriented
  • Training takes 2-3 months
  • Proper expertise needs >1 year

Stability

  • Language controlled by non-commercial entity
  • New edition every 3 years
  • General churn
  • Crate (library) stability
    • 0.x versions
    • "MSRV bump is not breaking change"

  • Rustc is pretty compatible though

Standardization

  • rustc == language standard
  • No formal language rules
  • No "MISRA Rust"

  • Ferrocene compiler though

Vendor support

  • With C, you usually get:
    • Toolchain
    • Low level drivers
    • BSP
    • Preconfigured RTOS
  • With Rust, you don't.

Ecosystem

  • Is my MCU even supported?
  • Is there a crate for my board
  • What about this popular OLED screen I have?
  • Or the gyro
  • Or advanced USART feature support

Should you use Rust in your commercial embedded project?

Language features

  • Rust is pretty high level
  • Real programmers can write FORTRAN in any language
  • Simple code => simple assembly

  • Abstract code => simple assembly 🤔

But I can do that in C too...

  • Cognitive load adds up
  • Boilerplate
  • Guarantees (contracts) not checked automatically
  • Forced to reduce readability with optimization

But I can do that in C++ too...

  • Yes, you actually can
  • Which subset of C++ though?
  • Would you let a Junior Dev write C++ code?

Language safety features

  • No undefined behavior
  • No null pointers
  • No aliased pointers
  • No implicit type conversions
  • Array bounds checks
  • RAII:
    • No uninitialized memory
    • No resource leaks
    • No double frees

Language convenience features

  • ?
  • Foreach
  • Operator overloading
  • Templates
    • For containers
    • For functions
  • Closures
  • Const functions

C bindings

  • "Rewrite it in Rust" is a meme
  • bindgen
  • cbindgen
  • Unsafety
    • C APIs are remarkably unsafe
    • Guarantees are undocumented and unstable

Type system example: I/O pins

Low level register access

Hypothetical memory layout

Port A

0x10000000 Output enable
0x10000001 Output value
0x10000002 Pull-up enable
0x10000003 Pull-down enable
0x10000010 Input value

Port B

0x10100000 Output enable
0x10100001 Output value
0x10100002 Pull-up enable
0x10100003 Pull-down enable
0x10100010 Input value

Low level register access


            typedef struct 
            __attribute__((__packed__))
            {
                unsigned char output_enable;
                unsigned char output_value;
                unsigned char pull_up_enable;
                unsigned char pull_down_enable;
                unsigned char _reserved[12];
                unsigned char input_value;
            } gpio_port_t;

            gpio_port_t* const PORT_A = 0x10000000;
            gpio_port_t* const PORT_B = 0x10100000;

            void main() {
                PORT_A->pull_up_enable = 1;
                PORT_B->output_enable = 1;
            }
            
        

            #[repr(C, packed)]
            struct GpioPort {
                output_enable: u8,
                output_value: u8,
                pull_up_enable: u8,
                pull_down_enable: u8,
                _reserved: [u8; 12],
                input_value: u8,
            }

            const PORT_A: *mut GpioPort =
                0x10000000 as *mut GpioPort;
            const PORT_B: *mut GpioPort =
                0x10100000 as *mut GpioPort;

            fn main() {
                unsafe { (*PORT_A).pull_up_enable = 1 };
                unsafe { (*PORT_B).output_enable = 1 };
            }
            
        

Volatile


            volatile gpio_port_t* const PORT_A = 0x10000000;

            void spike() {
                PORT_A->output_value = 1;
                PORT_A->output_value = 0;
            }
            
        

            use core::ptr::{addr_of_mut, write_volatile};
            const PORT_A: *mut GpioPort =
                0x10000000 as *mut GpioPort;
            fn spike() {
                unsafe {
                    write_volatile(
                        addr_of_mut!((*PORT_A).output_value),
                        1
                    );
                    write_volatile(
                        addr_of_mut!((*PORT_A).output_value),
                        0
                    ) 
                };
            }
            
        

Safe-ish Pin struct


            pub enum Direction {
                Input,
                Output,
            }
            pub enum Enabled {
                Enabled,
                Disabled,
            }
            pub enum SignalLevel {
                High,
                Low,
            }
            pub struct GpioPort {
                raw: *mut GpioPortRaw,
            }
        

            impl GpioPort {
                pub fn set_direction(&self, direction: Direction) {
                    unsafe { write_volatile(
                        addr_of_mut!((*self.raw).output_enable),
                        match direction {
                            Direction::Input => 0,
                            Direction::Output => 1,
                        },
                    )};
                }
                pub fn set_pull_up(&self, enabled: Enabled) { /* ... */ }
                pub fn set_pull_down(&self, enabled: Enabled) { /* ... */ }
                pub fn set_output(&self, level: SignalLevel) { /* ... */ }
                pub fn get_input(&self) -> SignalLevel { /* ... */ }
            }
        

Becoming safer: &mut


        impl GpioPort {
            pub fn set_direction(&mut self, direction: Direction) { /* ... */ }
            pub fn set_pull_up(&mut self, enabled: Enabled) { /* ... */ }
            pub fn set_pull_down(&mut self, enabled: Enabled) { /* ... */ }
            pub fn set_output(&mut self, level: SignalLevel) { /* ... */ }
            pub fn get_input(&self) -> SignalLevel { /* ... */ }
        }
    

Becoming safer:

no Pull-up + Pull-down


        pub enum PullState {
            Float,
            PullUp,
            PullDown,
        }
        impl GpioPort {
            /* ... */
            pub fn set_pull_state(&mut self, state: PullState) {
                /* Set both pull up and pull down registers */
            }
            /* ... */
        }
    

Becoming safer: direction

  • Output + pulling rarely makes sense
    • Disable pulling on direction change
    • Disable setting pull when in output mode
    • Runtime cost
    • Runtime errors
  • Input + SetOutput will do nothing
    • What if I forget to set direction?
    • Runtime error?

Type states


            pub struct InputPort {
                raw: *mut GpioPortRaw,
            }
            impl InputPort {
                pub fn to_output(self) -> OutputPort{
                    self.set_pull_state(Float);
                    /* ... set direction register ... */
                    OutputPort{
                        raw: self.raw
                    }
                }

                pub fn get_level(&self) -> SignalLevel
                { /*...*/ }

                pub fn set_pull_state(
                    &mut self, state: PullState
                ) { /*...*/}
            }
        

            pub struct OutputPort {
                raw: *mut GpioPortRaw,
            }
            impl OutputPort {
                pub fn to_input(self) -> InputPort{
                    /* ... set direction register ... */
                    InputPort{
                        raw: self.raw
                    }
                }

                pub fn set_level(
                    &mut self, level: SignalLevel
                ) { /*...*/ }
            }
        

Type states: extra


            pub trait PullState {
                fn set_registers(raw: *mut GpioPortRaw);
            }

            pub struct Float;
            pub struct PullUp;
            pub struct PullDown;

            impl PullState for Float { /*...*/ }
            impl PullState for PullUp { /*...*/ }
            impl PullState for PullDown { /*...*/ }

            pub struct InputPort<PS: PullState> {
                raw: *mut GpioPortRaw,
                _ps: std::marker::PhantomData<PS>,
            }
        

            impl<PS: PullState> InputPort<PS> {
                pub fn get_level(&self) -> SignalLevel { /*...*/ }
                pub fn set_pull_state<PSN:PullState>(self)
                    -> InputPort::<PSN>
                {
                    PSN::set_registers(self.raw);
                    InputPort::<PSN> {
                        raw: self.raw,
                        _ps: std::marker::PhantomData::<PSN>{},
                    }
                }
                pub fn to_output(self) -> OutputPort{
                    let new_self = self.set_pull_state::<Float>();
                    /* ... */
                }
            }
        

Type states: generic functions


        pub fn get_smooth<PS:PullState>(port: InputPort<PS>)
            -> SignalLevel
        {
            const SAMPLES:u8 = 16;
            let mut highs = 0;
            for _ in 0..SAMPLES {
                if port.get_level() == SignalLevel::High {
                    highs += 1;
                }
            }
            if highs > SAMPLES/2 {
                SignalLevel::High
            } else {
                SignalLevel::Low
            }
        }
    

Type state erasure

  • If we need heterogeneous objects
    • Multiple different ports in a collection
    • Generic function without monomorphization
  • We can
    • Create an enum
    • or a multifunctional object
    • And add an erase() method to the specific structs

Trait or generic?

How to grant access to the port

  • Global mutable variables are unsafe
  • Singleton object (or cell)
  • Create exactly one on init and pass as parameter
    • Remember that structs can be "taken apart"

Selected projects

Ferrocene

  • Certified version of rustc
    • ASIL D
    • SIL 4
  • Unmodified upstream source
  • Long term support

heapless


            use heapless::Vec;
            let mut vec = Vec::<_, 8>::new();

            vec.push(1);
            vec.push(2);
            assert_eq!(vec.len(), 2);
            assert_eq!(vec[0], 1);

            assert_eq!(vec.pop(), Some(2));
            assert_eq!(vec.len(), 1);
            vec.extend(
                [1, 2, 3].iter().cloned()
            );
        
  • Standard containers using memory pools instead of allocation
  • Vec, String
  • Map, Set
  • Arc, Box
  • Queue

embedded-hal

  • De-facto standard embedded traits
  • Solves the NxM problem
  • Very limited
  • GPIO, SPI, I2C, PWM, CAN
  • No proper UART, interrupts, clocks

        <register>
          <name>CR</name>
          <description>Control Register</description>
          <addressOffset>0x00</addressOffset>
          <size>32</size>
          <access>read-write</access>
          <resetValue>0x00000000</resetValue>
          <resetMask>0x1337F7F</resetMask>

          <fields>
            <!-- EN: Enable -->
            <field>
              <name>EN</name>
              <description>Enable</description>
              <bitRange>[0:0]</bitRange>
              <access>read-write</access>
              <enumeratedValues>
    

svd2rust

  • Translates CMSIS System View Description files to Rust code
  • Automatically creates structs for memory-mapped peripherals
  • Basically bindgen for hw
  • Lots of generated board support crates
  • Cortex-M, MSP430, RISCV, Xtensa LX6

svd2rust


        let peripherals = stm32f407::Peripherals::take().unwrap();
        cortex_m::interrupt::free(|_cs| {
            // Power up the relevant peripherals
            peripherals.RCC.ahb1enr.write(|w| w.gpioden().set_bit());
            peripherals.RCC.apb1enr.write(| w| w.tim6en().set_bit());
            // Configure the pin PD12 as a pullup output pin
            peripherals.GPIOD.otyper.write(|w| w.ot12().clear_bit());
            peripherals.GPIOD.moder.write(|w| w.moder12().output());
            peripherals.GPIOD.pupdr.write(|w| w.pupdr12().pull_up());
            // Configure TIM6 for periodic timeouts
            let ratio = frequency::APB1 / FREQUENCY;
            let psc = u16((ratio - 1) / u32(U16_MAX)).unwrap();
            let arr = u16(ratio / u32(psc + 1)).unwrap();
            unsafe {
                peripherals.TIM6.psc.write(|w| w.psc().bits(psc));
                peripherals.TIM6.arr.write(|w| w.arr().bits(arr));
            };
            peripherals.TIM6.cr1.write(|w| w.opm().clear_bit());
        }
    

defmt


        defmt::error!("The answer is {=i16}!", 300);
        // on the wire: [3, 44, 1]
        //  string index ^  ^^^^^ `300.to_le_bytes()`
        //  ^ = intern("The answer is {=i16}!")

        defmt::error!("The answer is {=u32}!", 131000);
        // on the wire: [4, 184, 255, 1, 0]
        //                  ^^^^^^^^^^^^^^^ 131000.to_le_bytes()

        defmt::error!("Data: {=[u8]}!", [0, 1, 2]);
        // on the wire: [1, 3, 0, 1, 2]
        //  string index ^  ^  ^^^^^^^ the slice data
        //   LEB128(length) ^
    
  • "Deferred formatting"
  • Log data is very compressed, reader uncompresses it to show
  • String table stored in special elf section

        $ cargo run --release
          Compiling microbit v0.1.0 (/microbit/)
            Finished release [optimized + debuginfo] target(s) in 0.17s
            Running `probe-rs run --chip nRF51822_xxAA target/thumbv6m-none-eabi/release/microbit`
            Erasing sectors ✔ [00:00:00] [############] 5.00 KiB/5.00 KiB @ 8.09 KiB/s (eta 0s )
        Programming pages   ✔ [00:00:00] [############] 5.00 KiB/5.00 KiB @ 5.29 KiB/s (eta 0s )

        Hello from the microbit!
        Going to udf to print a stacktrace on the host ...

        Frame 0: exp_u128 @ 0x00000fa2
        Frame 1: __udf @ 0x000000f2 inline
              /.cargo/registry/src/index.crates.io-6f17d22bba15001f/cortex-m-0.7.7/src/../asm/inline.rs:181:5
        Frame 2: udf @ 0x00000000000000f2 inline
              /.cargo/registry/src/index.crates.io-6f17d22bba15001f/cortex-m-0.7.7/src/call_asm.rs:11:43
        Frame 3: panic @ 0x00000000000000f2
              /repos/microbit/examples/gpio-hal-blinky/src/main.rs:36:9
        Frame 4: exp_u128 @ 0x000006be
              /rustc/5680fa18feaa87f3ff04063800aec256c3d4b4be/library/core/src/ptr/const_ptr.rs:921:18
        Frame 5: exp_u128 @ 0x000006ec
              /rustc/5680fa18feaa87f3ff04063800aec256c3d4b4be/library/core/src/num/bignum.rs:299:60
        Frame 6: b @ 0x0000015e inline
    

probe-rs

  • CMSIS-DAP, JLink, ST-Link and FTDI
  • cargo run runs on hardware
  • Debug from GDB, VSCode, vim, etc., with breakpoints, local variable monitoring, etc.
  • RTT and defmt integration

C RTOS support

  • C bindings exist for popular RTOS-es
    • FreeRTOS
    • Zephyr
    • RIOT (native support)
  • Rust modules + C frame always works
  • Rust as entry point sometimes works

Should you use Rust in your commercial embedded project?

But only if you find good crates first!

RTIC


    #[task(local = [led, state])]
    async fn blink(cx: blink::Context) {
        loop {
            rprintln!("blink");
            if *cx.local.state {
                cx.local.led.set_high().unwrap();
                *cx.local.state = false;
            } else {
                cx.local.led.set_low().unwrap();
                *cx.local.state = true;
            }
            Mono::delay(1000.millis()).await;
        }
    }
    
  • Based on "Stack Resource Policy" scheduling (1991)
  • Scheduling by interrupt vector
  • Tasks run to completion in interrupt handler
  • 2 phases: init + tasks
  • Resources: local + shared

        #[embassy_executor::task]
        async fn blink(pin: AnyPin) {
            let mut led = Output::new(pin, Level::Low, OutputDrive::Standard);
            loop {
                led.set_high();
                Timer::after_millis(150).await;
                led.set_low();
                Timer::after_millis(150).await;
            }
        }

        #[embassy_executor::main]
        async fn main(spawner: Spawner) {
            let p = embassy_nrf::init(Default::default());
            spawner.spawn(blink(p.P0_13.degrade())).unwrap();
            let mut button = Input::new(p.P0_11, Pull::Up);
            loop {
                button.wait_for_low().await;
                info!("Button pressed!");
                button.wait_for_high().await;
                info!("Button released!");
            }
        }
    

Embassy

  • Basically embedded tokio + a HAL
  • Single stack
  • No dynamic memory
  • Async executor uses interrupts => very low power
  • USB and network stack

Chips with good Rust support

  • ESP32 (both ARM and Xtensa)
  • STM32
  • ATmega328P
  • Nordic nRF52
Image by Barnaby Walters

Summary

  • Rust is not ready
    for embedded use-cases
  • Rust is kind of ready
    for embedded use-cases
  • Rust's type system is pretty cool

  • Reach me at
    alex@voidcomputing.hu


Special thanks to Yuliia for all her help