ABItarget

Cross-Compilation

Building on one machine for a different one. The compiler is the easy part: what makes it work is a sysroot containing the target's headers and libraries, because a compiler that reads the host's headers produces a binary for a machine that does not exist.

The question

Why does building for another platform need more than a compiler that can emit its instructions?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

Three machines, held apart deliberately: the *build* machine the compiler runs on, the *host* machine the compiler itself runs on if it is being built, and the *target* whose instructions it emits. Everything the compiler consults must be attributed to one of them — this header is the target's, this library is the target's, this temporary file is the build machine's — and cross-compilation is what happens when those attributions are made explicit instead of assumed identical.

What this phase may assume or do

A cross build is correct only if every artifact that will end up in the output, or that determines the shape of the output, comes from the target: headers, static and shared libraries, the C runtime startup files, and the linker's idea of the default library search paths. The build machine may contribute only tools. Any target-dependent value the build cannot determine by consulting the target's files — a type size, an endianness, whether a function exists — must be supplied as configuration rather than probed, since probing runs on the wrong machine.

Key points

  • A compiler emitting the right instructions is necessary and nowhere near sufficient: the headers, libraries and startup objects must also come from the target.
  • The sysroot is the mechanism — a directory holding the target's usr/include and usr/lib that --sysroot redirects every system lookup into.
  • Clang is natively multi-target and selects with --target; GCC is conventionally packaged as one driver per target with its sysroot configured in.
  • Anything a build discovers by running a test program cannot be discovered under cross-compilation and must be supplied as configuration.
  • Build-time tools and target artifacts need separate compilations, which is why serious build systems have an explicit host/target configuration split.
  • Go cross-compiles trivially only because a pure-Go build needs no target libc; enabling cgo restores every difficulty at once.

Host, build, target

The vocabulary is confusing because it has three terms for what is usually one machine. The *build* machine is where the compiler executes. The *target* is the machine whose code it emits. The third, *host*, matters only when you are compiling a compiler: it is the machine the compiler being built will itself run on. A build machine producing a compiler that runs on a phone and emits code for a microcontroller is a Canadian cross, and it is the case that forces the vocabulary to have all three.

For ordinary work, two suffice: you build on x86-64 Linux and target aarch64-unknown-linux-gnu. What makes this more than a flag is that a compiler consults far more than its own code generator. It reads headers to know what types and functions exist. It links against startup objects — crt1.o and friends — that call main. It links against a C library. It asks the linker for default search paths. Every one of those must come from the target.

Almost every cross-compilation failure is one of those inputs leaking from the build machine. Compiling against the host's stdio.h and linking against the target's libc gives you a program whose FILE layout is wrong. Picking up a host .so gives a link error at best and an unloadable binary at worst.

What a cross build must attribute to which machinetypical
  1. Preprocessingbuild time
    Source plus the target's headers, with the target's predefined macros.
    Type sizes, feature macros and declarations as the target defines them.
    Any assumption that the build machine's headers would have been equivalent.
  2. Compilationbuild time
    Target instructions in an object file for the target's ABI.
    The target's calling convention, register set, endianness and data model.
  3. Linkingbuild time
    A target executable or shared object.
    The target's startup objects, its libc and its search paths — all from the sysroot.
    The ability to run the result on the build machine, which is what makes testing a separate problem.
  4. Testingrun time
    A binary that this machine cannot execute.
    Nothing here — it requires the target, an emulator, or a remote runner.

Read it asRead the last row as the reason cross builds are organisationally harder than technically hard. The first three rows are solved by pointing the toolchain at a sysroot. The fourth has no equivalent solution: the artifact cannot run where it was made, so every feedback loop gets longer, and any build step that wanted to *execute* something it just built — a code generator, a configure probe, a build-time table computation — has to be split into a host build and a target build.

The sysroot is the actual mechanism

implementationThat Clang is natively multi-target while GCC is conventionally built per target is a property of how the two projects are built and packaged rather than a language rule; GCC can be configured with multiple targets and rarely is. Rust's rustup target add installs a precompiled standard library for the target but does not provide a linker or a sysroot, which is why cross-linking to a glibc target still requires a C toolchain.

