Part 1: An Introduction to Embedded Rust and Its Advantages
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 = new;
// The following line causes a compiler error because `Rc` is not marked as `Send`
// error[E0277]: `Rc<i32>` cannot be sent between threads safely
;
spawn
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 unsafe
ly 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 ;
// Use like ordinary Rust slice / array. Safe from here!
info!;
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.
// Marker trait
// Pin PA9
// PA9 is a Clock Pin (e.g. for SPI)
// This function only works for Clock Pins
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 = ;
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.
// No data -> size = 0 bytes
// 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;
// This won't work. No other driver can be create from the same SPI.
spi_driver; // 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
.cr.modify;
regs
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
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 ;
use 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 = new;
let mut handles = vec!;
for _ in 0..10
// Wait for all threads to finish
for handle in handles
println!;
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 Timer;
async
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:
Alternatively, you can use a rust-toolchain.toml
file in your project root:
# "rust-toolchain.toml" file in project root
[]
= "1.79"
= [
"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!):
- Creating a new project:
cargo new
- Adding new dependencies:
cargo add <dep>
- Checking for errors:
cargo check
- Compiling:
cargo build
- Running:
cargo run
(can be customized to run the flashing tool and attaching the console) - Cleaning up:
cargo clean
- Creating HTML documentation:
cargo doc
- And many more...
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
|
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
:
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:
Step 4: Add Dependencies
Edit the Cargo.toml
file to include dependencies for embedded development:
[]
# Embassy's STM32 HAL, selecting our chip with the "stm32f469ni" feature and let embassy generate a memory layout file with "memory-X"
= { = "0.1.0", = ["defmt", "stm32f469ni", "unstable-pac", "memory-x", "time-driver-any", "exti", "chrono"] }
# Embassy's async executor, don't care about the specific features for now
= { = "0.5.0", = ["task-arena-size-32768", "arch-cortex-m", "executor-thread", "executor-interrupt", "defmt", "integrated-timers"] }
# Embassy's time crate provides delays and an uptime
= { = "0.3.1", = ["defmt", "defmt-timestamp-uptime", "tick-hz-32_768"] }
# defmt is a very lightweight logger crate
= "0.3"
# Transfer logs from the MCU to the PC via RTT
= "0.4"
# Support crate for ARM Cortex-M processors, provides a critical section implementation for single-core MCUs via the feature
= { = "0.7.6", = ["inline-asm", "critical-section-single-core"] }
# Minimal runtime for ARM Cortex-M processors
= "0.7.0"
# "Panic handler that exits probe-run with an error code"
= { = "0.3", = ["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:
use Spawner;
use ;
use ;
use init;
// Make sure these crates are imported
use ;
async
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:
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:
|
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:
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.