Nine Rules for Running Rust on Embedded Systems

-

Based on my experience with range-set-blaze, a knowledge structure project, listed below are the selections I like to recommend, described one by one. To avoid wishy-washiness, I’ll express them as rules.

Before porting your Rust code to an embedded environment, ensure it runs successfully in WASM WASI and WASM within the Browser. These environments expose issues related to moving away from the usual library and impose constraints like those of embedded systems. By addressing these challenges early, you’ll be closer to running your project on embedded devices.

Environments through which we want to run our code as a Venn diagram of progressively tighter constraints.

Run the next commands to verify that your code works in each WASM WASI and WASM within the Browser:

cargo test --target wasm32-wasip1
cargo test --target wasm32-unknown-unknown

If the tests fail or don’t run, revisit the steps from the sooner articles on this series: WASM WASI and WASM within the Browser.

The WASM WASI article also provides crucial background on understanding Rust targets (Rule 2), conditional compilation (Rule 4), and Cargo features (Rule 6).

When you’ve fulfilled these prerequisites, the subsequent step is to see how (and if) we are able to get our dependencies working on embedded systems.

To envision in case your dependencies are compatible with an embedded environment, compile your project for an embedded goal. I like to recommend using the thumbv7m-none-eabi goal:

  • thumbv7m — Represents the ARM Cortex-M3 microcontroller, a well-liked family of embedded processors.
  • none — Indicates that there isn’t a operating system (OS) available. In Rust, this typically means we are able to’t depend on the usual library (std), so we use no_std. Recall that the usual library provides core functionality like Vec, String, file input/output, networking, and time.
  • eabi — Embedded Application Binary Interface, a regular defining calling conventions, data types, and binary layout for embedded executables.

Since most embedded processors share the no_std constraint, ensuring compatibility with this goal helps ensure compatibility with other embedded targets.

Install the goal and check your project:

rustup goal add thumbv7m-none-eabi
cargo check --target thumbv7m-none-eabi

Once I did this on range-set-blaze, I encountered errors complaining about dependencies, corresponding to:

This shows that my project is dependent upon num-traits, which is dependent upon either, ultimately depending on std.

The error messages might be confusing. To higher understand the situation, run this cargo tree command:

cargo tree --edges no-dev --format "{p} {f}"

It displays a recursive list of your project’s dependencies and their lively Cargo features. For instance:

range-set-blaze v0.1.6 (C:deldirbranchesrustconf24.nostd) 
├── gen_ops v0.3.0
├── itertools v0.13.0 default,use_alloc,use_std
│ └── either v1.12.0 use_std
├── num-integer v0.1.46 default,std
│ └── num-traits v0.2.19 default,i128,std
│ [build-dependencies]
│ └── autocfg v1.3.0
└── num-traits v0.2.19 default,i128,std (*)

We see multiple occurrences of Cargo features named use_std and std, strongly suggesting that:

  • These Cargo features require the usual library.
  • We will turn these Cargo features off.

Using the techniques explained within the first article, Rule 6, we disable the use_std and std Cargo features. Recall that Cargo features are additive and have defaults. To show off the default features, we use default-features = false. We then enable the Cargo features we wish to maintain by specifying, for instance, features = ["use_alloc"]. The Cargo.toml now reads:

[dependencies]
gen_ops = "0.3.0"
itertools = { version = "0.13.0", features=["use_alloc"], default-features = false }
num-integer = { version = "0.1.46", default-features = false }
num-traits = { version = "0.2.19", features=["i128"], default-features = false }

Turning off Cargo features won’t at all times be enough to make your dependencies no_std-compatible.

For instance, the favored thiserror crate introduces std into your code and offers no Cargo feature to disable it. Nonetheless, the community has created no_std alternatives. You will discover these alternatives by searching, for instance, https://crates.io/search?q=thiserror+no_std.

Within the case of range-set-blaze, an issue remained related to crate gen_ops — a beautiful crate for conveniently defining operators corresponding to + and &. The crate used std but didn’t must. I identified the required one-line change (using the methods we’ll cover in Rule 3) and submitted a pull request. The maintainer accepted it, and so they released an updated version: 0.4.0.

Sometimes, our project can’t disable std because we’d like capabilities like file access when running on a full operating system. On embedded systems, nonetheless, we’re willing—and indeed must—surrender such capabilities. In Rule 4, we’ll see the best way to make std usage optional by introducing our own Cargo features.

Using these methods fixed all of the dependency errors in range-set-blaze. Nonetheless, resolving those errors revealed 281 errors within the important code. Progress!

At the highest of your project’s lib.rs (or important.rs) add:

#![no_std]
extern crate alloc;

This implies we won’t use the usual library, but we are going to still allocate memory. For range-set-blaze, this alteration reduced the error count from 281 to 52.

