Systems Programming · Cross Compilation
Why a Cross Toolchain Bootstraps in a Fixed Order
The compiler needs a C library to link, and the C library needs a compiler to build. How a three-stage bootstrap breaks that cycle and which artefacts appear at each stage.
Building a cross toolchain from source is not a single configure and make. It is at least three passes over the same source trees, and the order is forced by a cycle: GCC’s configure wants to link a test program against the target’s C library to confirm the target works, while the C library’s configure wants a compiler that can produce a runnable target binary to answer its own questions. Neither can go first in the form its build system expects. The usual resolution is to build a deliberately incomplete compiler that cannot use a C library at all, use it to build the C library into a sysroot, and then rebuild the compiler properly against that sysroot.
The cycle is not an accident of packaging. Each pass exists because a specific class of file cannot exist before it, and knowing which class belongs to which pass turns a mystifying build failure into a missing stage.
The incomplete first compiler
The first pass produces a compiler that can generate target assembly and object code but has no target C library to link against. GCC’s configure accepts this state explicitly: --without-headers declares that no target headers exist yet, --with-newlib suppresses the assumption that a C library provides the standard functions, and --disable-shared avoids producing shared libraries that would need a dynamic loader. --without-headers implies --with-newlib in GCC’s own configure logic, so the two are often written together for clarity rather than necessity.
What this pass produces is cc1, cc1plus if C++ was requested, the assembler and linker if binutils is being built in the same tree, and a static libgcc.a. It also produces the compiler’s own documentation and message catalogues, which is why a bootstrap build takes noticeably longer than the size of the resulting binaries suggests.
The first pass cannot link a normal C program. Any test in its own configure that tries to link against libc fails, which is why the build is configured to skip those tests. The compiler is still useful because compiling to object code does not require any library: assembly and object emission depend on the back end’s knowledge of the target, not on the target’s filesystem.
libgcc.a at this stage contains the helper routines that GCC’s code generator can emit calls to. On a 32-bit ARM target those are the __aeabi_* functions: __aeabi_ldivmod for a 64-bit division, __aeabi_lmul for a 64-bit multiply, __aeabi_f2d and the other conversions, and the outline atomics helpers. On any target they include the unwinder’s frame registration hooks. A compiler whose back end can emit a call to __divdi3 and whose libgcc does not define it produces objects that fail to link with an undefined reference the user’s source never mentions.
Headers before the library body
The second pass builds the C library against the first-pass compiler. Its configure script is a native build in the sense that its tests are compiled and run, but the compiler it uses produces target code, so the only way it can proceed is with a cache of pre-answered checks or with tests that do not execute. glibc’s configure solves this the way most such projects do: a set of libc_cv_* variables can be preset from the command line, and the checks that genuinely require execution are the minority.
The important output of this stage is not the finished library but the installed header set. glibc provides an install-headers make target that copies the public headers, the generated bits/ headers, and the handful of stub files other components need, without requiring the library itself to have been built yet. install_root points the target at the directory that will become the sysroot, and the headers land under usr/include inside it.
Kernel headers belong to this stage as well, and they come from a different source tree. The C library needs the __NR_* system call numbers, the struct stat and struct sockaddr layouts, and the ioctl constants that the kernel’s user-space ABI header set defines. Distributions package these separately from the kernel image, exactly because a C library build needs them and a running kernel does not. A root that has libc headers but no kernel headers fails on the first #include <sys/socket.h>.
The order matters even inside this stage: glibc’s headers are generated partly by compiling small programs with the target compiler. A first-pass compiler that was configured for the wrong float ABI, for instance, generates headers whose float_t and double_t typedefs describe the wrong type, and the error surfaces many stages later as an unexplained precision loss. The float ABI decision is baked in here, which is why floating-point ABI variants have to be settled before the first configure runs rather than at the end.
The second compiler, which is the one that ships
The third pass rebuilds GCC against the sysroot: --with-sysroot=/opt/roots/target, --with-build-sysroot for the directory layout used during the build, and the same --target as before. Now the configure script’s link tests succeed, because a trivial program can be linked against the target’s crt1.o and libc.so. The compiler gains everything that was disabled in the first pass: shared libgcc_s.so, thread support through libgcc’s thread-local storage registration, the C++ standard library if --enable-languages=c,c++ was given, and the target’s float.h and limits.h values as seen through the real headers.
# Pass 1: a compiler that can emit target objects but not link a C program.
../gcc/configure --target=aarch64-linux-gnu \
--without-headers --with-newlib --disable-shared \
--enable-languages=c --prefix=/opt/cross
# Pass 3: the same source, now with a sysroot that has headers and libraries.
../gcc/configure --target=aarch64-linux-gnu \
--with-sysroot=/opt/roots/target \
--enable-languages=c,c++ --prefix=/opt/cross
The second compiler is not merely the first one plus features. Some of its generated code differs, because the availability of a C library changes the expansion of certain builtins and the default for -fstack-protector in distributions that enable it. A program compiled with the first-pass compiler and linked against the second-pass library can therefore differ in ways that are invisible in the source.
Where the startup objects come from
crt1.o, crti.o, and crtn.o come from the C library, not from GCC. crt1.o contains the process entry point _start, which reads argc and argv off the stack, initialises the C library, and calls main. crti.o and crtn.o hold the opening and closing halves of the .init and .fini function prologues, split so that the linker can place constructor and destructor code between them.
crtbegin.o and crtend.o come from GCC. They register exception-handling frames and walk the constructor lists that the linker has assembled. The default link line for a dynamically linked program on a GNU target is ordered crt1.o, crti.o, crtbegin.o, the user’s objects, the libraries, crtend.o, crtn.o. Both halves are needed, and a toolchain missing either produces a distinct error: a missing crt1.o is reported as a file the linker cannot find, while a missing crtbegin.o usually shows up as an undefined reference to __dso_handle or __register_frame_info.
The two halves living in different packages is the reason a “working compiler” can still fail to link. The compiler finds crtbegin.o in its own lib/gcc/<triple>/<version> directory, so it is always present. It finds crt1.o through the sysroot, so it is present only if the C library was actually installed into that root. gcc -print-file-name=crt1.o prints either a path inside the sysroot or, when the file is missing, the bare name repeated back — a diagnostic that is easy to misread as success.
What libgcc is, and why it appears twice
libgcc is the compiler’s runtime support library, and it is built twice with different contents. The static libgcc.a is linked into programs that need it: it provides the arithmetic helpers a target’s instruction set lacks, the software floating-point routines when no FPU is available, and the stack unwinder’s core. The shared libgcc_s.so is the same code compiled position-independent with a shared-library ABI, linked against the target’s C library, and required at run time by programs that use exceptions or backtrace.
There is a third artefact that is often confused with these two: the libraries GCC itself needs to run, such as its internal representation of arbitrary-precision integers and its own memory allocator. Those are built for the machine running the compiler, not for the target, and they are statically linked into cc1 by default. A Canadian cross build, where the compiler runs on a machine other than the one that built it, is precisely the case where this distinction stops being academic — see build, host, and target machines.
Failures that point at the wrong stage
configure: error: cannot compute suffix of object files: cannot compile is GCC’s most reported bootstrap failure, and it is a linking failure wearing a compiling failure’s name. The test compiles a trivial file to an object and then links it, so any missing crt file, missing libc.so, or missing linker script produces it. Reading config.log at the point of the failing link command shows which file was actually absent.
A first-pass compiler accidentally used for the third pass produces a different signature: the link line contains no -lc failure but every program crashes before main, because crt1.o was taken from the wrong root. A third-pass compiler whose sysroot points at headers from a newer libc than the libraries it links against produces objects that reference symbols the older .so does not define — a versioning failure described in symbol versioning and the glibc floor.
Rebuilding a full toolchain from source takes long enough that most builds use a prebuilt one, and the sequence above is what that prebuilt toolchain already encodes. The stage boundaries still apply when a build fails: the question is always which of the three passes produced the file that is missing or wrong, and the answer determines whether the fix belongs in the sysroot, in the compiler’s configuration, or in the flags passed to the build.
References
- GCC documentation: Installing GCC, Configuration — GNU Project
- GCC documentation: Libgcc — GNU Project
- The GNU C Library Reference Manual — GNU Project
- Crosstool-NG documentation — Crosstool-NG project