Let’s write our own micro kernel, which prints a greeting message to ourselves.

Disclaimer

I’m not a kernel developer. Everything written here must be considered as my baby steps towards finding and sharing a knowledge of an exciting area - OS development.

The plan

We are going to write a micro kernel in ASM, which performs a call to C code, which in turn prints a message on screen. We use qemu to run the ISO image of a compiled kernel. With a help of multiboot grub loader, we will boot a kernel image.

Toolset

To make this happen, we need the following toolset:

  • nasm

  • gcc

  • ld

  • grub2-mkrescue

  • qemu

Boot part

Let’s use multiboot grub loader [1]. However, there are plenty other bootloaders out there [2]. According to multiboot standard implementation, OS image must eventually contain a specific multiboot header structure [3].

Offset Type Field Name Note

0

u32

magic

required

4

u32

architecture

required

8

u32

header_length

required

12

u32

checksum

required

16-XX

tags

required

All five fields are required, so let’s provide them in a file called header.asm:

section .multiboot_header
header_start:
	; magic number
	dd 0xe85250d6 ; multiboot2
	; architecture
	dd 0 ; protected mode i386
	; header_length
	dd header_end - header_start
	; checksum
	dd -(0xe85250d6 + (header_end - header_start))

	; end tag
	dw 0
	dw 0
	dd 8
header_end:

Kernel part

A minimal kernel could be named like kernel.asm with the following contents:

global _start

section .text
_start:
    hlt

All it does, is just simply halting the cpu and performing no work.

Linker part

We also need a linker to glue all artifacts together in a single executable unit. This is an example of a linker script, containing both boot and text sections used in boot and kernel parts:

ENTRY(_start)

SECTIONS {
    . = 2M; /* code starting address */
    .boot : {
        KEEP(*(.multiboot_header))
    }
    .text : {
        *(.text)
    }
}

You Build It - You Run It

Now it’s time to build artifacts and run them. To automate this tedious process, let’s script it via Makefile:

build:
    nasm -f elf64 kernel.asm -o kernel.o && \
    nasm -f elf64 header.asm -o header.o && \
    ld -T linker.ld -o iso/boot/kernel.bin header.o kernel.o && \
    grub2-mkrescue /usr/share/grub2/i386-pc -o iso/boot/kernel.iso iso
clean:
    rm -f *.o ./iso/boot/*.bin ./iso/boot/*.iso

What we do here - we create executable artifacts, link them together into a single kernel.bin executable and pack this kernel into a bootable image kernel.iso. There is a tool grub2-mkrescue to bake all artifacts into an ISO file. Let’s also create a couple of directories so that our file structure looks like the following:

Source code file structure

Finally, run make script in the terminal:

> make clean build

And eventually you should get an iso image:

Writing to 'stdio:iso/boot/kernel.iso' completed successfully.

Let’s run it via qemu emulator:

> qemu-system-x86_64 -cdrom iso/boot/kernel.iso

Grub boot loader starts…​

First run. Grub loader just started.

…​and nothing else matters happens. That’s expected. We have to manually boot our kernel:

Booting the kernel manually using grub

After we hit boot command, again - black screen. Surely, because we are just halting the CPU in our kernel. Now we are going to improve a couple of things.

Add a GRUB config

In order not to manually boot the kernel each time, we can script these actions in a grub.cfg

set timeout=0
set default=0

menuentry "my bare minimum micro kernel" {
	multiboot2 /boot/kernel.bin
	boot
}

…​and store it in the following path:

File structure after adding a grub config

After building and running it again we see that the kernel is being loaded automagically:

Booting the kernel using multiboot

However, again it does no useful work. What a waste of electricity! Time to fix it.

Transfer of control to C code

What we do next is we try to use high level programming language to print a greeting message. Not something sophisticated. Something similar to:

Courtesy: Brian Kernighan and Wiki

Oh wait, we are not in Kansas anymore in a user space yet. It could have worked as if we were to run the code above from a user space. However we are in a protected mode as stated in header.asm. We don’t have printf available for us. But we have VGA text mode buffer [5] - and we print directly in there. A function to print some chars could look like this:

#include <stdio.h>

#define videoAddress 0xB8000

void print_chars(char* str);

void main()
{
    print_chars("Hello world!");
}

void print_chars(char* str)
{
    unsigned char *video = (unsigned char *)videoAddress;
    int j = 0;
    for (size_t i = 1; 1; i+=2) {
        char character = str[j++];
        if (character == '\0') {
            return;
        }
        video[i] = character;
    }
}

There are also changes to be done to perform a call to main function from kernel.asm:

global _start

extern main

section .text
_start:
    call    main
    hlt

And there are changes in Makefile to compile C code:

build:
    nasm -f elf64 kernel.asm -o kernel.o && \
    nasm -f elf64 header.asm -o header.o && \
    gcc -c hello.c -ffreestanding -o hello.o && \
    ld -T linker.ld -o iso/boot/kernel.bin header.o kernel.o hello.o && \
    grub2-mkrescue /usr/share/grub2/i386-pc -o iso/boot/kernel.iso iso
clean:
    rm -f *.o ./iso/boot/*.bin ./iso/boot/*.iso

And this results in:

Printing Hello World

Wonderful, but now it prints over previously written text. To clean up this mess a bit, we introduce a new function, which writes empty chars all over the available screen:

#include <stdio.h>

#define videoAddress 0xB8000

const int LINE_LENGTH = 80;
const int LINE_ROWS = 25;
const int COLOR_BLACK = 0x00;
const int COLOR_WHITE = 0x0F;

void clear_screen();
void print_chars(char* str);

void main()
{
    clear_screen();
    print_chars("Hello world!");
}

void print_chars(char* str)
{
    unsigned char *video = (unsigned char *)videoAddress;
    int j = 0;
    for (size_t i = 1; 1; i+=2) {
        char character = str[j++];
        if (character == '\0') {
            return;
        }
        video[i] = character;
    }
}

void clear_screen()
{
    volatile unsigned char *video = (unsigned char *)videoAddress;
    int i = 0;
    while(i < LINE_ROWS * LINE_LENGTH * 2) {
        video[i] = ' ';
        video[i+1] = COLOR_BLACK;
        i += 2;
    }
}

Which results in:

Printing Hello World

Conclusion

I tried to keep the code written there as concise as possible - to understand what’s the bare minimum we need to have for a viable OS kernel. As stated in the disclaimer - I’m not a kernel developer. If you see what can be improved there or written in a more accurate way, please don’t hesitate to state this in the comments or just DM me.

Code samples are available over on GitHub

References