AI-generated. This project — code, comments, and this README — was produced by an AI assistant in an interactive session with the repo owner. Treat it as a worked example, not authoritative documentation. Cross-check against the official rules_rust and Rust docs before copying patterns into anything serious.
A small Bazel + Rust cross-compile demo. One source tree produces two very different artifacts:
- a bare-metal
riscv64gc-unknown-none-elffirmware that QEMU runs as-biosand that prints to a memory-mapped UART; and - a
wasm32-unknown-unknownmodule that a browser instantiates and that prints to the page via a JS-importedhost_print.
The Rust program itself does almost nothing: it prints "hello\n" and then
"hello from the heap\n" — but the second string is built with
String::from(...), which exercises core + alloc + a custom
#[global_allocator] on both targets.
It started life as a Rust port of Uroš Popović's bare-metal RISC-V article, then grew a WASM build for fun.
.
├── MODULE.bazel Bazel module + rules_rust toolchain registration
├── platforms/BUILD.bazel //platforms:riscv64, //platforms:wasm32
├── src/
│ ├── BUILD.bazel :firmware (riscv) and :wasm targets
│ ├── main.rs entry points, cfg-gated per target
│ ├── platform.rs platform-specific print(&str)
│ ├── allocator.rs bump allocator backing #[global_allocator]
│ └── linker.ld RISC-V memory layout
└── web/index.html browser harness for the WASM build
bazel build --platforms=//platforms:riscv64 //src:firmware
qemu-system-riscv64 -machine virt -nographic -bios bazel-bin/src/firmware
# prints:
# hello
# hello from the heap
# then spins forever. Ctrl-A X to exit QEMU.bazel build --platforms=//platforms:wasm32 //src:wasm
# output: bazel-bin/src/wasm.wasmTo run it, copy the .wasm next to web/index.html as firmware.wasm and
serve the directory over HTTP (browsers refuse file:// for wasm):
python3 -m http.server 8000
# open http://localhost:8000MODULE.bazel registers a single Rust toolchain that pulls precompiled
rust-std (i.e. core, alloc, …) for both triples:
rust.toolchain(
edition = "2024",
versions = ["1.85.0"],
extra_target_triples = [
"riscv64gc-unknown-none-elf",
"wasm32-unknown-unknown",
],
)That's the entire cross-compile setup — no rustup target add, no nightly,
no -Z build-std. rules_rust fetches the stdlibs and registers per-triple
toolchains automatically.
Which toolchain is used? Bazel resolves it from --platforms. Each
platform() in platforms/BUILD.bazel carries constraint_values
(@platforms//cpu:… + @platforms//os:none) that match a registered
toolchain.
Cross-compiling Rust with Bazel often runs into rules_rust wanting to invoke
a C linker via a cc_toolchain you don't have. We dodge it with one line in
rustc_flags:
"-C", "linker=rust-lld",rust-lld is the LLD copy bundled inside rustc; we use it directly and never
involve Bazel's C toolchain resolution. For wasm, rustc already drives LLD
(in wasm-ld mode) by default, so no extra flag is needed there.
Older blog posts and snippets recommend a .bazelrc with
--incompatible_enable_cc_toolchain_resolution and a
@rules_rust//rust/settings:default_allocator_library=@rules_rust//ffi/cc/empty
override. Both are obsolete here: the flag is the default in Bazel 7+, and
neither the setting nor the ffi/cc/empty target actually exist in
rules_rust 0.70.0.
The two targets boot the program completely differently.
RISC-V. QEMU's virt machine jumps to the -bios ELF's entry address
(0x80000000) with sp undefined and a single hart running in M-mode. A Rust
function prologue immediately spills ra to the stack, so the very first
thing we do is initialize sp. That has to be in assembly. main.rs has a
global_asm! block that defines _start in section .text._start, loads
sp = _stack_top, and tail-calls kmain:
core::arch::global_asm!(
".section .text._start",
".globl _start",
"_start:",
" la sp, _stack_top",
" call kmain",
"1: j 1b",
);linker.ld KEEPs .text._start first in .text, so this stub really
does land at 0x80000000 — the address QEMU jumps to.
WASM. There is no _start. Wasm modules are libraries from the
embedder's perspective — you call exported functions. rustc on
wasm32-unknown-unknown auto-exports every pub #[no_mangle]
extern "C" function, so JS does instance.exports.run() to enter the
program.
Both targets are #![no_std]. extern crate alloc; brings in Vec, Box,
String, etc., but only works if we provide a #[global_allocator].
allocator.rs implements a ~30-line bump allocator. dealloc is a no-op
(memory is never reclaimed — fine for a one-shot demo). alloc walks a
next pointer up through a heap region; the region is per-target:
- riscv64:
_heap_start/_heap_endsymbols defined bylinker.ldbetween.bssand the stack. The linker just advances the location counter — no bytes go into the ELF, the region exists purely at runtime. - wasm32: a 64 KiB
static mut HEAP: [u8; 64 * 1024].wasm-ldputs zero-init statics in.bss-equivalent, so this does not inflate the.wasmfile — only the linear-memory reservation grows.
The UnsafeCell<usize> interior mutability holding next is sound because
both targets are strictly single-threaded: one hart with interrupts disabled
on RISC-V; one execution context per wasm instance.
heap_bounds() is cfg-gated to return the right (start, end) for the
current target, and the rest of alloc() is identical between them.
platform::print(&str) has two cfg-gated implementations:
-
riscv64 does
core::ptr::write_volatileto0x10000000, the NS16550A UART that QEMU'svirtmachine maps there.write_volatilebecause the compiler must not coalesce or reorder MMIO stores. -
wasm32 imports a host function and calls it with a
(ptr, len)pair:unsafe extern "C" { fn host_print(ptr: *const u8, len: usize); }
An unannotated
extern "C"block on wasm32 imports from moduleenv, so JS supplies the function in that namespace:const imports = { env: { host_print(ptr, len) { const bytes = new Uint8Array(memory.buffer, ptr, len); out.textContent += new TextDecoder().decode(bytes); }, }, };
memory(the module's linear memory) can't be bound until after instantiation, hence the small late-bind dance inweb/index.html.
ENTRY(_start)
MEMORY { RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 128M }
SECTIONS {
. = ORIGIN(RAM);
.text : { KEEP(*(.text._start)) *(.text*) } > RAM
.rodata : { *(.rodata*) } > RAM
.data : { *(.data*) } > RAM
.bss : { *(.bss*) *(COMMON) } > RAM
. = ALIGN(16);
_heap_start = .; . += 1M; _heap_end = .;
_stack_bottom = .; . += 64K; _stack_top = .;
}0x80000000is where thevirtmachine starts executing the-biospayload.KEEP(*(.text._start))first in.textguarantees the entry stub really does land at the base address.- The heap and stack regions are pure
.advances. No bytes are emitted into the ELF; the regions are just labeled chunks of DRAM the program uses at runtime.
WASM has no equivalent — wasm-ld handles layout itself, and we hand it a
plain static mut instead of a linker-defined region.