Boot Your Own OS Kernel from Scratch

Write a bootable x86 OS kernel in C and assembly that runs in QEMU.

Introduction

30 Second Summary

Every time you press the power button on your computer, a chain of events unfolds before anything appears on screen. Your operating system takes over from the hardware, but what actually happens in that gap between power-on and your first line of code running?

In this project, you will write a minimal operating system kernel from scratch in C and assembly, boot it inside an emulator, and watch it print colored text directly to the screen. No libraries. No OS underneath. Just your code and the hardware.

What You'll Build

A 32-bit x86 operating system kernel that boots via GRUB using the Multiboot protocol and writes directly to VGA video memory to display colored text inside the QEMU emulator.

By the end of this project, you'll have:

  • A boot.asm file that sets up the Multiboot header and stack, then hands control to your C code.
  • A kernel.c VGA terminal driver that writes colored text directly to video memory at address 0xB8000.
  • A complete build pipeline using Docker and Make that assembles, compiles, links, and boots your OS in one command.
  • Secret Mission: Add keyboard input handling by programming the CPU's interrupt descriptor table and reading scan codes from the hardware.

Are there any prerequisites?

You should have basic C or C++ knowledge (pointers, structs, functions). Assembly experience is helpful but not required. This project teaches you every line of assembly you need.

Before We Start

Before We Start...

Set Up Your OS Development Toolchain

Building an operating system from scratch requires tools that go beyond a standard development setup. You need an assembler to write low-level boot code, an emulator to test your kernel safely, and a cross-compiler that targets bare metal hardware instead of your host OS.

In this step, you will install NASM (an x86 assembler), QEMU (a hardware emulator), and build a Docker image containing the i686-elf cross-compiler.

In this step, get ready to:
  • Install NASM and QEMU in WSL.
  • Set up the cross-compiler Docker image.
  • Create the project file structure.
Install NASM and QEMU in WSL

NASM is the assembler that translates your boot code into machine instructions. QEMU is the emulator that pretends to be a real x86 PC so you can test your kernel without rebooting your machine.

  • Press the Windows key to open your search bar.
  • Type Ubuntu and press Enter to open your WSL terminal.
  • Install NASM and QEMU by running this command:
sudo apt update && sudo apt install -y nasm qemu-system-x86

What does this command do?

This is two commands joined by &&.

  • sudo apt update refreshes the list of available packages so your system knows what is available to install.
  • sudo apt install -y nasm qemu-system-x86 installs both tools in one go. The -y flag auto-confirms the installation so you don't have to type "yes" manually.
  • Verify NASM installed correctly by running:
nasm --version
  • Verify QEMU installed correctly by running:
qemu-system-x86_64 --version

Both commands should print a version number.

✔️ I see version numbers for both

Your assembler and emulator are ready. On to the cross-compiler.

ⓧ Command not found

The install may not have completed successfully. Try running the install command again:

sudo apt install -y nasm qemu-system-x86

Watch the output for any error messages about unmet dependencies or network issues. If you see errors about being unable to locate the package, run sudo apt update first and try again.

help me troubleshoot NASM or QEMU installation in WSL

Set up the cross-compiler Docker image

Your regular system compiler (GCC on Linux) builds programs for Linux. A cross-compiler builds programs for a different target. Here you need one that targets bare x86 hardware with no operating system underneath.

Building a cross-compiler from source takes 30-60 minutes and many dependencies. Instead, you will use a pre-built Docker image that already contains an i686-elf GCC cross-compiler.

  • Create your project directory and move into it by running these commands:
mkdir ~/myos && cd ~/myos

Why ~/myos?

The ~ means your WSL home directory. This gives your OS project a dedicated folder at a predictable location you can always return to with cd ~/myos.

  • Create a file called Dockerfile by running:
touch Dockerfile
  • Open your project in VS Code by running:
code .

What does code . do?

The code . command opens VS Code connected to your current WSL directory. You can edit files with a full GUI while they live inside your Linux environment.

  • In VS Code, open Dockerfile from the Explorer sidebar and paste in the following code:
FROM techiekeith/gcc-cross-i686-elf

RUN apt-get update && \
    apt-get upgrade -y && \
    apt-get install -y build-essential nasm xorriso grub-pc-bin grub-common && \
    apt-get clean

VOLUME /root/env
WORKDIR /root/env

What does this Dockerfile do?

  • FROM techiekeith/gcc-cross-i686-elf starts from a base image that already has GCC 13.2 built for the i686-elf target.
  • The RUN block installs additional tools you need: nasm for assembling, xorriso and grub-pc-bin for creating bootable ISO images.
  • VOLUME /root/env and WORKDIR /root/env set up a mount point so your local source files are accessible inside the container.
  • Save Dockerfile by pressing Ctrl+S.
  • Back in your WSL terminal, build the Docker image by running:
