Introduction
Welcome to my Kernel From Scratch (KFS) project documentation! This repository contains my implementation of the École 42 "Kernel From Scratch" curriculum, consisting of 10 progressive projects designed to explore and understand kernel architecture from the ground up in the language C.
⚠️ This project is in early stage development. It's not yet ready for production use.
This Book
This book contains more information about my technical approach & a more blog-style writing about each project to get to the current point I am at.
To find the book online, go to this page -> a page
Overview
Introduction
Welcome to my Kernel From Scratch (KFS) project documentation! This repository contains my implementation of the École 42 "Kernel From Scratch" curriculum, consisting of 10 progressive projects designed to explore and understand kernel architecture from the ground up in the language C.
⚠️ This project is in early stage development. It's not yet ready for production use.
Technical Requirements
- Target Architecture: i386 (x86_32)
- Build System: Custom Makefile required
- Programming Language: Not restricted to any specific language
- Compiler Flags:
-nostdlib
: Prevents linking with standard library-fnodefaultlibs
: Excludes default library functions-fno-stack-protector
: Disables stack protection mechanisms
- Custom Linker: Implementation of a custom linker is mandatory
- No cheating! All code must be original; no copying + pasting allowed
Documentation Structure
Each project in this repository is documented with:
- Project Goals and Requirements
- Technical Approach and Implementation Details
- Challenges Encountered and Solutions
- Conclusions and Lessons Learned
Join me
Feel free to explore each project's documentation to understand building a kernel from scratch. The projects are organized sequentially, with each building upon the knowledge and components developed in previous sections.
KFS 01 - Grub, boot & screen
Introduction
Our first KFS
project! I was quite nervous myself at first looking at this. My previous experience with kernels was limited to Linux from Scratch, which barely scratches the surface compared to KFS.
For this project, I chose the C Programming Language. I simply already have a few years of experience in C/C++ & Assembly, which will definitly be useful.
Goals
- A kernel that can boot via Grub
- An
ASM
bootable base - A basic Kernel Library
- Show "42" on screen
Technical Approach & Implimentation
My approach was quite straightforward for this project. Read, read & READ! I primarily started reading OSDev. They have a straightforward tutorial for OS booting in C/ASM
. With ease, I was able to make a system bootable.
Philipp Oppermann's blog helped me a lot! The blog is focused on developing a Rust Kernel, but it gave me more insight on how to setup a C Language environment. His writing style is to me more understandable compared to OSDev.
After that I noticed Mr. Oppermann having a second tutorial on VGA; how to set it up and print to it, which is one of the requirements. After setting that all up, I just had to put the dots on the i, and cross the t's.
I added a nix-shell
. nix-shell
creates an interactive shell based on a Nix expression.
It makes sure you get less of the "It works on my machine" & it is also great if you switch often from different devices. Nix as a language on the otherhand is quite unintutive. Their documentation is known to be a sluggish, because of that I had a very hard time finding out how to cross-compile gcc
using nix-shell
.
Challenges
The hardest challange of this project was understanding the nix-shell
, because the documentation of Nix is quite limited. It was just trying a lot of things until it worked.
Conclusion & Lesson Learned
In the end, it went much smoother than expected. There were plenty of tutorials and understandable documentation to get me through the first project.
I am still happy with my choice to use nix-shell
. It will definitely avoid headaches in the future.
KFS 02 - GDT & Stack
Introduction
Let's proceed to the second KFS
project. The first was doable and I felt confident doing the second one.
For this project, we had to implement a GDT (Global Descriptor Table). The GDT serves as a fundamental data structure in x86 architecture, playing a crucial role in memory management and protection. When our computer starts, it begins in real mode
, a simple operating mode that provides direct access to memory and I/O devices. However we need to switch to protected mode, which introduces memory protection, virtual memory, and privilege levels.
Think of protected mode
as establishing different security clearance levels in a building. The GDT acts as the rulebook for this security system, defining the access rights and boundaries for each level. The x86 architecture provides four rings (0-3), where ring 0 is the most privileged (kernel space) and ring 3 is the least privileged (user space). Each ring has specific permissions and restrictions, all defined in our GDT.
The GDT is essential not just for security, but also for the basic operation of protected mode
. Without a properly configured GDT, the CPU cannot execute protected mode
code at all.
Goals
The project requires creating a GDT at 0x00000800 with entries for Kernel Data Space, Kernel Code Space, User Data Space, and User Code Space. Additionally, we need to add minimal PS/2 Keyboard Support and implement a basic shell with the commands reboot
& gdt
. The gdt
command will print the GDT entries in a human-readable way.
Technical Approach & Implementation
My journey began with studying the OSDev documentation. The concepts were initially overwhelming. The terminology they used like segment descriptors, privilege levels, and descriptor flags felt like learning a new language. After watching several YouTube tutorials (here & here) about GDT implementation, things started to click.
I faced a choice: implement the GDT in Assembly or C. While Assembly would give more direct control, I chose C for its human-readability and my familiarity with the language. Here's how I structured the implementation:
The boot process begins in boot.asm, where we set up multiboot flags and prepare for the transition to protected mode. Once we call kmain()
, it would call the function gdt_init()
. This would create the GDT that we will use in our kernel.
extern void gdt_flush(uint32_t);
entry_t gdt_entries[5] __attribute__((section(".gdt")));
descriptor_pointer_t gdt_ptr;
void gdt_set_gate(uint32_t num, uint32_t base, uint32_t limit, uint8_t access,
uint8_t gran) {
...
}
void gdt_init() {
gdt_ptr.limit = sizeof(entry_t) * 5 - 1;
gdt_ptr.base = (uint32_t)&gdt_entries;
gdt_set_gate(0, 0, 0, 0, 0); // NULL Gate
gdt_set_gate(1, 0, 0xFFFFFFFF, 0x9A, 0xCF); // Kernel Code Segment
gdt_set_gate(2, 0, 0xFFFFFFFF, 0x92, 0xCF); // Kernel Data Segment
gdt_set_gate(3, 0, 0xFFFFFFFF, 0xFA, 0xCF); // User Code Segment
gdt_set_gate(4, 0, 0xFFFFFFFF, 0xF2, 0xCF); // User Data Segment
gdt_flush((uint32_t)&gdt_ptr);
}
We call gdt_set_gate() which creates what are known as segment descriptors
or gates
. The function takes five parameters:
- num: The index of the current Gate we are configuring
- base: The starting address of the segment (
0
for flat memory model) - limit: The maximum addressable unit (
0xFFFFFFFF
means use entire 4GB address space) - access: Defines segment privileges and type
- granularity: Controls granularity and size
At the top of the code snippet you will see two variables.
entry_t gdt_entries[5] __attribute__((section(".gdt")));
is where we will place the entries in. We will have 5 gates in total. The __attribute__((section(".gdt")))
is a compiler directive that instructs the linker
to place this array in a special data section named .gdt
. I then position this section at a specific memory address using the linker script.
descriptor_pointer_t gdt_ptr
is what we put into the lgdt
register. The CPU needs this to know where it can find the GDT Gates
& what its size is.
After setting up the GDT, I implemented basic keyboard support. While my current polling approach isn't ideal (it continuously checks for keystrokes), it works for our basic shell. A proper implementation would use interrupts to handle keyboard events, but that's a topic for future projects. The VGA driver from KFS_01 was adapted to create a simple shell interface, allowing for the reboot
and gdt
commands.
The system still experienced triple faults initially. The cause of the initial triple faults was the linker script
. Although I used the __attribute__
to place the GDT in a custom section, I hadn't yet told the linker
where to place that .gdt
section in the final memory layout, leading to the crash. I ensured our GDT was placed at the correct memory address. The ordering is crucial: BIOS boot code, then GDT, then the rest of our kernel.
/* Start at 2MB */
. = 2M;
.gdt 0x800 : ALIGN(0x800)
{
*(.gdt)
}
/* The rest... */
By using the command: objdump -h ferrite-c.elf | grep gdt
in your terminal. It will show something like this:
0 .gdt 00000028 00000800 00000800 00000800 2**11
The second column show 00000800
, which means you did it! The GDT is placed in 0x00000800. Alternativly, you can write in your program:
void print_gdt(void) {
descriptor_pointer_t gdtr;
__asm__ volatile("sgdt %0" : "=m"(gdtr));
printk("GDT Base Address: 0x%x\n", gdtr.base);
printk("GDT Limit: 0x%x\n", gdtr.limit);
}
Challenges
The challenges were mostly understanding the GDT. I struggled to grasp its purpose and exact workings. It took me reading several articles and watching multiple videos to finally understand what it's meant to do.
I also had no real experience with the linker. Finding the source of the triple fault was particularly frustrating, and it took quite a while before I realized the linker might not be placing the GDT at the correct address.
Conclusion & Lesson Learned
I found that I needed to reread materials multiple times to fully grasp concepts. Fortunately, there was plenty of documentation available about the GDT and its implementation. Working with the GDT motivated me to document everything extensively, like these pages. I mainly do this to ensure I truly understand the functionality of each component I'm working with. And to help others who might follow this path, of course. ;)
KFS 04 - Interrupts
Introduction
Interrupts are a critical part of any operating system, serving as a key mechanism for handling everything from hardware input to critical CPU errors. They allow the OS to respond to events asynchronously, ensuring that unexpected exceptions don't become fatal system crashes and that devices like the keyboard can communicate with the CPU efficiently. This post details the process of implementing a complete interrupt handling system.
Goals
The project goals were as follows:
-
Create and Register an Interrupt Descriptor Table (IDT): The foundational step was to properly define, populate, and load an IDT so the CPU knows where to find our handlers.
-
Implement a Signal-Callback System: Design a kernel API that can associate interrupt signals with specific callback functions.
-
Develop an Interrupt Scheduling Interface: To keep the time spent in an actual interrupt minimal, the goal was to create a system to schedule the main workload of a handler to be run outside of the interrupt context.
-
Implement Pre-Panic Cleanup: For security and stability, create an interface to clean general-purpose registers before a system panic or halt.
-
Implement Stack Saving on Panic: To aid in debugging, the system needed to save the stack state before a panic, allowing for post-mortem analysis of what caused the error.
-
Implement Keyboard Handling: With the core interrupt system in place, the final goal was to use it to handle input from the keyboard.
Technical Approach & Implementation
My approach was as straightforward as it could be. I began by initializing the IDT, creating the necessary exception handlers, and testing them. Programming the IDT is quite abstract since much of the logic is already baked into the CPU, leaving little room for creative implementation.
I implemented the IDT as follows:
// idt.c
typedef struct {
interrupt_handler_e type;
union {
// Some functions receive an error_code to help identify the error.
interrupt_handler regular;
interrupt_handler_with_error with_error_code;
} handler;
} interrupt_handler_entry_t;
const interrupt_handler_entry_t INTERRUPT_HANDLERS[17] = {
{REGULAR, .handler.regular = divide_by_zero_handler},
... // The other functions
};
interrupt_descriptor_t idt_entries[IDT_ENTRY_COUNT];
descriptor_pointer_t idt_ptr;
void idt_set_gate(uint32_t num, uint32_t handler) {
...
}
void idt_init(void) {
for (int32_t i = 0; i < 17; i += 1) {
uint32_t handler = 0;
if (INTERRUPT_HANDLERS[i].type == REGULAR) {
handler = (uint32_t)INTERRUPT_HANDLERS[i].handler.regular;
} else if (INTERRUPT_HANDLERS[i].type == WITH_ERROR_CODE) {
handler = (uint32_t)INTERRUPT_HANDLERS[i].handler.with_error_code;
}
// Pass the index & address of the function to set it correctly in the IDT.
idt_set_gate(i, handler);
}
// Set gates for the first 32 exceptions.
// 0x08 is the kernel code segment selector. 0x8E are the flags for an interrupt gate.
idt_ptr.limit = sizeof(entry_t) * IDT_ENTRY_COUNT - 1;
idt_ptr.base = (uint32_t)&idt_entries;
// Load the IDT using the lidt assembly instruction.
__asm__ __volatile__("lidt %0" : : "m"(idt_ptr));
}
// exceptions.c
__attribute__((target("general-regs-only"), interrupt)) void
divide_by_zero_handler(registers_t *regs) {
// The 'cs' register is on the stack as part of the interrupt frame.
// We can inspect it to see if the fault was in kernel-mode (CPL=0) or user-mode (CPL=3).
// This requires a more complex reading of the interrupt frame.
// For now, we assume a kernel fault is a panic.
if ((regs->cs & 3) == 0) {
panic(regs, "Division by Zero");
}
__asm__ volatile("cli; hlt");
}
The core of the logic revolves around two key variables: idt_entries
and idt_ptr
. The idt_entries
array is the table itself, which will hold all 256 vectors. The idt_ptr
is the structure we pass to the CPU, containing the base address and limit (size) of the table, so the processor knows exactly where to find it.
In the idt_init()
function, we loop through our predefined exception handlers. While you could hardcode each idt_set_gate()
call, a loop makes the code cleaner. This loop retrieves the memory address for each handler function and calls idt_set_gate()
to correctly populate the entry in the idt_entries table.
The final step is the lidt
assembly instruction. This tells the CPU to load our idt_ptr
, making our new Interrupt Descriptor Table active. From this point on, the CPU will use our table to find the correct handler for any interrupt or exception that occurs.
When an interrupt happens, the CPU needs to stop its current work and jump to the handler, but it must be able to return and resume its work later. The __attribute__((interrupt))
tells the compiler to automatically add the necessary assembly code to save the machine's state before your C code runs and restore it after. This is why interrupts should be as fast as possible; while a handler is running, the rest of the system is paused. For frequent events like keyboard presses, a common strategy is for the handler to do the bare minimum—like adding a key press to a queue—and letting a separate, lower-priority task scheduler process it later.
Once the IDT was set, I added a task scheduler. Inside an interrupt handler, I would add a task to the task_scheduler, which would add it to a queue. The main kernel loop then calls run_scheduled_tasks() to trigger the actual work of the interrupt. This is a great way to avoid staying too long in the interrupt itself. The shorter the interrupt, the faster and more responsive your kernel will be.
The panic()
function is designed to terminate everything gracefully when a fatal, unrecoverable error occurs. When panicking, it's important to not only print an error message but also to dump the register state for debugging and then clean them for security. Keep the printing to a minimum, since you cannot rely on the stability of any system services at this point.
After that was all set, I was able to set up the keyboard. This required communicating with the 8259 PIC
(Programmable Interrupt Controller), which manages hardware interrupts. The keyboard sends a signal to the PIC, which then interrupts the CPU. I made use of my task scheduler to queue up keyboard presses to spend as little time as possible in the interrupt. It looks something like this:
// exception.c
__attribute__((target("general-regs-only"), interrupt)) void
keyboard_handler(registers_t *regs) {
// Read the scancode from the keyboard's data port.
int32_t scancode = inb(KEYBOARD_DATA_PORT);
setscancode(scancode); // Store the scancode for processing.
// Schedule the main keyboard logic to run outside the interrupt.
schedule_task(keyboard_input);
// Send End-of-Interrupt (EOI) signal to the PIC.
pic_send_eoi(1);
}
// Kernel.c
while (true) {
// In the main kernel loop, execute any tasks that have been scheduled.
run_scheduled_tasks();
// Halt the CPU until the next interrupt occurs to save power.
__asm__ volatile("hlt");
}
To break it down, the keyboard_handler()
first reads the scancode
from the keyboard. It then schedules the real processing task and immediately sends an End-of-Interrupt (EOI) signal to the PIC
, telling it we're done. Meanwhile, the main kernel while-loop continuously runs any scheduled tasks, ensuring that the heavy lifting happens outside of the critical interrupt context.
Challenges
Implementing interrupts involved coordinating numerous individual components: the GDT (Global Descriptor Table), IDT, PIC (Programmable Interrupt Controller), and the interrupts themselves. While resources like OsDev
provided great checklists for setup, piecing together all the seperate elements proved challenging. It was quite helpful that I already setup the GDT. It also made the IDT setup much easier.
One unexpected hiccup was finding a source of truth for the behavior of each interrupt and determining which specific i386
interrupts were essential for our kernel. While an LLM offered some assistance, it couldn't match the detail and accuracy found in a specific MIT article.
We also encountered a minor hiccup with our LSP (Language Server Protocol), clangd. It reported an error with our interrupt logic, despite GCC
, our compiler, successfully compiling and running the code without issues. The solution was to ignore the LSP warning and ensure gcc
used the __attribute__((target("general-regs-only"), interrupt))
attributes. The general-regs-only
attribute is a promise to the compiler that only general-purpose registers will be used, which can prevent certain headaches, though it doesn't eliminate all potential issues.
Conclusion & Lessons Learned
In the end, this assignment was very insightful. It is so cool to delve into the components like the Real-Time Clock (RTC), which can provide system time, or how PS/2 keyboards uses interrupts to communicate. Interrupts are truly an ingenious and fundamental part of any operating system. This deep dive has definitely interested me into exploring system calls, which also heavily rely on interrupts.
Global Descriptor Table (GDT)
What is it
The GDT serves as a fundamental data structure in x86 architecture, playing a crucial role in memory management and protection. When our computer starts, it begins in real mode
, a simple operating mode that provides direct access to memory and I/O devices. However we need to switch to protected mode, which introduces memory protection, virtual memory, and privilege levels.
Think of protected mode
as establishing different security clearance levels in a building. The GDT acts as the rulebook for this security system, defining the access rights and boundaries for each level. The x86 architecture provides four rings (0-3), where ring 0 is the most privileged (kernel space) and ring 3 is the least privileged (user space). Each ring has specific permissions and restrictions, all defined in our GDT.
The GDT is essential not just for security, but also for the basic operation of protected mode
. Without a properly configured GDT, the CPU cannot execute protected mode
code at all.
For more information go to OSDev
My Technical Approach
My approach was as follows: I would start at the boot.asm
& setup the multiboot. After everything is setup, I would call kmain()
, which then calls gdt_init()
. gdt_init()
will setup the segment descriptors
of the GDT & ensure that it creates the correct struct pointer that will be passed to gdt_flush
. gdt_flush
will place the entries in the correct registers for the CPU to read.
Here are some snippets to give you a better idea:
extern void gdt_flush(uint32_t); // The Assembly function
entry_t gdt_entries[5] __attribute__((section(".gdt")));
descriptor_pointer_t gdt_ptr;
void gdt_set_gate(uint32_t num, uint32_t base, uint32_t limit, uint8_t access,
uint8_t gran) {
...
}
void gdt_init() {
gdt_ptr.limit = sizeof(entry_t) * 5 - 1;
gdt_ptr.base = (uint32_t)&gdt_entries;
gdt_set_gate(0, 0, 0, 0, 0); // NULL Gate
gdt_set_gate(1, 0, 0xFFFFFFFF, 0x9A, 0xCF); // Kernel Code Segment
gdt_set_gate(2, 0, 0xFFFFFFFF, 0x92, 0xCF); // Kernel Data Segment
gdt_set_gate(3, 0, 0xFFFFFFFF, 0xFA, 0xCF); // User Code Segment
gdt_set_gate(4, 0, 0xFFFFFFFF, 0xF2, 0xCF); // User Data Segment
gdt_flush((uint32_t)&gdt_ptr);
}
The assembly code in gdt_flush.asm
that actually loads the GDT:
gdt_flush:
; Get the address of the variable we passed to the function
mov eax, [esp + 4]
lgdt [eax]
; Set up segments
mov eax, 0x10
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
; Far jump to flush pipeline and load CS
jmp 0x08:.flush
.flush:
ret
Considerations
I considered doing a full assembly setup, but the reason I did not is simply because the C language ensures the readibilty.
Linker
I force the Linker
to put GDT before the rest of the code. You can do it without this, but you will have issues if you would like to place it in a specific address. If that is not necessary for you, you can ignore this. The specific placement of the GDT in memory can be important for some system designs, particularly when dealing with memory management and virtual memory setup. This was my approach:
/* Start at 2MB */
. = 2M;
.gdt 0x800 : ALIGN(0x800)
{
*(.gdt)
}
Memory Layout Considerations
When placing the GDT at a specific address, it's important to ensure that:
- The address is accessible during the transition to protected mode
- The address doesn't conflict with other important system structures
- The address is properly aligned for optimal performance
Interrupts and the IDT
The Interrupt Descriptor Table (IDT) is a data structure used by the x86 architecture to implement an interrupt vector table. The IDT is used by the processor to determine the correct response to interrupts and exceptions. ~ OSDev Wiki
The Interrupt Descriptor Table, or IDT, is a component of the x86
operating system. Interrupts
are a mechanism that allows hardware and software to get the CPU's attention without requiring it to constantly check their status. Instead of constantly polling
, a device or a program can simply "interrupt" the CPU when it needs something. Good examples is a keyboard press or a timer indicating that a time slice has passed.
The IDT can hold up to 256 entries, called vectors. Each entry points to an Interrupt Service Routine (ISR
), which is the code that runs when a specific interrupt occurs. These 256 interrupts can be broadly categorized into two types.
Exceptions (CPU-Generated Interrupts)
These are triggered directly by the CPU when it detects a problem during program execution. The first 32 vectors (0-31) are reserved for these exceptions. Non all the 32 vectors are taken. There were only 17 when the i386
came out.
A classic example is vector 0: Divide-by-Zero. If your code attempts to divide a number by zero, the CPU hardware itself will stop execution and trigger interrupt 0
. If this fault happens while the kernel (ring 0) is executing, it's usually an unrecoverable error. The OS has no choice but to panic
the system.
Hardware Interrupts (External Interrupts)
These are the interrupts generated by hardware devices external to the CPU. Your keyboard, mouse, and system timer (RTC) all use hardware interrupts to communicate.
On older i386 systems, these devices don't talk to the CPU directly. Instead, they are connected to a controller chip called the 8259 PIC
(Programmable Interrupt Controller). The PIC
manages the hardware requests (IRQs) and signals the CPU.
The Common CPU Exceptions
When you write code to handle interrupts, you're mostly concerned with the first 32 vectors, which are reserved for CPU exceptions. Some of these push an error code onto the stack to provide more information about the fault, while others do not. Here is a list of the most common exceptions you will need to handle.
Interrupt | Description | Error Code |
---|---|---|
0 | Divide error | No |
1 | Debug exceptions | No |
2 | Nonmaskable interrupt | No |
3 | Breakpoint | No |
4 | Overflow | No |
5 | Bounds check | No |
6 | Invalid opcode | No |
7 | Coprocessor not available | No |
8 | Double fault | Yes (always 0) |
9 | (reserved) | - |
10 | Invalid TSS | Yes |
11 | Segment not present | Yes |
12 | Stack exception | Yes |
13 | General protection fault | Yes |
14 | Page fault | Yes |
15 | (reserved) | - |
16 | Coprocessor error | No |
17-31 | (reserved) | - |
More information about each interrupt
Setup an IDT
Setting up the IDT as quite similair to the GDT. There are some minor difference. Each vector holds a function pointer to the function that should be called on a certain interrupt. Let's take a look on the Divide-By-Zero again:
// idt.c
typedef struct {
interrupt_handler_e type;
union {
// Some functions receive an error_code to help identify the error.
interrupt_handler regular;
interrupt_handler_with_error with_error_code;
} handler;
} interrupt_handler_entry_t;
const interrupt_handler_entry_t INTERRUPT_HANDLERS[17] = {
{REGULAR, .handler.regular = divide_by_zero_handler},
... // The other functions
};
interrupt_descriptor_t idt_entries[IDT_ENTRY_COUNT];
descriptor_pointer_t idt_ptr;
void idt_set_gate(uint32_t num, uint32_t handler) {
...
}
void idt_init(void) {
for (int32_t i = 0; i < 17; i += 1) {
uint32_t handler = 0;
if (INTERRUPT_HANDLERS[i].type == REGULAR) {
handler = (uint32_t)INTERRUPT_HANDLERS[i].handler.regular;
} else if (INTERRUPT_HANDLERS[i].type == WITH_ERROR_CODE) {
handler = (uint32_t)INTERRUPT_HANDLERS[i].handler.with_error_code;
}
// Pass the index & address of the function to set it correctly in the IDT.
idt_set_gate(i, handler);
}
// Set gates for the first 32 exceptions.
// 0x08 is the kernel code segment selector. 0x8E are the flags for an interrupt gate.
idt_ptr.limit = sizeof(entry_t) * IDT_ENTRY_COUNT - 1;
idt_ptr.base = (uint32_t)&idt_entries;
// Load the IDT using the lidt assembly instruction.
__asm__ __volatile__("lidt %0" : : "m"(idt_ptr));
}
// exceptions.c
__attribute__((target("general-regs-only"), interrupt)) void
divide_by_zero_handler(registers_t *regs) {
// The 'cs' register is on the stack as part of the interrupt frame.
// We can inspect it to see if the fault was in kernel-mode (CPL=0) or user-mode (CPL=3).
// This requires a more complex reading of the interrupt frame.
// For now, we assume a kernel fault is a panic.
if ((regs->cs & 3) == 0) {
panic(regs, "Division by Zero");
}
__asm__ volatile("cli; hlt");
}
The core of the logic revolves around two key variables: idt_entries
and idt_ptr
. The idt_entries
array is the table itself, which will hold all 256 vectors. The idt_ptr
is the structure we pass to the CPU, containing the base address and limit (size) of the table, so the processor knows exactly where to find it.
In the idt_init()
function, we loop through our predefined exception handlers. While you could hardcode each idt_set_gate()
call, a loop makes the code cleaner. This loop retrieves the memory address for each handler function and calls idt_set_gate()
to correctly populate the entry in the idt_entries table.
The final step is the lidt
assembly instruction. This tells the CPU to load our idt_ptr
, making our new Interrupt Descriptor Table active. From this point on, the CPU will use our table to find the correct handler for any interrupt or exception that occurs.
When an interrupt happens, the CPU needs to stop its current work and jump to the handler, but it must be able to return and resume its work later. The __attribute__((interrupt))
tells the compiler to automatically add the necessary assembly code to save the machine's state before your C code runs and restore it after. This is why interrupts should be as fast as possible; while a handler is running, the rest of the system is paused. For frequent events like keyboard presses, a common strategy is for the handler to do the bare minimum—like adding a key press to a queue—and letting a separate, lower-priority task scheduler process it later.
Here is a drawing on how the logic of the interrupts are being handled:
System Calls via Software Interrupts
You ever wondered how system calls worked? Well, a system call is simply a custom software interrupt to do a specific task. It is the most common way to implement system calls. It is probably the most portable way to implement. Linux traditionally uses interrupt 0x80 for this purpose on x86.
Debugging
Debugging is essential for finding bugs and truly understanding what your program does. gdb
is great for debugging kernels. It is extremely powerful and useful tool.
Unlike regular programs where you can directly run gdb
on the compiled binary, kernel debugging requires additional steps since we're running our code in a VM.
There are several debugging approaches:
- Create logfiles
- Print to the terminal (via QEMU)
- Use
gdb
The first two methods are similar, with the main difference being the output destination - file vs terminal. Choose based on your preference.
GDB with QEMU
QEMU can be configured to wait for a GDB connection before executing any code, enabling debugging. Run make debug
. A window will open & QEMU will wait for GDB to connect before starting execution
Connect GDB from another terminal:
gdb
(gdb) target remote localhost:1234
Load debug symbols:
(gdb) symbol-file <kernel bin>
Example debugging session:
gdb
(gdb) target remote localhost:1234
Remote debugging using localhost:1234
0x0000fff0 in ?? ()
(gdb) symbol-file kernel.b
Reading symbols from kernel.b...done.
(gdb) break kernel_main # Add breakpoint to any kernel function
Breakpoint 1 at 0x101800: file kernel/kernel.c, line 12.
(gdb) continue # Qemu starts the kernel
Breakpoint 1, kernel_main (mdb=0x341e0, magic=0) at kernel/kernel.c:12
Reference
Building from Source
You'll need:
nix-shell
for an isolated development environment- QEMU for testing
# Clone the repository
git clone https://github.com/xannyxs/ferrite-c
cd ferrite-c
# Initiate nix-shell
nix-shell shell.nix
# Build the kernel
make
# Run in QEMU
make run
References
Official Documentation
- Intel® 64 and IA-32 Architectures Manual - Primary CPU architecture reference
OSDev and Community Resources
- OSDev Wiki - The Kernel Bible
- OS Theory - Theoretical foundations
- Bare Bones - Basic implementation guide
- Known Bugs - Common pitfalls to avoid
- Brandon Friesen's Guide - Practical kernel development
- Linux Journal Introduction - Kernel development basics
Learning Resources
- Operating Systems: Three Easy Pieces - OS concepts textbook
- Writing an OS in Rust - Rust kernel development guide
- PS/2 Keyboard Hardware - Hardware implementation details
- Rust Atomics and Locks - All about mutexes, condition variables, atomics, and memory ordering
- Interrupt Handeling
- i386 Interrupts MIT
Development Tools
- nixpkgs Cross-Compilation - Build system setup
- x86 and amd64 instruction reference - Derived from the December 2023 version of the Intel® 64