Integrating a concrete, battle tested bootloader

In the previous post we were able to successfully boot into our kernel.

But.. even though the bootloader we wrote is our own and we love it very much, it is a very primitive one and doesn’t really do anything other than printing a fixed string to the screen.

We’ll now integrate a battle-tested bootloader and use it to load our Rust code, instead of relying entirely on assembly.

As discussed in the previous chapter, we’ll follow Linux’s lead to use a battle tested bootloader instead of rolling our own, otherwise it’ll distract us from our main goal of writing an OS without providing any meaningful insight besides “This is how the manufacturers set things up, so write this magic number to this address, write these instructions verbatim” etc.

We’ll be using the bootloader crate for this purpose which is an experimental bootloader and integrates with rust pretty well.

What does a bootloader do?

At its core, the purpose of a bootloader is to pass the validation checks imposed by the firmware, and then hand over control to the kernel by loading the kernel code in memory and then moving the instruction pointer to that address so that CPU can start executing that code.

To make sure that your kernel code runs smoothly, the bootloader does the following:

Pass firmware validation checks

The bootloader is the first piece of software executed by the firmware after power-on or reset. Before transferring control to it, the firmware performs a set of validation checks to ensure that the bootloader is well-formed and safe to execute.

The bootloader must conform to these expectations imposed by the firmware and tell the firmware that: β€œHey firmware, I am a valid executable and I am in the format you expect, so it is safe for you to hand control over to me now.”

BIOS and UEFI have different validation requirements, so the bootloader must make sure that it satisfies the specific checks of the firmware environment it wants to run under. If you want to support BIOS, it needs to pass the BIOS validation checks. If you want to support UEFI, it needs to pass the UEFI validation checks.

Fortunately for us, the bootloader crate supports exporting the bootloader as either a BIOS or a UEFI executable.

Initialize CPU Environment

Setting Up a stack

The bootloader sets up a valid stack for you to work with which is crucial for maintaining the correct state of the program and ensuring that the program can continue executing correctly.

Why a stack is needed | Figure 4.1
Why a stack is needed | Figure 4.1

When functions call other functions, stack frames are created to track the call chain, allowing execution to return to the correct instruction once a callee completes i.e back to the caller where it jumped off from (the instruction the code was at before the call)

In the above Figure 4.1, we can see that there are three functions Green, Blue and Pink. When green calls blue, we need to keep track of the fact that we called it at address 0xd, so when Blue returns, we must return the instruction pointer back to 0xd. Similarly, when Blue calls Pink, it calls it at address 0xf3, and when Pink returns, we must return the instruction pointer back to 0xf3.

This is where a stack is needed, as it keeps the address of the instruction that the CPU is supposed to return to after the current function completes. As you call new functions, all you have to do is push the current instruction pointer onto the stack before calling the new function, and pop it off when the new function returns.

Switch from real mode to protected or long mode

x86 CPUs start in real mode, which is an archaic 16 bit mode. This exists for backward compatibility with systems from the early IBM PC era (~45 years ago).

But, for modern computers, real mode is not enough and in fact it is fundamentally unusable since we need more than 1MiB of memory, a sane memory addressing scheme, a layer of protection between kernel program and user programs, and sane hardware support.

We do this in the bootloader layer since it’s already written with the real mode constraints in mind. If we were to write it at the kernel level, then we’d have to maintain two separate execution contexts (for real mode, protected mode, and long mode)

Set up a GDT

Historically, the Global Descriptor Table (GDT) played a central role in memory management through segmentation.

In modern 64-bit systems, however, while segmentation still exists architecturally, it is no longer used in memory management and most of its memory-boundary features are disabled. In long mode, memory protection/address translation are handled primarily by paging.

The GDT’s primary role has moved from memory segmentation to defining execution context such as privilege levels, code type, and task state information.

In long mode, the CPU must know the answer to a bunch of questions such as:

and many more.

The CPU uses structures called descriptors which contain architectural metadata that defines how execution should behave.

The Global Descriptor Table (GDT) is one such table that stores these descriptors.

While other architectural structures also participate in execution (such as paging and interrupt tables), the GDT is the only one we need to understand at this stage.

Setup bare minimum paging

The bootloader also sets up paging for its internal use since x86 long mode is architecturally defined to use paging and cannot work without it, meaning in order to even start executing a single 64-bit instruction, paging must be enabled and valid page tables must exist (i.e valid entries must be loaded in the CR3 register’s Page Table Base Register or PTBR).

Think of paging as the key that unlocks 64-bit mode. Without it, the CPU simply refuses to enter long mode.