docker build -t myos-buildenv .

What does this command do?

This tells Docker to read your Dockerfile and build an image named myos-buildenv. The image will contain everything you need to compile your kernel. This may take a few minutes on first run as Docker downloads the base image (~622 MB).

  • Verify the cross-compiler works by running:
docker run --rm myos-buildenv i686-elf-gcc --version

You should see GCC version information mentioning i686-elf as the target.

✔️ I see GCC version info

Your cross-compilation toolchain is working. The Docker image has everything you need to compile code for bare metal x86.

ⓧ Docker error or command not found

  • If you see a "Cannot connect to the Docker daemon" error, make sure Docker Desktop is running on Windows.
  • If the build failed, check that your Dockerfile content matches the code above exactly.
  • Try running docker build -t myos-buildenv . again. Network timeouts sometimes resolve on retry.

help me troubleshoot building the cross-compiler Docker image

✔️ Awesome, I've got everything!

Great. Make sure you have saved your Dockerfile.

ⓧ I'd like to double check the full code

FROM techiekeith/gcc-cross-i686-elf

RUN apt-get update && \
    apt-get upgrade -y && \
    apt-get install -y build-essential nasm xorriso grub-pc-bin grub-common && \
    apt-get clean

VOLUME /root/env
WORKDIR /root/env
Create the project structure

Your OS project needs four source files. You will fill them in over the next steps, but creating them now gives you a clear picture of the project layout.

  • Create the empty project files by running:
touch boot.asm kernel.c linker.ld Makefile

What are these files for?

  • boot.asm will contain the assembly bootstrap that starts your kernel.
  • kernel.c will contain your C kernel code that prints to the screen.
  • linker.ld will tell the linker where to place each section in memory.
  • Makefile will automate the entire build and run process.
  • Confirm the files exist by running:
ls

You should see boot.asm, Dockerfile, kernel.c, linker.ld, and Makefile in the output.

Missing a file?

Make sure you are in the ~/myos directory. Run pwd to check your current location. If you are somewhere else, run cd ~/myos first.

help me find my project files in WSL

Your toolchain is locked and loaded. Next up, you will write the assembly bootstrap that tells GRUB how to find and load your kernel.

Write the Bootstrap Assembly

When a computer powers on, it has no idea where your code lives. A bootloader like GRUB scans your kernel binary looking for a specific magic signature to confirm it's safe to hand over control.

In this step, you'll write the assembly code that tells GRUB "yes, I'm a valid kernel" and sets up the minimal environment your C code needs to execute.

In this step, get ready to:
  • Define the Multiboot header so GRUB can find your kernel.
  • Set up a stack and entry point for your C code.
  • Assemble the file into a 32-bit ELF object.
Write the Multiboot header in boot.asm

GRUB scans the first 8 KiB of your kernel binary looking for the magic number 0x1BADB002. If it finds this number followed by valid flags and a matching checksum, GRUB knows your binary is a legitimate Multiboot kernel.

  • Open boot.asm in VS Code and add the following code:
; Multiboot header constants
MBALIGN  equ 1 << 0            ; align loaded modules on page boundaries
MEMINFO  equ 1 << 1            ; provide memory map
FLAGS    equ MBALIGN | MEMINFO ; Multiboot flag field
MAGIC    equ 0x1BADB002        ; magic number for bootloader to find us
CHECKSUM equ -(MAGIC + FLAGS)  ; checksum to prove we are multiboot

; Multiboot header - must be in first 8 KiB, aligned to 32-bit boundary
section .multiboot
align 4
    dd MAGIC
    dd FLAGS
    dd CHECKSUM

What does this code do?

  • MAGIC equ 0x1BADB002 defines the Multiboot signature that GRUB searches for in the first 8 KiB.
  • FLAGS combines two feature requests: align loaded modules on page boundaries and provide a memory map.
  • CHECKSUM must equal zero when added to MAGIC and FLAGS. This proves the header was not corrupted.
  • section .multiboot places the header in its own section so the linker can position it within the first 8 KiB of the final binary.
  • dd writes each value as a 32-bit (double word) value into the binary.

You won't see a change yet. The next chunk adds the entry point that uses this header.

Set up the stack and call kernel_main

Before any C code can run, the CPU needs a stack for function calls and local variables. The x86 stack grows downward in memory, so you reserve space and point the stack pointer at the top.

  • Below the Multiboot header in boot.asm, add the following code:
