Introduction

In the first part of this series, we discussed the advantages of using Rust for embedded systems development. Now, let's delve into the current state of the embedded Rust ecosystem. The Rust ecosystem for embedded systems is already very rich and still growing, offering a variety of tools and libraries (called "crates") that facilitate embedded development. This is one of the main reasons that embedded Rust development is so much fun!

This second part of the series provided an overview of how the embedded Rust ecosystem is organized, some essential crates and useful tools for embedded Rust programming.

Terminology

There are some important terms you will come across as you use Rust for embedded development. If you are already familiar with these, you may skip this list.

Ecosystem Overview

The Rust embedded ecosystem consists of two parts: official and unofficial crates.

The official ones are maintained by the Rust Embedded Working Group. It is a community-driven initiative aimed at making Rust a great choice for embedded development. This group maintains a list of key crates, provides guidelines and ensures that the ecosystem evolves in a cohesive and integrated manner. It also standardizes commonly used interfaces to allow for platform-agnostic driver development. The most commonly used crate providing such interfaces is the embedded-hal and its associated crates.

The unofficial ones are developed by kind people around the world that share their libraries, drivers and tools with other users. Their maintenance depends on the individual developer's time and capacity and there are no guarantees about long-term support provided for any of them. There are some guarantees for most of them, e.g., "guaranteed to compile with a compiler version X or newer". Still, they make up for the largest part of the ecosystem and most of them are in a very good shape.

Core Crates

Several crates form the foundation of embedded Rust programming. Some of them are inherent to Rust and some are developed and maintained by the Rust Embedded Working Group. These crates include but are not limited to:

When compiling bare-metal programs, there is no operating system available that provides many features of the standard library. Therefore, the Rust standard library is not available in bare-metal programs. If you are running embedded Linux, however, you might be able to use it.

cortex-m and cortex-m-rt

For the widespread ARM Cortex-M microcontrollers, there are two essential crates for low-level interaction.

Usually, the HAL will make use of them, and you do not often have to use them directly.

For example, use the cortex-m to set a Systick timer:

use cortex_m::peripheral::syst::SystClkSource;
use cortex_m::peripheral::Peripherals;

let p = Peripherals::take().unwrap();
let mut syst = p.SYST;

// Configure the system timer to trigger a SysTick exception every second
syst.set_clock_source(SystClkSource::Core);
syst.set_reload(16_000_000); // Assume 16 MHz clock
syst.enable_counter();
syst.enable_interrupt();

With the cortex-m-rt crate, you can define the program entry point:

#![no_std]
#![no_main]

use cortex_m_rt::entry;

#[entry]
fn main() -> ! {
    // Your initialization code here

    loop {
        // Main loop
    }
}

Note: cortex-m-rt also allows you to define a #[pre_init] function that will be called at the beginning of the reset handler. For details, please refer to the crate documentation.

embedded-hal

The previously mentioned embedded-hal (HAL = Hardware Abstraction Layer) crate defines a set of traits that serve as a common interface for various hardware components like digital I/O pins, I2C, SPI, and more. This allows for writing device drivers that are platform-agnostic.

For example, implementing a driver for an I2C sensor might look like this:

// Original Source: https://docs.rs/embedded-hal/1.0.0/embedded_hal/i2c/index.html

use embedded_hal::i2c::{I2c, Error};

const ADDR: u8 = 0x15;
// Generic type: I2C
pub struct TemperatureSensorDriver<I2C> {
    i2c: I2C,
}

// Implementation constrains the generic I2C type to implement embedded-hal's I2c trait
impl<I2C: I2c> TemperatureSensorDriver<I2C> {
    // Moves the I2C peripheral into the struct to ensure exclusive ownership
    pub fn new(i2c: I2C) -> Self {
        Self { i2c }
    }

    // This requires a mutable reference because it writes to the I2C interface
    pub fn read_temperature(&mut self) -> Result<u8, I2C::Error> {
        let mut temp = [0];
        self.i2c.write_read(ADDR, &[TEMP_REGISTER], &mut temp)?;
        Ok(temp[0])
    }
}

Now you can use this driver on any platform that has an implementation of embedded-hal's I2c trait. Usually, the HAL will provide such an implementation.

The embedded-hal project contains many more abstraction layer crates like embedded-can for CAN, embedded-hal-async for async interfaces, embedded-hal-bus for sharing peripherals like SPI and I2C among multiple drivers and embedded-io for a no_std alternative to std::io.

Embassy

Embassy (name: Embedded + Async) is a modern Rust framework for embedded systems. It provides multiple crates ranging from HALs for STM32, nRF or Raspberry Pi controllers to timer abstractions, async executors and synchronization primitives. It leverages Rust's async/await syntax to provide efficient, non-blocking I/O operations.

The following is a non-exhaustive list of its most important crates:

You have already used some of these crates in the example project in the first part of this series.

