Skip to content

Building a Cross-Compiler

A cross-compiler is a compiler that runs on one system but produces code for another: in kernel development, one that runs on the development machine’s host operating system but produces freestanding binaries with no dependency on that host’s C library, ABI conventions, or executable format assumptions. Building one is one of the first practical steps in most kernel projects, and skipping it in favor of the compiler already installed on the development machine is a common source of subtle, hard-to-diagnose bugs later on, bugs that tend to surface only once a kernel is far enough along to run into the specific host assumption a given compiler feature made, rather than at the moment the offending flag was first used.

A default GCC or Clang installation is configured to target the host operating system, and several of its default behaviors depend on facilities that a freestanding kernel simply does not have. Stack-smashing protection, enabled by default on most Linux distributions’ GCC builds, inserts a call to __stack_chk_fail on function exit: a symbol the host’s C library provides and a kernel does not, producing a link failure or, worse, a jump to garbage if the flag is silently accepted but the symbol never gets defined. Position-independent executables, likewise a common distribution default, assume a dynamic loader will relocate the binary at load time; a kernel loaded directly by a bootloader has no such loader, and a PIE kernel image without one sitting at the address its own internal pointers assume is simply broken. A host compiler also links a small amount of C runtime startup code (conventionally crt0.o, crti.o, and crtn.o) into every executable by default, code whose entire job is to call into a C library’s own initialization before ever reaching main, none of which exists or would make sense for a kernel that has no C library to initialize and is itself the first code the CPU runs after the bootloader hands off control.

Some of these assumptions can be worked around with enough compiler flags on the host compiler directly (-nostdlib, -fno-stack-protector, -fno-pie, and similar), but the combination is fragile and differs across host distributions, compiler versions, and even patch releases, since a distribution is free to change which flags are on by default at any point without treating that as a compatibility break for ordinary userspace software. A cross-compiler avoids the problem structurally rather than defensively: because it is configured from the start to target a bare-metal, freestanding environment with no host operating system in the picture at all, none of these host-specific defaults are compiled into it in the first place, and there is no ongoing risk of a distribution update silently reintroducing one of them into a kernel’s build.

A GCC cross-compiler is identified by a target triplet, a hyphen-separated string historically read as cpu-vendor-os but more precisely, once a fourth optional field is present, as cpu-vendor-kernel-system. For kernel development the “vendor” field is conventionally omitted or left as a placeholder, and the meaningful choice is the final field: x86_64-elf (commonly written with an empty vendor position, sometimes explicitly as x86_64-elf-elf by build systems that insist on three parts) targets a 64-bit machine with no operating system and no C library assumed at all, in contrast to x86_64-linux-gnu, the triplet describing an ordinary host compiler targeting Linux with glibc as its C library.

The same convention extends to other architectures a hobbyist kernel might target: i686-elf for 32-bit x86, aarch64-elf or aarch64-none-elf for 64-bit ARM, and riscv64-unknown-elf for RISC-V, each following the pattern of naming a bare, standalone environment rather than a specific operating system’s ABI. The eabi variant seen on some 32-bit ARM triplets (arm-none-eabi) refers to the ARM Embedded Application Binary Interface rather than to any operating system, and is itself already a freestanding-oriented target commonly used unmodified for microcontroller work: kernel developers targeting 32-bit ARM sometimes use it directly rather than building a separate arm-none-elf toolchain, since the two differ mainly in calling-convention details that matter more for interoperating with vendor-supplied libraries than for a self-contained kernel.

This triplet is what both Binutils and GCC are configured with during their own build, and is also the prefix used to invoke the resulting tools (x86_64-elf-gcc, x86_64-elf-ld, x86_64-elf-objdump, and so on for the rest of the Binutils suite), letting a cross-compiler coexist on the same system as the host’s own native toolchain without either shadowing the other.

Binutils and GCC are developed and released independently, and while most released version combinations build and work correctly together, pairing a very new GCC release against a very old Binutils (or the reverse) occasionally fails outright, either because GCC’s build system expects an assembler directive or linker feature the older Binutils release doesn’t implement, or because Binutils’ own build scripts predate a change in how a newer host compiler enforces warnings. Sticking to Binutils and GCC releases that were current around the same time, both a year or two old rather than bleeding-edge, is the most reliable way to sidestep this class of problem without needing to track compatibility matrices directly.

