Introduction

Embedded software developers inevitably make mistakes. This is the logical implication from the fact that a) embedded software developers are human and b) humans inevitably make mistakes. Still, some in the software development community argue that "if you can't safely program in C or C++, then you shouldn't be programming at all." This stance is untenable, as even experienced programmers have made costly or deadly errors.

However, there is hope for improvement.

Since becoming stable in 2015, the Rust programming language has been the most loved language in the Stack Overflow Developer Survey for eight consecutive years. More people are recognizing a simple truth: compilers can check certain forms of correctness better than humans can. Rust, a modern systems programming language, implements these checks and therefore offers unique advantages for embedded systems concerning safety, performance and reliability.

Embedded systems are the backbone of modern technology, powering everything from household appliances to advanced medical devices. Traditionally, languages like C and C++ have been used for embedded firmware due to their performance and low-level hardware access. However, these languages pose challenges, especially regarding safety and concurrency. Both Rust and embedded systems are sometimes feared for their complexity. The former's perceived difficulty often stems from misunderstanding the compiler as an adversary rather than an ally. The latter's complexity is partly due to the intricate real-world interactions they manage, as well as subpar tools and prerequisites.

In this series of articles, we aim to demonstrate how Rust can simplify and reduce the costs of developing embedded systems - and be fun at the same time!

Why Choose Embedded Rust?

Let's dig deeper into how these advantages make Rust perfect for embedded development.

1. Safety

Safety is paramount in embedded systems, which are often considered safety-critical applications, such as medical devices or automotive systems. Rust's strong focus on safety makes it a natural choice for these applications. But even less critical devices need more focus on safety and security , especially when they have interfaces that connect them to the internet.

Memory and Thread Safety

Rust achieves memory safety without a garbage collector, which is crucial for performance-constrained environments. It's ownership model ensures that typical memory errors like null pointer dereferencing, buffer overflows and data races are caught at compile time. This is a significant improvement over C/C++, where errors often lead to unpredictable behavior and security vulnerabilities, and they can be hard to spot.

For example, have a look at the following code snipped where we try performing a potentially unsafe operation by passing a non-atomic reference counted pointer to another thread:

// Reference counted pointer holding the value 42.
let rc = std::rc::Rc::new(42);
// The following line causes a compiler error because `Rc` is not marked as `Send`
// error[E0277]: `Rc<i32>` cannot be sent between threads safely
std::thread::spawn(move || { println!("rc: {}", rc); });

Unsafe Code

The compiler is not always able to determine whether an operation is safe. In some cases, especially when dealing with memory-mapped peripherals or direct memory access (DMA), the compiler cannot determine if an operation is safe.

To solve this problem, Rust allows you to write unsafe code. That is, code marked with the unsafe keyword, which is allowed to perform all operations that potentially violate Rust's safety promise. Since these parts of the code are especially marked, they are easy to find and audit thoroughly. But fear not: this is usually done inside the hardware abstraction layer (HAL) crate.

As an example, let us initialize the SDRAM connected to our microcontroller. This is done by a driver or the HAL, which returns a start-address of that memory region. To use that slice of memory safely later on, we can let Rust unsafely create a memory slice out of thin air (actually, out of the SDRAM). This slice can now be used without any more unsafe code, just like a regular slice:

let ram_ptr: *mut u32 = sdram.init() as *mut _;
// Unsafe! You should know what you're doing...
let ram_slice = unsafe {
  // Convert raw pointer to slice, taking the start address and the length
  core::slice::from_raw_parts_mut(ram_ptr, length_in_bytes)
};

// Use like ordinary Rust slice / array. Safe from here!
info!("RAM contents: {:x}", ram_slice[..]);

Strong Type System

The type system is not typically considered a "safety" feature, but in embedded systems, it enhances safety and reliability. The "trick" is to use the type system, especially traits, to translate hardware features into code. From the Rust book: "A trait defines functionality a particular type has and can share with other types."

Let's have a look at the following code snippet. Let our microcontroller's pin PA9 have the ability to function as a clock pin for SPI. How can we translate this ability into code?

First, we define a (marker) trait ClkPin. It does not have any further functionality (though it could) than indicating that some type may behave as a clock pin.

Second, implement this trait for our pin PA9. For the sake of simplicity, the pin is just an empty struct. In practice, the pin and (some of) it's traits are provided by the HAL.

