1 — The cross-toolchain
Requirements: chapter 0, a few hours, and these tarballs in /lo/sources, with hashes checked: binutils 2.47, GCC 16.2.0, Linux 7.3 and musl 1.2.6 plus upstream patches.
Why it takes five steps
There’s a chicken-and-egg problem. A full compiler needs a C library, and the C library needs a compiler. So you build a minimal compiler first, use it to build the library, then rebuild the compiler.
Why cross-compile at all? LFS puts it well: “anything that is cross-compiled cannot depend on the host environment” (LFS).
Two pieces provide the isolation:
- The target name can never equal the host’s, so the build has to be a cross build.
--with-sysrootmakes GCC look in/lo/rootfsfor headers and libraries, instead of/usr(GCC docs).
Step 1: set up the shell
export LO=/lo TGT=x86_64-lo-linux-musl
export PATH=$LO/cross/bin:$PATH LC_ALL=POSIX
mkdir -p $LO/rootfs/usr/include $LO/build
Re-run this in every new shell. A missing PATH is the commonest cause of failures here.
Step 2: build binutils
cd $LO/build && tar xf $LO/sources/binutils-2.47.tar.xz
mkdir binutils-build && cd binutils-build
../binutils-2.47/configure --prefix=$LO/cross --target=$TGT \
--with-sysroot=$LO/rootfs --disable-nls --disable-werror \
--disable-multilib --enable-deterministic-archives
make -j"$(nproc)" && make install
Expected: $TGT-ld --print-sysroot prints /lo/rootfs.
Step 3: build GCC, first pass
This compiler handles C only and has no C library. --with-newlib tells libgcc to leave out anything that needs one (LFS):
cd $LO/build && tar xf $LO/sources/gcc-16.2.0.tar.xz
cd gcc-16.2.0 && ./contrib/download_prerequisites && cd ..
mkdir gcc1 && cd gcc1
../gcc-16.2.0/configure --prefix=$LO/cross --target=$TGT --with-sysroot=$LO/rootfs \
--with-newlib --without-headers --disable-shared --disable-threads \
--disable-multilib --disable-libatomic --disable-libgomp --disable-libquadmath \
--disable-libssp --disable-libsanitizer --enable-languages=c
make -j"$(nproc)" all-gcc all-target-libgcc
make install-gcc install-target-libgcc
Expected: $TGT-gcc -print-libgcc-file-name prints a path to a libgcc.a that exists.
Step 4: install the kernel headers
Userland and the kernel have to agree on the ABI, and this is where they do:
cd $LO/build && tar xf $LO/sources/linux-7.3.tar.xz && cd linux-7.3
make ARCH=x86_64 headers_install INSTALL_HDR_PATH=$LO/rootfs/usr
Expected: ls $LO/rootfs/usr/include/linux/version.h succeeds.
Step 5: build musl
cd $LO/build && tar xf $LO/sources/musl-1.2.6.tar.gz && cd musl-1.2.6
# apply upstream security patches here
./configure --host=$TGT --prefix=/usr --syslibdir=/lib \
CROSS_COMPILE=$TGT- LIBCC="$($TGT-gcc -print-libgcc-file-name)"
make -j"$(nproc)" && make DESTDIR=$LO/rootfs install
Expected: ls -l $LO/rootfs/lib/ld-musl-x86_64.so.1 shows a link to libc.so, and ls $LO/rootfs/usr/lib/crt1.o succeeds.
Step 6: build GCC, second pass
Now that there’s a C library, you can build the full compiler:
cd $LO/build && mkdir gcc2 && cd gcc2
../gcc-16.2.0/configure --prefix=$LO/cross --target=$TGT --with-sysroot=$LO/rootfs \
--enable-languages=c,c++ --enable-shared --enable-threads=posix --enable-tls \
--disable-multilib --disable-libsanitizer --disable-gnu-indirect-function \
--enable-initfini-array --enable-libstdcxx-time=rt
make -j"$(nproc)" && make install
GCC 16 defaults to C++20 (GCC 16 changes), so older C++ packages may need an explicit -std=.
Step 7: test it
cd /lo/out
printf '#include <stdio.h>\nint main(void){puts("hello");return 0;}\n' > hello.c
$TGT-gcc hello.c -o hello-dynamic
$TGT-gcc -static hello.c -o hello-static
$TGT-readelf -l hello-dynamic | grep interpreter
./hello-static
$LO/rootfs/lib/ld-musl-x86_64.so.1 ./hello-dynamic
Expected: the readelf line names /lib/ld-musl-x86_64.so.1, and both programs print hello.
Don’t use ldd on the dynamic binary. Your host’s glibc ldd won’t understand it.
If it fails
| What you see | Likely cause | Fix |
|---|---|---|
Building GCC requires GMP | Prerequisites missing | Run contrib/download_prerequisites |
$TGT-gcc: command not found | PATH not exported | Re-run Step 1 |
Pass 1 wants stdio.h | Missing --with-newlib, or wrong make targets | Delete the build directory and reconfigure |
rsync: not found | Host package missing | Install rsync |
./hello-dynamic: No such file or directory | The file exists, but its interpreter doesn’t exist on the host | Run it through the sysroot loader. Don’t copy it into the host’s /lib |
| Reruns behave strangely | Stale build directory | Delete gcc1 or gcc2 |
If you get stuck, a prebuilt toolchain from the musl-cross-make fork lets you continue. It targets x86_64-linux-musl, so the prefix changes, and its versions lag those here.
Observe: what does a compiler do?
gcc is a driver. It runs other programs to do the work. Watch it:
-
Start a trace, filtering on the first 15 characters, because
commholds no more than that (sched.h):sudo bpftrace -e 'tracepoint:syscalls:sys_enter_execve /comm == "x86_64-lo-linux" || comm == "cc1" || comm == "as" || comm == "collect2" || comm == "ld"/ { printf("%s execs %s\n", comm, str(args.filename)); }' -
In another shell, run
$TGT-gcc hello.c -o hello-dynamic. -
Look for: the compiler running
cc1, then the assembler, then the linker stage. Note the real paths. Don’t assume the order until you’ve seen it. -
Now watch
cc1search for headers. Count the failedopenatcalls. That’s the cost of a long-Ilist.
Principle: isolation is a boundary you can name
A private target name and a sysroot turn “don’t accidentally use the host” from a discipline into a structure. The same move shows up everywhere good systems isolate things: a separate namespace, a separate account, a separate network. Crossing the boundary takes an explicit act rather than an omitted flag. If your isolation depends on everyone remembering something, it isn’t isolation.