GCC’s own build additionally depends on three arbitrary-precision arithmetic libraries it uses internally for constant folding and floating-point emulation during compilation itself (GMP, MPFR, and MPC), which are not part of GCC’s own source tree. Most Linux distributions package development versions of all three (commonly libgmp-dev, libmpfr-dev, libmpc-dev or similarly named), and installing them beforehand avoids a configure failure partway through GCC’s build; GCC’s source tree also ships a contrib/download_prerequisites script that fetches and unpacks matching versions of all three directly into the source tree as a fallback for systems where the corresponding distribution packages are unavailable or too old.

Binutils (the assembler, linker, and related tools GCC itself depends on) is built first, since GCC’s own build process needs a matching cross-assembler and cross-linker already available:

Terminal window
mkdir build-binutils && cd build-binutils
../binutils-gdb/configure --target=x86_64-elf --prefix="$HOME/opt/cross" \
--with-sysroot --disable-nls --disable-werror
make
make install

--with-sysroot (even with no path following it) tells Binutils to treat itself as targeting an independent system root rather than the host’s own /, and --disable-nls skips building native-language support translations, which are irrelevant for a toolchain that will only ever be invoked from build scripts. --disable-werror matters more than its name suggests: Binutils’ own source occasionally triggers warnings under a host GCC newer than the one current when a given Binutils release shipped, and without this flag those warnings are treated as hard errors, aborting the build over a diagnostic wholly unrelated to the cross-compiler being produced. A successful build populates $HOME/opt/cross/bin with the full triplet-prefixed tool set (x86_64-elf-as, x86_64-elf-ld, x86_64-elf-ar, x86_64-elf-objcopy, x86_64-elf-objdump, x86_64-elf-nm, and x86_64-elf-ranlib among them), even though only as and ld are strictly required for GCC’s own subsequent build to proceed.

With the freshly built cross-Binutils on PATH, GCC’s own build follows a similar configure/make pattern, but with two flags specific to a freestanding target:

Terminal window
export PATH="$HOME/opt/cross/bin:$PATH"
mkdir build-gcc && cd build-gcc
../gcc/configure --target=x86_64-elf --prefix="$HOME/opt/cross" \
--disable-nls --enable-languages=c,c++ --without-headers
make all-gcc
make all-target-libgcc
make install-gcc
make install-target-libgcc

--without-headers tells GCC’s build not to expect a C library’s headers to be available at all, appropriate for a compiler that will only ever build freestanding kernel code, never hosted userspace programs expecting a standard library. Building only all-gcc and all-target-libgcc, rather than the full all target, skips components (like libstdc++) that either aren’t needed for kernel code or that require more of a working target environment than exists at this point to build correctly.

The two-stage split between all-gcc and all-target-libgcc reflects a genuine dependency, not just a build-script convenience: all-gcc produces the compiler driver and code generator itself, while all-target-libgcc builds libgcc, a small runtime library the compiler silently links against for operations no target instruction set handles directly: 64-bit integer division and modulo on a 32-bit target (__udivdi3, __moddi3), software floating-point emulation on targets without an FPU, and stack-unwinding support used by C++ exceptions among them. A kernel built with --without-headers, ironically, still depends on libgcc for these low-level helpers even though it depends on nothing else the host or a C library would normally supply, which is why building it is a mandatory second stage rather than an optional extra: a kernel that never triggers a 64-bit division on a 32-bit target might never notice its absence, but one that does gets an unresolved-symbol link error with no indication of which line of ordinary-looking C code was responsible for pulling it in.

A build failing partway through all-target-libgcc with complaints about a missing gmp.h, mpfr.h, or similarly named header almost always traces back to the dependency described above rather than to anything wrong with the cross-compiler configuration itself, and is resolved by installing the missing development package or rerunning GCC’s prerequisite-download script before retrying the build from a clean directory.