; Stack - 16 KiB, 16-byte aligned
section .bss
align 16
stack_bottom:
    resb 16384 ; 16 KiB
stack_top:

; Entry point
section .text
global _start:function (_start.end - _start)
_start:
    ; Set up the stack
    mov esp, stack_top

    ; Call the C kernel
    extern kernel_main
    call kernel_main

    ; If kernel_main returns, hang the CPU
    cli
.hang:
    hlt
    jmp .hang
.end:

What's happening here?

  • section .bss reserves 16 KiB of uninitialized memory for the stack. Since x86 stacks grow downward, esp starts at stack_top and grows toward stack_bottom.
  • _start is the entry point GRUB jumps to after loading your kernel into memory.
  • mov esp, stack_top gives the CPU a valid stack before calling any C functions.
  • call kernel_main hands off execution to your C code. This is where your kernel's logic begins.
  • The cli / hlt / jmp .hang loop is a safety net. If kernel_main ever returns, the CPU disables interrupts and halts permanently instead of executing random memory.

✔️ Awesome, I've got everything!

Great. Double check you've saved boot.asm before moving on.

ⓧ I'd like to double check the full code

Here's the complete boot.asm file. Make sure yours matches exactly:

; Multiboot header constants
MBALIGN  equ 1 << 0            ; align loaded modules on page boundaries
MEMINFO  equ 1 << 1            ; provide memory map
FLAGS    equ MBALIGN | MEMINFO ; Multiboot flag field
MAGIC    equ 0x1BADB002        ; magic number for bootloader to find us
CHECKSUM equ -(MAGIC + FLAGS)  ; checksum to prove we are multiboot

; Multiboot header - must be in first 8 KiB, aligned to 32-bit boundary
section .multiboot
align 4
    dd MAGIC
    dd FLAGS
    dd CHECKSUM

; Stack - 16 KiB, 16-byte aligned
section .bss
align 16
stack_bottom:
    resb 16384 ; 16 KiB
stack_top:

; Entry point
section .text
global _start:function (_start.end - _start)
_start:
    ; Set up the stack
    mov esp, stack_top

    ; Call the C kernel
    extern kernel_main
    call kernel_main

    ; If kernel_main returns, hang the CPU
    cli
.hang:
    hlt
    jmp .hang
.end:
  • Save boot.asm.
  • Assemble the file into a 32-bit ELF object by running this command:
docker run --rm -v "$(pwd)":/root/env myos-buildenv nasm -felf32 boot.asm -o boot.o

What does this command do?

This runs NASM inside your Docker build environment. The -felf32 flag tells NASM to output a 32-bit ELF object file. The -v "$(pwd)":/root/env flag mounts your current directory into the container so NASM can read your source file and write the output back to your host.

Seeing an error from NASM?

  • Check for typos in the constant definitions. The magic number is 0x1BADB002 (with a B, not an 8).
  • Make sure section .multiboot comes before section .bss in the file.
  • If Docker reports the image is not found, run docker images to confirm myos-buildenv is listed.

Help me debug my NASM assembly error

Verify the object file was created
  • Confirm the file exists by running:
ls -la boot.o

You should see boot.o listed with a file size of a few hundred bytes. The Multiboot header is now embedded in the binary, ready for GRUB to find.

What is boot.o?

boot.o is a 32-bit ELF object file. It contains your assembled machine code and the Multiboot header, but it's not yet a complete kernel. In a later step, you'll link it together with your C code to produce the final bootable binary.

Your Multiboot header is embedded and ready for GRUB to find. Next up, you'll write the VGA terminal driver in C that actually puts text on the screen.

Build the VGA Terminal Driver in C

In Step 2, you wrote the assembly bootstrap that sets up the stack and calls kernel_main. Now it's time to write what kernel_main actually does.

Your kernel will write directly to VGA video memory at address 0xB8000 to display colored text on screen. There is no printf, no standard library, and no operating system underneath. You are the operating system.

In this step, get ready to:
  • Define VGA color constants and helper functions for text-mode video memory.
  • Implement a terminal driver that handles character output, newlines, and wrapping.
  • Write kernel_main to print colored boot messages and compile the kernel object file.
Define VGA constants and color enum

In VGA text mode, the video hardware maps a block of memory starting at physical address 0xB8000 directly to the screen. The screen is 80 columns by 25 rows.

Each character cell is 2 bytes: one byte for the ASCII character, and one byte for the color attribute (foreground in the low 4 bits, background in the high 4 bits).

  • Open kernel.c in VS Code and add the following code to define includes, compiler safety checks, the VGA color enum, helper functions, and global state:
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

