Item 33: Consider making library code no_std compatible

Rust comes with a standard library called std, which includes code for a wide variety of common tasks, from standard data structures to networking, from multithreading support to file I/O. For convenience, several of the items from std are automatically imported into your program, via the prelude: a set of common use statements that make common types available without needing to use their full names (e.g., Vec rather than std::vec::Vec).

Rust also supports building code for environments where it's not possible to provide this full standard library, such as bootloaders, firmware, or embedded platforms in general. Crates indicate that they should be built in this way by including the #![no_std] crate-level attribute at the top of src/lib.rs.

This Item explores what's lost when building for no_std and what library functions you can still rely on—which turns out to be quite a lot.

However, this Item is specifically about no_std support in library code. The difficulties of making a no_std binary are beyond this text,1 so the focus here is how to make sure that library code is available for those poor souls who do have to work in such a minimal environment.

core

Even when building for the most restricted of platforms, many of the fundamental types from the standard library are still available. For example, Option and Result are still available, albeit under a different name, as are various flavors of Iterator.

The different names for these fundamental types start with core::, indicating that they come from the core library, a standard library that's available even in the most no_std of environments. These core:: types behave exactly the same as the equivalent std:: types, because they're actually the same types—in each case, the std:: version is just a re-export of the underlying core:: type.

This means that there's a quick and dirty way to tell if a std:: item is available in a no_std environment: visit the doc.rust-lang.org page for the std item you're interested in and follow the "source" link (at the top right).2 If that takes you to a src/core/... location, then the item is available under no_std via core::.

The types from core are available for all Rust programs automatically. However, they typically need to be explicitly used in a no_std environment, because the std prelude is absent.

In practice, relying purely on core is too limiting for many environments, even no_std ones. A core (pun intended) constraint of core is that it performs no heap allocation.

Although Rust excels at putting items on the stack and safely tracking the corresponding lifetimes (Item 14), this restriction still means that standard data structures—vectors, maps, sets—can't be provided, because they need to allocate heap space for their contents. In turn, this also drastically reduces the number of available crates that work in this environment.

alloc

However, if a no_std environment does support heap allocation, then many of the standard data structures from std can still be supported. These data structures, along with other allocation-using functionality, are grouped into Rust's alloc library.

As with core, these alloc variants are actually the same types under the covers. For example, the real name of std::vec::Vec is actually alloc::vec::Vec.

A no_std Rust crate needs to explicitly opt in to the use of alloc, by adding an extern crate alloc; declaration to src/lib.rs:3

//! My `no_std` compatible crate.
#![no_std]

// Requires `alloc`.
extern crate alloc;

Pulling in the alloc crate enables many familiar friends, now addressed by their true names:

With these things available, it becomes possible for many library crates to be no_std compatible—for example, if a library doesn't involve I/O or networking.

There's a notable absence from the data structures that alloc makes available, though—the collections HashMap and HashSet are specific to std, not alloc. That's because these hash-based containers rely on random seeds to protect against hash collision attacks, but safe random number generation requires assistance from the operating system—which alloc can't assume exists.

Another notable absence is synchronization functionality like std::sync::Mutex, which is required for multithreaded code (Item 17). These types are specific to std because they rely on OS-specific synchronization primitives, which aren't available without an OS. If you need to write code that is both no_std and multithreaded, third-party crates such as spin are probably your only option.

Writing Code for no_std

The previous sections made it clear that for some library crates, making the code no_std compatible just involves the following:

  • Replacing std:: types with identical core:: or alloc:: crates (which requires use of the full type name, due to the absence of the std prelude)
  • Shifting from HashMap/HashSet to BTreeMap/BTreeSet

However, this only makes sense if all of the crates that you depend on (Item 25) are also no_std compatible—there's no point in becoming no_std compatible if any user of your crate is forced to link in std anyway.

There's also a catch here: the Rust compiler will not tell you if your no_std crate depends on a std-using dependency. This means that it's easy to undo the work of making a crate no_std compatible—all it takes is an added or updated dependency that pulls in std.

To protect against this, add a CI check for a no_std build so that your CI system (Item 32) will warn you if this happens. The Rust toolchain supports cross-compilation out of the box, so this can be as simple as performing a cross-compile for a target system that does not support std (e.g., --target thumbv6m-none-eabi); any code that inadvertently requires std will then fail to compile for this target.

So: if your dependencies support it, and the simple transformations above are all that's needed, then consider making library code no_std compatible. When it is possible, it's not much additional work, and it allows for the widest reuse of the library.

If those transformations don't cover all of the code in your crate but the parts that aren't covered are only a small or well-contained fraction of the code, then consider adding a feature (Item 26) to your crate that turns on just those parts.