A sysroot is a directory containing the target's filesystem as the toolchain should see it: usr/include with the target's headers, usr/lib and lib with its libraries and startup objects. Passing --sysroot=/path tells the compiler and linker to resolve every system include and every library search relative to that root instead of /.

This is the entire trick, and it explains why cross-compiling C and C++ is mostly an exercise in acquiring one. You get it by unpacking the target distribution's packages, by copying it off a running device, by using a vendor SDK, or by building one with a tool like crosstool-NG or Buildroot. The compiler was never the hard part; a single Clang binary can emit code for every target it was configured with, given --target and a sysroot.

GCC works differently in a way worth knowing: it is conventionally built as a separate binary per target, named with the target triple — aarch64-linux-gnu-gcc — with its sysroot baked in at configure time. Clang is natively cross by design and selects the target at invocation. This is why "install a cross compiler" means downloading a toolchain package for GCC and means passing a flag for Clang, and why build systems have to accommodate both.

The same cross build, three toolchains
1# Clang: one binary, target and sysroot chosen per invocation
2clang --target=aarch64-unknown-linux-gnu --sysroot=/opt/sysroots/arm64-bookworm -fuse-ld=lld main.c -o app
3
4# GCC: a per-target driver with its sysroot configured in
5aarch64-linux-gnu-gcc main.c -o app
6
7# Rust: a target std must be installed; the linker is still a C toolchain
8rustup target add aarch64-unknown-linux-gnu
9cargo build --target aarch64-unknown-linux-gnu
10# .cargo/config.toml must name a linker that can link for the target:
11# [target.aarch64-unknown-linux-gnu]
12# linker = "aarch64-linux-gnu-gcc"
13
14# Go: cross-compiles with environment variables and no sysroot at all,
15# because pure-Go builds do not use a C toolchain or the target's libc
16GOOS=linux GOARCH=arm64 go build -o app

The Go line is the interesting one. Go cross-compiles trivially precisely because its default build has no dependency on the target's C library or headers — the runtime is Go, syscalls are made directly, and the linker is its own. Enable cgo and every difficulty in this lesson returns at once, which is why CGO_ENABLED=0 is the standard advice for cross-building Go.

What cannot be probed

Autoconf-style configuration works by compiling and *running* small test programs on the build machine to discover properties. That entire technique is invalid under cross-compilation, because the program would run on the wrong machine. This is why ./configure scripts have a long list of ac_cv_* cache variables you are expected to supply by hand when cross-compiling, and why the failure mode is a build that succeeds and produces a binary configured for the build machine's properties.

The properties in question are exactly the ones an ABI fixes: sizes of long and pointers, endianness, alignment requirements, whether char is signed, whether a function exists in the target libc. Compile-only probes still work — you can test whether a header declares something — but anything requiring execution must become configuration.

The same problem appears in project build systems as a distinction between build-time and target-time artifacts. A code generator that the build compiles and then runs must be compiled for the *build* machine, while everything it generates is compiled for the target. Build systems that take cross-compilation seriously — CMake with a toolchain file, Meson with a cross file, Bazel with host and target configurations — all encode this split explicitly, and build systems that do not tend to work by accident until someone tries it.

  • Anything discovered by running a test program must be supplied as configuration instead.
  • Build-time tools — generators, table builders, protoc plugins — are compiled for the build machine, not the target.
  • Test suites need an emulator (qemu-user), a remote runner, or to be deferred to the device.
  • Any embedded absolute path from the build machine is both a portability bug and a reproducibility bug — see [[reproducible-compilation]].
  • The dynamic linker path recorded in the binary is a target path, and it is a common source of "no such file or directory" on an executable that plainly exists.

Why bother

The reasons are unglamorous and decisive. The target may be far slower than the build machine, which is the entire embedded and mobile story: nobody compiles a kernel on a microcontroller. The target may have no development environment at all, which is the case for firmware, WebAssembly and most game consoles. Or the target simply may not be available in quantity, while build machines are.

There is also a reproducibility argument that has become the dominant one in server work. A cross build makes the build environment explicit — a named sysroot, a pinned toolchain — instead of inheriting whatever the build machine happens to have installed. That is the same property [[hermetic-compilation]] is after, and it is why container images and CI toolchain pinning have made cross-style discipline normal even for builds where host and target are the same machine.

