Chapter 3. Overview of GeekOS

Table of Contents

1. Memory
1.1. Page Allocator
1.2. Heap Allocator
2. Interrupts and Threads
2.1. Interrupts
2.2. Threads
2.3. Thread Synchronization
2.4. Interactions between Interrupts and Threads
3. Devices
3.1. Text Screen
3.2. Keyboard
3.3. System Timer
3.4. Block Devices: Floppy and IDE Disks

This chapter presents a high level overview of the GeekOS kernel and the subsystems you will use when you add new functionality to GeekOS. Once you have read this chapter, you can refer to Chapter 12, GeekOS API Reference for detailed descriptions of functions in the GeekOS kernel.

1. Memory

The GeekOS kernel manages all memory in the system. Two types of memory can be allocated.

1.1. Page Allocator

All of the memory in the system is divided into chunks called pages. In the x86 architecture, the page size is 4K. A page is a unit of memory that can be part of a virtual address space; this characteristic of pages will come into play when you add virtual memory to GeekOS (Chapter 9, Project 4: Virtual Memory). For now, you can just think of pages as small fixed size chunks of memory. Pages are allocated and freed using the Alloc_Page() and Free_Page() functions in the <geekos/mem.h> header file.

1.2. Heap Allocator

The heap allocator provides allocation of arbitrary-sized chunks of memory. The Malloc() function allocates a chunk of memory, and the Free() function releases a chunk of memory. The prototypes for these functions are in the <geekos/malloc.h> header file.

2. Interrupts and Threads

Interrupts and threads are the mechanisms used by GeekOS to divide CPU resources between the various tasks the operating system performs. Understanding how interrupts and threads interact is crucial to being able to add new functionality to the kernel.

2.1. Interrupts

Interrupts are used to inform the CPU of the occurrence of an important event. The important characteristic of an interrupt is that it causes control to transfer immediately to an interrupt handler. Interrupt handlers are simply C functions.

There are several kinds of interrupts:

  • Exceptions indicate that the currently executing thread performed an illegal action. They are a form of synchronous interrupt, because they happen in a predictable manner. Examples include execution of an invalid instruction and attempts to divide by zero. Exceptions generally kill the thread which raised them, because there is no way to recover from the exception.

  • Faults, like exceptions, are also synchronous. Unlike exceptions, they are generally recoverable; the kernel can do some work to remove the condition that caused the fault, and then allow the faulting thread to continue executing. An example is a page fault, which indicates that a page containing a referenced memory location is not currently mapped into the address space. If the kernel can locate the page and map it back into the address space, the faulting thread can continue executing. You will learn more about page faults in Chapter 9, Project 4: Virtual Memory.

  • Hardware interrupts are used by external hardware devices to notify the CPU of an event. These interrupts are asynchronous, because they are unpredictable. In other words, an asynchronous interrupt can happen at any time. Sometimes the kernel is in a state where it cannot immediately handle an asynchronous interrupts. In this case, the kernel can temporarily disable interrupts until it is ready to handle them again. An example of a hardware interrupt is the timer interrupt.

  • Software interrupts are used by user mode processes to signal that they require attention from the kernel. The only kind of software interrupt used in GeekOS is the system call interrupt, which is used by processes to request a service from the kernel. For example, system calls are used by processes to open files, perform input and output, spawn new processes, etc.

When an interrupt handler has completed, it returns control to the thead which was interrupted at the exact instruction where the interrupt occurred. For the most part, the original thread resumes as if the interrupt never happened.

The occurrence of an interrupt can cause a thread context switch. This fact has imporant consequences for code that modifies shared kernel data structures, and will be described in detail in Section 2.4, “Interactions between Interrupts and Threads”.

2.2. Threads

Threads allow multiple tasks to share the CPU. In GeekOS, each thread is represented by a Kernel_Thread object, defined in <geekos/kthread.h>.

Threads are selected for execution by the scheduler. At any given time, a single thread is executing. This is the current thread, and a pointer to its Kernel_Thread object is available in the g_currentThread global variable. Threads that are ready to run, but not currently running, are placed in the run queue. Threads that are waiting for a specific event to occur are placed on a wait queue. Both the run queue and wait queues are instances of the Thread_Queue data structure, which is simply a linked list of Kernel_Thread objects.

Some threads execute entirely in kernel mode. These threads are called system threads. System threads are used to perform demand-driven tasks within the kernel. For example, a system thread called the reaper thread is used to free the resources of threads which have exited. The floppy and IDE disk drives each use a system thread to wait for I/O requests, perform them, and communicate the results back to the requesting threads.

In contrast to system threads, processes spend most of their time executing in user mode. Processes should be familiar to you already; when you run an ordinary program in an operating system like Linux or Windows, the system creates a process to execute the program. Each process consists of a memory space reserved for the exclusive use of the running program, as well as other resources like files and semaphores.

