User:Zesterer/Bare Bones

From OSDev.wiki
Revision as of 04:33, 29 March 2017 by Zesterer (talk | contribs)
Jump to navigation Jump to search
Difficulty level

Beginner
Kernel Designs
Models
Other Concepts

This is an introductory tutorial for kernel development on the x86 architecture. It is a minimal example only, and does not show you how to structure a serious project. However, completing this project is a good first step for aspiring kernel developers.

By the end of this tutorial, you will have the following:

  • A simple kernel written in C and x86 Assembly that is capable of displaying a message
  • An ISO disk image containing your kernel that can be run from an emulator or on real hardware.
  • A rudimentary understanding of the x86 and x86 assembly.

What This Is Not

  • A 'make your own operating system' tutorial. Developing a complete operating system requires years of work and, in most cases, decades of experience.
  • A one-size-fits-all introduction. There are many languages, architectures and approaches to operating system development. However, this tends to be a common route taken by beginners.

Requirements

Before proceeding, make sure you have the basics ready.

Required Knowledge

It is important to make sure that you have enough basic knowledge. Kernel development has an extremely steep learning curve.

  • A basic understanding of the C programming language. You should be comfortable with functions, pointers, casting and flow control.
  • A basic understanding of computer architecture. You should know the difference between ROM and RAM, you should know what CPU registers are, and what machine code & executables are.
  • A basic understanding of computer logic and mathematics. You should understand hexadecimal & binary, as well as bitwise operators and the purpose of a stack.
  • Some basic command-line skills. You should understand how to run simple commands, navigate your filesystem and manipulate files.

Required Tools

You will also require some software tools to develop this project.

  • A UNIX-like operating system that supports operating system development well such as Linux or a UNIX-like environment (MinGW or Cygwin if you are using Windows).
  • A text editor of some sort, preferably with syntax highlighting (it will make your life easier). I recommend the cross-platform Atom editor.
  • A copy of QEMU for the i386 architecture. This is only necessary if you want to test your kernel without real hardware.

Some Background Knowledge

First, a little background information.

We will be developing our kernel for the x86 architecture. The x86 is a family of computer architectures first introduced in the 1970s. Most modern PCs are backwards-compatible with x86 however, so we should have no problem running our finished kernel on real hardware.

The x86 is a CISC (Complex Instruction Set Computer) architecture. This means that it has a large instruction set that we can manipulate in order to execute programs.

We will be compiling our kernel with our current operating system, with the intention of it running 'free-standing' (i.e: independent of any other OS). For this reason, we will be using a 'cross-compiler' to create our final executable. Cross-compilation is the first stage of writing a new operating system. After a huge amount of work, it is possible to make an operating system 'self-hosting'. This means that it is capable of compiling itself rather than relying on an existing OS. However, that is a long way down the road. For the foreseeable future, you will be cross-compiling your operating system.

We'll be writing the kernel in x86 assembly and the C programming language. When the x86 first loads up our kernel, it won't yet be in a fit state to run C code. This is why we must use assembly to first set up a basic C environment. Once this is done, we can write (most) of the rest of the kernel in C.

The x86 is a complex architecture with various different CPU states and modes. To avoid having to deal with them right now, we'll be using the GRUB bootloader to load our kernel into memory and set up a stable 32-bit 'protected-mode' environment.

To test our operating system, we'll be running the kernel in QEMU. QEMU is an emulator that will allow us to test our operating system without rebooting real hardware to test every change we make.

Building A Cross-Compiler

Main article: GCC Cross-Compiler, Why do I need a Cross-Compiler?

TODO

The Code

Please remember, this is a minimal setup. A more advanced kernel project will have a more complex code structure, as well as an automated build system.

For now, we'll be using 3 files. They are:

start.s     - This file will contain our x86 Assembly code that starts our kernel and sets up the x86
kernel.c    - This file will contain the majority of our kernel, written in C
linker.ld   - This file will give the compiler information about how it should construct our kernel executable and link the previous files together

start.s

First, we create 'start.s'. To best understand the code, I recommend typing it out by hand such that you can understand each part in detail.

// We declare the 'kernel_main' label as being external to this file.
// That's because it's the name of the main C function in 'kernel.c'.
.extern kernel_main

// We declare the 'start' label as global (accessible from outside this file), since the linker will need to know where it is.
// In a bit, we'll actually take a look at the code that defines this label.
.global start

// Our bootloader, GRUB, needs to know some basic information about our kernel before it can boot it.
// We give GRUB this information using a standard known as 'Multiboot 2'.
// To define a valid 'Multiboot header' that will be recognised by GRUB, we need to hard code some
// constants into the executable. The following code calculates those constants.
.set MB_MAGIC, 0xE85250D6          // This is a 'magic' constant that GRUB will use to detect our kernel's location.
.set MB_ARCH,  0                   // '0' is the code for the x86's 'protected' 32-bit mode. We'll be using this mode.
.set MB_SIZE,  (mb_end - mb_start) // This is the size of the Multiboot 2 header
// Finally, we calculate a checksum that includes all the previous values
.set MB_CHECKSUM, (0 - (MB_MAGIC + MB_ARCH + MB_SIZE))

