A tutorial on writing kernels
by: Brandon Friesen
Pascal & Macintosh by: G.E. Ozz Nixon Jr.
Published: February 6th 2005
©opyright 2009 by Friends of FPC
Introduction
Kernel development is not an easy task. This is a testament to your programming
expertise: To develop a kernel is to say that you understand how to create software
that interfaces with and manages the hardware. A kernel is designed to be a central
core to the operating system - the logic that manages the resources that the
hardware has to offer.
One of the most important system resources that you need to manage is the processor
or CPU - this is in the form of allotting time for specific operations, and possibly
interrupting a task or process when it is time for another scheduled event to happen.
This implies multitasking. There is cooperative multitasking, in which the program
itself calls a 'yield' function when it wants to give up processing time to the next
runnable process or task. There is preemptive multitasking, where the system timer
is used to interrupt the current process to switch to a new process: a form of
forcive switch, this more guarantees that a process can be given a chunk of time to
run. There are several scheduling algorithms used in order to find out what process
will be run next. The simplest of which is called 'Round Robin'. This is where you
simply get the next process in the list, and choose that to be runnable. A more
complicated scheduler involves 'priorities', where certain higher-priority tasks
are allowed more time to run than a lower-priority task. Even more complicated
still is a Real-time scheduler. This is designed to guarantee that a certain
process will be allowed at least a set number of timer ticks to run.
The next most important resource in the system is fairly obvious: Memory. There are
some times where memory can be more precious than CPU time, as memory is limited,
however CPU time is not. You can either code your kernel to be memory-efficient,
yet require alot of CPU, or CPU efficient by using memory to store caches and
buffers to 'remember' commonly used items instead of looking them up. The best
approach would be a combination of the two: Strive for the best memory usage, while
preserving CPU time.
The last resource that your kernel needs to manage are hardware resources. This
includes Interrupt Requests (IRQs), which are special signals that hardware devices
like the keyboard and hard disk can use to tell the CPU to execute a certain routine
to handle the data that these devices have ready. Another hardware resource is Direct
Memory Access (DMA) channels. A DMA channel allows a device to lock the memory bus
and transfer it's data directly into system memory whenever it needs to, without
halting the processor's execution. This is a good way to improve performance on a
system: a DMA-enabled device can transfer data without bothering the CPU, and then
can interrupt the CPU with an IRQ, telling it that the data transfer is complete:
Soundcards and ethernet cards are known for using both IRQs and DMA channels. The
third hardware resource is in the form of an address, like memory, but it's an
address on the I/O bus in the form of a port. A device can be configured, read, or
given data using it's I/O port(s). A Device can use many I/O ports, typically in the
form of ranges like ports 8 through 16, for example.
Getting Started
Kernel development is a lengthy process of writing code, as well as debugging various
system components. This may seem to be a rather daunting task at first, however you
don't nessarily require a massive toolset to write your own kernel. This kernel
development tutorial deals mainly with using the Grand Unified Bootloader (GRUB) to
load your kernel into memory. GRUB needs to be directed to a protected mode binary
image: this 'image' is our kernel, which we will be building.
For this tutorial, you will need at the very least, a general knowledge of a
programming language. X86 Assembler knowledge is highly recommended and beneficial
as it will allow you to manipulate specific registers inside your processor. This
being said, your toolset will need at the bare minimum, a compiler that can generate
32-bit code, a 32-bit Linker, and an Assembler which is able to generate 32-bit x86
output.
For hardware, you must have a computer with a 386 or later processor (this includes
386, 486, 5x86, 6x86, Pentium, Athlon, Celeron, Duron, and such). It is preferable
that you have a secondary computer set up to be your testbed, right beside your
development machine. If you cannot afford a second computer, or simply do not have
the room for a second computer on your desk, you may either use a Virtual Machine
suite, or you may also use your development machine as the testbed (although this
leads to slower development time). Be prepared for many sudden reboots as you test
and debug your kernel on real hardware.
Required Hardware for Testbed
a 100% IBM Compatible PC
a 386-based processor or later (486 or later recommended)
4MBytes of RAM
a VGA compatible video card with monitor
a Keyboard
a Floppy Drive
Recommended Hardware for Development
a 100% IBM Compatible PC
a Pentium II or K6 300MHz
32MBytes of RAM
a VGA compatible videocard with monitor
a Keyboard
a Floppy drive
a Hard disk with enough space for all development tools and space for documents and source code
Microsoft Windows, or a flavour of Unix (Linux, FreeBSD)
an Internet connection to look up documents
Toolset
Compilers
The Gnu C Compiler (GCC) [Unix]
DJGPP (GCC for DOS/Windows) [Windows]
FreePascal (Code changed to support FPC by G.E. Ozz Nixon Jr)
Assemblers
Netwide Assembler (NASM) [Unix/Windows]
Virtual Machines
VMWare Workstation/Player [Linux/Windows NT/2000/XP/Mac OS X]
Microsoft VirtualPC [Windows NT/2000/XP]
Bochs [Unix/Windows]
QEMU/Q [Linux/Mac OS X]
The Basic Kernel
In this section of the tutorial, we will delve into a bit of assembler, learn the basics
of creating a linker script as well as the reasons for using one, and finally, we will
learn how to use a batch/script file to automate the assembling, compiling, and linking
of this most basic protected mode kernel. Please note that at this point, the tutorial
assumes that you have NASM and DJGPP/GCC/FPC installed on a Windows, DOS-based, Linux
or Mac OS X platform. We also assume that you have a a minimal understanding of the x86
Assembly language.
The Kernel Entry
The kernel's entry point is the piece of code that will be executed FIRST when the
bootloader calls your kernel. This chunk of code is almost always written in assembly
language because some things, such as setting a new stack or loading up a new GDT, IDT,
or segment registers, are things that you simply cannot do in your C/FPC code. In many
beginner kernels as well as several other larger, more professional kernels, will put
all of their assembler code in this one file, and put all the rest of the sources in
several C/FPC source files.
If you know even a small amount of assembler, the actual code in this file should be
very straight forward. As far as code goes, all this file does is load up a new 8KByte
stack, and then jump into an infinite loop. The stack is a small amount of memory, but
it's used to store or pass arguments to functions in C/FPC. It's also used to hold local
variables that you declare and use inside your functions. Any other global variables are
stored in the data and BSS sections. The lines between the 'mboot' and 'stublet' blocks
make up a special signature that GRUB uses to verify that the output binary that it's
going to load is, infact, a kernel. Don't struggle too hard to understand the multiboot
header.
The kernel's entry file: 'start.asm'
; This is the kernel's entry point. We could either call main here,
; or we can use this to setup the stack or other nice stuff, like
; perhaps setting up the GDT and segments. Please note that interrupts
; are disabled at this point: More on interrupts later!
[BITS 32]
global start
start:
mov esp, _sys_stack ; This points the stack to our new stack area
jmp stublet
; This part MUST be 4byte aligned, so we solve that issue using 'ALIGN 4'
ALIGN 4
mboot:
; Multiboot macros to make a few lines later more readable
MULTIBOOT_PAGE_ALIGN equ 1<<0
MULTIBOOT_MEMORY_INFO equ 1<<1
MULTIBOOT_AOUT_KLUDGE equ 1<<16
MULTIBOOT_HEADER_MAGIC equ 0x1BADB002
MULTIBOOT_HEADER_FLAGS equ MULTIBOOT_PAGE_ALIGN | MULTIBOOT_MEMORY_INFO | MULTIBOOT_AOUT_KLUDGE
MULTIBOOT_CHECKSUM equ -(MULTIBOOT_HEADER_MAGIC + MULTIBOOT_HEADER_FLAGS)
EXTERN code, bss, end
; This is the GRUB Multiboot header. A boot signature
dd MULTIBOOT_HEADER_MAGIC
dd MULTIBOOT_HEADER_FLAGS
dd MULTIBOOT_CHECKSUM
; AOUT kludge - must be physical addresses. Make a note of these:
; The linker script fills in the data for these ones!
dd mboot
dd code
dd bss
dd end
dd start
; This is an endless loop here. Make a note of this: Later on, we
; will insert an 'extern _main', followed by 'call _main', right
; before the 'jmp $'.
stublet:
jmp $
; Shortly we will add code for loading the GDT right here!
; In just a few pages in this tutorial, we will add our Interrupt
; Service Routines (ISRs) right here!
; Here is the definition of our BSS section. Right now, we'll use
; it just to store the stack. Remember that a stack actually grows
; downwards, so we declare the size of the data before declaring
; the identifier '_sys_stack'
SECTION .bss
resb 8192 ; This reserves 8KBytes of memory here
_sys_stack:
The Linker Script
The Linker is the tool that takes all of our compiler and assembler output files
and links them together into one binary file. A binary file can have several formats:
Flat, AOUT, COFF, PE, and ELF are the most common. The linker we have chosen in our
toolset, if you can remember, was the LD linker. This is a very good multi-purpose
linker with an extensive feature set. There are versions of LD that exist which can
output a binary in any format that you wish. Regardless of what format you choose,
there will always be 3 'sections' in the output file. 'Text' or 'Code' is the executable
itself. The 'Data' section is for hardcoded values in your code, such as when you
declare a variable and set it to 5. The value of 5 would get stored in the 'Data'
section. The last section is called the 'BSS' section. The 'BSS' consists of
uninitialized data; it stores any arrays that you have not set any values to, for
example. 'BSS' is a virtual section: It doesn't exist in the binary image, but it
exists in memory when your binary is loaded.
What follows is a file called an LD Linker Script. There are 3 major keywords that
might pop out in this linker script: OUTPUT_FORMAT will tell LD what kind of binary
image we want to create. To keep it simple, we will stick to a plain "binary" image.
ENTRY will tell the linker what object file is to be linked as the very first file
in the list. We want the compiled version of 'start.asm' called 'start.o' to be the
first object file linked, because that's where our kernel's entry point is. The next
line is 'phys'. This is not a keyword, but a variable to be used in the linker script.
In this case, we use it as a pointer to an address in memory: a pointer to 1MByte,
which is where our binary is to be loaded to and run at. The 3rd keyword is SECTIONS.
If you study this linker script, you will see that it defines the 3 main sections:
'.text', '.data', and '.bss'. There are 3 variables defined also: 'code', 'data',
'bss', and 'end'. Do not get confused by this: the 3 variables that you see are
actually variables that are in our startup file, start.asm. ALIGN(4096) ensures
that each section starts on a 4096byte boundary. In this case, that means that each
section will start on a separate 'page' in memory.
The Linker Script: 'link.ld'
OUTPUT_FORMAT("binary")
ENTRY(start)
phys = 0x00100000;
SECTIONS
{
.text phys : AT(phys) {
code = .;
*(.text)
*(.rodata)
. = ALIGN(4096);
}
.data : AT(phys + (data - code))
{
data = .;
*(.data)
. = ALIGN(4096);
}
.bss : AT(phys + (bss - code))
{
bss = .;
*(.bss)
. = ALIGN(4096);
}
end = .;
}
Assemble and Link!
Now, we must assemble 'start.asm' as well as use the linker script, 'link.ld' shown
above, to create our kernel's binary for GRUB to load. The simplest way to do this
in Unix is to create a makefile script to do the assembling, compiling, and linking
for you, however, most of the people here including myself, use a flavour of Windows.
Here, we can create a batch file. A batch file is simply a collection of DOS commands
that you can execute with one command: the name of the batch file itself. Even
simpler: you just need to double-click the batch file in order to compile your kernel
under windows. (Ozz: For Linux and Mac, I have included a simple compile.sh for you!).
Shown below is the batch file we will use for this tutorial. 'echo' is a DOS command
that will say the following text on the screen. 'nasm' is our assembler that we use:
we compile in aout format, because LD needs a known format in order to resolve symbols
in the link process. This assembles the file 'start.asm' into 'start.o'. The 'rem'
command means 'remark' -- This is a comment: it's in the batch file, but it doesn't
actually mean anything to the computer. 'ld' is our linker. The '-T' argument tells
LD that a linker script follows. '-o' means the output file follows. Any other arguments
are understood as files that we need to link together and resolve in order to create
kernel.bin. Lastly, the 'pause' command will display "Press a key to continue..." on
the screen and wait for us to press a key so that we can see what our assembler or
linker gives out onscreen in terms of syntax errors.
Our builder batch file: 'build.bat'
echo Now assembling, compiling, and linking your kernel:
nasm -f aout -o start.o start.asm
rem Remember this spot here: We will add 'gcc' commands here to compile C sources
rem This links all your files. Remember that as you add *.o files, you need to
rem add them after start.o. If you don't add them at all, they won't be in
rem your kernel!
ld -T link.ld -o kernel.bin start.o
echo Done!
pause
Linux/Mac shell script file: 'compile.sh'
#!/bin/sh
nasm -f aout -o start.o start.asm
ld -T link.ld -o kernel.bin start.o
Creating Main and Linking C Sources
In normal C programming practice, the function main() is your normal program entry
point. In order to try to keep your normal programming practices and familiarize
yourself with kernel development, this tutorial will keep the main() function the
entry point for your C code. As you remember from above, we tried to keep minimal
assembler code. In later sections, we will have to go back into 'start.asm' in
order to add Interrupt Service Routines to call C functions.
In this section of the tutorial, we will attempt to create a 'main.c' as well as a
header file to include some common function prototypes: 'system.h'. 'main.c' will
also contain the function main() which will serve as your C entry point. As a rule
in kernel development, we should not normally return from main(). Many Operating
Systems get main to initialize the kernel and subsystems, load the shell application,
and then finally main() will sit in an idle loop. The idle loop is used in a
multitasking system when there are no other tasks that need to be run. Here is an
example 'main.c' with the basic main, as well as the function bodies for functions
that we will need in the next part of the tutorial.
'main.c': Our kernel's small, yet important beginnings
#include < system.h >
/* You will need to code these up yourself! */
unsigned char *memcpy(unsigned char *dest, const unsigned char *src, int count)
{
/* Add code here to copy 'count' bytes of data from 'src' to
* 'dest', finally return 'dest' */
const char *sp = (const char *)src;
char *dp = (char *)dest;
for(; count != 0; count--) *dp++ = *sp++;
return dest;
}
unsigned char *memset(unsigned char *dest, unsigned char val, int count)
{
/* Add code here to set 'count' bytes in 'dest' to 'val'.
* Again, return 'dest' */
char *temp = (char *)dest;
for( ; count != 0; count--) *temp++ = val;
return dest;
}
unsigned short *memsetw(unsigned short *dest, unsigned short val, int count)
{
/* Same as above, but this time, we're working with a 16-bit
* 'val' and dest pointer. Your code can be an exact copy of
* the above, provided that your local variables if any, are
* unsigned short */
unsigned short *temp = (unsigned short *)dest;
for( ; count != 0; count--) *temp++ = val;
return dest;
}
int strlen(const char *str)
{
/* This loops through character array 'str', returning how
* many characters it needs to check before it finds a 0.
* In simple words, it returns the length in bytes of a string */
size_t retval;
for(retval = 0; *str != '\0'; str++) retval++;
return retval;
}
/* We will use this later on for reading from the I/O ports to get data
* from devices such as the keyboard. We are using what is called
* 'inline assembly' in these routines to actually do the work */
unsigned char inportb (unsigned short _port)
{
unsigned char rv;
__asm__ __volatile__ ("inb %1, %0" : "=a" (rv) : "dN" (_port));
return rv;
}
/* We will use this to write to I/O ports to send bytes to devices. This
* will be used in the next tutorial for changing the textmode cursor
* position. Again, we use some inline assembly for the stuff that simply
* cannot be done in C */
void outportb (unsigned short _port, unsigned char _data)
{
__asm__ __volatile__ ("outb %1, %0" : : "dN" (_port), "a" (_data));
}
/* This is a very simple main() function. All it does is sit in an
* infinite loop. This will be like our 'idle' loop */
void main()
{
/* You would add commands after here */
/* ...and leave this loop in. There is an endless loop in
* 'start.asm' also, if you accidentally delete this next line */
for (;;);
}
Before compiling this, we need to add 2 lines into 'start.asm'. We need to
let NASM know that main() is in an 'external' file and we need to call main()
from the assembly file, also. Open 'start.asm', and look for the line that
says 'stublet:'. Immediately after that line, add the lines:
extern _main
call _main
Now wait just a minute. Why are there leading underscores for '_main', when
in C, we declared it as 'main'? The compiler gcc will put an underscore in
front of all of the function and variable names when it compiles. Therefore,
to reference a function or variable from our assembly code, we must add an
underscore to the function name if the function is in a C source file!.
This is actually good enough to compile 'as is', however we are still missing
our 'system.h'. Simply create a blank text file named 'system.h'. Add all the
function prototypes for memcpy, memset, memsetw, strlen, inportb, and outportb
to this file. It is wise to use macros to prevent an include file, or 'header'
file from declaring things more than once using some nice #ifndef, #define, and
#endif tricks. We will include this file in each C source file in this tutorial.
This will define each function that you can use in your kernel. Feel free to
expand upon this library with anything you think you will need. Observe:
Our global include file: 'system.h'
#ifndef __SYSTEM_H
#define __SYSTEM_H
/* MAIN.C */
extern unsigned char *memcpy(unsigned char *dest, const unsigned char *src, int count);
extern unsigned char *memset(unsigned char *dest, unsigned char val, int count);
extern unsigned short *memsetw(unsigned short *dest, unsigned short val, int count);
extern int strlen(const char *str);
extern unsigned char inportb (unsigned short _port);
extern void outportb (unsigned short _port, unsigned char _data);
#endif
Next, we need to find out how to compile this. Open your 'build.bat' or 'compile.sh' from
the previous section in this tutorial, and add the following line to compile your 'main.c'.
Please note that this assumes that 'system.h' is in an 'include' directory in your kernel
sources directory. This command executes the compiler 'gcc'. Among the various arguments
passed in, there is '-Wall' which gives you warnings about your code. '-nostdinc' along
with '-fno-builtin' means that we aren't using standard C library functions. '-I./include'
tells the compiler that our headers are in the 'include' directory inside the current.
'-c' tells gcc to compile only: No linking yet! Remembering from the previous section in
this tutorial, '-o main.o' is the output file that the compiler is to make, with the last
argument, 'main.c'. In short, compile 'main.c' into 'main.o' with options best for kernels.
Right click the batch file and select 'edit' to edit it!
gcc -Wall -O -fstrength-reduce -fomit-frame-pointer -finline-functions -nostdinc -fno-builtin -I./include -c -o main.o main.c
Don't forget to follow the instructions we left in 'build.bat'! You need to add 'main.o'
to the list of object files that need to be linked to create your kernel! Finally, if you
are stuck creating our accessory functions like memcpy, a solution 'main.c' is shown here.
Creating Main and Linking FPC Sources
In normal FPC programming practice, the normal entry point for your program is in
the file labels "program", in the "begin/end." block. However, to make this all work
your entry point will be in a unit as 'start.asm' is the true entry point for the
multiboot loader. Following Brandon's lead, we will make a function in our 'main.pas'
which is called 'main()' and exports itself as both a cdecl call and the name '_main'.
As you remember from above, we tried to keep minimal assembler code. In later sections,
we will have to go back into 'start.asm' in order to add Interrupt Service Routines to
call FPC functions.
In this section of the tutorial, we will attempt to create a 'main.pas' as well as
a replacement (smaller footprint) system file which contains some common functions:
'system.pp'. As a rule in kernel development, we should not normally return from
main(). Many Operating Systems get main to initialize the kernel and subsystems,
load the shell application, and then finally main() will sit in an idle loop. The
idle loop is used in a multitasking system when there are no other tasks that need
to be run. Here is an example 'main.pas' with the basic main, as well as the function
bodies for functions that we will need in the next part of the tutorial.
'system.pp': Small Footprint - Replacement for the normal system.ppu
unit system.pp;
{$ASMMODE INTEL}
interface
type
cardinal = $00000000..$FFFFFFFF;
function memcpy(dest:pointer;src:pointer;count:longint):pointer; cdecl;
function memset(dest:pointer;val:char;count:longint):pointer; cdecl;
function memsetw(dest:pointer;val:word;count:longint):pointer; cdecl;
function strlen(str:pointer):longint; cdecl;
function inportb (_port:word):byte; cdecl;
procedure outportb(_port:word;_data:byte); cdecl;
implementation
function memcpy(dest:pointer;src:pointer;count:longint):pointer;
cdecl; [public, alias: '_memcpy'];
var
sp,dp:pointer;
loop:longint;
begin
sp:=src;
dp:=dest;
for loop:=1 to count do begin
char(dp^):=char(sp^);
inc(cardinal(sp));
inc(cardinal(dp));
end;
memcpy:=dest;
end;
function memset(dest:pointer;val:char;count:longint):pointer;
cdecl; [public, alias: '_memset'];
var
temp:pointer;
loop:longint;
begin
temp:=dest;
for loop:=1 to count do begin
char(temp^):=val;
inc(cardinal(temp));
end;
memset:=dest;
end;
function memsetw(dest:pointer;val:word;count:longint):pointer;
cdecl; [public, alias: '_memsetw'];
var
temp:pointer;
loop:longint;
begin
temp:=dest;
for loop:=1 to count do begin
word(temp^):=val;
inc(cardinal(temp),2); {sizeof(word)}
end;
memsetw:=dest;
end;
function strlen(str:pointer):longint;
cdecl; [public, alias: '_strlen'];
var
tmp:pointer;
begin
tmp:=str;
while char(tmp^)<>#0 do inc(cardinal(tmp));
strlen:=cardinal(tmp)-cardinal(str); // new address - original address = length
end;
(* We will use this later on for reading from the I/O ports to get data
* from devices such as the keyboard. We are using what is called
* 'inline assembly' in these routines to actually do the work *)
function inportb (_port:word):byte;
cdecl; [public, alias: '_inportb'];
var
rv:byte;
begin
asm
mov edx, _port;
in al, dx; // in or inb?
mov rv, al;
end;
inportb:= rv;
end;
(* We will use this to write to I/O ports to send bytes to devices. This
* will be used in the next tutorial for changing the textmode cursor
* position. Again, we use some inline assembly for the stuff that simply
* cannot be done in C *)
procedure outportb(_port:word;_data:byte);
cdecl; [public, alias: '_outportb'];
begin
asm
mov edx, _port;
mov al, _data;
out dx, al; // out or outb?
end;
end;
begin
end.
'main.pas': Our kernel's small, yet important beginnings
unit main;
interface
// uses system; (* default pascal compilers automatically include "system". *)
procedure main();
implementation
(* This is a very simple main() function. All it does is sit in an
* infinite loop. This will be like our 'idle' loop *)
procedure main();
cdecl; [public; alias: '_main'];
begin
(* You would add commands after here *)
(* ...and leave this loop in. There is an endless loop in
* 'start.asm' also, if you accidentally delete this next line *)
while true do ;
end;
begin
end.
Before compiling this, we need to add 2 lines into 'start.asm'. We need to
let NASM know that main() is in an 'external' file and we need to call main()
from the assembly file, also. Open 'start.asm', and look for the line that
says 'stublet:'. Immediately after that line, add the lines:
extern _main
call _main
Now wait just a minute. Why are there leading underscores for '_main', when
in FPC, we declared it as 'main'? The compiler fpc will put an underscore in
front of all of the function and variable names when it compiles that are
defined as 'cdecl'. Therefore, to reference a function or variable from our
assembly code, we must add an underscore to the function name if the function
is in a FPC source file!.
For more information about name mangling, see my research documents on
library programming -- I address name manling and the different calling
conventions -- along with information on how a few different compilers
mangle their names. This way you can make sure your kernel will be friendly
with certain compilers -- for now I am focusing to support gcc naming to
keep the code compatible with Brandon's original tutorial code.
Next, we need to find out how to compile this. Open your 'build.bat' or 'compile.sh' from
the previous section in this tutorial, and add the following line to compile your 'main.pas'.
Please note that this assumes that 'system.pp' is in the same directory as your kernel
sources directory. This command executes the compiler 'fpc'. Among the various arguments
passed in note for system.pp we have -Us (System Unit!) otherwise you will get compilation
errors in your main.pas file. Remembering from the previous section in this tutorial,
'-o main.o' is the output file that the compiler is to make, with the last argument,
'main.pas'. In short, compile 'main.pas' into 'main.o' with options best for kernels.
Right click the batch file and select 'edit' to edit it!
fpc -Acoff -n -O3 -Op3 -Si -Sc -Sg -Xd -Us -Rintel -Tlinux system.pp
fpc -Acoff -n -O3 -Op3 -Si -Sc -Sg -Xd -Rintel -Tlinux main.pas
Don't forget to follow the instructions we left in 'build.bat'/'compile.sh'! You need to add 'main.o'
to the list of object files that need to be linked to create your kernel!
Printing to the Screen
Now, we will try to print to the screen. In order to print to the screen, we need a way
to manage scrolling the screen as needed, also. It might be nice to allow for different
colors on the screen as well. Fortunately, a VGA video card makes it rather simple: It
gives us a chunk of memory that we write both attribute byte and character byte pairs
in order to show information on the screen. The VGA controller will take care of
automatically drawing the updated changes on the screen. Scrolling is managed by our
kernel software. This is technically our first driver, that we will write right now.
As mentioned, above, the text memory is simply a chunk of memory in our address space.
This buffer is located at 0xB8000 (FPC: $B800), in physical memory. The buffer is of
the datatype 'short', meaning that each item in this text memory array takes up 16-bits,
rather than the usual 8-bits that you might expect. Each 16-bit element in the text
memory buffer can be broken into an 'upper' 8-bits and a 'lower' 8-bits. The lower
8-bits of each element tells the display controller what character to draw on the
screen. The upper 8-bits is used to define the foreground and background colors of which
to draw the character.
15 12 11 8 7 0
Backcolor Forecolor Character
The upper 8-bits of each 16-bit text element is called an 'attribute byte', and the lower
8-bits is called the 'character byte'. As you can see from the above table, mapping out
the parts of each 16-bit text element, the attribute byte gets broken up further into 2
different 4-bit chunks: 1 representing background color and 1 representing foreground
color. Now, because of the fact that only 4-bits define each color, there can only be a
maximum of 16 different colors to choose from (Using the equation (num bits ^ 2) - 4^2 =
16). Below is a table of the default 16-color palette.
Value Color Value Color
0 BLACK 8 DARK GREY
1 BLUE 9 LIGHT BLUE
2 GREEN 10 LIGHT GREEN
3 CYAN 11 LIGHT CYAN
4 RED 12 LIGHT RED
5 MAGENTA 13 LIGHT MAGENTA
6 BROWN 14 LIGHT BROWN
7 LIGHT GREY 15 WHITE
Finally, to access a particular index in memory, there is an equation that we must use.
The text mode memory is a simple 'linear' (or flat) area of memory, but the video
controller makes it appear to be an 80x25 matrix of 16-bit values. Each line of text is
sequential in memory; they follow each other. We therefore try to break up the screen
into horizontal lines. The best way to do this is to use the following equation:
index ::= (y_value * width_of_screen) + x_value;
This equation shows that to access the index in the text memory array for say (3, 4), we
would use the equation to find that 4 * 80 + 3 is 323. This means that to draw to location
(3, 4) on the screen, we need to write to do something similar to this:
in C:
unsigned short *where = (unsigned short *)0xB800 + 323;
*where = character | (attribute << 8);
in FPC:
var
where:pointer;
begin
where := pointer($B8000 + 323);
word(where^):=ord(character) or (attribute shl 8);
end;
Following now is 'scrn.c' (followed by a scrn.pas), which is where all of our functions
dealing with the screen will be. We include our 'system.h' (FPC automatically includes
the system unit) file so that we can use outportb, memcpy, memset, memsetw, and strlen.
The scrolling method that we use is rather interesting: We take a chunk of text memory
starting at line 1 (NOT line 0), and copy it over top of line 0. This basically moves
the entire screen up one line. To complete the scroll, we erase the last line of text
by writing spaces with our attribute bytes. The putch function is possibly the most
complicated function in this file. It is also the largest, because it needs to handle
any newlines ('\n'), carriage returns ('\r'), and backspaces ('\b'). Later, if you wish,
you may handle the alarm character ('\a' - ASCII character 7), which is only supposed to
do a short beep when it is encountered. I have included a function to set the screen
colors also (settextcolor) if you wish.
Printing to the screen: 'scrn.c'
#include < system.h >
/* These define our textpointer, our background and foreground
* colors (attributes), and x and y cursor coordinates */
unsigned short *textmemptr;
int attrib = 0x0F;
int csr_x = 0, csr_y = 0;
/* Scrolls the screen */
void scroll(void)
{
unsigned blank, temp;
/* A blank is defined as a space... we need to give it
* backcolor too */
blank = 0x20 | (attrib << 8);
/* Row 25 is the end, this means we need to scroll up */
if(csr_y >= 25)
{
/* Move the current text chunk that makes up the screen
* back in the buffer by a line */
temp = csr_y - 25 + 1;
memcpy (textmemptr, textmemptr + temp * 80, (25 - temp) * 80 * 2);
/* Finally, we set the chunk of memory that occupies
* the last line of text to our 'blank' character */
memsetw (textmemptr + (25 - temp) * 80, blank, 80);
csr_y = 25 - 1;
}
}
/* Updates the hardware cursor: the little blinking line
* on the screen under the last character pressed! */
void move_csr(void)
{
unsigned temp;
/* The equation for finding the index in a linear
* chunk of memory can be represented by:
* Index = [(y * width) + x] */
temp = csr_y * 80 + csr_x;
/* This sends a command to indicies 14 and 15 in the
* CRT Control Register of the VGA controller. These
* are the high and low bytes of the index that show
* where the hardware cursor is to be 'blinking'. To
* learn more, you should look up some VGA specific
* programming documents. A great start to graphics:
* http://www.brackeen.com/home/vga */
outportb(0x3D4, 14);
outportb(0x3D5, temp >> 8);
outportb(0x3D4, 15);
outportb(0x3D5, temp);
}
/* Clears the screen */
void cls()
{
unsigned blank;
int i;
/* Again, we need the 'short' that will be used to
* represent a space with color */
blank = 0x20 | (attrib << 8);
/* Sets the entire screen to spaces in our current
* color */
for(i = 0; i < 25; i++)
memsetw (textmemptr + i * 80, blank, 80);
/* Update out virtual cursor, and then move the
* hardware cursor */
csr_x = 0;
csr_y = 0;
move_csr();
}
/* Puts a single character on the screen */
void putch(unsigned char c)
{
unsigned short *where;
unsigned att = attrib << 8;
/* Handle a backspace, by moving the cursor back one space */
if(c == 0x08)
{
if(csr_x != 0) csr_x--;
}
/* Handles a tab by incrementing the cursor's x, but only
* to a point that will make it divisible by 8 */
else if(c == 0x09)
{
csr_x = (csr_x + 8) & ~(8 - 1);
}
/* Handles a 'Carriage Return', which simply brings the
* cursor back to the margin */
else if(c == '\r')
{
csr_x = 0;
}
/* We handle our newlines the way DOS and the BIOS do: we
* treat it as if a 'CR' was also there, so we bring the
* cursor to the margin and we increment the 'y' value */
else if(c == '\n')
{
csr_x = 0;
csr_y++;
}
/* Any character greater than and including a space, is a
* printable character. The equation for finding the index
* in a linear chunk of memory can be represented by:
* Index = [(y * width) + x] */
else if(c >= ' ')
{
where = textmemptr + (csr_y * 80 + csr_x);
*where = c | att; /* Character AND attributes: color */
csr_x++;
}
/* If the cursor has reached the edge of the screen's width, we
* insert a new line in there */
if(csr_x >= 80)
{
csr_x = 0;
csr_y++;
}
/* Scroll the screen if needed, and finally move the cursor */
scroll();
move_csr();
}
/* Uses the above routine to output a string... */
void puts(unsigned char *text)
{
int i;
for (i = 0; i < strlen(text); i++)
{
putch(text[i]);
}
}
/* Sets the forecolor and backcolor that we will use */
void settextcolor(unsigned char forecolor, unsigned char backcolor)
{
/* Top 4 bytes are the background, bottom 4 bytes
* are the foreground color */
attrib = (backcolor << 4) | (forecolor & 0x0F)
}
/* Sets our text-mode VGA pointer, then clears the screen for us */
void init_video(void)
{
textmemptr = (unsigned short *)0xB8000;
cls();
}
Printing to the screen: 'scrn.pas'
unit scrn;
interface
procedure scroll();
procedure move_csr();
implementation
(* These define our textpointer, our background and foreground
* colors (attributes), and x and y cursor coordinates *)
var
textmemptr:pointer;
attrib:byte = $0F;
csr_x:byte = 0;
csr_y:byte = 0;
(* Scrolls the screen *)
procedure scroll();
var
blank:word;
temp:cardinal;
begin
(* A blank is defined as a space... we need to give it
* backcolor too *)
blank := $20 or (attrib shl 8);
(* Row 25 is the end, this means we need to scroll up *)
if(csr_y >= 25) then begin
(* Move the current text chunk that makes up the screen
* back in the buffer by a line *)
temp := csr_y - 25 + 1;
memcpy (textmemptr, pointer(cardinal(textmemptr) + temp * 80), (25 - temp) * 80 * 2);
(* Finally, we set the chunk of memory that occupies
* the last line of text to our 'blank' character *)
memsetw (textmemptr + (25 - temp) * 80, blank, 80);
csr_y = 25 - 1;
}
}
(* Updates the hardware cursor: the little blinking line
* on the screen under the last character pressed! *)
procedure move_csr();
var
temp:word;
begin
(* The equation for finding the index in a linear
* chunk of memory can be represented by:
* Index ::= [(y * width) + x] *)
temp := csr_y * 80 + csr_x;
(* This sends a command to indicies 14 and 15 in the
* CRT Control Register of the VGA controller. These
* are the high and low bytes of the index that show
* where the hardware cursor is to be 'blinking'. To
* learn more, you should look up some VGA specific
* programming documents. A great start to graphics:
* http://www.brackeen.com/home/vga *)
outportb(0x3D4, 14);
outportb(0x3D5, temp shr 8);
outportb(0x3D4, 15);
outportb(0x3D5, temp);
end;
(* Clears the screen *)
procedure cls();
var
blank:word;
i:longint;
begin
(* Again, we need the 'short' that will be used to
* represent a space with color *)
blank := $20 or (attrib shl 8);
(* Sets the entire screen to spaces in our current
* color *)
for i := 0 to 24 do
memsetw (pointer(cardinal(textmemptr) + i * 80), blank, 80);
(* Update out virtual cursor, and then move the
* hardware cursor *)
csr_x := 0;
csr_y := 0;
move_csr();
end;
(* Puts a single character on the screen */
procedure putch(c:char);
var
where:cardinal;
att:word;
begin
att := attrib shl 8;
(* Handle a backspace, by moving the cursor back one space *)
if(c = #08) then begin
if(csr_x <> 0) then csr_x--;
end
(* Handles a tab by incrementing the cursor's x, but only
* to a point that will make it divisible by 8 *)
else if(c = #09) then begin
csr_x := csr_x + (8-(csr_x mod 8));
}
(* Handles a 'Carriage Return', which simply brings the
* cursor back to the margin *)
else if(c = #13) then begin
csr_x := 0;
end
/* We handle our newlines the way DOS and the BIOS do: we
* treat it as if a 'CR' was also there, so we bring the
* cursor to the margin and we increment the 'y' value */
else if(c = #10) then begin
csr_x := 0;
csr_y++;
end
(* Any character greater than and including a space, is a
* printable character. The equation for finding the index
* in a linear chunk of memory can be represented by:
* Index = [(y * width) + x] *)
else if(c >= ' ') then begin
where := cardinal(textmemptr) + (csr_y * 80 + csr_x);
Pointer(where)^ := word(c or att); /* Character AND attributes: color */
csr_x++;
end;
(* If the cursor has reached the edge of the screen's width, we
* insert a new line in there *)
if(csr_x >= 80) then begin
csr_x := 0;
csr_y++;
end;
/* Scroll the screen if needed, and finally move the cursor */
scroll();
move_csr();
end;
(* Uses the above routine to output a string... *)
prcoedure puts(text:pointer);
var
i,maxloop:longint;
tmp:pointer;
begin
maxloop:=strlen(text);
tmp:=text;
for i:=1 to maxloop do begin
putch(char(tmp^));
inc(cardinal(tmp));
end;
end;
(* Sets the forecolor and backcolor that we will use *)
procedure settextcolor(forecolor,backcolor:byte);
begin
(* Top 4 bytes are the background, bottom 4 bytes
* are the foreground color *)
attrib := (backcolor shl 4) or (forecolor and $0F);
end;
(* Sets our text-mode VGA pointer, then clears the screen for us *)
procedure init_video();
begin
textmemptr := pointer($B800);
cls();
end;
end.
Next, we need to compile this into our kernel. To do that, you need to edit 'build.bat' in order to add a new gcc compile command. Simply copy the command in 'build.bat' that corresponds to 'main.c' and paste it right afterwards. In our newly pasted line, change 'main' to 'scrn'. Again, don't forget to add 'scrn.o' to the list of files that LD needs to link! Before we can use these in main, you must add the function prototypes for putch, puts, cls, init_video, and settextcolor into 'system.h'. Don't forget the 'extern' keyword and the semicolons as these are each function prototypes:
extern void cls();
extern void putch(unsigned char c);
extern void puts(unsigned char *str);
extern void settextcolor(unsigned char forecolor, unsigned char backcolor);
extern void init_video();
Add these to 'system.h' so we can call these from 'main.c'
Now, it's safe to use our new screen printing functions in out main function. Open 'main.c' and add a line that calls init_video(), and finally a line that calls puts, passing it a string: puts("Hello World!"); Finally, save all your changes, double click 'build.bat' to make your kernel, debugging any syntax errors. Copy your 'kernel.bin' to your GRUB floppy disk, and if all went well, you should now have a kernel that prints 'Hello World!' on a black screen in white text!