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.
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 useno_std
. Recall that the usual library provides core functionality likeVec
,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
. AddC:msys64mingw64bin
to your path.
Mac
brew install qemu
orsudo 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-levelCargo.toml
. All packages within the workspace share a singleCargo.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.