Nine Rules for Running Rust on Embedded Systems

Practical Lessons from Porting range-set-blaze to no_std

Rust Running on Embedded — Source: https://openai.com/dall-e-2/. All other figures from the author.

Do you want your Rust code to run everywhere — from large servers to web pages, robots, and even watches? In this final article of a three-part series [1, 2, 3], we’ll see how to use Rust to run on embedded devices using no_std.

Porting your Rust project to a no_std environment allows you to target microcontrollers and deeply embedded systems, creating highly efficient software for constrained environments. For example, I used the upcoming version of range-set-blaze to create an LED animation sequencer and compositor that runs on a Raspberry Pi Pico:

1 minute video showing LED animation on the Pico

Running Rust without the standard library presents unique challenges. Without operating system support, features like file I/O, networking, and sometimes even dynamic memory allocation are unavailable. In this article, we’ll look at practical strategies to overcome these limitations.

Porting Rust to no_std requires careful steps and choices, and missing any step can lead to failure. We’ll simplify the process by following these nine rules, which we will examine in detail:

  1. Confirm that your project works with WASM WASI and WASM in the Browser.
  2. Use target thumbv7m-none-eabi and cargo tree to identify and fix dependencies incompatible with no_std.
  3. Mark main (non-test) code no_std and alloc. Replace std:: with core:: and alloc::.
  4. Use Cargo features to let your main code use std optionally for file-related (etc.) functions.
  5. Understand why test code always uses the standard library.
  6. Create a simple embedded test project. Run it with QEMU.
  7. In Cargo.toml, add keywords and categories for WASM and no_std.
  8. [Optional] Use preallocated data types to avoid alloc.
  9. Add thumbv7m-none-eabi and QEMU to your CI (continuous integration) tests.

Aside: These articles are based on a three-hour workshop that I presented at RustConf24 in Montreal. Thanks to the participants of that workshop. A special thanks, also, to the volunteers from the Seattle Rust Meetup who helped test this material. These articles replace an article I wrote last year with updated information.

As with the first and second articles in this series, before we look at the rules one by one, let’s define our terms.

  • Native: Your home OS (Linux, Windows, macOS)
  • Standard library (std): Provides Rust’s core functionality — Vec, String, file input/output, networking, time.
  • WASM: WebAssembly (WASM) is a binary instruction format that runs in most browsers (and beyond).
  • WASI: WebAssembly System Interface (WASI) allows outside-the-browser WASM to access file I/O, networking (not yet), and time handling.
  • no_std: Instructs a Rust program not to use the full standard library, making it suitable for small, embedded devices or highly resource-constrained environments.
  • alloc: Provides heap memory allocation capabilities (Vec, String, etc.) in no_std environments, essential for dynamically managing memory.

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.

Rule 1: Confirm that your project works with WASM WASI and WASM in the Browser.

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.

Rule 2: Use target thumbv7m-none-eabi and cargo tree to identify and fix dependencies incompatible with no_std.

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: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 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!

Rule 3: Mark main (non-test) code no_std and alloc. Replace std:: with core:: and alloc::.

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.

Rule 4: Use Cargo features to let your main code use std optionally for file-related (etc.) functions.

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.

Rule 5: Understand why test code always uses the standard library.

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.

Rule 6: Create a simple embedded test project. Run it with QEMU.

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 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 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.

Rule 7: In Cargo.toml, add keywords and categories for WASM and no_std.

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.

Rule 8: [Optional] Use preallocated data types to avoid alloc.

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

Rule 9: Add thumbv7m-none-eabi and QEMU to your CI (continuous integration) tests.

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.

So, there you have it — nine rules for porting your Rust code to embedded. To see a snapshot of the whole range-set-blaze project after applying all nine rules, see this branch on Github.

Here is what surprised me about porting to embedded:

The Bad:

  • We cannot run our existing tests on embedded systems. Instead, we must create a new subproject and write (a few) new tests.
  • Many popular libraries rely on std, so finding or adapting dependencies that work with no_std can be challenging.

The Good:

  • The Rust saying that “if it compiles, it works” holds true for embedded development. This gives us confidence in our code’s correctness without requiring extensive new tests.
  • Although no_std removes our immediate access to the standard library, many items continue to be available via core and alloc.
  • Thanks to emulation, you can develop for embedded systems without hardware.

Thank you for joining me on this journey from WASI to WebAssembly in the browser and, finally, to embedded development. Rust has continued to impress me with its ability to run efficiently and safely across environments. As you explore these different domains, I hope you find Rust’s flexibility and power as compelling as I do. Whether you’re working on cloud servers, browsers, or microcontrollers, the tools we’ve discussed will help you tackle the challenges ahead with confidence.

Interested in future articles? Please follow me on Medium. I write about Rust and Python, scientific programming, machine learning, and statistics. I tend to write about one article per month.