Master Embedded Development with Rust and no_std

0


Based on my experience with range-set-blaze, a data structure project, here are the decisions I recommend, described one at a time. 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 in the Browser. These environments expose issues related to moving away from the standard 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 in which we wish to run our code as a Venn diagram of progressively tighter constraints.

Run the following commands to confirm that your code works in both WASM WASI and WASM in 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 earlier articles in this series: WASM WASI and WASM in 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).

Once you’ve fulfilled these prerequisites, the next step is to see how (and if) we can get our dependencies working on embedded systems.

To check if your dependencies are compatible with an embedded environment, compile your project for an embedded target. I recommend using the thumbv7m-none-eabi target:

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

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

Install the target and check your project:

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

When I did this on range-set-blaze, I encountered errors complaining about dependencies, such as:

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

The error messages can be confusing. To better 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 active Cargo features. For example:

range-set-blaze v0.1.6 (C:\deldir\branches\rustconf24.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 standard library.
  • We can turn these Cargo features off.

Using the techniques explained in the first article, Rule 6, we disable the use_std and std Cargo features. Recall that Cargo features are additive and have defaults. To turn off the default features, we use default-features = false. We then enable the Cargo features we want to keep by specifying, for example, 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 will not always be enough to make your dependencies no_std-compatible.

For example, the popular thiserror crate introduces std into your code and offers no Cargo feature to disable it. However, the community has created no_std alternatives. You can find these alternatives by searching, for example, https://crates.io/search?q=thiserror+no_std.

In the case of range-set-blaze, a problem remained related to crate gen_ops — a wonderful crate for conveniently defining operators such as + and &. The crate used std but didn’t need to. 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 they released an updated version: 0.4.0.

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

Using these methods fixed all the dependency errors in range-set-blaze. However, resolving those errors revealed 281 errors in the main code. Progress!

At the top of your project’s lib.rs (or main.rs) add:

#![no_std]
extern crate alloc;

This means we won’t use the standard library, but we will still allocate memory. For range-set-blaze, this change reduced the error count from 281 to 52.

Many of the remaining errors are due to using items in std that are available in core or alloc. Since much of std is just a re-export of core and alloc, we can resolve many errors by switching std references to core or alloc. This allows us to keep the essential functionality without relying on the standard library.

For example, we get an error for each of these 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, such as file access, are std-only—that is, they are defined outside of core and alloc. Fortunately, for range-set-blaze, switching to core and alloc resolved all 52 errors in the main code. However, this fix revealed 89 errors in its test code. Again, progress!

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

If we need two versions of our code — one for running on a full operating system and one for embedded systems — we can use Cargo features (see Rule 6 in the first article). For example, let’s define a feature called foo, which will 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<P, T>(path: P) -> std::io::Result<RangeSetBlaze<T>>
where
P: AsRef<std::path::Path>,
T: FromStr + Integer,
{
todo!("This function is not yet implemented.");
}

This says to define function demo_read_ranges_from_file only when Cargo feature foo is enabled. We can 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 top to bring in 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<P, T>(path: P) -> std::io::Result<RangeSetBlaze<T>>
where
P: AsRef<std::path::Path>,
T: FromStr + Integer,
{
todo!("This function is not yet implemented.");
}

We’d like to make one more change to our Cargo.toml. We want our new Cargo feature to control 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: If 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 check that your project compiles both with the standard library (std) and without, use the following commands:

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

With cargo check working, you’d think that cargo test would be straight forward. Unfortunately, it’s not. We’ll look at that next.

When we compile our project with --no-default-features, it operates in a no_std environment. However, Rust’s testing framework always includes the standard library, even in a no_std project. This is because cargo test requires std; for example, the #[test] attribute and the test harness itself are defined in the standard library.

As a result, running:

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

does not actually test the no_std version of your code. Functions from std that are unavailable in a true no_std environment will still be accessible during testing. For instance, the following test will compile and run successfully with --no-default-features, even though it uses std::fs:

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

Additionally, when testing in std mode, you may need to add explicit imports for features from the standard library. This is because, even though std is available during testing, your project is still compiled as #![no_std], meaning the standard prelude is not automatically in scope. For example, you’ll often need the following imports in your test code:

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

These imports bring in the necessary utilities from the standard library so that they are available during testing.

To genuinely test your code without the standard library, you’ll need to use alternative methods that do not rely on cargo test. We’ll explore how to run no_std tests in the next rule.

You can’t run your regular tests in an embedded environment. However, you can — and should — run at least one embedded test. My philosophy is that even a single test is infinitely better than none. Since “if it compiles, it works” is generally true for no_std projects, one (or a few) well-chosen test can 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 main 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 is OK). Add "C:\Program Files\qemu\" 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:\msys64\mingw64\bin\ 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 new tests/embedded

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

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

Edit tests/embedded/Cargo.toml to look 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 refer to your top-level project
range-set-blaze = { path = "../..", default-features = false }

Update the test code.

Replace the contents of tests/embedded/src/main.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 main() -> ! {
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 main.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 this is an embedded system, the code ends in an infinite loop to run continuously.

Add supporting files.

Before you can run the test, you must add two files to the subproject: build.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/build.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/build.rs' -OutFile 'build.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 following content:

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

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

This configuration instructs Cargo to use QEMU to run the embedded code and sets thumbv7m-none-eabi as the default target 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 target add thumbv7m-none-eabi

# If needed, cd tests/embedded
cargo run

You should see log messages, and the process should exit without error. In my case, I see: "-4..=-3, 100..=103".

These steps may seem like a significant amount of work just to run one (or a few) tests. However, it’s primarily a one-time effort involving mostly copy and paste. Additionally, it enables running tests in a CI environment (see Rule 9). The alternative — claiming that the code works in a no_std environment without ever actually running it in no_std—risks overlooking critical issues.

The next rule is much simpler.

Once your package compiles and passes the additional embedded test, you may want to publish it to crates.io, Rust’s package registry. To let others know that it is compatible with WASM and no_std, add the following 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 five keywords and five categories.

Here is a list of categories and keywords of possible interest, along with the number of crates using each term:

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

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

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

The reasons to avoid alloc if you can:

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

There are crates available that can sometimes help 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 recommend the heapless crate because it provides a collection of data 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 a list of integers. We limit the number of items in the map and the length of the integer list to DIGIT_COUNT (in this case, 4).

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

Full details about creating a no_alloc project are beyond my experience. However, the first step is to remove this line (added in Rule 3) from your lib.rs or main.rs:

extern crate alloc; // remove this

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

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

Recall that continuous integration (CI) is a system that can automatically run tests every time you update your code. I use GitHub Actions as my CI platform. Here’s the configuration I added to .github/workflows/ci.yml to test 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: Set up Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
target: thumbv7m-none-eabi
- name: Install check stable and nightly
run: |
cargo check --target thumbv7m-none-eabi --no-default-features
rustup override set nightly
rustup target 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 be sure that my code will continue to support embedded platforms in the future.

Leave a Reply

Your email address will not be published. Required fields are marked *