A Simple Kernel

A Simple Kernel

Robbert Haarman

2010-12-11


Introduction

The kernel is the central part of the operating system. It provides the functions that the other parts build on. In this issue, we will develop a simple kernel with just enough functionality to run a little example program.


Monolithic Kernels vs. Microkernels

Kernels are typically divided in two categories: monolithic kernels and microkernels. A monolithic kernel contains all the code that is necessary to run the system in a single image. By contrast, a microkernel provides only some hooks for other modules or programs to connect to; relying on those external objects to provide the necessary functionality. The distinction is a bit blurry, as even monolithic kernels often support separately loadable modules. The fundamental criterion is whether the kernel is self-sufficient, or needs external objects to work at all.

The kernel developed here is a monolithic one, as it does not require any external code to work (save for the loader that puts it into memory). In fact, it doesn't even support loadable modules at all. It only provides a simple console driver, which can read and write characters and lines.


A Simple Kernel

Although the BIOS provides the functionality we will be putting in this kernel (the BIOS basically provides a complete operating system), we will not be using the BIOS from now on. There are two reasons for this. First, it gives a better understanding of what actually goes on at the hardware level. Secondly, the BIOShas its limitations, and we will have to bypass it to overcome those limitations. Rather than using the BIOS now and rewrite the code later, we avoid the BIOS altogether.

I will not provide a complete analysis of the code. It should be clear what it does from the comments. However, there are two things that need further explanation: the stack, and function calls.

The Stack

We've already used the stack in the bootsector, but without going into details about how it works. We must do so now, as the stack plays an important role in understanding how this kernel works.

The stack works just like a stack of plates. You can put plates on the stack, and take plates off. Analoguously, we can push things on the program's stack, and pop items off. For both stacks, the item that was added last is removed first.

The x86 implements the stack using two registers: ss (Stack Segment) and sp (Stack Pointer). The stack pointer initially points to the top of the stack, and every push decrements it by the size of the datum being pushed, whereas every pop increments it by the size of the item being popped. In other words, the stack grows downwards.

When sp becomes zero, the stack is full, and any push executed afterwards will cause it to wrap around; it will point to the top of the stack again, and the value that was stored there will be overwritten. This is called a stack overflow, and must be avoided at all cost.

Function Calls

Function calls are tightly coupled to the stack. The call instruction pushes the location of the next instruction on the stack, then jumps to the specified location. The ret instruction pops the last value off the stack and jumps to it. If this is the same value that was pushed by call, the program will continue just after the call instruction. However, if the value was modified on the stack, or sp has a different value, ret will jump to a different address, which will likely result in madness and chaos.

Functions wouldn't be very useful if they didn't take parameters. There are various ways to pass parameters to functions. One way, which we used to pass parameters to the read sectors BIOS call in the bootsector, is to pass them in registers. The advantage of this is that registers are fast (they operate at the clock speed of the CPU, whereas other parts of the machine can be orders of magnitude slower). The disadvantage is that the x86 has very few of them, and they all have special purposes that may not fit how we want to use them. Thus, all kinds of tricks are necessary to move the right values to the right registers at the right time, which leads to messy and illegible code.

Another way to pass parameters is to store them at known locations in memory. We simply define a number of variables with the functions, and set them to the right values before calling the function. This makes the code very easy to read. However, this method has one major problem: the functions will not be re-entrant. When one instance of a function is active, calling it another time overwrites the values stored in the variables, which will distort the operation of the first function call. For very simple systems, this is not a problem, but for recursive functions (functions that call themselves, either directly or indirectly) and functions that are to be used by multiple processes (think multi-tasking), it is.

One final method, and the method chosen here, is to use the stack for passing parameters. We simply push the values on the stack, and the function reads them from there. As long as we have enough stack space, functions are re-entrant, staying out of one another's ways because they all use a different stack frame.

To access the parameters on the stack, we use a little bit of magic. We could simply use pop, but recall that call uses the stack for storing the return address. So, the first value we would pop is that return address, which we then need to get back on the stack before we return. Also, the parameters would then have disappeared from the stack, but we might want to use them later. So, we choose a different method, using the knowledge that sp points to the last item on the stack, being the return address. Since the parameters were pushed before the return address, they come at higher offsets in the stack segment, starting at sp + 2 (as the return address is 16 bits or two bytes).

We might want to use the stack inside our function for saving values. Pushing these would change the value of sp, and thus the parameters would no longer be where we expect them. To avoid this, we put the value of sp in bp, after which we can freely use the stack inside our function. However, we are not there yet! What if we call a function inside ours, and it overwites bp with a new value? Then we can't find our parameters anymore! So, we first save the value on bp on the stack, then overwrite it with the value of sp, after which the parameters are found at bp + 2.

Note that the parameters to the function call are pushed in reverse order. This is done so that the location of the first argument is always known. Imagine a function that takes a variable number of arguments, with the first one indicating somehow the number of arguments. This does not help much if we don't know which one is the first one. Pushing the first argument last esures that we know its location. Another detail is that, after the function call, we increment the stack pointer by a certain value. Recall that the function itself does not pop its parameters from the stack. So, after the return, they are still there. Incrementing the stack pointer is a shortcut for popping each argument off the stack.

The conventions for function calls used here are not necessarily ideal, but they match the way C works on UNIX systems. This makes it easier to integrate C programs with your operating system, in case you wish to do so.

Compiling and Testing

After you have compiled the minimal boot loader from the previous section and written it to the disk image, you can compile and install the kernel as follows:


nasm simple_kernel.asm -o simple_kernel.bin
dd if=simple_kernel.bin of=bootdisk.bin conv=notrunc bs=512 seek=1

The kernel will be written to the disk image right after the bootsector, which is where the bootloader expects it to be. You can then test the new system with the emulator. Of course, you could also use a physical diskette and computer - it's up to you.

Next part: Keyboard Support

Valid XHTML 1.1! Valid CSS! Viewable with Any Browser