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 Ubuntu
  • armv7-unknown-linux-gnueabihf — 32-bit Pi 2/3, BeagleBone, many industrial boards
  • arm-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-zigbuild with a pinned .2.31 suffix, or a build container matching the device's distro)
  • statically link against musl with x86_64/aarch64-unknown-linux-musl when 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