/* Ensure we are using a cross-compiler */
#if defined(__linux__)
#error "You are not using a cross-compiler"
#endif

#if !defined(__i386__)
#error "This needs to be compiled with an ix86-elf compiler"
#endif

/* VGA 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;
}

static const size_t VGA_WIDTH = 80;
static const size_t VGA_HEIGHT = 25;

size_t terminal_row;
size_t terminal_column;
uint8_t terminal_color;
uint16_t* terminal_buffer;

What does this code do?

The #if defined(__linux__) check ensures you accidentally don't compile with your system's regular GCC. Your kernel code must be compiled with the i686-elf cross-compiler inside Docker.

The vga_color enum defines all 16 CGA colors. The vga_entry_color function packs a foreground and background color into one byte. The vga_entry function packs a character and its color into the 16-bit format VGA hardware expects.

We implement our own strlen because there is no standard library available in a freestanding kernel. The global variables track cursor position, current color, and the pointer to VGA memory at 0xB8000.

You won't see a change yet. The terminal functions in the next section use these definitions.

Why do we need our own strlen?

In normal C programs, strlen comes from the C standard library (libc). But your kernel IS the operating system. There is no libc loaded beneath you. Every function you need, you write yourself.

Implement terminal functions

Now you'll build the functions that actually write characters to the screen. These functions manipulate the VGA buffer pointer directly.

  • In kernel.c, add the following terminal functions directly below the global variable declarations:
void terminal_initialize(void) {
    terminal_row = 0;
    terminal_column = 0;
    terminal_color = vga_entry_color(VGA_COLOR_LIGHT_GREY, VGA_COLOR_BLACK);
    terminal_buffer = (uint16_t*) 0xB8000;
    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) {
    if (c == '\n') {
        terminal_column = 0;
        if (++terminal_row >= VGA_HEIGHT)
            terminal_row = 0;
    } else {
        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));
}

What does this code do?

  • terminal_initialize clears the entire screen by writing spaces to all 80x25 cells. It points terminal_buffer at VGA memory address 0xB8000.
  • terminal_putentryat writes a single character at a specific (x, y) position by calculating the buffer index.
  • terminal_putchar handles newlines by resetting the column to 0 and advancing the row. It also wraps to the next row when a line exceeds 80 columns.
  • terminal_writestring is the high-level function you'll call from kernel_main. It iterates through a string one character at a time.

You won't see a change yet. The next section wires everything together in kernel_main and compiles the file.

How does writing to 0xB8000 put text on screen?

In text mode, the VGA controller continuously reads from the buffer at 0xB8000 and renders the characters on the display. When your code stores a value at that memory address, the hardware picks it up on the next refresh cycle. No system call needed.

Write kernel_main and compile

This is the function your assembly bootstrap calls. It initializes the terminal and prints colored text to prove everything works.

  • In kernel.c, add the kernel_main function below terminal_writestring:
void kernel_main(void) {
    terminal_initialize();

    terminal_writestring("MyOS kernel booted successfully!\n");

    terminal_setcolor(vga_entry_color(VGA_COLOR_LIGHT_GREEN, VGA_COLOR_BLACK));
    terminal_writestring("Welcome to your own operating system.\n");

    terminal_setcolor(vga_entry_color(VGA_COLOR_LIGHT_CYAN, VGA_COLOR_BLACK));
    terminal_writestring("You wrote this from scratch. No standard library. No OS underneath.\n");
}

What does this code do?

kernel_main first clears the screen with terminal_initialize. Then it prints three lines, each with a different color. The first line uses the default light grey. The second switches to light green. The third uses light cyan.

Each call to terminal_setcolor changes the color for all subsequent characters until the next color change. This proves your terminal driver supports multiple colors in one session.

  • Save kernel.c.
  • Compile your kernel by running this command in your WSL terminal:
docker run --rm -v "$(pwd)":/root/env myos-buildenv i686-elf-gcc -c kernel.c -o kernel.o -std=gnu99 -ffreestanding -O2 -Wall -Wextra
  • Confirm the object file was created by running:
ls -la kernel.o
  • You should see kernel.o listed with a file size greater than 0 bytes. This means your C code compiled without errors.

What do these compiler flags mean?

  • -c compiles to an object file without linking. You'll link in the next step.
  • -ffreestanding tells GCC there is no standard library or OS. Your code stands alone.
  • -std=gnu99 selects the C99 standard with GNU extensions.
  • -Wall -Wextra enables extra warnings so you catch mistakes early.

Seeing a compile error?

  • If you see "You are not using a cross-compiler", make sure you are running the compile command through Docker (not your system's GCC). The Docker container provides the i686-elf-gcc cross-compiler.
  • If you see syntax errors, check that each opening brace has a matching closing brace and that the enum entries are comma-separated.
  • If Docker reports the image is not found, confirm you built it in Step 1 by running docker images and looking for myos-buildenv.

Why won't my kernel.c compile with the cross-compiler?

✔️ Awesome, I've got everything!

Great. Double check you've saved kernel.c and that kernel.o compiled without warnings.

ⓧ I'd like to double check the full code

Here is the complete kernel.c file. Make sure yours matches exactly.

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

/* Ensure we are using a cross-compiler */
#if defined(__linux__)
#error "You are not using a cross-compiler"
#endif

