Adding Rust Stable libstd Support for Xous

9 min

Note: This article was originally posted as a backer update for Precursor backers.

Embedded targets lack Rust’s libstd, which in turn means they lack the conveniences of structures such as Vec, HashMap, Mutex, and Box. I added support for my OS, Xous to the Rust compiler’s stable channel without rebuilding the entire Rust ecosystem, thus enabling libstd support for an entirely new operating system. In this post I’ll show how it’s done.

tl;dr: It is possible to add support for a new OS to the Rust compiler’s stable channel without rebuilding everything, enabling libstd support for entirely new operating systems.

Rust is a programming language designed to support everything from embedded software all the way up to desktop applications. It offers many nice features such as threads, mutexes, and heap-allocated structures such as Vec and HashMap.

Unfortunately, anything that requires a heap depends on liballoc which (as of today) can only be used on nightly. This greatly limits the features that can be used.

A solution to this is to port the standard library to our target operating system, Xous. This involves patching the standard library to support our syscalls and then creating precompiled crates for our operating system. With a version of the standard library targeted for our operating system, it becomes much easier to port software to our platform. Additionally, it gives us a stable platform on which to build, thanks to Rust’s strong stability guarantees.

Precompiled Crates in Rust

Rust crates are all built at compile time. If you add a new dependency to your Cargo.toml file, it will be added to the build and compiled when you run cargo build.

Except that’s not quite true. There are several libraries that are shipped as precompiled binaries that are provided by the Rust installation. By my count there are over twenty precompiled libraries in my Rust Linux target, including libcore, liballoc, and libstd. This also includes several behind-the-scenes libraries such as libunwind, libhashbrown, and libtest. These libraries are all built by the Rust compiler team when they produce a release and are shipped to you in binary form. This is currently the only instance of precompiled libraries in Rust.

When you add a new target with rustup target add [target], all it’s doing is downloading these precompiled libraries and extracting them to your sysroot. If we could somehow create our own version of these libraries for our own target, then we could create our own standard library for our own custom operating system.

Aside: Why Not Just Use nightly?

A common solution to getting features such as liballoc on embedded hardware is to use the nightly toolchain. Early on in Xous, we decided to avoid the nightly toolchain because it is too unstable by definition. Features that are not stabilized are likely to change, and if you’re forced to use the nightly channel then you are, by definition, relying on features that will change.

This is mostly alright when dealing with a small project, however the situation quickly grows out of hand as dependencies are added. When an unstable feature shifts, it’s challenging to update each individual dependency to track the change. One solution is to pin the project to a particular version of nightly, which simply invites technical debt – you’re no longer building for Rust, you’re now building for rust-nightly-20210803.

This problem is exacerbated by crates that automatically turn on features when they detect they’re running on nightly. For example, a crate may have assembly optimizations that will be enabled when running on nightly. All of these crates broke when Rust changed its assembly syntax. Crates that didn’t use these unstable features continued to work.

Rust is incredibly stable, but if you only target the stable channel. If you target nightly, then all bets are off. nightly changes constantly as experiments are tried and discarded, so any unstable features you rely on may disappear at any moment.

Adding Support for a New Operating System

The standard library relies on lots of unstable nightly features. Much like how a building’s foundation allows a building to be constructed on an unstable surface, libstd uses unstable nightly features to be used as the basis for stable programs. Rust has a hidden, undocumented environment variable called RUSTC_BOOTSTRAP that you should never ever use because it violates the core principles of Rust. What RUSTC_BOOTSTRAP allows you to do is to use nightly features on stable. It is explicitly designed to bootstrap the compiler and to build the standard library. Any other use is just asking for trouble.

Fortunately we’re building the standard library, so this flag is exactly what we need. We won’t run into any future compatibility issues because our standard library only targets one particular version of the compiler. When a future version of Rust is released, we will need to forward-port our changes to the new compiler, however this is a much smaller amount of code to change compared to reimplementing everything from scratch.

Broadly speaking, the steps are as follows:

  1. Create a compiler definition file
  2. Build libstd
  3. Deploy libstd

Let’s look at each step in turn.

1. Creating a Compiler Definition File

First, let’s prove to ourselves that Rust does not currently support our target:

$ rustc --target riscv32imac-unknown-none-elf
error: Error loading target specification: Could not find specification for target "riscv32imac-unknown-none-elf". Run `rustc --print target-list` for a list of built-in targets

$

Unsurprisingly, the compiler has never heard of our target. It’s not part of the list of built-in targets. Fortunately, Rust supports using an external JSON file to define a new target.

Let’s create a target JSON file for our operating system by using an existing platform as a reference. We can use the --print target-spec-json argument to copy an existing definition. Printing a target list requires nightly features, so let’s enable the bootstrap option. Remember that this argument may change in the future. This is an unstable, nightly feature. Such are the risks of working with nightly Rust.

