/x86-kernel

A minimal x86 kernel for educational purposes :)

Primary LanguageAssembly

A minimal x86 kernel

Writing a minimal x86 kernel in (intel) assembler and rust, with https://os.phil-opp.com/multiboot-kernel/. See https://github.com/phil-opp/blog_os for the original repo.

Table of Contents

Requirements

  • nasm for assembling
  • ld for linking
  • make
  • grub to create a bootable iso
  • xorriso (libisoburn package) to create a bootable iso
  • qemu to run our kernel in a VM

Usage

Command Description
make Build the kernel
make iso Create a bootable iso image
make run Boot our kernel in a VM
make clean Clean the working directory from build artifacts

Notes

The multiboot header

Most bootloaders are compatible with the Multiboot Specification. This means that if our kernel implements the multiboot specification, most generic bootloaders will be able to boot our kernel.

To indicate that our kernel supports Multiboot 2, our kernel must start with the following Multiboot Header:

Field Type Value
magic number u32 0xE85250D6
architecture u32 0 for i386
header length u32 total header size, including tags
checksum u32 -(magic + architecture + header_length)
tags variable
end tag (u16, u16, u32) (0, 0, 9)

Putting it into Assembly

multiboot_header.asm:

section .multiboot_header
; tag the start and end of the header, so we can determine the size of the
; header.
header_start:
  dd 0xe85250d6                 ; magic number
  dd 0                          ; protected mode i386
  dd header_end - header_start  ; header length
  dd 0x100000000 - (0xe85250d6 + 0 + (header_end - header_start)) ; checksum

  ; insert optional tags here

  ; end tag
  dw 0 ; type
  dw 0 ; flags
  dd 8 ; size
header_end:

Some executable code

To keep it simple, we will just write OK to the screen for now. We can do this by simply writing the bytes we want to display the the VGA buffer, at offset 0xb8000. We will do this in 32 bits assembly, since the CPU is still in Protected Mode when the bootloader starts our kernel.

boot.asm:

; We will call `start` from outside (e.g. from the bootloader), so we will
; export it
global start

section .text
bits 32
start:
  ; print  `OK` to the screen
  mov dword [0xb8000], 0x2f4b2f4f
  hlt

Creating an executable

First, we assemble our boot.asm and multiboot_header.asm in ELF64 format:

nasm -f elf64 -o multiboot_header.o multiboot_header.asm
nasm -f elf64 -o boot.o boot.asm

Next, we'll need to link our two binaries (boot.o and multibooot_header.o) together to create a single binary. Because we need some control over what endsup where, we'll use a custom linker script (linker.ld):

/* the entrypoint is the 'start' label from our boot.asm, this is where
 * execution of our kernel will begin */
ENTRY(start)

SECTIONS {
  /* set the load address of the first section to 1MiB, which is a common
   * place to load a kernel. Lower addresses can be used for special
   * purposes, like the VGA buffer */
  . = 1M;

  .boot :
  {
    /* ensure that the multiboot header is at the beginning */
    *(.multiboot_header)
  }

  .text :
  {
    /* the text section will just include the text section from our boot.asm */
    *(.text)
  }
}

In addition to using our custom linker script, we'll also need to tell the linker to not try to our sections (.e.g. the .boot section) to page boundaries, or grub might be unable to find it. We do this by passing -n to the linker.

The final command to link everything together becomes:

  ld -n -o kernel.bin -T linker.ld boot.o multiboot_header.o

We should now have a bootable kernel in kernel.bin!

Creating a bootable ISO

Grub comes with a utility, grub-mkrescue, that makes it easy to create a bootable iso image. Under the hood, grub-mkrescue uses xorriso to actually create the ISO 9660 filesystem, so you'll need to have that installed too. (And alternative, if you're feeling adventurous, you could use xorriso directly to create your ISO image).

Creating a bootable grub ISO is as easy as creating a directory tree with a grug.cfg and a kernel, and passing it to grub-mkrescue:

ISO_ROOT=./iso_root
KERNEL=./kernel.bin

# Create the boot and boot/grub directories
mkdir -p "${ISO_ROOT}/boot/grub"

# Create grub.cfg
cat <<EOF > "${ISO_ROOT}/boot/grub/grub.cfg"
set timeout=0
set default=0

menuentry "minimal kernel" {
  multiboot2 /boot/$(basename ${KERNEL})
  boot
}
EOF

# Copy our kernel
cp "$KERNEL" "${ISO_ROOT}/boot/$(basename ${KERNEL})"

# Make a bootable iso from our directory
grub-mkrescue -o kernel.iso "$ISO_ROOT"

We should now have a bootable ISO that will print OK to the screen when booted!

Booting our kernel

You can easily test your kernel by booting from our ISO in a Virtual Machine:

qemu-system-x86_64 -cdrom kernel.iso

Putting it all together in a Makefile

Since we don't want to run all these nasm, ld, etc commands every time we change something, we will create a Makefile to do these things for us. Basically, we tell Make what the dependencies between files are (e.g. boot.o depends on boot.asm and kernel.iso depends on kernel.bin), and how to create each file. When we invoke Make, Make will check what files have changed, and so will know what files need to be re-created because their dependencies changed.

Instead of writing out all dependencies by hand, we'll make use of some Make features like wildcards that I will not explain here, but there are plenty of tutorials that explain these in more depth.

MAKEFLAGS += --no-builtin-rules
ASSEMBLY_FILES=$(wildcard *.asm)
ASSEMBLY_BINS=$(ASSEMBLY_FILES:.asm=.o)
KERNEL_BIN=kernel.bin
LINK_SCRIPT=linker.ld
ISO_DIR=iso
ISO_NAME=kernel.iso

# The file ('target') kernel.bin depends boot.o and multiboot_header.o, and
# can be created by running the linker command below. This should be TAB
# indented, or Make will barf) # $@ is expended to the target (i.e. 'kernel.bin')
$(KERNEL_BIN): $(ASSEMBLY_BINS)
	ld -n -o $@ -T $(LINK_SCRIPT) $(ASSEMBLY_BINS)

# Files ending in `.o` depend on files ending in `.asm`
%.o: %.asm
	nasm -f elf64 $< -o $(<:.asm=.o)

$(ISO_DIR)/boot/grub/grub.cfg:
	mkdir -p $(ISO_DIR)/boot/grub
	echo "$$GRUBCFG" > $@

$(ISO_DIR)/boot/$(KERNEL_BIN): $(KERNEL_BIN)
	cp $(KERNEL_BIN) $@

kernel.iso: $(ISO_DIR)/boot/grub/grub.cfg $(ISO_DIR)/boot/$(KERNEL_BIN)
	grub-mkrescue -o $@ $(ISO_DIR)

iso: $(ISO_NAME)

run: $(ISO_NAME)
	qemu-system-x86_64 -cdrom $(ISO_NAME)

clean:
	rm -rf $(ASSEMBLY_BINS) $(KERNEL_BIN) $(ISO_DIR) $(ISO_NAME)

# Tell make 'clean', 'iso' and 'run' aren't actually files
.PHONY: clean iso run

define GRUBCFG
set timeout=0
set default=0

menuentry "minimal kernel" {
        multiboot2 /boot/$(KERNEL_BIN)
        boot
}
endef
export GRUBCFG

You can now invoke Make with a particular target, .e.g. make run, and Make will automatically know what needs to be done.