After perusing a couple books on systems programming and OS internals, I set out to develop my own operating system, which I called JamesOS after my former online pseudonym, James.
As I would soon find out, the undertaking was highly ambitious, so this project never got past the "toy OS" phase. That said, the lessons I learned developing it were invaluable and led me to tap into several fields of computer science, and to catch a glimpse of the fascinating mechanisms used at the lowest level in the software stack.
Development#
How it started#
Following the Bare Bones tutorial from OSDev Wiki, I got a very simple VGA text-mode terminal driver going. It could not do much except print a colorful message to the screen, scrolling down when the message exceeded screen height.
I then rigorously adjusted my little driver to the project structure example given in the OSDev Wiki tutorial Meaty Skeleton, an important step up from "Bare Bones". Now the project had a minimalistic foundation for a libc implementation, better modularity, and an automated build process.
It would have been rather difficult to move forward using Multiboot and GRUB, so I spent an evening getting rid of GRUB and integrating Limine into the project, which went a long way.
The joy of C#
The obvious next step was implementing various useful routines from the C standard library, with a focus on printf which is essential for debugging. Writing my own version of printf was a fun exercise, but in the end I settled for Marco Paland's complete and well-tested freestanding printf implementation.
GDT#
It was time to set up a few important data structures used on the x86. First, I gathered a few segment descriptors into a Global Descriptor Table (GDT).
The GDT mechanism is straightforward. Segmentation in protected (32-bit) mode derives from real (16-bit) mode, in which memory addressing requires a segment and an offset. The offset is 16 bits wide (meaning each segment is 64 KiB long). Segments are aligned to a 16-byte boundary, like so:
- Segment 0 starts at physical address 0
- Segment 1 starts at 16
- Segment 2 starts at 32
- ...and so on
So the physical address of every logical address of the form is in fact . Here, is called the base address of the segment.
In protected mode, the part of the logical address does not determine the base address directly, but rather refers to an entry in the GDT, which specifies the base and size of the corresponding segment (segments are not necessarily 64 KiB long in protected mode). This makes segment-level access control possible.
However, Limine enables long (64-bit) mode and implicitly paging, which supersedes segmentation, so I only needed a minimal GDT as it would hardly be used.
Interrupts#
Next, I constructed an Interrupt Descriptor Table (IDT), which required writing a bunch of interrupt service routines, callbacks conjured by the CPU whenever an interrupt (such as a key press, or an exception) is triggered. The IDT is simply a lookup table for locating the corresponding ISR of a given interrupt.
When setting up interrupts, care must be taken to remap the programmable interrupt controller (PIC) on x86. This is because in the default mapping of interrupt requests (IRQs or "hardware" interrupts) to entries in the IDT, some IRQs map to the same handlers as exceptions generated by the CPU. That means the interrupt controller could send the CPU an INT 8 meaning "timer fired" (IRQ 0), although the CPU also raises INT 8 when a Double Fault – a fatal exception – occurs! This is no good, so programming the PIC to map IRQs to higher interrupt numbers is essential.
PS/2 will be really popular in 2007#
Once interrupts were enabled, I began writing a PS/2 keyboard driver. This may not be cutting-edge peripheral technology, nor do I have such a port on my laptop, but QEMU can easily emulate PS/2, and it works great for basic interaction with the OS. After a bit of work, the keyboard driver could handle all keys on a standard US keyboard, control keys, multibyte key codes (e.g. arrow keys, multimedia keys such as Volume Up or Next Track, and many others), uppercase letters (via Shift and Caps Lock), and Shift-symbols (e.g. Shift + number keys).
Writing a driver is delightful, so this is the part I spent the most time on. Many passes of refactoring later, the event-driven keyboard driver could be used like so:
KeyEvent ev; if (isKeyEvent()) { ev = getKeyEvent(); if (isAscii(ev.key) && ev.key.press) putchar(keyEventToAscii(ev)); }
Key events are stored in a circular buffer until consumed by a call to getKeyEvent. Each event carries a key that corresponds to a physical button on the keyboard, irrespective of layout. The key event contains context information about active control keys, which makes processing events very natural:
// Layout-dependent processing unsigned char keyEventToAscii(KeyEvent event) { return keyEventUpper(event) ? keyToAsciiUpper(event.key) : keyToAscii(event.key); }
Striving for simplicity and flexibility, I think I stroke the right balance in the end.
Memory management#
Since Limine enters long mode before handing control to the kernel, it necessarily enables paging in advance and provides some placeholder memory mappings. To be able to allocate memory and provide my own mappings, I needed to implement physical and virtual memory managers.
Physical memory – a piece of (the) cake#
Limine presents us with a memory map that reveals which sections of physical memory (i.e. RAM) are usable by the kernel (the other sections are reserved for various reasons). The physical memory manager of JamesOS uses a bitmap to remember for every page frame (4 KiB-aligned chunk of 4 KiB in physical memory) whether it is free or used. The PMM then provides a simple interface for allocating one page frame – a function called kallocFrame which will be used extensively as a primitive dynamic memory allocator.
Virtual memory – not so simple (technical interlude)#
Virtual memory FTW
Virtual memory is an impressive feat of modern operating systems. It enables each process to own an isolated, contiguous address space that hides the capacity and location of the physical storage. The process need not worry about corrupting (or accidentally reading) memory used by other processes – or by the kernel, for that matter – because even if it tried, it would promptly be terminated.
Virtual memory also makes possible other useful mechanisms, like swap, copy-on-write, memory-mapped files, shared libraries... and the list goes on.
When paging is enabled, every address is virtual and must resolve to a physical address through a series of nested hash maps (page directories and page tables) in order for the CPU to access memory at all. This data structure is what the virtual memory manager actually manages.
In 4-level paging, the hierarchy of page directories has four levels at most:
- The fourth-level page directory (or PML4, in the x86-64 vernacular) is an array containing 512 physical addresses of third-level page directories (generally, only a handful of these entries are used, because they cover a lot of ground).
- Third-level PDs contain entries that map to second-level PDs, but these entries can also map pages directly to physical memory.
- Second-level PDs are similar to third-level PDs, mapping to page tables or to pages directly.
- Page tables on the first level contain 512 addresses of physical page frames.
The page table has the highest granularity, mapping virtual pages 4 KiB in size to physical page frames. Second-level and third-level PDs can map larger pages, 2 MiB and 1 GiB in size respectively.
Every virtual address contains (at most) four 9-bit sections that index into these tables of 512 () entries each. In the case of 4 KiB (-byte) pages, the least significant 12 bits of the virtual address index particular bytes of the page itself. Add to that another 9 bits for 2 MiB pages, and another 9 bits still for 1 GiB pages.
Writing a virtual memory manager#
With a little help from the OSDev community, I implemented an algorithm to map arbitrary ranges of virtual addresses to physical memory. To reduce redundancy, the mapping is done using the largest pages possible.
A function called mapLargest traverses the page directory tree, creating new tables if necessary, in order to find (and map) the largest not-yet-mapped page – at the beginning of the desired range – that's no larger than the total amount of memory to map, and returns the size of that page. This is used in the high-level mapping function exposed by the VMM, map, which simply invokes mapLargest, progressively mapping chunks until all is mapped:
void map(Vaddr vaddr, Paddr paddr, size_t size, Perms perm, MemoryType mt) { // Round up size to 4 KiB (or "level 0") page boundary size += firstBits(entryMaskBits(0)); size &= ~firstBits(entryMaskBits(0)); while (size) { size_t maxMapSize = mapLargest(vaddr, paddr, size, perm, mt); // Tighten the range after mapping an initial chunk vaddr += maxMapSize; paddr += maxMapSize; size -= maxMapSize; } }
This interface is later used like so:
map(kernelBase, 0x0, GiB(2), Perm_RWX, MemoryType_Memory);
The above call maps the range of virtual memory used by the kernel's code and data – 2 GiB starting from kernelBase – to physical address 0 with full permissions and regular memory mapping (the other option is memory-mapped IO, which is not implemented yet).

Figure 1. Paging enabled.
Multitasking#
The next goal was concurrently executing multiple tasks on a single CPU core. This is the job of a component in the kernel called the task scheduler.
The term task usually refers to a thread of some currently running process. In JamesOS, each task is assigned a control block (subsequently referred to as TCB) containing details such as a pointer to the PML4 (top-level page directory) of the task, its "top of the stack", its state and the amount of time it ran for.
New tasks are created through a procedure newKernelTask that receives an entry point and allocates some memory for the task's TCB and stack, then carefully lays some default register values (and return address) on this new stack and finally inserts the TCB into a linked list.
The part where he schedules you#
Time-sharing
Modern operating systems generally use a scheduler that's preemptive, meaning it lets every task run for a while before interrupting it and handing execution to another task. This is the basic mechanism of time-sharing.
The main functional element of any preemptive scheduler is the context switch, that is, the act of restoring the state of a task and handing control to it. For switching to a "target" task I wrote a function in assembly that carries out the following steps:
- Save registers to stack.
- Save stack pointer to current task's TCB.
- Load stack pointer from target task's TCB.
- Load address space of target task.
- Restore registers from stack.
The scheduler gets involved when the Programmable Interval Timer raises an interrupt, which occurs with a set frequency. A brief ISR then updates the time elapsed since the previous timer interrupt. If this interval is long enough, it means the current task has used up its share of CPU time, so the scheduler must switch to the next task, doing so in a round-robin fashion.
While this simple policy works for now, it ignores many important aspects a scheduler worth its salt should take care of. In particular, since there is no mechanism in place for a task to give up the CPU early, the round-robin scheduler cannot distinguish between interactive (or I/O intensive) and CPU-intensive tasks. This scheduler also does not account for task priority at all. It would be more sensible to use a legitimate scheduling algorithm like the multilevel feedback queue.
Conclusion and advice#
Although JamesOS is not a completed project, working on it was great fun. It enhanced my debugging skills and my understanding of computers considerably.
To anyone looking to develop an OS, you will have to do quite some reading. It is a good idea to start with the introduction on OSDev wiki. Look into the source code of hobby operating systems. Many OS developers write articles on topics that may be less comprehensibly covered by the wiki, so make sure to search for those too.
Also, join one of the OSDev communities, like the Discord server or the forum. It's true that I made JamesOS before AI assistants like ChatGPT were available, so I had to ask more experienced developers in times of confusion. Still, I believe you would benefit from asking questions on the forum and from writing as much code yourself as possible. At any point, getting your hands dirty will immediately reveal to you whether you really understand what you are doing.
Share this