RISC-V Bytes: Rust Cross-Compilation

January 30, 2022

This is part of a series on the blog where we explore RISC-V by breaking down real programs and explaining how they work. You can view all posts in this series on the RISC-V Bytes page.

Today we are going to take a brief detour from our previous posts in this series and look at Rust Cross-Compilation for RISC-V. This will be a shorter post focused on providing useful information about how rustc works, as well as offering exact steps and configuration to target RISC-V when compiling your Rust programs. There are a number of existing references for building Rust programs for RISC-V, but I have found that many of them are targeting a bare metal (i.e. no_std) use case, such as running embedded code on a microcontroller, or they don’t provide much background context on why various configuration is being used and how required tooling is being managed. I’ll attempt to be more comprehensive here, and will also do my best to post any updates as the ecosystem continues to evolve. Let’s get started!


If you are already a seasoned Rust programmer feel free to skip this section.

While rustc is the Rust compiler, most Rust projects make use of two other tools to invoke and manage rustc: cargo and rustup. rustup is an installer, meaning that it will handle making sure you have the correct versions of rustc and other language components from the various channels for toolchains you are using, as well as ensuring that the standard library is installed for every compilation target. cargo is the Rust package manager and build tool, and itself can be installed using rustup. Throughout this post we will only be interacting with rustc via cargo and its related configuration. Before we move forward, make sure that you have followed the instructions to install rustup.

rustc is distributed via stable, beta, and nightly channels. You may opt to consume from a channel other than stable if there are features required that have not been included in a formal release.

Hosts vs. Targets

It’s worth taking a moment to explicitly distinguish toolchains and supporting libraries, which can be confusing for folks who are not already familiar with cross-compilation. A toolchain is the set of tools required to build artifacts on a specific host machine. For example, I am using a 64-bit Linux machine (or x86_64-unknown-linux-gnu in Rust target parlance), and I typically want to build artifacts that can run on a 64-bit Linux machine (i.e. the same machine). I also sometimes want to build artifacts that can run on other types of machines, which is what we are doing today. Cross-compilation refers to any time that the host machine does not match the target machine.

x86_64-unknown-linux-gnu and other machine type identifiers are referred to as target triples.

With this in mind, you may have guessed that since we are only ever going to be building on my 64-bit Linux machine, we only need the x86_64-unknown-linux-gnu toolchain. However, because we are interested in cross-compiling to a RISC-V machine, we are going to need to add an additional target for our 64-bit Linux toolchain. This can be summarized by the following:

  • A toolchain is needed for every type of host machine.
  • Supporting libraries are needed for every type of target machine.

rustc is inherently a cross-compiler, meaning that we can use the same host toolchain to compile for many different targets as long as we have the supporting libraries. This is not true of all other compiler toolchains. For example, gcc, as we will see later, requires a separate compiler for every host/target pair.

Rust uses a familiar three-tier hierarchy for target support, which could really be broken out into six tiers. Using the same vernacular as above, the six tiers could be translated as:

  • Tier 1 with Host Tools: toolchain and target both available and guaranteed to work.
  • Tier 1: target available and guaranteed to work. There are no targets in this tier today.
  • Tier 2 with Host Tools: toolchain and target both available and guaranteed to build.
  • Tier 2: target available and guaranteed to build. Standard library may or may not be supported.
  • Tier 3 with Host Tools: toolchain and target are supported but not built, distributed, or guaranteed to work.
  • Tier 3: target is supported but not built, distributed, or guaranteed to work. Standard library may or may not be supported.


Now that we know what components are required to build Rust programs on and for various machines, we can install the toolchain for our host machine and bootstrap a project. First lets check what toolchains are already present:

$ rustup toolchain list
stable-x86_64-unknown-linux-gnu (default)

rustup has already installed the default toolchain for our machine, so we don’t need to take any other steps on that front. We can take a look at the components of the toolchain in ~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:

$ ls ~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin
cargo  cargo-clippy  cargo-fmt  clippy-driver  rustc  rustdoc  rustfmt  rust-gdb  rust-gdbgui  rust-lldb

