So I started doing Advanced Linux Kernel Programming this semester. I have always been interested in operating systems and kernel programming. I once tried following tutorial on operating system development using Rust, but I never got past the first tutorial. This course gives me a more realistic motivation to properly learn operating systems and kernel development.
Honestly, I had know the terms like bootloader, BIOS, UEFI, and kernel before, but I never really paid attention to how they are organized or how the system actually boots. Going forward, I want to write a post every week based on what I learn in this course.
In this first article, I’ll cover the boot-up process and how to set up the Linux kernel for development and debugging.
Some other interesting questions I want to explore during this article are:
- How the boot process differs between Windows, Linux, and macOS
- What the difference is between distributions like Ubuntu, Fedora, Debian, and Arch
- How an operating system is built around a kernel
- How to set up the Linux kernel for development
- How to debug the Linux kernel
The Boot Process
When you press the power button, the first software that runs is the system firmware. On modern systems, this is usually UEFI (older systems used BIOS). UEFI is responsible for performing the Power-On Self Test (POST) and initializing hardware such as the CPU, memory, and storage devices.
After this, UEFI loads a bootloader from disk. On Linux systems, the bootloader is usually GRUB. On Windows systems, the bootloader is Windows Boot Manager. In a dual-boot system, the UEFI firmware starts the bootloader that appears first in its boot order. When GRUB is started first, it shows a menu that allows booting either Linux or Windows. Linux is booted directly by GRUB, while Windows is booted by chainloading the Windows Boot Manager. If the Windows Boot Manager is started first instead, Linux does not appear as an option because the Windows bootloader does not know how to boot or chainload Linux.
The bootloader’s job is to load the Linux kernel image and an accompanying initramfs into memory and then transfer execution to the kernel.
Once the Linux kernel starts running, it initializes core subsystems such as memory management, scheduling, and device drivers. However, the kernel cannot immediately mount the real root filesystem from disk because the required storage drivers may not yet be available.
To solve this problem, the kernel first uses a temporary root filesystem called initramfs. initramfs is loaded into RAM and contains a minimal userspace environment along with essential drivers. This allows the kernel to mount the real root filesystem from disk.
After the real root filesystem is mounted, the kernel starts the first userspace process called init. On most modern Linux distributions, this process is systemd. systemd is responsible for starting system services, networking, and login processes.
This is the high-level overview of how the Linux boot process works.
What Is an Operating System?
An operating system is more than just a kernel. The kernel is responsible for low-level tasks such as CPU scheduling, memory management, device drivers, and system calls. However, a complete operating system also includes userspace components such as core utilities, libraries, an init system, and a package manager.
Some common kernels include:
- The Linux kernel
- The Windows NT kernel
- Apple’s XNU kernel (used by macOS)
UNIX itself is not a single kernel, but a family of operating systems and standards. macOS is UNIX-certified and is based on the XNU kernel, which combines components from Mach and BSD. Windows uses its own proprietary NT kernel.
Today, most servers and cloud systems use the Linux kernel because it is open source, flexible, and widely supported.
Linux Distributions
Linux distributions such as Ubuntu, Fedora, Debian, and Arch all use the same Linux kernel, but they differ in their userspace components and design goals.
Some of the key differences between distributions include:
- Package manager (apt, dnf, pacman, etc.)
- Release model (stable vs rolling release)
- Default software and configuration
- Target audience
For example:
- Ubuntu focuses on ease of use and has strong desktop and server support
- Debian prioritizes stability
- Fedora often includes newer technologies and is closer to upstream development
- Arch Linux is minimal and gives the user full control over the system
These differences exist because different users and use cases require different trade-offs.
Kernel Development Setup
To start kernel programming, the first step is to clone the Linux kernel source code. After making changes to the kernel, we need a safe way to build and test it. Testing kernel changes directly on the host system can break the machine, so instead we use QEMU.
QEMU is a hardware emulator that allows us to run a custom kernel inside a virtual machine. This makes kernel development much safer and more convenient.
To run the kernel in QEMU, we also need an initramfs. For development and testing, we use BusyBox to create a minimal initramfs environment. BusyBox provides lightweight implementations of common Unix utilities and is commonly used in initramfs and embedded systems.
Debugging the Kernel
To debug the Linux kernel, it must be built with debug symbols enabled. Once the kernel is built, QEMU can be started in a mode that allows a debugger to attach to it.
Although the debugger used is gdb, I use VS Code as a frontend for debugging. This requires creating launch.json and tasks.json files inside a .vscode directory in the Linux kernel source tree.
One of the earliest and most useful places to set a breakpoint is the start_kernel function located in init/main.c. This is one of the first C functions executed by the kernel. When the debugger hits this breakpoint, the kernel is fully under our control, and we can begin stepping through kernel initialization code.
At this point, the development environment is set up and ready for kernel hacking. I’ll continue documenting what I learn in future weeks.