Nine Rules for Running Rust on the Web and on Embedded | by Carl M. Kadie | Jul, 2023


Aside: First, use Git to create a new branch for your project. That way, if things don’t work out, you can easily undo all changes.

Mark the top of lib.rs with:

#![cfg_attr(not(test), no_std)]

This tells the Rust compiler not to include the standard library, except when testing.

Aside 1: My project is a library project with a lib.rs. I believe the steps for a binary project with a main.rs are about the same, but I haven’t tested them.

Aside 2: We’ll talk much more about code testing in later rules.

Adding the “no_std” line to range-set-blaze’s lib.rs, causes 40 compiler problems, most of this form:

Fix some of these by changing, “std::” to “core::” in your main code (not in test code). For range-set-blaze, this reduces the number of problems from 40 to 12. This fix helps because many items, such as std::cmp::max, are also available as core::cmp::max.

Sadly, items such as Vec and Box cannot be in core because they need to allocate memory. Happily, if you’re willing to support memory allocation, you can still use them.

Should you allow your crate to allocate memory? For WASM you should. For many embedded applications, you also should. For some embedded applications, however, you should not. If you decide to allow memory allocation, then at the top of lib.rs add:

extern crate alloc;

You can now add lines such as these to get access to many memory-allocated items:

extern crate alloc;

use alloc::boxed::Box;
use alloc::collections::btree_map;
use alloc::collections::BTreeMap;
use alloc::vec::Vec;
use alloc::{format, string::String};
use alloc::vec;

With range-set-blaze, this reduces the number of problems from 12 to two. We’ll fix these in Rule 3.

Aside: What if you are writing for an embedded environment that can’t use memory allocation and are having problems with, for example, Vec. You may be able to re-write. For example, you may be able to use an array in place of a vector. If that doesn’t work, take a look at the other rules. If nothing works, you may not be able to port your crate to no_std.

The Rust compiler complains if your project used a crate that puts “std” functions in your code. Sometimes, you can search crates.io and find alternative “no_std” crates. For example, the popular thiserror crate injects “std” into your code. However, the community has created alternatives that do not.

In the case of range-set-blaze, the two remaining problems relate to crate gen_ops — a wonderful crate for defining operators such as “+” and “&” conveniently. Version 0.3.0 of gen_ops did not fully support “no std”. Version 0.4.0, however, does. I updated my dependencies in Cargo.toml and improved my “no std” compatibility.

I can now run these commands:

cargo check # check that compiles as no_std
cargo test # check that tests, using std, still pass

The command cargo check confirms that my crate isn’t directly using the standard library. The command cargo test confirms that my tests (which still use the standard library) continue to pass. If your crate still doesn’t compile, take a look at the next rule.

Embedded processors generally don’t support reading and writing files. Likewise, WASM doesn’t yet fully support files. While you can find some file-related “no std” crates, none seem comprehensive. So, if file IO is central to your crate, porting to WASM and embedded may not be practical.

However, if file IO — or any other std-only function — is merely incidental to your crate, you can make that function optional via a “std” feature. Here is how:

Add this section to your Cargo.toml:

[package]
#...
resolver = "2" # the default for Rust 2021+

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

This says that your crate now has two features, “std” and “alloc”. By default, the compiler should use “std”.

At the top of your lib.rs, replace:

#![cfg_attr(not(test), no_std)]

with:

#![cfg_attr(not(feature = "std"), no_std)]

This says that if you do not apply the “std” feature, the compiler should compile without the standard library.

On the line before any code that is std-only, placed #[cfg(feature = "std")]. For example, here we define a function that creates a RangeSetBlaze struct based on the contents of a file:

#[cfg(feature = "std")]
use std::fs::File;
#[cfg(feature = "std")]
use std::io::{self, BufRead};
#[cfg(feature = "std")]
use std::path::Path;

#[cfg(feature = "std")]
#[allow(missing_docs)]
pub fn demo_read_ranges_from_file<P, T>(path: P) -> io::Result<RangeSetBlaze<T>>
where
P: AsRef<Path>,
T: FromStr + Integer,
{
//...code not shown
}

To check the “std” and “alloc” features, do this:

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

We can test “std” with

cargo test

Aside: Surprisingly, cargo test --features alloc --no-default-features does not test “alloc”. That is because tests require threads, allocation, and other things that may not be available​ in no_std so cargo always runs regular tests as “std”.

At this point we’re checking both “std” and “alloc”, so can we assume that our library will work with WASM and embedded. No! Generally, Nothing works without being tested. Specifically, we might be depending on crates that use “std” code internally. To find these issues, we must test in the WASM and embedded environments.

