This text is a practical guide to writing your own x86 operating system. It is designed to give enough help with the technical details while at the same time not reveal too much with samples and code excerpts. We’ve tried to collect parts of the vast (and often excellent) expanse of material and tutorials available, on the web and otherwise, and add our own insights into the problems we encountered and struggled with.
This book is not about the theory behind operating systems, or how any specific operating system (OS) works. For OS theory we recommend the book Modern Operating Systems by Andrew Tanenbaum [1]. Lists and details on current operating systems are available on the Internet.
The starting chapters are quite detailed and explicit, to quickly get you into coding. Later chapters give more of an outline of what is needed, as more and more of the implementation and design becomes up to the reader, who should now be more familiar with the world of kernel development. At the end of some chapters there are links for further reading, which might be interesting and give a deeper understanding of the topics covered.
In chapter 2 and 3 we set up our development environment and boot up our OS kernel in a virtual machine, eventually starting to write code in C. We continue in chapter 4 with writing to the screen and the serial port, and then we dive into segmentation in chapter 5 and interrupts and input in chapter 6.
After this we have a quite functional but bare-bones OS kernel. In chapter 7 we start the road to user mode applications, with virtual memory through paging (chapter 8 and 9), memory allocation (chapter 10), and finally running a user application in chapter 11.
In the last three chapters we discuss the more advanced topics of file systems (chapter 12), system calls (chapter 13), and multitasking (chapter 14).
About the Book
The OS kernel and this book were produced as part of an advanced individual course at the Royal Institute of Technology [2], Stockholm. The authors had previously taken courses in OS theory, but had only minor practical experience with OS kernel development. In order to get more insight and a deeper understanding of how the theory from the previous OS courses works out in practice, the authors decided to create a new course, which focused on the development of a small OS. Another goal of the course was writing a thorough tutorial on how to develop a small OS basically from scratch, and this short book is the result.
The x86 architecture is, and has been for a long time, one of the most common hardware architectures. It was not a difficult choice to use the x86 architecture as the target of the OS, with its large community, extensive reference material and mature emulators. The documentation and information surrounding the details of the hardware we had to work with was not always easy to find or understand, despite (or perhaps due to) the age of the architecture.
The OS was developed in about six weeks of full-time work. The implementation was done in many small steps, and after each step the OS was tested manually. By developing in this incremental and iterative way, it was often easier to find any bugs that were introduced, since only a small part of the code had changed since the last known good state of the code. We encourage the reader to work in a similar way.
During the six weeks of development, almost every single line of code was written by the authors together (this way of working is also called pair-programming). It is our belief that we managed to avoid a lot of bugs due to this style of development, but this is hard to prove scientifically.
The Reader
The reader of this book should be comfortable with UNIX/Linux, systems programming, the C language and computer systems in general (such as hexadecimal notation [3]). This book could be a way to get started learning those things, but it will be more difficult, and developing an operating system is already challenging on its own. Search engines and other tutorials are often helpful if you get stuck.
Credits, Thanks and Acknowledgements
We’d like to thank the OSDev community [4] for their great wiki and helpful members, and James Malloy for his eminent kernel development tutorial [5]. We’d also like to thank our supervisor Torbjörn Granlund for his insightful questions and interesting discussions.
Most of the CSS formatting of the book is based on the work by Scott Chacon for the book Pro Git, http://progit.org/.
Contributors
We are very grateful for the patches that people send us. The following users have all contributed to this book:
Changes and Corrections
This book is hosted on Github – if you have any suggestions, comments or corrections, just fork the book, write your changes, and send us a pull request. We’ll happily incorporate anything that makes this book better.
Issues and where to get help
If you run into problems while reading the book, please check the issues on Github for help: https://github.com/littleosbook/littleosbook/issues.
License
All content is under the Creative Commons Attribution Non Commercial Share Alike 3.0 license, http://creativecommons.org/licenses/by-nc-sa/3.0/us/. The code samples are in the public domain – use them however you want. References to this book are always received with warmth.
Developing an operating system (OS) is no easy task, and the question “How do I even begin to solve this problem?” is likely to come up several times during the course of the project for different problems. This chapter will help you set up your development environment and booting a very small (and primitive) operating system.
Quick Setup
We (the authors) have used Ubuntu [6] as the operating system for doing OS development, running it both physically and virtually (using the virtual machine VirtualBox [7]). A quick way to get everything up and running is to use the same setup as we did, since we know that these tools work with the samples provided in this book.
Once Ubuntu is installed, either physical or virtual, the following packages should be installed using apt-get
:
sudo apt-get install build-essential nasm genisoimage bochs bochs-sdl
Programming Languages
The operating system will be developed using the C programming language [8][9], using GCC [10]. We use C because developing an OS requires a very precise control of the generated code and direct access to memory. Other languages that provide the same features can also be used, but this book will only cover C.
The code will make use of one type attribute that is specific for GCC:
__attribute__((packed))
This attribute allows us to ensure that the compiler uses a memory layout for a struct
exactly as we define it in the code. This is explained in more detail in the next chapter.
Due to this attribute, the example code might be hard to compile using a C compiler other than GCC.
For writing assembly code, we have chosen NASM [11] as the assembler, since we prefer NASM’s syntax over GNU Assembler.
Bash [12] will be used as the scripting language throughout the book.
Host Operating System
All the code examples assumes that the code is being compiled on a UNIX like operating system. All code examples have been successfully compiled using Ubuntu [6] versions 11.04 and 11.10.
Build System
Make [13] has been used when constructing the Makefile examples.
Virtual Machine
When developing an OS it is very convenient to be able to run your code in a virtual machine instead of on a physical computer, since starting your OS in a virtual machine is much faster than getting your OS onto a physical medium and then running it on a physical machine. Bochs [14] is an emulator for the x86 (IA-32) platform which is well suited for OS development due to its debugging features. Other popular choices are QEMU [15] and VirtualBox [7]. This book uses Bochs.
By using a virtual machine we cannot ensure that our OS works on real, physical hardware. The environment simulated by the virtual machine is designed to be very similar to their physical counterparts, and the OS can be tested on one by just copying the executable to a CD and finding a suitable machine.
Booting
Booting an operating system consists of transferring control along a chain of small programs, each one more “powerful” than the previous one, where the operating system is the last “program”. See the following figure for an example of the boot process:
An example of the boot process. Each box is a program.
BIOS
When the PC is turned on, the computer will start a small program that adheres to the Basic Input Output System (BIOS) [16] standard. This program is usually stored on a read only memory chip on the motherboard of the PC. The original role of the BIOS program was to export some library functions for printing to the screen, reading keyboard input etc. Modern operating systems do not use the BIOS’ functions, they use drivers that interact directly with the hardware, bypassing the BIOS. Today, BIOS mainly runs some early diagnostics (power-on-self-test) and then transfers control to the bootloader.
The Bootloader
The BIOS program will transfer control of the PC to a program called a bootloader. The bootloader’s task is to transfer control to us, the operating system developers, and our code. However, due to some restrictions1 of the hardware and because of backward compatibility, the bootloader is often split into two parts: the first part of the bootloader will transfer control to the second part, which finally gives control of the PC to the operating system.
Writing a bootloader involves writing a lot of low-level code that interacts with the BIOS. Therefore, an existing bootloader will be used: the GNU GRand Unified Bootloader (GRUB) [17].
Using GRUB, the operating system can be built as an ordinary ELF [18] executable, which will be loaded by GRUB into the correct memory location. The compilation of the kernel requires that the code is laid out in memory in a specific way (how to compile the kernel will be discussed later in this chapter).
The Operating System
GRUB will transfer control to the operating system by jumping to a position in memory. Before the jump, GRUB will look for a magic number to ensure that it is actually jumping to an OS and not some random code. This magic number is part of the multiboot specification [19] which GRUB adheres to. Once GRUB has made the jump, the OS has full control of the computer.
Hello Cafebabe
This section will describe how to implement of the smallest possible OS that can be used together with GRUB. The only thing the OS will do is write 0xCAFEBABE
to the eax
register (most people would probably not even call this an OS).
Compiling the Operating System
This part of the OS has to be written in assembly code, since C requires a stack, which isn’t available (the chapter “Getting to C” describes how to set one up). Save the following code in a file called loader.s
:
global loader ; the entry symbol for ELF
MAGIC_NUMBER equ 0x1BADB002 ; define the magic number constant
FLAGS equ 0x0 ; multiboot flags
CHECKSUM equ -MAGIC_NUMBER ; calculate the checksum
; (magic number + checksum + flags should equal 0)
section .text: ; start of the text (code) section
align 4 ; the code must be 4 byte aligned
dd MAGIC_NUMBER ; write the magic number to the machine code,
dd FLAGS ; the flags,
dd CHECKSUM ; and the checksum
loader: ; the loader label (defined as entry point in linker script)
mov eax, 0xCAFEBABE ; place the number 0xCAFEBABE in the register eax
.loop:
jmp .loop ; loop forever
The only thing this OS will do is write the very specific number 0xCAFEBABE
to the eax
register. It is very unlikely that the number 0xCAFEBABE
would be in the eax
register if the OS did not put it there.
The file loader.s
can be compiled into a 32 bits ELF [18] object file with the following command:
nasm -f elf32 loader.s
Linking the Kernel
The code must now be linked to produce an executable file, which requires some extra thought compared to when linking most programs. We want GRUB to load the kernel at a memory address larger than or equal to 0x00100000
(1 megabyte (MB)), because addresses lower than 1 MB are used by GRUB itself, BIOS and memory-mapped I/O. Therefore, the following linker script is needed (written for GNU LD [20]):
ENTRY(loader) /* the name of the entry label */
SECTIONS {
. = 0x00100000; /* the code should be loaded at 1 MB */
.text ALIGN (0x1000) : /* align at 4 KB */
{
*(.text) /* all text sections from all files */
}
.rodata ALIGN (0x1000) : /* align at 4 KB */
{
*(.rodata*) /* all read-only data sections from all files */
}
.data ALIGN (0x1000) : /* align at 4 KB */
{
*(.data) /* all data sections from all files */
}
.bss ALIGN (0x1000) : /* align at 4 KB */
{
*(COMMON) /* all COMMON sections from all files */
*(.bss) /* all bss sections from all files */
}
}
Save the linker script into a file called link.ld
. The executable can now be linked with the following command:
ld -T link.ld -melf_i386 loader.o -o kernel.elf
The final executable will be called kernel.elf
.
Obtaining GRUB
The GRUB version we will use is GRUB Legacy, since the OS ISO image can then be generated on systems using both GRUB Legacy and GRUB 2. More specifically, the GRUB Legacy stage2_eltorito
bootloader will be used. This file can be built from GRUB 0.97 by downloading the source from ftp://alpha.gnu.org/gnu/grub/grub-0.97.tar.gz. However, the configure
script doesn’t work well with Ubuntu [21], so the binary file can be downloaded from http://littleosbook.github.com/files/stage2_eltorito. Copy the file stage2_eltorito
to the folder that already contains loader.s
and link.ld
.
Building an ISO Image
The executable must be placed on a media that can be loaded by a virtual or physical machine. In this book we will use ISO [22] image files as the media, but one can also use floppy images, depending on what the virtual or physical machine supports.
We will create the kernel ISO image with the program genisoimage
. A folder must first be created that contains the files that will be on the ISO image. The following commands create the folder and copy the files to their correct places:
mkdir -p iso/boot/grub # create the folder structure
cp stage2_eltorito iso/boot/grub/ # copy the bootloader
cp kernel.elf iso/boot/ # copy the kernel
A configuration file menu.lst
for GRUB must be created. This file tells GRUB where the kernel is located and configures some options:
default=0
timeout=0
title os
kernel /boot/kernel.elf
Place the file menu.lst
in the folder iso/boot/grub/
. The contents of the iso
folder should now look like the following figure:
iso
|-- boot
|-- grub
| |-- menu.lst
| |-- stage2_eltorito
|-- kernel.elf
The ISO image can then be generated with the following command:
genisoimage -R
-b boot/grub/stage2_eltorito
-no-emul-boot
-boot-load-size 4
-A os
-input-charset utf8
-quiet
-boot-info-table
-o os.iso
iso
For more information about the flags used in the command, see the manual for genisoimage
.
The ISO image os.iso
now contains the kernel executable, the GRUB bootloader and the configuration file.
Running Bochs
Now we can run the OS in the Bochs emulator using the os.iso
ISO image. Bochs needs a configuration file to start and an example of a simple configuration file is given below:
megs: 32
display_library: sdl
romimage: file=/usr/share/bochs/BIOS-bochs-latest
vgaromimage: file=/usr/share/bochs/VGABIOS-lgpl-latest
ata0-master: type=cdrom, path=os.iso, status=inserted
boot: cdrom
log: bochslog.txt
clock: sync=realtime, time0=local
cpu: count=1, ips=1000000
You might need to change the path to romimage
and vgaromimage
depending on how you installed Bochs. More information about the Bochs config file can be found at Boch’s website [23].
If you saved the configuration in a file named bochsrc.txt
then you can run Bochs with the following command:
bochs -f bochsrc.txt -q
The flag -f
tells Bochs to use the given configuration file and the flag -q
tells Bochs to skip the interactive start menu. You should now see Bochs starting and displaying a console with some information from GRUB on it.
After quitting Bochs, display the log produced by Boch:
cat bochslog.txt
You should now see the contents of the registers of the CPU simulated by Bochs somewhere in the output. If you find RAX=00000000CAFEBABE
or EAX=CAFEBABE
(depending on if you are running Bochs with or without 64 bit support) in the output then your OS has successfully booted!
Further Reading
- Gustavo Duertes has written an in-depth article about what actually happens when a x86 computer boots up, http://duartes.org/gustavo/blog/post/how-computers-boot-up
- Gustavo continues to describe what the kernel does in the very early stages at http://duartes.org/gustavo/blog/post/kernel-boot-process
- The OSDev wiki also contains a nice article about booting an x86 computer: http://wiki.osdev.org/Boot_Sequence
This chapter will show you how to use C instead of assembly code as the programming language for the OS. Assembly is very good for interacting with the CPU and enables maximum control over every aspect of the code. However, at least for the authors, C is a much more convenient language to use. Therefore, we would like to use C as much as possible and use assembly code only where it make sense.
Setting Up a Stack
One prerequisite for using C is a stack, since all non-trivial C programs use a stack. Setting up a stack is not harder than to make the esp
register point to the end of an area of free memory (remember that the stack grows towards lower addresses on the x86) that is correctly aligned (alignment on 4 bytes is recommended from a performance perspective).
We could point esp
to a random area in memory since, so far, the only thing in the memory is GRUB, BIOS, the OS kernel and some memory-mapped I/O. This is not a good idea – we don’t know how much memory is available or if the area esp
would point to is used by something else. A better idea is to reserve a piece of uninitialized memory in the bss
section in the ELF file of the kernel. It is better to use the bss
section instead of the data
section to reduce the size of the OS executable. Since GRUB understands ELF, GRUB will allocate any memory reserved in the bss
section when loading the OS.
The NASM pseudo-instruction resb
[24] can be used to declare uninitialized data:
KERNEL_STACK_SIZE equ 4096 ; size of stack in bytes
section .bss
align 4 ; align at 4 bytes
kernel_stack: ; label points to beginning of memory
resb KERNEL_STACK_SIZE ; reserve stack for the kernel
There is no need to worry about the use of uninitialized memory for the stack, since it is not possible to read a stack location that has not been written (without manual pointer fiddling). A (correct) program can not pop an element from the stack without having pushed an element onto the stack first. Therefore, the memory locations of the stack will always be written to before they are being read.
The stack pointer is then set up by pointing esp
to the end of the kernel_stack
memory:
mov esp, kernel_stack + KERNEL_STACK_SIZE ; point esp to the start of the
; stack (end of memory area)
Calling C Code From Assembly
The next step is to call a C function from assembly code. There are many different conventions for how to call C code from assembly code [25]. This book uses the cdecl calling convention, since that is the one used by GCC. The cdecl calling convention states that arguments to a function should be passed via the stack (on x86). The arguments of the function should be pushed on the stack in a right-to-left order, that is, you push the rightmost argument first. The return value of the function is placed in the eax
register. The following code shows an example:
/* The C function */
int sum_of_three(int arg1, int arg2, int arg3)
{
return arg1 + arg2 + arg3;
}
; The assembly code
external sum_of_three ; the function sum_of_three is defined elsewhere
push dword 3 ; arg3
push dword 2 ; arg2
push dword 1 ; arg1
call sum_of_three ; call the function, the result will be in eax
Packing Structs
In the rest of this book, you will often come across “configuration bytes” that are a collection of bits in a very specific order. Below follows an example with 32 bits:
Bit: | 31 24 | 23 8 | 7 0 |
Content: | index | address | config |
Instead of using an unsigned integer, unsigned int
, for handling such configurations, it is much more convenient to use “packed structures”:
struct example {
unsigned char config; /* bit 0 - 7 */
unsigned short address; /* bit 8 - 23 */
unsigned char index; /* bit 24 - 31 */
};
When using the struct
in the previous example there is no guarantee that the size of the struct
will be exactly 32 bits – the compiler can add some padding between elements for various reasons, for example to speed up element access or due to requirements set by the hardware and/or compiler. When using a struct
to represent configuration bytes, it is very important that the compiler does not add any padding, because the struct
will eventually be treated as a 32 bit unsigned integer by the hardware. The attribute packed
can be used to force GCC to not add any padding:
struct example {
unsigned char config; /* bit 0 - 7 */
unsigned short address; /* bit 8 - 23 */
unsigned char index; /* bit 24 - 31 */
} __attribute__((packed));
Note that __attribute__((packed))
is not part of the C standard – it might not work with all C compilers.
Compiling C Code
When compiling the C code for the OS, a lot of flags to GCC need to be used. This is because the C code should not assume the presence of a standard library, since there is no standard library available for our OS. For more information about the flags, see the GCC manual.
The flags used for compiling the C code are:
-m32 -nostdlib -nostdinc -fno-builtin -fno-stack-protector -nostartfiles
-nodefaultlibs
As always when writing C programs we recommend turning on all warnings and treat warnings as errors:
-Wall -Wextra -Werror
You can now create a function kmain
in a file called kmain.c
that you call from loader.s
. At this point, kmain
probably won’t need any arguments (but in later chapters it will).
Now is also probably a good time to set up some build tools to make it easier to compile and test-run the OS. We recommend using make
[13], but there are plenty of other build systems available. A simple Makefile for the OS could look like the following example:
OBJECTS = loader.o kmain.o
CC = gcc
CFLAGS = -m32 -nostdlib -nostdinc -fno-builtin -fno-stack-protector
-nostartfiles -nodefaultlibs -Wall -Wextra -Werror -c
LDFLAGS = -T link.ld -melf_i386
AS = nasm
ASFLAGS = -f elf
all: kernel.elf
kernel.elf: $(OBJECTS)
ld $(LDFLAGS) $(OBJECTS) -o kernel.elf
os.iso: kernel.elf
cp kernel.elf iso/boot/kernel.elf
genisoimage -R
-b boot/grub/stage2_eltorito
-no-emul-boot
-boot-load-size 4
-A os
-input-charset utf8
-quiet
-boot-info-table
-o os.iso
iso
run: os.iso
bochs -f bochsrc.txt -q
%.o: %.c
$(CC) $(CFLAGS) $< -o $@
%.o: %.s
$(AS) $(ASFLAGS) $< -o $@
clean:
rm -rf *.o kernel.elf os.iso
The contents of your working directory should now look like the following figure:
.
|-- bochsrc.txt
|-- iso
| |-- boot
| |-- grub
| |-- menu.lst
| |-- stage2_eltorito
|-- kmain.c
|-- loader.s
|-- Makefile
You should now be able to start the OS with the simple command make run
, which will compile the kernel and boot it up in Bochs (as defined in the Makefile above).
Further Reading
- Kernigan & Richie’s book, The C Programming Language, Second Edition, [8] is great for learning about all the aspects of C.
This chapter will present how to display text on the console as well as writing data to the serial port. Furthermore, we will create our first driver, that is, code that acts as a layer between the kernel and the hardware, providing a higher abstraction than communicating directly with the hardware. The first part of this chapter is about creating a driver for the framebuffer [26] to be able to display text on the console. The second part shows how to create a driver for the serial port. Bochs can store output from the serial port in a file, effectively creating a logging mechanism for the operating system.
Interacting with the Hardware
There are usually two different ways to interact with the hardware, memory-mapped I/O and I/O ports.
If the hardware uses memory-mapped I/O then you can write to a specific memory address and the hardware will be updated with the new data. One example of this is the framebuffer, which will be discussed in more detail later. For example, if you write the value 0x410F
to address 0x000B8000
, you will see the letter A in white color on a black background (see the section on the framebuffer for more details).
If the hardware uses I/O ports then the assembly code instructions out
and in
must be used to communicate with the hardware. The instruction out
takes two parameters: the address of the I/O port and the data to send. The instruction in
takes a single parameter, the address of the I/O port, and returns data from the hardware. One can think of I/O ports as communicating with hardware the same way as you communicate with a server using sockets. The cursor (the blinking rectangle) of the framebuffer is one example of hardware controlled via I/O ports on a PC.
The Framebuffer
The framebuffer is a hardware device that is capable of displaying a buffer of memory on the screen [26]. The framebuffer has 80 columns and 25 rows, and the row and column indices start at 0 (so rows are labelled 0 – 24).
Writing Text
Writing text to the console via the framebuffer is done with memory-mapped I/O. The starting address of the memory-mapped I/O for the framebuffer is 0x000B8000
[27]. The memory is divided into 16 bit cells, where the 16 bits determine both the character, the foreground color and the background color. The highest eight bits is the ASCII [28] value of the character, bit 7 – 4 the background and bit 3 – 0 the foreground, as can be seen in the following figure:
Bit: | 15 14 13 12 11 10 9 8 | 7 6 5 4 | 3 2 1 0 |
Content: | ASCII | FG | BG |
The available colors are shown in the following table:
Black | 0 | Red | 4 | Dark grey | 8 | Light red | 12 |
Blue | 1 | Magenta | 5 | Light blue | 9 | Light magenta | 13 |
Green | 2 | Brown | 6 | Light green | 10 | Light brown | 14 |
Cyan | 3 | Light grey | 7 | Light cyan | 11 | White | 15 |
The first cell corresponds to row zero, column zero on the console. Using an ASCII table, one can see that A corresponds to 65 or 0x41
. Therefore, to write the character A with a green foreground (2) and dark grey background (8) at place (0,0), the following assembly code instruction is used:
mov [0x000B8000], 0x4128
The second cell then corresponds to row zero, column one and its address is therefore:
0x000B8000 + 16 = 0x000B8010
Writing to the framebuffer can also be done in C by treating the address 0x000B8000
as a char pointer, char *fb = (char *) 0x000B8000
. Then, writing A at place (0,0) with green foreground and dark grey background becomes:
fb[0] = 'A';
fb[1] = 0x28;
The following code shows how this can be wrapped into a function:
/** fb_write_cell:
* Writes a character with the given foreground and background to position i
* in the framebuffer.
*
* @param i The location in the framebuffer
* @param c The character
* @param fg The foreground color
* @param bg The background color
*/
void fb_write_cell(unsigned int i, char c, unsigned char fg, unsigned char bg)
{
fb[i] = c;
fb[i + 1] = ((fg & 0x0F) << 4) | (bg & 0x0F)
}
The function can then be used as follows:
#define FB_GREEN 2
#define FB_DARK_GREY 8
fb_write_cell(0, 'A', FB_GREEN, FB_DARK_GREY);
Moving the Cursor
Moving the cursor of the framebuffer is done via two different I/O ports. The cursor’s position is determined with a 16 bits integer: 0 means row zero, column zero; 1 means row zero, column one; 80 means row one, column zero and so on. Since the position is 16 bits large, and the out
assembly code instruction argument is 8 bits, the position must be sent in two turns, first 8 bits then the next 8 bits. The framebuffer has two I/O ports, one for accepting the data, and one for describing the data being received. Port 0x3D4
[29] is the port that describes the data and port 0x3D5
[29] is for the data itself.
To set the cursor at row one, column zero (position 80 = 0x0050
), one would use the following assembly code instructions:
out 0x3D4, 14 ; 14 tells the framebuffer to expect the highest 8 bits of the position
out 0x3D5, 0x00 ; sending the highest 8 bits of 0x0050
out 0x3D4, 15 ; 15 tells the framebuffer to expect the lowest 8 bits of the position
out 0x3D5, 0x50 ; sending the lowest 8 bits of 0x0050
The out
assembly code instruction can’t be executed directly in C. Therefore it is a good idea to wrap out
in a function in assembly code which can be accessed from C via the cdecl calling standard [25]:
global outb ; make the label outb visible outside this file
; outb - send a byte to an I/O port
; stack: [esp + 8] the data byte
; [esp + 4] the I/O port
; [esp ] return address
outb:
mov al, [esp + 8] ; move the data to be sent into the al register
mov dx, [esp + 4] ; move the address of the I/O port into the dx register
out dx, al ; send the data to the I/O port
ret ; return to the calling function
By storing this function in a file called io.s
and also creating a header io.h
, the out
assembly code instruction can be conveniently accessed from C:
#ifndef INCLUDE_IO_H
#define INCLUDE_IO_H
/** outb:
* Sends the given data to the given I/O port. Defined in io.s
*
* @param port The I/O port to send the data to
* @param data The data to send to the I/O port
*/
void outb(unsigned short port, unsigned char data);
#endif /* INCLUDE_IO_H */
Moving the cursor can now be wrapped in a C function:
#include "io.h"
/* The I/O ports */
#define FB_COMMAND_PORT 0x3D4
#define FB_DATA_PORT 0x3D5
/* The I/O port commands */
#define FB_HIGH_BYTE_COMMAND 14
#define FB_LOW_BYTE_COMMAND 15
/** fb_move_cursor:
* Moves the cursor of the framebuffer to the given position
*
* @param pos The new position of the cursor
*/
void fb_move_cursor(unsigned short pos)
{
outb(FB_COMMAND_PORT, FB_HIGH_BYTE_COMMAND);
outb(FB_DATA_PORT, ((pos >> 8) & 0x00FF));
outb(FB_COMMAND_PORT, FB_LOW_BYTE_COMMAND);
outb(FB_DATA_PORT, pos & 0x00FF);
}
The Driver
The driver should provide an interface that the rest of the code in the OS will use for interacting with the framebuffer. There is no right or wrong in what functionality the interface should provide, but a suggestion is to have a write
function with the following declaration:
int write(char *buf, unsigned int len);
The write
function writes the contents of the buffer buf
of length len
to the screen. The write
function should automatically advance the cursor after a character has been written and scroll the screen if necessary.
The Serial Ports
The serial port [30] is an interface for communicating between hardware devices and although it is available on almost all motherboards, it is seldom exposed to the user in the form of a DE-9 connector nowadays. The serial port is easy to use, and, more importantly, it can be used as a logging utility in Bochs. If a computer has support for a serial port, then it usually has support for multiple serial ports, but we will only make use of one of the ports. This is because we will only use the serial ports for logging. Furthermore, we will only use the serial ports for output, not input. The serial ports are completely controlled via I/O ports.
Configuring the Serial Port
The first data that need to be sent to the serial port is configuration data. In order for two hardware devices to be able to talk to each other they must agree upon a couple of things. These things include:
- The speed used for sending data (bit or baud rate)
- If any error checking should be used for the data (parity bit, stop bits)
- The number of bits that represent a unit of data (data bits)
Configuring the Line
Configuring the line means to configure how data is being sent over the line. The serial port has an I/O port, the line command port, that is used for configuration.
First the speed for sending data will be set. The serial port has an internal clock that runs at 115200 Hz. Setting the speed means sending a divisor to the serial port, for example sending 2 results in a speed of 115200 / 2 = 57600
Hz.
The divisor is a 16 bit number but we can only send 8 bits at a time. We must therefore send an instruction telling the serial port to first expect the highest 8 bits, then the lowest 8 bits. This is done by sending 0x80
to the line command port. An example is shown below:
#include "io.h" /* io.h is implement in the section "Moving the cursor" */
/* The I/O ports */
/* All the I/O ports are calculated relative to the data port. This is because
* all serial ports (COM1, COM2, COM3, COM4) have their ports in the same
* order, but they start at different values.
*/
#define SERIAL_COM1_BASE 0x3F8 /* COM1 base port */
#define SERIAL_DATA_PORT(base) (base)
#define SERIAL_FIFO_COMMAND_PORT(base) (base + 2)
#define SERIAL_LINE_COMMAND_PORT(base) (base + 3)
#define SERIAL_MODEM_COMMAND_PORT(base) (base + 4)
#define SERIAL_LINE_STATUS_PORT(base) (base + 5)
/* The I/O port commands */
/* SERIAL_LINE_ENABLE_DLAB:
* Tells the serial port to expect first the highest 8 bits on the data port,
* then the lowest 8 bits will follow
*/
#define SERIAL_LINE_ENABLE_DLAB 0x80
/** serial_configure_baud_rate:
* Sets the speed of the data being sent. The default speed of a serial
* port is 115200 bits/s. The argument is a divisor of that number, hence
* the resulting speed becomes (115200 / divisor) bits/s.
*
* @param com The COM port to configure
* @param divisor The divisor
*/
void serial_configure_baud_rate(unsigned short com, unsigned short divisor)
{
outb(SERIAL_LINE_COMMAND_PORT(com),
SERIAL_LINE_ENABLE_DLAB);
outb(SERIAL_DATA_PORT(com),
(divisor >> 8) & 0x00FF);
outb(SERIAL_DATA_PORT(com),
divisor & 0x00FF);
}
The way that data should be sent must be configured. This is also done via the line command port by sending a byte. The layout of the 8 bits looks like the following:
Bit: | 7 | 6 | 5 4 3 | 2 | 1 0 |
Content: | d | b | prty | s | dl |
A description for each name can be found in the table below (and in [31]):
d | Enables (d = 1 ) or disables (d = 0 ) DLAB |
b | If break control is enabled (b = 1 ) or disabled (b = 0 ) |
prty | The number of parity bits to use |
s | The number of stop bits to use (s = 0 equals 1, s = 1 equals 1.5 or 2) |
dl | Describes the length of the data |
We will use the mostly standard value 0x03
[31], meaning a length of 8 bits, no parity bit, one stop bit and break control disabled. This is sent to the line command port, as seen in the following example:
/** serial_configure_line:
* Configures the line of the given serial port. The port is set to have a
* data length of 8 bits, no parity bits, one stop bit and break control
* disabled.
*
* @param com The serial port to configure
*/
void serial_configure_line(unsigned short com)
{
/* Bit: | 7 | 6 | 5 4 3 | 2 | 1 0 |
* Content: | d | b | prty | s | dl |
* Value: | 0 | 0 | 0 0 0 | 0 | 1 1 | = 0x03
*/
outb(SERIAL_LINE_COMMAND_PORT(com), 0x03);
}
The article on OSDev [31] has a more in-depth explanation of the values.
Configuring the Buffers
When data is transmitted via the serial port it is placed in buffers, both when receiving and sending data. This way, if you send data to the serial port faster than it can send it over the wire, it will be buffered. However, if you send too much data too fast the buffer will be full and data will
4 Comments
dlachausse
Thank you! This looks like a great resource on the topic.
I wish I still had the source code for the “OS” I made as a teenager. I got as far as writing an MBR boot loader, switching to protected mode, displaying characters on the screen, and keyboard input. I highly recommend it if you’re looking for a fun challenge.
furkanonder
The book is good. I wish they would look at the issues on GitHub, there are some things that need to be fixed. The last commit was 10 years ago.
fragmede
Speaking of OS development, games make learning fun. I had the idea of making a game to teach operating systems while taking a journey through the history of computers. The player would play the part of the process scheduler and interrupt handler, starting on a single CPU system with very limited RAM, before growing to SMP systems then maybe getting to multi-system distributed computing platforms that we have today.
vibrantrida
[dead]