For a while now, I've had OSDev Bare Bones on my todo listâalmost two or three years if I'm not wrong. I always wanted to give it a shot but never had the time, and in the last months I was dealing with some burnout. Fortunately, I'm managing to recover from that. Long story for another post, or maybe not.
This post will be the first in a new series where I'll be working through the OSDev Bare Bones tutorial, or at least try to get as far as I can. I'll be posting along the way to share knowledge as usual and also to reinforce my own learning.
The OSDev Bare Bones tutorial is a well-known guide from the OSDev Wiki that walks you through creating a minimal operating system kernel. It covers the fundamentals: setting up a cross-compiler, writing a simple kernel in
C, and booting it with GRUB.
Why? I think the OSDev tutorial is a very interesting exercise that can teach you a couple of things. My only thought is that it's a bit old. Also, I'm not a C developer and I'm still fighting to learn more about Rust, so I decided to have a look at the tutorial and try to follow it using Rust instead.
But of course, I want to read and follow the original tutorial first, trying to pair it with modern concepts and Rust along the way.
Cross-Compilation
The first step that the tutorial mentions is cross-compiling, and it talks about the setup of a GCC cross-compiler for i686-elf.
i686-elfis a classic target designed for developing 32-bit (x86) operating systems. The-elftarget produces bare binaries with no OS assumptions for bare metalâexactly what we want for kernel development.
What Is a Cross-Compiler?
TL;DR: A cross-compiler produces code for a different platform than the one it runs on (e.g., compiling ARM code on an x86 machine).
A native compiler runs on platform Y and produces binaries for platform Y. A cross-compiler runs on platform X but produces binaries for platform Y.
As I already mentioned, I'm not a C developerânever used C more than for ultra simple basic hello worlds or some lecture. So I didn't know how this process works in C. I have more experience with Go and some experience with Rust, where cross-compilation is a native feature offered out of the box.
Why Cross-Compilers Are Necessary
The compiler must know the correct target platform (CPU, operating system). The compiler that comes with the host system does not know by default that it is compiling something else entirely. In our case, the host platform is our current operating system and the target platform is the operating system we are about to make. It is important to realize that these two platforms are not the same; the operating system we are developing is always going to be different from the operating system we currently use.
As you read before, the guide mentions that we are going to use i686-elf, designed to build 32-bit (i686) executable binaries that run on bare metal, without an underlying operating system. In my case, I have access to an ARM M1 Max as my main machine or a Lima VM running Ubuntu x86_64.
The tutorial mentions i686-elf which is 32-bit, because we will not be able to correctly complete the tutorial with an x86_64-elf cross-compiler, as GRUB 1 "legacy" is only able to load 32-bit multiboot kernels.
So, we will not be able to correctly compile the operating system without a cross-compiler.
Freestanding and Hosted Environments
As the guide mentions, to write an operating system kernel we need code that does not depend on any operating system features. This makes sense since the whole point of the guide is to create our own OS.
Let's look at the two execution environments:
Hosted Environment
This is when we are writing a program that runs on top of an existing Operating System (Linux, macOS).
- The Contract: The OS provides a managed environment. It sets up the stack, clears memory, and handles hardware I/O.
- The Toolbox: You have the Full Standard Library. You can call
printformallocbecause the OS provides the "plumbing" (drivers and memory managers) to make them work. - Safety: If your code crashes, the OS catches it and kills the process. The rest of the computer keeps running.
- Target: 99% of software (apps, web servers, CLI tools).
Freestanding Environment
This is when we are writing code for Bare Metal (a CPU with no OS).
- The Contract: There is no help. You must manually define the Entry Point and tell the CPU how to use its own RAM.
- The Toolbox: You only have Core Language Primitives. No
printf, nomalloc, no file system, no network. You only get basic types (integers, pointers) and math or core language features. - Safety: If your code crashes, the CPU triggers a "Triple Fault" and the physical machine reboots. There is no "safety net."
- Target: The kernel itself, bootloaders, and embedded firmware.
Recap
So basically, a cross-compiler produces code for a different platform than the one it runs on, and a freestanding environment is one where no OS or standard library exists just our code and the hardware. These two concepts go hand-in-hand: when writing an OS kernel or bare metal firmware, we have to cross-compile for our target architecture in freestanding mode.
Zig to the Rescue?
The guide has a link to GCC Cross-Compiler explaining how to set up the required compiler, which I found a bit complex and tedious. I remembered hearing a lot of good things about the Zig compiler and its flexibility, so I thought I could just use Zig as the compiler.
The OSDev tutorial provides the following C code for the kernel.c file. Here's a brief walkthrough of what it does:
- The
#if definedchecks at the top are safeguards that prevent you from compiling with the wrong compilerâthey ensure you're using a cross-compiler targeting i386. - It defines a
vga_colorenum and helper functions to create VGA text-mode entries (each character on screen is a 16-bit value: the character byte + a color byte). - The
terminal_*functions manage a simple text terminal by writing directly to VGA memory at0xB8000âthis is the physical address where the VGA text buffer lives in x86 systems. kernel_mainis our entry point: it initializes the terminal and prints "Hello, kernel World!".
Let's look at the full code and then jump to the cross-compilation part:
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
/* Check if the compiler thinks you are targeting the wrong operating system. */
#if defined(__linux__)
#error "You are not using a cross-compiler, you will most certainly run into trouble"
#endif
/* This tutorial will only work for the 32-bit ix86 targets. */
#if !defined(__i386__)
#error "This tutorial needs to be compiled with a ix86-elf compiler"
#endif
/* Hardware text mode color constants. */
enum vga_color {
VGA_COLOR_BLACK = 0,
VGA_COLOR_BLUE = 1,
VGA_COLOR_GREEN = 2,
VGA_COLOR_CYAN = 3,
VGA_COLOR_RED = 4,
VGA_COLOR_MAGENTA = 5,
VGA_COLOR_BROWN = 6,
VGA_COLOR_LIGHT_GREY = 7,
VGA_COLOR_DARK_GREY = 8,
VGA_COLOR_LIGHT_BLUE = 9,
VGA_COLOR_LIGHT_GREEN = 10,
VGA_COLOR_LIGHT_CYAN = 11,
VGA_COLOR_LIGHT_RED = 12,
VGA_COLOR_LIGHT_MAGENTA = 13,
VGA_COLOR_LIGHT_BROWN = 14,
VGA_COLOR_WHITE = 15,
};
static inline uint8_t vga_entry_color(enum vga_color fg, enum vga_color bg)
{
return fg | bg << 4;
}
static inline uint16_t vga_entry(unsigned char uc, uint8_t color)
{
return (uint16_t) uc | (uint16_t) color << 8;
}
size_t strlen(const char* str)
{
size_t len = 0;
while (str[len])
len++;
return len;
}
#define VGA_WIDTH 80
#define VGA_HEIGHT 25
#define VGA_MEMORY 0xB8000
size_t terminal_row;
size_t terminal_column;
uint8_t terminal_color;
uint16_t* terminal_buffer = (uint16_t*)VGA_MEMORY;
void terminal_initialize(void)
{
terminal_row = 0;
terminal_column = 0;
terminal_color = vga_entry_color(VGA_COLOR_LIGHT_GREY, VGA_COLOR_BLACK);
for (size_t y = 0; y < VGA_HEIGHT; y++) {
for (size_t x = 0; x < VGA_WIDTH; x++) {
const size_t index = y * VGA_WIDTH + x;
terminal_buffer[index] = vga_entry(' ', terminal_color);
}
}
}
void terminal_setcolor(uint8_t color)
{
terminal_color = color;
}
void terminal_putentryat(char c, uint8_t color, size_t x, size_t y)
{
const size_t index = y * VGA_WIDTH + x;
terminal_buffer[index] = vga_entry(c, color);
}
void terminal_putchar(char c)
{
terminal_putentryat(c, terminal_color, terminal_column, terminal_row);
if (++terminal_column == VGA_WIDTH) {
terminal_column = 0;
if (++terminal_row == VGA_HEIGHT)
terminal_row = 0;
}
}
void terminal_write(const char* data, size_t size)
{
for (size_t i = 0; i < size; i++)
terminal_putchar(data[i]);
}
void terminal_writestring(const char* data)
{
terminal_write(data, strlen(data));
}
void kernel_main(void)
{
/* Initialize terminal interface */
terminal_initialize();
/* Newline support is left as an exercise. */
terminal_writestring("Hello, kernel World!\n");
}
As was previously mentioned, if we try to compile this code with a regular gcc on my Lima Ubuntu VM, you will see the safeguards in the code acting up:
$ gcc -c kernel.c -o hosted_kernel.o
kernel.c:7:2: error: #error "You are not using a cross-compiler, you will most certainly run into trouble"
7 | #error "You are not using a cross-compiler, you will most certainly run into trouble"
| ^~~~~
kernel.c:12:2: error: #error "This tutorial needs to be compiled with a ix86-elf compiler"
12 | #error "This tutorial needs to be compiled with a ix86-elf compiler"
| ^~~~~
But if we use Zig and its build magic, we get the output successfully and we can inspect the file:
$ zig cc -target x86-freestanding-none -c kernel.c -o kernel.o
$ readelf -h kernel.o
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: REL (Relocatable file)
Machine: Intel 80386
Version: 0x1
Entry point address: 0x0
Start of program headers: 0 (bytes into file)
Start of section headers: 7164 (bytes into file)
Flags: 0x0
Size of this header: 52 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 40 (bytes)
Number of section headers: 22
Section header string table index: 20
We can see that the class is ELF32 (32-bit) and the OS/ABI is UNIX - System V. If it said Linux, it would not be freestanding; it would have been built with Linux-specific assumptions.
We could also inspect the symbol table, and we should not see _start:
$ nm kernel.o
U __ubsan_handle_pointer_overflow
U __ubsan_handle_shift_out_of_bounds
U __ubsan_handle_type_mismatch_v1
000004f0 T kernel_main
00000000 T strlen
00000024 D terminal_buffer
00000008 B terminal_color
00000004 B terminal_column
000000a0 T terminal_initialize
00000390 T terminal_putchar
000002b0 T terminal_putentryat
00000000 B terminal_row
000002a0 T terminal_setcolor
00000410 T terminal_write
000004c0 T terminal_writestring
00000230 t vga_entry
000001e0 t vga_entry_color
So great, we managed to cross-compile our kernel.c for a different architecture in freestanding mode using Zig. But as I mentioned at the beginning of the article, I want to do the same using Rust.
Rust in Action
Beyond my personal goal of learning Rust, it's actually an ideal candidate for OS development: it gives you the same low-level control as C but with memory safety guarantees at compile time, no garbage collector, and first-class no_std support designed exactly for bare-metal targets.
Let's create a new project:
$ cargo new osdev
A small disclaimer: Rust by default offers x86_64-unknown-none, which means 64-bit instead of the 32-bit target used in the tutorial. We could use nightly and a custom target spec to specify 32-bit, but for simplicity and because I want to try following along using 64-bit later! we are going to use x86_64-unknown-none.
$ rustup target add x86_64-unknown-none
As you can see, both Zig and Rust leverage the Target Triple convention for specifying targets:
| Target Component | x86-freestanding-none (Zig) | x86_64-unknown-none (Rust) | Definition |
|---|---|---|---|
| Architecture | x86 (32) | x86_64 (64) | The CPU instruction set (32-bit vs. 64-bit). |
| Vendor | freestanding* | unknown | Historically the hardware maker. In OS dev, this is a "don't care" field. |
| OS | none | none | Tells the compiler there is no OS (no Linux/macOS). |
As you may know, when you create a fresh Rust project it comes with its hello world version:
fn main() {
println!("Hello, world!");
}
If we try to compile using the target we previously installed (x86_64-unknown-none), we get the following error:
$ cargo build --target x86_64-unknown-none
Compiling osdev v0.1.0 (/Users/douglasmakey/workdir/personal/os-barebones/osdev)
error[E0463]: can't find crate for `std`
|
= note: the `x86_64-unknown-none` target may not support the standard library
= note: `std` is required by `osdev` because it does not declare `#![no_std]`
error: cannot resolve a prelude import
error: cannot find macro `println` in this scope
--> src/main.rs:2:5
|
2 | println!("Hello, world!");
| ^^^^^^^
error: `#[panic_handler]` function required, but not found
For more information about this error, try `rustc --explain E0463`.
error: could not compile `osdev` (bin "osdev") due to 4 previous errors
This happens because our current build implicitly links the standard library. Since we specified a freestanding target (meaning no OS context, just core primitives), Rust can't find std. Rust has support for this scenario using #![no_std]:
#![no_std]
fn main() {
println!("Hello, world!");
}
But if we try to compile this, we get another error:
$ cargo build --target x86_64-unknown-none
Compiling osdev v0.1.0 (/Users/douglasmakey/workdir/personal/os-barebones/osdev)
error: cannot find macro `println` in this scope
--> src/main.rs:3:5
|
3 | println!("Hello, world!");
| ^^^^^^^
error: `#[panic_handler]` function required, but not found
error: could not compile `osdev` (bin "osdev") due to 2 previous errors
The problem is that #![no_std] tells the Rust compiler not to link against the standard library. Instead, it links against the core library, which only provides core primitives. This means macros like println! are gone. We are essentially disabling all OS dependent functionality. The other error is about the panic_handler.
Panic Handler
When a Rust program encounters an unrecoverable error, it "panics." In a regular std environment, this typically prints a backtrace and the OS catches it. In no_std, you must define your own panic handler, as there's no OS to catch the panic. There is no "safety net."
So to make this work, we need to address a few things: add the panic handler, drop println!, and also tell the compiler that we don't have a normal main entry point. In a freestanding environment there's no runtime that calls main for us, so we use #![no_main] to disable that expectation and define our own entry point _start instead. The #[unsafe(no_mangle)] attribute ensures the function name isn't changed by the compiler, so the linker can find it. The -> ! return type means the function never returnsâwhich makes sense because there's no OS to return to, so we just loop forever.
#![no_std]
#![no_main]
use core::panic::PanicInfo;
#[unsafe(no_mangle)]
pub extern "C" fn _start() -> ! {
loop {}
}
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
If you want to deep dive on
no_std, I found this article useful: Rust Without the Standard Library: A Deep Dive into no_std Development
And now if we compile again, this time it will succeed:
$ readelf -h target/x86_64-unknown-none/debug/osdev
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Position-Independent Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x1280
Start of program headers: 64 (bytes into file)
Start of section headers: 4216 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 8
Size of section headers: 64 (bytes)
Number of section headers: 21
Section header string table index: 19
As we can notice, the header has similarities with our kernel.c compiled with Zig, of course with the difference of ELF64 (64-bit) and for the same reason the machine shows as AMD X86-64.
We could also inspect the symbol table, this time, weâll notice the presence of _start. Thatâs because cargo build automatically invokes the linker (LLD) when it detects a pub fn _start in our code. By default, the linker looks for that symbol and exports it as the programâs entry point.
$ nm target /x86_64-unknown-none/debug/osdev
0000000000002288 d _DYNAMIC
0000000000001280 T _start
I will leave this first article at this point. I hope you find it as interesting as I did. I will continue the series hopefully as soon as possible. I'll be working on the OS at the same time I write the articles, so a couple of things might change or I might make mistakes that I'll fix later on.
In the next post, we'll cover implementing the bootloader and get our kernel running in QEMUâeither by following the OSDev guide step by step or by using the Rust bootloader crate.
Bonus: Lima Setup for Mac Users
In case you want to play around with this on a Mac, I'm using Lima to run an x86_64 Ubuntu VM. Here's my current Lima configuration with Zig, Rust, and the build essentials pre-installed:
vmType: "qemu"
arch: "x86_64"
images:
- location: "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img"
arch: "x86_64"
- location: "https://cloud-images.ubuntu.com/releases/22.04/release/ubuntu-22.04-server-cloudimg-amd64.img"
arch: "x86_64"
mounts:
- location: path-to-your-repo
writable: true
provision:
- mode: system
script: |
DEBIAN_FRONTEND=noninteractive apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential gdb qemu-system-x86 nasm xorriso mtools
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env"
rustup component add rust-src llvm-tools-preview
snap install zig --beta --classic
cpus: 4
memory: "4GiB"
You can start the VM with:
$ limactl start --name osdev osdev.yaml
$ limactl shell osdev
Thanks for reading, and as always, feedback is welcome. Happy coding!

