Cross-compiling Rust for embedded targets
Introduction to Embedded Rust Cross-Compilation
Cross-compiling Rust for embedded targets signifies the process of generating binary artifacts for a machine architecture (e.g., ARM Cortex-M) that is distinct from the host machine architecture (e.g., x86-64 or AArch64). This capability is fundamental to bare-metal and RTOS development in Rust. Unlike standard application development where rustc links against the host operating system's standard library (std), embedded systems typically require a no_std environment, meaning the operating system, global allocators, and standard library features are unavailable or replaced by specialized embedded crates.
The workflow is not solely managed by rustup or Cargo. While these tools efficiently handle the Rust component—installing the target triple, managing toolchains, and selecting the correct architecture (thumbv7em-none-eabihf for STM32F411RETx)—they are fundamentally incomplete without an external, native cross-toolchain. This external toolchain, usually provided by the chip vendor (e.g., arm-none-eabi-gcc from GNU Arm), supplies the essential linker (ld), the C runtime library objects, and the low-level utilities required to produce a runnable binary executable for the target microcontroller.
This guide elaborates on the deep technical requirements—from no_std complexities and memory-mapped peripherals to meticulous linker configuration—necessary to transform a Rust project into a functional firmware image for specific targets like the STM32F411RETx. A successful cross-compilation is less about a single command and more about orchestrating three distinct, verified components: 1) Rust libraries for the target triple, 2) Cargo configuration for that triple, and 3) a native cross-toolchain capable of linking the result.
The Rust Toolchain Foundation
The initial step involves ensuring the Rust ecosystem recognizes the target. This is achieved through rustup target add <triple>. For the STM32F411RETx, the triple thumbv7em-none-eabihf is commonly used, indicating an ARM Cortex-M4 core, an empty (no OS) environment, and no floating-point unit (hf).
It is crucial to understand what rustup target add accomplishes: it fetches the necessary Rust-specific libraries (rust-std for that triple, if available, or components needed for no_std development) into the Rust toolchain installation directory. Critically, it does not install the C compiler, linker, C standard library (libc), or flashing tools. A common pitfall is assuming Rustup handles the entire toolchain, leading to build failures related to missing linker executables or incorrect symbols during the linking phase.
For projects requiring a stable, repeated build environment, setting a default target in .cargo/config.toml is highly recommended. This avoids the need to append --target thumbv7em-none-eabihf to every cargo build command, simplifying the project configuration for application targets:
[build]
target = "thumbv7em-none-eabihf"
However, developers must exercise caution here: a global default target affects all builds in the workspace unless explicitly overridden, potentially interfering with host builds.
Navigating the no_std Paradigm
The transition to no_std is the most significant conceptual shift when moving from desktop Rust to embedded Rust. std provides abstractions like dynamic memory allocation (Vec, String), threading primitives, file I/O, and comprehensive system interfaces, which depend on an underlying OS. Embedded microcontrollers lack an OS in the bare-metal sense.
In no_std development, Rust code interacts directly with the hardware via memory-mapped registers. Instead of relying on std::fs, the developer uses crates that implement drivers by reading/writing specific memory addresses defined by the chip's datasheet (e.g., I2C, SPI peripherals).
The standard library relies heavily on an operating system to provide thread scheduling, memory management, and system calls. When no_std is used, these functionalities must be provided by a minimal runtime or crate, such as cortex-m-rt or cortex-m-rtic. These crates provide the necessary entry points (e.g., the _start function), interrupt vector tables, and basic peripheral initialization hooks that rustc expects, allowing the program to start and handle interrupts correctly on the Cortex-M processor. Failure to include a proper runtime crate leads to undefined reference errors because the linker cannot find the required startup code or interrupt handlers.
Dependency management in no_std is rigorous. Any crate dependency that unconditionally pulls in std (e.g., certain logging frameworks or utility libraries) must be replaced with a crate designed specifically for no_std (e.g., log/defmt coupled with a hardware logger, or heapless for fixed-capacity data structures). Verification involves auditing the dependency tree using Cargo's dependency graph visualization to ensure no std requirements leak into the compiled artifact.
The Role of the Native Cross-Toolchain and Linker Configuration
The cross-compilation pipeline’s second critical element is the native toolchain. For STM32F411RETx, the required toolchain is typically arm-none-eabi-gcc. This toolchain is external to Rustup and must be installed manually (e.g., via apt, brew, or the GNU Arm toolchain installer). This toolchain is what performs the final, low-level linking.
Cargo integrates this native toolchain via the [target.<triple>] configuration block in .cargo/config.toml. This block is paramount for bridging the Rust high-level abstractions to the C-level hardware interfaces.
A typical configuration for the STM32F411RETx (or similar Cortex-M devices) involves specifying the linker and, critically, the runner and rustflags:
[target.thumbv7em-none-eabihf]
linker = "arm-none-eabi-gcc"
# Runner is used by cargo run/test to interface with the hardware (e.g., via SEGGER J-Link, OpenOCD, or probe-rs)
runner = "probe-rs run --chip STM32F411RETx"
# Rust flags to inform rustc about specific linking requirements or target-specific definitions
rustflags = ["-C", "link-arg=-Tlink.x"]
The linker flag points Cargo to the executable provided by the external toolchain. The rustflags are used to pass compiler arguments, such as -Tlink.x.
Understanding Linker Scripts (link.x)
The -Tlink.x flag directs rustc to use a specific linker script file. The linker script (link.x) is a platform-specific file (often provided by STMicroelectronics or the embedded Rust ecosystem) that defines the memory layout of the microcontroller. It tells the linker exactly where in the physical memory (Flash and RAM) the program’s code (.text), initialized data (.data), and uninitialized/static data (.bss) should reside.
Without a correctly configured link.x file that matches the specific memory map of the STM32F411RETx (which includes specific FLASH start addresses and RAM sizes), the compiled binary will fail to run because the CPU will attempt to execute code or access memory outside its defined boundaries. This is a low-level hardware constraint that Rust cannot solve alone; it requires verified data from the chip vendor.
Common Dependency and C Runtime Issues
A frequent point of failure in embedded Rust is the interface between Rust and C code, or between Rust and the required C runtime objects (like the boot code or startup assembly provided by the vendor).
- C Dependencies: Crates utilizing
build.rsto interface with external C libraries (e.g., for hardware abstraction like HALs) require a fully functional C cross-compiler (arm-none-eabi-gcc). The Rust project must successfully compile the C code before Rust can link against the resulting object files. - C Runtime/Bootloader: On bare-metal targets, the initial boot sequence is handled by assembly startup code provided by the runtime crate (
cortex-m-rt). This code sets up the stack pointer and callsmain. If the linker fails to include this runtime startup code—often due to misconfiguration of thelink.xor incorrect target triple usage—the program hangs immediately, failing to executemain. - Toolchain Mismatch: Using a compiler targeting a different ARM variant (e.g., Cortex-M0 instead of Cortex-M4) will result in incorrect ABI (Application Binary Interface) usage, leading to runtime crashes even if the linker succeeds structurally.
Advanced Debugging and Verification
When cross-compilation fails, the failure point is rarely in the Rust source code logic but rather in the toolchain configuration or low-level memory mapping.
- Initial Check: Verify the
rustup target listoutput matches the required triple (thumbv7em-none-eabihf). - Compilation Check: Run
cargo build --target <triple>. If this succeeds, Rust successfully translated the high-level code. - Linking Check: Check the resulting
.elffile usingarm-none-eabi-objcopyto confirm the sections (.text,.data,.bss) are positioned exactly as specified by thelink.xscript. - Execution Check: Use the specified
runner(e.g.,probe-rs) to flash the verified.elfbinary onto the STM32F411RETx and monitor the hardware debugger for correct execution flow, stack initialization, and interrupt handling.
Conclusion
Cross-compiling Rust for embedded systems is a highly integrated engineering discipline, blending Rust's modern safety guarantees with the low-level constraints of silicon hardware. While rustup and Cargo streamline the Rust part, the success for targets like STM32F411RETx hinges on providing the correct external GNU Arm cross-toolchain, precise [target.<triple>] configuration, and, most critically, a verified memory map via the link.x linker script supplied by the chip vendor. By treating the workflow as an orchestration of three separate, verified components—Rust libraries, Cargo configuration, and Native Toolchain/Linker—rather than a single magic command, development becomes repeatable, robust, and deterministic. The required detail necessitates consulting the specific STM32F411RETx datasheet and official ST microcontroller documentation alongside the general guidance provided by The Embedded Rust Book.
Sources
This post is informed by discussion on rust-embedded.github.io. Primary source: https://rust-embedded.github.io/book/.