mittos64/doc/2_A_Bootable_Kernel.md

11 KiB

Chapter 2 - Booting a Kernel

In this chapter we'll create a minimal kernel that can be loaded by a Multiboot compatible bootloader.

Makefile

Gnu Make is a powerful tool. I don't think most people realize just how powerful it is. It's really ridiculously smart, if you trust it.

As an example, I'm currently going through the Build Your Own Text Editor booklet. Here's my Makefile for the project:

CFLAGS := -Wall -Wextra -pedantic -std=c99

kilo:

And Make takes care of the rest. Of course, the makefile for a kernel will be a bit more complex, but we'll trust Make as far as possible.

Just for fun, you can run make -p to see all the built-in rules of Make, some of which we will make use of, such as $(LINK.C) or $(COMPILE.S)

But let's get into it. First we create a directory for our sourc files, and the kernel sources specifically. In it, we place a Makefile:

src/kernel/Makefile

ifeq ($(MITTOS64),)
$(error Unsupported environment! See README)
endif

CC := x86_64-elf-gcc

SRC := $(wildcard **/*.[cS])
OBJ := $(patsubst %, %.o, $(basename $(SRC)))

CFLAGS := -Wall -Wextra -pedantic -ffreestanding
CFLAGS += -ggdb -O0
ASFLAGS += -ggdb
CPPFLAGS += -I include
LDFLAGS := -n -nostdlib -lgcc -T Link.ld

kernel: $(OBJ)
	$(LINK.c) $^ -o $@


DEP := $(OBJ:.o=.d)
DEPFLAGS = -MT $@ -MMD -MP -MF $*.d
$(OBJ): CPPFLAGS += $(DEPFLAGS)
%.d: ;

DESTDIR ?= $(BUILDROOT)sysroot

# Copy kernel to sysroot
$(DESTDIR)$(PREFIX)/kernel: kernel
	install -D kernel $(DESTDIR)$(PREFIX)/kernel

install: $(DESTDIR)$(PREFIX)/kernel

clean:
	rm -rf $(OBJ) $(DEP) kernel

.PHONY: install

include $(DEP)

I think there are a few things here that needs explanation.

First of all, there's the environment check

ifeq ($(MITTOS64),)
$(error Unsupported environment! See README)
endif

This is just as discussed in the previous chapter, to make sure the makefile is only run inside the Docker container.

Then we set CC := x86_64-elf-gcc to make sure the kernel is build using our cross compiler. If you look through the built in Make rules, you'll see that CC is used for compiling .c files, compiling .cpp files, compiling .S files and linking it all together.

The next three lines

SRC := $(wildcard **/*.[cS])
OBJ := $(patsubst %, %.o, $(basename $(SRC)))

scan the source directory and all subdirectories for .c or .S files, and find the names of the corresponding object files.

Then we set some compiler and linker options

CFLAGS := -Wall -Wextra -pedantic -ffreestanding
CFLAGS += -ggdb -O0
ASFLAGS += -ggdb
CPPFLAGS += -I include
LDFLAGS := -n -nostdlib -lgcc -T Link.ld
  • We want to compile with -Wall -Wextra and -pedantic to help keep the code quality high.
  • -ffreestanding stops the compiler from assuming that there's a standard c library present.
  • -ggdb and -O0 simplifies debugging by including gdb specific debug symbols in the compiled object code and turning off all optimizations
  • and we also want -ggdb for assembly files. -The c preprocessor should look for include files in src/kernel/include, that's what the -I include option does.
  • And finally, there's the linker options. -nostdlib because we don't want any libraries but libgcc to be linked with our kernel. -n tells the linker that we don't necessarily want the sections page aligned. -T Link.ld tells the linker that the file src/kernel/Link.ld contains more information about how we want the file structured.

The next two lines:

kernel: $(OBJ)
	$(LINK.c) $^ -o $@

tells make that the file kernel depends on all our objects and how to build it. Here we're using the built in $(LINK.c) rule.

And that's really all we need to build the kernel. 15 lines. The rest of the file is just for convenience.

Like the lines:

DEP := $(OBJ:.o=.d)
DEPFLAGS = -MT $@ -MMD -MP -MF $*.d
$(OBJ): CPPFLAGS += $(DEPFLAGS)
%.d: ;
[...]
include $(DEP)

which deserve some explanation.

If you update a source file and run make, it will know which files were updated and need to be rebuilt. But what about included header files? If they update, you'd want to rebuild all files that include them.

The book Managing Projects with GNU Make by Robert Meclenburg suggests a solution that uses the -M option of gcc to generate a list of dependencies from a source file, coupled with a mess of temporary files and sed commands to generate make rules that you can import. I've seen this method used in lots of projects.

I am sorry to say I can't remember where I picked this up - I certainly didn't come up with it myself - but it turns out that through the use of a bunch of more gcc options, the rules can be generated exactly as we want them. -MT $@ for example changes the name of the generated rule to the input file - which is what Meclenburg does with sed 's,\($*\)\.o[ :]*,\1.o $@ : ,g' < $@.$$$ > $@;.

I just think this is a very neat solution.

The part of the Makefile I skipped is for "installing" the kernel, i.e. copying it to the sysroot directory and for cleaning up.

Bonus

For convenience, I added a makefile to the project root directory which contains the following rule

kernel:
ifeq ($(shell make -sqC src/kernel || echo 1), 1)
	$(MAKE) -C src/kernel install
endif

Linking

As described above, the linker gets passed a linker script. This is as basic as possible for now.

src/kernel/Link.ld

ENTRY(_start)