You’ll notice both cargo and rustc are present, as well as a number of other tools, such as formatters and debuggers. When you install a toolchain, rustup will also install required target libraries for that same type of machine:

$ ls ~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/lib/
libaddr2line-e8504b1ed73d6c6f.rlib          libhashbrown-6c448d94453f4d95.rlib     libprofiler_builtins-2b27848f56b860ee.rlib          librustc_std_workspace_std-e2232747f29a2298.rlib
libadler-671a9f10c55c6c87.rlib              liblibc-b4424726f33da388.rlib          librustc_demangle-7c5cb27d99d10614.rlib             libstd-4c74cbab78ec4891.rlib
liballoc-aa0bad4c4d134922.rlib              libmemchr-bed369233e55d851.rlib        librustc-stable_rt.asan.a                           libstd-4c74cbab78ec4891.so
libcfg_if-c0badcb9f7c5eab7.rlib             libminiz_oxide-e35e56ad39c7e20e.rlib   librustc-stable_rt.lsan.a                           libstd_detect-0ddec007a0883060.rlib
libcompiler_builtins-5667a4a7e2c48d47.rlib  libobject-ee577127549b7793.rlib        librustc-stable_rt.msan.a                           libtest-3d8d1f7e04ea304d.rlib
libcore-6cfcec236d576603.rlib               libpanic_abort-b6371bac4bee0de9.rlib   librustc-stable_rt.tsan.a                           libtest-3d8d1f7e04ea304d.so
libgetopts-86970f502db1b86e.rlib            libpanic_unwind-0ef58120f7b95253.rlib  librustc_std_workspace_alloc-22835d1ac5e3244b.rlib  libunicode_width-86b36790f7c9a304.rlib
libgimli-411eeeec028606dc.rlib              libproc_macro-b961a3f930b2d0eb.rlib    librustc_std_workspace_core-483ad457673e0f5c.rlib   libunwind-84878e033904a7a4.rlib

So we have support for building on and for x86_64-unknown-linux-gnu machines, but we need to add target support for RISC-V. No RISC-V targets are supported in Tier 1, but riscv64gc-unknown-linux-gnu is supported in Tier 2 (it also includes host tools, which we will not be using today). Let’s go ahead and tell rustup to add support:

All Tier 3 RISC-V targets (riscv32i-unknown-none-elf, riscv32imac-unknown-none-elf, riscv32imc-unknown-none-elf, riscv64gc-unknown-none-elf, riscv64imac-unknown-none-elf) only support no_std development. More on this in a bit.

$ rustup target add riscv64gc-unknown-linux-gnu
info: downloading component 'rust-std' for 'riscv64gc-unknown-linux-gnu'
info: installing component 'rust-std' for 'riscv64gc-unknown-linux-gnu'
 22.4 MiB /  22.4 MiB (100 %)  13.5 MiB/s in  1s ETA:  0s

You’ll notice that all rustup does here is add library support (i.e. we are not downloading a RISC-V toolchain). We can see now that these libraries live under the x86_64-unknown-linux-gnu toolchain directory in a new riscv64gc-unknown-linux-gnu directory:

$ ls ~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/riscv64gc-unknown-linux-gnu/lib/
libaddr2line-b9f13a31d36ace4d.rlib          libgimli-26f1e8e2a29c6abc.rlib        libpanic_unwind-f075600c05f846f9.rlib               libstd-b9a58deffd9afad7.so
libadler-e58e6ef377f5b261.rlib              libhashbrown-68b1f88e6bc5c009.rlib    libproc_macro-77a9b848fe88e73b.rlib                 libstd_detect-b0b3246e370d99f5.rlib
liballoc-08e2a49fbdb36fe7.rlib              liblibc-1162f95a581d7874.rlib         librustc_demangle-4132820dd26e4dd2.rlib             libtest-33230b1de6d8a7e4.rlib
libcfg_if-57cb37c2109ec8d3.rlib             libmemchr-4ea2a6be1c1298e6.rlib       librustc_std_workspace_alloc-69bfa5a0f9a81fa5.rlib  libtest-33230b1de6d8a7e4.so
libcompiler_builtins-09da015b98bbb243.rlib  libminiz_oxide-3579444854cd0b3b.rlib  librustc_std_workspace_core-cb5020bd1de8755e.rlib   libunicode_width-c8ae7d271f61c6bd.rlib
libcore-e22fac431ac00ff6.rlib               libobject-361332bd32322cb1.rlib       librustc_std_workspace_std-65dffd0afcdbdc70.rlib    libunwind-4d764747497457ba.rlib
libgetopts-4abf1528f3149ce7.rlib            libpanic_abort-e8bba43be6b6e1d1.rlib  libstd-b9a58deffd9afad7.rlib

