When the board speaks a different language
You have a RISC-V development board on your desk. It's a Sipeed Maix, a HiFive, or a custom FPGA setup you just brought up. You write a Rust program that blinks an LED or reads a sensor. You hit cargo run. Rust compiles the code, hands it to the operating system, and the OS refuses to execute it. Your host machine runs x86 or ARM. The board runs RISC-V. The binary format is wrong. The instruction set is wrong. The runtime assumptions are wrong.
Cross-compilation bridges this gap. You tell the Rust compiler to generate code for a different architecture than the one you're running on. You specify the exact CPU features, the absence of an operating system, and the binary format. The result is a file you can flash to the board or load into a simulator. The compiler does the heavy lifting of translation; you manage the contract between your code and the silicon.
The target triple is your contract
Rust identifies every compilation target with a triple. The triple is a string that encodes the architecture, the vendor, the operating system, and the environment. For RISC-V bare-metal development, the standard triple is riscv64gc-unknown-none-elf.
Break it down:
riscv64: The architecture is RISC-V, 64-bit mode.gc: The instruction set extensions.gstands for the general-purpose set, which includes integer multiplication/division (m), atomic operations (a), single-precision floating point (f), and double-precision floating point (d). Thecadds the compressed instruction set, which packs two instructions into 16 bits to save flash space.unknown-none: The vendor is unknown (open standard), and there is no operating system. This signalsno_stdmode.elf: The output binary uses the Executable and Linkable Format.
The triple is your contract with the hardware. If you compile with gc but your chip only supports im, the binary will contain floating-point instructions the chip cannot execute. The processor will trap, and your firmware will crash on the first float operation. Match the target to the metal.
Setting up the toolchain
Rust manages targets through rustup. The rustup tool downloads the standard library and compiler support for specific triples. You don't install separate cross-compilers or linkers for most Rust targets. The toolchain is self-contained.
Add the RISC-V target to your active toolchain:
rustup target add riscv64gc-unknown-none-elf
This command fetches the precompiled core and alloc crates for the target, along with the necessary linker scripts and ABI definitions. The download is small because bare-metal targets don't include the full std library.
Convention aside: always use rustup to manage targets. System package managers like apt or brew often lag behind the Rust release cycle and can introduce ABI mismatches. rustup guarantees the target matches your compiler version.
Once the target is installed, build your project:
cargo build --target riscv64gc-unknown-none-elf
Cargo invokes rustc with the target flag. The compiler switches its code generator to emit RISC-V instructions. It links against the no_std standard library. The output lands in target/riscv64gc-unknown-none-elf/debug/your_crate. The file is an ELF binary, not an executable for your host. You need a flasher or a simulator to run it.
Trust rustup to keep the toolchain in sync. If you update your Rust version, rustup handles the target updates automatically.
The no_std reality
The unknown-none part of the triple triggers no_std mode. This doesn't mean Rust has no standard library. It means the std crate is unavailable. The std crate assumes an operating system: file systems, threads, dynamic memory allocation, and process termination. Bare-metal targets have none of these.
You still get the core crate. core provides fundamental types, traits, iterators, and synchronization primitives. You also get alloc if you opt in, which provides heap allocation via Box, Vec, and String. You must declare an allocator if you use alloc.
Mark your crate as no_std in the root file:
#![no_std]
#![no_main]
// Core is always available. It contains the language primitives.
use core::panic::PanicInfo;
// You must provide a panic handler. Without an OS, there's no default behavior.
// This function runs when a panic occurs. It must never return.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
// In a real device, you might halt the CPU or trigger a watchdog.
// For simulation, an infinite loop is common.
loop {}
}
The #![no_main] attribute tells the compiler that you won't use the standard main function. The standard main has a runtime prologue that initializes the heap and sets up arguments. Bare-metal code needs a different entry point. You define _start or use a framework like cortex-m-rt (adapted for RISC-V) to handle reset vectors.
If you forget #![no_std] and try to use std on a none target, the compiler rejects you with E0463 (can't find crate for std). The target simply doesn't provide the std crate. The error is unambiguous: you're asking for OS features that don't exist.
A realistic bare-metal entry point
A minimal bare-metal program needs an entry point that the linker recognizes. The default linker script for unknown-none-elf looks for a symbol named _start. You mark it with #[no_mangle] to prevent name mangling and pub extern "C" to use the C calling convention, which matches the ABI expected by the bootloader or reset vector.
#![no_std]
#![no_main]
use core::panic::PanicInfo;
// The entry point. The linker places this at the reset vector.
// #[no_mangle] keeps the symbol name exactly as _start.
// extern "C" ensures the ABI matches the hardware expectations.
#[no_mangle]
pub extern "C" fn _start() -> ! {
// Initialize hardware here.
// Configure clocks, set up GPIO, start peripherals.
// This function must never return.
loop {
// Main loop.
// Blink an LED or process sensor data.
}
}
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
The Cargo.toml should specify the crate type. While bin is the default, being explicit helps tooling understand the output:
[package]
name = "riscv-bare-metal"
version = "0.1.0"
edition = "2021"
[dependencies]
# No dependencies needed for this minimal example.
# Add `alloc` if you need Vec or Box.
Convention aside: keep #![no_std] and #![no_main] at the very top of your crate root. It's a community convention that makes the crate's nature immediately visible to reviewers and tools. Don't hide these attributes deep in the file.
Pitfalls and compiler traps
Cross-compiling for RISC-V introduces specific failure modes. Recognizing them early saves debugging time.
Wrong ISA extensions. If your chip lacks the c extension but you compile with gc, the binary contains compressed instructions. The processor treats them as invalid opcodes. The result is an illegal instruction trap. Check your chip's datasheet. If it only supports imac, use a target that matches, or disable compressed instructions via compiler flags.
Missing linker. Some setups require an external linker. Rust's built-in rust-lld handles most cases, but complex memory layouts might need a custom linker script. If you see a linker error about missing symbols, ensure your _start is defined and exported. The compiler won't generate a default entry point in no_std mode.
Panic handler missing. If you use no_std but forget the panic handler, the linker fails with an undefined reference to rust_begin_unwind. The core library calls this function on panic. You must provide it. The error is clear: define #[panic_handler] and never return from it.
Tier 3 targets. The Rust project classifies targets by support level. Tier 1 targets are fully tested and stable. Tier 3 targets are community-maintained and may break between releases. The riscv64im-unknown-none-elf target is a tier 3 option in Rust 1.94.0. It lacks the g extensions and compressed instructions. Use it only when your hardware strictly matches the im profile. Tier 3 targets can change behavior without warning. Check the Rust release notes before upgrading if you rely on them.
Counter-intuitive but true: the more restricted your target, the more control you have. A tier 3 target forces you to verify every assumption. That verification prevents subtle hardware bugs.
Choosing the right target
Select the target based on your hardware capabilities and development needs. Use the parallel structure below to decide.
Use riscv64gc-unknown-none-elf when your chip supports the full general-purpose ISA including floating point and compressed instructions. This is the default for most modern RISC-V microcontrollers. It balances performance and code size. The g set covers integer math, atomics, and floats. The c set reduces flash usage.
Use riscv64im-unknown-none-elf when your chip only supports integer multiplication and division without atomics or floats. This target is tier 3 and community-maintained. It produces smaller binaries but lacks hardware float support. Use it for minimal cores or when flash space is extremely constrained. Verify the target exists in your Rust version with rustc --print target-list.
Use riscv64gc-unknown-none-elf with cargo test when you have a simulator like QEMU. The simulator can execute the ELF binary and run the test harness. This allows unit testing without flashing hardware. Ensure your panic handler doesn't hang the simulator indefinitely; some test frameworks require cooperative yielding.
Use riscv64gc-unknown-linux-gnu when your RISC-V board runs Linux. This target produces a standard Linux executable. You can cross-compile and scp the binary to the device. This is for application development, not firmware. The toolchain includes std and all OS features.
Reach for cargo-binutils when you need to inspect or transform the binary. The community uses cargo-binutils to provide objcopy, objdump, and nm wrappers. These tools convert ELF to binary, disassemble code, and list symbols. Install it with cargo install cargo-binutils. It integrates seamlessly with cargo.
Treat the target triple as a configuration file. If your hardware changes, update the triple. If you add a simulator, add the test workflow. The compiler enforces the contract; you define the terms.