Install the WASM cross compiler and check your project with these commands:

rustup target add wasm32-unknown-unknown # only need to do this once
# may find issues
cargo check --target wasm32-unknown-unknown --features alloc --no-default-features

When I do this on range-set-blaze, it complains that the getrandom crate doesn’t work with WASM. On the one hand, I’m not surprised that WASM does not fully support random numbers. On the other hand, I am surprised because my project doesn’t directly depend on getrandom. To find the indirect dependency, I use cargo tree. I discover that my project depends on crate rand which depends on getrandom. Here is the cargo tree command to use:

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

The command outputs both crates and the features they use:

range-set-blaze v0.1.6 (O:ProjectsSciencewasmetcwasm3) alloc
├── gen_ops v0.4.0
├── itertools v0.10.5 default,use_alloc,use_std
│ └── either v1.8.1 use_std
├── num-integer v0.1.45 default,std
│ └── num-traits v0.2.15 default,std
│ [build-dependencies]
│ └── autocfg v1.1.0
│ [build-dependencies]
│ └── autocfg v1.1.0
├── num-traits v0.2.15 default,std (*)
├── rand v0.8.5 alloc,default,getrandom,libc,rand_chacha,std,std_rng
│ ├── rand_chacha v0.3.1 std
│ │ ├── ppv-lite86 v0.2.17 simd,std
│ │ └── rand_core v0.6.4 alloc,getrandom,std
│ │ └── getrandom v0.2.9 std
│ │ └── cfg-if v1.0.0
...

The output shows that range-set-blaze depends on rand. Also, it shows that rand depends on getrandom with its “std” feature.

I read the getrandom documentation and learn that its “js” feature supports WASM. So, how do we tell rand to use getrandom/js, but only when we compile with our “alloc” feature? We update our Cargo.toml like so:

[features]
default = ["std"]
std = ["getrandom/std"]
alloc = ["getrandom/js"]

[dependencies]
# ...
getrandom = "0.2.10"

This says that our “std” feature depends on getrandom’s “std” feature. Our “alloc” feature, however, should use the js feature of getrandom.

This now works:

cargo check --target wasm32-unknown-unknown --features alloc --no-default-features

So, we have WASM compiling, but what about testing WASM?

Let’s put the WASM version to work, first with tests and then with a demo web page.

Create WASM tests in tests/wasm.rs