Most of the remaining errors are resulting from using items in std which might be available in core or alloc. Since much of std is only a re-export of core and alloc, we are able to resolve many errors by switching std references to core or alloc. This permits us to maintain the essential functionality without counting on the usual library.

For instance, we get an error for every of those lines:

use std::cmp::max;
use std::cmp::Ordering;
use std::collections::BTreeMap;

Changing std:: to either core:: or (if memory related) alloc:: fixes the errors:

use core::cmp::max;
use core::cmp::Ordering;
use alloc::collections::BTreeMap;

Some capabilities, corresponding to file access, are std-only—that’s, they’re defined outside of core and alloc. Fortunately, for range-set-blaze, switching to core and alloc resolved all 52 errors within the important code. Nonetheless, this fix revealed 89 errors in its test code. Again, progress!

We’ll address errors within the test code in Rule 5, but first, let’s determine what to do if we’d like capabilities like file access when running on a full operating system.

If we’d like two versions of our code — one for running on a full operating system and one for embedded systems — we are able to use Cargo features (see Rule 6 within the first article). For instance, let’s define a feature called foo, which might be the default. We’ll include the function demo_read_ranges_from_file only when foo is enabled.

In Cargo.toml (preliminary):

[features]
default = ["foo"]
foo = []

In lib.rs (preliminary):

#![no_std]
extern crate alloc;

// ...

#[cfg(feature = "foo")]
pub fn demo_read_ranges_from_file

(path: P) -> std::io::Result>
where
P: AsRef<:path::path>,
T: FromStr + Integer,
{
todo!("This function shouldn't be yet implemented.");
}

This says to define function demo_read_ranges_from_file only when Cargo feature foo is enabled. We will now check various versions of our code:

cargo check # enables "foo", the default Cargo features
cargo check --features foo # also enables "foo"
cargo check --no-default-features # enables nothing

Now let’s give our Cargo feature a more meaningful name by renaming foo to std. Our Cargo.toml (intermediate) now looks like:

[features]
default = ["std"]
std = []

In our lib.rs, we add these lines near the highest to herald the std library when the std Cargo feature is enabled:

#[cfg(feature = "std")]
extern crate std;

So, lib.rs (final) looks like this:

#![no_std]
extern crate alloc;

#[cfg(feature = "std")]
extern crate std;

// ...

#[cfg(feature = "std")]
pub fn demo_read_ranges_from_file

(path: P) -> std::io::Result>
where
P: AsRef<:path::path>,
T: FromStr + Integer,
{
todo!("This function shouldn't be yet implemented.");
}

We’d prefer to make another change to our Cargo.toml. We would like our recent Cargo feature to regulate dependencies and their features. Here is the resulting Cargo.toml (final):

[features]
default = ["std"]
std = ["itertools/use_std", "num-traits/std", "num-integer/std"]

[dependencies]
itertools = { version = "0.13.0", features = ["use_alloc"], default-features = false }
num-integer = { version = "0.1.46", default-features = false }
num-traits = { version = "0.2.19", features = ["i128"], default-features = false }
gen_ops = "0.4.0"

Aside: Should you’re confused by the Cargo.toml format for specifying dependencies and features, see my recent article: Nine Rust Cargo.toml Wats and Wat Nots: Master Cargo.toml formatting rules and avoid frustration in Towards Data Science.

To envision that your project compiles each with the usual library (std) and without, use the next commands:

cargo check # std
cargo check --no-default-features # no_std

With cargo check working, you’d think that cargo test can be simple. Unfortunately, it’s not. We’ll have a look at that next.

Once we compile our project with --no-default-features, it operates in a no_std environment. Nonetheless, Rust’s testing framework at all times includes the usual library, even in a no_std project. It is because cargo test requires std; for instance, the #[test] attribute and the test harness itself are defined in the usual library.

Because of this, running:

# DOES NOT TEST `no_std`
cargo test --no-default-features

doesn’t actually test the no_std version of your code. Functions from std which might be unavailable in a real no_std environment will still be accessible during testing. As an example, the next test will compile and run successfully with --no-default-features, though it uses std::fs:

#[test]
fn test_read_file_metadata() {
let metadata = std::fs::metadata("./").unwrap();
assert!(metadata.is_dir());
}

Moreover, when testing in std mode, you might must add explicit imports for features from the usual library. It is because, though std is accessible during testing, your project remains to be compiled as #![no_std], meaning the usual prelude shouldn’t be mechanically in scope. For instance, you’ll often need the next imports in your test code:

#![cfg(test)]
use std::prelude::v1::*;
use std::{format, print, println, vec};

These imports herald the mandatory utilities from the usual library so that they’re available during testing.

To genuinely test your code without the usual library, you’ll need to make use of alternative methods that don’t depend on cargo test. We’ll explore the best way to run no_std tests in the subsequent rule.