// We now start the section of the executable that will contain our Multiboot 2 header
.section .multiboot
	.align 4 // Make sure the following data is aligned on a multiple of 4 bytes
	mb_start: // The start of the header
		// Use the previously calculated constants in executable code
		.long MB_MAGIC
		.long MB_ARCH
		.long MB_SIZE
		// Use the checksum we calculated earlier
		.long MB_CHECKSUM
	mb_end: // The end of the header

// This section contains data initialised to zeroes when the kernel is loaded
.section .bss
	// Our C code will need a stack to run. Here, we allocate 1024 bytes (or 1 Kilobyte) for our stack.
	// We can expand this later if we want a larger stack. For now, it will be perfectly adequate.
	.align 16
	stack_bottom:
		.skip 1024 // Reserve a 1024-byte (1K) stack
	stack_top:

// This section contains our actual assembly code to be run when our kernel loads
.section .text
	// Here is the 'start' label we mentioned before. This is the first code that gets run in our kernel.
	start:
		// First thing's first: we want to set up an environment that's ready to run C code.
		// C is very relaxed in its requirements: All we need to do is to set up the stack.
		// Please note that on x86, the stack grows DOWNWARD. This is why we start at the top.
		mov $stack_top, %esp // Set the stack pointer to the top of the stack

		// Now we have a C-worthy (haha!) environment ready to run the rest of our kernel.
		// At this point, we can call our main C function.
		call kernel_main

		// If, by some mysterious circumstances, the kernel's C code ever returns, all we want to do is to hang the CPU
		hang:
			cli      // Disable CPU interrupts
			hlt      // Halt the CPU
			jmp hang // If that didn't work, loop around and try again.

kernel.c

'kernel.c' contains our main kernel code. Specifically, it contains code for displaying text on the screen using the VGA textmode buffer.

// GCC provides these header files automatically
// They give us access to useful things like fixed-width types
#include <stddef.h>
#include <stdint.h>

// First, let's do some basic checks to make sure we are using our x86-elf cross-compiler correctly
#if defined(__linux__)
	#error "This code must be compiled with a cross-compiler"
#elif !defined(__i386__)
	#error "This code must be compiled with an x86-elf compiler"
#endif

// This is the x86's VGA textmode buffer. To display text, we write data to this memory location
volatile uint16_t vga_buffer = (uint16_t*)0xB8000;
// By default, the VGA textmode buffer has a size of 80x25 characters
const int VGA_COLS = 80;
const int VGA_ROWS = 25;

// We start displaying text in the top-left of the screen (column = 0, row = 0)
int term_col = 0;
int term_row = 0;
uint8_t term_color = 0x0F; // Black background, White foreground

// This function initiates the terminal by clearing it
void term_init()
{
	// Clear the textmode buffer
	for (int col = 0; col < VGA_COLS; col ++)
	{
		for (int row = 0; row < VGA_ROWS; row ++)
		{
			// The VGA textmode buffer has size (VGA_COLS * VGA_ROWS).
			// Given this, we find an index into the buffer for our character
			const size_t index = (VGA_COLS * row) + col;
			// Entries in the VGA buffer take the binary form BBBBFFFFCCCCCCCC, where:
			// - B is the background color
			// - F is the foreground color
			// - C is the ASCII character
			vga_buffer[index] = ((uint16_t)term_color << 8) | ' '; // Set the character to blank (a space character)
		}
	}
}

// This function places a single character onto the screen
void term_putc(char c)
{
	// Remember - we don't want to display ALL characters!
	switch (c)
	{
		case '\n': // Newline characters should return the column to 0, and increment the row
			term_col = 0;
			term_row ++;
			break;
		
		default: // Normal characters just get displayed and then increment the column
			const size_t index = (VGA_COLS * term_row) + term_col; // Like before, calculate the buffer index
			vga_buffer[index] = ((uint16_t)term_color << 8) | c;
			term_col ++;
			break;
	}
	
	// What happens if we get past the last column? We need to reset the column to 0, and increment the row to get to a new line
	if (column >= VGA_COLS)
	{
		term_col = 0;
		term_row ++;
	}
}

// This function prints an entire string onto the screen
void term_print(const char* str)
{
	for (size_t i = 0; str[i] != '\0'; i ++) // Keep placing characters until we hit the null-terminating character ('\0')
		term_putc(str[i]);
}



// This is our kernel's main function
void kernel_main()
{
	// We're here! This is the kernel.
	// Let's display a message to show we got here.

	term_print("Hello, World!\n");
	term_print("Welcome to the kernel.\n");
}

linker.ld