When your host machine isn't the destination
You are writing a CLI tool on your M2 MacBook. The client needs a .exe for their Windows server. Or you are deploying to a fleet of ARM-based Raspberry Pis running a custom Linux distro. The old workflow meant spinning up a virtual machine, installing the exact same compiler version, and wrestling with file transfers. Rust skips the VM. You compile on your machine, and the output runs natively on the other architecture.
The target triple and the translator
Cross-compilation is just telling the compiler to speak a different dialect. Your host runs the compiler, but the compiler generates machine code for a different processor. Think of a professional translator. You hand them a document in English. They read it, understand the meaning, and rewrite it in Japanese. You never need to speak Japanese. The translator handles the grammar, vocabulary, and cultural context. In Rust, rustc is the translator. You give it source code, and it outputs binaries that match the target instruction set and system libraries.
Every Rust target follows a strict naming pattern called a target triple. The format is architecture-vendor-os-environment. The architecture tells the compiler which CPU instruction set to emit. x86_64 means 64-bit Intel or AMD. aarch64 means 64-bit ARM. wasm32 means WebAssembly. The vendor field is almost always unknown in the Rust ecosystem. Rust does not tie itself to specific hardware manufacturers, so the community convention is to leave this field as unknown. The OS field specifies the operating system ABI. linux, windows, macos, and freebsd are the common values. The environment field specifies the C standard library or runtime behavior. gnu means glibc. musl means the musl C library. unknown means no OS or standard library at all.
The triple dictates what your binary expects to find on the target machine. A gnu binary expects glibc to be installed. A musl binary links the C library statically and expects nothing. A unknown-unknown binary expects zero OS support and runs in a sandboxed environment like a browser or a WASM runtime. Get the triple wrong, and the binary crashes on startup.
The minimal workflow
The baseline workflow takes two commands. First, you tell rustup to download the standard library and compiler components for your target. Second, you pass that target to cargo.
Here is the smallest case: a value, a raw pointer, and a read.
// main.rs
/// A binary that prints a message regardless of architecture.
pub fn main() {
// println! uses the target's standard I/O, not the host's.
println!("Running on the target architecture.");
}
# Download the standard library for ARM64 Linux.
rustup target add aarch64-unknown-linux-gnu
# Build the release binary for that target.
cargo build --release --target aarch64-unknown-linux-gnu
The rustup target add command fetches a .tar.gz from the official Rust release servers. Inside that archive sits libstd.rlib and libcore.rlib compiled specifically for your target triple. The compiler itself does not change. rustc is a single binary that contains code generation backends for dozens of architectures. The target flag flips internal switches that change register allocation, calling conventions, and system call numbers. When cargo build runs with the --target flag, it invokes rustc with -C target=<triple> behind the scenes. The compiler parses your code, performs monomorphization, and generates object files. Then it hands those object files to the linker. For pure Rust projects, the linker is rust-lld, which is bundled with the Rust toolchain. It knows how to combine Rust object files with the precompiled standard library. The entire process happens locally. No network calls to the target machine. No SSH tunnels. Just local compilation and linking.
Convention aside: always pass --release when cross-compiling. Debug builds include heavy profiling metadata that increases binary size and can mask performance characteristics that only appear on the target architecture. Strip the binary afterward if size matters. Run strip target/aarch64-unknown-linux-gnu/release/your-binary to remove symbols that the target does not need.
Pure Rust compiles locally with zero network calls to the target. The magic stops where C code begins.
C dependencies and the linker wall
Real projects rarely stay pure. You will likely pull in crates that use cc to compile C code, or link against system libraries like OpenSSL or libsqlite3. The Rust compiler can generate the Rust object files, but it still needs a C compiler that targets the same architecture to link everything together.
Here is a realistic dependency that triggers C compilation.
# Cargo.toml
[dependencies]
# ring compiles C code via the cc crate during build.
ring = "0.17"
If you try to build this for aarch64-unknown-linux-gnu on an x86_64 Mac, rustc will compile the Rust portions successfully. Then the linker will fail. It cannot find aarch64-linux-gnu-gcc or an equivalent cross-linker. You have two paths forward. You can install a cross-compilation toolchain from your package manager, or you can use a containerized workflow that handles the toolchain setup automatically.
Convention aside: the Rust community heavily favors cross for this exact reason. It spins up a Docker container with the correct target toolchain preinstalled, mounts your code, and runs the build. It removes the guesswork from installing gcc-aarch64-linux-gnu or configuring musl toolchains manually. Install it with cargo install cross and run cross build --target aarch64-unknown-linux-gnu --release. The container handles the C compiler, the linker, and the environment variables. Your host machine stays clean.
If your crate pulls in C code, you need a C cross-compiler. cross removes the guesswork.
Running what you built
You build the binary. You try to run it. You get Exec format error. The host CPU does not understand the instructions in the binary. You cannot execute an ARM binary on an Intel CPU without help.
You need an emulator to test the binary locally. qemu-user can emulate the CPU architecture and run the binary on your host. This lets you verify the binary works before shipping it to the target.
# Install qemu-user-static if not present.
# On Debian/Ubuntu: sudo apt install qemu-user-static
# Run the ARM binary under emulation.
qemu-aarch64 ./target/aarch64-unknown-linux-gnu/release/your-binary
# prints:
Running on the target architecture.
Convention aside: cross run handles this automatically. It builds the binary and invokes the appropriate QEMU emulator in one step. Use cross run --target aarch64-unknown-linux-gnu to build and test without leaving your terminal.
Test locally before shipping. An emulator catches crashes that only appear on the target instruction set.
Pitfalls and workarounds
Cross-compilation trips up developers in three predictable places. The first is the linker. You will see errors like linker 'cc' not found or cannot find -lpthread. The compiler is asking for a C toolchain that matches your target triple. Install the appropriate cross-compiler package for your host OS, or switch to a containerized build.
The second is environment variables. Crates that probe the host system during build scripts will report the wrong architecture. You will get a E0308 mismatched types error or a panic in a build.rs file because the script detected your host CPU instead of the target CPU. Always pass CARGO_TARGET_<TRIPLE>_LINKER or rely on cross to set the correct environment. Build scripts should never assume the host matches the target.
The third is dynamic linking. If you compile for gnu targets, the resulting binary expects glibc to be present on the target machine. Older servers might run a glibc version that is too old for your binary. You will get GLIBC_2.31 not found at runtime. Compile for musl instead. Musl is a lightweight C library that produces fully static binaries. Add aarch64-unknown-linux-musl as your target and link against it. The binary will run anywhere without worrying about system library versions.
Trust the target triple. If the environment field says gnu, you are tied to the host glibc version. If it says musl, you are shipping a self-contained binary. Pick the one that matches your deployment reality.
Choosing your cross-compilation path
Use rustup target add with cargo build --target when your project is pure Rust or only depends on crates that ship precompiled artifacts. Use a host-native cross-toolchain when you need fine-grained control over the C compiler flags and cannot use Docker. Use the cross crate when your project includes C dependencies, build scripts, or complex linking requirements and you want a reproducible build environment. Use cargo-zigbuild when you need to statically link against system libraries without managing multiple Docker images or installing host toolchains. Reach for plain cargo build when the target matches your host machine. The extra flags only add friction when they are unnecessary.