RUSTC_BOOTSTRAP=1 rustc -Z unstable-options --print target-spec-json --target riscv32imac-unknown-none-elf > target.json

This generates a new file called target.json. We can use this as a base to construct our new target. Since our OS is named xous, let’s add a key to the JSON file:

{
  "arch": "riscv32",
  "cpu": "generic-rv32",
  "data-layout": "e-m:e-p:32:32-i64:64-n32-S128",
  "eh-frame-header": true,
  "emit-debug-gdb-scripts": false,
  "executables": true,
  "features": "+m,+a,+c",
  "os": "xous", // <------ Add an OS value here
  "position-independent-executables": true,
  "linker": "rust-lld",
  "linker-flavor": "ld.lld",
  "link-self-contained": true,
  "llvm-target": "riscv32",
  "max-atomic-width": 32,
  "panic-strategy": "abort",
  "relocation-model": "static",
  "target-pointer-width": "32",
  "unsupported-abis": [
    "cdecl",
    "stdcall",
    "fastcall",
    "vectorcall",
    "thiscall",
    "aapcs",
    "win64",
    "sysv64",
    "ptx-kernel",
    "msp430-interrupt",
    "x86-interrupt",
    "amdgpu-kernel"
  ]
}

Thanks to Rust PR#83800, we can add this to Rust by simply copying it to a directory under the Rust sysroot:

$ mkdir -p $(rustc --print sysroot)/lib/rustlib/riscv32imac-unknown-xous-elf/lib
$ cp target.json $(rustc --print sysroot)/lib/rustlib/riscv32imac-unknown-xous-elf/
$

With this change, we can see Rust now recognizes our target as valid:

$ rustc --target riscv32imac-unknown-none-elf
error: no input filename given

$

2. Compiling the Standard Library

The standard library is shipped alongside the Rust compiler, as well as various support tools such as Cargo, the Rust Language Server, and Rustdoc. Usually these are built together in stages, though there are options to build only certain components.

Since we’re only interested in building the standard library, we can skip building anything not related to this one task. That means we can directly invoke the build command, along with any environment variables that need to be set.

This boils down to a single cargo build statement that is moderately long:

CARGO_PROFILE_RELEASE_DEBUG=0 \
CARGO_PROFILE_RELEASE_DEBUG_ASSERTIONS=true \
RUSTC_BOOTSTRAP=1 \
RUSTFLAGS="-Cforce-unwind-tables=yes -Cembed-bitcode=yes" \
__CARGO_DEFAULT_LIB_METADATA="stablestd" \
cargo build \
	--target riscv32imac-unknown-xous-elf \
    -Zbinary-dep-depinfo \
    --release \
    --features "panic-unwind compiler-builtins-c compiler-builtins-mem" \
    --manifest-path "library/test/Cargo.toml"

This command will compile all of the basic Rust libraries for the current stable version in release mode. The end result will be several .rlib files under the target’s release directory.

3. Deploying the Standard Library

To deploy the standard library, all we need to do is copy all of the .rlib files into the sysroot:

$ cp target/riscv32imac-unknown-xous-elf/release/deps/*.rlib $(rustc --print sysroot)/lib/rustlib/riscv32imac-unknown-xous-elf/lib
$

Note that the rlib filenames can change if you rebuild the standard library, so if you’re hacking on the standard library you’ll want to remove older versions of the rlib. I develop on Windows so I have a Powershell script that handles this. Perhaps someone will create a Python script to do something similar.

Future Work

Packaging the resulting binaries is still an ongoing area of research. The good news is that it’s possible to package all the .rlib files and the target.json file together such that installation is very easy. Furthermore, .rlib files are platform-agnostic – the same files can be installed on Windows, Linux, Mac, or any other host.

Problems arise when it comes to versioning. What happens if we release a newer version of 1.54.0 support? How do we ensure users are running the correct compiler? What sort of installer should we have?

This is just the first step towards wider support. There’s still lots more work to do. We have CI builds that generate zipfile releases, and we’d like to understand better how to improve these releases.

Additionally, the Xous implementation of libstd still has many unimplemented features. While threading works, Mutex is backed by a spinlock. Additionally, Condvar does not yet work due to missing kernel support, meaning we don’t yet have support for features such as channel.

liballoc does work, which means boxed closures work just fine. There’s something immensely satisfying about having an interrupt handler be a closure.

Closing Remarks

Rust’s stable channel provides a very solid foundation on top of which we can build an operating system. With its six week release cycle, we only have to forward-port our changes every month and a half. Having support for libstd greatly increases the number of supported packages we can use, at the expense of needing to track memory more closely.

Overall this is a very exciting development, and I look forward to using standard Rust constructs in an embedded operating system. I’m happy that Rust has made it easy to support new targets, and the cross-platform nature means we don’t have to have separate releases for each platform.

All of this helps us to utilize Rust to its fullest potential, giving us a base that is constructed in a language that is safe, fast, and efficient.