Passing --enable-languages=c,c++ builds a cross-compiler capable of compiling C++ source, but a kernel using it still cannot use C++ the way a hosted application would without giving something up. Exception handling and dynamic_cast/RTTI both depend on a runtime support library (normally libsupc++, paired with unwind tables interpreted by a personality routine) that assumes a hosted environment with a working abort and other C library facilities a freestanding kernel does not provide; most kernels written partly or fully in C++ compile with -fno-exceptions -fno-rtti for this reason; enabling either requires supplying the missing runtime pieces by hand, which few hobbyist kernels bother to do given how little functionality is gained relative to the effort. Global constructors present a smaller, more commonly solved version of the same underlying issue: a hosted program’s C runtime startup code walks the .init_array section and calls each entry before main runs, but a kernel has no such startup code by default and must walk that section itself, typically as one of the first things its own entry point does, or global objects with non-trivial constructors silently never get initialized despite compiling and linking without any error.

A correctly built cross-compiler should refuse, by default, to produce output depending on host facilities it was configured without:

Terminal window
x86_64-elf-gcc -v # confirms the target triplet in its own reported configuration
echo 'int main(){return 0;}' | x86_64-elf-gcc -x c -c - -o /tmp/test.o # should succeed
x86_64-elf-readelf -d /tmp/test.o # should report no dynamic section

A telltale sign of accidentally still using the host compiler rather than the freshly built cross one is any error or warning mentioning the host’s own C library headers or a host-specific target triple in diagnostic output: a genuine x86_64-elf-gcc invocation has no knowledge of the host’s /usr/include at all. Running x86_64-elf-nm over a linked kernel binary and finding ordinary libc symbol names (malloc, printf, memcpy provided by glibc rather than by the kernel’s own implementation) is a similar red flag one level further downstream, one that a passing compile can still hide if the host’s linker was invoked in place of the cross one somewhere in a build script.

Clang, built on LLVM, takes a structurally different approach to cross-compilation that avoids most of the process described above: rather than each target requiring its own separately configured and built compiler binary, a single Clang binary natively supports every LLVM backend it was built with, selected at invocation time with a -target flag (clang -target x86_64-elf -ffreestanding ...) instead of by which prefixed binary happens to be on PATH. This does not eliminate every cross-compilation concern: Clang still needs an appropriate linker for the target (LLVM’s own lld supports the same triplets Clang does and is commonly paired with it for this reason) and its own equivalent of libgcc, called compiler-rt, still needs building for the target if a kernel ends up depending on the same low-level helper routines libgcc provides, but it removes the multi-hour Binutils-then-GCC build sequence entirely for a developer who already has a suitably configured Clang installed. Most existing kernel-development tutorials and build scripts are still written against GCC, which is the main practical reason a GCC cross-compiler remains the more commonly documented starting point despite Clang’s simpler cross-compilation model.

PATH ordering matters for every subsequent build step in a kernel project: if the host’s native compiler is found first, make or a hand-written build script silently uses it instead of the cross-compiler unless every invocation explicitly references the cross-compiler’s prefixed name: a common, confusing failure mode is a kernel that appears to build successfully but crashes immediately on boot, the result of having actually been compiled with host assumptions baked in throughout. Placing the cross-compiler’s bin directory early in PATH, and referencing tools by their triplet-prefixed names explicitly in build scripts rather than relying on bare gcc, avoids this ambiguity entirely.

Build time varies enormously with available parallelism: a single-threaded make building GCC alone can take the better part of an hour even on otherwise fast hardware, while passing -j followed by the host’s core count (make -j$(nproc) on Linux) commonly cuts this to well under fifteen minutes on a modern multi-core machine, since the bulk of the time goes into compiling GCC’s own considerable C and C++ source rather than into anything inherent to cross-compilation. The build and source directories together typically consume one to two gigabytes of disk space by the time both Binutils and GCC finish building, most of which is safe to delete once make install has copied the finished toolchain into its --prefix directory. Finally, once a working cross-compiler exists for a given target and toolchain version pair, it is worth keeping the exact source archive versions used on hand or noted somewhere: rebuilding from a newer point release later, after months of kernel development against the old one, occasionally surfaces a warning or default-flag change that a stable, unchanging toolchain would never have introduced mid-project.

  1. ^ OSDev Wiki, “GCC Cross-Compiler”: a detailed hobbyist walkthrough covering additional configure flags and common build errors.
  2. ^ GCC Installation, “Configure Terms and Generalities”: the canonical reference for --target, --without-headers, and GCC’s other configure-time options.
  3. ^ GNU Binutils manual: documents --with-sysroot and the rest of Binutils’ own configure options.