Build a Mini OS Kernel from Scratch
Write a bootable x86 kernel in C and assembly that runs on bare metal.
Introduction
30 Second Summary
Every app you use, every file you save, and every window you open relies on an operating system running underneath. But how does a computer go from "power on" to actually running software?
In this project, you will build a mini operating system kernel from scratch that boots inside an emulator and prints a colorful welcome message directly to the screen.
What You'll Build
A minimal 32-bit x86 kernel that boots via the Multiboot standard and writes colored text to VGA video memory with no operating system underneath.
By the end of this project, you'll have:
- A complete bare-metal development toolchain with a cross-compiler, assembler, and emulator set up on your machine.
- An x86 assembly boot stub that initializes the CPU and hands control to your C code.
- A C kernel that writes directly to hardware memory to display colored text on screen.
- Secret Mission: Add a screen-clearing function and a color-cycling animation that loops through all 16 VGA text colors.
Are there any prerequisites?
You should have a basic understanding of C programming (variables, functions, pointers) and be comfortable using a terminal. No prior assembly or OS knowledge is required.
Set Up WSL2 and Your Development Tools
Before you can write a single line of kernel code, you need a bare-metal development environment. Your kernel targets raw x86 hardware, so you need specialized tools that produce binaries without any operating system dependencies.
In this step, you will set up WSL2 with Ubuntu to get a Linux environment on Windows. Then you will install an assembler, an emulator, and a cross-compiler that together form your complete OS development toolchain.
In this step, get ready to:
- Install and configure WSL2 with Ubuntu on your Windows machine.
- Install NASM (assembler) and QEMU (emulator) inside Ubuntu.
- Build and install the i686-elf cross-compiler toolchain.
Install and configure WSL2 with Ubuntu
WSL2 gives you a full Linux kernel running inside Windows. The OSDev community recommends WSL for Windows users because building cross-compilation toolchains works cleanly in a real Linux environment.
- Open PowerShell as Administrator by pressing the Windows key, typing PowerShell, right-clicking it, and selecting Run as administrator.
- Install WSL2 with Ubuntu by running this command:
wsl --install
What does this command do?
This single command enables the WSL2 feature, downloads the Linux kernel, and installs Ubuntu as your default distribution. It handles everything in one step.
- When prompted, restart your computer to complete the installation.
- After rebooting, Ubuntu will open automatically and ask you to create a username and password. Enter any username and password you like (this is for your Linux environment only).
- Once you see a terminal prompt (something like username@DESKTOP:~$), WSL2 is ready.
Not seeing Ubuntu launch after reboot?
Press the Windows key, type Ubuntu, and press Enter to launch it manually.
If you get a virtualization error, you may need to enable hardware virtualization (VT-x) in your BIOS settings. Help me enable virtualization for WSL2
Install NASM and QEMU
NASM is the assembler that translates your human-readable assembly code into machine code. QEMU is the emulator that simulates x86 hardware so you can boot your kernel without needing a physical machine.
- Inside your Ubuntu terminal, update the package list by running:
sudo apt-get update
- Install NASM and QEMU by running:
sudo apt-get install -y nasm qemu-system
What does this install?
This installs nasm (the Netwide Assembler for x86) and qemu-system (which includes qemu-system-i386, the 32-bit x86 emulator you will use to boot your kernel).
- Verify NASM installed correctly by running:
nasm --version
- Verify QEMU installed correctly by running:
qemu-system-i386 --version
You should see a version number printed for each tool.
Command not found?
Make sure you are running these commands inside the Ubuntu terminal, not in Windows PowerShell. If you accidentally closed Ubuntu, reopen it from the Start menu.
Help me troubleshoot my NASM or QEMU installation in WSL2
Install the i686-elf cross-compiler
The regular GCC on your Ubuntu system produces Linux binaries. Your kernel is not Linux. A cross-compiler targeting i686-elf produces freestanding binaries with no operating system dependencies.
Why do you need a cross-compiler?
Without a cross-compiler, GCC assumes your code will run on Linux and links against Linux libraries. Your kernel has no operating system beneath it, so those assumptions would cause subtle and confusing bugs.
The i686-elf target tells GCC to produce clean 32-bit x86 binaries with zero OS dependencies.
Building the cross-compiler from source is the recommended approach from the OSDev Wiki. You will install the build prerequisites, then compile Binutils and GCC for the i686-elf target.
- Install the build dependencies by running:
sudo apt-get install -y build-essential bison flex libgmp3-dev libmpc-dev libmpfr-dev texinfo
What are these packages?
These are the libraries and tools needed to compile GCC and Binutils from source. They include the math libraries (GMP, MPC, MPFR) that GCC depends on, plus bison and flex for parser generation.
- Set up the environment variables and create the build directory by running:
# Set the install location and target
export PREFIX="$HOME/opt/cross"
export TARGET=i686-elf
export PATH="$PREFIX/bin:$PATH"
# Create a working directory for the build
mkdir -p $HOME/src
cd $HOME/src
- Download and build Binutils (the assembler and linker) by running:
# Download Binutils source
wget https://ftp.gnu.org/gnu/binutils/binutils-2.41.tar.xz
tar -xf binutils-2.41.tar.xz
# Build Binutils for the i686-elf target
mkdir build-binutils
cd build-binutils
../binutils-2.41/configure --target=$TARGET --prefix="$PREFIX" --with-sysroot --disable-nls --disable-werror
make -j$(nproc)
make install
cd $HOME/src
- Download and build GCC by running:
# Download GCC source
wget https://ftp.gnu.org/gnu/gcc/gcc-13.2.0/gcc-13.2.0.tar.xz
tar -xf gcc-13.2.0.tar.xz
# Build GCC for the i686-elf target
mkdir build-gcc
cd build-gcc
../gcc-13.2.0/configure --target=$TARGET --prefix="$PREFIX" --disable-nls --enable-languages=c --without-headers
make -j$(nproc) all-gcc
make -j$(nproc) all-target-libgcc
make install-gcc
make install-target-libgcc
cd $HOME/src
What is happening during this build?
You are compiling two tools from source: Binutils (which provides i686-elf-ld, the linker) and GCC (which provides i686-elf-gcc, the C compiler).
The -j$(nproc) flag tells make to use all your CPU cores for faster compilation. This process takes 10-20 minutes depending on your machine.
Grab a coffee while it builds
The GCC build takes a while. This is normal. Let it run and come back when you see the terminal prompt again.
- Make the cross-compiler permanently available on your PATH by running:
echo 'export PATH="$HOME/opt/cross/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
- Verify the cross-compiler is working by running:
i686-elf-gcc --version
You should see a version number confirming that i686-elf-gcc is installed and on your PATH.
- Create your project directory by running:
mkdir -p ~/myos
cd ~/myos
- Confirm the directory exists by running:
ls ~/myos
You should see an empty directory listed. This is where all your kernel source files will live.
i686-elf-gcc not found after the build?
Make sure you ran the source ~/.bashrc command to reload your PATH. You can also try opening a new Ubuntu terminal window.
If the build itself failed, check that all dependencies installed correctly with sudo apt-get install -y build-essential bison flex libgmp3-dev libmpc-dev libmpfr-dev texinfo and try the build steps again.
Help me fix my i686-elf cross-compiler build in WSL2
Your development environment is fully set up. Next up, you will write the assembly boot stub that starts your operating system.
Write the Assembly Boot Stub
Your development environment is ready with a cross-compiler, assembler, and emulator all installed. Now it's time to write the first piece of your operating system.
Every kernel needs a way to tell the bootloader "I'm a valid kernel, load me." The Multiboot standard defines exactly how to do this. You'll write x86 assembly that declares this identity and prepares the CPU to run your C code.
In this step, get ready to:
- Create your boot assembly file inside the project folder.
- Write the Multiboot header that makes your kernel recognizable to a bootloader.
- Set up a stack and entry point that hands control to your C kernel function.
Create the project directory structure
Your ~/myos folder already exists from the previous step. You need to navigate into it so all your source files land in the right place.
- Navigate to your project folder by running this command:
cd ~/myos
- Create the boot assembly file by running:
touch boot.asm
- Confirm the file exists by running:
ls
You should see boot.asm in the output.
- Open boot.asm in your text editor. If you have VS Code connected to WSL, you can open it by running:
code boot.asm
No VS Code?
You can use any text editor available inside Ubuntu, such as nano boot.asm or vim boot.asm. The important thing is that you can edit and save the file.
Write boot.asm
This file is your kernel's front door. It declares the Multiboot header so the bootloader recognizes it, reserves memory for a stack, and defines the entry point that jumps into your C code.
- In boot.asm, define the Multiboot constants and header section by pasting this code:
; Multiboot constants
MBALIGN equ 1 << 0
MEMINFO equ 1 << 1
FLAGS equ MBALIGN | MEMINFO
MAGIC equ 0x1BADB002
CHECKSUM equ -(MAGIC + FLAGS)
; Multiboot header
section .multiboot
align 4
dd MAGIC
dd FLAGS
dd CHECKSUM
What does this code do?
The first five lines define constants that the Multiboot specification requires. MAGIC is the value 0x1BADB002. This is a special number that the bootloader scans for in the first 8KB of your binary to confirm it's a valid kernel.
MBALIGN requests page-aligned modules. MEMINFO requests a memory map from the bootloader. CHECKSUM ensures the three header values add up to zero, which is how the bootloader verifies nothing is corrupted.
The dd instructions write these values as raw 32-bit integers into a special .multiboot section. The linker script (coming in Step 4) places this section at the very start of the binary so the bootloader finds it immediately.
- Below the Multiboot header, add the stack reservation and entry point by pasting this code:
; Stack
section .bss
align 16
stack_bottom:
resb 16384 ; 16 KB stack
stack_top:
; Entry point
section .text
global _start
extern kernel_main
_start:
mov esp, stack_top
call kernel_main
cli
.hang:
hlt
jmp .hang
What does this code do?
The .bss section reserves 16KB of uninitialized memory for the stack. C code needs a stack to work. Without it, function calls, local variables, and return addresses have nowhere to go.
The .text section contains the actual executable code. _start is the entry point that the bootloader jumps to after loading your kernel into memory.
The three instructions inside _start do all the work: set the stack pointer (esp) to the top of the reserved memory, call your C function kernel_main, and then halt the CPU in an infinite loop so it doesn't wander into random memory.
- Save boot.asm.
File looks different from what you expected?
Make sure you pasted both code blocks into the same file, with the Multiboot constants at the top and the entry point section below. The file should have one .multiboot section, one .bss section, and one .text section.
If you accidentally created two separate files or missed a section, delete everything and paste the full code from the double-check tab below.
help me fix my boot.asm file
✔️ Awesome, I've got everything!
Great. Make sure you have saved boot.asm before moving on.
ⓧ I'd like to double check the full code
Here is the complete boot.asm file. Compare it with yours and fix any differences.
; Multiboot constants
MBALIGN equ 1 << 0
MEMINFO equ 1 << 1
FLAGS equ MBALIGN | MEMINFO
MAGIC equ 0x1BADB002
CHECKSUM equ -(MAGIC + FLAGS)
; Multiboot header
section .multiboot
align 4
dd MAGIC
dd FLAGS
dd CHECKSUM
; Stack
section .bss
align 16
stack_bottom:
resb 16384 ; 16 KB stack
stack_top:
; Entry point
section .text
global _start
extern kernel_main
_start:
mov esp, stack_top
call kernel_main
cli
.hang:
hlt
jmp .hang
Understand the boot sequence
You just wrote an entire boot stub in under 30 lines. Here's what happens when your kernel boots, step by step.
What does the Multiboot magic number do?
When QEMU loads your binary with the -kernel flag, its built-in bootloader scans the first 8KB of the file looking for the value 0x1BADB002. If it finds this magic number followed by valid flags and a correct checksum, it knows the binary is a Multiboot-compliant kernel and proceeds to load it into memory.
Without this header, the bootloader has no way to distinguish your kernel from a random file. The magic number is essentially your kernel's ID badge.
Why do you need a stack before calling C?
C functions rely on the stack for everything. When you call a function, the CPU pushes the return address onto the stack. Local variables live on the stack. Function parameters are passed through the stack (or registers, depending on the calling convention).
When the bootloader hands control to _start, there is no stack set up. The esp register points to garbage. If you tried to call kernel_main without first pointing esp at valid memory, the CPU would crash immediately. That's why the very first instruction sets esp to stack_top.
Why halt in a loop after kernel_main returns?
If kernel_main ever returns, the CPU would execute whatever random bytes come after the call instruction in memory. The cli instruction disables interrupts, and hlt puts the CPU into a low-power state. The jmp .hang loop is a safety net in case a non-maskable interrupt wakes the CPU back up.
Your boot stub is complete. Next up, you'll write the C kernel that this assembly code calls into. That's where you'll put characters directly onto the screen.
Write the C Kernel
Your boot stub is ready. It sets up the stack and calls kernel_main. Now you need to write the function it is calling.
In a normal program, you would use printf to display text. But there is no operating system beneath your kernel to provide that function. Instead, you will write characters directly to the VGA text buffer, a special region of memory that the display hardware reads from automatically.
In this step, get ready to:
- Create kernel.c with VGA constants and helper functions.
- Implement a print function that writes characters to the screen.
- Add a colorful welcome message in kernel_main to prove your kernel is alive.
Create kernel.c with the kernel_main function
The VGA text buffer is a region of memory mapped at address 0xB8000. It is 80 columns wide and 25 rows tall. Each character on screen occupies 2 bytes: one for the ASCII character and one for the color attribute.
- Create a new file called kernel.c in your ~/myos directory by running this command:
touch ~/myos/kernel.c
- Open kernel.c in your text editor and add the VGA constants and helper functions by pasting the following code:
#include <stdint.h>
/* VGA text buffer address */
volatile uint16_t* const VGA_BUFFER = (volatile uint16_t*)0xB8000;
/* VGA dimensions */
#define VGA_WIDTH 80
#define VGA_HEIGHT 25
/* VGA colors */
#define VGA_COLOR_BLACK 0
#define VGA_COLOR_BLUE 1
#define VGA_COLOR_GREEN 2
#define VGA_COLOR_CYAN 3
#define VGA_COLOR_RED 4
#define VGA_COLOR_MAGENTA 5
#define VGA_COLOR_BROWN 6
#define VGA_COLOR_LIGHT_GREY 7
#define VGA_COLOR_DARK_GREY 8
#define VGA_COLOR_LIGHT_BLUE 9
#define VGA_COLOR_LIGHT_GREEN 10
#define VGA_COLOR_LIGHT_CYAN 11
#define VGA_COLOR_LIGHT_RED 12
#define VGA_COLOR_LIGHT_MAGENTA 13
#define VGA_COLOR_YELLOW 14
#define VGA_COLOR_WHITE 15
/* Create a VGA color byte from foreground and background */
static inline uint8_t vga_color(uint8_t fg, uint8_t bg) {
return fg | (bg << 4);
}
/* Create a VGA entry (character + color) */
static inline uint16_t vga_entry(unsigned char c, uint8_t color) {
return (uint16_t)c | ((uint16_t)color << 8);
}
/* Current cursor position */
static int cursor_row = 0;
static int cursor_col = 0;
What does this code do?
VGA_BUFFER is a pointer to the VGA text buffer at memory address 0xB8000. Writing to this address puts characters directly on the screen with no OS or driver involved.
The 16 color defines (VGA_COLOR_BLACK through VGA_COLOR_WHITE) represent the standard VGA text-mode palette. Each character can have one of these as its foreground or background color.
The two helper functions combine a character with a color into the 2-byte format the VGA buffer expects. vga_color() packs foreground and background into one byte. vga_entry() combines a character with that color byte into a 16-bit value.
- Save kernel.c.
- You won't see any visible output yet. The next chunk adds a print function that uses these constants to write text to the screen.
Implement a print function
You have the VGA buffer pointer and color helpers. Now you need a function that takes a string and walks through it character by character, writing each one to the correct screen position.
- In kernel.c, add the following print_string function below the cursor_col variable:
/* Print a string at the current cursor position with a given color */
void print_string(const char* str, uint8_t color) {
for (int i = 0; str[i] != '\0'; i++) {
if (str[i] == '\n') {
cursor_row++;
cursor_col = 0;
continue;
}
int index = cursor_row * VGA_WIDTH + cursor_col;
VGA_BUFFER[index] = vga_entry(str[i], color);
cursor_col++;
if (cursor_col >= VGA_WIDTH) {
cursor_col = 0;
cursor_row++;
}
}
}
What does this function do?
print_string loops through each character in the string. For each character, it calculates the buffer position using cursor_row * VGA_WIDTH + cursor_col. This converts a row/column pair into a flat index in the 80-column buffer.
When the function encounters a newline character (\n), it moves to the next row and resets the column to zero. If the column reaches 80, it wraps to the next line automatically.
- Save kernel.c.
- This function won't produce visible output on its own. The next chunk calls it from kernel_main to actually display text.
Add your welcome message
This is the function your boot stub calls. Everything your kernel does starts here. You will print a colorful multi-line message to prove that your code is running on bare metal.
- In kernel.c, add the kernel_main function below print_string:
/* Kernel entry point */
void kernel_main(void) {
/* Print welcome message */
uint8_t color = vga_color(VGA_COLOR_LIGHT_GREEN, VGA_COLOR_BLACK);
print_string("=================================", color);
cursor_row++;
cursor_col = 0;
color = vga_color(VGA_COLOR_WHITE, VGA_COLOR_BLACK);
print_string(" Welcome to MyOS!", color);
cursor_row++;
cursor_col = 0;
color = vga_color(VGA_COLOR_LIGHT_CYAN, VGA_COLOR_BLACK);
print_string(" A kernel built from scratch.", color);
cursor_row++;
cursor_col = 0;
color = vga_color(VGA_COLOR_LIGHT_GREEN, VGA_COLOR_BLACK);
print_string("=================================", color);
cursor_row++;
cursor_col = 0;
cursor_row++;
cursor_col = 0;
color = vga_color(VGA_COLOR_YELLOW, VGA_COLOR_BLACK);
print_string("You are running on bare metal.", color);
cursor_row++;
cursor_col = 0;
color = vga_color(VGA_COLOR_LIGHT_GREY, VGA_COLOR_BLACK);
print_string("No OS beneath you. Just hardware.", color);
}
What does kernel_main do?
Each call to print_string outputs one line of text with a specific color. The vga_color() calls set a foreground color (like VGA_COLOR_LIGHT_GREEN) against a black background.
Between each line, cursor_row++ and cursor_col = 0 move the cursor to the start of the next line. This produces a multi-line banner when the kernel boots.
- Save kernel.c.
- Verify the file compiles without errors by running this command from your ~/myos directory:
i686-elf-gcc -c kernel.c -o kernel.o -std=gnu99 -ffreestanding -O2 -Wall -Wextra
- If the command completes with no output, your code compiled successfully. Confirm the object file was created by running:
ls kernel.o
- You should see kernel.o listed in the output.
Seeing a compile error?
- Check that every opening brace { has a matching closing brace }. Missing braces are the most common typo in C.
- Make sure print_string appears BEFORE kernel_main in the file. C requires functions to be declared before they are called.
- Verify the command i686-elf-gcc is on your PATH. If it is not found, revisit the cross-compiler setup in Step 1.
Still stuck? Help me debug this kernel.c compile error.
Why use -ffreestanding?
The -ffreestanding flag tells GCC that this code runs without an operating system. It disables assumptions about the standard library being available. Only a handful of headers like stdint.h work in freestanding mode because they define types without needing OS support.
✔️ Awesome, I've got everything!
Great. Double check you have saved kernel.c and that it compiled without errors.
ⓧ I'd like to double check the full code
Here is the complete kernel.c file. Make sure yours matches exactly.
#include <stdint.h>
/* VGA text buffer address */
volatile uint16_t* const VGA_BUFFER = (volatile uint16_t*)0xB8000;
/* VGA dimensions */
#define VGA_WIDTH 80
#define VGA_HEIGHT 25
/* VGA colors */
#define VGA_COLOR_BLACK 0
#define VGA_COLOR_BLUE 1
#define VGA_COLOR_GREEN 2
#define VGA_COLOR_CYAN 3
#define VGA_COLOR_RED 4
#define VGA_COLOR_MAGENTA 5
#define VGA_COLOR_BROWN 6
#define VGA_COLOR_LIGHT_GREY 7
#define VGA_COLOR_DARK_GREY 8
#define VGA_COLOR_LIGHT_BLUE 9
#define VGA_COLOR_LIGHT_GREEN 10
#define VGA_COLOR_LIGHT_CYAN 11
#define VGA_COLOR_LIGHT_RED 12
#define VGA_COLOR_LIGHT_MAGENTA 13
#define VGA_COLOR_YELLOW 14
#define VGA_COLOR_WHITE 15
/* Create a VGA color byte from foreground and background */
static inline uint8_t vga_color(uint8_t fg, uint8_t bg) {
return fg | (bg << 4);
}
/* Create a VGA entry (character + color) */
static inline uint16_t vga_entry(unsigned char c, uint8_t color) {
return (uint16_t)c | ((uint16_t)color << 8);
}
/* Current cursor position */
static int cursor_row = 0;
static int cursor_col = 0;
/* Print a string at the current cursor position with a given color */
void print_string(const char* str, uint8_t color) {
for (int i = 0; str[i] != '\0'; i++) {
if (str[i] == '\n') {
cursor_row++;
cursor_col = 0;
continue;
}
int index = cursor_row * VGA_WIDTH + cursor_col;
VGA_BUFFER[index] = vga_entry(str[i], color);
cursor_col++;
if (cursor_col >= VGA_WIDTH) {
cursor_col = 0;
cursor_row++;
}
}
}
/* Kernel entry point */
void kernel_main(void) {
/* Print welcome message */
uint8_t color = vga_color(VGA_COLOR_LIGHT_GREEN, VGA_COLOR_BLACK);
print_string("=================================", color);
cursor_row++;
cursor_col = 0;
color = vga_color(VGA_COLOR_WHITE, VGA_COLOR_BLACK);
print_string(" Welcome to MyOS!", color);
cursor_row++;
cursor_col = 0;
color = vga_color(VGA_COLOR_LIGHT_CYAN, VGA_COLOR_BLACK);
print_string(" A kernel built from scratch.", color);
cursor_row++;
cursor_col = 0;
color = vga_color(VGA_COLOR_LIGHT_GREEN, VGA_COLOR_BLACK);
print_string("=================================", color);
cursor_row++;
cursor_col = 0;
cursor_row++;
cursor_col = 0;
color = vga_color(VGA_COLOR_YELLOW, VGA_COLOR_BLACK);
print_string("You are running on bare metal.", color);
cursor_row++;
cursor_col = 0;
color = vga_color(VGA_COLOR_LIGHT_GREY, VGA_COLOR_BLACK);
print_string("No OS beneath you. Just hardware.", color);
}
Your C kernel is ready. It has everything it needs to print a welcome banner when it boots. Next up, you will write a linker script and Makefile to combine boot.asm and kernel.c into a single bootable binary.
Link and Build the Kernel
You have your assembly boot stub and your C kernel ready. The problem is, they exist as separate source files with no connection between them.
The linker is the tool that stitches your object files together into a single binary. It needs a linker script to know where in memory to place your kernel. You also need a Makefile to automate the full build pipeline so you can go from source to bootable binary in one command.
In this step, get ready to:
- Write a linker script that places your kernel at the 1 MB memory mark.
- Create a Makefile that automates assembling, compiling, and linking.
- Build your kernel into a single bootable binary.
Write the linker script
When a bootloader loads a kernel, it needs the Multiboot header to appear very early in the binary. The linker script tells the linker exactly where to place each section of your code in the final output.
- Create a new file called linker.ld inside your ~/myos folder by running this command:
touch linker.ld
- Open linker.ld in your text editor and paste the following:
ENTRY(_start)
SECTIONS {
. = 1M;
.text BLOCK(4K) : ALIGN(4K) {
*(.multiboot)
*(.text)
}
.rodata BLOCK(4K) : ALIGN(4K) {
*(.rodata)
}
.data BLOCK(4K) : ALIGN(4K) {
*(.data)
}
.bss BLOCK(4K) : ALIGN(4K) {
*(COMMON)
*(.bss)
}
}
What does this linker script do?
- ENTRY(_start) tells the linker that execution begins at the _start symbol you defined in boot.asm.
- . = 1M sets the starting address to 1 megabyte (0x100000). This is the conventional address where bootloaders load kernels.
- The .text section puts .multiboot first so the Multiboot header appears in the first 8 KB of the binary. The bootloader scans this region to recognize your kernel.
- Each section uses BLOCK(4K) : ALIGN(4K) to page-align sections on 4 KB boundaries. This is required for memory protection on x86.
- Save linker.ld.
Not sure linker.ld is saved correctly?
Make sure the file is saved inside ~/myos (not in a subfolder). You can verify by running ls ~/myos/linker.ld and confirming the file appears.
Help me check if my linker.ld is set up correctly.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file.
ⓧ I'd like to double check the full code
ENTRY(_start)
SECTIONS {
. = 1M;
.text BLOCK(4K) : ALIGN(4K) {
*(.multiboot)
*(.text)
}
.rodata BLOCK(4K) : ALIGN(4K) {
*(.rodata)
}
.data BLOCK(4K) : ALIGN(4K) {
*(.data)
}
.bss BLOCK(4K) : ALIGN(4K) {
*(COMMON)
*(.bss)
}
}
Create the Makefile
A Makefile defines build rules so you can compile everything with a single make command. It tracks dependencies between files, so only changed files get recompiled.
- Create a new file called Makefile inside your ~/myos folder by running this command:
touch Makefile
- Open Makefile in your text editor and paste the following:
ASM=nasm
CC=i686-elf-gcc
LD=i686-elf-ld
CFLAGS=-std=gnu99 -ffreestanding -O2 -Wall -Wextra
LDFLAGS=-T linker.ld -nostdlib
all: kernel.bin
boot.o: boot.asm
$(ASM) -f elf32 boot.asm -o boot.o
kernel.o: kernel.c
$(CC) -c kernel.c -o kernel.o $(CFLAGS)
kernel.bin: boot.o kernel.o
$(CC) $(LDFLAGS) -o kernel.bin boot.o kernel.o -lgcc
run: kernel.bin
qemu-system-i386 -kernel kernel.bin
clean:
rm -f *.o kernel.bin
.PHONY: all run clean
What does this Makefile do?
- ASM, CC, and LD set the tool names so you can change them in one place.
- CFLAGS uses -ffreestanding to tell GCC there is no operating system beneath your code. No standard library is available.
- LDFLAGS uses -nostdlib so the linker does not try to link against a C library (there is none on bare metal).
- The boot.o target assembles your assembly into a 32-bit ELF object. The kernel.o target compiles your C into an object file.
- The kernel.bin target links both objects together using i686-elf-gcc as the linker driver, with -lgcc providing compiler runtime support.
- The run target launches QEMU with your kernel so you can boot it in one command.
Makefile indentation matters
Makefiles require real tab characters for indentation, not spaces. If you get a "missing separator" error later, your editor likely converted tabs to spaces. Check your editor settings to ensure tabs are preserved in this file.
- Save Makefile.
- Confirm both new files exist by running:
ls
You should see boot.asm, kernel.c, linker.ld, and Makefile listed in the output.
Missing a file?
Make sure you are in the ~/myos directory. Run cd ~/myos and try ls again. If Makefile or linker.ld is missing, re-create the file using the steps above.
Help me figure out why my files aren't showing up.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file.
ⓧ I'd like to double check the full code
ASM=nasm
CC=i686-elf-gcc
LD=i686-elf-ld
CFLAGS=-std=gnu99 -ffreestanding -O2 -Wall -Wextra
LDFLAGS=-T linker.ld -nostdlib
all: kernel.bin
boot.o: boot.asm
$(ASM) -f elf32 boot.asm -o boot.o
kernel.o: kernel.c
$(CC) -c kernel.c -o kernel.o $(CFLAGS)
kernel.bin: boot.o kernel.o
$(CC) $(LDFLAGS) -o kernel.bin boot.o kernel.o -lgcc
run: kernel.bin
qemu-system-i386 -kernel kernel.bin
clean:
rm -f *.o kernel.bin
.PHONY: all run clean
Build the kernel
Everything is in place. Your assembly, C code, linker script, and Makefile are all ready to produce a bootable binary.
- Build your kernel by running:
make
- You should see output showing each build step completing without errors:
nasm -f elf32 boot.asm -o boot.o
i686-elf-gcc -c kernel.c -o kernel.o -std=gnu99 -ffreestanding -O2 -Wall -Wextra
i686-elf-gcc -T linker.ld -nostdlib -o kernel.bin boot.o kernel.o -lgcc
- Confirm the binary was created by running:
ls kernel.bin
You should see kernel.bin listed. This is your bootable kernel binary.
Seeing an error during make?
- "missing separator" error: Your Makefile has spaces instead of tabs before the commands. Open Makefile and replace the leading spaces on each command line with a single tab character.
- "i686-elf-gcc: command not found": The cross-compiler is not on your PATH. Revisit Step 1 where you added it to your PATH and run source ~/.bashrc to reload.
- "undefined reference to kernel_main": Check that kernel.c has a function named exactly kernel_main (not main) and that boot.asm has extern kernel_main.
Help me debug my kernel build error.
Your kernel is compiled and linked into a single bootable binary. Next up, you will boot it in QEMU and see your OS come to life.
Boot Your OS in QEMU
You have a compiled kernel binary sitting in your project folder. Every byte of it is your code, assembled and linked together into something a processor can execute.
Now comes the moment of truth. You are going to boot your kernel inside QEMU, a hardware emulator that simulates a full x86 computer. When QEMU loads your binary, it goes through the same boot sequence a real computer would. Your Multiboot header gets recognized, your stack gets set up, and your C code takes control of the machine.
In this step, get ready to:
- Launch your kernel in QEMU and see your welcome message on screen.
- Modify the kernel's text and colors, then rebuild and reboot to see changes instantly.
Run your kernel in QEMU
Your Makefile already has a run target that launches QEMU with your kernel binary. This single command simulates powering on a computer that boots directly into your OS.
- Make sure you are in the ~/myos directory inside your Ubuntu terminal, then boot your kernel by running:
make run
What does this command do?
The make run target in your Makefile first ensures kernel.bin is built, then executes qemu-system-i386 -kernel kernel.bin.
The -kernel flag tells QEMU to boot a Multiboot-compliant binary directly, without needing GRUB or a disk image. QEMU acts as both the bootloader and the hardware.
A QEMU window should appear on your Windows desktop showing your colored welcome message on a black background. This window is your virtual computer's screen.
What should I see?
You should see a window titled QEMU with green ================================= borders, white Welcome to MyOS! text, cyan A kernel built from scratch. text, and yellow You are running on bare metal. text below.
This is your code running directly on simulated hardware. There is no Linux, no Windows, and no standard library beneath it.
QEMU window not appearing?
WSL2 uses WSLg to display GUI windows on your Windows desktop. If no window appears, your WSLg may need updating.
- Open PowerShell on Windows (not inside Ubuntu) and run wsl --update followed by wsl --shutdown. Then reopen your Ubuntu terminal and try make run again.
- Make sure you are on Windows 10 Build 19044 or later, or Windows 11. Older versions do not support WSLg.
Help me get the QEMU window to appear in WSL2.
Experiment with changes
One of the best parts of this setup is the instant feedback loop. You can change your kernel's code, rebuild in seconds, and reboot to see the result immediately.
- Close the QEMU window (click the X button or press Ctrl+C in the terminal).
- Open kernel.c in your text editor.
- Find this line inside kernel_main:
print_string(" Welcome to MyOS!", color);
- Change Welcome to MyOS! to any message you like. For example, Hello from my kernel! or your own name.
Now try changing a color. Find this line near the top of kernel_main:
uint8_t color = vga_color(VGA_COLOR_LIGHT_GREEN, VGA_COLOR_BLACK);
- Replace VGA_COLOR_LIGHT_GREEN with VGA_COLOR_LIGHT_RED or VGA_COLOR_YELLOW to change the border color.
- Save kernel.c.
- Rebuild and reboot your kernel by running:
make clean && make && make run
What does this command do?
This runs three Makefile targets in sequence. make clean removes old object files. make reassembles and recompiles everything into a fresh kernel.bin. make run boots the new binary in QEMU.
A new QEMU window appears with your updated message and colors. You just modified an operating system and rebooted it in seconds.
Still seeing the old message?
- Make sure you saved kernel.c before running make. Unsaved changes will not be compiled.
- Check the terminal output for errors. A typo in a color constant name (like VGA_COLOR_LIGHT_GREN instead of VGA_COLOR_LIGHT_GREEN) causes a compile failure.
My kernel is not showing my changes after rebuilding.
All 16 VGA colors you can use
The VGA text mode supports 16 foreground colors, defined as constants in your kernel.c. The first argument to vga_color() is the text color (foreground). The second argument is the background color.
- Colors 0-7: VGA_COLOR_BLACK, VGA_COLOR_BLUE, VGA_COLOR_GREEN, VGA_COLOR_CYAN, VGA_COLOR_RED, VGA_COLOR_MAGENTA, VGA_COLOR_BROWN, VGA_COLOR_LIGHT_GREY.
- Colors 8-15: VGA_COLOR_DARK_GREY, VGA_COLOR_LIGHT_BLUE, VGA_COLOR_LIGHT_GREEN, VGA_COLOR_LIGHT_CYAN, VGA_COLOR_LIGHT_RED, VGA_COLOR_LIGHT_MAGENTA, VGA_COLOR_YELLOW, VGA_COLOR_WHITE.
Celebrate what you built
Take a moment to appreciate what just happened. You wrote x86 assembly that initializes a computer from scratch. You wrote C code that runs with absolutely nothing beneath it. You compiled it all with a cross-compiler, linked it with a custom memory layout, and booted it on simulated hardware.
This is how every operating system begins. Linux, Windows, macOS all started from something remarkably similar to what you just built.
Secret mission
Screen Clear and Color Animation
Your kernel prints a static message. But real operating systems are dynamic. Extend your kernel with a screen-clearing function and a color-cycling animation that proves you can control every pixel on the display.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally inside WSL2, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Project directory with boot.asm, kernel.c, linker.ld, Makefile, and compiled binaries.
- NASM assembler (installed via apt).
- QEMU system emulator (installed via apt).
- i686-elf cross-compiler toolchain (installed to $HOME/opt/cross).
- WSL2 Ubuntu distribution.
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend your kernel soon.
- Your project files, installed packages, and WSL2 environment stay in place.
- You can rebuild and run your kernel any time by opening Ubuntu in WSL and running make run inside your project directory.
Pause - I'll come back to this later
Shut down WSL2 to free up memory. Your files and packages remain intact for next time.
- Open PowerShell on Windows.
- Shut down all running WSL distributions by running:
wsl --shutdown
When you are ready to resume, just open Ubuntu from the Start menu. Everything will be exactly as you left it.
Delete - I don't want to use this again
Remove all project resources and start fresh. This removes your files, installed packages, and the WSL2 distribution.
- Delete your project files. Open Ubuntu and run:
rm -rf ~/Desktop/myos
- Remove the i686-elf cross-compiler toolchain by running:
rm -rf $HOME/opt/cross
- Remove NASM by running:
sudo apt remove nasm -y
- Remove QEMU by running:
sudo apt remove qemu-system -y
- If you no longer need WSL2, you can unregister the Ubuntu distribution entirely. Open PowerShell on Windows and run:
wsl --unregister Ubuntu
This deletes everything inside Ubuntu
Running wsl --unregister Ubuntu removes the entire Ubuntu file system, including any other projects or files you stored inside WSL. Only do this if you have nothing else in Ubuntu you want to keep.
Nice Work!
Nice Work!
Congratulations. You just built an operating system kernel from absolute zero.
You've learned how to:
- Set up a complete bare-metal development toolchain with a cross-compiler, assembler, and emulator.
- Write x86 assembly that boots a computer and hands control to C code using the Multiboot protocol.
- Write C code that directly manipulates VGA video memory to display colored text on screen with no OS, no standard library, and no safety net.
- Secret Mission: Add screen clearing and a color-cycling animation that displays your welcome message in all 16 VGA colors.
Ready to quiz yourself?