In GeekOS, a process is simply a Kernel_Thread which has a special data structure attached to it. This data structure is the User_Context. It contains all of the memory and other resources allocated to the process. Because processes are just ordinary threads which have the capability of executing in user mode, they are sometimes referred to in GeekOS as user threads.

Processes start out executing in user mode. However, interrupts occurring while the process is executing in user mode cause the process to switch back into kernel mode. When the interrupt handler returns, the process resumes executing in user mode.

2.3. Thread Synchronization

GeekOS provides a high level mechanism to synchronize threads: mutexes and condition variables. (The mutex and condition variable implementation in GeekOS is modeled on the pthreads API, so if you have some any pthreads programming, this section should seem very familiar.) Mutexes and condition variables are defined in the <geekos/synch.h> header file.

[Important]Important

Mutexes and condition variables may only be used to synchronize threads. It is not legal to access a mutex or condition variable from an handler for an asynchronous interrupt.

Mutexes are used to guard critical sections. A mutex ensures MUTual EXclusion within a critical section guarded by the mutex; only one thread is allowed to hold a mutex at any given time. If a thread tries to acquire a mutex that is already held by another thread, it is suspended until the mutex is available.

Here is an example of a function that atomically adds a node to a list:

#include <geekos/synch.h>

struct Mutex lock;
struct Node_List nodeList;

void Add_Node(struct Node *node) {
    Mutex_Lock(&lock);
    Add_To_Back_Of_Node_List(&nodeList, node);
    Mutex_Unlock(&lock);
}

Condition variables represent a condition that threads can wait for. Each condition variable is associated with a mutex, which must be held while accessing the condition variable and when inspecting or modifying the program state associated with the condition.

Here is an elaboration of the earlier example that allows threads to wait for a node to become available in the node list:

#include <geekos/synch.h>

struct Mutex lock;
struct Condition nodeAvail;
struct Node_List nodeList;

void Add_Node(struct Node *node) {
    Mutex_Lock(&lock);
    Add_To_Back_Of_Node_List(&nodeList, node);
    Cond_Broadcast(&nodeAvail);
    Mutex_Unlock(&lock);
}

struct Node *Wait_For_Node(void) {
    struct Node *node;

    Mutex_Lock(&lock);
    while (Is_Node_List_Empty(&nodeList)) {
        /* Wait for another thread to call Add_Node() */
        Cond_Wait(&nodeAvail, &lock);
    }
    node = Remove_From_Front_Of_Node_List(&nodeList);
    Mutex_Unlock(&lock);

    return node;
}

2.4. Interactions between Interrupts and Threads

The GeekOS kernel is preemptible. This means that, in general, a thread context switch can occur at any time. Choosing which thread to execute at a preemption point is the job of the scheduler. In general, the scheduler will choose the task which has the highest priority and is ready to execute.

The main cause of asynchronous (involuntary) threads switches is the timer interrupts, which the kernel uses to ensure that no single thread can completely monopolize the CPU. However, other hardware interrupts (such as the floppy disk interrupt) can also cause asynchronous thread switches. Threads often need to modify data structures shared by other threads and/or interrupt handler functions. If a thread switch were to occur in the middle of an operation modifying a shared data structure, the data structure could be left in an inconsistent state, leading to a kernel crash or other unpredictable behavior.

Fortunately, it is easy to temporarily disable preemption by disabling interrupts. This is done by calling the Disable_Interrupts() function (prototype in <geekos/int.h>). After this function is called, the processor ignores all external hardware interrupts. While interrupts are disabled, the current thread is guaranteed to retain control of the CPU: no other threads or interrupt handlers will execute. When the thread is ready to re-enable preemption, it can call Enable_Interrupts().

There are a variety of situations when interrupts should be disabled. Generally, disabling interrupts can be used to make any sequence of instructions atomic; this means that the entire sequence of instructions is guaranteed to complete as a unit, without interruption.

The most important specific situation when interrupts should be disabled is when a scheduler data structure is modified. A typical example is putting the current thread on a wait queue. Here is an example:

/* Wait for an event */
Disable_Interrupts();
while (!eventHasOccurred) {
    Wait(&waitQueue);
}
Enable_Interrupts();

In this example, a thread is waiting for the occurrence of an asynchronous event. Until the event occurs, it will suspend itself by waiting on a wait queue. When the event occurs, the interrupt handler for the event will set the eventHasOccurred flag and move the thread from the wait queue to the run queue.

Consider what could happen if interrupts were not disabled in the example above. First, the interrupt handler for the event could occur between the time eventHasOccurred is checked and when the calling thread puts itself on the wait queue. This might result in the thread waiting forever, even though the event it is waiting for has already occurred. A second possibility is that a thread switch might occur while the thread is adding itself to the wait queue. The handler for the interrupt causing the thread switch will place the current thread on the run queue, while the wait queue is in a modified (inconsistent) state. Any code accessing the wait queue (such as an interrupt handler or another thread) might cause the system to crash.

