The machine with no floor

When you write a normal C++ program, you take an enormous amount of infrastructure for granted. Your compiler assumes there is an OS that will load your code, set up your stack, initialize your heap, and hand you argc and argv. Your standard library assumes it can make system calls to allocate memory, write to files, and exit gracefully. Your linker assumes it is building something that fits into a well-defined executable format understood by a loader.

When you write an operating system, none of this exists. You are the thing everything else depends on.

This is the fundamental distinction between a hosted environment and a freestanding environment:

  • Hosted: The program runs on top of an operating system. The OS loads your binary, tracks memory and devices, and answers requests through system calls for I/O, allocation, and process control. On that base you get the full C and C++ standard library. Under the hood, its routines are implemented using those OS services. A hosted runtime finishes startup (for example static constructors in C++) and then hands control to main(), typically with argc and argv already set up.
  • Freestanding: The program runs directly on the hardware (or on a bootloader’s handoff). There is no standard library, no runtime initialization, no system calls. The language standard guarantees almost nothing. Still, a small set of headers: <stddef.h>, <stdint.h>, <float.h>, and <limits.h> - must be available. Do not read that as “part of libc light.” Those headers are mostly typedefs, preprocessor macros, and implementation-defined limits (sizes, widths, floating-point characteristics). They do not give you printf, malloc, file handles, or any other hosted conveniences.

The first step in building an operating system is accepting this, and building a toolchain that works within these constraints.

Why you need a cross-compiler

You might think you can just use the compiler installed on your Linux or macOS system. After all, g++ compiles C++ - what is the problem?

The problem is that your system compiler is configured for your host platform. It assumes:

  • A specific target triple (e.g., x86_64-linux-gnu), which encodes the OS the binary will run on
  • That the generated code will be linked against your system’s libc (glibc, musl, etc.)
  • That system headers from /usr/include are valid
  • That the binary will be loaded by a dynamic linker (ld-linux.so)

None of these assumptions hold for kernel code. If you try to build a freestanding kernel binary with your system compiler, you will get link errors from glibc dependencies, incorrect ABI assumptions, and subtle bugs from compiler-generated code that relies on OS features (like thread-local storage) that do not exist without the system.

The solution is a cross-compiler: a compiler specifically configured to target a generic, OS-independent architecture. For x86-64 OS development, the standard target triple is x86_64-elf - no OS, no libc, just raw ELF binaries for the x86-64 instruction set.

Getting the toolchain

For the x86_64-elf target you need Binutils (x86_64-elf-as, x86_64-elf-ld, and friends), GCC as the cross-compiler, and GDB if you plan to debug kernel code.

With a working install you should have x86_64-elf-gcc, x86_64-elf-g++, and x86_64-elf-ld available. Those are the tools to use for kernel code, not the host gcc.

Note: You will eventually need a second toolchain for userspace - one that does know about your OS and links against your custom libc. We will cover that when we get to the userspace chapters. For now, the freestanding toolchain is all you need.

The build system

Once you have a cross-compiler, you need a way to orchestrate compilation. An OS kernel is not a single source file. Even a minimal one involves:

  • Assembly files for architecture CPU specific code and optimizations
  • C/C++ source for the kernel logic
  • Linker scripts that control the memory layout of the final binary
  • Configuration flags that changes code and build behaviour
  • Third-party libraries that have their own build requirements
  • Eventually, userspace programs with a completely different compilation model

You have options here. There is no single “correct” build system for OS work, and different projects make different trade-offs. There are also plenty of other choices (Meson, Bazel, plain Ninja files, or custom scripts), but Make and CMake are the two you will run into most often in OS projects.

  • Makefiles: minimal assumptions, almost no magic, and they scale surprisingly far. That is one reason the Linux kernel still uses a Makefile-based build system. The flip side is that you are building the abstractions yourself, and some higher-level features (dependency discovery, multi-target ergonomics, reusable build functions) are simply not there unless you implement them.
  • CMake (or similar meta-build systems): a richer toolbox and more structure, especially once you have multiple architectures, multiple outputs, and lots of third-party code. The cost is that CMake has opinions. It assumes a lot about “normal” targets, and with bare metal you will occasionally have to fight it.

We chose CMake because it lets us factor the build into reusable functions and keep the build logic readable as the project grows.

Compiler flags for kernel code

Compiling kernel code requires a specific set of flags that most application developers never encounter:

set(KERNEL_CXX_FLAGS
    -ffreestanding          # No hosted standard library
    -fno-exceptions         # C++ exceptions require runtime support we don't have
    -fno-rtti               # Runtime type information requires a runtime
    -mno-red-zone           # Critical on x86-64 - see below
    -mcmodel=kernel         # Use the kernel code model for higher-half addressing
    -mno-sse                # Disable SSE unless explicitly managed
    -mno-mmx                # Same for MMX
    -mno-avx                # Same for AVX
    -nostdlib               # Don't link against the standard library
)

Most of these are self-explanatory, but two deserve deeper explanation:

-mno-red-zone: On x86-64, the System V ABI defines a 128-byte “red zone” below the stack pointer. Leaf functions (functions that don’t call other functions) can use this space without adjusting the stack pointer - it is an optimization that avoids the overhead of sub rsp, N / add rsp, N for small local variables. The problem is that in kernel mode, interrupts can fire at any time. When an interrupt occurs, the CPU pushes the interrupt frame onto the current stack, starting at the current stack pointer. If a function was using the red zone, the interrupt frame overwrites it, corrupting local variables. The resulting bugs are extraordinarily difficult to diagnose - they manifest as rare, seemingly random memory corruption that depends on exact interrupt timing. Disable the red zone. Always.

-mcmodel=kernel: By default, GCC generates code that assumes all symbols are within the lower 2 GiB of the virtual address space (the small code model). A higher-half kernel is mapped far above that - typically at addresses starting with 0xFFFF8000_00000000. The kernel code model tells GCC that symbols reside in the upper 2 GiB of the 64-bit address space, enabling it to use the correct addressing modes.

One caution before you turn on “normal” performance flags: not every optimization that is fine in userspace is correct in kernel code. Some can introduce instructions (SSE/AVX), reorder memory in ways that break low-level assumptions, or rely on undefined behaviour you did not realize you had. In practice you often end up enabling optimizations selectively, and explicitly disabling the ones that get in the way.

Configuration management

Early on you can often get away with hard-coded constants and a couple of #ifdefs. When the kernel is still a few files, “just change it in the source” is fine.

The pain starts when your “settings” are no longer just C/C++ #defines. In an OS project, a lot of important knobs live in the build and packaging layer: which architecture you are targeting, which toolchain/tool paths to use, which boot protocol you are building for, what memory layout/linker script to pick, whether to emit a raw image or an ELF, and which debug instrumentation should be on.

The simplest long-term fix is to pick a single source of truth for configuration and treat everything else as derived.

Exactly what that source looks like is up to you. The important part is the direction of control: there should be one authoritative place where options are defined, and other layers should consume it instead of redefining the same knobs.

Emulation

You need a fast feedback loop.

Picture testing a change the simplest way. You build a kernel image, flash it to a USB stick, carry it to a test machine, reboot, sit through the firmware’s power-on checks, and squint at the screen to see what happened. Then you change one line and do the whole dance again. A few hundred rounds of that and you will either quit OS development or build something faster. The something faster is emulation.

An emulator is a program that pretends to be an entire computer - CPU, memory, disks, buses, peripherals - in software, running on the same machine you write your code on. Your kernel boots inside it just as it would on real hardware, except the “reboot and wait” loop shrinks from minutes to a second or two. Build, run, look, repeat, without ever leaving your desk.

That covers most of what you need to test. Not all of it, and the missing slice is worth understanding.

Why not all? Two forces pull in opposite directions:

  • An emulator is not the hardware. Modern CPUs are staggeringly complicated, and emulators are software written by people, so they drift from the real thing. They can carry bugs, trail behind the specification, miss a rarely-used feature, or simply behave a little differently than silicon does.
  • An emulator is often kinder than the hardware. It may quietly hand you guarantees a real machine never makes - memory that happens to be zeroed, timing that happens to line up, a device that happens to start in a convenient state. Build on those and your code looks correct, right until it meets hardware that promises nothing of the sort.

So real hardware never fully leaves the picture. And even “it boots on my machine” is not the finish line: move to an older CPU, a newer one, or a different vendor, and some corner the specification left undefined can shift just enough to break you. Ideally you would test across a few generations and vendors of the same architecture. For a student or hobby project, one real machine next to the emulator is plenty.

