Item 37: 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 multi-threading support to file I/O. For convenience, many of the items from std are automatically imported into your program, via the prelude: a set of common use statements.

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, so the focus here is how to make sure that library code is available for those poor souls who 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 flavours of Iterator.

The different names for these fundamental types starts 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 an easy way to tell if a std:: item is available in 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). 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. This is because a core1 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 15), this restriction still means that 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, is grouped into Rust's alloc library.

As with core, these alloc variants are actually the same types under the covers; for example, following the source link from the documentation for std::vec::Vec leads to a src/alloc/... location.

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

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

// Requires `alloc`.
extern crate alloc;

Functionality that's enabled by turning on alloc includes 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 – e.g. for libraries that don'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 multi-threaded 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 multi-threaded, 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:

  • 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 for the work of making a crate no_std-compatible to be undone – 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 (e.g. --target thumbv6m-none-eabi) that does not support std – 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 re-use 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 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 function:

#[cfg(feature = "alloc")]
extern crate alloc;

As ever with feature-gated code (Item 26), make sure that your CI system 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), or an more generous environment where heap allocation is allowed (core + alloc). However, there are some important environments that fall between these two camps.

In particular, 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:

    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 to this question depends on the toolchain, target and configuration, but is likely to involve 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 is likely to have 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 mechanism3 to catch allocation failures in the form of std::bad_alloc exceptions.

At the time of writing, Rust's inability to cope with failed allocation has been flagged in some high-profile contexts (such as the Linux kernel, Android, and the Curl tool), and so work is on-going to fix the omission.

The first step is 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 a Result<_, AllocError> (although the try_... variant is currently only available with the nightly toolchain). 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:

    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| {
        MyError::new(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);

Fallible allocation is an area where work on Rust is on-going. The entrypoints described above will hopefully be stabilized and expanded, and there has also been a proposal to make infallible allocation operations controlled by a default-on feature – by explicitly disabling the feature, a programmer can then be sure that no use of infallible allocation has inadvertently crept into their program.


1: Pun intended.

2: 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 that are optional in no_std environments.

3: 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 which can therefore only signal allocation failure via an exception.