MiniOS

systemsdevC

An operating system built to learn how modern kernels function.

MiniOS is an operating system that I designed and implemented for CPSC 415 at UBC, and have modified since to learn more topics. The OS is mostly a kernel, capable of handling process scheduling, memory management, IPC, signalling and devices. The technologies this uses are, by necessity, relatively primitive, so this is project is built in C and IA32 assembly and running on the bochs x86-i386 emulator.

The first part of the project that was implemented was the memory management system, kalloc and kfree. This system was given a block of memory, which may or may not be contiguous, and was tasked with allocating that memory to programs whenever they needed it, and freeing it whenever those programs no longer needed it. The initial block of memory is just a range of addresses, so managing this in a data structure becomes necessary. This operating system uses the concept of a free list - every allocated “pointer” of memory would have a header associated with it, which would indicate the size and the next free pointer. In this way, any time a block was allocated, a new header would be inserted after the allocated block, the existing header at the start of the allocated block would be linked to the new header, and the memory was marked as “used”. Once freed, the blocks would be merged and re-added to the list.

Once memory management was dealt with, the next building block to create was the process. Any running system needs a process in order to indicate what code is running, and this OS is no different. The process itself consisted of a number of pieces of data, like the code text and stack pointer for the program, as well as process ID and some more internal flags. This was all stored on the process table, and any running processes would be initialized on this table.

The third piece of critical code is the context switcher and dispatcher. These two units work in tandem to allow the OS and user space programs to operate together. MiniOS uses preemptive multitasking on a 10μs timer to switch between non-yielding programs. The context switcher’s job was simply to handle interrupts by stashing the stack and loading the OS stack, and then when called again, returning to the next program’s stashed stack. A program was initialized with a set of stack values and a line of code to “return” to from an interrupt in order to allow the context switcher to connect to it. The dispatcher was then responsible for handling the interrupt and dispatching the next program to run. The dispatcher made use of a ready queue (a linked list built atop the process table) to put processes in line to run, and various tasks would mark processes as “ready” and put them on the queue.

The next stage of implementation was to create a system call procedure. To be able to communicate with the operating system, programs need to have some way to call code in it, and this method is with syscalls. Syscalls would interface with the OS by setting values in certain registers and then manually triggering an interrupt, which would trigger the context switcher and subsquent dispatch logic to handle those system calls. Once this logic was in place, creating new system calls and dispatch paths was fairly easy.

After system calls were implemented, signals and IPC could follow suit. Signals are, more or less, the inverse of a system call. The operating system will receive some interrupt, be it from a syscall from another program, from an external timer, from a port, or from other sources, and then process the signal that the interrupt intends to send. The dispatcher then sends the signal to the requested process on its next ready cycle, and the process will handle that. There are special cases, for example killing processes, but most signals are just routed through to the program directly. IPC was a similar process to setup, with a syscall initiating conversation between two processes. These processes would be marked as blocked until both had called ‘send’ and ‘receive’ respective to how they wanted to communicate. Once they unblock, the message is passed via shared memory and the IPC is complete.

Finally, device drivers were created. Two trivial device drivers, /dev/rand and /dev/null were implemented, to provide a random byte stream and to provide a sink for data that could be ignored. The third device driver was for the keyboard, in order to properly interface with the user. The keyboard used two ports and communicated via interrupts to the CPU, and the driver would then decode the information based on the ports, and store the pressed keys in a small buffer. Processes which requested these devices would then be able to read from the buffer in order to handle keyboard input as they needed.

To improve upon this project in the future, I intend to implement a virtual memory paging system atop the current memory management unit, a mouse driver to properly build graphical interfaces, a proper boot loader instead of using the scaffolding that was provided by the course and the emulator, MMIO and other performance saving options for I/O, a file system (modelled after ext) to store actual data and programs, and other things as they come up.

All in all, this project taught me a ton of really important lessons about how our computer systems are designed and work, about how many small things really need to be well thought out before being implemented in order to avoid pitfalls of security or performance, and about how large scale projects can be broken down into more piecemeal parts to make creating them more manageable.