You can test on WASM almost as easily as you can test natively. We do this by having the original tests only run natively while an almost duplicate set of tests run on WASM. Here are the steps based on The wasm-bindgen Guide:

  1. Do cargo install wasm-bindgen-cli
  2. Copy your current integration tests from, for example, tests/integration_tests.rs to tests/wasm.rs. (Recall that in Rust, integration tests are tests that live outside the src directory and that see only the public methods of a project.)
  3. At the top of tests/wasm.rs, remove #![cfg(test)] and add
    #![cfg(target_arch = “wasm32”)]
    use wasm_bindgen_test::*;
    wasm_bindgen_test_configure!(run_in_browser);
  4. In wasm.rs, replace all#[test]’s to #[wasm_bindgen_test]’s.
  5. Everywhere you have #![cfg(test)] (typically, intests/integration_tests.rs and src/tests.rs) add the additional line: #![cfg(not(target_arch = "wasm32"))]
  6. In your, Cargo.toml, change your[dev-dependencies] (if any) to [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
  7. In your, Cargo.toml, add a section:
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
wasm-bindgen-test = "0.3.37"

With all this set up, native tests, cargo test, should still work. If you don’t have the Chrome browser installed, install it. Now try to run the WASM tests with:

wasm-pack test --chrome --headless --features alloc --no-default-features

It will likely fail because your WASM tests use dependencies that haven’t or can’t be put in Cargo.toml. Go through each issue and either:

  1. Add the needed dependencies to Cargo.toml’s [target.'cfg(target_arch = "wasm32")'.dev-dependencies]section, or
  2. Remove the tests from tests/wasm.rs.

For range-set-blaze, I removed all WASM tests related to testing the package’s benchmarking framework. These tests will still be run on the native side. Some useful tests in testswasm.rs needed crate syntactic-for, so I added it to Cargo.toml, under [target.'cfg(target_arch = "wasm32")'.dev-dependencies]. With this fixed, all 59 WASM tests run and pass.

Aside: If your project includes an examples folder, you may need create a native module inside your example and a wasm module. See this range-set-blaze file for an “example” example of how to do this.

Create a WASM demo in tests/wasm-demo

Part of the fun of supporting WASM is that you can demo your Rust code in a web page. Here is a web demo of range-set-blaze.

Follow these steps to create your own web demo:

In your project’s main Cargo.toml file, define a workspace and add tests/wasm-demo to it:

[workspace]
members = [".", "tests/wasm-demo"]

In your tests folder, create a test/wasm-demo subfolder.

It should contain a new Cargo.toml like this (change range-set-blaze to the name of your project):

[package]
name = "wasm-demo"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"
range-set-blaze = { path = "../..", features = ["alloc"], default-features = false}

Also, create a file tests/wasm-demo/src/lib.rs. Here is mine:

#![no_std]
extern crate alloc;
use alloc::{string::ToString, vec::Vec};
use range_set_blaze::RangeSetBlaze;
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn disjoint_intervals(input: Vec<i32>) -> JsValue {
let set: RangeSetBlaze<_> = input.into_iter().collect();
let s = set.to_string();
JsValue::from_str(&s)
}

This file defines a function called disjoint_intervals that takes a vector of integers as input, for example, 100,103,101,102,-3,-4. Using the range-set-blaze package, the function returns a string of the integers as sorted, disjoint ranges, for example, -4..=-3, 100..=103.

As your final step, create file tests/wasm-demo/index.html. Mine uses a little JavaScript to accept a list of integers and then call the Rust WASM function disjoint_intervals.

<!DOCTYPE html>
<html>
<body>
<h2>Rust WASM RangeSetBlaze Demo</h2>
<p>Enter a list of comma-separated integers:</p>
<input id="inputData" type="text" value="100,103,101,102,-3,-4" oninput="callWasmFunction()">
<br><br>
<p id="output"></p>
<script type="module">
import init, { disjoint_intervals } from './pkg/wasm_demo.js';

function callWasmFunction() {
let inputData = document.getElementById("inputData").value;
let data = inputData.split(',').map(x => x.trim() === "" ? NaN : Number(x)).filter(n => !isNaN(n));
const typedArray = Int32Array.from(data);
let result = disjoint_intervals(typedArray);
document.getElementById("output").innerHTML = result;
}
window.callWasmFunction = callWasmFunction;
init().then(callWasmFunction);
</script>
</body>
</html>

To run the demo locally, first move your terminal to tests/wasm-demo. Then do:

# from tests/wasm-demo
wasm-pack build --target web

Next, start a local web server and view the page. I use the Live Preview extension to VS Code. Many people use python -m http.server. The range-set-blaze demo looks like this (also available, live on GitHub):

I find watching my Rust project run in a web page very gratifying. If WASM-compatibility is all you are looking for, you can skip to Rule 9.

If you want to take your project a step beyond WASM, follow this rule and the next.

Be sure you move your terminal back to your project’s home directory. Then, install thumbv7m-none-eabi, a popular embedded processor, and check your project with these commands:

# from project's home directory
rustup target add thumbv7m-none-eabi # only need to do this once
# will likely find issues
cargo check --target thumbv7m-none-eabi --features alloc --no-default-features

When I do this on range-set-blaze, I get errors related to four sets of dependencies:

  • thiserror — My project depended on this crate but didn’t actually use it. I removed the dependency.
  • rand and getrandom — My project only needs random numbers for native testing, so I moved the dependency to [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]. I also updated my main and testing code.
  • itertools, num-traits, and num-integer — These crates offer features for “std” and “alloc”. I updated Cargo.toml like so:
...
[features]
default = ["std"]
std = ["itertools/use_std", "num-traits/std", "num-integer/std"]
alloc = ["itertools/use_alloc", "num-traits", "num-integer"]

[dependencies]
itertools = { version = "0.10.1", optional = true, default-features = false }
num-integer = { version = "0.1.44", optional = true, default-features = false }
num-traits = { version = "0.2.15", optional = true, default-features = false }
gen_ops = "0.4.0"

[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
#...
rand = "0.8.4"
#...

How did I know which feature of which dependancies to use? Understanding the features of a crate such as itertools requires reading its documentation and (often) going to its GitHub repository and reading its Cargo.toml. You should also use cargo tree to check that you are getting the desire feature from each dependency. For example, this use of cargo tree shows that for a default compile, I get the “std” features of range-set-blaze, num-integer, and num-traits and the “use-std” features of itertools and either:

cargo tree --edges no-dev --format "{p} {f}"
range-set-blaze v0.1.6 (O:ProjectsSciencewasmetcwasm4) default,itertools,num-integer,num-traits,std
├── gen_ops v0.4.0
├── itertools v0.10.5 use_alloc,use_std
│ └── either v1.8.1 use_std
├── num-integer v0.1.45 std
│ └── num-traits v0.2.15 std
│ [build-dependencies]
│ └── autocfg v1.1.0
│ [build-dependencies]
│ └── autocfg v1.1.0
└── num-traits v0.2.15 std (*)

And this shows that for a --features alloc --no-default-feature compile, I get the desired “use_alloc” feature of itertools and “no default” version of the other dependances:

cargo tree --edges no-dev --format "{p} {f}" --features alloc --no-default-features
range-set-blaze v0.1.6 (O:ProjectsSciencewasmetcwasm4) alloc,itertools,num-integer,num-traits
├── gen_ops v0.4.0
├── itertools v0.10.5 use_alloc
│ └── either v1.8.1
├── num-integer v0.1.45
│ └── num-traits v0.2.15
│ [build-dependencies]
│ └── autocfg v1.1.0
│ [build-dependencies]
│ └── autocfg v1.1.0
└── num-traits v0.2.15 (*)

When you think you have everything working, use these commands to check/test native, WASM, and embedded:

# test native
cargo test
cargo test --features alloc --no-default-features
# check and test WASM
cargo check --target wasm32-unknown-unknown --features alloc --no-default-features
wasm-pack test --chrome --headless --features alloc --no-default-features
# check embedded
cargo check --target thumbv7m-none-eabi --features alloc --no-default-features

These check embedded, but what about testing embedded?

Let’s put our embedded feature to work by creating a combined test and demo. We will run it on an emulator called QEMU.

Testing native Rust is easy. Testing WASM Rust is OK. Testing embedded Rust is hard. We will do only a single, simple test.

Aside 1: For more, about running and emulating embedded Rust see: The Embedded Rust Book.

Aside 2: For ideas on a more complete test framework for embedded Rust, see defmt-test. Sadly, I couldn’t figure out how to get it to run under emulation. The cortex-m/testsuite project uses a fork of defmt-test and can run under emulation but doesn’t offer a stand-alone testing crate and requires three additional (sub)projects.

Aside 3: One embedded test is infinitely better than no tests. We’ll do the rest of our testing at the native and WASM level.

We will create the embedded test and demo inside our current tests folder. The files will be:

tests/embedded
├── .cargo
│ └── config.toml
├── Cargo.toml
├── build.rs
├── memory.x
└── src
└── main.rs

Here are the steps to creating the files and setting things up.

  1. Install the QEMU emulator. On Windows, this involves running an installer and then manually adding "C:Program Filesqemu" to your path.

2. Create a tests/embedded/Cargo.toml that depends on your local project with “no default features” and “alloc”. Here is mine:

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

[dependencies]
alloc-cortex-m = "0.4.4"
cortex-m = "0.6.0"
cortex-m-rt = "0.6.10"
cortex-m-semihosting = "0.3.3"
panic-halt = "0.2.0"# reference your local project here
range-set-blaze = { path = "../..", features = ["alloc"], default-features = false }

[[bin]]
name = "embedded"
test = false
bench = false

3. Create a file tests/embedded/src/main.rs. Put your test code after the “test goes here” comment. Here is my file:

// 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 _;
use range_set_blaze::RangeSetBlaze;

#[global_allocator]
static ALLOCATOR: CortexMHeap = CortexMHeap::empty();
const HEAP_SIZE: usize = 1024; // in bytes

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

// test goes here
let range_set_blaze = RangeSetBlaze::from_iter([100, 103, 101, 102, -3, -4]);
assert!(range_set_blaze.to_string() == "-4..=-3, 100..=103");
hprintln!("{:?}", range_set_blaze.to_string()).unwrap();

// exit QEMU/ NOTE do not run this on hardware; it can corrupt OpenOCD state
debug::exit(debug::EXIT_SUCCESS);
loop {}
}

#[alloc_error_handler]
fn alloc_error(_layout: Layout) -> ! {
asm::bkpt();
loop {}
}

4. Copy build.rs and memory.x from cortex-m-quickstart’s GitHub to tests/embedded/.

5. Create a tests/embedded/.cargo/config.toml containing:

[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"

6. Update your project’s main Cargo.toml by adding tests/embedded to your workspace:

[workspace]
members = [".", "tests/wasm-demo", "tests/embedded"]



Source link

Leave a Comment