Cross Compilation

Cross compilation is compiling on one architecture to produce a binary that runs on another. Target triples, sysroots, and a GitHub Actions example.

Cross compilation is compiling source on a machine of one architecture to produce a binary that runs on a different architecture, using a toolchain configured to emit code for that other target. An x86-64 build machine can therefore produce an ARM64 executable that the build machine itself is unable to run.

In GitHub Actions this appears whenever a workflow ships artifacts for more than one architecture or operating system. A single job on a single runner can emit several targets, because the compiler picks the output architecture from a flag rather than from the hardware underneath it.

Definition

Toolchain documentation still uses the three machine roles the GNU build system established:

  • Build: the machine that compiled the toolchain itself.
  • Host: the machine that runs the compiler. In GitHub Actions the host is the runner.
  • Target: the machine that runs the compiler's output.

A native build puts host and target on the same architecture. A cross build separates them, and everything else about cross compilation follows from that separation.

A working cross toolchain has four parts. A missing part is where most cross builds fail:

  1. A compiler backend that emits instructions for the target instruction set.
  2. A linker and binutils that understand the target object format.
  3. A sysroot holding the target's headers and libraries, so includes resolve and the link step finds the libc for the target ABI.
  4. A standard library or runtime compiled for the target, in languages that ship one.

The target is named by a target triple, conventionally written arch-vendor-os-abi even though the field count varies by toolchain. aarch64-unknown-linux-gnu asks for 64-bit ARM instructions, a Linux kernel interface, and the GNU C library ABI. Changing the final field to musl changes the libc the binary links against and yields a different artifact from identical source. Rust publishes its full list on the platform support page, and GCC documents the naming convention under configure terms.

Every mainstream toolchain exposes the target as a build-time selector:

ToolchainHow the target is selectedExample value
GoGOOS and GOARCH environment variablesGOOS=linux GOARCH=arm64
Rust--target plus a std library added by rustup target addaarch64-unknown-linux-gnu
Clang--target plus --sysrootaarch64-linux-gnu
GCCa target-prefixed driver binaryaarch64-linux-gnu-gcc
.NET--runtime, or the RuntimeIdentifier propertylinux-arm64
Zig-targetaarch64-linux-gnu

Cross compilation and emulation solve the same problem from opposite ends. A cross compiler writes out target instructions while every process on the machine stays native, so the build executes at host speed and no target code ever runs. An emulator translates target instructions at run time, which lets a native toolchain and a test suite for the target execute on host hardware at a translation cost. The second path is covered in QEMU emulation.

A green cross build proves that the source compiled and linked for the target triple. Nothing executed the output, so run-time behavior stays unverified. Weak memory ordering on aarch64, the unsigned default for char on ARM64, and a sysroot glibc newer than the deployment target are invisible to the compiler and surface the first time the binary runs on real target hardware.

Example

This workflow cross compiles a Rust binary for 64-bit ARM Linux on an x86-64 runner. The target triple is stated explicitly on the cargo build line, and the job never executes the artifact it produces.

name: release
on:
  push:
    tags: ["v*"]

jobs:
  build-arm64:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: rustup target add aarch64-unknown-linux-gnu
      - run: sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu
      - run: cargo build --release --target aarch64-unknown-linux-gnu
        env:
          CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
      - uses: actions/upload-artifact@v4
        with:
          name: app-aarch64
          path: target/aarch64-unknown-linux-gnu/release/app

Each step supplies one part of the toolchain listed above:

  • rustup target add downloads a standard library precompiled for aarch64-unknown-linux-gnu.
  • The gcc-aarch64-linux-gnu package installs the cross linker and the target sysroot from the Ubuntu archive.
  • CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER points Cargo at that linker, because the host cc is unable to link aarch64 objects.
  • --target selects the code generation backend and the ABI.

The artifact lands under target/aarch64-unknown-linux-gnu/release/, and file identifies it as an aarch64 object rather than an x86-64 one:

app: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), dynamically linked

Running that binary on the same runner returns cannot execute binary file: Exec format error, which is the expected result and the clearest sign that the cross build worked.

Go reaches the same output with two environment variables and no extra packages, because the Go toolchain ships every target it supports:

      - run: GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o app-arm64 ./cmd/app

Setting CGO_ENABLED=1 puts that build back on a C toolchain, so a CC variable pointing at a cross compiler becomes necessary again. The same rule governs Python wheels with native extensions, Node addons built by node-gyp, and any JNI library in a JVM project.

Runner images differ in which cross toolchains they carry, so a workflow either installs the packages it needs in a step or selects an image that already ships them. Preinstalled software lists the toolchains on each image, and cloud runners lists the labels a workflow can request.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.