With these components in place, let’s try to build something.


We can setup a new project using cargo:

cargo new rusty-risc

This will give us the following directory structure:

└── rusty-risc
    ├── Cargo.toml
    └── src
        └── main.rs

By default, cargo is going to give us a fairly straightforward “Hello, world!” program in main.rs. However, while it may look simple, this program is actually doing quite a lot.

fn main() {
    println!("Hello, world!");

In order for the println! macro to show text in our terminal, it is going to need to be able to perform I/O, which requires interacting with the operating system via syscalls, typically accessed by libc. The Rust standard library handles that interaction for us, and we saw earlier that rustup took care of downloading it for our riscv64gc-unknown-linux-gnu. Let’s see how far that gets us.

As mentioned before, we are going to use cargo to build our program. cargo supports passing configuration via flags or a configuration file. In order to keep track of all of our configuration, we’ll use the latter by creating a .cargo directory in the project and adding a config.toml inside. Initially, we only need to specify that we want to build for riscv64gc-unknown-linux-gnu:


target = "riscv64gc-unknown-linux-gnu"

Now any time we invoke cargo build, we should only build for the specified target. However, if we give it a try, we’ll see that we immediately hit some issues (output condensed):

$ (rusty-risc) cargo build
   Compiling rusty-risc v0.1.0 (/home/dan/code/github.com/hasheddan/testing/rusty-risc)
error: linking with `cc` failed: exit status: 1

          /usr/bin/ld: /home/dan/code/github.com/hasheddan/testing/rusty-risc/target/riscv64gc-unknown-linux-gnu/debug/deps/rusty_risc-c85960b13f0f920c.26a7wz4sk6dm01p0.rcgu.o: Relocations in generic ELF (EM: 243)
          /usr/bin/ld: /home/dan/code/github.com/hasheddan/testing/rusty-risc/target/riscv64gc-unknown-linux-gnu/debug/deps/rusty_risc-c85960b13f0f920c.26a7wz4sk6dm01p0.rcgu.o: error adding symbols: file in wrong format
          collect2: error: ld returned 1 exit status

There are a few things to notice here. First of all the overall error says that we are linking with cc. This is typically a symbolic link to the system’s C compiler, which on my Ubuntu machine is going to be gcc. We can double check with the following commands:

$ (rusty-risc) which cc
$ (rusty-risc) readlink -f /usr/bin/cc

Since this is an x86_64-linux compiler and we are targeting riscv-64, it makes sense that this would cause problems, but why is Rust choosing to use cc and does it choose to for every target? The answer is no and defaults can be found in the rustc source.

File names are links to source.


use crate::spec::{CodeModel, Target, TargetOptions};

pub fn target() -> Target {
    Target {
        llvm_target: "riscv64-unknown-linux-gnu".to_string(),
        pointer_width: 64,
        data_layout: "e-m:e-p:64:64-i64:64-i128:128-n64-S128".to_string(),
        arch: "riscv64".to_string(),
        options: TargetOptions {
            code_model: Some(CodeModel::Medium),
            cpu: "generic-rv64".to_string(),
            features: "+m,+a,+f,+d,+c".to_string(),
            llvm_abiname: "lp64d".to_string(),
            max_atomic_width: Some(64),

Here we can see that the TargetOptions set a few values, then ultimately defer to the base options for GNU/Linux, which are mostly the same a base Linux, which consume the overall target defaults:


use crate::spec::TargetOptions;

pub fn opts() -> TargetOptions {
    TargetOptions { env: "gnu".to_string(), ..super::linux_base::opts() }


use crate::spec::{RelroLevel, TargetOptions};

pub fn opts() -> TargetOptions {
    TargetOptions {
        os: "linux".to_string(),
        dynamic_linking: true,
        executables: true,
        families: vec!["unix".to_string()],
        has_rpath: true,
        position_independent_executables: true,
        relro_level: RelroLevel::Full,
        has_thread_local: true,
        crt_static_respected: true,

Ultimately, if we drill down into the defaults, we’ll find that the default linker_flavor is in fact gcc:


            linker_flavor: LinkerFlavor::Gcc,
            linker: option_env!("CFG_DEFAULT_LINKER").map(|s| s.to_string()),

So that explains why we are invoking cc, but while we are here, its worth noticing that linux_base.rs also sets dynamic_linking: true, which is not the default. This is not the case for the Tier 3 riscv64gc-unknown-none-elf target, which consumes the default behavior to produce static binaries, but overrides the default linker to be rust-lld:


use crate::spec::{CodeModel, LinkerFlavor, LldFlavor, PanicStrategy, RelocModel};
use crate::spec::{Target, TargetOptions};

pub fn target() -> Target {
    Target {
        data_layout: "e-m:e-p:64:64-i64:64-i128:128-n64-S128".to_string(),
        llvm_target: "riscv64".to_string(),
        pointer_width: 64,
        arch: "riscv64".to_string(),

        options: TargetOptions {
            linker_flavor: LinkerFlavor::Lld(LldFlavor::Ld),
            linker: Some("rust-lld".to_string()),
            llvm_abiname: "lp64d".to_string(),
            cpu: "generic-rv64".to_string(),
            max_atomic_width: Some(64),
            features: "+m,+a,+f,+d,+c".to_string(),
            executables: true,
            panic_strategy: PanicStrategy::Abort,
            relocation_model: RelocModel::Static,
            code_model: Some(CodeModel::Medium),
            emit_debug_gdb_scripts: false,
            eh_frame_header: false,

This makes sense as the riscv64gc-unknown-none-elf target is not expecting to be able to target a Linux platform, and thus presumably will not need to link libc or load shared libraries at runtime (i.e. dynamically linked). However, as mentioned before, this also means that we cannot use the Rust standard library. In fact, if we use rustup to add the riscv64gc-unknown-none-elf target and change our build to use it, we’ll see an error indicating that we must declare #![no_std]:

$ rustup target add riscv64gc-unknown-none-elf
info: downloading component 'rust-std' for 'riscv64gc-unknown-none-elf'
info: installing component 'rust-std' for 'riscv64gc-unknown-none-elf'
$ rustup tarcargo build
   Compiling rusty-risc v0.1.0 (/home/dan/code/github.com/hasheddan/testing/rusty-risc)
error[E0463]: can't find crate for `std`
  = note: the `riscv64gc-unknown-none-elf` target may not support the standard library
  = note: `std` is required by `rusty_risc` because it does not declare `#![no_std]`

For more information about this error, try `rustc --explain E0463`.
error: could not compile `rusty-risc` due to previous error

If we were to change our main.rs to contain a very small #![no_std] program, perhaps the one in The Embedonomicon, we would see that it builds successfully:



use core::panic::PanicInfo;

fn panic(_panic: &PanicInfo<'_>) -> ! {
    loop {}

$ (rusty-risc) cargo build
   Compiling rusty-risc v0.1.0 (/home/dan/code/github.com/hasheddan/testing/rusty-risc)
    Finished dev [unoptimized + debuginfo] target(s) in 0.11s

Though this compiles, we would not be able to run it under a normal userspace emulator (more on this below).

This is important as we did not have to download any RISC-V toolchain (outside of the rustup target) to be able to build for riscv64gc-unknown-none-elf. You’ll frequently see bare metal RISC-V Rust projects, such as riscv-rust-quickstart and Stephen Marz’s fantastic osblog take advantage of this. In fact, you can see where each of them switched from explicitly specifying an external linker to using Rust’s default for riscv64gc-unknown-none-elf and riscv32imac-unknown-none-elf respectively. However, we want to build a program that can run on a Linux system, so we don’t have this luxury.

Fortunately, some awesome RISC-V folks have a riscv-gnu-toolchain repository with detailed instructions on how you can build a cross-compiler. If you’re running Ubuntu, as I am, they also publish periodic builds that you can download and install. As of the writing of this post, I am using the 2022.01.17 release for riscv64-glibc-ubuntu-20.04.

Now that we have our toolchain, let’s swap back to our “Hello, world!” program and change our target back to riscv64gc-unknown-linux-gnu. We can add a target-specific linker to override the default cc that Rust will use:


target = "riscv64gc-unknown-linux-gnu"

linker = "riscv64-unknown-linux-gnu-gcc"

Alright, let’s see how that goes:

$ (rusty-risc) cargo build
   Compiling rusty-risc v0.1.0 (/home/dan/code/github.com/hasheddan/testing/rusty-risc)
    Finished dev [unoptimized + debuginfo] target(s) in 0.25s

Looks good! Now in order to see if this runs succesfully, we are going to need to acquire a userspace emulator for 64-bit RISC-V. If you haven’t already installed QEMU head back to the first RISC-V Bytes post where we looked at cross-platform debugging for download instructions. Once installed, we should be able to invoke the binary produced from cargo build directly thanks to binfmt_misc, which will automatically invoke qemu-riscv64.

$ (rusty-risc) ./target/riscv64gc-unknown-linux-gnu/debug/rusty-risc 
/lib/ld-linux-riscv64-lp64d.so.1: No such file or directory

Hmm, that doesn’t look great. If you remember earlier, the default behavior that the riscv64gc-unknown-linux-gnu target consumed was producing a dynamically linked executable. We can use readelf to see where our ELF file is requesting ld-linux-riscv64-lp64d.so.1 as its “interpreter”:

$ (rusty-risc) riscv64-unknown-linux-gnu-readelf -l ./target/riscv64gc-unknown-linux-gnu/debug/rusty-risc

  INTERP         0x0000000000000270 0x0000000000000270 0x0000000000000270
                 0x0000000000000021 0x0000000000000021  R      0x1
      [Requesting program interpreter: /lib/ld-linux-riscv64-lp64d.so.1]

When we installed our toolchain, it came with the ld.so dynamic linker/loader, which you should be able to find under sysroot/lib in the location where you installed your toolchain (mine is ~/opt/riscv):

$ (rusty-risc) ls -la ~/opt/riscv/sysroot/lib/ | grep ld
-rwxr-xr-x 1 dan dan  1120424 Jan 16 20:51 ld-2.33.so
lrwxrwxrwx 1 dan dan       10 Jan 16 20:51 ld-linux-riscv64-lp64d.so.1 -> ld-2.33.so
drwxrwxr-x 2 dan dan    12288 Jan 16 20:31 ldscripts


Let’s try invoking it with the dynamic loader:

$ (rusty-risc) ~/opt/riscv/sysroot/lib/ld-linux-riscv64-lp64d.so.1 ./target/riscv64gc-unknown-linux-gnu/debug/rusty-risc
./target/riscv64gc-unknown-linux-gnu/debug/rusty-risc: error while loading shared libraries: libgcc_s.so.1: cannot open shared object file: No such file or directory

Not quite there. One of our dynamically linked libraries, libgcc_s.so, can’t be found. While we’re here, let’s see all of the libraries we need to load:

$ (rusty-risc) riscv64-unknown-linux-gnu-readelf -a ./target/riscv64gc-unknown-linux-gnu/debug/rusty-risc | grep NEEDED
 0x0000000000000001 (NEEDED)             Shared library: [libgcc_s.so.1]
 0x0000000000000001 (NEEDED)             Shared library: [libpthread.so.0]
 0x0000000000000001 (NEEDED)             Shared library: [libdl.so.2]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x0000000000000001 (NEEDED)             Shared library: [ld-linux-riscv64-lp64d.so.1]

All of these libraries live alongside our dynamic loader in ~/opt/riscv/sysroot/lib:

$ (rusty-risc) ls -la ~/opt/riscv/sysroot/lib/ | grep -E 'libgcc|libpthread|libdl|libc'
-rwxr-xr-x 1 dan dan 11675800 Jan 16 20:51 libc-2.33.so
-rwxr-xr-x 1 dan dan   148752 Jan 16 20:50 libcrypt-2.33.so
lrwxrwxrwx 1 dan dan       16 Jan 16 20:50 libcrypt.so.1 -> libcrypt-2.33.so
lrwxrwxrwx 1 dan dan       12 Jan 16 20:50 libc.so.6 -> libc-2.33.so
-rwxr-xr-x 1 dan dan   118136 Jan 16 20:50 libdl-2.33.so
lrwxrwxrwx 1 dan dan       13 Jan 16 20:50 libdl.so.2 -> libdl-2.33.so
-rw-r--r-- 1 dan dan      132 Jan 16 21:05 libgcc_s.so
-rw-r--r-- 1 dan dan   599912 Jan 16 21:05 libgcc_s.so.1
-rwxr-xr-x 1 dan dan  1214880 Jan 16 20:50 libpthread-2.33.so
lrwxrwxrwx 1 dan dan       18 Jan 16 20:50 libpthread.so.0 -> libpthread-2.33.so

The loader identifies shared libraries with using a cache in /etc/ld.so.cache that is managed by ldconfig based on the contents of /etc/ld.so.config. However, we can add directories at runtime that we would like to take precedence over any in the cache using LD_LIBRARY_PATH:

$ (rusty-risc) LD_LIBRARY_PATH=~/opt/riscv/sysroot/lib ~/opt/riscv/sysroot/lib/ld-linux-riscv64-lp64d.so.1 --library-path ~/opt/riscv/sysroot/lib ./target/riscv64gc-unknown-linux-gnu/debug/rusty-risc
Hello, world!

The program runs successfully! We could automate this to make it possible to invoke using cargo run by passing LD_LIBRARY_PATH as a flag and setting it as the runner:

This can be cleaned up further so we aren’t using absolute paths. Also, remember that this working is predicated on binfmt_misc invoking qemu-riscv64 for us automatically.


target = "riscv64gc-unknown-linux-gnu"

runner = "/home/dan/opt/riscv/sysroot/lib/ld-linux-riscv64-lp64d.so.1 --library-path /home/dan/opt/riscv/sysroot/lib"
linker = "riscv64-unknown-linux-gnu-gcc"

Lastly, if we wanted to build a statically linked executable instead, we could supply the crt-static flag to rustc:


target = "riscv64gc-unknown-linux-gnu"

rustflags = ["-C", "target-feature=+crt-static"]
linker = "riscv64-unknown-linux-gnu-gcc"

To understand how this impacts compilation, we can check the flag descriptions in rustc:

$ (rusty-risc) rustc -C help | grep target-features
    -C           target-feature=val -- target specific attributes. (`rustc --print target-features` for details). This feature is unsafe.

$ (rusty-risc) rustc --print target-features | grep crt-static
    crt-static                      - Enables C Run-time Libraries to be statically linked.

Now let’s see if we can run without specifying any runner at all:

$ (rusty-risc) cargo build
   Compiling rusty-risc v0.1.0 (/home/dan/code/github.com/hasheddan/testing/rusty-risc)
    Finished dev [unoptimized + debuginfo] target(s) in 2.40s

$ (rusty-risc) cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/riscv64gc-unknown-linux-gnu/debug/rusty-risc`
Hello, world!

Nice! We can now build both statically and dynamically linked Rust binaries for RISC-V Linux platforms.

Concluding Thoughts

This post was a bit of a deviation from our normal content, but I am a big fan of Rust and naturally enjoy when I see more folks producing RISC-V builds. Hopefully centralizing some of this information will make it easier for folks to pick up.

As always, these posts are meant to serve as a useful resource for folks who are interested in learning more about RISC-V and low-level software in general. If I can do a better job of reaching that goal, or you have any questions or comments, please feel free to send me a message @hasheddan on Twitter!