Now we can define a function that accepts only pins as parameters that implement the ClkPin trait. Trying to a variable whose type is not marked as ClkPin, say struct PA8{} will produce a compiler error.

pub trait ClkPin {} // Marker trait
struct PA9 {} // Pin PA9
impl ClkPin for PA9 {} // PA9 is a Clock Pin (e.g. for SPI)

// This function only works for Clock Pins
fn take_clk_pin(pin: impl ClkPin){
    println!("Got a ClkPin!");
}

fn main() {
    let pin = PA9{};
    take_clk_pin(pin);
}

This example is not very useful by itself, but the concept allows for building super-reliable and easy to use HALs and drivers: An SPI driver may accept as a clock pin only a pin that implements the ClkPin trait. And as a CS pin it may only accept a pin implementing the CsPin trait and so on. The HAL's responsibility is to mark the correct pins with the ClkPin or CsPin traits according to the datasheet.

If you accidentally provide the wrong pin to the driver, the compiler will give you an error instead of mis-configuring the SPI device and potentially crashing your program at runtime.

2. Performance

Performance is a critical consideration in embedded systems, where resources are often limited and efficiency is paramount. Rust provides several features that contribute to high performance without compromising safety.

Zero-Cost Abstractions

Rust's zero-cost abstractions ensure that high-level features do not introduce overhead. This means you can write idiomatic Rust code using abstractions like iterators, smart pointers and zero-sized types without sacrificing performance. The compiler optimizes these abstractions away, generating code that is as efficient as hand-written C or C++.

For example, consider the following Rust code that calculates the sum of elements in an array using an iterator:

let data = [1, 2, 3, 4, 5];
let sum: i32 = data.iter().sum();

The iterator abstraction in this code is optimized away by the compiler, resulting in assembly code that is just as efficient as a manually written loop.

In embedded Rust, zero-sized types are commonly used as zero-cost abstractions. For example, peripherals can be represented by an empty struct, such as struct SPI {}.

By representing a peripheral as a struct, like SPI, we can pass it to drivers or other functions that then have an explicitly defined type of control (exclusive ownership, shared reference, ...) of the peripheral.

The following example illustrates how an instance zero-sized struct can be passed to a function only once. Care must only be taken that no second instance of the SPI struct can be created.

    struct SPI {} // No data -> size = 0 bytes  
    
    fn spi_driver(spi: SPI) {
        // Freely manipulate any SPI registers, nobody else controls the SPI
    }
    
    // This is usually obtained from the HAL,
    // which ensures only one instance of SPI exists,
    // e.g., `let spi = peripherals.SPI`
    let spi = SPI {};
    
    spi_driver(spi);
    
    // This won't work. No other driver can be create from the same SPI.
    spi_driver(spi); // error[E0382]: use of moved value: `spi`

Low-Level Control

Rust provides low-level control over system resources, similar to C and C++, but with the added benefits of safety and modern syntax. You can directly manipulate memory, control registers and perform bitwise operations when needed.

For instance, stm32-metapac (part of the Embassy embedded framework) let's you easily modify single bits of a hardware register:

// Set the ENable bit in the Control Register to true
T::regs().cr().modify(|w| w.set_en(true));

Even though the register access is abstracted for convenience, the code below performs low-level memory manipulation: modifying hardware register without any additional overhead. This ensures that your code remains efficient.

Inline Assembly

For performance-critical sections where direct control over the generated machine code is necessary, Rust allows you to use inline assembly. This feature allows you to write assembly code within your Rust program, providing maximum control over performance.

unsafe {
    asm!(
        "mov r0, #0",
        "mov r1, #1",
        // More assembly instructions
    );
}

Fearless Concurrency

Concurrency is crucial in embedded systems, where tasks often need to run simultaneously or asynchronously to meet real-time constraints. Rust's concurrency model is built on top of its ownership system, providing safe and efficient concurrency primitives.

Rust eliminates data races at compile time, ensuring that concurrent code is safe by design. The ownership and borrowing rules enforce that data cannot be accessed mutably from multiple threads simultaneously, preventing common concurrency issues.

Multi-Core Concurrency

In systems with multiple CPU cores, concurrency allows for parallel execution of tasks, making efficient use of available hardware resources. This is particularly useful for CPU-bound tasks where parallelism can lead to significant performance improvements. Supported by the standard library or purpose-built libraries, Rust's concurrency model ensures that data races are prevented, even with multiple threads, making it easier to write safe concurrent code compared to traditional languages like C/C++.

