December 2024
Well let's start by clearing the record by saying that this is a ring 0 bare-metal microkernel instead of a full fledged OS. But let's be honest, it just doesn't have the same ring to it.
Where do I start? Well it all started by me wanting to be able to tell everyone that I had made a custom OS just to play pong. And guess what, after a few months of hard work. I can now finally tell everybody I did just that :)
Let's start with the bootloader. Every OS needs a warm-up act, in the world of custom OS development, that's the bootloader. It's a compact, no-nonsense 512 byte program that is loaded by the BIOS into memory at the start address of 0x7c00
The process starts with the CPU which is booted in 16-bit real-mode for backwards compatibility reasons. And for us to use the full potential of the i386 chip, we will transition the CPU from the afformentioned 16-bit real-mode to the 32-bit protected mode. This way our program can take full advantage of the CPU's advanced capabilities.
To achieve this, we first need to set up a Global Descriptor Table (GDT). You can think of this a fancy clipboard that the CPU uses to understand the layout of the memory. It defines segments like our code semgent and data segments. And because of this, if we try to enter protected mode without the GDT it would feel like stepping into a building without knowing where to go.
Once the GDT is in place, the bootloader will flip the switch to 32-bit mode by setting one specified bit in the CR0 register. With that being done, we can perform a far jump to flush the pipeline (fancy IT speak for 'resetting the internal GPS') and then we have finally landed into 32-bit world.
But we still cannot play our game. To do that we first need to load in the kernel. Which is a core component of an operating system that serves as the main interface between the computer's physical hardware and the processes running on it. And since this OS was designed just to play Pong, I could make it intentionally lightweight without any bloatware. Because of this, the bootloader can just pull the kernel off the disk and place it neatly into memory at 0x0100000 without any needed magic. Then after it being loaded in, the bootloader will hand over the controls to the kernel and retires, it's job is done.
For this project I decided to code the kernel in C, an honestly, it was an easy decision. For an operating system you need binary code that will be directly injected into the CPU. This means I can only use languages that can compile to binary. So there were a few choices such as Rust, C++ or even plain old assembly. In the end I chose for C. I could've used Rust or C++, but I did not want to learn those languages and their paradigms. I could have gone for assembly, because it is very handy for low-level operations, but writing an entire kernel in it would be to high maintencance. And that's where C strikes the perfect balance between staying close enough to the hardware, but high-level enough to not hate my life while coding this project.
The kernel's main job is to set up the system environment for Pong. Making sure everything works before starting the Pong game. This kernel does have a very minimalistic design, which only focusses on what's absolutely necessary:
The design philosophy here is 'do more with less'. The kernel is small and fast for one single purpose: running Pong. And guess what, it works great, and who needs more when you have a pixel-perfect paddle and a bouncing ball?
All operating systems need a way to respond to interruptions. Whether it is from hardware, software, or the occasional existential crisis from it's developer. This is where the Interrupt Descriptor Table (IDT), Interrupt Service Routines (IRS), and Interrupt Requests (IRQ) come into play. For a game like Pong, these systems are the unsung heroes, ensuring that inputs from the keyboard, screen updates, and timer events happen without any issues.
The Interrupt Descriptor Table is like a phonebook for the CPU, mapping each interrupt number to a specific handler function. Whether it's a hardware interrupt (like a key press) or a software interrupt (like a system call), the CPU consults the IDT to figure out what to do next.
Setting up the IDT involves defining 256 entries, one for each interrupt. Not all of these are used, but having the full table gives flexibility for the future. For instance:
An Interrupt Service Routine (IRS) is the code that gets executed when an interrupt occurs. Think of an ambulance arriving to the scene of an accident. It knows what to do and how to stabalize the environment.
For example, when a divide-by-zero error occurs (interrupt 0), the CPU triggers an ISR. In this case, the exception handler for this OS doesn't just halt the system, it displays the error message both on screen and in the terminal via serial output.
Interrupt Requests (IRQs) are hardware-generated interrupts. Devices like the keyboard, timer, and screen all use IRQs to signal the CPU that they need some attention. For example:
In Pong-OS, handling IRQs means first routing them through the Programmable Interrupt Controller (PIC) and then passing them on the approriate ISR. This process involves some extra steps such as remapping the PIC to avoid conflicting with CPU exceptions. Once these are remapped they can be routed to a specific handler. For example, the keyboard interrupt triggers a handler that will read the scancode, processes it, and moves the paddle in the correct direction in Pong.
One of the key features of Pong-OS is its graphics implementation, which uses double buffering to provide a smooth and flicker-free rendering. So what is double buffering? Imagine that you're a magician with two hats. While you're secretly preparing a rabbit in one hat, the audience is mesmerized by the other hat. Tha's how this implementation of double buffering works.
We use two buffers to hold the frame data. While one buffer is being displayed, the other buffer is being drawn to. Once the drawing is complete, we swap the hats (buffers), and voila! The audiece (players) see a smooth, flicker-free game.
Our little keyboard controller is responsible for handling all the keyboard input and translating it into actions within the game. The controller uses the IRQ1 interrupt to detect any key presses and releases.
In this case we have defined our keyboard layout as a simplified US keyboard. And when a key is pressed, it will read the corresponding scancode from the keyboard port (0x60) and translated into a character using this layout.
Meet the Timekeeper, better known as the Programmable Interval Timer (PIT). This component is responsible for generating timer interrupts at regular intervals, ensuring that the game state is updated, animations are handles, and gameplay remains smooth.
This bad boy of a PIT operates at a base frequency of exactly 1193181 Hz. Then to achieve a desired frequency, a divisor is calculated and set in the PIT control registers.
Last but not least, let's talk about fonts! Fonts make sure that each letter and number looks fabulous on screen. So how does Pong-OS handle our font then?
Well, our font is stored in a glorious 8x8 pixel format for ASCII characters 0 to 127. And each character gets its own 8-byte representation. And when we want to draw a character to the screen, we will call the font_char function with the specified character we want to display and then we read the entry in the array to get the specified coordinates we need to draw to:
1void font_char(u8 character, size_t xCor, size_t yCor, u8 colour) {
2 if (character >= 128) return;
3
4 const u8 *glyph = FONT[(u8)character];
5
6 for (size_t row = 0; row < 8; row++) {
7 for (size_t col = 0; col < 8; col++) {
8 if (glyph[row] & (1 << col)) {
9 screen_set(xCor + col, yCor + row, colour);
10 }
11 }
12 }
13
14}
So, what did I take away from completing this project? Quite a bit, actually. First of all, I realized that my planning skills still need a bit of an improvement. Something I definetely have to work on for future projects. But besides that, I have gotten a much deeper understanding of how an operating system works under the hood. I gained a lot of insight into the boot process, how a computer can start itself up and then display a fully functional OS, and the role of memory management and specific hardwre registers in making it all happen.
Now for the million dollar question, would I do it again? Absolutely. The knowledge I've gained is invaluable, and the questioning of my insanity were well worth it.
Happy coding! 🎉