#if !defined(__i386__)
#error "This needs to be compiled with an ix86-elf compiler"
#endif

/* VGA 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;
}

static const size_t VGA_WIDTH = 80;
static const size_t VGA_HEIGHT = 25;

size_t terminal_row;
size_t terminal_column;
uint8_t terminal_color;
uint16_t* terminal_buffer;

void terminal_initialize(void) {
    terminal_row = 0;
    terminal_column = 0;
    terminal_color = vga_entry_color(VGA_COLOR_LIGHT_GREY, VGA_COLOR_BLACK);
    terminal_buffer = (uint16_t*) 0xB8000;
    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) {
    if (c == '\n') {
        terminal_column = 0;
        if (++terminal_row >= VGA_HEIGHT)
            terminal_row = 0;
    } else {
        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) {
    terminal_initialize();

    terminal_writestring("MyOS kernel booted successfully!\n");

    terminal_setcolor(vga_entry_color(VGA_COLOR_LIGHT_GREEN, VGA_COLOR_BLACK));
    terminal_writestring("Welcome to your own operating system.\n");

    terminal_setcolor(vga_entry_color(VGA_COLOR_LIGHT_CYAN, VGA_COLOR_BLACK));
    terminal_writestring("You wrote this from scratch. No standard library. No OS underneath.\n");
}

Your kernel code compiles cleanly. You now have both boot.o (the assembly bootstrap) and kernel.o (the C terminal driver). Next up, you'll link them together with a linker script and boot the result in QEMU.

Link and Boot Your Kernel

You have a compiled assembly object file and a compiled C object file. Right now they exist as separate pieces with no idea how to find each other in memory.

In this step, you will write a linker script that arranges your kernel's sections at the correct memory addresses, link everything into a single binary, wrap it in a bootable ISO image, and watch your OS boot for the first time in QEMU.

In this step, get ready to:
  • Write a linker script that places your kernel at the correct memory address.
  • Link your object files into a single kernel binary.
  • Create a bootable ISO and launch it in QEMU.
Write the linker script

The linker needs a script telling it where to place each section of your kernel in memory. Without this, GRUB cannot find the Multiboot header, and the CPU will not know where your code starts.

  • Open linker.ld in VS Code (it should be empty) and paste the following:
/* Entry point defined in boot.asm */
ENTRY(_start)

SECTIONS
{
    /* Load kernel at 1 MiB - conventional address for x86 kernels */
    . = 1M;

    /* Multiboot header must be first, then executable code */
    .text BLOCK(4K) : ALIGN(4K)
    {
        *(.multiboot)
        *(.text)
    }

    /* Read-only data */
    .rodata BLOCK(4K) : ALIGN(4K)
    {
        *(.rodata)
    }

    /* Initialized read-write data */
    .data BLOCK(4K) : ALIGN(4K)
    {
        *(.data)
    }

    /* Uninitialized data and stack */
    .bss BLOCK(4K) : ALIGN(4K)
    {
        *(COMMON)
        *(.bss)
    }
}

What does this linker script do?

ENTRY(_start) tells the linker that _start (defined in boot.asm) is the first instruction to execute.

. = 1M; sets the load address to 1 MiB. This is the conventional starting address for x86 kernels because the memory below 1 MiB is reserved for BIOS, VGA, and other hardware.

The .text section places .multiboot first so GRUB finds the magic header within the first 8 KiB of the binary. Each section uses BLOCK(4K) : ALIGN(4K) to align on 4 KiB page boundaries, which is required for memory protection later.

  • Save linker.ld.

Not sure the file saved correctly?

Confirm the file starts with ENTRY(_start) and ends with a closing }. Make sure there are no extra characters or blank lines before the first comment.

Help me debug my linker script

✔️ Awesome, I've got everything!

Great. Make sure you have saved linker.ld before moving on.

ⓧ I'd like to double check the full code

/* Entry point defined in boot.asm */
ENTRY(_start)

