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 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. 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:
alloc::boxed::Box<T>
alloc::rc::Rc<T>
alloc::sync::Arc<T>
alloc::vec::Vec<T>
alloc::string::String
alloc::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—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 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 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 forNULL
. - 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:
Vec::try_reserve
is available as an alternative toVec::reserve
.Box::try_new
is available (with the nightly toolchain) as an alternative toBox::new
.
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 fromcore
oralloc
. - As a result, making library code
no_std
compatible may be more straightforward than you might think. - Confirm that
no_std
code remainsno_std
compatible by checking it in CI. - Be aware that working in a limited-heap environment currently has limited library support.
See The Embedonomicon or
Philipp Oppermann's older blog post for information about
what's involved in creating a no_std
binary.
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.
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.
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.