no_std
Playbook
Learn More β
This is a note summarizing my experience with making rust libraries no_std
compatible (especially for WASM supports).
I'm not a WASM expert and all feedback is welcomed!
You could also leave comments in the Rust forum post.
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 useno_std
.
Some targets with arch like WASM or MIPS could be bare-metal environments[1], 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.
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.
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.
wasm[32|64]-unknown-unknown
, wasm[32|64]-emscripten
, wasm[32|64]-wasi
.
wasm32-unknown-unknown
doesn't specify a particular OS or runtime env, but is typically intended to run in env that provides necessary system interfaces, such as browsers.web-sys
for Web APIs). These APIs might access system resources indirectly, subjecting to the limitations of the host env.*-emscripten
is intended as a web target, but wasm-bingen
crate only supports wasm32-unknown-unknown
.wasm*-wasi
and runtime like wasmtime
or wasmer
.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 |
While there are many paths to no_std
compliance, the steps below should be mostly idiomatic to the Rust community.
std
, you could simply add #![no_std]
(read more on no_std attribute here), then job done and jump to the next step!lib.rs
:
ββββ#![no_std]
ββββ#[cfg(feature = "std")]
ββββextern crate std;
Cargo.toml
as 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"]
#![cfg_attr(not(feature = "std"), no_std)]
in the lib.rs
. The benefit of our approach above is "you never get the std
prelude items implicitly imported, so every use of std
in 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]Run:
βββrustup target add thumbv7em-none-eabi
βββcargo build --target thumbv7em-none-eabi --no-default-features
If 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 links std
, so free of runtime panics in std
), then use a target that does not have std
at all, such as thumbv7em-none-eabi
.
To see the full list of target platforms, run rustc --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-features
Alternatively, if you have multiple targets to test, you could use the strategy.matrix
feature 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 }}
This is my approach for debugging compilation errors and I'd greatly appreciate suggestions on better approaches.
lib.rs
or main.rs
(faster compilation check later).[dependencies]
and then run cargo build --target wasm32-unknown-unknown --no-default-features
to locate the problematic dependencies or their feature flag selections.crate-a = { version = "x.x.x", default-features = false }
should suffice.[dev-dependencies]
also would cause compilation errors, so repeat Step 2 for those as well.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.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 with alloc::*
or core::*
.wasm
binary size, consider using crate wee_alloc
as the global allocator in place of the default alloc::alloc::Global
choice.[3]wee_alloc
is buggy and unmaintained.)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 like struct HashMap
or HashSet
, consider using the drop-in replacement from crate hashbrown
.
If some of your dependencies are outright non-no_std
compliant, e.g. crate threadpool
, 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 blst
with original code written in C and Assembly, then you need to further ensure your Clang and LLVM toolchain is configured properly. Specifically,
clang
recognizes wasm targets by running: clang --version --target=wasm32-unknown-unknown
.CC
and AR
are pointing to the right version.arm64
, but targetting wasm32
), then it's recommended to explicitly add RUSTFLAGS="-C target-cpu=generic"
, because by default it will be set to native
leading to lots of warning or even errors.nix
users, here's my flake.nix
that setup the environment for nix-shell
properly:
ββββ{
ββββ 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"
ββββ '';
ββββ };
ββββ }
ββββ );
ββββ}
cargo-nono
could be helpful (especially after the first pass and you could add the cargo nono check
in 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.Target architecture being wasm
or mips
doesn't necessarily mean bare-metal env β it depends on the full target triple. E.g. wasm-wasi
and mips-unknown-linux
do have system access through some abstracted interfaces. β©οΈ
If you need to use higher-level structs and implementations like Vec
, Rc
, or alloc::collections::*
, then you would still import both alloc
and wee_alloc
crates, 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