Consider the following example where multiple threads increment a shared counter:

use std::sync::{Arc, Mutex};
use std::thread;

// This example uses an atomic reference-counted pointer (Arc) holding a mutex.
// Alternatively, you could use atomic types for certain use cases.
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..10 {
    // Get a copy of the pointer that can be passed to the thread
    let counter = Arc::clone(&counter);
    let handle = thread::spawn(move || {
        // Blocking wait for a lock on the mutex
        let mut num = counter.lock().unwrap();
        *num += 1;
    });
    handles.push(handle);
}

// Wait for all threads to finish
for handle in handles {
    handle.join().unwrap();
}

println!("Result: {}", *counter.lock().unwrap());

In this example, the Mutex ensures that only one thread can increment the counter at a time, and the Arc (atomic reference counting) enables safe sharing of the counter across threads. This demonstrates how Rust's concurrency primitives can be used effectively for multi-core systems.

Single-Core Asynchronous Programming with async/await

In single-core systems, where parallelism is not possible, asynchronous programming enables efficient task management and enhances responsiveness.

For single-core systems, Rust's async/await syntax allows for efficient asynchronous programming, enabling you to write non-blocking code that can handle multiple tasks concurrently. This is particularly useful in embedded systems for tasks such as handling I/O operations without blocking the main execution.

In embedded systems, concurrency often involves managing multiple peripherals and handling interrupts efficiently. Rust's concurrency model, combined with the embassy crate, makes it straightforward to manage these tasks.

One very educational example of how async/await can be used to control the blinking interval of an LED using a button is found in the embassy repository.

Simple tasks like waiting for the falling edge on an input would traditionally require either polling the pin state or using a custom interrupt. Using async/await, this can be nicely abstracted so you can let your program wait for the falling edge as simply as:

// Do something before waiting
input.wait_for_falling_edge().await;
// Do something after the falling edge occurred

Another commonly needed feature is delaying the program by a specific amount of time. This is also easily done using async/await and timers:

use embassy_time::Timer;

async fn async_delay_example() {
    // Do something before waiting
    // Delay for 500 ms. Other tasks may run in the meantime!
    Timer::after_millis(500).await;
    // Do something after waiting
}

Asynchronous programming on a single core allows for efficient task management without the overhead of context switching associated with threads. It maximizes CPU utilization by allowing the processor to switch between tasks that are waiting (e.g., for I/O operations), thereby improving overall system responsiveness.

By leveraging both multi-core concurrency and single-core asynchronous programming, Rust empowers embedded developers to write efficient, safe, and responsive applications capable of handling the complexities of modern embedded systems.

3. Productivity

One of Rust's significant advantages is the simplicity of setting up its toolchain and the helpful compiler messages.

While most other C/C++ projects take new developers a few hours or days to compile, a Rust project with the correct configuration can set itself up. rustup will download and install the specified toolchain, and cargo, the build system and package manager, will handle the rest until your program is running.

Toolchain Management

Rust's toolchain manager rustup makes it easy to download and install the correct toolchain for any project. To manually install a toolchain and a compilation target, you can use the following commands:

$ rustup toolchain install 1.79.0-x86_64-apple-darwin # for macOS
$ rustup target add thumbv7em-none-eabihf # e.g. Cortex-M4F, Cortex-M7F (FPU)

Alternatively, you can use a rust-toolchain.toml file in your project root:

# "rust-toolchain.toml" file in project root
[toolchain]
channel = "1.79"
targets = [
    "thumbv7em-none-eabihf",
]

This file provides a clear definition of the required toolchains and targets, which can be checked into your version control system. Thus, other developers can simply clone the repository and have rustup automatically set up the toolchain as part of the compilation process.

Package Management

Rust's build system and package manager cargo downloads and builds your project's dependencies and manages the compilation of your program. But that is not all.

Cargo is a one-stop-shop for nearly everything you need (and more as it can be extended!):

Of course, cargo is not single, massive program. Most of the time, it simply abstracts away the pain of calling other programs (like the Rust compiler rustc) with the correct parameters. It is a bit like a full-featured Makefile you never had to write.

State of C/C++

While similar tools exist for C/C++ projects, they are often not as well-integrated as the ones for Rust. Developers need to manage compilers, linkers and debuggers from various vendors, which can lead to compatibility issues, a steep learning curve - and a lot of frustration. In contrast, Rust's ecosystem, driven by Cargo, provides a unified and consistent experience, significantly reducing setup time and complexity.

