The Embedded Rust Book

repository·master·Indexed 23 days ago

https://github.com/rust-embedded/book

A comprehensive guide for developers using Rust for bare-metal firmware development on microcontrollers. It covers the embedded crate hierarchy (BSP, HAL, PAC), hardware protocols, and provides tips for transitioning from C, including the use of Cargo features, const fn, volatile access via core::ptr, and memory layout control with repr attributes. The book also details memory management options using the alloc crate for heap-allocated collections versus the heapless crate for fixed-capacity collections.

Tokens
34.8K
Snippets
83
Records
153
Agent score
79%

What's inside The Embedded Rust Book

  1. Overview of Embedded Concurrency Frameworks

    master

    Beyond manual Mutex<RefCell<Option<T>>> patterns, several frameworks provide more efficient or ergonomic concurrency models:

    RTIC (Real Time Interrupt-driven Concurrency)

    RTIC uses static priorities to manage resource access. It tracks access to static mut variables (resources) at compile time, ensuring safety without the runtime overhead of RefCell or constant critical sections. It also supports:

    • async tasks via an asynchronous executor.
    • Message passing.
    • Scheduled tasks.

    Embassy

    Embassy is an ecosystem focused on Rust's async/await syntax. It provides:

    • An asynchronous executor supporting most MCU architectures.
    • A Time library.
    • Various HAL libraries.
    • embassy-sync for synchronization primitives.

    RTOS (Real-Time Operating Systems)

    Traditional models like FreeRTOS or ChibiOS use threads and multitasking:

    • Cooperative multitasking: Threads yield control.
    • Preemptive multitasking: The OS swaps threads based on timers or interrupts.

    Multi-core Concurrency

    On multi-core systems, single-core critical sections (like cortex_m::interrupt::Mutex) are insufficient. You must use synchronization primitives designed for SMP (Symmetric Multi-Processing), which typically rely on atomic instructions to maintain atomicity across all cores.

  2. Overview of The Embedded Rust Book

    master
    The Embedded Rust Book is a living document providing guidance on using the Rust programming language to develop firmware for bare metal (microcontroller) devices. It is maintained by the Rust Embedded Resources team.
  3. HAL Design Patterns for Microcontrollers

    master

    When writing Hardware Abstraction Layers (HALs) for microcontrollers in Rust, follow these recommended design patterns to ensure consistency, safety, and usability. These patterns complement the standard Rust API Guidelines.

    Key areas covered by these patterns include:

    • Naming: Standardizing how hardware components and methods are named.
    • Interoperability: Ensuring your HAL works well with the broader Rust embedded ecosystem (e.g., embedded-hal).
    • Predictability: Designing APIs that behave in expected ways for embedded developers.
    • GPIO: Specific patterns for managing General Purpose Input/Output pins.
  4. Hardware specifications for the STM32F3DISCOVERY board

    master

    The STM32F3DISCOVERY (the "F3") board is the primary hardware used in this book. It features a main microcontroller and an integrated programmer/debugger.

    Main Microcontroller: STM32F303VCT6

    • Processor: Single-core ARM Cortex-M4F (supports single-precision floating point) @ 72 MHz.
    • Flash Memory: 256 KiB.
    • RAM: 48 KiB.
    • Peripherals: Timers, I2C, SPI, USART, and GPIO pins accessible via side headers.
    • USB: Mini-USB interface via the "USB USER" port.

    Integrated Sensors and Components

    • LSM303DLHC Chip: Contains both an accelerometer and a magnetometer.
    • L3GD20 Chip: Contains a gyroscope.
    • LEDs: 8 user LEDs arranged in a compass shape.

    On-board Programmer/Debugger

    • Microcontroller: STM32F103.
    • Connection: Connected via the Mini-USB port labeled "USB ST-LINK".
  5. What is Typestate Programming?

    master

    Typestate programming is a design pattern where the current state of an object is encoded directly into its type. This allows you to use Rust's type system to enforce valid state transitions and prevent invalid operations at compile time.

    In this pattern, different stages of an object's lifecycle are represented by different structs. For example, a Builder struct might represent an "unconfigured" state, while the final product struct represents a "ready to use" state. You transition between these states by calling methods that consume the current type and return a new type.

    // A simplified example of state transition via types
    pub mod foo_module {
        pub struct Foo {
            inner: u32,
        }
    
        pub struct FooBuilder {
            a: u32,
            b: u32,
        }
    
        impl FooBuilder {
            pub fn new(starter: u32) -> Self {
                Self { a: starter, b: starter }
            }
    
            pub fn double_a(self) -> Self {
                Self { a: self.a * 2, b: self.b }
            }
    
            pub fn into_foo(self) -> Foo {
                Foo { inner: self.a + self.b }
            }
        }
    }
    
    fn main() {
        // The type changes from FooBuilder -> Foo through method calls
        let x = foo_module::FooBuilder::new(10)
            .double_a()
            .into_foo();
    
        println!("{:#?}", x);
    }
  6. Representing hardware registers in Rust

    master

    Hardware peripherals are typically controlled by writing to memory-mapped registers. A common way to expose this in Rust is to create a wrapper structure around a peripheral register block (often generated by tools like svd2rust).

    While this approach provides a convenient API for modifying individual bits or fields, it has a significant drawback: it does not enforce the hardware's state machine logic. A simple bit-manipulation API allows a user to set fields that are invalid for the current mode (e.g., setting an output_mode while the pin is configured as an input), which can lead to undefined behavior on some hardware.

    /// GPIO interface
    struct GpioConfig {
        /// GPIO Configuration structure generated by svd2rust
        periph: GPIO_CONFIG,
    }
    
    impl GpioConfig {
        pub fn set_enable(&mut self, is_enabled: bool) {
            self.periph.modify(|_r, w| {
                w.enable().set_bit(is_enabled)
            });
        }
    
        pub fn set_direction(&mut self, is_output: bool) {
            self.periph.modify(|_r, w| {
                w.direction().set_bit(is_output)
            });
        }
    
        pub fn set_input_mode(&mut self, variant: InputMode) {
            self.periph.modify(|_r, w| {
                w.input_mode().variant(variant)
            });
        }
    
        pub fn set_output_mode(&mut self, is_high: bool) {
            self.periph.modify(|_r, w| {
                w.output_mode.set_bit(is_high)
            });
        }
    
        pub fn get_input_status(&self) -> bool {
            self.periph.read().input_status().bit_is_set()
        }
    }
  7. Erase pin and port types for runtime flexibility

    master

    While ZSTs are efficient for static assignments, applications often require runtime flexibility (e.g., passing pins to functions that don't know the specific pin at compile time). HALs should provide type erasure methods to move pin properties from compile-time to runtime.

    Typically, this involves a hierarchy of erasure:

    1. erase_pin(): Converts a specific pin (e.g., PA0) into a generic port-pin type (e.g., PA).
    2. erase_port(): Converts a port-pin type into a fully generic Pin type that tracks both port and pin number at runtime.
    /// Port A, pin 0.
    pub struct PA0;
    
    impl PA0 {
        pub fn erase_pin(self) -> PA {
            PA { pin: 0 }
        }
    }
    
    /// A pin on port A.
    pub struct PA {
        /// The pin number.
        pin: u8,
    }
    
    impl PA {
        pub fn erase_port(self) -> Pin {
            Pin {
                port: Port::A,
                pin: self.pin,
            }
        }
    }
    
    pub struct Pin {
        port: Port,
        pin: u8,
        // (these fields can be packed to reduce the memory footprint)
    }
    
    enum Port {
        A,
        B,
        C,
        D,
    }
  8. Understand memory-mapped peripherals and linear address space

    master

    Microcontrollers typically use a real and linear address space (e.g., 0x0000_0000 to 0xFFFF_FFFF for 32-bit systems) without the virtual memory re-mapping provided by an MMU (Memory Management Unit) found in desktop systems.

    In this linear space, different regions are assigned to different hardware components:

    • RAM: Located at specific address ranges (e.g., 0x2000_0000).
    • Flash ROM: Located at specific address ranges (e.g., 0x0000_0000).
    • Peripherals: Instead of leaving the gaps between RAM and Flash empty, designers map peripheral interfaces to these remaining memory locations.

    To interact with a peripheral, you write data to a specific memory address. This address acts as a hardware API. For example, writing a specific bit pattern to a configuration register address will change the hardware's behavior (like setting a SPI frequency) instead of storing data in RAM.

  9. Provide a destructor method for non-Copy wrapper types

    master

    When designing a Hardware Abstraction Layer (HAL), any non-Copy wrapper type (a type that wraps a raw peripheral) should provide a free method. This method must consume the wrapper and return the original raw peripheral (and any other non-Copy objects used in its construction, such as I/O pins) as a tuple.

    Key requirements for free:

    1. Consumption: It must consume self.
    2. State Reset: It should shut down and reset the peripheral if necessary.
    3. Reusability: Calling new with the raw peripheral returned by free should not fail due to an unexpected peripheral state.
    pub struct TIMER0;
    pub struct Timer(TIMER0);
    
    impl Timer {
        pub fn new(periph: TIMER0) -> Self {
            Self(periph)
        }
    
        pub fn free(self) -> TIMER0 {
            self.0
        }
    }
  10. Enforcing state transitions with Strong Types

    master

    By leveraging Rust's strong type system, you can ensure that certain actions are only possible when an object is in a specific state.

    Key mechanics include:

    • Type-based restrictions: Because Foo and FooBuilder are distinct types, a user cannot accidentally use a FooBuilder where a Foo is required.
    • Consuming transitions: Methods that transition states (like into_foo(self)) should take self by value. This consumes the previous state, preventing the user from reusing an "unconfigured" object once it has been converted to a "configured" state.
  11. How Send and Sync relate to embedded concurrency

    master

    In Rust, concurrency safety is governed by two marker traits:

    • Send: A type is Send if it can safely be moved to another thread.
    • Sync: A type is Sync if it can be safely shared between multiple threads.

    In an embedded context, interrupts are considered separate threads from the main application code. Therefore, any variable accessed by both an interrupt and the main code must implement Sync.

    Types containing UnsafeCell are not Sync by default. To use such types in a static variable (which must be Sync), you must explicitly implement Sync after ensuring your access patterns (e.g., via critical sections) prevent data races.

  12. Implement zero-cost GPIO abstractions using zero-sized types

    master

    To achieve zero-cost GPIO abstractions when pin assignments are known at compile time, HALs should provide dedicated zero-sized types (ZSTs) for every pin on every port. A common pattern is to have the Port implement a split method that consumes the port and returns a struct containing all individual pin instances.

    This ensures that using specific pins does not incur runtime memory or performance overhead.

    pub struct PA0;
    pub struct PA1;
    // ...
    
    pub struct PortA;
    
    impl PortA {
        pub fn split(self) -> PortAPins {
            PortAPins {
                pa0: PA0,
                pa1: PA1,
                // ...
            }
        }
    }
    
    pub struct PortAPins {
        pub pa0: PA0,
        pub pa1: PA1,
        // ...
    }