SECTIONS
{
  .text :
  {
    *(.multiboot)
    *(.text)
  }
}

What's important here is that the *(.multiboot) statement is first inside the .text section. The multiboot headers (described in the next section) need to be near the beginning of the kernel executable, or the bootloader won't be able to find them. As the kernel grows, this will ensure they can still be found.

Note that you can't do this:

SECTIONS
{
  .multiboot : {*(.multiboot)}
  .text : {*(.text)}
}

Oh, how I wish this worked. But for some reason, the linker will put the .text section first in the output file, no matter what.

The linker script will soon grow, but for now, this is enough.

Multiboot Headers

There are currently two actively used versions of the multiboot specification, "Multiboot 1" (latest version is 0.6.96) and "Multiboot 2" (latest version is 1.6). Since Multiboot 2 has better support for 64 bit kernels, that's what I'll use.

In order for the bootloader to be able to recognize a multiboot kernel, there has to be a special header near the start of the executable. Via our linker file trick above, we can place the header at the very start (actually after the program headers, but that's OK) by making sure it's in the .multiboot section.

src/kernel/boot/multiboot_header.S

#include <multiboot.h>
.section .multiboot

.align 0x8
Multiboot2Header:
.long MBOOT2_MAGIC
.long MBOOT2_ARCH
.long MBOOT2_LENGTH
.long MBOOT2_CHECKSUM

.short 0
.short 0
.long 8
Multiboot2HeaderEnd:

The Multiboot 2 header is followed by a list of tags to specify further options. We only have one; the list termination tag.

The constants above are defined in multiboot.h.

src/kernel/include/multiboot.h

#pragma once

#define MBOOT2_MAGIC 0xE85250D6
#define MBOOT2_REPLY 0x36D76289
#define MBOOT2_ARCH 0
#define MBOOT2_LENGTH (Multiboot2HeaderEnd - Multiboot2Header)
#define MBOOT2_CHECKSUM -(MBOOT2_MAGIC + MBOOT2_ARCH + MBOOT2_LENGTH)

This is just for the simplest possible multiboot headers. No frills or extra functions.

For more information, see Multiboot 1 specification or Multiboot 2 specification.

The kernel code

Ladies and gentlemen, I present to you: The Simplest Kernel

src/kernel/boot/boot.S

.intel_syntax noprefix
.section .text
.global _start
.code32
_start:
cli
jmp $

That's all you need to make sure the booting procedure works as it should.

Testing it out

Let's go.

# compile
$ d make
make -C src/kernel install
make[1]: Entering directory '/opt/src/kernel'
cc -ggdb -I include -MT boot/multiboot_header.o -MMD -MP -MF boot/multiboot_header.d  -c -o boot/multiboot_header.o boot/multiboot_header.S
cc -ggdb -I include -MT boot/boot.o -MMD -MP -MF boot/boot.d  -c -o boot/boot.o boot/boot.S
cc -Wall -Wextra -pedantic -ffreestanding -ggdb -O0 -I include -n -nostdlib -lgcc -T Link.ld  boot/multiboot_header.o boot/boot.o -o kernel
mkdir -p /opt/sysroot
cp kernel /opt/sysroot/kernel
make[1]: Leaving directory '/opt/src/kernel'

Then run the emulator as before with d emul and also start the debugger in another terminal window with d gdb.

Now you can load the kernel debug symbols into gdb with the command

(gdb) file sysroot/kernel
A program is being debugged already
Are you sure you want to change the file? (y or n) y
Reading symbols from sysroot/kernel...done.

A small problem at this point is that GRUB will start the kernel in 32 bit protected mode, while gdb assumes we're in 64 bit mode. You can see this by running

(gdb) show architecture
The target architecture is set automatically (currently i386:x86-64)

So, to get the addresses and stuff correct, you can run

(gdb) set architecture i386
The target architecture is assumed to be i386

And finally, we're ready to start the emulator

(gdb) c
Continuing.

This will make the familliar grub> prompt show up in the emulator.

It's time to load our kernel

grub> multiboot2 /kernel
grub> boot

And... nothing should happen, that you can see...

But switch back to gdb and press ctrl+C to interrupt the emulator and you should see

Program received signal SIGINT, Interrupt.
_start () at boot/boot.S:9
9      jmp $
(gdb)

So, apparently, the processor is running our "kernel" and is stuck at the infinite loop on line 9. Just as we expected!

The multiboot specification says that the bootloader should leave a magic number in the EAX register after boot. Let's check it.

(gdb) p/x $eax
$1 = 0x36d76289

Exactly according to the specification. Great!

And with that everything seems to work!

Some extra fancy stuff

You really don't want to keep typing all those grub and gdb instructions in manually each time you test your OS, right?

The gdb commands can be added to toolchain/gdbinit. In the name of cinsistency, the debug symbols should not be loaded from sysroot/kernel, but from /opt/sysroot/kernel, or rather yet ${BUILDROOT}sysroot/kernel. Using environment variables in a gdb script is a bit tricky, but can be done via the python function:

python
import os
gdb.execute('file ' + os.environ['BUILDROOT'] + 'sysroot/kernel')
end

In order to make grub load the kernel automatically, you need to add a file called grub.cfg at /boot/grub in the sysroot. I suggest you add this file to sysroot while building the iso in toolchain/mkiso.

I did it this way

cat > ${sysroot}/boot/grub/grub.cfg << EOF
set timeout=1
set default=0

menuentry "mittos64" {
  multiboot2 /kernel
}

EOF

See the grub 0.97 manual for more information.