The paging setup that the bootloader provides is very minimal, which means we will need to replace it with Page tables of our own later down the line.

I’ll explain what paging is and how it works in detail, but for now think of it as a mandatory memory management system that allows the CPU to manage memory in a more efficient and secure way.

Placing the kernel at the correct place in memory

Finally, the bootloader needs to hand over control to the kernel, which means it must load the kernel (built as an ELF binary) at a valid memory address, and move the instruction pointer to the start of the kernel code

Bootloader placing the Kernel in the correct place
Bootloader placing the Kernel in the correct place


With that, the bootloader’s job is done. From here on, the machine is ours.

You don’t need to understand *all* of this right now because we’ll revisit these later as required.

For now, treat these as a required rituals of the β€œCPU initialization phase” that the bootloader does in order to prepare the processor so that our kernel can behave like a modern 64-bit program.

How does the bootloader crate work?

The bootloader crate takes our kernel code, and appends it with the initialization code that does the tasks we discussed above.

We need to pass our kernel code to it and link them together.

We do this by first moving our code to a separate kernel crate, and then utilizing our main crate not as the actual kernel but rather as a driver and compiler stage.

Let’s begin.

First steps towards a rust kernel

As mentioned earlier, we need to move all our kernel code into a separate, child crate. You can call this crate anything, but I’ve decided to call it kernel for simplicity.

Create a new folder called kernel at the root of our project and create the following files

Moving kernel code to sub-crate
1kernel/src/main.rs
2kernel/Cargo.toml

Then move the code that we wrote in src/main.rs file earlier, into the kernel/src/main.rs and cleanup the src/main.rs file by getting rid of everything in it except an empty main function.

Once you do this, your files should look like this

kernel/src/main.rs
1#![no_std]
2#![no_main]
3
4use core::panic::PanicInfo;
5
6#[panic_handler]
7fn panic(_info: &PanicInfo) -> ! {
8    loop {}
9}
src/main.rs
1fn main() {
2    // empty
3}

Next, create a file kernel/Cargo.toml and add the following content

kernel/Cargo.toml
1[package]
2name = "kernel"
3version = "0.1.0"
4edition = "2024"
5
6[dependencies]

Additionally, move the section that told cargo to not compile test and benches over from /Cargo.toml as well.

At the end, your kernel/Cargo.toml should look like this

kernel/Cargo.toml
 1[package]
 2name = "kernel"
 3version = "0.1.0"
 4edition = "2024"
 5
 6[dependencies]
 7
 8# Move over from `Cargo.toml` (the one at project root)
 9[[bin]]
10name = "kernel" # πŸ‘ˆ Crate name changed from zeno to kernel
11test = false
12bench = false

We also need to tell cargo that our project now uses sub crates. We do this by adding the following at the top of our root level Cargo.toml file

Cargo.toml
1# πŸ‘‡ Add this block
2[workspace]
3resolver = "3"
4members = ["kernel"]
5
6[package]
7...

We’ll also add our kernel crate as a build time dependency in our main crate

Cargo.toml
1...
2[build-dependencies.kernel]
3path = "kernel" # πŸ‘ˆ Path where our kernel code lives
4artifact = "bin"
5target = "x86_64-unknown-none"
6features = []
7...

When you try to compile this code with cargo build, you’ll see the following error

1error: failed to parse manifest at `/home/astronaut/Project/blogs/writing-an-os-in-rust/zeno/Cargo.toml`
2
3Caused by:
4  `artifact = …` requires `-Z bindeps` (kernel)

Rust official docs states (verbatim):

Artifact dependencies allow Cargo packages to depend on bin, cdylib, and staticlib crates, and use the artifacts built by those crates at compile time.

Run cargo with -Z bindeps to enable this functionality.

Okay, let’s try to do that

1cargo build -Z bindeps

πŸ’‘ Persisting the -Z bindeps

It’d be hectic to remember and add the -Z bindeps every time we compile. We should persist this in the cargo config file so that it is applied by default

Add this to your .cargo/config.toml (create this file)

.cargo/config.toml
1[unstable]
2bindeps = true

This also requires that we are in nightly channel. Some features of rust we’re going to need, that are not yet stabilized (e.g the asm! macro, the bindeps config etc) require the usage of the nightly channel.

To do this, create a new file called rust-toolchain.toml and add this snippet

rust-toolchain.toml
1[toolchain]
2channel = "nightly"
3components = ["rustfmt", "clippy", "llvm-tools"]
4targets = ["x86_64-unknown-none"]

