• Bazel for Rust embedded

  • Bazel for Rust embedded

    Lately I have been playing more and more with embedded Rust. While the memory safety and strict typing are very nice, I really love being able to leverage the work done by other developers using the excellent cargo ecosystem. I love the freedom of C, but having to reinvent the wheel every time I need a specific embedded driver/library just feels wrong.

    The only pain point I’ve had so far is that embedded rust has been hard to integrate with build systems other than cargo, specifically, bazel.

    Let’s fix that!

    Overview

    1. Embedded Rust Basics
    2. Cargo vs Bazel
    3. Bazel’s rules_rust
    4. Example repo

    Embedded rust basics

    Embedded rust is not much different than normal rust, but a lot of thing are in flux. The main differences are:

    1. We need a target that specifies the architecture we are cross-compiling for. This is usually a triple that specifies {cpu}-{os}-{abi} (e.g. thumbv7em-none-eabi)

    2. Once all the crates and source files have been compiled for the target architecture, we need a linker (this will be important later) that is not part of the standard llvm rust toolchain

    3. Finally, we need a linker, usually memory.x to specify some things about the binary that are not easily expressed in the code by itself (memory layout, interrupts available, etc)

    Cargo vs Bazel

    First the good news: the cargo ecosystem has added some really nice features that allow easy compilcation of ebedded targets!

    1. rustup target add --toolchain <toolchain> <target>... will magically include all the things you need to cross compile from your host operating system. This includes the required cross compiled libraries and, crucially, a linker!!.

    2. cargo --target="{cpu}-{os}-{abi}" ... builds like magic for the specified target using LLVM’s magic under the hood.

    3. Creative developers have been using build.rs build scripts to add custom rustc flags to include link scripts that tie the entire build together and generate the reuqired binary.

    This is quite amazing, I was very impressed by how easy cargo makes it to cross compile for ANY target supported by LLVM.

    Now the bad news: we cannot take advantage of all this good stuff with bazel (sadly). Bazel shines when it can handle all of the dependencies, and not just call cargo under the hood. Luckily much better developers than me built first class support for rust in bazel: rules_rust

    Bazel rules_rust

    rules_rust aims at providing a similar experience to cargo when using bazel. It magically registers all the required bits and pieces (see: toolchains) required to build rust, just like rustup does.

    Cross compiliing for embedded, however, is a bit more complicated since there are no examples (Maybe there will be after this post if my PR gets in :)

    Let’s use rules_rust and a bit of toolcahin magic to compile our binary.

    Example repo

    Alright enough talking, let’s build stuff. You can follow along with the example repo on github

    We will be using the excellent nrf52840 as the example platform. It’s arm based, has Bluetooth Low Energy, hard floating point and is a darling of the DYI community. Overall a good candidate for a demo.

    First thing

    Baseline Rust project

    We’ll start with a very bare bone embedded rust project.

    1
    2
    3
    4
    5
    6
    
    project
    ├── Cargo.toml
    ├── build.rs
    ├── linker.x
    └── src
        └── main.rs
    

    Application

    main.rs is a very simple LED blinker application that uses the nrf-52840-hal. We also have to explicity provide a panic function since we are in #![no_std] land

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    
    #![no_main]
    #![no_std]
    
    use nrf52840_hal as hal;
    
    use embedded_hal::blocking::delay::DelayMs;
    use embedded_hal::digital::v2::OutputPin;
    use hal::gpio::Level;
    
    #[panic_handler] // panicking behavior
    fn panic(_: &core::panic::PanicInfo) -> ! {
        loop {
            cortex_m::asm::bkpt();
        }
    }
    
    #[cortex_m_rt::entry]
    fn main() -> ! {
        let peripherals = hal::pac::Peripherals::take().unwrap();
        let core = hal::pac::CorePeripherals::take().unwrap();
    
        let mut timer = hal::delay::Delay::new(core.SYST);
        let port0 = hal::gpio::p0::Parts::new(peripherals.P0);
    
        let mut led = port0.p0_06.into_push_pull_output(Level::Low);
    
        loop {
            led.set_low().ok();
            timer.delay_ms(500u32);
            led.set_high().ok();
            timer.delay_ms(500u32);
        }
    }
    

    Build script

    We use the build.rs script to instruct the toolchain to use our custom linker and to rebuild if we change it. Build scripts are the usual way cargo projects mess with link time options.

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    
    use std::env;
    use std::fs::File;
    use std::io::Write;
    use std::path::PathBuf;
    
    fn main() {
        // Put `linker.x` in our output directory and ensure it's
        // on the linker search path.
        let out = &PathBuf::from(env::var_os("OUT_DIR").unwrap());
        File::create(out.join("linker.x"))
            .unwrap()
            .write_all(include_bytes!("linker.x"))
            .unwrap();
        println!("cargo:rustc-link-search={}", out.display());
        println!("cargo:rerun-if-changed=linker.x");
        println!("cargo:rustc-link-arg-bins=--nmagic");
        println!("cargo:rustc-link-arg-bins=-Tlinker.x");
    }
    
    

    Does it build with cargo ?

    1
    2
    3
    
    > cargo build --target=thumbv7em-none-eabihf
        Compiling nrf v0.1.0
        Finished dev [unoptimized + debuginfo] target(s) in 1.12s
    

    Awesome. Ok let’s make it bazel

    Bazel Rust integration

    Ok so first things first, lets build a mono-repo style folder. The top level will contain a WORKSPACE file and we’ll move our rust baseline into project

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    ├── Cargo.lock
    ├── Cargo.toml
    ├── cargo-bazel-lock.json
    ├── BUILD
    ├── WORKSPACE
    ├── platforms
    │   └── BUILD
    └── project
        ├── BUILD
        ├── Cargo.toml
        ├── build.rs
        ├── linker
        │   ├── device.x
        │   ├── link.x
        │   └── memory.x
        ├── linker.x
        └── src
            └── main.rs
    

    We’ll take advantage of cargo workspaces to make sure we have a single Cargo.lock file at the top of our repo.

    1
    2
    3
    4
    5
    6
    7
    
    # Cargo.toml
    
    [workspace]
    
    members = [
        "project",
    ]
    

    Rules rust

    Let’s add rules rust to our workspace

    1
    2
    3
    4
    5
    6
    7
    8
    
    # WORKSPACE
    
    load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
    http_archive(
        name = "rules_rust",
        sha256 = "dc8d79fe9a5beb79d93e482eb807266a0e066e97a7b8c48d43ecf91f32a3a8f3",
        urls = ["https://github.com/bazelbuild/rules_rust/releases/download/0.19.0/rules_rust-v0.19.0.tar.gz"],
    )
    

    Rust toolchain

    Ok, now we need to fetch the right rust toolchain. We can add extra compilation targets using the extra_target_triples attribute

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    # WORKSPACE
    
    load("@rules_rust//rust:repositories.bzl", "rules_rust_dependencies", "rust_register_toolchains")
    rules_rust_dependencies()
    rust_register_toolchains(
        edition = "2021",
        extra_target_triples = ["thumbv7em-none-eabihf"],
        versions = ["1.64.0"],
    )
    

    Crates management

    Now we want to make sure that all the dependencies we had in the original Cargo.toml are preserved when going into bazel. rules_rust allows us to do this very easily.

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    # WORKSPACE
    
    load("@rules_rust//crate_universe:defs.bzl", "crates_repository")
    crates_repository(
        name = "crates",
        cargo_lockfile = "//:Cargo.lock",
        lockfile = "//:cargo-bazel-lock.json",
        manifests = [
            "//:Cargo.toml",
            "//project:Cargo.toml",
        ],
    )
    
    load("@crates//:defs.bzl", "crate_repositories")
    crate_repositories()
    

    Freezing crate dependeices

    To freeze the crates dependencies into cargo-bazel-lock.json we need to run

    CARGO_BAZEL_REPIN=true bazel sync --only=crates

    Bazel application

    We are finally ready to build the application with bazel!

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    
    # project/BUILD
    
    load("@rules_rust//rust:defs.bzl", "rust_binary")
    load("@crates//:defs.bzl", "aliases", "all_crate_deps")
    
    rust_binary(
        name = "blinky",
        srcs = ["src/main.rs"],
        aliases = aliases(),
        edition = "2021",
        linker_script = "linker.x",            # use the custom linker"
        deps = all_crate_deps(normal = True),  # all the dependecies from Cargo.toml
    )
    

    let’s give it a go!

    1
    2
    3
    4
    5
    6
    7
    8
    
    bazel build //project:blinky
    
    INFO: Analyzed target //project:blinky (76 packages loaded, 2639 targets configured).
    INFO: Found 1 target...
    ERROR: /private/var/tmp/_bazel/<hash>/external/crates__nrf52840-pac-0.11.0/BUILD.bazel:22:13: Compiling Rust rlib nrf52840_pac v0.11.0 (991 files) failed: (Exit 101) ...
    
    LLVM ERROR: Global variable '__INTERRUPTS' has an invalid section specifier '.
    vector_table.interrupts': mach-o section specifier requires a segment and section separated by a comma.
    

    Oh bummer. This is not compiling. Looks like it’s trying to build for… macOS???

    Platforms

    Of course, we need to tell bazel what target platform we are using! Like we used to do with cargo build --target=thumbv7em-none-eabihf.

    Ok so let’s do that, let’s create a custom platform with our CPU type and OS

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    #platforms/BUILD
    
    platform(
        name = "thumbv7em-none-eabihf",
        constraint_values = [
            "@platforms//cpu:armv7e-mf",
            "@platforms//os:none",
        ],
    )
    

    and let’s add the following to our .bazelrc to tell bazel that we want this targt

    1
    2
    3
    
    # Tell bazel to figure out the toolchain based on what platform we are using
    build --incompatible_enable_cc_toolchain_resolution 
    build --platforms=//platforms:thumbv7em-none-eabihf
    

    Ok let’s try again

    1
    2
    
    ERROR: project/BUILD:4:12: While resolving toolchains for target //project:blinky: 
    No matching toolchains found for types @bazel_tools//tools/cpp:toolchain_type.
    

    Oh ok… why is it complaining about cpp? Aren’t we building rust?

    cc_toolchain

    Turns out, the linker (remember?) is actually what’s missing. We need to find a cc toolcahin that will provide a compatible linker for us to use.

    Thankfully one of my previous projects is a arm-none-eabi-gcc toolchain for bazel!

    Ok, let’s add that to the WORKSPACE

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    # WORKSPACE
    
    load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
    git_repository(
        name = "arm_none_eabi",
        commit = "88d8e25b06be484188b04905f11f2788d302aa72",
        remote = "https://github.com/hexdae/bazel-arm-none-eabi",
    )
    
    load("@arm_none_eabi//:deps.bzl", "arm_none_eabi_deps")
    arm_none_eabi_deps()
    

    And finally…

    1
    2
    3
    4
    5
    6
    7
    
    INFO: Analyzed target //project:blinky (4 packages loaded, 5392 targets configured).
    INFO: Found 1 target...
    Target //project:blinky up-to-date:
      bazel-bin/project/blinky
    INFO: Elapsed time: 17.060s, Critical Path: 11.86s
    INFO: 37 processes: 1 internal, 36 darwin-sandbox.
    INFO: Build completed successfully, 37 total actions
    

    A successfull build! We can now go build embedded software using rust and be sure bazel will be there to speed up our development!

    Comments

    comments powered by Disqus