Example: Setting Up a Simple Embedded Rust Project

Setting up an embedded Rust project is straightforward. Let's walk through the steps to create a simple "blinky" example, where an LED on a microcontroller blinks on and off. This is like the "Hello World" of embedded development.

The example is meant to run on an STM32F469NI microcontroller but is easy to adapt to other STM32 controllers.

Step 1: Install Rust

First, install Rust using rustup, see www.rust-lang.org/learn/get-started

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

This command downloads and installs the Rust toolchain, including rustc (the Rust compiler), cargo (the Rust package manager), and other essential tools.

Step 2: Add the Target for Your Microcontroller

Next, you need to add the appropriate compilation target for your microcontroller. Rust is able to cross-compile for many platforms due to its LLVM compiler backend.

For the microcontroller in this example we will use thumbv7em-none-eabihf:

rustup target add thumbv7em-none-eabihf

More information for other Cortex-M processors can be found in the cortex-m-quickstart template.

Step 3: Create a New Cargo Project

Create a new Rust binary project using Cargo:

cargo new --bin blinky
cd blinky

Step 4: Add Dependencies

Edit the Cargo.toml file to include dependencies for embedded development:

[dependencies]
# Embassy's STM32 HAL, selecting our chip with the "stm32f469ni" feature and let embassy generate a memory layout file with "memory-X"
embassy-stm32 = { version = "0.1.0", features = ["defmt", "stm32f469ni", "unstable-pac", "memory-x", "time-driver-any", "exti", "chrono"] }
# Embassy's async executor, don't care about the specific features for now
embassy-executor = { version = "0.5.0", features = ["task-arena-size-32768", "arch-cortex-m", "executor-thread", "executor-interrupt", "defmt", "integrated-timers"] }
# Embassy's time crate provides delays and an uptime
embassy-time = { version = "0.3.1", features = ["defmt", "defmt-timestamp-uptime", "tick-hz-32_768"] }

# defmt is a very lightweight logger crate
defmt = "0.3"
# Transfer logs from the MCU to the PC via RTT
defmt-rtt = "0.4"

# Support crate for ARM Cortex-M processors, provides a critical section implementation for single-core MCUs via the feature
cortex-m = { version = "0.7.6", features = ["inline-asm", "critical-section-single-core"] }
# Minimal runtime for ARM Cortex-M processors
cortex-m-rt = "0.7.0"

# "Panic handler that exits probe-run with an error code"
panic-probe = { version = "0.3", features = ["print-defmt"] }

Step 5: Write the Code

Now, let's write a simple program to blink an LED. Open the src/main.rs file and replace its contents with the following:

#![no_std]
#![no_main]

use embassy::executor::Spawner;
use embassy::time::{Duration, Timer};
use embassy_stm32::gpio::{Level, Output, Speed};
use embassy_stm32::init;

// Make sure these crates are imported
use {defmt_rtt as _, panic_probe as _};

#[embassy::main]
async fn main(spawner: Spawner) {
    let p = init(Default::default());

    // Choose whatever pin is attached to your LED. On the STM32F469 Discovery Kit one LED is on PG6
    let mut led = Output::new(p.PG6, Level::Low, Speed::Low);

    loop {
        led.set_high();
        Timer::after(Duration::from_millis(500)).await;
        led.set_low();
        Timer::after(Duration::from_millis(500)).await;
    }
}

Step 6: Build and Run

Finally, build and flash your program to the microcontroller using cargo and probe-rs.

To compile the program, we use cargo:

cargo build # for debug build, usually more logging available, or
cargo build --release # for full optimization and minimum size

To copy the binary from the Host PC to the MCU, we will use probe-rs.

The latest installation instructions are found on the tool's website. At the time of writing, probe is installed like this:

On macOS and Linux:

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh | sh

On Windows:

irm https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.ps1 | iex

Now we can flash the MCU and run our program:

probe-rs run --chip STM32F469NIHx target/thumbv7em-none-eabi/debug/blinky

Conclusion

Rust's emphasis on safety, concurrency and performance makes it an excellent choice for embedded systems development.

The toolchain and package managers rustup and cargo provide a great developer experience, leading to happy developers and rapid development cycles. This makes Rust a powerful alternative to traditional languages like C and C++. In the next part of this series, we will delve into the current state of the Rust ecosystem for embedded development and hightlight essential crates and tools that make Rust a practical choice for embedded projects.