Next step: install the bootloader crate that will help us compile our code and package it up with a bootloader.

Add the bootloader crate as a build dependency dependency (notice the --build flag)

1# We're using a fixed version since at the time of writing
2# the subsequent versions are broken
3cargo add --build bootloader@=0.11.13

Build Script

Let’s now write a bit of code to actually start processing our kernel and load it as a bootable image.

We want to run this process as soon as we build our code, so instead of writing this in our src/main.rs file, we write it in our build.rs file, which executes at compile time. This code is copied straight from the official bootloader crate docs.

If you’ve chosen to name your kernel sub-crate something other than kernel, you’ll need to adjust the path to the kernel binary accordingly. More specifically, you need to change:

From CARGO_BIN_FILE_KERNEL_kernel to CARGO_BIN_FILE_<NAME OF YOUR CRATE>_<name of your binary>

This variable tells our build script where our kernel binary lives.

We also add additional log statements of our own (the last 2 lines), which will make our lives easier later.

build.rs
 1use bootloader::BootConfig;
 2use std::path::PathBuf;
 3
 4fn main() {
 5    // set by cargo, build scripts should use this directory for output files
 6    let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
 7    // set by cargo's artifact dependency feature, see
 8    // https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#artifact-dependencies
 9    let kernel = PathBuf::from(std::env::var_os("CARGO_BIN_FILE_KERNEL_kernel").unwrap());
10
11    let mut boot_config = BootConfig::default();
12    boot_config.frame_buffer.minimum_framebuffer_height = Some(1080);
13    boot_config.frame_buffer.minimum_framebuffer_width = Some(1920);
14
15    boot_config.frame_buffer_logging = true;
16    boot_config.serial_logging = true;
17
18    // create an UEFI disk image (optional)
19    let uefi_path = out_dir.join("uefi.img");
20    bootloader::UefiBoot::new(&kernel)
21        .set_boot_config(&boot_config)
22        .create_disk_image(&uefi_path)
23        .unwrap();
24
25    // create a BIOS disk image
26    let bios_path = out_dir.join("bios.img");
27    bootloader::BiosBoot::new(&kernel)
28        .set_boot_config(&boot_config)
29        .create_disk_image(&bios_path)
30        .unwrap();
31
32    // pass the disk image paths as env variables to the
33    println!("cargo:rustc-env=UEFI_PATH={}", uefi_path.display());
34    println!("cargo:rustc-env=BIOS_PATH={}", bios_path.display());
35
36    // On top of the code mentioned in the bootloader crate's docs, we've added πŸ‘‡ these lines as well, to make our lives easier
37    println!("cargo::warning={}", format_args!("{}", uefi_path.display()));
38    println!("cargo::warning={}", format_args!("{}", bios_path.display()));
39}

When we try to compile our code now, it compiles successfully and we get the following output:

1warning: zeno@0.1.0: /path/to/zeno/target/debug/build/zeno-{some-hash-here}/out/uefi.img
2warning: zeno@0.1.0: /path/to/zeno/target/debug/build/zeno-{some-hash-here}/out/bios.img
3    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s

Okay.. What now?

Successful Boot

We take the files that were emitted by the build process and pass them over to qemu-system-x86_64 and see if it can load them.

BIOS

1qemu-system-x86_64 /path/to/bios.img # πŸ‘ˆ replace this path with the path that you see above

If all goes well, you should see the following screen.

Successful Boot
Successful Boot

Nice! We just crossed the boundary between firmware and our own OS. We’re so close. Let’s try to run our UEFI image as well. Run the following command and make sure to add the -bios OVMF.fd flag to tell QEMU to use a UEFI based firmware

UEFI

1qemu-system-x86_64 /path/to/uefi.img -bios OVMF.fd # πŸ‘ˆ replace this path with the path that you see above

Uh oh..

We see the following error in the QEMU virtual machine:

1panicked at /path/to/zeno/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/
2bootloader-x86_64-common-0.11.13/src/lib.rs:107:18:
3    bootloader config section not found; kernel must be compiled against bootloader_api

In the Leaving the Kernel dependencies behind post, we got rid of any entry point to our code as rust suggested. We now need to introduce a new entry point so that our bootloader can call our kernel.

Let’s consult the bootloader docs:

Under the section titled Make your kernel compatible with bootloader it mentions all the steps we need to follow in order to make our kernel work.

The gist of it is that we need to call the entry_point! macro with a few configuration options.

Let’s make those changes and then circle back