Fortunately, (almost) all functions in GeekOS that need to be called with interrupts disabled contain an assertion that will inform you immediately if they are called with interupts enabled. You will see many places in the code that look like this:

KASSERT(!Interrupts_Enabled());

These statements indicate that interrupts must be disabled in order for the code immediately following to execute. Knowing when interrupts need to be disabled is a bit tricky at first, but you will soon get the hang of it.

One final caveat: regions of code which explicitly disable interrupts cannot be nested. Another way to put this is that it is illegal to call Disable_Interrupts() when interrupts are already disabled, and it is illegal to call Enabled_Interrupts() when interrupts are already enabled. Consider the following code:

void f(void) {
    Disable_Interrupts();
    g();
    ... modify shared data structures ...
    Enable_Interrupts();
}

void g(void) {
    Disable_Interrupts();
    ...
    Enable_Interrupts();
}

This code, if allowed to execute, would contain a bug. If the function g() returned with interrupts enabled, a context switch in function f() could interrupt a modification to a shared data structure, leaving it in an inconsistent state. Fortunately, there is a simple way to write code that will selectively disable interrupts if needed:

bool iflag;

iflag = Begin_Int_Atomic();
... interrupts are disabled ...
End_Int_Atomic(iflag);

Sections of code that use Begin_Int_Atomic() and End_Int_Atomic() may be nested safely.

3. Devices

The GeekOS kernel contains device drivers for several important hardware devices.

3.1. Text Screen

The text screen provides support for displaying text. The screen driver in GeekOS emulates a subset of VT100 and ANSI escape codes for cursor movement and setting character attributes. Text screen services are provided in the <geekos/screen.h> header file.

The main function you will use in sending output to the screen is the Print() function, which supports a subset of the functionality of the standard C printf() function. Low level screen output is provided by the Put_Char() and Put_Buf() functions, which write a single character and a sequence of characters to the screen, respectively.

3.2. Keyboard

The keyboard device driver provides a high level interface to the keyboard. It installs an interrupt handler for keyboard events, and translates the low level key scan codes to higher level codes that contain ASCII character codes for pressed keys, as well as modifier information (whether shift, control, and/or alt are pressed). The header for for the keyboard services is <geekos/keyboard.h>.

Threads can wait for a key event by calling the Wait_For_Key() function. Key codes are returned using the Keycode datatype, which is a 16 bit unsigned integer. The low 10 bits of the keycode indicate which physical key was pressed or released. Several flag bits are used:

  • KEY_SPECIAL_FLAG is set for keys that do not have an ASCII representation. For examples, function keys and cursor keys call into this category. If this flag is not set, then the low 8 bits of the key code contain the ASCII code.

  • KEY_KEYPAD_FLAG is set for keys on the numeric keypad.

  • KEY_SHIFT_FLAG is set if one of the shift keys is currently pressed. For alphabetic keys, this will cause the ASCII code to be upper case.

  • KEY_CTRL_FLAG and KEY_ALT_FLAG are set to indicate that the control and alt keys are pressed, respectively.

  • KEY_RELEASE_FLAG is set for key release events. You will probably want to ignore key events that have this flag set.

3.3. System Timer

The system timer is used to provide a periodic timeslice interrupt. After the current thread has been interrupted a set number of times by the timer interrupt, the scheduler is invoked to choose a new thread. You will generally not need to use any timer services directly. However, it is worth noting that the system timer is the mechanism used to ensure that all threads have a chance to execute by preventing any single thread from monopolizing the CPU.

3.4. Block Devices: Floppy and IDE Disks

Block devices are the abstraction used to represent storage devices: i.e., disks. They are called block devices because they are organized as a sequence of fixed size blocks called sectors. Block device services are defined in the <geekos/blockdev.h> header file.

Although real block devices often have varying sector sizes, GeekOS makes the simplifying assumption that all block devices have a fixed sector size of 512 bytes. This is the value defined by the SECTOR_SIZE macro.

GeekOS supports two kinds of block devices: floppy drives and IDE disk drives. A naming scheme is used to identify particular drives attached to the system:

  • fd0: the first floppy drive

  • ide0: the first IDE disk drive

  • ide1: the second IDE disk drive

A particular instance of a block device is represented in the kernel by the Block_Device data structure. You can retrieve a block device object by passing its name to the Open_Block_Device() function. Once you have the block device object, you can read and write particular sectors using the Block_Read() and Block_Write() functions.

Block devices are used as the underlying storage for filesystems. A filesystem builds higher level abstractions (files and directories) on top of the raw block storage offered by block devices. In Chapter 10, Project 5: A Filesystem, you will implement a filesystem using an underlying block device.