Such a feature is conventionally named either std, if it enables use of std-specific functionality:

#![cfg_attr(not(feature = "std"), no_std)]

or alloc, if it turns on use of alloc-derived functionality:

#![allow(unused)]
fn main() {
#[cfg(feature = "alloc")]
extern crate alloc;
}

Note that there's a trap for the unwary here: don't have a no_std feature that disables functionality requiring std (or a no_alloc feature similarly). As explained in Item 26, features need to be additive, and there's no way to combine two users of the crate where one configures no_std and one doesn't—the former will trigger the removal of code that the latter relies on.

As ever with feature-gated code, make sure that your CI system (Item 32) builds all the relevant combinations—including a build with the std feature disabled on an explicitly no_std platform.

Fallible Allocation

The earlier sections of this Item considered two different no_std environments: a fully embedded environment with no heap allocation whatsoever (core) and a more generous environment where heap allocation is allowed (core + alloc).

However, there are some important environments that fall between these two camps—in particular, those where heap allocation is possible but may fail because there's a limited amount of heap.

Unfortunately, Rust's standard alloc library includes a pervasive assumption that heap allocations cannot fail, and that's not always a valid assumption.

Even a simple use of alloc::vec::Vec could potentially allocate on every line:

#![allow(unused)]
fn main() {
let mut v = Vec::new();
v.push(1); // might allocate
v.push(2); // might allocate
v.push(3); // might allocate
v.push(4); // might allocate
}

None of these operations returns a Result, so what happens if those allocations fail?

The answer depends on the toolchain, target, and configuration but is likely to descend into panic! and program termination. There is certainly no answer that allows an allocation failure on line 3 to be handled in a way that allows the program to move on to line 4.

This assumption of infallible allocation gives good ergonomics for code that runs in a "normal" userspace, where there's effectively infinite memory—or at least where running out of memory indicates that the computer as a whole has bigger problems elsewhere.

However, infallible allocation is utterly unsuitable for code that needs to run in environments where memory is limited and programs are required to cope. This is a (rare) area where there's better support in older, less memory-safe, languages:

  • C is sufficiently low-level that allocations are manual, and so the return value from malloc can be checked for NULL.
  • C++ can use its exception mechanism to catch allocation failures in the form of std::bad_alloc exceptions.4

Historically, the inability of Rust's standard library to cope with failed allocation was flagged in some high-profile contexts (such as the Linux kernel, Android, and the Curl tool), and so work to fix the omission is ongoing.

The first step was the "fallible collection allocation" changes, which added fallible alternatives to many of the collection APIs that involve allocation. This generally adds a try_<operation> variant that results in a Result<_, AllocError>; for example:

These fallible APIs only go so far; for example, there is (as yet) no fallible equivalent to Vec::push, so code that assembles a vector may need to do careful calculations to ensure that allocation errors can't happen:

#![allow(unused)]
fn main() {
fn try_build_a_vec() -> Result<Vec<u8>, String> {
    let mut v = Vec::new();

    // Perform a careful calculation to figure out how much space is needed,
    // here simplified to...
    let required_size = 4;

    v.try_reserve(required_size)
        .map_err(|_e| format!("Failed to allocate {} items!", required_size))?;

    // We now know that it's safe to do:
    v.push(1);
    v.push(2);
    v.push(3);
    v.push(4);

    Ok(v)
}
}

As well as adding fallible allocation entrypoints, it's also possible to disable infallible allocation operations, by turning off the no_global_oom_handling config flag (which is on by default). Environments with limited heap (such as the Linux kernel) can explicitly disable this flag, ensuring that no use of infallible allocation can inadvertently creep into the code.

Things to Remember

  • Many items in the std crate actually come from core or alloc.
  • As a result, making library code no_std compatible may be more straightforward than you might think.
  • Confirm that no_std code remains no_std compatible by checking it in CI.
  • Be aware that working in a limited-heap environment currently has limited library support.

1

See The Embedonomicon or Philipp Oppermann's older blog post for information about what's involved in creating a no_std binary.

2

Be aware that this can occasionally go wrong. For example, at the time of writing, the Error trait is defined in core:: but is marked as unstable there; only the std:: version is stable.

3

Prior to Rust 2018, extern crate declarations were used to pull in dependencies. This is now entirely handled by Cargo.toml, but the extern crate mechanism is still used to pull in those parts of the Rust standard library (the sysroot crates) that are optional in no_std environments.

4

It's also possible to add the std::nothrow overload to calls to new and check for nullptr return values. However, there are still container methods like vector<T>::push_back that allocate under the covers and that can therefore signal allocation failure only via an exception.