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.