SECTIONS
{
    /* Load kernel at 1 MiB - conventional address for x86 kernels */
    . = 1M;

    /* Multiboot header must be first, then executable code */
    .text BLOCK(4K) : ALIGN(4K)
    {
        *(.multiboot)
        *(.text)
    }

    /* Read-only data */
    .rodata BLOCK(4K) : ALIGN(4K)
    {
        *(.rodata)
    }

    /* Initialized read-write data */
    .data BLOCK(4K) : ALIGN(4K)
    {
        *(.data)
    }

    /* Uninitialized data and stack */
    .bss BLOCK(4K) : ALIGN(4K)
    {
        *(COMMON)
        *(.bss)
    }
}
Link the kernel and create a bootable ISO

With the linker script ready, you can combine boot.o and kernel.o into a single kernel binary. Then you will wrap that binary in an ISO image that GRUB can boot from.

  • Link the two object files into a kernel binary by running this command:
docker run --rm -v "$(pwd)":/root/env myos-buildenv i686-elf-gcc -T linker.ld -o myos.bin -ffreestanding -O2 -nostdlib boot.o kernel.o -lgcc

What does this command do?

-T linker.ld tells GCC to use your custom linker script instead of the system default. -nostdlib prevents linking against the host C library (which does not exist for your freestanding kernel).

-lgcc links against GCC's internal support library, which provides low-level routines your compiler-generated code may call (like 64-bit division on a 32-bit CPU).

  • Confirm the kernel binary was created by running:
ls -la myos.bin

You should see myos.bin listed with a file size of a few kilobytes.

Seeing undefined reference errors?

If the linker says kernel_main is undefined, check that kernel.o exists in your directory. Run ls *.o to verify both boot.o and kernel.o are present.

If you see an error about the Multiboot header not being found, make sure your linker.ld places *(.multiboot) before *(.text) inside the .text section.

Help me fix my kernel linking errors

Now you need to create the directory structure that grub-mkrescue expects for building a bootable ISO.

  • Set up the ISO directory structure and GRUB config by running these commands:
mkdir -p iso/boot/grub
cp myos.bin iso/boot/myos.bin
echo 'menuentry "MyOS" { multiboot /boot/myos.bin }' > iso/boot/grub/grub.cfg

What is grub.cfg?

The grub.cfg file tells GRUB how to load your kernel. The multiboot directive instructs GRUB to load the file at /boot/myos.bin using the Multiboot protocol, which sets up the CPU state your _start code expects.

  • Confirm the directory structure was created by running:
ls iso/boot/grub/

You should see grub.cfg in the output.

  • Generate the bootable ISO image by running:
docker run --rm -v "$(pwd)":/root/env myos-buildenv grub-mkrescue -o myos.iso iso/

What does grub-mkrescue do?

grub-mkrescue packages your kernel binary and GRUB bootloader together into a standard ISO image. This ISO acts like a virtual CD-ROM that QEMU can boot from, just like a physical computer boots from a disc.

  • Verify the ISO was created by running:
ls -la myos.iso

You should see myos.iso with a file size of several megabytes (GRUB adds its own files to the image).

ISO file not created?

Make sure Docker Desktop is running. If you see a permission error, check that your myos.bin was copied into iso/boot/ correctly by running ls iso/boot/myos.bin.

Help me troubleshoot grub-mkrescue

Boot in QEMU and verify

This is the moment of truth. Your kernel exists as a bootable ISO image. Time to power on a virtual machine and see if your code runs.

  • Launch your kernel in QEMU by running:
qemu-system-i386 -cdrom myos.iso

A QEMU window opens. You should see GRUB briefly, then your kernel's boot message appear in colored text on a black screen.

What should I see?

After GRUB loads, your screen should display three lines of text:

  • "MyOS kernel booted successfully!" in light grey.
  • "Welcome to your own operating system." in light green.
  • "You wrote this from scratch. No standard library. No OS underneath." in light cyan.

These colors come from the terminal_setcolor calls you wrote in kernel.c. Your code is writing directly to VGA video memory at 0xB8000 with no operating system underneath.

Seeing a blank screen or reboot loop?

A blank screen usually means the Multiboot header is not in the first 8 KiB of the binary. Check that your linker.ld places *(.multiboot) before *(.text) inside the .text output section.

A reboot loop means the CPU crashed. Verify that boot.asm sets esp to stack_top before calling kernel_main.

If WSLg is not available and no window appears, try adding -curses to the QEMU command to render in the terminal instead.

Help me debug my kernel boot issue

Your operating system boots. You wrote every byte of code between the power button and that text on screen. Next up, you will automate this entire build pipeline into a single make run command.

Automate with a Makefile