You’ll be able to’t run your regular tests in an embedded environment. Nonetheless, you can — and may — run a minimum of one embedded test. My philosophy is that even a single test is infinitely higher than none. Since “if it compiles, it really works” is mostly true for no_std projects, one (or just a few) well-chosen test might be quite effective.

To run this test, we use QEMU (Quick Emulator, pronounced “cue-em-you”), which allows us to emulate thumbv7m-none-eabi code on our important operating system (Linux, Windows, or macOS).

Install QEMU.

See the QEMU download page for full information:

Linux/WSL

  • Ubuntu: sudo apt-get install qemu-system
  • Arch: sudo pacman -S qemu-system-arm
  • Fedora: sudo dnf install qemu-system-arm

Windows

  • Method 1: https://qemu.weilnetz.de/w64. Run the installer (tell Windows that it’s OK). Add "C:Program Filesqemu" to your path.
  • Method 2: Install MSYS2 from https://www.msys2.org/. Open MSYS2 UCRT64 terminal. pacman -S mingw-w64-x86_64-qemu. Add C:msys64mingw64bin to your path.

Mac

  • brew install qemu or sudo port install qemu

Test installation with:

qemu-system-arm --version

Create an embedded subproject.

Create a subproject for the embedded tests:

cargo recent tests/embedded

This command generates a brand new subproject, including the configuration file at tests/embedded/Cargo.toml.

Aside: This command also modifies your top-level Cargo.toml so as to add the subproject to your workspace. In Rust, a workspace is a group of related packages defined within the [workspace] section of the top-level Cargo.toml. All packages within the workspace share a single Cargo.lock file, ensuring consistent dependency versions across all the workspace.

Edit tests/embedded/Cargo.toml to appear like this, but replace "range-set-blaze" with the name of your top-level project:

[package]
name = "embedded"
version = "0.1.0"
edition = "2021"

[dependencies]
alloc-cortex-m = "0.4.4"
cortex-m = "0.7.7"
cortex-m-rt = "0.7.3"
cortex-m-semihosting = "0.5.0"
panic-halt = "0.2.0"
# Change to seek advice from your top-level project
range-set-blaze = { path = "../..", default-features = false }

Update the test code.

Replace the contents of tests/embedded/src/important.rs with:

// Based on https://github.com/rust-embedded/cortex-m-quickstart/blob/master/examples/allocator.rs
// and https://github.com/rust-lang/rust/issues/51540
#![feature(alloc_error_handler)]
#![no_main]
#![no_std]
extern crate alloc;
use alloc::string::ToString;
use alloc_cortex_m::CortexMHeap;
use core::{alloc::Layout, iter::FromIterator};
use cortex_m::asm;
use cortex_m_rt::entry;
use cortex_m_semihosting::{debug, hprintln};
use panic_halt as _;
#[global_allocator]
static ALLOCATOR: CortexMHeap = CortexMHeap::empty();
const HEAP_SIZE: usize = 1024; // in bytes
#[alloc_error_handler]
fn alloc_error(_layout: Layout) -> ! {
asm::bkpt();
loop {}
}

#[entry]
fn important() -> ! {
unsafe { ALLOCATOR.init(cortex_m_rt::heap_start() as usize, HEAP_SIZE) }

// Test(s) goes here. Run only under emulation
use range_set_blaze::RangeSetBlaze;
let range_set_blaze = RangeSetBlaze::from_iter([100, 103, 101, 102, -3, -4]);
hprintln!("{:?}", range_set_blaze.to_string());
if range_set_blaze.to_string() != "-4..=-3, 100..=103" {
debug::exit(debug::EXIT_FAILURE);
}

debug::exit(debug::EXIT_SUCCESS);
loop {}
}

Most of this important.rs code is embedded system boilerplate. The actual test code is:

use range_set_blaze::RangeSetBlaze;
let range_set_blaze = RangeSetBlaze::from_iter([100, 103, 101, 102, -3, -4]);
hprintln!("{:?}", range_set_blaze.to_string());
if range_set_blaze.to_string() != "-4..=-3, 100..=103" {
debug::exit(debug::EXIT_FAILURE);
}

If the test fails, it returns EXIT_FAILURE; otherwise, it returns EXIT_SUCCESS. We use the hprintln! macro to print messages to the console during emulation. Since that is an embedded system, the code ends in an infinite loop to run constantly.

Add supporting files.

Before you possibly can run the test, it’s essential to add two files to the subproject: construct.rs and memory.x from the Cortex-M quickstart repository:

Linux/WSL/macOS

cd tests/embedded
wget https://raw.githubusercontent.com/rust-embedded/cortex-m-quickstart/master/construct.rs
wget https://raw.githubusercontent.com/rust-embedded/cortex-m-quickstart/master/memory.

