Skip to content

About

Bazel + Rust cross-compilation demo: one no_std source tree builds both a bare-metal riscv64gc-unknown-none-elf firmware (runs in QEMU virt) and a wasm32-unknown-unknown browser module, sharing a tiny bump allocator. AI-generated worked example.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

bazel-rust-example

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-elf firmware that QEMU runs as -bios and that prints to a memory-mapped UART; and
  • a wasm32-unknown-unknown module that a browser instantiates and that prints to the page via a JS-imported host_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.

Layout

.
├── 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

Build & run

RISC-V

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.

WASM

bazel build --platforms=//platforms:wasm32 //src:wasm
# output: bazel-bin/src/wasm.wasm

To 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:8000

How it works

Bazel + rules_rust

MODULE.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.

No cc_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.

Entry points

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.

no_std and the bump allocator

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_end symbols defined by linker.ld between .bss and 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-ld puts zero-init statics in .bss-equivalent, so this does not inflate the .wasm file — 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-specific I/O

platform::print(&str) has two cfg-gated implementations:

  • riscv64 does core::ptr::write_volatile to 0x10000000, the NS16550A UART that QEMU's virt machine maps there. write_volatile because 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 module env, 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 in web/index.html.

Linker script (riscv64 only)

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 = .;
}
  • 0x80000000 is where the virt machine starts executing the -bios payload.
  • KEEP(*(.text._start)) first in .text guarantees 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.

References

About

Bazel + Rust cross-compilation demo: one no_std source tree builds both a bare-metal riscv64gc-unknown-none-elf firmware (runs in QEMU virt) and a wasm32-unknown-unknown browser module, sharing a tiny bump allocator. AI-generated worked example.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Contributors

Languages