The counter-argument is equally real: a native build on the target has no sysroot to acquire, no configure variables to guess, and can run its own tests. Where target machines are plentiful and fast — most cloud work — building natively on the architecture you deploy to is simpler and remains the common choice.

How it works

The steps, in the order the compiler takes them.

  • The build selects a target triple, which fixes the architecture, the OS and the ABI the backend and the ABI lowering will use.
  • A sysroot supplies the target's headers, so preprocessing sees the target's type sizes, feature macros and declarations rather than the build machine's.
  • The compiler emits object files for the target ABI, using the target's calling convention, register set, endianness and data model.
  • The linker is invoked with the target's startup objects and libraries from the sysroot, and with search paths rooted there rather than at /.
  • Any value the build cannot determine without executing target code is supplied by configuration — a toolchain file, a cross file, or cached configure variables.
  • Build-time tools are compiled separately, for the build machine, and their outputs are fed into the target compilation.
  • Testing is deferred to real hardware, an emulator, or a remote runner, because the artifact cannot execute where it was produced.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • The build succeeds and the binary immediately faults on the target, because a host header was picked up and a struct the libc uses has a different layout than the one compiled in.
  • A configure script silently caches the build machine's answers — pointer size, endianness, signedness of char — and the resulting binary computes wrong values on the target for reasons no source line explains.
  • The linker fails with an "incompatible" or "wrong file class" error partway through a large link, because one library in the search path was the host's.
  • The binary refuses to start on the target with "no such file or directory" even though it exists, because the recorded interpreter path names a dynamic linker that is not there.
  • A build step compiles a code generator with the cross compiler and then tries to run it, and the build dies with exec format error.
  • Everything works on the build machine and only fails on the device, so the feedback loop for every mistake is a deploy cycle rather than a compile.

When it helps

  • Targets that cannot practically build for themselves: microcontrollers, firmware, consoles, WebAssembly, and anything where the target is much slower than the build fleet.
  • Building many targets from one place — a release pipeline producing Linux, macOS and Windows binaries for two architectures from a single job.
  • Making the build environment explicit and pinned, which is the same discipline that makes builds reproducible and cacheable.

When it hurts

  • When target machines are cheap and fast, as in most cloud work. A native build needs no sysroot, guesses nothing, and can run its own tests.
  • Projects with heavy native dependencies and autoconf-based builds, where every dependency brings its own set of cross-compilation assumptions to discover and override.

What it costs

Every one of these is paid by something.

  • Cross-compiling buys build throughput and the ability to target machines that cannot build, and costs a sysroot to acquire and maintain plus the loss of running tests as part of the build.
  • Pinning a sysroot buys reproducibility and a build that does not depend on the machine it ran on, and costs the work of keeping that sysroot patched — it is now a dependency you own, including its security updates.
  • A per-target GCC toolchain buys a self-contained, known-good setup and costs a separate installation per target; a single multi-target Clang costs correct flags at every invocation and buys one binary for all of them.
  • Deferring tests to an emulator buys a feedback loop inside the build and costs fidelity — qemu-user does not reproduce the target's timing, memory model or hardware, so a test passing under it is weaker evidence than one passing on the device.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Build natively on the target architecture, using cloud instances or CI runners of that architecture. Simplest by far when such machines exist, and it is why arm64 CI runners changed the calculus for a lot of projects.
  • Build in a container or virtual machine for the target platform, emulated if necessary. You get a native build with a native sysroot and pay heavily in speed under emulation.
  • Target a virtual machine instead of a physical one — the JVM, WebAssembly, a bytecode format — so the build produces one artifact and the platform difference is resolved at load time. See [[wasm-vs-native]].
  • Vendor-supplied SDKs, which are a curated sysroot plus a pinned toolchain plus device tooling, and are what mobile and console development uses because assembling the equivalent by hand is not worth it.

See it for yourself