Your kernel boots, prints colored text, and you built every piece by hand. But right now, each change to your code means re-typing four long Docker commands just to see the result.

A Makefile lets you define build rules once and replay the entire pipeline with a single command. In this step, you will write a Makefile that assembles, compiles, links, creates the ISO, and boots QEMU in one shot.

In this step, get ready to:
  • Write a Makefile with targets for every build stage.
  • Run the full pipeline with a single command.
  • Experiment with VGA colors and messages to see instant rebuilds.
Write the Makefile

A Makefile declares targets (the files you want to produce), dependencies (the files each target needs), and recipes (the commands that produce them). When you run make, it checks what changed and only rebuilds what is necessary.

  • Open the empty Makefile in VS Code and paste the following code:
DOCKER_RUN = docker run --rm -v "$(shell pwd)":/root/env myos-buildenv

all: myos.iso run

boot.o: boot.asm
	$(DOCKER_RUN) nasm -felf32 boot.asm -o boot.o

kernel.o: kernel.c
	$(DOCKER_RUN) i686-elf-gcc -c kernel.c -o kernel.o -std=gnu99 -ffreestanding -O2 -Wall -Wextra

myos.bin: boot.o kernel.o linker.ld
	$(DOCKER_RUN) i686-elf-gcc -T linker.ld -o myos.bin -ffreestanding -O2 -nostdlib boot.o kernel.o -lgcc

myos.iso: myos.bin
	mkdir -p iso/boot/grub
	cp myos.bin iso/boot/myos.bin
	echo 'menuentry "MyOS" { multiboot /boot/myos.bin }' > iso/boot/grub/grub.cfg
	$(DOCKER_RUN) grub-mkrescue -o myos.iso iso/

run: myos.iso
	qemu-system-i386 -cdrom myos.iso

clean:
	rm -f boot.o kernel.o myos.bin myos.iso
	rm -rf iso/

What does this Makefile do?

The DOCKER_RUN variable stores the Docker prefix so you do not repeat it in every target.

  • boot.o: assembles boot.asm into a 32-bit ELF object file.
  • kernel.o: compiles kernel.c with the freestanding cross-compiler.
  • myos.bin: links both object files using linker.ld into the kernel binary.
  • myos.iso: creates the GRUB directory structure and generates a bootable ISO.
  • run: boots the ISO in QEMU.
  • clean: removes all generated files so you can rebuild from scratch.
  • all: the default target that builds the ISO and then runs it.

Tabs are mandatory in Makefiles

Every recipe line (the commands under a target) must start with a tab character, not spaces. If you see a missing separator error, your editor likely converted tabs to spaces. In VS Code, click Spaces: 4 in the bottom status bar and switch to Indent Using Tabs for this file.

  • Save Makefile.

Seeing a missing separator error?

The most common cause is spaces instead of tabs before recipe lines. Open Makefile and make sure each command under a target starts with a real tab character.

Also confirm you have no extra blank lines between a target line and its recipe.

Help me fix my Makefile syntax error.

✔️ Awesome, I've got everything!

Great. Make sure you have saved your Makefile before continuing.

ⓧ I'd like to double check the full code

DOCKER_RUN = docker run --rm -v "$(shell pwd)":/root/env myos-buildenv

all: myos.iso run

boot.o: boot.asm
	$(DOCKER_RUN) nasm -felf32 boot.asm -o boot.o

kernel.o: kernel.c
	$(DOCKER_RUN) i686-elf-gcc -c kernel.c -o kernel.o -std=gnu99 -ffreestanding -O2 -Wall -Wextra

myos.bin: boot.o kernel.o linker.ld
	$(DOCKER_RUN) i686-elf-gcc -T linker.ld -o myos.bin -ffreestanding -O2 -nostdlib boot.o kernel.o -lgcc

myos.iso: myos.bin
	mkdir -p iso/boot/grub
	cp myos.bin iso/boot/myos.bin
	echo 'menuentry "MyOS" { multiboot /boot/myos.bin }' > iso/boot/grub/grub.cfg
	$(DOCKER_RUN) grub-mkrescue -o myos.iso iso/

run: myos.iso
	qemu-system-i386 -cdrom myos.iso

clean:
	rm -f boot.o kernel.o myos.bin myos.iso
	rm -rf iso/
Test the full build pipeline

Your previous manual build left boot.o, kernel.o, myos.bin, and myos.iso in the directory. Start fresh to prove the Makefile rebuilds everything from scratch.

  • Clean all generated files and run the full pipeline by running this command:
make clean && make run

What does this command do?