defmt

defmt (name: "deferred formatting") is an efficient logging framework designed for resource-constrained environments. It provides a way to log messages with minimal overhead, both in terms of memory and CPU usage. This is done by deferring the string formatting to the host PC instead of doing it on the microcontroller. Moreover, string literals like in info!("Init complete") are not transmitted to the host. Instead, a table of string literals is built at compile-time and the MCU only sends a table index to the host, not the entire string.

Logging a message over the Real-time Transfer (RTT) interface using defmt looks like this:

use defmt_rtt as _; // global logger

defmt::info!("Hello, world!");

RTT, invented by SEGGER Microcontroller, employs a ring buffer and dedicated memory locations to facilitate efficient data communication between a host PC and an embedded target system via JTAG or SWD interfaces. This setup ensures minimal latency and continuous data flow, enhancing real-time data logging and debugging capabilities without interrupting the target system's execution.

probe-rs

probe-rs, which we have already used in the first part of this series, is a modern, open-source tool for flashing and debugging embedded devices. It supports a wide range of microcontrollers and provides a unified interface for development and debugging.

You can either use cargo to build the program, and probe-rs for flashing and running:

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

where the argument for --chip needs to be adapted to your specific chip (probe-rs chip list gives you all available options) and the target subfolder depends on your compilation target. For details on available targets and the nomenclature see https://doc.rust-lang.org/rustc/platform-support/arm-none-eabi.html.

Or you can define a custom "runner" in your cargo config in <project root>/.cargo/config.toml:

[target.'cfg(all(target_arch = "arm", target_os = "none"))']
# replace STM32F469NIHx with your chip as listed in `probe-rs chip list`
runner = "probe-rs run --chip STM32F469NIHx"

and then just do cargo run to compile, flash and run the firmware in a single step. That's as convenient as it gets in embedded software development.

Other crates

Describing every crate that may be helpful for your embedded project would be impossible. However, there are a few more crates that you might want to check out (no particular order):

Current Challenges and Areas for Improvement

While the embedded Rust ecosystem is already very robust and growing, there are still some challenges and areas that need improvement:

Documentation

Although Rust has excellent documentation, the resources specific to embedded development could be more comprehensive and detailed. Tutorials, guides and example projects are sometimes outdated because the used crates have seen a lot of new development in the meantime. To keep up with the rapid evolution of embedded Rust, more up-to-date and thorough documentation is essential. This would help both new and experienced developers to quickly adapt to the latest changes and best practices in the ecosystem.

Tooling

While tools like probe-rs and defmt are incredibly useful, there is always room for improvement in terms of stability, features and user experience. Enhancements in debugging tools, better integration with existing IDEs and more robust error reporting would significantly streamline the development process. Additionally, expanding the capabilities of these tools to support a wider range of microcontrollers and development boards would make embedded Rust accessible to a broader audience.

Display Abstraction

There is a notable absence of a comprehensive display abstraction crate. Efforts are underway to develop such a crate, which will significantly enhance the development of applications involving graphical displays. A unified display abstraction layer would simplify the process of interfacing with different types of displays and make it easier to develop portable graphical applications. For more information, see the not-yet-awesome-embedded-rust list on GitHub. Spoiler: Systemscape is working on a first draft of such an abstraction layer. Stay tuned for more information on this.

Ecosystem Maturity

The ecosystem around embedded Rust is still maturing. While there are many high-quality crates available, some areas lack the polish and stability found in more established languages. Continued efforts to stabilize and mature critical libraries, improve documentation and provide long-term support for key crates will help solidify Rust's position as a leading language for embedded systems development.

Integration with Existing Systems

Integrating Rust into existing embedded systems that are traditionally developed in C or C++ can be challenging. Tools and guides that help developers gradually introduce Rust into their workflows, along with better interoperability with existing codebases, would facilitate a smoother transition. This includes improving bindings to existing libraries and providing better tools for mixed-language projects.

Conclusion

The embedded Rust ecosystem has made significant strides but still faces several challenges that need addressing to reach its full potential. By focusing on improving documentation, tooling, display abstraction, ecosystem maturity and integration with existing systems, the Rust community can continue to advance the capabilities and adoption of Rust in embedded systems development.

We are happy to be a part of that.

Summary: The Embedded Rust Ecosystem

The embedded Rust ecosystem offers a rich set of crates and tools that make it a powerful option for embedded systems development. From low-level hardware access to efficient, non-blocking I/O operations, Rust supports a wide range of embedded use cases. While there are areas for improvement, the community-driven nature of Rust ensures that these gaps will be addressed over time. And the great thing about Rust being open-source: You can help with it, too!

In the final part of this series, we will perform a time comparison between setting up and deploying an embedded project on an STM32 MCU using Rust and a traditional C/C++ approach. If you haven't been convinced yet, the time saved during project setup will change that.