The flag, dump or tool that shows you this directly.

  • See what the compiler thinks the target is: clang --target=aarch64-linux-gnu -dM -E - < /dev/null prints every predefined macro, including type widths and endianness.
  • See where the headers and libraries came from: -v on the compile and link commands prints the include search list and the full linker invocation with all its paths.
  • Check the artifact: file app reports the architecture and the interpreter path, and readelf -h app gives the ELF class, data encoding and machine.
  • Check what it will look for at runtime: readelf -d app | grep NEEDED lists required shared objects and readelf -l app | grep interpreter shows the dynamic linker path.
  • Run it anyway: qemu-aarch64 -L /opt/sysroots/arm64 ./app executes a foreign binary against the sysroot, which is the usual way to get a test signal without hardware.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "Cross-compiling just needs a compiler that supports the architecture." It needs the target's headers, libraries and startup objects too. The code generator is the part that was already solved.
  • "Go proves cross-compilation is easy." Go proves that removing the dependency on the target's C library makes it easy. Turn on cgo and Go faces exactly the same sysroot problem as everyone else.
  • "If it links, it will run." Linking checks symbol names. A host header producing a wrong struct layout links perfectly and faults at runtime.
  • "The build machine and the target are both Linux, so the environment is the same." Different architectures have different type widths, alignments, endianness and libc versions, and any of them is enough.

Misconceptions

The claim, and what is actually true.

Cross-compilation is a compiler feature.
It is a toolchain and environment problem. The backend supporting the target is table stakes; the sysroot, the linker and the build-system split are the work.
A cross-compiled binary is somehow lower quality than a native one.
It is the same compiler running the same passes. What differs is the confidence you have in it, because you did not run its tests as part of the build.
The host is the machine you are compiling for.
The *target* is. Host is the machine the compiler itself runs on, and the two are only distinct when you are building a compiler.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

Cross-compiling means building on one kind of machine for another. The compiler part is a flag. The real requirement is a copy of the target system's headers and libraries — a sysroot — because otherwise the compiler reads your machine's definitions of everything and produces a binary that matches no real system.

practical

Get a sysroot, point the toolchain at it, and make the build system distinguish build-time tools from target artifacts. Then check the result: file and readelf -h confirm the architecture, readelf -d shows what it will look for at runtime, and qemu-user will run it well enough to catch gross mistakes. If a configure script is involved, expect to supply cross answers for anything it wanted to discover by running a program.

advanced

The deepest issue is that cross-compilation exposes assumptions a native build lets you keep implicit, and that is why it is a good forcing function even when you do not need it. Every place the build reads something from the machine it is running on — a header, a library, a path, a probe result, a timestamp — is a place where the build is not a pure function of its inputs. Cross-compilation makes those places fail loudly, whereas a native build lets them quietly encode the build machine into the artifact. This is the same set of leaks that [[hermetic-compilation]] closes for correctness and [[reproducible-compilation]] closes for verifiability, which is why organisations that adopt one tend to end up with all three.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

targetType widths, endianness, alignment rules and the default dynamic linker path are all target properties, and they differ even between two Linux systems: LP64 on Linux versus LLP64 on Windows changes long, and the interpreter path differs between glibc and musl even on the same architecture.
implementationClang selecting its target per invocation and GCC being packaged per target is a property of how the two are conventionally built rather than a rule; the naming convention aarch64-linux-gnu-gcc reflects a configure-time choice. Rust ships target standard libraries via rustup but not linkers, so a cross build still needs a C toolchain for the link step on most targets.
typicalThat autoconf-based builds cache incorrect answers under cross-compilation is typical rather than universal — a well-maintained configure script supplies cross defaults for the common probes. The failure is common enough that CMake toolchain files and Meson cross files exist specifically to make the host/target split explicit instead of discovered.

If you were asked this in an interview

  • What does a cross compiler need beyond the ability to emit the target's instructions?
  • Why do autoconf-style configure scripts break under cross-compilation, and what replaces them?
  • Why does Go cross-compile so easily, and what changes when you enable cgo?

Connections

Domains that do not exist yet
  • DevOps / Production Engineering — Build environments as pinned, versioned artifacts
    A sysroot and a toolchain are dependencies with versions, patches and a supply chain, and treating them as such is what turns a cross build from a fragile local setup into a reproducible pipeline step. How those artifacts are stored, pinned and rolled forward is owned there.
  • Testing & Reliability Engineering — Testing artifacts that cannot run in the environment that produced them
    Cross-compilation removes the ability to run tests as part of the build, so the test strategy has to change — emulation, device farms, staged rollout. The compiler side is here; designing the test strategy that compensates belongs there.