make clean removes all .o files, myos.bin, myos.iso, and the iso/ directory. Then make run triggers the dependency chain: it needs myos.iso, which needs myos.bin, which needs boot.o and kernel.o. Make builds everything in the correct order automatically.

  • Watch the terminal output as each target builds in sequence.
  • A QEMU window should open showing your colored boot message on a black screen.
  • Close the QEMU window when you are done verifying.

QEMU window not appearing?

If you see a Could not initialize SDL or display error, WSLg may not be running. Make sure you are on Windows 11 with WSL2 and try restarting WSL with wsl --shutdown in PowerShell, then reopen your WSL terminal.

If the kernel does not boot (blank screen or reboot loop), check that make clean ran successfully before make run. Stale .o files from manual builds can cause linking errors.

Help me debug my make run output.

Experiment with VGA colors

Now that rebuilding takes one command, you can iterate quickly. Try changing your kernel's output to see how the VGA color system works.

  • In kernel.c, find the kernel_main function at the bottom of the file.
  • Find this line:
terminal_setcolor(vga_entry_color(VGA_COLOR_LIGHT_GREEN, VGA_COLOR_BLACK));
  • Try changing VGA_COLOR_LIGHT_GREEN to VGA_COLOR_RED or VGA_COLOR_LIGHT_MAGENTA, so the line looks like this:
terminal_setcolor(vga_entry_color(VGA_COLOR_RED, VGA_COLOR_BLACK));
  • Save kernel.c and run the build again:
make run

The second line of your boot message now appears in the new color. Try other combinations from the vga_color enum. You can also change the background color (the second argument) to see light text on colored backgrounds.

Why does make only rebuild kernel.o?

Make checks timestamps on files. Since only kernel.c changed, it only recompiles kernel.o, re-links myos.bin, regenerates the ISO, and boots. The assembly step is skipped entirely because boot.asm did not change. This is the core value of a build system.

  • Try printing a long string that exceeds 80 characters to see how the terminal wraps to the next row.
  • Try printing more than 25 lines to see how the terminal wraps back to the top of the screen.
  • When you are done experimenting, set VGA_COLOR_LIGHT_GREEN back as the foreground color for the second line so your code matches the original.
  • Save kernel.c.

Secret mission

Handle Keyboard Input

Your kernel can print text, but it cannot respond to anything you type. In this secret mission, you will program the CPU's Interrupt Descriptor Table and handle real hardware interrupts from the keyboard controller. By the end, you will type on your keyboard and see characters appear on screen in your own OS.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources for future kernel hacking, free up disk space, or remove everything entirely.

Resources you used:

  • Docker image myos-buildenv (~600 MB stored locally).
  • Project folder ~/myos (source files, build artifacts, ISO image).
  • NASM and QEMU packages installed in WSL via apt.

Keep everything running

No action needed. Choose this if you plan to keep experimenting with your kernel or want to rebuild quickly later.

  • The myos-buildenv Docker image stays cached so future builds start instantly.
  • Your source files in ~/myos are plain text and take negligible disk space.
  • NASM and QEMU remain available for any future assembly or emulation work.

Pause - I'll come back to this later

Remove the Docker image to reclaim ~600 MB of disk space, but keep your source files so you can rebuild later.

  • Remove the Docker image by running:
docker rmi myos-buildenv
  • Your source files in ~/myos stay untouched.
  • When you come back, rebuild the image with docker build -t myos-buildenv . from inside your project folder.

Delete - I don't want to use this again

Remove all project resources and reclaim disk space completely.

  • Remove the Docker image by running:
docker rmi myos-buildenv
  • Remove the project folder and all build artifacts by running:
rm -rf ~/myos
  • Optionally, remove NASM and QEMU from WSL by running:
sudo apt remove -y nasm qemu-system-x86
  • After running these commands, confirm the Docker image is gone by running:
docker images | grep myos-buildenv

You should see no output, confirming the image has been removed.

That's a Wrap!

That's a Wrap!

Nice work. You just wrote a bootable operating system kernel from scratch in C and assembly, then watched it boot in an emulator and print colored text to the screen.

You've learned how to:

  • Write a bootable x86 kernel from scratch using the Multiboot protocol, including magic numbers, headers, and GRUB handoff.
  • Write directly to VGA video memory at address 0xB8000 with zero OS support, rendering colored text on screen using a freestanding C terminal driver.
  • Build a complete cross-compilation toolchain using Docker and a one-command Makefile pipeline that assembles, compiles, links, creates a bootable ISO, and runs it in QEMU.
  • Secret Mission: handle hardware interrupts by programming the IDT and PIC, then read PS/2 keyboard scan codes to display typed characters on screen.

Ready to quiz yourself?