There is one more reason to love a good emulator: debuggability. The best ones ship tools you will quickly come to depend on - capturing serial output so you can read what your kernel “prints” long before it has a screen driver, exposing a GDB server so you can attach a debugger and single-step through kernel code, logging interrupts and CPU resets, and more. On real hardware, a crash three instructions into boot tells you almost nothing. In an emulator, that same crash can come with a full replay.

We reach for QEMU. It is stable, has strong x86-64 support, and is available just about everywhere on Linux. It is not the only choice - Bochs (a careful software emulator with a long history in OS-dev) and VirtualBox (really a virtualizer rather than a pure emulator) will also boot your system - but QEMU is our default.

Standard library

Here is something that catches most beginners off guard: your kernel needs a C standard library. Not the whole thing - printf to a terminal, fopen, and malloc all assume an OS sitting underneath - but the slice of it that does not. Think string and memory routines (memcpy, memset, strlen), formatting a value into a buffer (snprintf), and plenty more. Early on, the piece you will miss most is output: some way to print, so you can actually see what your kernel is doing before anything else works.

You have two options.

Use an existing library

Projects like Newlib and mlibc are built to be ported across operating systems. You implement a small set of backend hooks (syscall stubs for write, sbrk, and friends), and the library hands you everything above them. It is the fastest way to a working printf.

Build your own

We took this road for AlkOS. It is more work, but you get complete control over memory, performance, and behaviour - and every function does exactly what you expect, because you wrote it. When snprintf misbehaves, you read your code instead of reverse-engineering someone else’s.

libk and libc

Whichever road you take, there is a subtlety: you really need two builds of the library, compiled from (mostly) the same sources.

  • libk - the kernel build. Freestanding. No system calls, and no dynamic allocation until your heap exists. A routine like kprintf writes straight to the serial port or framebuffer instead of going through a file descriptor.
  • libc - the userspace build. Hosted. It does make system calls for I/O and memory, and it ships with a real main() entry point and C-runtime startup.

Keeping them apart is not bureaucracy. It stops kernel code from accidentally calling something that secretly needs an OS - a bug that is subtle, dangerous, and very easy to introduce.

Language support

There is one more dependency that is easy to miss, because it does not come from your code at all - it comes from the language. On a normal system, the OS and its standard library quietly back a number of language features. Build freestanding and that backing disappears, so any feature that leaned on it stops working until you re-supply the missing piece.

In C++ that covers things like thread-local storage, global constructors, and static initializers (and there is more beyond those). You will not want all of it - some features are fine to leave switched off - but a few are genuinely worth wiring up early. Global constructors are the classic example: without them, your global objects are never initialized before the rest of the kernel starts touching them.

For C++ there is a handy rundown of exactly which pieces need supporting and how. For other languages you are more on your own - dig into that language’s own documentation to find the equivalent runtime bits.

Freestanding vs. hosted, again

Notice what keeps happening.

We drew the line between hosted and freestanding once, near the top, as if it were a single decision you make and then move past. It is not. That line runs straight through the middle of the whole project, and you cross it constantly - because a real operating system is not one program. It is two worlds that happen to share a repository.

On one side sits the kernel: freestanding, on the bare metal, nothing underneath it. On the other sits userspace: the programs that run on top of your kernel, hosted, leaning on the very services the kernel provides. Same project, two completely different sets of assumptions.

And here is the part that catches people out: most of the machinery in this post does not get built once. It gets built twice - a freestanding copy for the kernel and a hosted copy for userspace.

  • Two toolchains. The x86_64-elf cross-compiler builds the kernel. Userspace needs its own - one that knows your OS, targets your syscalls, and links against your libc. (Remember the “second toolchain” we flagged earlier? This is where it comes due.)
  • Two standard libraries. libk for the freestanding kernel, libc for hosted userspace - exactly the split we just walked through.
  • Two runtimes. The kernel supplies its own minimal language-support stubs. Userspace gets real CRT startup, a proper main(), and process teardown.

The same pattern shows up for almost anything that depends on the system beneath it. So whenever you reach for a new capability, it pays to ask up front: does this belong to the kernel, to userspace, or to both? When the answer is “both”, you have just signed up to build it - or at least to compile it - two ways.

Get into that habit early and the duplication stays tidy: shared sources that compile both ways, a boundary you can point at, and no hosted dependency quietly sneaking into kernel code. Ignore it, and the two worlds slowly bleed into each other - which is exactly the kind of bug that is miserable to hunt down.


Next up: File structure and coding practices - how we keep a kernel-sized codebase from collapsing under its own weight. Coming soon.