Windows (Powershell)

cd tests/embedded
Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/rust-embedded/cortex-m-quickstart/master/construct.rs' -OutFile 'construct.rs'
Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/rust-embedded/cortex-m-quickstart/master/memory.x' -OutFile 'memory.x'

Also, create a tests/embedded/.cargo/config.toml with the next content:

[target.thumbv7m-none-eabi]
runner = "qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,goal=native -kernel"

[build]
goal = "thumbv7m-none-eabi"

This configuration instructs Cargo to make use of QEMU to run the embedded code and sets thumbv7m-none-eabi because the default goal for the subproject.

Run the test.

Run the test with cargo run (not cargo test):

# Setup
# Make this subproject 'nightly' to support #![feature(alloc_error_handler)]
rustup override set nightly
rustup goal add thumbv7m-none-eabi

# If needed, cd tests/embedded
cargo run

It’s best to see log messages, and the method should exit without error. In my case, I see: "-4..=-3, 100..=103".

These steps may appear to be a big amount of labor simply to run one (or just a few) tests. Nonetheless, it’s primarily a one-time effort involving mostly copy and paste. Moreover, it enables running tests in a CI environment (see Rule 9). The choice — claiming that the code works in a no_std environment without ever actually running it in no_std—risks overlooking critical issues.

The subsequent rule is way simpler.

Once your package compiles and passes the extra embedded test, you might wish to publish it to crates.io, Rust’s package registry. To let others know that it’s compatible with WASM and no_std, add the next keywords and categories to your Cargo.toml file:

[package]
# ...
categories = ["no-std", "wasm", "embedded"] # + others specific to your package
keywords = ["no_std", "wasm"] # + others specific to your package

Note that for categories, we use a hyphen in no-std. For keywords, no_std (with an underscore) is more popular than no-std. Your package can have a maximum of 5 keywords and five categories.

Here is an inventory of categories and keywords of possible interest, together with the variety of crates using each term:

Good categories and keywords will help people find your package, however the system is informal. There’s no mechanism to envision whether your categories and keywords are accurate, nor are you required to offer them.

Next, we’ll explore one of the crucial restricted environments you’re prone to encounter.

My project, range-set-blaze, implements a dynamic data structure that requires memory allocation from the heap (via alloc). But what in case your project doesn’t need dynamic memory allocation? In that case, it might run in much more restricted embedded environments—specifically those where all memory is preallocated when this system is loaded.

The explanations to avoid alloc for those who can:

  • Completely deterministic memory usage
  • Reduced risk of runtime failures (often attributable to memory fragmentation)
  • Lower power consumption

There are crates available that may sometimes aid you replace dynamic data structures like Vec, String, and HashMap. These alternatives generally require you to specify a maximum size. The table below shows some popular crates for this purpose:

I like to recommend the heapless crate since it provides a group of information structures that work well together.

Here is an example of code — using heapless — related to an LED display. This code creates a mapping from a byte to an inventory of integers. We limit the variety of items within the map and the length of the integer list to DIGIT_COUNT (on this case, 4).

use heapless::{LinearMap, Vec};
// …
let mut map: LinearMap, DIGIT_COUNT> = LinearMap::recent();
// …
let mut vec = Vec::default();
vec.push(index).unwrap();
map.insert(*byte, vec).unwrap(); // actually copies

Full details about making a no_alloc project are beyond my experience. Nonetheless, step one is to remove this line (added in Rule 3) out of your lib.rs or important.rs:

extern crate alloc; // remove this

Your project is now compiling to no_std and passing a minimum of one embedded-specific test. Are you done? Not quite. As I said within the previous two articles:

If it’s not in CI, it doesn’t exist.

Recall that continuous integration (CI) is a system that may mechanically run tests each time you update your code. I exploit GitHub Actions as my CI platform. Here’s the configuration I added to .github/workflows/ci.yml to check my project on embedded platforms:

test_thumbv7m_none_eabi:
name: Setup and Check Embedded
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Arrange Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
goal: thumbv7m-none-eabi
- name: Install check stable and nightly
run: |
cargo check --target thumbv7m-none-eabi --no-default-features
rustup override set nightly
rustup goal add thumbv7m-none-eabi
cargo check --target thumbv7m-none-eabi --no-default-features
sudo apt-get update && sudo apt-get install qemu qemu-system-arm
- name: Test Embedded (in nightly)
timeout-minutes: 1
run: |
cd tests/embedded
cargo run

By testing embedded and no_std with CI, I can make certain that my code will proceed to support embedded platforms in the longer term.

ASK ANA

What are your thoughts on this topic?
Let us know in the comments below.

0 0 votes
Article Rating
guest
0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments

Share this article

Recent posts

0
Would love your thoughts, please comment.x
()
x