Your everyday compiler is built to create programs for your everyday operating system. It expects that system's ABI, headers, libraries, startup files, executable format, and linker conventions. That is exactly what you do not want when you are writing a kernel.
A kernel runs in a freestanding environment. There may be no C library, no process loader, no main, and no operating system underneath your code. You control the entry point, memory layout, runtime support, and every byte that enters the final image.
The clean solution is a cross-compiler: a compiler that runs on your development machine but emits code for a deliberately separate target. In this guide, we will build an i686-elf toolchain suitable for a 32-bit x86 kernel. The same structure also works for targets such as x86_64-elf, although architecture-specific kernel flags and startup code will differ.
By the end, you will have:
${TARGET}-gccand${TARGET}-g++for compiling freestanding C and C++${TARGET}-asfor assembling target code${TARGET}-ldfor linking target objects${TARGET}-ar,${TARGET}-objcopy,${TARGET}-objdump,${TARGET}-readelf, and other target-aware utilitieslibgcc, GCC's low-level runtime support library- the freestanding subset of
libstdc++for useful C++ headers that do not require a hosted operating system
This guide uses GCC 16.2 and GNU Binutils 2.47, the current releases at the time of writing. The optional debugger section uses GDB 17.2. If newer releases are available when you read this, update the version variables and review their installation notes before building.
Why the host compiler is the wrong tool
Suppose your development machine runs 64-bit Linux. Its normal gcc is probably configured for a target similar to x86_64-pc-linux-gnu. Even when it can emit 32-bit instructions, its defaults still belong to a hosted Linux environment. Depending on the distribution, it may assume position-independent executables, Linux startup objects, glibc, a dynamic loader, and the host's linker behavior.
The -ffreestanding option is necessary for kernel code, but it does not transform a host toolchain into a purpose-built kernel toolchain. It changes the compiler's language assumptions: GCC sets __STDC_HOSTED__ to 0 and stops assuming most standard-library functions have their hosted meanings. You still need to control assembly, linking, startup, runtime support, and the target format.
A target such as i686-elf makes your intent explicit:
i686selects a 32-bit x86 processor family.elfselects the Executable and Linkable Format without naming a hosted operating system.- The installed programs are prefixed with
i686-elf-, so accidentally invoking the host linker becomes much less likely.
That separation is the real advantage. Your kernel build no longer depends on whatever defaults your desktop distribution chose this year.
What build, host, and target mean
Toolchain documentation uses three similar terms:
- Build is the machine on which you compile the toolchain.
- Host is the machine on which the resulting compiler will run.
- Target is the machine for which that compiler will generate code.
For this guide, build and host are your current GNU/Linux machine, while target is i686-elf. Because host and target differ, the result is a cross-compiler.
Before you begin
The commands below assume a recent Debian or Ubuntu installation, including Ubuntu running in WSL 2. Native Windows users will have a much easier time performing this build inside WSL 2 than trying to translate a POSIX-oriented build system into PowerShell.
We will now begin installing necessary tools and compiling a cross compiler from source which can then be used for kernel development.
Install the basic host tools in the Ubuntu or Linux terminal:
sudo apt update
sudo apt install -y \
build-essential \
bison \
flex \
texinfo \
xz-utils \
curl \
ca-certificates
GCC needs a working C++14 compiler, GNU Make, and several mathematical support libraries. We will use GCC's contrib/download_prerequisites helper to download supported copies of GMP, MPFR, MPC, ISL, and other required source packages into the GCC tree.
Plan for several gigabytes of free disk space and a build that may take from minutes to well over an hour, depending on your CPU, storage, and available memory.
1. Choose versions, target, and directories
Keep the source, build, and installation trees separate. This makes failed builds easier to diagnose and lets you replace the toolchain without touching the system compiler. These export commands are also run in the Ubuntu terminal.
export GCC_VERSION=16.2.0
export BINUTILS_VERSION=2.47
export GDB_VERSION=17.2
export TARGET=i686-elf
export PREFIX="$HOME/opt/cross"
export SRC="$HOME/src/cross"
export BUILD="$HOME/build/cross"
mkdir -p "$PREFIX" "$SRC" "$BUILD"
export PATH="$PREFIX/bin:$PATH"
The PREFIX is intentionally outside /usr. GCC does not provide a reliable uninstall target, so an isolated installation directory is safer to upgrade or remove later.
The PATH change matters twice: it lets your shell find the finished tools, and it lets the GCC build find the cross-assembler and cross-linker that we install first.
If you open a new terminal later, export PREFIX, TARGET, and the updated PATH again. After the build succeeds, you can place the relevant exports in your shell profile.
2. Download the official source releases
Download GCC and Binutils from Sourceware's official release directories:
cd "$SRC"
curl -LO \
"https://sourceware.org/pub/binutils/releases/binutils-${BINUTILS_VERSION}.tar.xz"
curl -LO \
"https://sourceware.org/pub/gcc/releases/gcc-${GCC_VERSION}/gcc-${GCC_VERSION}.tar.xz"
tar -xf "binutils-${BINUTILS_VERSION}.tar.xz"
tar -xf "gcc-${GCC_VERSION}.tar.xz"
For a security-sensitive or reproducible environment, also download the published signature or checksum files and verify the archives before extracting them.
Now fetch GCC's supported prerequisites:
cd "$SRC/gcc-${GCC_VERSION}"
./contrib/download_prerequisites
Run that helper from the root of the GCC source tree. It places the prerequisite sources where GCC's build system expects to find them.
3. Build and install GNU Binutils
GCC generates assembly and object code, but it still needs target-aware programs to assemble, archive, inspect, and link those files. That is why Binutils comes first.
Create an empty out-of-tree build directory:
mkdir -p "$BUILD/binutils"
cd "$BUILD/binutils"
Configure Binutils for the target:
"$SRC/binutils-${BINUTILS_VERSION}/configure" \
--target="$TARGET" \
--prefix="$PREFIX" \
--with-sysroot \
--disable-nls \
--disable-werror
The important options are:
--target="$TARGET"selects the object format and architecture the tools will handle.--prefix="$PREFIX"installs everything into the isolated cross-toolchain directory.--with-sysrootgives the tools a target-root concept that can grow with your kernel project.--disable-nlsomits translated diagnostic catalogs, reducing build complexity.--disable-werrorprevents a warning in Binutils itself from becoming a fatal build error on a newer host compiler.
Build in parallel and install:
make -j"$(nproc)"
make install
Do not use sudo make install. Your prefix is inside your home directory and should be writable by your normal account.
Confirm that the cross-linker is available before continuing:
command -v "${TARGET}-ld"
"${TARGET}-ld" --version
If command -v prints nothing, verify that $PREFIX/bin exists and appears near the beginning of PATH.
4. Build the freestanding GCC compiler
Create a second empty build directory. GCC's own installation instructions strongly recommend building outside the source tree.
mkdir -p "$BUILD/gcc"
cd "$BUILD/gcc"
Configure GCC:
"$SRC/gcc-${GCC_VERSION}/configure" \
--target="$TARGET" \
--prefix="$PREFIX" \
--disable-nls \
--enable-languages=c,c++ \
--without-headers \
--disable-hosted-libstdcxx \
--disable-threads \
--disable-multilib
Here is what those choices mean:
--enable-languages=c,c++builds the C and C++ front ends and skips languages you do not need for a typical kernel.--without-headerstells GCC that the target has no C library headers. That is the correct starting point for a new freestanding system.--disable-hosted-libstdcxxbuilds only the part of GNU's C++ library intended for freestanding environments.--disable-threadsselects the single-threaded target model until your operating system supplies a threading runtime.--disable-multilibbuilds one default target runtime variant. This avoids failures caused by missing secondary-ABI support and keeps the first toolchain small.
Now build the compiler programs themselves:
make -j"$(nproc)" all-gcc
Next, build libgcc for the target:
make -j"$(nproc)" all-target-libgcc
Build the freestanding subset of libstdc++ as well:
make -j"$(nproc)" all-target-libstdc++-v3
Finally, install the compiler and both target libraries:
make install-gcc
make install-target-libgcc
make install-target-libstdc++-v3
This is intentionally not a complete hosted GCC installation. There is no target libc and only the freestanding subset of the C++ standard library. You are building the layer needed to compile freestanding kernel code, not a compiler for normal user-space applications.
5. Verify the toolchain
First, ask GCC which target it was built for:
"${TARGET}-gcc" -dumpmachine
The output should be:
i686-elf
Check the installed compiler and libgcc path:
"${TARGET}-gcc" --version
"${TARGET}-gcc" -print-libgcc-file-name
The second command should print a file inside your cross-toolchain prefix, not a library from /usr/lib.
Now compile a tiny freestanding translation unit:
cd "$BUILD"
cat > sanity.c <<'EOF'
void kernel_entry(void)
{
for (;;) {
__asm__ volatile ("hlt");
}
}
EOF
"${TARGET}-gcc" \
-std=gnu23 \
-ffreestanding \
-O2 \
-Wall \
-Wextra \
-c sanity.c \
-o sanity.o
"${TARGET}-readelf" -h sanity.o
For i686-elf, the ELF header should identify a 32-bit object for Intel 80386. This test proves that the compiler, assembler, and binary-inspection tools agree on the target.
Which standard headers are available?
A naked cross-compiler does not have a target C library, so hosted headers such as stdio.h are intentionally absent. GCC does provide the C headers required for a basic freestanding implementation. For C11, that set includes:
float.hiso646.hlimits.hstdalign.hstdarg.hstdbool.hstddef.hstdint.hstdnoreturn.h
These headers primarily define types, constants, and macros rather than depending on operating-system services. Later language standards adjust the exact required set, so treat the GCC manual for your selected language mode as authoritative.
The reduced libstdc++ installation similarly exposes the C++ facilities that can work without a hosted C library. In modern GCC releases that is more than a token handful of headers, but it is still not the full desktop C++ library. Unsupported hosted headers or portions of headers will be unavailable in freestanding mode.
6. Use it in a kernel build
This is just an example of how you're kernels makefile might look to target the cross compiler you built and not something to copy and paste
Your build system should name the cross tools explicitly. A small Makefile might start like this:
TARGET := i686-elf
CC := $(TARGET)-gcc
CXX := $(TARGET)-g++
AS := $(TARGET)-as
LD := $(TARGET)-ld
AR := $(TARGET)-ar
OBJCOPY := $(TARGET)-objcopy
CFLAGS := -std=gnu23 -ffreestanding -O2 -Wall -Wextra
CFLAGS += -fno-stack-protector -fno-pie
The right flags depend on your architecture, boot protocol, memory model, and runtime. A 64-bit x86 kernel commonly adds -mno-red-zone, for example, because interrupt handlers can invalidate the assumptions behind the System V red zone.
For final linking, many kernels use the compiler driver rather than invoking ld directly. The driver knows where its target libgcc lives:
"${TARGET}-gcc" \
-T linker.ld \
-ffreestanding \
-O2 \
-nostdlib \
boot.o kernel.o \
-lgcc \
-o kernel.elf
-nostdlib prevents hosted startup files and standard libraries from entering the link. It also suppresses libgcc, so adding -lgcc explicitly is usually the right choice. GCC may emit calls to helper routines for arithmetic or other operations that the processor cannot express in one instruction.
Your kernel must provide anything else it uses. Even freestanding compiler output can require routines such as memcpy, memmove, memset, or memcmp, depending on the code and optimization decisions. Implement those routines inside the kernel instead of trying to link the host's libc.
What C++ support does—and does not—include
Enabling the C++ front end gives you ${TARGET}-g++, the C++ language parser, and the freestanding subset of libstdc++. It does not give your new kernel a complete hosted C++ runtime.
Until you implement or deliberately port the required runtime pieces, kernel C++ code usually avoids exceptions, RTTI, threads, and the hosted standard library:
CXXFLAGS := $(CFLAGS) -fno-exceptions -fno-rtti
You may also need to provide global new and delete, constructor initialization, destructor registration policy, guard functions for local statics, and other ABI support. C++ can be an excellent kernel language, but the kernel—not the compiler build—must define its runtime environment.
You can confirm that a freestanding C++ header is installed with a compile-only test:
cat > sanity.cc <<'EOF'
#include <type_traits>
static_assert(std::is_unsigned_v<unsigned int>);
EOF
"${TARGET}-g++" \
-std=gnu++23 \
-ffreestanding \
-fno-exceptions \
-fno-rtti \
-c sanity.cc \
-o sanity-cxx.o
Changing the target
For a 64-bit x86 kernel, you can rebuild with:
export TARGET=x86_64-elf
Use fresh Binutils and GCC build directories when changing the target. A build directory caches configuration decisions; reusing one across targets produces confusing and sometimes subtly incorrect results.
Do not assume that changing the target triple is the only architecture work required. Your boot path, assembly, linker script, ABI, compiler flags, interrupt rules, and emulator configuration must all match the new architecture.
Optional: build a target-aware GDB
Your host's GDB may already understand the architecture you are developing for. If the host and target architectures differ, or if you want a consistently prefixed debugger alongside the rest of the toolchain, build GDB separately.
Download and unpack the current official release:
cd "$SRC"
curl -LO \
"https://sourceware.org/pub/gdb/releases/gdb-${GDB_VERSION}.tar.xz"
tar -xf "gdb-${GDB_VERSION}.tar.xz"
Depending on your distribution, GDB may require additional host development packages such as Expat, ncurses, and Python headers. Configure only the debugger for your target in a fresh build directory:
mkdir -p "$BUILD/gdb"
cd "$BUILD/gdb"
"$SRC/gdb-${GDB_VERSION}/configure" \
--target="$TARGET" \
--prefix="$PREFIX" \
--disable-werror
make -j"$(nproc)" all-gdb
make install-gdb
Verify it with:
"${TARGET}-gdb" --version
For emulator-based kernel debugging, GDB commonly connects to a remote debugging stub. For example, after starting QEMU with its GDB server enabled, load your symbol-bearing kernel.elf and use target remote localhost:1234 from GDB. Keep the unstripped ELF file for debugging even if your boot image uses a stripped or converted copy.
Common failures and what they mean
${TARGET}-as: command not found
Binutils was not installed successfully, or $PREFIX/bin was not in PATH when GCC was configured. Fix the path, confirm ${TARGET}-as and ${TARGET}-ld work, then configure GCC in a fresh build directory.
cannot compute suffix of object files
This is a summary error, not usually the root cause. Look earlier in config.log for the first failed compiler or linker command. Common causes include an unavailable cross-assembler, stale configuration files, missing host prerequisites, or an incorrect path.
Missing gnu/stubs-32.h or another secondary-ABI header
This often means the build attempted a multilib variant unsupported by your host setup. Confirm that GCC was configured with --disable-multilib, then rebuild from a clean GCC build directory.
fatal error: stdio.h: No such file or directory
That is expected in this toolchain. You deliberately built it without target libc headers. Kernel code should not include hosted headers such as stdio.h. Create freestanding kernel headers and implement the services you need.
Undefined references to memcpy, memset, or memcmp
The compiler may generate calls to these functions even when your source does not call them directly. Supply kernel implementations with the correct signatures and semantics.
cannot find -lgcc
You probably installed the compiler without building and installing the target runtime. Run the all-target-libgcc and install-target-libgcc steps, then verify the result with ${TARGET}-gcc -print-libgcc-file-name.
The final ELF file has the wrong architecture
Run ${TARGET}-gcc -dumpmachine and ${TARGET}-readelf -h kernel.elf. Also inspect the build log for an accidental invocation of plain gcc, as, or ld without the target prefix.
Make the setup reproducible
A manually entered build is useful for learning, but a checked-in script is better for a real project. Pin the GCC and Binutils versions, verify source checksums, keep the installation prefix versioned, and record the configure flags. That lets teammates and CI build the same toolchain instead of depending on a developer's workstation state.
Also keep the toolchain separate from the kernel source tree. The compiler is a build dependency; your kernel repository should be able to detect it, report a useful error when it is missing, and build cleanly when it is present.
Official references
- GCC releases
- GCC installation prerequisites
- GCC configuration options
- Building GCC and cross-compilers
- GCC on hosted and freestanding environments
- GNU libstdc++ configuration
- GNU Binutils releases and documentation
- GDB downloads and documentation
Go from a toolchain to a working kernel
Building the cross-compiler is the foundation. The exciting work comes next: bootstrapping the machine, entering the right CPU mode, designing memory management, handling interrupts, writing drivers, building filesystems, adding processes, and debugging the whole stack when there is no operating system underneath you.
If you want a structured path through that work, the DragonZap Kernel Development From Scratch Bundle gives you 69 hours of hands-on kernel development training in one focused package. Stop piecing the journey together from disconnected snippets and start building with a complete roadmap.
Daniel McCarthy