Build Systems
A kernel’s build process differs from an ordinary application’s in one important respect beyond simply using a cross-compiler instead of the host’s own: the final output isn’t a program the host’s own loader knows how to run: it’s a binary with a very specific memory layout, and often has to be packaged into a bootable image before it can be run at all, whether under an emulator or on real hardware. Nearly every hobbyist kernel’s build system, regardless of the specific tool driving it, exists to produce and enforce this layout, not merely to turn source files into object code.
What a kernel build has to do beyond compilation
Section titled “What a kernel build has to do beyond compilation”Compiling and assembling source files into object files is the ordinary part, identical in principle to building any C or assembly project. What differs is everything after that: the object files must be linked according to a linker script controlling exactly where in memory each section ends up: a detail no ordinary application build needs to specify, since a normal program’s loader handles placement automatically, but a kernel loaded directly by a bootloader has no such loader doing that on its behalf. The resulting binary frequently also needs to be embedded into a bootable image (an ISO containing GRUB and the kernel together, in the common case of relying on an existing bootloader) before an emulator or real hardware has anything to actually boot from.
Linker scripts
Section titled “Linker scripts”A linker script tells the linker where to place each section of the final binary, which matters enormously for a kernel expected to be loaded at a specific physical (or, for a higher-half kernel, virtual) address:
ENTRY(kernel_main)
SECTIONS { . = 1M; /* conventional load address just above the first megabyte */
.text : { *(.multiboot) *(.text) } .rodata : { *(.rodata) } .data : { *(.data) } .bss : { *(COMMON) *(.bss) }
kernel_end = .;}Placing .multiboot first within .text is what satisfies the requirement, covered under Multiboot, that a Multiboot header appear within the binary’s first 8 KB: a placement the compiler and assembler alone have no way to guarantee, since ordinary section ordering is otherwise left up to the toolchain’s own defaults, and the linker script is the only place that ordering is actually pinned down explicitly. The kernel_end assignment at the close of the script defines an ordinary linker symbol rather than a reserved keyword (any name would work), but it is a common one, since it gives the kernel’s own C code (declared there as extern char kernel_end[]; and referenced by its address, &kernel_end, never by dereferencing it as data) a reliable answer to “where does my own binary stop and free physical memory begin,” a question a physical memory manager needs answered before it can hand out any page the kernel image itself occupies.
The .bss section deserves particular attention because of what a linker script does not do for it: .bss holds zero-initialized data but stores none of it in the file itself, only its size, which means nothing anywhere automatically zeroes that memory region in RAM before the kernel starts using it: a normal hosted program gets this for free from its C runtime startup code, but a kernel has no such startup code by default and must zero the range between linker-provided bss_start and bss_end symbols itself, typically as one of the very first instructions its entry point executes, or global variables relying on a zero initial value silently contain whatever garbage happened to occupy that physical memory beforehand.
Higher-half layout and the load/link address split
Section titled “Higher-half layout and the load/link address split”Many kernels are higher-half: linked to run at a high virtual address such as 0xFFFFFFFF80000000, keeping the low address space free for user-mode programs, while the bootloader can only ever place the raw binary at a low physical address such as 1 MB, since paging (the only mechanism able to make a high virtual address resolve to that low physical location) isn’t active yet when the bootloader hands off control. A linker script reconciles this with two distinct notions of address for the same section: the VMA (virtual memory address, where code expects to run once paging is active) and the LMA (load memory address, where the bootloader actually places the bytes), set apart explicitly with AT():
KERNEL_VMA = 0xFFFFFFFF80000000;KERNEL_LMA = 0x00100000;
SECTIONS { . = KERNEL_VMA + KERNEL_LMA;
.text ALIGN(4K) : AT(ADDR(.text) - KERNEL_VMA) { *(.multiboot) *(.text) }}Code and data in such a kernel are compiled and linked against the high VMA throughout: every symbol reference and absolute address the compiler generates already assumes the high address is correct, while the bootloader only ever sees and loads the low LMA bytes; the small amount of assembly that runs first, before paging is enabled, has to be position-independent or explicitly linked at its own low address for this reason, since it executes before the VMA it was otherwise compiled against becomes valid, and jumping to a higher-half address too early, before the page tables mapping it exist, produces an immediate fault indistinguishable, from the outside, from almost any other early boot failure.
A representative Makefile
Section titled “A representative Makefile”A kernel’s Makefile generally mirrors the sequence described above (compile, assemble, link with the custom script, then package) explicitly, since kernel builds rarely benefit from an off-the-shelf build system’s assumptions about producing an ordinary hosted executable:
CC := x86_64-elf-gccAS := nasmCFLAGS := -ffreestanding -O2 -Wall -Wextra -mno-red-zone -mno-sse -mno-mmxASFLAGS := -f elf64
OBJS := boot.o kernel.o
kernel.elf: $(OBJS) linker.ld $(CC) -T linker.ld -o $@ -ffreestanding -O2 -nostdlib $(OBJS) -lgcc
%.o: %.c $(CC) $(CFLAGS) -MMD -c $< -o $@
%.o: %.asm $(AS) $(ASFLAGS) $< -o $@
iso: kernel.elf mkdir -p isodir/boot/grub cp kernel.elf isodir/boot/kernel.elf cp grub.cfg isodir/boot/grub/grub.cfg grub-mkrescue -o mykernel.iso isodir
.PHONY: iso cleanclean: rm -f $(OBJS) kernel.elf mykernel.iso *.d
-include $(OBJS:.o=.d)-ffreestanding tells the compiler not to assume a hosted C library environment is available: the same assumption a cross-compiler is already configured without, but worth passing explicitly regardless, since it also disables certain compiler optimizations that assume standard library semantics for functions like memcpy that a freestanding kernel may implement itself, differently. -mno-red-zone disables the x86-64 System V ABI’s red zone, an optimization assuming 128 bytes below the stack pointer are safe to use without adjusting it first: an assumption that doesn’t hold inside an interrupt handler, which can itself be invoked at any point atop whatever the interrupted code’s stack looked like, making red-zone usage a subtle source of stack corruption specific to kernel code if left enabled. -mno-sse and -mno-mmx stop the compiler from generating floating-point or vector instructions at all, not because a kernel can never use them, but because doing so safely requires saving and restoring that register state across every context switch and interrupt, work most kernels defer entirely until they explicitly decide to support floating-point-using tasks, making these flags the safe default for code that hasn’t done that work yet.
Two build-system details matter as much as the compiler flags themselves. First, having two separate pattern rules (one compiling .c files with the cross-GCC, another assembling .asm files with NASM) reflects a common split in kernel source trees: the earliest boot code, needing precise control over segment registers, the stack, and mode transitions before any C runtime exists to rely on, is usually written directly in assembly, while everything reachable after that point is ordinary C. NASM’s Intel-syntax .asm files and GNU as’s AT&T-syntax .S files are not interchangeable without translation, and a Makefile mixing both toolchains (NASM for hand-written assembly, the cross-GCC’s own assembler invoked implicitly for any inline asm blocks inside .c files) needs a separate pattern rule per syntax, since neither assembler accepts the other’s input format. Second, -MMD next to the compile step, combined with the -include $(OBJS:.o=.d) line near the bottom, is what makes Make aware that a .c file’s object depends not just on that file but on every header it transitively includes: without it, editing a shared header silently fails to trigger a rebuild of everything that included it.
Building a bootable ISO
Section titled “Building a bootable ISO”grub-mkrescue, part of the GRUB toolset, takes a directory laid out with the kernel binary and a grub.cfg configuration file in the conventional locations GRUB expects and produces a bootable ISO image usable directly by QEMU (-cdrom) or burned to physical media. Under the hood, grub-mkrescue is itself a wrapper around xorriso, the tool that actually assembles the ISO 9660 filesystem and writes the El Torito boot catalog entries a BIOS or UEFI firmware reads to locate the boot image within it; a grub-mkrescue failure complaining about a missing tool almost always means xorriso (or, on some distributions, a separately packaged mtools, needed for the FAT-formatted UEFI boot partition GRUB embeds alongside the BIOS one) isn’t installed, rather than anything wrong with the kernel or grub.cfg themselves.
The grub.cfg itself is typically minimal for a hobby kernel: a menu entry naming the kernel binary to load via the multiboot (or multiboot2) directive GRUB itself interprets:
menuentry "My Kernel" { multiboot /boot/kernel.elf}Building a full ISO on every iteration is not the only option, and most kernel developers skip it during day-to-day work: QEMU’s -kernel flag, covered in more detail alongside the rest of the emulator’s flags, loads a Multiboot-compliant ELF binary directly without any bootloader or ISO involved at all, cutting the iteration loop down to compile-link-run. The ISO step only becomes necessary again when testing against real hardware, or against a firmware boot path (UEFI in particular) that -kernel bypasses entirely and therefore can’t exercise.
Build systems beyond Make
Section titled “Build systems beyond Make”Make is the dominant choice for small, hobbyist kernels: it ships with essentially every Unix-like development environment already installed, and a kernel’s build graph is usually simple enough that Make’s limitations rarely become a practical problem. Larger kernel projects frequently outgrow it for reasons specific to their own scale: SerenityOS builds with CMake, generating Ninja build files, in large part because its build spans an entire userspace of libraries and applications alongside the kernel itself, a scale at which CMake’s automatic dependency discovery and out-of-source build directories pay for their added complexity. seL4 likewise builds with CMake, driven by the added need to run a separate code-generation and formal-proof pipeline as part of an ordinary build, something a plain Makefile has no native facility for orchestrating. Redox, being written in Rust rather than C, uses Cargo as its primary build driver instead of Make or CMake at all, relying on #![no_std] (Rust’s own freestanding-mode declaration, functionally equivalent to -ffreestanding) together with a custom target specification file describing the bare-metal environment in place of a linker script’s SECTIONS block, though Redox’s linker still resolves the resulting object code with the same underlying ld semantics described above. None of these tools change the fundamental problem a kernel build has to solve (placing sections at controlled addresses and producing a bootable image), only how much of the surrounding orchestration is handled by hand-written rules versus a more capable tool’s own conventions.
Implementation notes
Section titled “Implementation notes”A Makefile’s implicit dependency tracking (or lack of it) is a common source of confusing behavior during kernel development specifically: editing a header file included by several source files, without the build correctly recompiling everything that included it, produces a kernel built from a stale mix of old and new object code: errors that manifest as bizarre, seemingly impossible runtime behavior rather than a build failure. Generating proper header dependencies (via gcc -MMD, or an equivalent for the toolchain in use) and including them in the Makefile avoids this class of bug, which is disproportionately painful to diagnose in kernel code given how few other diagnostic tools are available compared to userspace development. Link-line ordering is a second, less obvious pitfall specific to static linking: ld resolves symbols left to right, so an object file that references a symbol libgcc defines must appear on the command line before -lgcc, not after: a kernel that links cleanly today can start failing with an unresolved-symbol error after nothing more than reordering source files inside the $(OBJS) variable, if that reordering happens to move a libgcc-dependent object past the point -lgcc was already given. Declaring iso and clean as .PHONY targets, finally, matters more in a kernel project than it might elsewhere, since it’s common for a stray file named exactly iso or clean to end up sitting in a build directory (a leftover ISO-staging folder, for instance), which would otherwise cause Make to treat the target as already up to date and silently skip running it.
References
Section titled “References”- ^ OSDev Wiki, “Bare Bones”: a complete walkthrough of a minimal buildable kernel, linker script, and GRUB configuration.
- ^ GNU
ldmanual, “Linker Scripts”: documentsSECTIONS,AT(), and the rest of the linker script syntax used above. - ^ OSDev Wiki, “Higher Half Kernel”: covers the VMA/LMA split and early boot code position-independence in more implementation-specific detail.
See also
Section titled “See also”- Building a Cross-Compiler: the toolchain this build process invokes.
- Multiboot: the header this article’s linker script places within the binary’s first 8 KB.
- Paging: the mechanism that eventually makes a higher-half kernel’s virtual addresses resolve correctly.
- The System V ABI: the full calling convention
-mno-red-zoneand the register-saving rules it implies are both part of. - ISO 9660: the on-disk format behind the bootable image
grub-mkrescue/xorriso produce.