Try   HackMD

Rust no_std Playbook

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.

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

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-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.
      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. crate 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.
    • To produce standalone binaries to run outside of the web env, use wasm*-wasi and runtime like wasmtime or wasmer.
  • 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.

  1. If your crate never uses std, you could simply add #![no_std] (read more on no_std attribute here), then job done and jump to the next step!
    More often, you have an optionally-std crate, then add to your lib.rs:
    ​​​​#![no_std]
    
    ​​​​#[cfg(feature = "std")]
    ​​​​extern crate std;
    
    Then configure 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"]
    
    • Another common practice is declaring #![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]
  1. 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.

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

Debugging workflow (opinionated)

This is my approach for debugging compilation errors and I'd greatly appreciate suggestions on better approaches.

  1. Comment out all my code in lib.rs or main.rs (faster compilation check later).
  2. Iteratively comment out some [dependencies] and then run cargo build --target wasm32-unknown-unknown --no-default-features to locate the problematic dependencies or their feature flag selections.
    Usually, if they follow the idiosyncratic pattern, just use crate-a = { version = "x.x.x", default-features = false } should suffice.
    When in trouble, refer to the Common issues section below.
  3. Sometimes, [dev-dependencies] also would cause compilation errors, so repeat Step 2 for those as well.
  4. 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 with alloc::* or core::*

    • Tips: for a smaller .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.)
    • 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 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,

    • first ensure that your clang recognizes wasm targets by running: clang --version --target=wasm32-unknown-unknown.
    • Then ensure your environment variables like CC and AR are 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 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.
    • For 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"
      ​​​​          '';
      ​​​​        };
      ​​​​      }
      ​​​​    );
      ​​​​}
      

Miscellaneous

  • Tools like 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.
  • Image Not Showing Possible Reasons
    • The image file may be corrupted
    • The server hosting the image is unavailable
    • The image path is incorrect
    • The image format is not supported
    Learn More β†’
    Acknowledgment[4], more thanks to everyone in this discussion, and @kpreid, @bjorn3 for precious feedback.

  1. 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. β†©οΈŽ

  2. Pointed out by @kpreid β†©οΈŽ

  3. 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. β†©οΈŽ

  4. Shout out to my friends who have helped me debug and shared their lessons/code on this seemingly simple task

    Image Not Showing Possible Reasons
    • The image file may be corrupted
    • The server hosting the image is unavailable
    • The image path is incorrect
    • The image format is not supported
    Learn More β†’
    – @sveitser, @DieracDelta, @jbearer, @nomaxg, @nyospe, @ec2, @mike1729. β†©οΈŽ