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 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 use
d 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 14), 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:
alloc::boxed::Box<T>
alloc::rc::Rc<T>
alloc::sync::Arc<T>
alloc::vec::Vec<T>
alloc::string::String
format!
alloc::collections::BTreeMap<K, V>
alloc::collections::BTreeSet<T>
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 identicalcore::
oralloc::
crates (which requiresuse
of the full type name, due to the absence of thestd
prelude). - Shifting from
HashMap
/HashSet
toBTreeMap
/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 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 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:
#![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 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 forNULL
. - 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:
Box::try_new
is available as an alternative toBox::new
Vec::try_reserve
is available as an alternative toVec::reserve
BTreeMap::try_insert
is available as an alternative toBTreeMap::insert
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.