Cross-compiling Rust is easy to start and hard to standardize across teams. I treat toolchains as part of the project, not local machine state.
Target triples, linker config, and environment variables live in versioned project files. That removes guesswork when onboarding new contributors.
For ARM Linux deployments, I test build artifacts in CI with smoke checks before shipping. If binaries link against wrong system libraries, failure should happen in pipeline, not on device.
Once configured, release cadence improves dramatically because developers can produce deployable builds without touching target hardware.
Adding the target and picking a triple
The first step is installing the target with rustup. For 64-bit Raspberry Pi OS and most modern ARM Linux boards I use aarch64; older 32-bit boards and Pi Zero/Pi 1 need armv7 or arm.
# 64-bit Raspberry Pi 3/4/5, most ARM64 SBCs
rustup target add aarch64-unknown-linux-gnu
# 32-bit Raspberry Pi 2/3 (armv7), hard-float
rustup target add armv7-unknown-linux-gnueabihf
Common triples I reach for:
aarch64-unknown-linux-gnu— Raspberry Pi 3/4/5 on 64-bit Raspberry Pi OS or Ubuntuarmv7-unknown-linux-gnueabihf— 32-bit Pi 2/3, BeagleBone, many industrial boardsarm-unknown-linux-gnueabihf— older ARMv6 boards like the Pi Zero / Pi 1
Linker config in the project
Cross-linking needs a cross linker, not the host one. I pin it in a versioned .cargo/config.toml so nobody has to remember environment variables:
# .cargo/config.toml
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"
On Debian/Ubuntu those linkers come from gcc-aarch64-linux-gnu and gcc-arm-linux-gnueabihf. With that in place, cargo build --release --target aarch64-unknown-linux-gnu produces an ARM binary.
Reproducible builds with cross or cargo-zigbuild
Installing the right system toolchain on every machine is exactly the "local machine state" I try to avoid. Two tools remove it.
cross runs the build inside a container with the toolchain baked in — no host packages needed:
cargo install cross
cross build --release --target aarch64-unknown-linux-gnu
cargo-zigbuild uses Zig as the linker and lets you pin the glibc version, which is the single most useful trick for the pitfall below:
cargo install cargo-zigbuild
rustup target add aarch64-unknown-linux-gnu
# Target glibc 2.31 so the binary runs on older devices too.
cargo zigbuild --release --target aarch64-unknown-linux-gnu.2.31
CI outline
A minimal GitHub Actions job that builds and uploads an ARM64 artifact:
jobs:
build-arm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-unknown-linux-gnu
- name: Install cross
run: cargo install cross
- name: Build
run: cross build --release --target aarch64-unknown-linux-gnu
- uses: actions/upload-artifact@v4
with:
name: gateway-aarch64
path: target/aarch64-unknown-linux-gnu/release/gateway
The glibc pitfall
The failure that bites people is glibc version mismatch. If CI builds against a newer glibc than the target device ships, the binary links fine but dies on the device with version 'GLIBC_2.34' not found. Options, in order of preference:
- build against the oldest glibc you must support (
cargo-zigbuildwith a pinned.2.31suffix, or a build container matching the device's distro) - statically link against musl with
x86_64/aarch64-unknown-linux-muslwhen you want zero libc dependency - as a last resort, upgrade the device — but that is a fleet decision, not a build fix
This is exactly the class of failure I want CI smoke checks to catch before the artifact ever reaches a device.
Sources
- Imported from the previous version of the site (gaborl.hu). Original publication around 2024-09-18.
- rustup — cross-compilation and the platform support list
- cross — zero-setup, Docker-based cross compilation
- cargo-zigbuild — Zig-linker cross builds with pinnable glibc versions
Update history
- — Adopted from previous gaborl.hu (Hugo) site via legacy import.