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 insrc/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 butlibgcc
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 filesrc/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.