I’m not a WASM expert and all feedback is welcomed! You could also leave comments in the Rust forum post.
Why
Bare-metal Environments
In a bare metal environment no code has been loaded before your program. Without the software provided by an OS we can not load the standard library. Instead the program, along with the crates it uses, can only use the hardware (bare metal) to run. To prevent rust from loading the standard library use
no_std.
Some targets with arch like WASM or MIPS could be bare-metal environments1, thus cross-compilation for them will render the best compatibility when your Rust code is no_std compliant.
Read more on The Embedded Rust Book.
On the std support inside WASM
“Wait!”, you say, “I remember the entirety of std is supported in WASM according to the Rustc Book”. Then on a second thought, you wonder how could std::fs or std::os be supported in such a sandboxed env?
“A lot of those things panic or they just return unimplemented error where possible (such as
Err(io::Error::Unimplemented))” — Josh Stone (@cuviper) on Zulip thread
In short, while std usages in your Rust code (or some of your dependencies) won’t affect successful compilation into .wasm modules, they might cause runtime errors. Therefore being no_std compliant offers the “best effort” WASM support.
Put it another way, no_std is a more stringent requirement than “wasm-compilable”. Having your Rust libraries support no_std is a sufficient but not strictly necessary condition for your code to run in WASM env.
In fact, the hardest part of your WASM work usually is not compiling into .wasm module, but rather centered around exporting your Rust code and gluing them with JS using wasm-bingen, and translate (usually conditionally compile) system resource calls into Web API calls offered by web-sys, js-sys etc.
More on Rust + WASM
Here are some related WASM knowledge that I find personally useful. If you only care about the “know-how”, you can safely skip this section.
- There are 6 WASM-related targets:
wasm[32|64]-unknown-unknown,wasm[32|64]-emscripten,wasm[32|64]-wasi.- browsers only supports 32-bit WASM modules as of May 2023.
wasm32-unknown-unknowndoesn’t specify a particular OS or runtime env, but is typically intended to run in env that provides necessary system interfaces, such as browsers. By default, the target doesn’t have direct access to the file system or other system-level resources. But the WASM modules for this target could interact with the host env via runtime-specific APIs (e.g. crateweb-sysfor Web APIs). These APIs might access system resources indirectly, subjecting to the limitations of the host env.*-emscriptenis intended as a web target, butwasm-bingencrate only supportswasm32-unknown-unknown.- To produce standalone binaries to run outside of the web env, use
wasm*-wasiand runtime likewasmtimeorwasmer.
- Shared-memory concurrency is supported in WASM through “Threads Proposal”. It is usable today as demonstrated here! But the toolchain is underbaked and requires lots of manual setup (quoting @alexcrichton). Further reading here.
std-linking primitives | Replacement for WASM |
|---|---|
/dev/(u)random (system randomness) | crate getrandom, with features = ["js"] for target wasm32-unknown-unknown |
rayon (data parallelism) | crate wasm-bingen-rayon, some maybe-rayon options for feature-flag-toggled parallelism |
std::time::Instant | crate instant |
std::collections::{HashMap, HashSet} | crate hashbrown |
How: a somewhat opinionated way
While there are many paths to no_std compliance, the steps below should be mostly idiomatic to the Rust community.
- If your crate never uses
std, you could simply add#, then job done and jump to the next step! More often, you have an optionally-std crate, then add to yourlib.rs:
Then configure#![no_std] #[cfg(feature = "std")] extern crate std;Cargo.tomlas follows:[features] std = ["dep-a/std", "dep-b/std"] # optionally use `std` by default, so that downstream could # enable `no_std` using "default-features=false": # default = ["std"]- Another common practice is declaring
#![cfg_attr(not(feature = "std"), no_std)]in thelib.rs. The benefit of our approach above is “you never get thestdprelude items implicitly imported, so every use ofstdin your crate will be explicit; this makes the two cfg scenarios closer to each other, so it’s easier to understand what’s happening and to debug.”2
- Another common practice is declaring
-
Run:
rustup target add thumbv7em-none-eabi cargo build --target thumbv7em-none-eabi --no-default-featuresIf you just want WASM-compilable, you can replace the target with one of the six aforementioned WASM targets. But if you want “true
no_std” (i.e. neither your crate nor its dependencies linksstd, so free of runtime panics instd), then use a target that does not havestdat all, such asthumbv7em-none-eabi. To see the full list of target platforms, runrustc --print target-list. -
Adding checks to your CI (the following is specific to GitHub action only):
jobs: build: runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@v3 - name: Install stable toolchain uses: actions-rs/toolchain@v1 with: profile: default toolchain: stable override: true default: true target: | wasm32-unknown-unknown - name: Check no_std support and WASM compilation env: RUSTFLAGS: -C target-cpu=generic run: | cargo check --no-default-features cargo build --target wasm32-unknown-unknown --no-default-featuresAlternatively, if you have multiple targets to test, you could use the
strategy.matrixfeature like:# credit: https://github.com/RustCrypto/ jobs: build: runs-on: ubuntu-latest strategy: matrix: rust: - stable target: - thumbv7em-none-eabi - wasm32-unknown-unknown steps: - uses: actions/checkout@v3 - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ matrix.rust }} targets: ${{ matrix.target }} - run: cargo build --no-default-features --target ${{ matrix.target }}
Debugging workflow (opinionated)
This is my approach for debugging compilation errors and I’d greatly appreciate suggestions on better approaches.
- Comment out all my code in
lib.rsormain.rs(faster compilation check later). - Iteratively comment out some
[dependencies]and then runcargo build --target wasm32-unknown-unknown --no-default-featuresto locate the problematic dependencies or their feature flag selections. Usually, if they follow the idiosyncratic pattern, just usecrate-a = { version = "x.x.x", default-features = false }should suffice. When in trouble, refer to the Common issues section below. - Sometimes,
[dev-dependencies]also would cause compilation errors, so repeat Step 2 for those as well. - Once the (empty) package could compile with all dependencies required, uncomment the actual
lib.rs, do minor cosmetic drop-in replacements or conditional compilation changes (as mentioned in the next section), then finally compile your Rust code to the target platform.
Common issues
If it’s your first time trying to compile under no_std mode, you will likely encounter some compilation errors.
Here are some steps you could take to debug.
-
If you have
use std::*, see if you could replace withalloc::*orcore::*Tips: for a smaller(.wasmbinary size, consider using cratewee_allocas the global allocator in place of the defaultalloc::alloc::Globalchoice.3wee_allocis buggy and unmaintained.)- Using the default allocator (i.e.
alloc) costs less than 8k with LTO after stripping debuginfo:-Clto -Cstrip=debuginfo. (quoting @bjorn3)
-
Sometimes, you will need to switch to
no_std-compatible dependencies for some types or implementations. E.g. if you use structs likestruct HashMaporHashSet, consider using the drop-in replacement from cratehashbrown. -
If some of your dependencies are outright non-
no_stdcompliant, e.g. cratethreadpool, then you could turn them into conditional dependencies:[dependencies] threadpool = "1.8.1" [features] std = ["dep:threadpool"] [target.'cfg(not(target_arch = "wasm32"))'.dependencies] threadpool = { version = "^1.8.1", optional = true } -
Similar to the conditional dependency, you could have conditionally compiled code:
#[cfg(feature = "std")] use std::sync::Arc; #[cfg(feature = "std")] fn parallel_add(list: Arc<Vec<u8>>) -> Result<u8, std::error:Error> { } #[cfg(not(feature = "std"))] fn parallel_add(list: &[u8]) -> Result<u8, MyError> { } -
If one of your dependencies uses bindings for foreign code (via FFI), e.g. crate
blstwith original code written in C and Assembly, then you need to further ensure your Clang and LLVM toolchain is configured properly. Specifically,- first ensure that your
clangrecognizes wasm targets by running:clang --version --target=wasm32-unknown-unknown. - Then ensure your environment variables like
CCandARare pointing to the right version. - Furthermore, if you are running on arch different from that of the target (e.g. you run an Apple M1 or
arm64, but targettingwasm32), then it’s recommended to explicitly addRUSTFLAGS="-C target-cpu=generic", because by default it will be set tonativeleading to lots of warning or even errors. - For
nixusers, here’s myflake.nixthat setup the environment fornix-shellproperly:
- first ensure that your
{
description = "My nix-shell dev env";
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
inputs.flake-utils.url = "github:numtide/flake-utils"; # for dedup
inputs.rust-overlay.url = "github:oxalica/rust-overlay";
outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
overlays = [(import rust-overlay)];
pkgs = import nixpkgs { inherit system overlays; };
stableToolchain = pkgs.rust-bin.stable.latest.minimal.override {
extensions = [ "clippy" "llvm-tools-preview" "rust-src" ];
targets = ["wasm32-unknown-unknown"];
};
in with pkgs;
{
devShell = clang15Stdenv.mkDerivation {
name = "clang15-nix-shell";
buildInputs = [
git
stableToolchain
clang-tools_15
clangStdenv
llvm_15
] ++ lib.optionals stdenv.isDarwin [ darwin.apple_sdk.frameworks.Security ];
shellHook = ''
export C_INCLUDE_PATH="${llvmPackages_15.libclang.lib}/lib/clang/${llvmPackages_15.libclang.version}/include"
export CC="${clang-tools_15.clang}/bin/clang"
export AR="${llvm_15}/bin/llvm-ar"
export CFLAGS="-mcpu=generic"
'';
};
}
);
}
Miscellaneous
- Tools like
cargo-nonocould be helpful (especially after the first pass and you could add thecargo nono checkin your CI), but I encounter many false positives (like this) and false negatives (like this). I might come back to re-check its status in the future. - 🙏 Acknowledgment4, more thanks to everyone in this discussion, and @kpreid, @bjorn3 for precious feedback.
Footnotes
-
Target architecture being
wasmormipsdoesn’t necessarily mean bare-metal env — it depends on the full target triple. E.g.wasm-wasiandmips-unknown-linuxdo have system access through some abstracted interfaces. ↩ -
If you need to use higher-level structs and implementations like
Vec,Rc, oralloc::collections::*, then you would still import bothallocandwee_alloccrates, as the latter only provides a replacement for the memory allocator. ↩ -
Shout out to my friends who have helped me debug and shared their lessons/code on this seemingly simple task 😅 — @sveitser, @DieracDelta, @jbearer, @nomaxg, @nyospe, @ec2, @mike1729. ↩