Rust ❤️ Bela – Setting up Cargo for Cross Compilation
Posted
So my rate of posting to this blog has dropped from once a year to… well… it's been almost four years. But I can assure you, the reports of my death have been greatly exaggerated (to quote Mark Twain). In any case, a few things have happened.
First off — and this has probably been the biggest blocker — I finally finished my PhD thesis, woo! Second, there was (and is, sadly) this little thing called a global pandemic, so get vaccinated if you haven't yet! While many have taken to baking sourdough bread in their free time, I didn't have much extra time at all, but a generally higher stress level. But Rust has refreshed a lot of the fun in programming for me (the language is only a tiny part of that, the great tools and incredible community are far more important), so I decided I might as well write a bit about that for a while, instead of C++ with which I have more of a love-hate relationship.
Since things that make or manipulate sound are particularly motivating for me, I recently acquired a Bela (well, actually the gigantic Bela+BEAST stack, but more on that in a later post if everything goes according to plan).
The Bela is a Cape (analogous to a Raspberry Pi HAT) for the BeagleBone Black. But the Bela is more than just an extension board for a single-board computer: it comes with a Xenomai real-time Linux for ultra-low latency. Yes, an actual hard real-time operating system (RTOS)! Plus a web-based IDE and all kinds of bells and whistles. But the IDE is for C++ and I'd much rather write Rust now, so we're going to ignore that completely and cross-compile from our comfortable Windows (or macOS, or Linux, if that's what you prefer) desktop with rust-analyzer-based IDE. This first post in what is planned to be a series will deal with just that: how do you cross-compile for and run Rust code on an embedded Linux system?
First things first, what kind of machine is it? We could look up all the data sheets… or just ask it! So let's log in.
ssh root@bela.local
Yep, password-less root
login via ssh
. Sounds safe. But I don't plan on connecting it directly to the web and there's nothing on there but the OS and open source samples, so we should be ok for now (famous last words). Now let's find out what we're dealing with.
root@bela:~# gcc -dumpmachine
arm-linux-gnueabihf
This is the target triple, which tells us that we have an ARM processor, running Linux, in a GNU environment, using the embedded application binary interface with hard float. Let's also have a look at what targets Rust supports by running.
rustc --print=target-list
This will print the long list of targets that Rust supports. Including a whole bunch of arm
<something>
-unknown-linux-gnueabihf
(the unknown
part is the vendor, which for Linux is typically unknown
or none
). Which <something>
is right for the Bela? Let's dig into the hardware a little deeper.
root@bela:~# lscpu
Architecture: armv7l
Byte Order: Little Endian
CPU(s): 1
On-line CPU(s) list: 0
Thread(s) per core: 1
Core(s) per socket: 1
Socket(s): 1
Model: 2
Model name: ARMv7 Processor rev 2 (v7l)
BogoMIPS: 995.32
Flags: half thumb fastmult vfp edsp thumbee neon vfpv3 tls vfpd32
The operative part being armv7
, since arm
alone means ARMv6 for Rust/LLVM (as can be seen in the list of Tier 2 targets with host tools). This is as good a time as any to actually install the target support (just that it's supported doesn't mean the support is already installed). If you installed Rust via rustup
that's pretty easy:
rustup target add armv7-unknown-linux-gnueabihf
Let's get started with a simple example
cargo new --bin hello
cd hello
cargo build --target armv7-unknown-linux-gnueabihf
Drumroll, please…
error: linker `cc` not found
|
= note: The system cannot find the file specified. (os error 2)
Well that's not what we were hoping for. Ok, we don't have cc
on Windows, but that's the wrong linker for cross compilation anyway. So we need an ARM-compatible cross-platform GCC to use as a linker. There are two options that worked for me:
UPDATE 2022-02-10: While writing a later issue of this series, I discovered that even the old Linaro GCC 7.5 compiler expects a newer libc.so
(GLIBC) than is available on the Bela with the v0.3.8b firmware. By downgrading to the even older Linaro GCC 6.3, I was able to get things up and running again.
For macOS, you can check out the Bela project's own toolchain distribution. While others recommend using Zig, that option did not work for me, as ARM support isn't quite there yet. After installing the toolchain in a location of our choice and adding it to the PATH
, we can proceed:
$env:CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER="arm-linux-gnueabihf-gcc"
cargo build --target armv7-unknown-linux-gnueabihf
It works (you may have to add a none
in there depending on the toolchain you chose)! But it is kinda clumsy having to pass all that by hand, isn't it? But there's a solution, Cargo config files! So let's add a simple one to our project under .cargo/config.toml
:
[build]
target = "armv7-unknown-linux-gnueabihf"
[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"
Now, we can just run cargo build
without all the manual parameter passing shenanigans. It works! But I said we also want to run the code and guess what: cargo run
, cargo test
, and cargo bench
won't work. No surprise there, since a Linux ARM executable won't exactly work on a Windows x86_64
machine. Sure, we could just use scp
to copy it over, then ssh
to run it, but then we lose a bunch of nice features, such as the aforementioned cargo
commands (and rust-analyzer IDE equivalents). But Cargo (awesome tool that it is) has a solution for this, the runner
option which specifies a wrapper script for running executables. Since I wanted something that works on both Windows and Linux (and maybe macOS?), I wrote a Python script that uploads the working directory via rsync
(via WSL on Windows) to the target, runs the executable via ssh
, and finally copies back generated files via rsync
(useful for benchmark reports etc.). The script is fairly complex, so I won't go into detail here and just present the finished thing:
# no shebang-line as Python scripts aren't really executable on Windows
# use runner = ["python", "runner.py"] in your Cargo config instead
from hashlib import sha3_224
from shlex import quote
import os
import pathlib
import platform
import sys
import subprocess as sp
# "parse" command line
EXECUTABLE = sys.argv[1]
ARGUMENTS = sys.argv[2:]
# get relative part of executable path and convert to POSIX (as host may be Windows)
EXECUTABLE_RELATIVE = pathlib.Path(os.path.relpath(EXECUTABLE)).as_posix()
# create a (statistically) unique name for the remote working directory copy
WORKDIR = sha3_224((platform.node() + ':' + os.getcwd()).encode('utf-8')).hexdigest()
# the target hardware (Bela.io) has passwordless root login
# for normal systems you'll need to handle user authentication in a smarter manner
SSH_NONINTERACTIVE = ['ssh', '-qTo', 'BatchMode yes', 'root@bela.local']
SSH_INTERACTIVE = ['ssh', '-qt', 'root@bela.local']
# use rsync via WSL when on Windows
RSYNC = (['wsl'] if platform.system() == 'Windows' else []) + ['rsync']
# ensure base directory exists
sp.run(SSH_NONINTERACTIVE + [
'mkdir', '-p', '.cargo_runner'
], stdout=sp.DEVNULL, stderr=sp.DEVNULL, check=True)
# synchronize working directory to remote
sp.run(RSYNC + [
'-rlptz',
# prevent syncing the .git folder
'--exclude', '.git',
# delete old files (otherwise they'll get copied back later)
'--delete',
'.',
f'root@bela.local:.cargo_runner/{WORKDIR}/'
], stdout=sp.DEVNULL, stderr=sp.DEVNULL, check=True)
# run executable remotely, explicitly without checking, as partial results should still be copied
code = sp.run(SSH_INTERACTIVE + [
f'cd .cargo_runner/{WORKDIR} && {quote(EXECUTABLE_RELATIVE)} {" ".join(map(quote, ARGUMENTS))}'
]).returncode
# synchronize working directory from remote (for Criterion reports etc.)
sp.run(RSYNC + [
'-rlptz',
f'root@bela.local:.cargo_runner/{WORKDIR}/',
'.',
], stdout=sp.DEVNULL, stderr=sp.DEVNULL, check=True)
# exit with the code from the actual run
sys.exit(code)
Now we only have to update our Cargo config to add the runner:
[build]
target = "armv7-unknown-linux-gnueabihf"
[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"
rustflags = ["-Ctarget-cpu=cortex-a8", "-Ctarget-feature=+neon"]
runner = ["python", "runner.py"]
Ok, I added two more things that we'll want in the future: -Ctarget-cpu=cortex-a8
to optimize for the BeagleBone Black's CPU and -Ctarget-feature=+neon
since the BeagleBone has Neon SIMD support, which not every Cortex A8 has. In any case, let's try it!
PS D:\development\hello> cargo run
Compiling hello v0.1.0 (D:\development\hello)
Finished dev [unoptimized + debuginfo] target(s) in 0.80s
Running `python runner.py target\armv7-unknown-linux-gnueabihf\debug\hello`
Hello, world!
Beautiful! Next time, I'll go into more detail on the project(s) I'm planning to try on the Bela and some SIMD optimization fun.
As there is no comment function anymore since I switched to a static site generator, feel free to follow me and send me a DM on Mastodon if you have any questions or comments.