blog.dbrgn.ch

Testing for no_std compatibility in Rust crates

written on Tuesday, December 24, 2019 by

When creating a Rust crate that aims to be no_std compatible, it can happen that you accidentally break that promise without noticing. To demonstrate this, let's create an example project:

$ cargo new --lib tinycrate
     Created library `tinycrate` package

Let's add some simplistic but no_std compatible code to count words in a string:

// src/lib.rs
#![no_std]

pub fn count_words(val: &str) -> usize {
    val.chars().filter(|c| *c == ' ').count() + 1
}

#[test]
fn test_count_words() {
    assert_eq!(count_words("hello world"), 2);
    assert_eq!(count_words("hello, rusty world"), 3);
}

The tests pass:

$ cargo test
    Finished test [unoptimized + debuginfo] target(s) in 0.00s
     Running target/debug/deps/tinycrate-b053ba182408266a

running 1 test
test tests::test_count_words ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

So far, Rust will prevent us from accidentally using std features. For example, when adding the following line to the count_words function:

pub fn count_words(val: &str) -> usize {
    println!("Counting words in {}", val);
    ...

...then compilation will fail.

$ cargo build
   Compiling tinycrate v0.1.0 (/tmp/tinycrate)
error: cannot find macro `println` in this scope
 --> src/lib.rs:4:5
  |
4 |     println!("Counting words in {}", val);
  |     ^^^^^^^

error: aborting due to previous error

However, that's different when it comes to dependencies that require std!

Since our algorithm is very primitive (e.g. it fails for empty strings or for inputs with multiple consecutive spaces), let's pull in an external crate that can count words, like voca_rs:

# Cargo.toml
...

[dependencies]
voca_rs = "1"

The updated count_words function:

use voca_rs;

pub fn count_words(val: &str) -> usize {
    voca_rs::count::count_words(val, "")
}

Let's compile and re-run our tests:

$ cargo test
   ...
   Compiling voca_rs v1.9.1
   Compiling tinycrate v0.1.0 (/tmp/tinycrate)
    Finished test [unoptimized + debuginfo] target(s) in 6.67s
     Running target/debug/deps/tinycrate-b2a8be64818486a6

running 1 test
test test_count_words ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Our tests pass, even though voca_rs clearly isn't no_std compatible. Nobody (not even CI) would prevent us from pushing that update to our unexpecting users, potentially breaking a few workflows. How can we catch this?

Adding a no_std test crate

I tried a few variants (a no_std example program, a no_std integration test, etc) to no avail. In the end, I settled for a separate crate that is only used for testing whether compilation in a no_std binary project works.

Let's add a sub-project inside our existing codebase:

$ cargo new --bin ensure_no_std
     Created binary (application) `ensure_no_std` package

Then, let's first make the crate no_std compatible (based on this helpful blog post):

# ensure_no_std/Cargo.toml
...

[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"
// ensure_no_std/src/main.rs
#![no_std]
#![no_main]

use core::panic::PanicInfo;

/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

#[no_mangle]
pub extern "C" fn _start() -> ! {
    loop {}
}

To build this crate for bare metal x64 architectures, compile it like this:

$ cargo rustc -- -C link-arg=-nostartfiles
   Compiling ensure_no_std v0.1.0 (/tmp/tinycrate/ensure_no_std)
    Finished dev [unoptimized + debuginfo] target(s) in 0.07s

Success! Now let's add in a dependency on our own tinycrate project:

# ensure_no_std/Cargo.toml
...

[dependencies]
tinycrate = { path = ".." }

Include it in our main.rs:

// ensure_no_std/src/main.rs
...
#[allow(unused_imports)]
use tinycrate;
...

...aaaaand we got our failing smoke test!

$ cargo rustc -- -C link-arg=-nostartfiles
   Compiling ensure_no_std v0.1.0 (/tmp/tinycrate/ensure_no_std)
error[E0152]: duplicate lang item found: `panic_impl`.
  --> src/main.rs:11:1
   |
11 | / fn panic(_info: &PanicInfo) -> ! {
12 | |     loop {}
13 | | }
   | |_^
   |
   = note: first defined in crate `std` (which `voca_rs` depends on).

CI

In order for regressions to be found (and fixed) quickly, compiling this sub-crate should be integrated into your CI setup. Here's a build step for GitHub Actions:

- name: Ensure that crate is no_std
  uses: actions-rs/cargo@v1
  with:
    command: rustc
    args: --manifest-path=ensure_no_std/Cargo.toml -- -C link-arg=-nostartfiles

Code & Discussion

If you want to see a real-world project where this approach is being used, check out my embedded Rust driver for the Microchip RN2xx3 LoRaWAN modem.

If you found this approach useful or have an idea on how to improve this workflow, leave a comment below or on Reddit!

Update 2019-12-25

Nemo157 on Reddit noted that another way to achieve this goal is to build for an ARM target, since std isn't available for embedded ARM.

To install the target:

$ rustup target add thumbv6m-none-eabi

Then build your crate for this target:

$ cargo build --target thumbv6m-none-eabi

This will fail with a bunch of error messages if std is required by a dependency.

As noted by Nemo157, "that becomes more complicated once you introduce dev-dependencies and optionally-std crates though".

Update 2019-12-26

Alternatively you can also use cargo-nono to check the compatibility (and also to debug the reason for incompatibilities). I'm now using this as a CI step in the shtcx crate.

This entry was tagged embedded, no_std and rust