Add bootloader_api dependency
1# in the `kernel` directory. Remember to use a fixed version, since subsequent versions are broken.
2cargo add bootloader_api@=0.11.13
kernel/src/main.rs
 1use bootloader_api::{BootInfo, entry_point}; // πŸ‘ˆ Import what's needed
 2
 3// πŸ‘‡ Add these changes
 4...
 5fn main() {
 6    // our main code will live here
 7}
 8
 9// We use a separate `launch` function to initialize the kernel.
10// This is where the bulk of our initialization, setup and startup code will live.
11fn launch(_: &'static mut BootInfo) -> ! {
12    main();
13
14    loop {
15        // since there's nothing for us to do right now, we'll just loop endlessly
16    }
17}
18
19const CONFIG: bootloader_api::BootloaderConfig = {
20    bootloader_api::BootloaderConfig::new_default()
21};
22
23entry_point!(launch, config = &CONFIG);
24...

Now when we try to run our code

1# in the root of our source
2cargo build
3qemu-system-x86_64 /path/to/uefi.img -bios OVMF.fd # πŸ‘ˆ replace this path with the result from cargo build

Fingers Crossed!

Booting with UEFI
Booting with UEFI

YEAHH!!! πŸŽ‰πŸ₯³

Now we’re getting somewhere! At this point, our OS boots on both BIOS and UEFI. This is a very good start.

Automating the build process

This process of first compiling, then finding the output files and passing them to qemu-system-x86_64 is a bit much. Let’s automate this by writing some code in rust that first fetches the environment variables to figure out where our build.rs file put those images, and then pass it over to QEMU. We’ll also add a bunch of additional options on top of it so that we can customize QEMU behavior.

We can repurpose our root level src/main.rs for this, since most of our work is being done in the build.rs file instead.

main.rs
 1use std::env;
 2use std::process::{Command, exit};
 3
 4fn main() {
 5    // read env variables that were set in build script
 6    let uefi_path = env!("UEFI_PATH");
 7    let bios_path = env!("BIOS_PATH");
 8
 9    // parse mode from CLI
10    let args: Vec<String> = env::args().collect();
11    let prog = &args[0];
12
13    // choose whether to start the UEFI or BIOS image
14    let uefi = match args.get(1).map(|s| s.to_lowercase()) {
15        Some(ref s) if s == "uefi" => true,
16        Some(ref s) if s == "bios" => false,
17        Some(ref s) if s == "-h" || s == "--help" => {
18            println!("Usage: {prog} [uefi|bios]");
19            println!("  uefi  - boot using OVMF (UEFI)");
20            println!("  bios  - boot using legacy BIOS");
21            exit(0);
22        }
23        _ => {
24            eprintln!("Usage: {prog} [uefi|bios]");
25            exit(1);
26        }
27    };
28
29    let mut cmd = Command::new("qemu-system-x86_64");
30    // print serial output to the shell
31    cmd.arg("-serial").arg("mon:stdio");
32
33    // enable the guest to exit qemu
34    cmd.arg("-device")
35        .arg("isa-debug-exit,iobase=0xf4,iosize=0x04");
36
37    if uefi {
38        cmd.arg("-drive")
39            .arg(format!("format=raw,file={uefi_path}"));
40
41        cmd.arg("-machine").arg("q35");
42        cmd.arg("-cpu").arg("qemu64");
43        cmd.arg("-smp").arg("4");
44        cmd.arg("-m").arg("512M");
45
46        cmd.arg("-device").arg("virtio-gpu-pci");
47        cmd.arg("-display").arg("gtk");
48
49        cmd.arg("-bios").arg("OVMF.fd");
50    } else {
51        cmd.arg("-drive")
52            .arg(format!("format=raw,file={bios_path}"));
53    }
54
55    let mut child = cmd.spawn().expect("failed to start qemu-system-x86_64");
56    let status = child.wait().expect("failed to wait on qemu");
57    match status.code().unwrap_or(1) {
58        0x10 => 0, // success
59        0x11 => 1, // failure
60        _ => 2,    // unknown fault
61    };
62}

Now, instead of manually building, then figuring out the output location and passing it to QEMU, all you have to do is

1cargo run bios # for BIOS mode
2cargo run uefi # for UEFI mode

Phew, Lot of changes, lot of new things to figure out. But with that, I think we’re in a very good position, all things considered.

In the next post, we’ll introduce Serial output so that we can debug our code. We’ll also introduce Port I/O as a way to control QEMU from inside the kernel.

Before we do that though, we’ll perform some minor housekeeping to make sure our codebase stays modular and organized.

Next: A scalable structure for our code

Next: Serial Output and Debugging