KFS — Kernel From Scratch
This is the complete documentation for building a bare-metal i386 kernel in Rust, written as it was actually built — including the wrong turns, the debugging sessions at 5am, and the moments where everything finally clicked.
The Kernel From Scratch (KFS) series is a 42 school project, but the documentation here goes far beyond what the subject requires. Every concept is explained from first principles. Every decision has a “why”. Every bug that caused a headache has its own section.
What This Is
A working reference for anyone building or studying a kernel from scratch in Rust. Not a tutorial that skips the hard parts — a complete record of what it actually takes.
What You’ll Build
By following through all three projects:
- A kernel that boots via GRUB on i386 bare metal
- VGA text mode output with color, scrolling, and cursor control
- Interrupt-driven keyboard input
- A proper interrupt subsystem (IDT + PIC + exception handlers)
- A Global Descriptor Table (GDT) with kernel and user segments
- An interactive shell
- A working memory paging system with frame allocation
How to Use This Book
Each chapter documents a concept at the theory level first, then walks through the implementation in Rust. Code examples are taken directly from the actual kernel source.
The debugging chapters are not optional reading — they contain the lessons that can’t be found in any spec.
Prerequisites
- Basic Rust knowledge (ownership, unsafe, no_std)
- Comfortable reading x86 assembly
- A Linux system with QEMU and NASM installed
Built with 🦀 Rust, fuelled by too much coffee, and the eternal optimism that this time it won’t triple fault.
KFS_1 — Kernel From Scratch
This is it. Kernel From Scratch. We are actually building a kernel.
Without any existing software, API, or anything.
There are many parts to this project and they are all linked together. As we build these features, keep in mind that the kernel must be flexible and that functions must easily fit in. Half of the time you’ll spend on these projects will be adding links between different aspects of your kernel.
That means, you have to write memory code before processes & execution code. But processes must use memory, right? So those two have to be linked. Keep it clean, and the internal API simple.
Objectives
By the end of KFS_1, you will have:
- A kernel you can boot via GRUB
- An ASM bootable base
- A basic kernel library, with basic functions and types
- Code to print text on the screen
- A basic “Hello World” kernel
- Full keyboard input via hardware interrupts
- Exception handling to catch CPU faults
- A robust interrupt system
Mandatory Requirements
The kernel must be able to boot with GRUB and write characters on screen. That means:
- Install GRUB on a virtual image
- Write an ASM boot code that handles the Multiboot header, and use GRUB to initialise and call the main function of the kernel
- Compile with correct flags, and link to make it bootable
- Write helpers like kernel types or basic functions
- Must be smaller than 10MB
- Code the interface between the kernel and the screen
- Display text on the screen
For the linker, you must create a linker file with the GNU linker (ld).
Bonus Features (Implemented)
- ✅ Scroll and cursor support
- ✅ Color support
- ✅
printf/printkhelpers - ✅ Keyboard input via interrupts
- ✅ Exception handlers
Project Structure
kfs_1/kfs/
├── Cargo.toml
├── Cargo.lock
├── i386-unknown-none.json # Custom bare-metal target
├── boot.asm # Multiboot header + stack setup
├── kb.asm # Keyboard interrupt wrapper
├── exceptions.asm # Exception handler wrappers
├── linker.ld # Linker script
├── Makefile
└── src/
├── lib.rs # Kernel entry point + panic handler
├── vga.rs # VGA text mode driver
├── idt.rs # Interrupt Descriptor Table
├── pic.rs # Programmable Interrupt Controller
├── kb.rs # Keyboard handler
└── exceptions.rs # CPU exception handlers
Build & Run
make # Build kernel
make run # Run in QEMU
make clean # Clean artifacts
make re # Full rebuild
Prerequisites
rustup toolchain install nightly
rustup component add rust-src --toolchain nightly
sudo pacman -S nasm qemu-system-i386 # Arch Linux
Freestanding Rust Binary
The first real step is creating a Rust executable that does not link to the standard library. That way we can run Rust code on bare metal without an underlying operating system.
This means we will not have threads, files, heap memory, the network, random numbers, standard output, or any features requiring OS abstractions or specific hardware. We have to make all of these on our own.
However, there are some things we can use: iterators, closures, pattern matching, Option and Result, string formatting, and the ownership system. These features make it possible to write a kernel in a very expressive, high-level way without worrying about undefined behavior or memory safety.
Disabling the Standard Library
By default, all Rust crates link the standard library, which depends on the operating system for threads, files, networking, and the C standard library libc.
Creating a new project:
cargo new kfs --bin --edition 2024
To break that down:
kfs— name of the crate--bin— creates an executable binary (not a library)--edition 2024— use the 2024 edition of Rust
To disable the standard library, add #![no_std] at the top of src/main.rs:
#![no_std]
fn main() {
println!("Hello, world!");
}
Running cargo build will now error because println! requires the standard library. That’s expected — we’re breaking free.
Panic Implementation
The standard library provides its own panic handler. In a no_std environment, we define our own:
#![allow(unused)]
fn main() {
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
}
The PanicInfo parameter contains the file, line, and optional panic message. The ! return type means this function is diverging — it never returns. For now, we just loop forever.
Disabling Unwinding
Rust can unwind the stack on panic, walking frames to produce a backtrace. In a kernel, this requires OS support we don’t have. We disable it in Cargo.toml:
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"
This sets the panic strategy to abort for both profiles — no unwinding, just halt.
What Do You Do Without a main()?
A typical Rust binary has a runtime that runs before main() — it initialises the garbage collector (if any), sets up stack overflow guards, etc. We have none of that.
We need to:
- Tell the Rust compiler we don’t want the normal entry point chain with
#![no_main] - Define our own entry point
#![allow(unused)]
#![no_std]
#![no_main]
fn main() {
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
#[no_mangle]
pub extern "C" fn kernel_main() -> ! {
loop {}
}
}
#[no_mangle] — Disables Rust’s name mangling. Without this, the compiler generates a cryptic symbol name and the linker can’t find the entry point.
extern "C" — Uses the C calling convention. Required so the assembly bootloader can call into Rust correctly.
-> ! — The function is diverging. The entry point is not called by any other function — it’s invoked directly by the bootloader. There’s nothing to return to.
Building for Bare Metal
By default, Rust compiles for your current host system. We need to cross-compile for a bare-metal i386 target with no OS:
rustup run nightly cargo build -Z build-std=core --target i386-unknown-none.json
The custom target file i386-unknown-none.json:
{
"llvm-target": "i386-unknown-none",
"data-layout": "e-m:e-p:32:32-...",
"arch": "x86",
"target-endian": "little",
"target-pointer-width": 32,
"target-c-int-width": 32,
"os": "none",
"executables": true,
"linker-flavor": "ld.lld",
"linker": "rust-lld",
"panic-strategy": "abort",
"disable-redzone": true,
"features": "-mmx,-sse"
}
Key points:
"os": "none"— No operating system"disable-redzone": true— Required for interrupt handlers (the red zone is a 128-byte region below RSP that interrupts would clobber)"-mmx,-sse"— Disable SIMD; the kernel shouldn’t use floating point registers
Cargo.toml
[package]
name = "kfs"
version = "0.1.0"
edition = "2021"
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"
[lib]
crate-type = ["staticlib"]
crate-type = ["staticlib"] builds the Rust code as a static library (.a file) that the linker will combine with the assembly object files.
Why Rust?
- Memory safety without garbage collection
- Zero-cost abstractions
- Pattern matching and type safety
- No undefined behavior (in safe code)
- Great tooling for cross-compilation
The challenge: No standard library means no String, no Vec, no Box. Translating C kernel examples to Rust requires understanding what each C construct really does at the memory level — which turns out to be a fantastic forcing function for actually learning it.
Boot Process & Multiboot
What Happens When You Turn a Computer On
When you turn on a computer, it begins executing firmware stored in motherboard ROM. This code performs a power-on self-test, detects available RAM, and pre-initialises the CPU and hardware. Afterwards it looks for a bootable disk and starts booting the OS kernel.
On x86 there are two firmware standards: BIOS (Basic Input/Output System) and the newer UEFI. BIOS is old and outdated, but simple and well-supported on any x86 machine since the 1980s. We use BIOS.
BIOS Boot Sequence
- BIOS runs self-test and hardware initialisation
- BIOS looks for a bootable disk (checks the boot sector)
- BIOS loads the bootloader from the disk
- The bootloader switches the CPU from 16-bit real mode → 32-bit protected mode
- The bootloader loads the kernel into memory
- The bootloader jumps to the kernel entry point
The bootloader also queries the BIOS for memory maps and hardware information and passes that to the kernel.
The Multiboot Standard
To avoid every OS implementing its own bootloader, the Free Software Foundation created the Multiboot standard in 1995. It defines an interface between the bootloader and the OS:
Any Multiboot-compliant bootloader can load any Multiboot-compliant kernel.
The reference implementation is GNU GRUB, the most popular bootloader for Linux systems. To make a kernel Multiboot-compliant, we insert a Multiboot header at the beginning of the kernel file.
What GRUB Does For Us
- Scans the first 8KB of the kernel file for the magic number
0x1BADB002 - Reads the Multiboot header to understand what the kernel needs
- Loads the kernel into memory at address 1MB (
0x00100000) - Switches CPU to 32-bit protected mode
- Jumps to the kernel’s entry point (
_start)
The Multiboot Header
section .multiboot_header
align 4
MULTIBOOT_MAGIC equ 0x1BADB002
MULTIBOOT_FLAGS equ 0x00000003
MULTIBOOT_CHECKSUM equ -(MULTIBOOT_MAGIC + MULTIBOOT_FLAGS)
dd MULTIBOOT_MAGIC
dd MULTIBOOT_FLAGS
dd MULTIBOOT_CHECKSUM
Magic Number: 0x1BADB002
This is the special number GRUB searches for. “1 BAD B002” — someone had a sense of humour.
Flags: 0x00000003
- Bit 0 (value 1): Align loaded modules on page boundaries
- Bit 1 (value 2): Provide a memory map to the kernel
Checksum
Error detection. The formula ensures: magic + flags + checksum == 0
0x1BADB002 + 0x00000003 + checksum = 0
checksum = -(0x1BADB002 + 0x00000003)
The Stack
Before calling Rust code, the assembly bootloader must set up a stack. The CPU needs a stack for function calls, local variables, and interrupt handlers.
section .bss
align 16
stack_bottom:
resb 65536 ; 64KB stack
stack_top:
Key properties of the x86 stack:
- LIFO — Last In, First Out
- Grows downward — from high addresses to low addresses
- ESP points to the top — the most recently added item
We reserve 64KB. This was increased from an initial 16KB after we discovered that interrupt handlers need significant stack space.
The Boot Code
section .text
global _start
extern kernel_main
_start:
mov esp, stack_top ; Set up stack pointer
call kernel_main ; Jump to Rust kernel
.hang:
cli ; Disable interrupts
hlt ; Halt CPU
jmp .hang ; Loop forever if we somehow wake up
mov esp, stack_top — Set the Stack Pointer to the top of our reserved region. Remember the stack grows downward, so we start at the top.
call kernel_main — Jump into Rust. From here, the kernel controls everything.
The .hang label handles the case where kernel_main somehow returns (which it shouldn’t — it’s declared -> !). We disable interrupts and halt the CPU.
Key Assembly Instructions Used
| Instruction | Meaning |
|---|---|
mov dest, src | Copy data from src to dest |
call fn | Jump to function (saves return address on stack) |
cli | Clear Interrupts — disable all maskable interrupts |
hlt | Halt CPU until next interrupt |
sti | Set Interrupts — enable interrupts |
pusha | Push all general-purpose registers to stack |
popa | Pop all general-purpose registers from stack |
iretd | Return from interrupt (32-bit version) |
Memory Layout After Boot
0x00100000 (_kernel_start)
↓
[.multiboot] ← GRUB header (MUST be in first 8KB)
[.text] ← Code: _start, kernel_main, handlers
[.rodata] ← Constants, strings
[.data] ← Initialised globals
[.bss] ← Stack, uninitialised data
↓
0x001XXXXX (_kernel_end)
The kernel is loaded at 1MB. Everything below 1MB is either BIOS data, BIOS ROM, or hardware memory-mapped regions (like the VGA buffer at 0xB8000).
Linker Scripts
A linker script tells the linker (ld) exactly how to arrange all the pieces of the kernel into a final binary. Without one, the linker would put sections wherever it felt like — and GRUB would fail to find our Multiboot header.
Why We Need a Custom Linker Script
Two reasons:
- GRUB scans only the first 8KB for the Multiboot header. If our header ends up in the wrong place, GRUB won’t recognise the binary.
- We need to control the load address. GRUB loads the kernel at 1MB (
0x00100000). The linker needs to know this.
The rule from the subject: we cannot use the .ld file of the host system. We write our own.
The Linker Script
ENTRY(_start)
SECTIONS
{
/* Kernel is loaded at 1MB by GRUB */
. = 1M;
_kernel_start = .;
/* Multiboot header MUST come first (GRUB looks in first 8KB) */
.multiboot ALIGN(4K) : {
*(.multiboot_header)
}
/* Read-only code section */
.text ALIGN(4K) : {
*(.text)
*(.text.*)
}
/* Read-only data section */
.rodata ALIGN(4K) : {
*(.rodata)
*(.rodata.*)
}
/* Read-write data section */
.data ALIGN(4K) : {
*(.data)
*(.data.*)
}
/* Uninitialised data section */
.bss ALIGN(4K) : {
*(.bss)
*(.bss.*)
*(COMMON)
}
_kernel_end = .;
/* Discard debug and comment sections */
/DISCARD/ : {
*(.comment)
*(.eh_frame)
*(.note*)
}
}
Line-by-Line Breakdown
Entry Point
ENTRY(_start)
Tells the linker “execution begins at _start”. This is the symbol defined in boot.asm.
Setting the Base Address
. = 1M;
The dot (.) is the location counter — it tracks where the linker is currently placing things. Setting it to 1M means “start placing sections at address 1MB (0x00100000)”. This matches where GRUB loads the kernel.
The Multiboot Section
.multiboot ALIGN(4K) : {
*(.multiboot_header)
}
ALIGN(4K) aligns this section to a 4KB boundary. The *(.multiboot_header) pattern means “collect all .multiboot_header sections from all input files”. Since we put our Multiboot header in section .multiboot_header in boot.asm, it lands here — first in the binary.
Text, Data, BSS
Each section uses ALIGN(4K) to ensure 4KB page alignment. This matters for paging later — memory protection works at page granularity.
.text— Executable code.rodata— Read-only data (string literals, constants).data— Initialised read-write data (global variables with values).bss— Uninitialised data (zero-initialised, includes our stack)
Kernel Boundary Symbols
_kernel_start = .;
/* ... all sections ... */
_kernel_end = .;
These create symbols that Rust code can reference:
#![allow(unused)]
fn main() {
extern "C" {
static _kernel_end: u8;
}
let kernel_end = &_kernel_end as *const u8 as u32;
}
_kernel_end is critical for the memory allocator in KFS_3 — we need to know where the kernel ends so we don’t allocate frames on top of ourselves.
Discarding Sections
/DISCARD/ : {
*(.comment)
*(.eh_frame)
*(.note*)
}
These sections are generated by compilers but are not needed in our kernel:
.comment— Compiler version info.eh_frame— Exception handling frames (we’re not using stack unwinding).note*— Various annotations
Discarding them keeps the binary smaller and avoids the linker warning about RWX segment permissions.
Linking Command
ld -m elf_i386 -n -T linker.ld -o kernel.bin boot.o kb.o exc.o libkfs.a
-m elf_i386— Target i386 ELF format-n— Disable automatic section alignment (we control this in the script)-T linker.ld— Use our linker script- The inputs: assembly
.ofiles and the Rust static library
Memory Layout After Linking
0x00100000 (_kernel_start)
│
├── [.multiboot] ← Multiboot header
├── [.text] ← Code (_start, kernel_main, handlers)
├── [.rodata] ← String literals, constants
├── [.data] ← Initialised global variables
└── [.bss] ← Stack, uninitialised variables
│
0x001XXXXX (_kernel_end)
VGA Text Mode
What is VGA Text Mode?
VGA (Video Graphics Array) text mode is a simple way to display text on screen. When a computer boots, it starts in VGA text mode by default — so the screen is already working before we do anything. We just need to write to the right memory address.
Key properties:
- 80 columns × 25 rows = 2000 character positions
- 16 foreground colours, 16 background colours
- Memory-mapped at physical address
0xB8000
The VGA Buffer
The VGA text buffer lives at 0xB8000 in physical memory. Writing to this address writes to the screen.
Each character takes 2 bytes:
[Byte 0]: ASCII character code
[Byte 1]: Colour attribute (4 bits background | 4 bits foreground)
Buffer layout:
Screen position (row 0, col 0) → Address 0xB8000
Screen position (row 0, col 1) → Address 0xB8002
Screen position (row 1, col 0) → Address 0xB80A0 (row * 80 * 2 = 160 bytes per row)
Formula for any position:
#![allow(unused)]
fn main() {
let offset = (row * VGA_WIDTH + col) * 2;
// Character byte at: 0xB8000 + offset
// Colour byte at: 0xB8000 + offset + 1
}
Colour System
The colour byte packs foreground and background into 8 bits:
Bit: 7 6 5 4 | 3 2 1 0
────────┼────────
Background│Foreground
#![allow(unused)]
fn main() {
pub const fn color_byte(fg: Color, bg: Color) -> u8 {
(bg as u8) << 4 | (fg as u8)
}
}
Colour Values
| Value | Colour | Value | Colour |
|---|---|---|---|
| 0 | Black | 8 | Dark Gray |
| 1 | Blue | 9 | Light Blue |
| 2 | Green | 10 | Light Green |
| 3 | Cyan | 11 | Light Cyan |
| 4 | Red | 12 | Light Red |
| 5 | Magenta | 13 | Pink |
| 6 | Brown | 14 | Yellow |
| 7 | Light Gray | 15 | White |
The Writer
The Writer struct tracks cursor position and current colour, and exposes methods to write to the buffer:
#![allow(unused)]
fn main() {
pub struct Writer {
column: usize,
row: usize,
color: u8,
}
}
Writing a Byte
#![allow(unused)]
fn main() {
pub fn write_byte(&mut self, byte: u8) {
match byte {
b'\n' => self.new_line(),
byte => {
if self.column >= VGA_WIDTH {
self.new_line();
}
let offset = (self.row * VGA_WIDTH + self.column) * 2;
unsafe {
*VGA_BUFFER.add(offset) = byte;
*VGA_BUFFER.add(offset + 1) = self.color;
}
self.column += 1;
}
}
self.update_cursor();
}
}
The unsafe block is required because we’re writing to a raw pointer — we’re directly manipulating hardware memory. Rust can’t verify this is safe; we know it is because we control the address space.
Scrolling
When the cursor reaches the last row, we scroll the entire screen up by one row:
#![allow(unused)]
fn main() {
fn scroll(&mut self) {
unsafe {
// Move each row up by one
for row in 1..VGA_HEIGHT {
for col in 0..VGA_WIDTH {
let src = (row * VGA_WIDTH + col) * 2;
let dst = ((row - 1) * VGA_WIDTH + col) * 2;
*VGA_BUFFER.add(dst) = *VGA_BUFFER.add(src);
*VGA_BUFFER.add(dst + 1) = *VGA_BUFFER.add(src + 1);
}
}
// Clear the last row
for col in 0..VGA_WIDTH {
let offset = ((VGA_HEIGHT - 1) * VGA_WIDTH + col) * 2;
*VGA_BUFFER.add(offset) = b' ';
*VGA_BUFFER.add(offset + 1) = self.color;
}
}
self.row = VGA_HEIGHT - 1;
self.column = 0;
}
}
Hardware Cursor
The VGA hardware has its own blinking cursor. We control it through port I/O — a separate address space accessed with special CPU instructions.
- Port
0x3D4— Control register (select which cursor setting to change) - Port
0x3D5— Data register (write the value)
#![allow(unused)]
fn main() {
fn update_cursor(&self) {
let pos = self.row * VGA_WIDTH + self.column;
unsafe {
// Send high byte of position
outb(VGA_CTRL_PORT, 0x0E);
outb(VGA_DATA_PORT, (pos >> 8) as u8);
// Send low byte of position
outb(VGA_CTRL_PORT, 0x0F);
outb(VGA_DATA_PORT, pos as u8);
}
}
}
Port I/O uses special CPU instructions — out to write, in to read — accessed from Rust via inline assembly:
#![allow(unused)]
fn main() {
unsafe fn outb(port: u16, value: u8) {
core::arch::asm!(
"out dx, al",
in("dx") port,
in("al") value,
options(nomem, nostack, preserves_flags)
);
}
}
Global Writer
The writer needs to be accessible from anywhere in the kernel (including interrupt handlers). We use a static global with addr_of_mut! to safely get a mutable reference without triggering Rust’s stacking-borrow rules:
#![allow(unused)]
fn main() {
static mut WRITER: Writer = Writer::new();
pub fn writer() -> &'static mut Writer {
unsafe { &mut *core::ptr::addr_of_mut!(WRITER) }
}
}
Why
addr_of_mut!instead of&mut WRITER?Taking
&mutof a static directly can create undefined behaviour under Rust’s aliasing rules.addr_of_mut!creates a raw pointer to the static without creating a reference, which is then safely cast. This is the correct pattern for mutable statics inno_stdkernels.
Memory-Mapped I/O vs Port I/O
Two different ways hardware communicates with the CPU:
| Memory-Mapped I/O | Port I/O | |
|---|---|---|
| Example | VGA buffer at 0xB8000 | Keyboard at port 0x60 |
| Access | Normal memory reads/writes | Special in/out CPU instructions |
| Address space | Shares physical memory space | Separate 16-bit address space (ports 0–65535) |
| Rust | Raw pointer access | Inline assembly |
The VGA buffer uses memory-mapped I/O. The VGA cursor control registers use port I/O. We end up using both.
Print Macros
Three macros for easy printing, mirroring the standard library API:
print!(...)— Print without newlineprintln!(...)— Print with newlineprintk!(...)— Kernel print (alias forprint!, named after the Linux kernel function)
How They Work
The Print Function
#![allow(unused)]
fn main() {
pub fn _print(args: fmt::Arguments) {
use core::fmt::Write;
unsafe {
let writer = &mut *core::ptr::addr_of_mut!(WRITER);
writer.write_fmt(args).unwrap();
}
}
}
fmt::Arguments is the type produced by format_args!(). It’s a lazily-evaluated format string — nothing is formatted until write_fmt is called. This keeps the hot path efficient.
impl fmt::Write for Writer is what makes this work — it requires a single method:
#![allow(unused)]
fn main() {
impl fmt::Write for Writer {
fn write_str(&mut self, s: &str) -> fmt::Result {
self.write_string(s);
Ok(())
}
}
}
The Macros
#![allow(unused)]
fn main() {
#[macro_export]
macro_rules! print {
($($arg:tt)*) => ($crate::vga::_print(format_args!($($arg)*)));
}
#[macro_export]
macro_rules! println {
() => ($crate::print!("\n"));
($($arg:tt)*) => ($crate::print!("{}\n", format_args!($($arg)*)));
}
#[macro_export]
macro_rules! printk {
($($arg:tt)*) => ($crate::print!($($arg)*));
}
}
#[macro_export] makes the macros available at the crate root, so any module can use them without importing.
The $($arg:tt)* pattern captures any token tree — it’s the standard Rust pattern for variadic macro arguments that get passed to format_args!.
Usage
#![allow(unused)]
fn main() {
println!("KFS_1 - Kernel From Scratch");
println!("Stack pointer: 0x{:08x}", esp);
print!("Loading");
printk!("."); // Kernel-style progress dots
}
The #[macro_use] Pattern
In lib.rs, the VGA module is declared with #[macro_use]:
#![allow(unused)]
fn main() {
#[macro_use]
mod vga;
}
This makes all #[macro_export] macros from vga available throughout the crate without needing use statements. Since print! and println! are used everywhere, this saves a lot of boilerplate.
Cursor & Scroll
Automatic Scrolling
When text reaches row 24 (the last row), the screen scrolls up automatically. The implementation copies each row to the row above it, then clears the last row:
#![allow(unused)]
fn main() {
fn scroll(&mut self) {
unsafe {
for row in 1..VGA_HEIGHT {
for col in 0..VGA_WIDTH {
let src_offset = (row * VGA_WIDTH + col) * 2;
let dst_offset = ((row - 1) * VGA_WIDTH + col) * 2;
*VGA_BUFFER.add(dst_offset) = *VGA_BUFFER.add(src_offset);
*VGA_BUFFER.add(dst_offset + 1) = *VGA_BUFFER.add(src_offset + 1);
}
}
// Clear the last line
for col in 0..VGA_WIDTH {
let offset = ((VGA_HEIGHT - 1) * VGA_WIDTH + col) * 2;
*VGA_BUFFER.add(offset) = b' ';
*VGA_BUFFER.add(offset + 1) = self.color;
}
}
self.row = VGA_HEIGHT - 1;
self.column = 0;
}
}
This is software scrolling — we copy the entire buffer. The alternative (hardware scrolling via the VGA start address register) is more efficient but more complex to implement.
Hardware Cursor
The VGA card maintains a blinking cursor position in hardware registers, separate from our software cursor tracking. If we don’t keep these in sync, the cursor appears in the wrong place on screen.
Control is via port I/O to two registers:
| Port | Role |
|---|---|
0x3D4 | Index register — select which internal register to access |
0x3D5 | Data register — read or write the selected register |
Register 0x0E holds the high byte of cursor position, 0x0F holds the low byte. Position is a flat index: row * 80 + col.
#![allow(unused)]
fn main() {
fn update_cursor(&self) {
let pos = self.row * VGA_WIDTH + self.column;
unsafe {
outb(VGA_CTRL_PORT, 0x0E);
outb(VGA_DATA_PORT, (pos >> 8) as u8);
outb(VGA_CTRL_PORT, 0x0F);
outb(VGA_DATA_PORT, pos as u8);
}
}
}
update_cursor() is called at the end of every write_byte() call, so the hardware cursor always tracks the software state.
Hiding and Showing the Cursor
#![allow(unused)]
fn main() {
pub fn set_cursor_visible(&self, visible: bool) {
unsafe {
outb(VGA_CTRL_PORT, 0x0A);
let cursor_start = if visible { 0x0E } else { 0x20 };
outb(VGA_DATA_PORT, cursor_start);
}
}
}
Register 0x0A controls cursor start scanline. Setting bit 5 (0x20) disables the cursor. Setting it to 0x0E re-enables it with a default underline style.
Backspace
Implemented in KFS_2 for the shell. It moves the column back by one and overwrites with a space:
#![allow(unused)]
fn main() {
pub fn backspace(&mut self) {
if self.column == 0 {
return; // Can't backspace at start of line
}
self.column -= 1;
let offset = (self.row * VGA_WIDTH + self.column) * 2;
unsafe {
*VGA_BUFFER.add(offset) = b' ';
*VGA_BUFFER.add(offset + 1) = self.color;
}
self.update_cursor();
}
}
Note: This doesn’t handle backspacing across a newline (going from column 0 to the previous row). That’s left as a future improvement.
Keyboard Input
Keyboard input is the most complex feature of KFS_1. It requires five separate concepts to work together correctly:
- Interrupts — Hardware signals that stop CPU execution
- IDT — Interrupt Descriptor Table (tells the CPU where handlers are)
- PIC — Programmable Interrupt Controller (manages interrupt routing)
- Scan codes — Raw keyboard codes that need translation
- ASCII conversion — Convert scan codes to printable characters
Get any one of these wrong and the result is either silence (keyboard does nothing) or an interrupt storm (keyboard fires thousands of times per second and the screen flickers into apparent oblivion). Both happened during development.
What Are Interrupts?
The kernel runs in a loop:
#![allow(unused)]
fn main() {
loop {
unsafe { core::arch::asm!("hlt", options(nomem, nostack)); }
}
}
How does the keyboard tell the kernel that a key was pressed?
Bad solution: Polling
#![allow(unused)]
fn main() {
loop {
if keyboard_has_data() {
read_keyboard();
}
}
}
Wastes CPU cycles constantly checking a port that rarely has data.
Good solution: Interrupts
#![allow(unused)]
fn main() {
loop {
hlt(); // CPU sleeps until an interrupt fires
}
// Called automatically by hardware when a key is pressed
fn keyboard_interrupt_handler() {
read_keyboard();
}
}
The CPU sleeps with hlt until the keyboard hardware sends a signal. The CPU wakes up, runs the handler, then goes back to sleep. Zero CPU wasted.
The Interrupt Lifecycle
- Key is pressed → Keyboard hardware sends an electrical signal
- PIC receives signal → Routes it to the CPU as IRQ1
- CPU stops current execution → Saves state (registers, instruction pointer)
- CPU looks up handler → Checks the IDT for the IRQ1 entry
- Handler runs → Reads the key, processes it
- CPU restores state → Returns to whatever it was doing
PS/2 Keyboard Hardware
The keyboard communicates via port I/O:
| Port | Purpose |
|---|---|
0x60 | Data port — read scan codes here |
0x64 | Status port — check if data is available |
Scan Codes
When a key is pressed or released, the keyboard sends a scan code — a number identifying which physical key changed state.
- Key press: Scan code < 128 (e.g., pressing ‘A’ sends
0x1E) - Key release: Scan code + 128 (e.g., releasing ‘A’ sends
0x9E)
We need to filter out releases and translate press codes to ASCII.
Scan Code to ASCII Table
#![allow(unused)]
fn main() {
static SCANCODE_TO_ASCII: [u8; 128] = [
0, 27, b'1', b'2', b'3', b'4', b'5', b'6', // 0x00-0x07
b'7', b'8', b'9', b'0', b'-', b'=', 8, b'\t', // 0x08-0x0F
b'q', b'w', b'e', b'r', b't', b'y', b'u', b'i', // 0x10-0x17
b'o', b'p', b'[', b']', b'\n', 0, b'a', b's', // 0x18-0x1F
b'd', b'f', b'g', b'h', b'j', b'k', b'l', b';', // 0x20-0x27
b'\'', b'`', 0, b'\\', b'y', b'x', b'c', b'v',// 0x28-0x2F
b'b', b'n', b'm', b',', b'.', b'/', 0, b'*', // 0x30-0x37
0, b' ', 0, 0, 0, 0, 0, 0, // 0x38-0x3F
// ... rest zeros (F-keys, numpad, etc.)
];
}
Index into this array with the scan code to get the ASCII character. Zero means “not a printable character.”
The Keyboard Handler
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn rust_keyboard_handler() {
unsafe {
// ALWAYS read from port 0x60 — this clears the keyboard buffer
let scancode = inb(KEYBOARD_DATA_PORT);
// ALWAYS send EOI — this tells the PIC we handled the interrupt
pic::send_eoi(1);
// Filter out releases (scan code >= 128) and nulls
if scancode >= 128 || scancode == 0 {
return;
}
// Translate to ASCII
let ascii = SCANCODE_TO_ASCII[scancode as usize];
// Print if it's a printable character
if ascii != 0 {
crate::print!("{}", ascii as char);
}
}
}
}
Critical ordering: Read the port before sending EOI. The read clears the keyboard buffer and de-asserts the IRQ line. If you send EOI first, the PIC might fire another interrupt before you’ve cleared the source.
Why #[no_mangle] and extern "C"?
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn rust_keyboard_handler()
}
#[no_mangle]— Keep the function name exactly as written. Without it, Rust mangles the name into something like_ZN3kfs2kb20rust_keyboard_handler17h...and the assembly wrapper can’t find it.extern "C"— Use C calling convention. The assembly wrapper usescallwith C-style stack discipline.
The Assembly Wrapper
The IDT stores function pointers to assembly wrappers, not Rust functions directly. The wrappers save and restore registers:
keyboard_interrupt_handler:
cli ; Disable interrupts during handler
pusha ; Save all general-purpose registers
call rust_keyboard_handler ; Call Rust handler
popa ; Restore all registers
sti ; Re-enable interrupts
iretd ; Return from interrupt (32-bit)
Why not call Rust directly from the IDT?
iretd— Interrupt return. Restores the saved CPU state (EIP, CS, EFLAGS) from the stack. A normalretwon’t do this — it would crash.pusha/popa— The Rust handler will use registers. We must save and restore them, otherwise we corrupt whatever was running when the interrupt fired.
Port I/O in Rust
Reading from a hardware port requires inline assembly:
#![allow(unused)]
fn main() {
unsafe fn inb(port: u16) -> u8 {
let value: u8;
core::arch::asm!(
"in al, dx",
out("al") value,
in("dx") port,
options(nomem, nostack, preserves_flags)
);
value
}
}
in al, dx — Read a byte from the port number in dx into the al register. The Rust compiler handles mapping port to dx and extracting the result from al.
Full Flow: Key Press to Screen
[1] You press 'A'
[2] Keyboard sends scan code 0x1E to port 0x60
[3] Keyboard sends electrical signal to PIC (IRQ1)
[4] PIC forwards IRQ1 to CPU as interrupt 33 (after remapping)
[5] CPU saves current state and looks up IDT entry 33
[6] CPU jumps to keyboard_interrupt_handler (ASM wrapper)
[7] ASM: cli, pusha
[8] ASM: calls rust_keyboard_handler
[9] Rust: reads 0x1E from port 0x60
[10] Rust: sends EOI to PIC
[11] Rust: SCANCODE_TO_ASCII[0x1E] = b'a'
[12] Rust: print!("a")
[13] ASM: popa, sti, iretd
[14] CPU restores saved state
[15] CPU returns to hlt loop
Interrupt Handling
Interrupt handling ties together three separate hardware/software systems: the IDT, the PIC, and the exception handlers. Each has its own chapter, but this page explains how they fit together.
The Three Pieces
Hardware Event (key press, timer, fault)
│
▼
┌─────────┐
│ PIC │ Routes hardware IRQs to specific interrupt numbers
└────┬────┘ (after remapping: IRQ0 → INT32, IRQ1 → INT33, ...)
│
▼
┌─────────┐
│ IDT │ Table of 256 entries: "when interrupt N fires, call function F"
└────┬────┘
│
▼
┌─────────────────┐
│ Handler Function │ ASM wrapper → Rust function → EOI → iretd
└─────────────────┘
Interrupt Numbers
The x86 CPU has 256 possible interrupt numbers:
| Range | Purpose |
|---|---|
| 0–31 | CPU exceptions (reserved by Intel) |
| 32–255 | Hardware interrupts and software interrupts |
The CPU exceptions (0–31) are fixed — divide by zero always fires interrupt 0, page fault always fires interrupt 14, etc.
Hardware interrupts (from PIC) are configurable. By default the BIOS maps them to 8–15, which conflicts with CPU exceptions. We remap them to 32–47 to avoid this.
After remapping:
| Interrupt | Source |
|---|---|
| 0 | Divide by zero |
| 6 | Invalid opcode |
| 8 | Double fault |
| 13 | General protection fault |
| 14 | Page fault |
| 32 | Timer (IRQ0) |
| 33 | Keyboard (IRQ1) |
| 34–47 | Other IRQs |
Initialisation Order
The order in kernel_main matters:
#![allow(unused)]
fn main() {
idt::init(); // Set up the IDT with all handlers
pic::remap(); // Remap PIC IRQs to 32–47
idt::enable_interrupts(); // Run 'sti' — interrupts now live
}
Why this order?
- IDT must be populated before enabling interrupts. If an interrupt fires with an empty IDT, the CPU triple-faults.
- PIC must be remapped before enabling interrupts. If the timer fires at interrupt 8 (default) and we have a double fault handler there, chaos ensues.
stiis last — only turn interrupts on when everything is ready.
Default Handler
Every IDT slot that isn’t explicitly handled gets a default handler:
#![allow(unused)]
fn main() {
for i in 1..256 {
if i != 0 && i != 6 && i != 8 && i != 13 && i != 14 && i != 33 {
IDT.entries[i].set_handler(default_interrupt_handler);
}
}
}
The default handler displays “UNHANDLED INTERRUPT!” in red and halts. Without this, any unexpected interrupt (like the timer) causes a triple fault and reboots the machine. This was the root cause of the reboot bug — see the Debugging: The Reboot Problem chapter.
The IDT — Interrupt Descriptor Table
The IDT is a table of 256 entries that tells the CPU: “When interrupt N fires, call function at address X.”
Structure
IDT Entry (8 bytes)
#![allow(unused)]
fn main() {
#[repr(C, packed)]
#[derive(Copy, Clone)]
struct IdtEntry {
offset_low: u16, // Lower 16 bits of handler address
selector: u16, // Code segment selector (0x08 = kernel code)
zero: u8, // Always zero
type_attr: u8, // Gate type and privilege level
offset_high: u16, // Upper 16 bits of handler address
}
}
#[repr(C, packed)] — The CPU reads this structure directly from memory. It must have C layout with no padding. Without packed, Rust might insert alignment padding between fields and the CPU would read garbage.
Field Breakdown
offset_low + offset_high — Together they form the 32-bit address of the handler function. Split across the entry because that’s how the x86 IDT format was designed in the 1980s.
Handler at 0x00102ABC:
offset_low = 0x2ABC
offset_high = 0x0010
selector — Which code segment the handler runs in. 0x08 = kernel code segment (selector index 1, GDT entry 1). This is the segment GRUB sets up for us.
type_attr = 0x8E:
Bit 7: Present = 1 (entry is valid)
Bits 6-5: DPL = 0 (kernel privilege level)
Bits 4-0: Type = 0xE (32-bit interrupt gate)
Combined: 10001110 = 0x8E
An interrupt gate automatically disables interrupts (clears the IF flag) when the handler is called, and re-enables them on iretd.
Setting a Handler
#![allow(unused)]
fn main() {
fn set_handler(&mut self, handler: unsafe extern "C" fn()) {
let handler_addr = handler as usize;
self.offset_low = (handler_addr & 0xFFFF) as u16;
self.offset_high = ((handler_addr >> 16) & 0xFFFF) as u16;
self.selector = 0x08;
self.zero = 0;
self.type_attr = 0x8E;
}
}
The IDT Descriptor
To load the IDT, we need a small descriptor structure that tells the CPU where the table is and how big it is:
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct IdtPointer {
limit: u16, // Size of IDT in bytes - 1
base: u32, // Physical address of IDT
}
}
limit = size_of::<Idt>() - 1 — The limit is the last valid byte offset, not the count.
Loading the IDT
#![allow(unused)]
fn main() {
let idt_ptr = IdtPointer {
limit: (core::mem::size_of::<Idt>() - 1) as u16,
base: core::ptr::addr_of!(IDT) as u32,
};
asm!(
"lidt [{}]",
in(reg) &idt_ptr,
options(readonly, nostack, preserves_flags)
);
}
lidt — Load Interrupt Descriptor Table. Takes a pointer to the descriptor structure. After this instruction, the CPU uses our IDT for all interrupts and exceptions.
Full Initialisation
#![allow(unused)]
fn main() {
pub fn init() {
unsafe {
// CPU exception handlers
IDT.entries[0].set_handler(divide_by_zero_handler);
IDT.entries[6].set_handler(invalid_opcode_handler);
IDT.entries[8].set_handler(double_fault_handler);
IDT.entries[13].set_handler(general_protection_fault_handler);
IDT.entries[14].set_handler(page_fault_handler);
// Default handler for everything else (catches timer, spurious IRQs)
for i in 1..256 {
if i != 0 && i != 6 && i != 8 && i != 13 && i != 14 && i != 33 {
IDT.entries[i].set_handler(default_interrupt_handler);
}
}
// Keyboard interrupt (IRQ1 → interrupt 33 after PIC remapping)
IDT.entries[33].set_handler(keyboard_interrupt_handler);
let idt_ptr = IdtPointer {
limit: (core::mem::size_of::<Idt>() - 1) as u16,
base: core::ptr::addr_of!(IDT) as u32,
};
asm!(
"lidt [{}]",
in(reg) &idt_ptr,
options(readonly, nostack, preserves_flags)
);
}
}
pub fn enable_interrupts() {
unsafe {
asm!("sti", options(nomem, nostack));
}
}
}
The PIC — Programmable Interrupt Controller
The 8259 PIC is one of the most important chips in the x86 architecture. Without it, x86 would not be an interrupt-driven architecture.
What It Does
The PIC manages hardware interrupts and routes them to the CPU in order. When a keyboard registers a key hit, it sends a pulse along its interrupt line (IRQ1) to the PIC. The PIC translates the IRQ into a system interrupt number and tells the CPU to stop what it’s doing and handle it.
Without a PIC, you’d have to constantly poll every device to see if it wants to do anything. With the PIC, devices come to you when they’re ready — the CPU can sleep between events.
Two PICs: Master and Slave
Modern x86 systems use two cascaded 8259 PICs:
- PIC1 (Master) — Handles IRQ0–7
- PIC2 (Slave) — Handles IRQ8–15, connected to PIC1 via IRQ2
This gives 15 usable IRQs (IRQ2 is used for the cascade connection, so hardware that would have used IRQ2 is wired to IRQ9 instead).
The Remapping Problem
The BIOS maps hardware IRQs to interrupt numbers 8–15 by default. But the CPU reserves interrupts 0–31 for its own exceptions. This creates a conflict:
| Default Mapping | Problem |
|---|---|
| IRQ0 (Timer) → Interrupt 8 | Interrupt 8 is “Double Fault” |
| IRQ1 (Keyboard) → Interrupt 9 | Interrupt 9 is “Coprocessor Segment Overrun” |
When the keyboard fires at interrupt 9, the CPU thinks it’s a CPU exception. Bad things happen.
Solution: Remap the PIC to interrupts 32–47.
IRQ0 (Timer) → Interrupt 32 ✓ No conflict
IRQ1 (Keyboard) → Interrupt 33 ✓ No conflict
...
IRQ15 → Interrupt 47 ✓ No conflict
Remapping the PIC
The PIC initialisation sequence sends four “Initialisation Command Words” (ICWs):
#![allow(unused)]
fn main() {
pub fn remap() {
unsafe {
// ICW1: Start initialisation
outb(PIC1_COMMAND, ICW1_INIT); // 0x11
io_wait();
outb(PIC2_COMMAND, ICW1_INIT);
io_wait();
// ICW2: Set vector offsets
outb(PIC1_DATA, 32); // IRQ0-7 → INT 32-39
io_wait();
outb(PIC2_DATA, 40); // IRQ8-15 → INT 40-47
io_wait();
// ICW3: Tell PICs about each other
outb(PIC1_DATA, 4); // PIC2 is at IRQ2 (bitmask: 00000100)
io_wait();
outb(PIC2_DATA, 2); // PIC2's cascade identity
io_wait();
// ICW4: Set 8086 mode
outb(PIC1_DATA, ICW4_8086); // 0x01
io_wait();
outb(PIC2_DATA, ICW4_8086);
io_wait();
// Mask all IRQs except keyboard (IRQ1)
outb(PIC1_DATA, 0xFD); // 11111101 — only IRQ1 enabled
io_wait();
outb(PIC2_DATA, 0xFF); // All slave IRQs disabled
io_wait();
}
}
}
What io_wait() Does
#![allow(unused)]
fn main() {
unsafe fn io_wait() {
outb(0x80, 0); // Write to an unused port
}
}
On old hardware, the PIC needs a small delay to process each command. Writing to port 0x80 (unused during normal operation, used by BIOS for POST codes) introduces a ~1–4 microsecond delay. The Linux kernel uses the same technique.
The Mask: 0xFD
0xFD = 11111101 binary
││││││└─ IRQ0 (Timer) = 1 (DISABLED)
│││││└── IRQ1 (Keyboard) = 0 (ENABLED) ← The only one we want
││││└─── IRQ2 (Cascade) = 1 (DISABLED)
...
Disabling the timer is important — if the timer fires with no handler installed, it triple-faults and reboots the machine. That was a real bug.
End of Interrupt (EOI)
After handling an interrupt, you must tell the PIC you’re done. Otherwise it won’t send any more interrupts.
#![allow(unused)]
fn main() {
pub fn send_eoi(irq: u8) {
unsafe {
if irq >= 8 {
outb(PIC2_COMMAND, PIC_EOI); // Slave PIC acknowledgement
}
outb(PIC1_COMMAND, PIC_EOI); // Master PIC acknowledgement
}
}
}
PIC_EOI = 0x20 — The “End of Interrupt” command code.
For IRQs 8–15 (from the slave PIC), you must send EOI to both PICs — the slave PIC, and the master PIC that received the cascade signal.
Interrupt Mask Register
Each PIC has an 8-bit Interrupt Mask Register (IMR). A bit set to 1 masks (disables) that IRQ; 0 enables it.
#![allow(unused)]
fn main() {
unsafe fn irq_set_mask(irq_line: u8) {
let (port, line) = if irq_line < 8 {
(PIC1_DATA, irq_line)
} else {
(PIC2_DATA, irq_line - 8)
};
let value = inb(port) | (1 << line);
outb(port, value);
}
unsafe fn irq_clear_mask(irq_line: u8) {
let (port, line) = if irq_line < 8 {
(PIC1_DATA, irq_line)
} else {
(PIC2_DATA, irq_line - 8)
};
let value = inb(port) & !(1 << line);
outb(port, value);
}
}
Spurious IRQs
Occasionally the PIC will send a spurious IRQ — interrupt 7 (master) or interrupt 15 (slave) — when there’s no actual interrupt. This happens due to electrical noise or timing issues.
The correct handling: check the In-Service Register (ISR). If the bit for that IRQ isn’t set, it’s spurious — acknowledge it but don’t do any real processing. For spurious IRQ15 specifically, send EOI to the master PIC (because the master didn’t know it was spurious) but not the slave.
In our implementation, the default interrupt handler catches these without crashing the kernel.
Exception Handlers
Exception handlers catch CPU faults before they become triple faults.
Why They’re Critical
Without exception handlers:
CPU exception occurs (e.g. divide by zero)
↓
No handler → CPU fires interrupt 8 (Double Fault)
↓
No double fault handler → CPU fires interrupt 8 again
↓
Triple Fault → CPU resets → Machine reboots
With exception handlers:
CPU exception occurs
↓
Handler catches it
↓
Display error message in red
↓
Halt gracefully (no reboot!)
The difference between “the kernel crashed and showed an error” and “the kernel crashed and silently rebooted” is entirely the exception handlers.
Common Exceptions
| # | Name | Cause | Has Error Code? |
|---|---|---|---|
| 0 | Divide by Zero | Division by zero or overflow | No |
| 6 | Invalid Opcode | Undefined instruction | No |
| 8 | Double Fault | Exception during exception handling | Yes (always 0) |
| 13 | General Protection Fault | Memory access violation, bad segment | Yes |
| 14 | Page Fault | Accessing unmapped memory | Yes (CR2 = fault address) |
Implementation
Rust Handlers
#![allow(unused)]
fn main() {
unsafe fn write_error(row: usize, msg: &str, color: u8) {
for (i, byte) in msg.bytes().enumerate() {
let offset = (row * 80 + i) * 2;
*VGA_BUFFER.add(offset) = byte;
*VGA_BUFFER.add(offset + 1) = color;
}
}
#[no_mangle]
pub extern "C" fn rust_general_protection_fault() {
unsafe {
write_error(10, "EXCEPTION #13: GENERAL PROTECTION FAULT", 0x4F);
loop {}
}
}
}
Colour 0x4F = white text on red background. Unmistakable.
Assembly Wrappers
Exceptions with error codes require the wrapper to pop the error code off the stack before calling the Rust handler:
; Exception #13: General Protection Fault (has error code)
general_protection_fault_handler:
cli
add esp, 4 ; Remove error code pushed by CPU
pusha
call rust_general_protection_fault
popa
iretd
Why remove the error code?
When an exception has an error code, the CPU pushes it onto the stack before the return address. Our Rust handler uses the C calling convention, which doesn’t expect this extra value. If we don’t remove it, the stack is misaligned and the handler reads garbage as its “return address.”
Exceptions without error codes (like divide by zero) don’t need this:
divide_by_zero_handler:
cli
pusha
call rust_divide_by_zero
popa
iretd
Installing in the IDT
#![allow(unused)]
fn main() {
IDT.entries[0].set_handler(divide_by_zero_handler);
IDT.entries[6].set_handler(invalid_opcode_handler);
IDT.entries[8].set_handler(double_fault_handler);
IDT.entries[13].set_handler(general_protection_fault_handler);
IDT.entries[14].set_handler(page_fault_handler);
}
These must be installed before enabling interrupts. The double fault handler is especially important — without it, any exception causes a second exception which triple-faults.
Debugging: Interrupt Storms
What Is an Interrupt Storm?
An interrupt storm occurs when an interrupt fires repeatedly without being properly cleared. Symptoms:
- Keys briefly appear on screen then vanish
- Screen flickers constantly
- System appears frozen or rebooting
- CPU at 100% handling interrupts
What Happened
After getting the keyboard handler working for the first time, this was the result: each key press caused a brief flicker of the character, then the screen would stroble with characters and nothing was readable. It looked like the system was rebooting, but it wasn’t — it was the handler running thousands of times per second.
The Cause: Not Clearing the Buffer
The keyboard sends a scan code to port 0x60. The keyboard’s IRQ line stays asserted (high) as long as data is in the buffer. The PIC won’t clear the interrupt until the IRQ line goes low. The IRQ line won’t go low until the data is read.
The broken code:
#![allow(unused)]
fn main() {
pub extern "C" fn rust_keyboard_handler() {
unsafe {
let status = inb(KEYBOARD_STATUS_PORT); // Check if data is ready
if (status & 0x01) == 0 {
pic::send_eoi(1);
return; // ← BUG: We returned WITHOUT reading port 0x60!
}
let scancode = inb(KEYBOARD_DATA_PORT);
pic::send_eoi(1);
// ...
}
}
}
If the status register says “no data ready” but there’s still stale data in the buffer, the IRQ line stays asserted. We send EOI, the PIC thinks the interrupt is done, but the keyboard immediately re-fires the interrupt. The handler runs again. And again. Forever.
The Fix: Always Read Port 0x60
#![allow(unused)]
fn main() {
pub extern "C" fn rust_keyboard_handler() {
unsafe {
// ALWAYS read from 0x60 — this clears the keyboard buffer
// and de-asserts the IRQ line, regardless of whether data is "ready"
let scancode = inb(KEYBOARD_DATA_PORT);
// NOW send EOI
pic::send_eoi(1);
// Process the scan code
if scancode < 128 && scancode != 0 {
let ascii = SCANCODE_TO_ASCII[scancode as usize];
if ascii != 0 {
crate::print!("{}", ascii as char);
}
}
}
}
}
Reading from port 0x60 always clears the keyboard buffer. It doesn’t matter if the status says data is ready or not — reading it clears any pending data and de-asserts the IRQ line.
The Lessons
- Always read port 0x60 — This is how the hardware works. The read is the acknowledgement.
- Order matters — Read the port first, then send EOI. Sending EOI before the read means the PIC might fire again before the buffer is cleared.
- Status checks can lie — The status register tells you if new data is ready, not if the buffer is fully clear.
- Debug incrementally — When the handler misbehaves, strip it back to the absolute minimum (just read + EOI, nothing else) and add things back one at a time.
Debugging: The Reboot Problem
Symptoms
After fixing the interrupt storm, a new problem appeared:
- Kernel boots
- Shows welcome message
- Keyboard works briefly
- System reboots
- SeaBIOS screen appears
- Cycle repeats every few seconds
This meant the kernel was running — interrupts were working — but something was causing it to triple-fault, which resets the CPU.
Diagnosing
Added incremental diagnostic output to trace exactly where the system was getting to:
#![allow(unused)]
fn main() {
println!("Step 1: Initializing IDT...");
idt::init();
println!("Step 2: Remapping PIC...");
pic::remap();
println!("Step 3: Verifying PIC masks...");
let (mask1, mask2) = pic::read_masks();
println!(" -> PIC1 mask: {:08b}", mask1);
println!(" -> PIC2 mask: {:08b}", mask2);
println!("Step 4: Enabling interrupts...");
idt::enable_interrupts();
}
Output:
PIC1 mask: 11111101 ← Correct! Only IRQ1 enabled
PIC2 mask: 11111111 ← Correct! All slave IRQs masked
But it still rebooted.
The Root Cause: The Timer
Even with the correct mask, the timer (IRQ0) was occasionally firing. Here’s why:
When the PIC is first powered on or reset, it has its own internal state. During the remapping sequence, there’s a window where the mask register is being written. If the timer fires in that window, it might sneak through.
The sequence was:
- Keyboard interrupt (IRQ1) fires → handler runs → counter increments (proof: we saw this working)
- Timer interrupt (IRQ0) fires → CPU looks up IDT entry 32
- IDT entry 32 was… not set. Empty. Zero.
- CPU tries to call address 0x0000 → invalid memory → general protection fault
- GPF handler wasn’t installed yet → double fault
- Double fault handler not installed → triple fault
- CPU resets → reboot
The Fix: Handle Every Interrupt
#![allow(unused)]
fn main() {
// Exception handlers for CPU faults
IDT.entries[0].set_handler(divide_by_zero_handler);
IDT.entries[6].set_handler(invalid_opcode_handler);
IDT.entries[8].set_handler(double_fault_handler);
IDT.entries[13].set_handler(general_protection_fault_handler);
IDT.entries[14].set_handler(page_fault_handler);
// DEFAULT HANDLER FOR EVERYTHING ELSE — this is the fix
for i in 1..256 {
if i != 0 && i != 6 && i != 8 && i != 13 && i != 14 && i != 33 {
IDT.entries[i].set_handler(default_interrupt_handler);
}
}
IDT.entries[33].set_handler(keyboard_interrupt_handler);
}
Now when the timer fires:
- Timer interrupt (IRQ0) → IDT entry 32 →
default_interrupt_handler - Handler displays “UNHANDLED INTERRUPT!” in red
- System halts gracefully
No more triple fault. No more reboot.
Key Insight
Fill the entire IDT. You don’t know what interrupts will fire — spurious IRQs, timer overflow, hardware sending unexpected signals. Every empty IDT slot is a potential triple-fault waiting to happen.
The cost of a default handler is minimal. The cost of leaving slots empty is mysterious reboots that are extremely hard to debug (because the machine resets before you can read the error).
Other Lessons from This Bug
- “The mask looks right” doesn’t mean an interrupt can’t slip through during initialisation
- Diagnostic printing is essential — adding explicit step-by-step output was what revealed the problem was after the PIC remapping, not during
- Start with the complete IDT — install default handlers for all 256 slots before handling specific ones
Key Learnings
Everything that actually matters from KFS_1, distilled.
The Non-Negotiables
1. Always read port 0x60 in the keyboard handler
This clears the keyboard buffer and de-asserts the IRQ line. Skipping this causes an interrupt storm — the handler runs thousands of times per second.
2. Always send EOI after handling an interrupt
The PIC won’t send any more interrupts until it receives EOI. Every interrupt handler must call pic::send_eoi(irq) before returning.
3. Read the port BEFORE sending EOI
Read port 0x60 → THEN send EOI. Sending EOI first means the PIC might fire another interrupt before you’ve cleared the source.
4. Fill the entire IDT with a default handler
Every unhandled interrupt is a potential triple fault. Install a “catch-all” handler for all 256 IDT slots, then override specific ones. This was the root cause of the reboot bug.
5. Exception handlers prevent silent reboots
Without the double fault and general protection fault handlers, the kernel silently reboots when anything goes wrong. With them, you get a readable error on screen.
6. Stack size matters
Interrupt handlers need their own stack space. We use 64KB (up from an initial 16KB). Too small and handlers corrupt the kernel stack.
7. Use addr_of_mut! for mutable statics
#![allow(unused)]
fn main() {
// Wrong - undefined behaviour
let writer = &mut WRITER;
// Correct
let writer = &mut *core::ptr::addr_of_mut!(WRITER);
}
8. Assembly wrappers are not optional for interrupt handlers
Interrupt handlers need pusha/popa to save/restore all registers, and iretd to return correctly. A plain Rust function called directly won’t do this.
Debugging Methodology
- Start minimal — Get the simplest possible thing working before adding features
- Add diagnostics —
println!at each step of initialisation reveals exactly where things break - Test incrementally — Add one feature at a time, verify it works, then proceed
- Trust the hardware spec — If port 0x60 is the data port, read from it unconditionally
Rust-Specific Lessons
#[no_mangle]andextern "C"are required on any function called from assembly#[repr(C, packed)]is required on structures the CPU reads directly (IDT entries, GDT entries, descriptors)unsafeis genuinely necessary for kernel code — don’t fight it, document why it’s safecore::ptr::addr_of!/addr_of_mut!for taking addresses of statics- The
!return type onkernel_mainis not just style — it’s required because the entry point can never return
What Comes Next
KFS_2 takes the interrupt infrastructure built here and adds memory segmentation via the Global Descriptor Table, a proper kernel stack, and a simple interactive shell. The keyboard input from KFS_1 becomes the foundation for real user interaction.
KFS_2 — Stack & Segments
On today’s episode of ~~~“Why the hell do I do this to myself”~~~ “I’m having so much fun”, we are going to learn how to code a stack and integrate it with GDT.
Now you’re probably asking yourself “But Michael, I have a stack”, “What’s GDT?” and maybe “It’s 5am, can I go to sleep yet?”.
Firstly, we did include a stack in our bootloader. Check ✓
Secondly, GDT stands for “Global Descriptor Table”. It is a data structure used to define different memory areas: the base address, the size, and access privileges like execute and write.
And lastly, no… of course we don’t sleep, this is way too much fun.
What We Want to Achieve
-
Build, fill, and link a Global Descriptor Table into the kernel. The GDT must contain:
- Kernel Code
- Kernel Data
- Kernel Stack
- User Code
- User Data
- User Stack
GDT must be declared to the BIOS and set to address
0x00000800.Code a tool to print the kernel stack in a human-friendly way. We already built
printk(), so that’s great. -
Understand how memory works — how the stack and RAM work, how to use them, how to fill them, and how to link them with the BIOS.
REMEMBER TO KEEP IT UNDER 10MB
Bonus: A Shell
Because we have keyboards working, let’s build a Shell. Prototype name: NPS — Not a POSIX Shell.
Suggested commands: print-kernel-stack, reboot, halt, and similar.
Project Structure
kfs_2/kfs/
├── Cargo.toml
├── boot.asm # Multiboot header (same as KFS_1)
├── kb.asm # Keyboard handler wrapper
├── exc.asm # Exception handler wrappers
├── gdt.asm # GDT flush assembly
├── linker.ld
├── Makefile
└── src/
├── lib.rs # Kernel entry
├── vga.rs # Enhanced VGA (added printc, backspace)
├── idt.rs # IDT (from KFS_1, enhanced)
├── pic.rs # PIC (from KFS_1, enhanced with ISR/IRR reading)
├── kb.rs # Keyboard (now routes to shell)
├── exc.rs # Exception handlers
├── gdt.rs # GDT implementation
└── nps.rs # NPS Shell
What’s New vs KFS_1
- GDT — Global Descriptor Table with six entries
gdt.asm— Assemblygdt_flushfunction to load the GDT and reload segment registers- NPS Shell — Interactive command interpreter
vga::printc()— Print with specific colour without changing the global colour statevga::backspace()— Delete previous character- Enhanced PIC — Added ISR/IRR register reading, proper mask functions
- Keyboard routes to shell — Key presses go to
nps::handle_input()instead of printing directly
Global Descriptor Table (GDT)
The Global Descriptor Table is a binary data structure specific to IA-32 and x86-64 architectures. It tells the CPU about memory segments — their base addresses, sizes, and access privileges.
GRUB already sets up a basic GDT when it loads the kernel. Without it, the kernel wouldn’t boot at all. But we replace it with our own to understand memory segmentation and to set up proper kernel and user mode segments.
What Is a Segment?
A segment is a logically contiguous chunk of memory with consistent properties from the CPU’s perspective. In protected mode, every memory access goes through a segment. The CPU checks: is this access allowed for this privilege level? Does it fit within the segment limit?
In modern kernels we use the flat memory model — all segments cover the full 4GB address space, effectively disabling segmentation. We rely on paging for memory protection instead. But the GDT still needs to exist because the CPU requires it in protected mode.
GDT Entry Structure (8 bytes)
#![allow(unused)]
fn main() {
#[repr(C, packed)]
#[derive(Copy, Clone)]
pub struct GdtEntry {
limit_low: u16, // Lower 16 bits of limit
base_low: u16, // Lower 16 bits of base
base_middle: u8, // Next 8 bits of base
access: u8, // Access byte (privilege, type)
granularity: u8, // Flags and limit high bits
base_high: u8, // Upper 8 bits of base
}
}
#[repr(C, packed)] is mandatory — the CPU reads this structure directly from memory. Any padding inserted by the Rust compiler would corrupt the entries.
Memory Layout of an Entry
Bit positions in the 8-byte entry:
┌────────────────────────┬─────┬──────────┬──────────┐
│ Base 31:24 │Flags│ Limit │ Access │
│ (8 bits) │(4b) │ 19:16 │ (8 bits) │
│ │ │ (4 bits) │ │
├────────────────────────┴─────┴──────────┴──────────┤
│ Base 23:16 │ Base 15:0 │ Limit 15:0 │
│ (8 bits) │ (16 bits) │ (16 bits) │
└─────────────────┴────────────────────┴─────────────┘
Constructing an Entry
#![allow(unused)]
fn main() {
const fn new(base: u32, limit: u32, access: u8, gran: u8) -> GdtEntry {
GdtEntry {
limit_low: (limit & 0xFFFF) as u16,
base_low: (base & 0xFFFF) as u16,
base_middle: ((base >> 16) & 0xFF) as u8,
access,
granularity: ((limit >> 16) & 0x0F) as u8 | (gran & 0xF0),
base_high: ((base >> 24) & 0xFF) as u8,
}
}
}
The limit and base are split across non-contiguous bits in the entry — a legacy of the 286-era design. Our constructor packs them correctly.
The Access Byte
The access byte controls who can use a segment and how:
Bit: 7 6 5 4 3 2 1 0
P | DPL | S | E | DC | RW | A
| Bit | Name | Meaning |
|---|---|---|
| 7 | P (Present) | 1 = segment is valid |
| 6-5 | DPL (Privilege Level) | 0 = ring 0 (kernel), 3 = ring 3 (user) |
| 4 | S (Descriptor Type) | 1 = code/data segment, 0 = system segment |
| 3 | E (Executable) | 1 = code segment, 0 = data segment |
| 2 | DC (Direction/Conforming) | Grows down for data, conforming for code |
| 1 | RW (Read/Write) | Read-enable for code, write-enable for data |
| 0 | A (Accessed) | CPU sets this when segment is accessed |
Our Access Bytes
| Segment | Access | Binary | Meaning |
|---|---|---|---|
| Kernel Code | 0x9A | 10011010 | Present, Ring 0, Code, Executable, Readable |
| Kernel Data | 0x92 | 10010010 | Present, Ring 0, Data, Read/Write |
| User Code | 0xFA | 11111010 | Present, Ring 3, Code, Executable, Readable |
| User Data | 0xF2 | 11110010 | Present, Ring 3, Data, Read/Write |
The Granularity Byte
Bit: 7 6 5 4 | 3 2 1 0
G | D | L | A | Limit 19:16
| Bit | Meaning |
|---|---|
| 7 (G) | Granularity: 0 = limit in bytes, 1 = limit in 4KB pages |
| 6 (D) | Default size: 0 = 16-bit, 1 = 32-bit protected mode |
| 5 (L) | Long mode: 1 = 64-bit code (we don’t use this) |
| 4 (A) | Available for OS use |
| 3-0 | Upper 4 bits of limit |
We pass 0xC0 (11000000) as the granularity flag:
- G = 1: Limit is in 4KB pages →
0xFFFFF * 4KB = 4GBaddress space - D = 1: 32-bit protected mode
Combined with limit = 0xFFFFF, this gives every segment a 4GB reach — the flat memory model.
Our GDT: Six Entries
#![allow(unused)]
fn main() {
static mut GDT: [GdtEntry; 6] = [
GdtEntry::null(), // [0] Null (required)
GdtEntry::new(0, 0xFFFFF, 0x9A, 0xC0), // [1] Kernel Code (0x08)
GdtEntry::new(0, 0xFFFFF, 0x92, 0xC0), // [2] Kernel Data (0x10)
GdtEntry::new(0, 0xFFFFF, 0xFA, 0xC0), // [3] User Code (0x18)
GdtEntry::new(0, 0xFFFFF, 0xF2, 0xC0), // [4] User Data (0x20)
GdtEntry::null(), // [5] TSS (placeholder)
];
}
Why the Null Descriptor Is Required
The CPU uses selector 0x00 to detect errors. If code tries to access memory through the null descriptor, the CPU generates a General Protection Fault. Removing it → triple fault at boot.
Segment Selectors
Each entry is 8 bytes. The segment selector is the byte offset into the GDT, which is why our assembly uses:
mov ax, 0x10 ; Kernel data — entry 2 × 8 bytes = offset 0x10
jmp 0x08:.flush ; Kernel code — entry 1 × 8 bytes = offset 0x08
| Offset | Segment |
|---|---|
0x00 | Null |
0x08 | Kernel Code |
0x10 | Kernel Data |
0x18 | User Code |
0x20 | User Data |
0x28 | TSS |
The TSS Placeholder
The Task State Segment (TSS) is needed for hardware task switching and for switching stacks when transitioning from ring 3 to ring 0. We reserve the slot now so all selectors stay stable, but leave it as null until we actually need it.
Loading the GDT
The GDT Pointer
lgdt expects a 6-byte descriptor structure:
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct GdtPointer {
limit: u16, // Size of GDT in bytes - 1
base: u32, // Linear address of GDT
}
}
#![allow(unused)]
fn main() {
let gdt_ptr = GdtPointer {
limit: (core::mem::size_of::<[GdtEntry; 6]>() - 1) as u16,
base: &raw const GDT as *const _ as u32,
};
}
&raw const GDT — takes the address of the static without creating a reference (avoiding aliasing issues). This is the addr_of! macro equivalent using the &raw syntax.
The Assembly Flush
Simply loading a new GDT with lgdt isn’t enough. The CPU caches segment register contents in hidden “shadow” registers. We must reload all segment registers to pick up the new GDT:
gdt_flush:
mov eax, [esp+4] ; Get GDT pointer from stack argument
lgdt [eax] ; Load the new GDT
mov ax, 0x10 ; Kernel data segment selector
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
jmp 0x08:.flush ; Far jump to reload CS with kernel code selector
.flush:
ret
Why a far jump?
The Code Segment register (CS) can only be changed with a far jump or iretd. The far jump jmp 0x08:.flush jumps to .flush with CS = 0x08 (kernel code segment). This flushes the instruction pipeline and reloads CS from the new GDT.
From Rust
#![allow(unused)]
fn main() {
extern "C" {
fn gdt_flush(gdt_ptr: *const GdtPointer);
}
pub fn init() {
unsafe {
let gdt_ptr = GdtPointer {
limit: (core::mem::size_of::<[GdtEntry; 6]>() - 1) as u16,
base: &raw const GDT as *const _ as u32,
};
gdt_flush(&gdt_ptr);
}
}
}
Debugging the GDT
The shell’s gdt command dumps all entries:
#![allow(unused)]
fn main() {
pub fn print_gdt() {
unsafe {
println!("GDT Address: 0x{:08x}", &raw const GDT as *const _ as u32);
for (i, name) in entries.iter().enumerate() {
let entry = &GDT[i];
let base = (entry.base_low as u32)
| ((entry.base_middle as u32) << 16)
| ((entry.base_high as u32) << 24);
let limit = (entry.limit_low as u32)
| (((entry.granularity & 0x0F) as u32) << 16);
println!("[{}] {} — Base: 0x{:08x}, Limit: 0x{:05x}, Access: 0x{:02x}",
i, name, base, limit, entry.access);
}
}
}
}
PIC — Advanced Topics
The PIC was covered in depth in KFS_1. This page covers the additions made in KFS_2.
ISR and IRR Registers
The PIC has two status registers useful for debugging:
- IRR (Interrupt Request Register) — Which IRQs have been raised but not yet serviced
- ISR (In-Service Register) — Which IRQs are currently being serviced (sent to CPU)
#![allow(unused)]
fn main() {
const PIC_READ_IRR: u8 = 0x0A;
const PIC_READ_ISR: u8 = 0x0B;
unsafe fn __pic_get_irq_reg(ocw3: u8) -> u16 {
outb(PIC1_COMMAND, ocw3);
outb(PIC2_COMMAND, ocw3);
((inb(PIC2_COMMAND) as u16) << 8) | (inb(PIC1_COMMAND) as u16)
}
unsafe fn pic_get_irr() -> u16 { __pic_get_irq_reg(PIC_READ_IRR) }
unsafe fn pic_get_isr() -> u16 { __pic_get_irq_reg(PIC_READ_ISR) }
}
The combined 16-bit value has PIC2’s 8 bits in the high byte and PIC1’s in the low byte. Bit N corresponds to IRQ N.
Spurious IRQ Handling (Proper)
A spurious IRQ fires when the IRQ line is de-asserted after the PIC has already told the CPU an interrupt is pending. The PIC sends interrupt 7 (master) or interrupt 15 (slave) as a placeholder.
The correct handling:
#![allow(unused)]
fn main() {
fn handle_irq7() {
// Check if it's real or spurious
let isr = unsafe { pic_get_isr() };
if (isr & 0x80) == 0 {
// Bit 7 not set in ISR — it's spurious, don't send EOI
return;
}
// Real IRQ7 — send EOI
pic::send_eoi(7);
}
}
For spurious IRQ15: send EOI to master PIC only (master doesn’t know it’s spurious from the slave).
Mask Helpers
#![allow(unused)]
fn main() {
unsafe fn irq_set_mask(irq_line: u8) {
let (port, line) = if irq_line < 8 {
(PIC1_DATA, irq_line)
} else {
(PIC2_DATA, irq_line - 8)
};
let value = inb(port) | (1 << line);
outb(port, value);
}
unsafe fn irq_clear_mask(irq_line: u8) {
let (port, line) = if irq_line < 8 {
(PIC1_DATA, irq_line)
} else {
(PIC2_DATA, irq_line - 8)
};
let value = inb(port) & !(1 << line);
outb(port, value);
}
}
These allow enabling/disabling individual IRQs without affecting others.
The Shell — NPS (Not a POSIX Shell)
With keyboard input working, we build a simple interactive shell. The name is honest about its limitations.
Design
The shell maintains a command buffer. Each keystroke either appends to the buffer, handles a special key (backspace, enter), or is ignored. On Enter, the buffer is parsed and executed.
#![allow(unused)]
fn main() {
const MAX_COMMAND_LEN: usize = 64;
pub struct NPShell {
buffer: [u8; MAX_COMMAND_LEN],
pos: usize,
}
}
Fixed-size buffer on the stack — no heap allocation needed.
Input Handling
#![allow(unused)]
fn main() {
pub fn handle_char(&mut self, ch: u8) {
match ch {
b'\n' => {
self.execute();
self.clear();
println!();
self.show_prompt();
}
0x08 => { // Backspace
if self.pos > 0 {
self.pos -= 1;
writer().backspace();
}
}
0x20..=0x7E => { // Printable ASCII
if self.pos < MAX_COMMAND_LEN - 1 {
self.buffer[self.pos] = ch;
self.pos += 1;
print!("{}", ch as char);
}
}
_ => {} // Ignore everything else
}
}
}
Command Dispatch
#![allow(unused)]
fn main() {
fn execute(&self) {
let cmd = core::str::from_utf8(&self.buffer[..self.pos]).unwrap_or("");
match cmd.trim() {
"help" => self.cmd_help(),
"stack" => self.cmd_stack(),
"gdt" => self.cmd_gdt(),
"clear" => self.cmd_clear(),
"about" => self.cmd_about(),
"reboot" => self.cmd_reboot(),
"halt" => self.cmd_halt(),
"42" => self.cmd_42(),
"" => {}
_ => println!("Unknown command: '{}'. Type 'help' for commands.", cmd),
}
}
}
core::str::from_utf8 — Convert byte buffer to string slice. Can fail if the buffer contains invalid UTF-8, hence the unwrap_or("") fallback.
Commands
| Command | Function |
|---|---|
help | List available commands |
stack | Print kernel stack contents (ESP, EBP, top 16 dwords) |
gdt | Dump all GDT entries |
clear | Clear screen |
about | Kernel information |
halt | cli; hlt — stop everything |
reboot | Pulse keyboard controller reset line |
42 | Print the obligatory 42 logo |
Reboot Implementation
#![allow(unused)]
fn main() {
fn cmd_reboot(&self) {
unsafe {
// Wait for keyboard controller to be ready
let mut port: u8;
loop {
core::arch::asm!("in al, dx", out("al") port, in("dx") 0x64u16);
if (port & 0x02) == 0 { break; }
}
// Pulse the CPU reset line via keyboard controller port
core::arch::asm!("out dx, al", in("dx") 0x64u16, in("al") 0xFEu8);
// Fallback: triple fault
core::arch::asm!("cli; hlt", options(noreturn));
}
}
}
The keyboard controller (port 0x64) has a line connected to the CPU reset pin. Writing 0xFE pulses it low, resetting the CPU. This is the standard software reboot method on x86.
Global Instance
#![allow(unused)]
fn main() {
static mut NPSHELL: NPShell = NPShell::new();
pub fn handle_input(ch: u8) {
unsafe {
let shell = &mut *core::ptr::addr_of_mut!(NPSHELL);
shell.handle_char(ch);
}
}
}
The shell is a static global, accessed via addr_of_mut! for the same reasons as the VGA writer.
The keyboard handler now calls nps::handle_input(ascii) instead of printing directly — the shell owns all display of typed characters.
KFS_3 — Memory Paging
Now we are getting serious. You know how to use malloc? Do you know how it really works?
Last round we declared the memory space into the BIOS, and now it’s time to use it. It’s time to code memory paging, allocation, and free.
To remind yourself what dynamic memory allocation is:
The task of fulfilling an allocation request consists of locating a block of unused memory of sufficient size. Memory requests are satisfied by allocating portions from a large pool of memory called the heap or free store. At any given time, some parts of the heap are in use, while some are “free” (unused) and thus available for future allocations.
This is more than malloc — this is memory paging, physical and virtual memory, and memory code structures.
This subject is one of the more important of the Kernel From Scratch series. Take your time, learn everything.
What to Achieve
To consider KFS_3 complete, the kernel will have:
- A complete memory code structure with pagination handling
- Read and write permissions on memory
- User space memory and kernel space memory
- Physical and virtual memory
- Code helpers for physical memory:
kmalloc,kfree,ksize,kbrk - Code helpers for virtual memory:
vmalloc,vfree,vsize,vbrk - Kernel panic handling
The memory must be a complete, stable, and functional memory system in the kernel.
Specific Requirements
- Enable memory paging in the kernel
- Code a memory structure that handles paging and memory rights
- Define kernel and user space
- Implement a function to create/get memory pages
- Implement a function to allocate, free, and get size of a variable
- Implement those functions for both virtual and physical memory
- Handle kernel panics (print, stop the kernel)
- Keep it under 10MB
Extra Fun
- Implement memory dumping and debug in NPS
Module Structure
kfs_3/kfs/src/
├── lib.rs
├── gdt.rs
├── shell.rs
├── drivers/
│ ├── mod.rs
│ ├── vga.rs
│ └── keyboard.rs
├── interrupts/
│ ├── mod.rs
│ ├── idt.rs
│ ├── pic.rs
│ └── handlers.rs
└── memory/
├── mod.rs
├── paging.rs ← Frame allocator, page tables, init
├── physical_mem.rs ← kmalloc, kfree, ksize (TODO)
└── virtual_mem.rs ← vmalloc, vfree, vsize (TODO)
Current Status
| Component | Status |
|---|---|
| Frame allocator | ✅ Bump allocator |
| Page directory | ✅ Allocated and zeroed |
| Identity mapping (4MB) | ✅ Working |
| Recursive mapping | ✅ Set up |
| Paging enabled | ✅ CR3 + CR0 PG bit |
map_page / unmap_page | ✅ Implemented |
get_physaddr | ✅ Implemented |
kmalloc / kfree / ksize | ❌ Not yet implemented |
vmalloc / vfree / vsize | ❌ Not yet implemented |
Why We Need Paging
Note: This section draws from Samy Pesse’s How to make an Operating System.
The Problem Without Paging
Without paging, every process sees the same physical memory. Process A can read and write Process B’s memory. A bug in any program can corrupt the kernel. You’re limited to the actual amount of RAM in the machine.
What Paging Gives You
A paged system gives each process its own virtual 4GB address space — completely isolated from every other process:
Physical Memory Process A Process B
┌───────────┐ ┌───────────┐ ┌───────────┐
│ 00x: HELL │ │ 00x: HELL │ │ 00x: HAVE │
│ 01x: RLD! │ ←mapped │ 01x: O WO │ ←mapped │ 01x: LOT │
│ 02x: O WO │ │ 02x: RLD! │ │ 02x: S OF │
│ 03x: HAVE │ │ 03x: #### │ │ 03x: FUN │
│ 04x: FUN │ │ 04x: #### │ │ 04x: #### │
│ 05x: LOT │ │ 05x: ;-) │ │ 05x: ;-) │
│ 06x: S OF │ └───────────┘ └───────────┘
│ 07x: ;-) │
└───────────┘
Both processes can use address 0x00000000 without conflict. Their page tables map that virtual address to different physical pages. Neither process can see the other’s memory.
The Three Benefits
1. More memory than RAM
Virtual addresses can map to disk (swap space). Pages that aren’t accessed can be moved to disk and loaded back when needed. Processes can use more memory than physically exists.
2. Unique memory space per process
Every process thinks it owns 4GB of address space. The kernel manages the actual physical memory behind the scenes.
3. Dynamic permission control
Memory pages can be marked read-only, executable, or inaccessible. The CPU enforces these permissions in hardware. Writing to a read-only page generates a page fault, not silent corruption.
What This Means for KFS_3
At this stage, KFS_3 is a single-process kernel — there are no user processes yet. The paging system we build here is the foundation that will eventually support:
- Process isolation (when we add user-mode processes)
- Memory protection (kernel vs user space)
- Dynamic allocation (
kmalloc/vmallocsitting on top of page frames)
For now, the most important thing paging gives us is a controlled memory allocation system — instead of raw physical addresses, we work with frames and virtual mappings.
How Paging Works
The Two-Level Page Table
Translating a virtual address to a physical address involves two lookups:
32-bit Virtual Address
┌──────────┬──────────┬────────────────┐
│ Dir (10) │ Tab (10) │ Offset (12) │
└──────────┴──────────┴────────────────┘
Bits 31-22 Bits 21-12 Bits 11-0
- The CPU reads CR3 to get the physical address of the Page Directory
- Bits 31-22 index into the Page Directory (0-1023) → gives address of a Page Table
- Bits 21-12 index into that Page Table (0-1023) → gives physical address of a 4KB page
- Bits 11-0 are the offset within that 4KB page (0-4095)
CR3 → Page Directory
│
│ [bits 31-22]
▼
PD Entry → Page Table
│
│ [bits 21-12]
▼
PT Entry → Physical Page
│
│ + [bits 11-0]
▼
Physical Address
Worked Example
Virtual address: 0x00120ABC
0x00120ABC = 0000 0000 00 | 01 0010 0000 | 1010 1011 1100
─────────── ──────────── ────────────
PD index:0 PT index:288 Offset:0xABC
- Read PD[0] → Physical address of page table 0
- Read PT[288] → Physical address of some 4KB page
- Physical address = page_base + 0xABC
Capacity
- One page table covers: 1024 × 4KB = 4MB
- One page directory covers: 1024 × 4MB = 4GB
- Each structure (directory or table) is: 1024 × 4 bytes = 4KB (one page!)
Enabling Paging
Two control registers:
CR3 — Holds the physical address of the Page Directory. The CPU reads this to start every address translation.
CR0 — Bit 31 (the PG bit) enables paging:
#![allow(unused)]
fn main() {
unsafe fn enable_paging(page_dir_phys: u32) {
// Tell CPU where the page directory is
core::arch::asm!("mov cr3, {}", in(reg) page_dir_phys);
// Enable paging by setting the PG bit in CR0
core::arch::asm!(
"mov eax, cr0",
"or eax, 0x80000000", // Set bit 31
"mov cr0, eax",
out("eax") _,
);
}
}
Why OR instead of MOV?
CR0 has other important bits (Protected Mode enable, Write Protect, etc.). MOV would overwrite them. OR only sets the PG bit and leaves everything else intact.
After the mov cr0, eax instruction, the CPU immediately starts translating all virtual addresses. If the page tables aren’t set up correctly at that exact moment, the next instruction causes a page fault and the system crashes.
The Translation Lookaside Buffer (TLB)
Walking two levels of page tables for every memory access would be slow. The CPU caches recent translations in the TLB (Translation Lookaside Buffer).
When we modify a page table entry, we must invalidate the corresponding TLB entry:
#![allow(unused)]
fn main() {
unsafe fn flush_tlb(vaddr: u32) {
core::arch::asm!(
"invlpg [{0:e}]",
in(reg) vaddr,
options(nostack, preserves_flags)
);
}
}
invlpg — Invalidate a single TLB entry for the given virtual address. After this, the next access to that address will do a fresh page table walk.
Loading a new value into CR3 flushes the entire TLB (all entries).
Page Entry Format & Flags
Both page directory entries and page table entries use the same 32-bit format.
Entry Structure
Bit: 31 12 | 11 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0
────────────── ────── ─ ─ ─ ─ ─ ─ ─ ─ ─
Physical Address Avail G PS D A PCD PWT U/S R/W P
(20 bits, page- (PT (PT
aligned) only) only)
The upper 20 bits hold the physical address of the next-level structure (or the page itself). Since all structures are 4KB-aligned, the lower 12 bits of the address are always zero — the CPU uses those bits for flags instead.
Flag Definitions
| Bit | Flag | Name | Description |
|---|---|---|---|
| 0 | P | Present | 1 = page/table exists in physical memory. If 0, accessing this address causes a page fault. |
| 1 | R/W | Read/Write | 0 = read-only, 1 = read/write |
| 2 | U/S | User/Supervisor | 0 = kernel-only (ring 0), 1 = accessible from user mode (ring 3) |
| 3 | PWT | Write-Through | Cache policy. 0 = write-back, 1 = write-through |
| 4 | PCD | Cache Disable | 1 = disable CPU cache for this page |
| 5 | A | Accessed | CPU sets this bit when the page is read or written |
| 6 | D | Dirty | CPU sets this when the page is written (page table entries only) |
| 7 | PS | Page Size | In PD entries: 0 = 4KB pages, 1 = 4MB pages |
| 8 | G | Global | Page is not flushed from TLB on CR3 write |
| 9-11 | Avail | Available | Reserved for OS use — CPU ignores these bits |
The Flags We Use
#![allow(unused)]
fn main() {
pub const PAGE_PRESENT: u32 = 1 << 0; // Page exists
pub const PAGE_WRITABLE: u32 = 1 << 1; // Allow writes
pub const PAGE_USER: u32 = 1 << 2; // User mode accessible
}
For kernel pages: PAGE_PRESENT | PAGE_WRITABLE (no user access)
For user pages: PAGE_PRESENT | PAGE_WRITABLE | PAGE_USER
Constructing an Entry
#![allow(unused)]
fn main() {
// Physical address: 0x00123000
// Flags: present + writable
let entry: u32 = 0x00123000 | PAGE_PRESENT | PAGE_WRITABLE;
// = 0x00123003
}
The physical address must be 4KB-aligned (lower 12 bits zero), so |-ing in the flags never corrupts the address.
Extracting the Address
#![allow(unused)]
fn main() {
let physical_address = entry & !0xFFF; // Mask off the flags
let flags = entry & 0xFFF; // Mask off the address
}
The PDEBit Enum
In our implementation, bits are represented as an enum for type-safe manipulation:
#![allow(unused)]
fn main() {
#[repr(u32)]
#[derive(Copy, Clone)]
pub enum PDEBit {
P = 0, // Present
RW = 1, // Read/Write
US = 2, // User/Supervisor
PWT = 3, // Write-Through
PCD = 4, // Cache Disable
A = 5, // Accessed
D = 6, // Dirty
PS = 7, // Page Size
G = 8, // Global
}
impl PDEBit {
const fn mask(self) -> u32 { 1 << (self as u32) }
}
fn is_set(entry: u32, bit: PDEBit) -> bool {
(entry & bit.mask()) != 0
}
}
Identity Mapping
The Problem of Enabling Paging
There’s a chicken-and-egg problem when enabling paging:
- Before paging: The CPU uses physical addresses directly
- After paging: The CPU translates every address through the page tables
- The problem: The kernel is running at a physical address (e.g.
0x00102000). When paging is enabled, if virtual address0x00102000isn’t mapped to physical0x00102000, the next instruction the CPU tries to fetch will cause a page fault.
In other words: if the virtual address space doesn’t match the physical address space for the code that enables paging, enabling paging crashes the kernel immediately.
The Solution: Identity Mapping
Identity mapping maps virtual addresses to the same physical addresses:
Virtual 0x00000000 → Physical 0x00000000
Virtual 0x00001000 → Physical 0x00001000
Virtual 0x00002000 → Physical 0x00002000
...
Virtual 0x003FF000 → Physical 0x003FF000
The kernel continues executing without noticing the transition — the virtual addresses it uses map to exactly the same physical locations.
Our Identity Mapping
We identity-map the first 4MB (1024 pages):
0xFFFFFFFF ──┬─────────────┐
4GB │ │
│/ │/
(unmapped) (unmapped)
0x00400000 ──┼─────────────┤ ← End of identity-mapped region
4MB │ │ Virtual = Physical
│ KERNEL │
│ CODE │
0x00100000 ├─────────────┤ ← Kernel loads here (1MB)
1MB │ BIOS/hw │
0x000B8000 │ VGA buf │ ← Identity mapped!
0x00000000 └─────────────┘
Virtual = Physical
4MB ensures we cover:
- The kernel code and data (
0x00100000onwards) - The VGA buffer (
0x000B8000) - The stack (just below 1MB in some configurations)
- The GDT, IDT, and page tables themselves
Implementation
#![allow(unused)]
fn main() {
// Allocate a page table (one table covers 4MB)
let pt_frame = allocate_frame().expect("Cannot allocate page table");
let page_table = pt_frame as *mut u32;
// Map 1024 pages: virtual 0x00000000-0x003FFFFF = physical same
for i in 0usize..1024 {
let phys_addr = (i as u32) * 0x1000;
*page_table.add(i) = phys_addr | PAGE_PRESENT | PAGE_WRITABLE;
}
// Install this page table at PD index 0
// (PD index 0 covers virtual 0x00000000-0x003FFFFF)
(*page_dir).entries[0] = pt_frame | PAGE_PRESENT | PAGE_WRITABLE;
}
Why Only 4MB?
The subject specifies the kernel is limited to 4MB. We check this at startup:
#![allow(unused)]
fn main() {
if kernel_end > 0x00400000 {
panic!("Kernel is larger than 4MB! Cannot identity map.");
}
}
If the kernel ever exceeded 4MB, we’d need a second page table for the second 4MB region (PD index 1).
After Paging Is Enabled
Once paging is enabled:
CPU fetches instruction at 0x00102000
↓
Virtual 0x00102000 → PD[0] → PT[258] → Physical 0x00102000
↓
Same physical address — execution continues normally
The kernel never notices paging was enabled.
Our Memory Model
Physical Memory Layout
The kernel reserves the first 8MB for its own use:
0xFFFFFFFF ───────────────────── 4GB
Free for apps
and kernel heap
0x00800000 ───────────────────── 8MB
Kernel Reserved:
- Kernel binary
- GDT / IDT
- Stack
- VGA (0xB8000)
- Page directory
- Page tables
0x00100000 ───────────────────── 1MB ← GRUB loads kernel here
BIOS Reserved
0x000B8000 ───────────────────── ← VGA buffer
0x00000000 ───────────────────── 0
In practice the kernel is much smaller than 8MB, but this gives us comfortable headroom for page tables and future expansion.
Virtual Address Space Division
0xFFFFFFFF ───────────────────── 4GB
User Space
(process-specific)
~1GB
0xC0000000 ───────────────────── 3GB ← Kernel/User boundary
Kernel Space
(always mapped)
Kernel code, data,
page tables
0x00000000 ───────────────────── 0
Kernel Space (0x00000000 – 0xC0000000):
- Always mapped in every process’s page directory
- Accessible only in ring 0
- Contains kernel code, data, page tables
- Identity-mapped for the first 4MB
User Space (0xC0000000 – 0xFFFFFFFF):
- Process-specific mappings
- Accessible from ring 3
- Different content for every process
At the current stage of KFS_3, there are no user processes. The entire address space is kernel-controlled. The boundary matters when we eventually add user-mode execution.
Where kmalloc Lives
The physical allocator (kmalloc) manages a heap region inside the identity-mapped kernel space:
0x00400000 ───────────────────── ← End of identity mapping
Kernel Heap
kmalloc manages here
~0x00200000 ──────────────────── ← Heap starts (after kernel + page tables)
Page tables
Page directory
~kernel_end ──────────────────── ← End of compiled kernel
Kernel Code/Data
0x00100000 ───────────────────── ← GRUB loads kernel
The frame allocator tracks _kernel_end and allocates upward from there.
Where vmalloc Lives
The virtual allocator (vmalloc) maps memory into the high virtual address space, above the identity-mapped region. When a vmalloc call comes in:
- Find a free virtual address range (starting around
0xC0000000or another high address) - Allocate physical frames from the frame allocator
- Map those frames into the virtual address space using
map_page() - Return the virtual address to the caller
The key difference: kmalloc returns a physical address that happens to also be the virtual address (identity mapping). vmalloc returns a virtual address that is mapped to physical pages somewhere else in RAM.
Page Structures in Rust
The C structures for paging map directly to Rust. Understanding the C originals helps when reading documentation and OSDev resources.
The Page Entry
In C, a page entry is often defined as a bitfield:
struct page {
u32 present:1; // Present in memory
u32 rw:1; // Read-only if clear
u32 user:1; // Supervisor only if clear
u32 accessed:1; // Has page been accessed?
u32 dirty:1; // Has page been written?
u32 unused:7; // Reserved bits
u32 frame:20; // Frame address (shifted right 12)
};
In Rust, we use a plain u32 with bit manipulation:
#![allow(unused)]
fn main() {
// A page entry is just a u32 with the address and flags packed in
// Physical address occupies bits 31-12, flags occupy bits 11-0
let entry: u32 = physical_address | PAGE_PRESENT | PAGE_WRITABLE;
}
We don’t use Rust bitfields — they’re not part of the language. Bit manipulation with constants is cleaner and more explicit.
Page Directory
// C
struct page_directory {
struct page tables[1024];
};
#![allow(unused)]
fn main() {
// Rust
#[repr(C, align(4096))]
pub struct PageDirectory {
entries: [u32; 1024],
}
impl PageDirectory {
pub const fn new() -> Self {
Self { entries: [0; 1024] }
}
}
}
#[repr(C, align(4096))] is critical:
repr(C)— C layout, no Rust reorderingalign(4096)— Must start on a 4KB boundary. The CPU requires this; CR3 stores the physical address with the lower 12 bits masked off.
Page Table
// C
struct page_table {
struct page pages[1024];
};
#![allow(unused)]
fn main() {
// Rust — same structure as PageDirectory
// In our implementation, page tables are raw *mut u32 pointers
// because they're allocated dynamically from the frame allocator
}
In our implementation, we don’t have a named PageTable struct because page tables are created dynamically. We allocate a frame and treat its address as a *mut u32 pointing to 1024 entries.
Memory Layout of a Page Directory
Physical address of PageDirectory (must be 4KB-aligned):
┌──────────────────────────────────┐ ← 4KB aligned address
│ entries[0] (4 bytes) │ → Points to page table 0 (covers 0x00000000-0x003FFFFF)
│ entries[1] (4 bytes) │ → Points to page table 1 (covers 0x00400000-0x007FFFFF)
│ ... │
│ entries[1022] (4 bytes) │ → 0 (unmapped)
│ entries[1023] (4 bytes) │ → Points back to THIS directory (recursive mapping)
└──────────────────────────────────┘ ← 4KB aligned address + 4096 bytes
The entire structure is exactly one page (4KB = 1024 × 4 bytes).
Implementation Overview
This section documents the actual paging implementation in memory/paging.rs. Everything here reflects the current working code.
File: memory/paging.rs
This single file contains the complete paging subsystem:
- Frame allocator (bump allocator)
- Page directory and table management
map_page,unmap_page,get_physaddrinit_paging— the entry point called fromkernel_main
Initialization Sequence
init_paging(mem_size) runs once at boot, after the IDT, PIC, interrupts, and GDT are set up:
init_paging(16MB)
│
├─ Get _kernel_end from linker script
├─ Align up to 4KB boundary
├─ init_frame_allocator(start, mem_size)
├─ allocate_frame() → Page Directory
├─ Zero all 1024 PD entries
├─ allocate_frame() → Page Table 0
├─ Identity map 1024 pages (0x000000 – 0x3FFFFF)
├─ PD[0] = Page Table 0
├─ PD[1023] = Page Directory itself (recursive)
└─ enable_paging(pd_physical_address)
├─ mov cr3, pd_phys
└─ set bit 31 of cr0
After enable_paging returns, all memory accesses go through the page tables.
Subchapters
- Frame Allocator — How physical frames are managed
- Page Directory — Allocation and zeroing of the PD
- Identity Mapping — First 4MB implementation
- Recursive Mapping — The PD[1023] trick
- Enabling Paging — CR3 and CR0 manipulation
Frame Allocator
What Is a Frame?
A frame is a 4KB-aligned chunk of physical memory — the physical counterpart to a virtual page. The frame allocator tracks which physical frames are in use and hands them out when needed.
Our Implementation: Bump Allocator
The simplest possible allocator. A pointer starts just after the kernel and moves forward with each allocation. No free list, no recycling.
#![allow(unused)]
fn main() {
static mut FREE_FRAME_HEAD: u32 = 0; // Head of free list (not currently used by allocate_frame)
static mut NEXT_FREE_FRAME: u32 = 0; // Bump pointer
static mut MAX_FRAMES: u32 = 0; // Total frames available
}
Initialization
#![allow(unused)]
fn main() {
unsafe fn init_frame_allocator(start: u32, mem_size: u32) {
// Align start to 4KB boundary
NEXT_FREE_FRAME = align_up(start);
// Total frames = total memory / frame size
MAX_FRAMES = mem_size / PAGE_SIZE;
}
}
start is _kernel_end aligned up to 4KB — the first byte after the kernel’s compiled code and data.
Allocation
#![allow(unused)]
fn main() {
unsafe fn allocate_frame() -> Option<u32> {
if NEXT_FREE_FRAME < MAX_FRAMES * PAGE_SIZE {
let frame = NEXT_FREE_FRAME;
NEXT_FREE_FRAME += PAGE_SIZE; // Advance bump pointer
// Zero out the frame before returning it
let ptr = frame as *mut u8;
for i in 0..PAGE_SIZE {
*ptr.add(i as usize) = 0;
}
Some(frame)
} else {
None // Out of physical memory
}
}
}
Returns None if we’ve exhausted available memory. Callers use .expect() which triggers kernel_panic — there’s no recovery from running out of frames.
Why zero the frame? Security (prevent data leakage between allocations) and stability (avoid random data causing subtle bugs).
Freeing
#![allow(unused)]
fn main() {
pub unsafe fn free_frame(addr: u32) {
*(addr as *mut u32) = FREE_FRAME_HEAD;
FREE_FRAME_HEAD = addr;
}
}
This pushes the frame onto a linked list using the frame’s own memory to store the next pointer. However, allocate_frame() currently uses the bump pointer and does not check FREE_FRAME_HEAD. This means freed frames are not currently reused.
Known Issue: The free list (
FREE_FRAME_HEAD) and the bump allocator (NEXT_FREE_FRAME) are disconnected.free_framecorrectly records freed frames, butallocate_framenever draws from that list. This is acceptable for the current stage — the frame allocator is primarily used to set up the page tables at boot, not for repeated alloc/free cycles. A proper frame allocator that recycles freed frames is on the roadmap.
Address Alignment
#![allow(unused)]
fn main() {
fn align_up(addr: u32) -> u32 {
(addr + PAGE_SIZE - 1) & !(PAGE_SIZE - 1)
}
}
How it works:
addr = 0x00100500 (not page-aligned)
Step 1: Add (PAGE_SIZE - 1)
0x00100500 + 0x00000FFF = 0x001014FF
Step 2: AND with bitmask that zeros the lower 12 bits
!(PAGE_SIZE - 1) = !(0x00000FFF) = 0xFFFFF000
0x001014FF & 0xFFFFF000 = 0x00101000
Result: Next 4KB boundary after 0x00100500
Memory State After Initialization
Physical Memory:
┌────────────────────────┐ ← 16MB (mem_size)
│ │
│ Available for │
│ frame allocation │ ← NEXT_FREE_FRAME walks up from here
│ │
├────────────────────────┤ ← start (kernel_end aligned up)
│ Page Table 0 (4KB) │ ← allocate_frame() call 2
├────────────────────────┤
│ Page Directory (4KB) │ ← allocate_frame() call 1
├────────────────────────┤
│ Kernel code + data │ ← _kernel_start to _kernel_end
└────────────────────────┘ ← 0x00100000
Limitations of the Bump Allocator
| Property | Bump Allocator | What We Eventually Need |
|---|---|---|
| Allocation speed | O(1) | O(1) |
| Free speed | O(1) (push to list) | O(1) |
| Memory reuse | ❌ No | ✅ Yes |
| Fragmentation | N/A (no reuse) | Depends on algorithm |
| Complexity | Very simple | Moderate |
For setting up the initial page tables at boot, a bump allocator is perfectly sufficient. For kmalloc/kfree where blocks are repeatedly allocated and freed, we need a proper heap allocator on top.
Page Directory
Allocating the Page Directory
The page directory is the root of the entire address translation system. Every virtual address lookup starts here.
#![allow(unused)]
fn main() {
let pd_frame: u32 = allocate_frame().expect("Cannot allocate page directory");
let page_dir: *mut PageDirectory = pd_frame as *mut PageDirectory;
}
allocate_frame() returns a physical address. We cast it to a *mut PageDirectory. This works because at this point in init_paging, paging is not yet enabled — physical and virtual addresses are the same thing. After paging is enabled, we access the page directory through the recursive mapping at 0xFFFFF000.
Zeroing All Entries
#![allow(unused)]
fn main() {
for i in 0usize..1024 {
(*page_dir).entries[i] = 0;
}
}
Every entry starts as zero — not present, not writable. Any access to an unmapped virtual address immediately triggers a page fault. This is correct behaviour: we explicitly map only what we need.
allocate_frame() already zeros the frame before returning it, so this loop is technically redundant. It’s kept for clarity — the intent is explicit.
The _kernel_end Symbol
init_paging uses the linker-provided _kernel_end symbol to know where the kernel stops:
#![allow(unused)]
fn main() {
extern "C" {
static _kernel_end: u8;
}
pub fn get_kernel_end() -> u32 {
unsafe { &_kernel_end as *const u8 as u32 }
}
}
This is declared in linker.ld:
_kernel_end = .;
The linker sets _kernel_end to the address immediately after the last byte of the kernel. By starting frame allocation from here, we guarantee we never overwrite the kernel’s code or data.
Sanity check at startup:
#![allow(unused)]
fn main() {
if kernel_end > 0x00400000 {
panic!("Kernel is larger than 4MB! Cannot identity map.");
}
}
If the compiled kernel ever exceeds 4MB, our single identity-mapped page table (covering 0x000000–0x3FFFFF) won’t cover it, and the kernel would crash the moment paging is enabled.
Memory Layout After Allocation
NEXT_FREE_FRAME → ┌─────────────────┐
│ (next allocs) │
├─────────────────┤ ← pd_frame + 4096
│ Page Directory │ ← pd_frame (4KB, zeroed)
├─────────────────┤ ← align_up(_kernel_end)
│ Kernel binary │
└─────────────────┘ ← 0x00100000
Identity Mapping Implementation
This chapter covers the actual code that creates the identity mapping. For the theory of why we identity map, see Identity Mapping.
Creating the Page Table
#![allow(unused)]
fn main() {
// Allocate a 4KB frame to use as our first page table
let pt_frame: u32 = allocate_frame().expect("Cannot allocate page table");
let page_table: *mut u32 = pt_frame as *mut u32;
}
A page table is just 1024 u32 entries packed into a 4KB frame. We don’t need a named struct — a raw pointer to the frame works fine.
Filling the Page Table
#![allow(unused)]
fn main() {
// Identity map 1024 pages = 4MB
for i in 0usize..1024 {
let phys_addr = (i as u32) * 0x1000; // 0x000000, 0x001000, 0x002000, ...
*page_table.add(i) = phys_addr | PAGE_PRESENT | PAGE_WRITABLE;
}
}
Each entry maps virtual page N to physical page N. Page 0 → physical 0x000000, page 1 → physical 0x001000, and so on up to page 1023 → physical 0x3FF000.
Every page is marked present and writable. The kernel needs write access to everything in this region — including the VGA buffer, the stack, and the page tables themselves once they’re allocated above the kernel.
Installing in the Page Directory
#![allow(unused)]
fn main() {
// PD entry 0: covers virtual 0x00000000–0x003FFFFF
(*page_dir).entries[0] = pt_frame | PAGE_PRESENT | PAGE_WRITABLE;
}
Page directory index 0 covers the virtual range 0x00000000 – 0x003FFFFF (first 4MB). Pointing it to our page table completes the identity mapping.
What Gets Covered
Virtual Range PD Index PT Frame Coverage
0x00000000-0x003FFFFF [0] pt_frame Identity mapped:
- Low memory
- 0x000B8000 (VGA buffer)
- 0x00100000 (kernel start)
- Kernel code + data
- Page directory frame
- Page table frame
- Future frame allocations
Note that the page directory frame and page table frame themselves fall within this identity-mapped range (they’re allocated after the kernel end, which is below 4MB). So we can access them both before and after enabling paging using the same addresses.
The Critical Moment
After installing PD[0] but before enabling paging:
Virtual addresses: Not translated (paging off)
Physical addresses: CPU uses directly
Kernel executing at physical 0x00102000
PT[0] = pt_frame | PRESENT | WRITABLE (physical 0x00100FFF or wherever)
PD[0] = pt_frame | PRESENT | WRITABLE
After enable_paging():
Virtual addresses: Translated through PD → PT
Physical addresses: Hidden behind MMU
CPU fetches from virtual 0x00102000
→ PD[0] → pt_frame
→ PT[258] → physical 0x00102000
→ Same physical memory!
Execution continues without interruption.
Recursive Mapping
Recursive mapping is a clever trick that lets us access page tables through virtual addresses after paging is enabled — without needing to separately track the physical addresses of every page table we create.
The Problem It Solves
After paging is enabled, the CPU translates all virtual addresses. But map_page() needs to modify page table entries. To do that, it needs to write to the page table — which means it needs a virtual address that maps to the page table’s physical location.
Without recursive mapping, we’d need to maintain a separate data structure that records “page table for virtual range X is at physical address Y.” That’s bookkeeping overhead we can avoid.
The Trick: PD[1023] Points to Itself
We set the last entry of the page directory to point to the page directory itself:
#![allow(unused)]
fn main() {
(*page_dir).entries[1023] = pd_frame | PAGE_PRESENT | PAGE_WRITABLE;
}
Now, when the CPU translates a virtual address that has 1023 as its page directory index, it ends up reading the page directory as if it were a page table. And if the page table index is also 1023, it reads the page directory as if it were a page!
The Magic Addresses
This creates two special virtual address regions:
Virtual address 0xFFFFF000:
┌─────────────────────────────────────────────────┐
│ PD index = 1023 → PD itself (used as page table)│
│ PT index = 1023 → PD itself (used as page) │
│ Offset = 0x000 │
└─────────────────────────────────────────────────┘
Result: You're reading the page DIRECTORY as if it were a page.
0xFFFFF000 gives you access to the page directory's contents.
Virtual addresses 0xFFC00000 – 0xFFFFEFFF:
┌─────────────────────────────────────────────────┐
│ PD index = 1023 → PD itself (used as page table)│
│ PT index = N → PD[N] entry (the Nth PT frame)│
│ Offset = X → byte within that PT frame │
└─────────────────────────────────────────────────┘
Result: You can read and write any page table!
0xFFC00000 = page table 0 (covers 0x00000000-0x003FFFFF)
0xFFC01000 = page table 1 (covers 0x00400000-0x007FFFFF)
...etc
Constants
#![allow(unused)]
fn main() {
const PD_RECURSIVE_ADDR: u32 = 0xFFFFF000; // Access the page directory
const PT_BASE_ADDR: u32 = 0xFFC00000; // Access page tables
}
How map_page Uses It
#![allow(unused)]
fn main() {
pub unsafe fn map_page(physaddr: u32, virtualaddr: u32, flags: u32) {
let pdindex = (virtualaddr >> 22) & 0x3FF; // Top 10 bits
let ptindex = (virtualaddr >> 12) & 0x3FF; // Middle 10 bits
// Access the page directory through the recursive mapping
let pd = PD_RECURSIVE_ADDR as *mut u32;
let pd_entry_ptr = pd.add(pdindex as usize);
let pd_entry = ptr::read_volatile(pd_entry_ptr);
// If the page table for this range doesn't exist yet, create it
if !is_set(pd_entry, PDEBit::P) {
let new_pt_frame = allocate_frame()
.expect("Out of memory: cannot allocate page table");
// Install the new page table in the page directory
ptr::write_volatile(pd_entry_ptr, new_pt_frame | PAGE_PRESENT | PAGE_WRITABLE | PAGE_USER);
// Zero the new page table (via the PT_BASE_ADDR window)
let pt_virt = (PT_BASE_ADDR + (pdindex * 0x1000)) as *mut u32;
for i in 0..1024 {
ptr::write_volatile(pt_virt.add(i), 0);
}
}
// Write the mapping into the page table
let pt = (PT_BASE_ADDR + (pdindex * 0x1000)) as *mut u32;
let pt_entry_ptr = pt.add(ptindex as usize);
ptr::write_volatile(pt_entry_ptr, (physaddr & !0xFFF) | (flags & 0xFFF) | PAGE_PRESENT);
// Invalidate TLB entry for this virtual address
flush_tlb(virtualaddr);
}
}
ptr::read_volatile / ptr::write_volatile — Volatile reads and writes prevent the compiler from caching or reordering these memory accesses. The page tables are hardware-visible state, not just ordinary RAM.
Why This Is Clever
Without recursive mapping, map_page would need to:
- Look up the physical address of the relevant page table
- Temporarily map that physical address somewhere accessible
- Write the entry
- Unmap the temporary mapping
With recursive mapping, steps 1-2-4 vanish. The special virtual addresses (PT_BASE_ADDR + pdindex * 0x1000) give direct access to any page table, always.
The Trade-Off
Recursive mapping permanently uses PD[1023], which means the virtual address range 0xFFC00000 – 0xFFFFFFFF (4MB) is reserved for page table access. This region can’t be used for anything else.
For a 4GB address space, sacrificing 4MB for page table access is a reasonable trade.
Enabling Paging
The Two-Step Activation
Enabling paging requires configuring two CPU control registers in the correct order:
#![allow(unused)]
fn main() {
unsafe fn enable_paging(page_dir_phys: u32) {
// Step 1: Tell the CPU where the page directory is
core::arch::asm!(
"mov cr3, {0:e}",
in(reg) page_dir_phys,
);
// Step 2: Set the PG bit in CR0 to enable paging
core::arch::asm!(
"mov eax, cr0",
"or eax, 0x80000000",
"mov cr0, eax",
out("eax") _,
);
}
}
CR3 — Page Directory Base Register
CR3 (32-bit):
Bits 31-12: Physical address of page directory (4KB-aligned)
Bits 11-0: Flags (cache control, etc.) — we use 0
Writing to CR3 also flushes the entire TLB. The CPU starts fresh with no cached translations.
mov cr3, {page_dir_phys}
After this instruction, the CPU knows where to find the page directory, but paging is not yet active. The CPU still uses physical addresses directly.
CR0 — Control Register 0
CR0 controls fundamental CPU operation modes. We only want to set bit 31 (the PG bit):
CR0 bit 31: PG (Paging Enable)
0 = Paging disabled (physical addressing)
1 = Paging enabled (virtual addressing via page tables)
mov eax, cr0 ; Read current CR0 (preserve all other bits)
or eax, 0x80000000 ; Set bit 31 only
mov cr0, eax ; Write back
The OR is essential. CR0 has other critical bits:
| Bit | Name | Meaning |
|---|---|---|
| 0 | PE | Protected Mode Enable (must stay 1) |
| 16 | WP | Write Protect (kernel can’t write to read-only pages) |
| 31 | PG | Paging Enable ← we set this |
Using MOV instead of OR would overwrite these bits and crash the CPU.
The Point of No Return
The instruction mov cr0, eax (with PG bit set) is the point of no return. The CPU immediately starts translating all virtual addresses — including the very next instruction fetch.
Before this instruction: CPU uses physical address 0x00102ABC directly
After this instruction: CPU translates virtual 0x00102ABC
→ PD[0] → PT[258] → physical 0x00102ABC
→ Same address (identity mapping works!)
If the identity mapping is wrong, the CPU page-faults on the instruction immediately following mov cr0, eax. Since there’s no page fault handler set up yet (paging wasn’t enabled until now), this becomes a double fault, then a triple fault, then a CPU reset.
This is why the identity mapping is set up entirely before calling enable_paging — you get exactly one shot.
After Paging Is Enabled
init_paging returns
↓
kernel_main continues executing
↓
Every memory access (instruction fetch, stack push, variable read)
goes through the page tables
↓
Unmapped addresses → page fault → rust_page_fault() → "EXCEPTION #14"
Mapped addresses → transparent hardware translation
The kernel doesn’t need to change any of its pointer arithmetic — identity mapping ensures all existing pointers still work. The paging system is invisible unless something accesses unmapped memory.
Physical Allocator — kmalloc
Status: Not yet implemented. This chapter documents the design and what needs to be built.
The Gap Between Frame Allocator and kmalloc
The frame allocator hands out 4KB blocks. But most allocations are much smaller — a few bytes, a struct, maybe a kilobyte. Giving out an entire 4KB frame for a 64-byte struct wastes 4032 bytes.
kmalloc sits between the caller and the frame allocator:
Caller wants 256 bytes
↓
kmalloc(256)
↓
Find or create a region with a free 256-byte block
↓
Return pointer to that block
(Only calls allocate_frame() when it needs a new 4KB slab to carve up)
Where the Heap Lives
The kernel heap starts just after the kernel binary and initial page tables, within the identity-mapped 4MB region:
0x00400000 ← End of identity-mapped region
← Heap upper bound (can grow up to here)
(heap) ← kmalloc manages this region
~0x00200000 ← Heap start (approximate, after page tables)
The heap is a fixed region reserved at init time. We’re not doing dynamic expansion (kbrk) in the first pass — just a fixed-size heap.
Design: Free-List Allocator
The classic approach. Every allocation is preceded by a small header:
┌──────────────────────┐ ← What kmalloc returns (pointer to here)
│ User Data │
│ (requested size) │
├──────────────────────┤ ← Header (hidden from user)
│ size: u32 │ ← Total size of this block (header + data)
│ used: bool │ ← Is this block allocated?
│ next: *mut Header │ ← Next block in the heap (for walking)
└──────────────────────┘
The header is stored before the user data. kmalloc returns a pointer to the user data region; the header is at ptr - sizeof(Header).
First-Fit Allocation
Walk the block list from the start
For each block:
If block.used == false AND block.size >= requested_size:
Mark block as used
Split if block is much larger than needed
Return pointer to user data
If no block found:
Out of memory → return null
Freeing
Given ptr:
Header = ptr - sizeof(Header)
Mark header.used = false
Try to merge with adjacent free blocks (coalescing)
Coalescing
Without coalescing, repeated alloc/free cycles produce a heap full of tiny unusable gaps:
Before: [used:100][free:50][used:200][free:50][free:100]
After free of used:200:
[used:100][free:50][free:200][free:50][free:100]
Without coalescing: 4 free blocks, largest is 200 bytes
With coalescing: [used:100][free:400] ← one big usable block
Functions to Implement
#![allow(unused)]
fn main() {
// memory/physical_mem.rs
pub unsafe fn init_heap(start: u32, size: u32);
// Set up the initial free block spanning the heap region
pub unsafe fn kmalloc(size: usize) -> *mut u8;
// Allocate `size` bytes of physical memory
// Returns null on failure
pub unsafe fn kfree(ptr: *mut u8);
// Free memory previously returned by kmalloc
pub unsafe fn ksize(ptr: *mut u8) -> usize;
// Return the usable size of an allocation
}
Key Design Decisions
Alignment: Return pointers aligned to at least 4 bytes (or 8). Misaligned access is slow on x86 and illegal on some architectures.
Minimum block size: Set a floor (e.g. 16 or 32 bytes) to avoid splitting blocks into unusable fragments.
Split threshold: Only split a block if the remainder is large enough to be useful. Splitting a 100-byte block to satisfy a 96-byte request leaves a 4-byte remainder that can’t hold anything.
Header size: Keep it small. If the header is 12 bytes and you’re allocating 8-byte structs, you’re spending more on metadata than data.
Testing
The shell will get a memtest command:
> memtest
Allocating 64 bytes... ptr = 0x00201010 OK
Allocating 1024 bytes... ptr = 0x00201060 OK
Freeing 64 bytes... OK
Reallocating 64 bytes... ptr = 0x00201010 OK (reused!)
Allocating 100 * 256 bytes... OK
Freeing every other block... OK
Stats: 25KB used, 975KB free
Further Reading
- OSDev Wiki: Memory Allocation
- OSDev Wiki: Page Frame Allocation
Virtual Allocator — vmalloc
Status: Not yet implemented. This chapter documents the design and what needs to be built.
How vmalloc Differs from kmalloc
kmalloc | vmalloc | |
|---|---|---|
| Returns | Physical address (identity mapped) | Virtual address |
| Backed by | Heap region in identity-mapped space | Any physical frames, mapped on demand |
| Contiguous? | Physically contiguous | Virtually contiguous, physically scattered |
| Use case | Small kernel allocations | Large allocations, kernel buffers |
| Location | Low memory (~0x00200000+) | High virtual memory (~0xC0000000+) |
How vmalloc Works
vmalloc(size)
│
├─ Round up size to page boundary
├─ Find N free virtual pages in the vmalloc region
├─ For each of the N pages:
│ ├─ allocate_frame() ← get a physical frame
│ └─ map_page(frame, virt, flags) ← map it into virtual space
└─ Return first virtual address
The physical frames don’t need to be contiguous — each is independently mapped to a contiguous virtual range.
The vmalloc Region
0xFFFFFFFF ───────────── 4GB
(end of vmalloc region)
vmalloc allocates here:
virtual pages mapped to
scattered physical frames
0xC0000000 ───────────── 3GB
(start of vmalloc region)
0x00400000 ───────────── ← End of identity mapping
(kmalloc lives here)
Tracking Virtual Allocations
Unlike kmalloc, where the header is adjacent to the data, vmalloc needs a separate metadata structure. We need to know, for each vmalloc allocation:
- Which virtual address range is in use
- How many pages it spans (so
vfreeknows how many to unmap)
One approach: a small array of VmallocRegion structs in kernel memory:
#![allow(unused)]
fn main() {
struct VmallocRegion {
virt_start: u32, // Virtual start address
page_count: u32, // Number of pages allocated
in_use: bool, // Is this slot taken?
}
static mut VMALLOC_TABLE: [VmallocRegion; MAX_VMALLOC_REGIONS] = ...;
}
Functions to Implement
#![allow(unused)]
fn main() {
// memory/virtual_mem.rs
pub unsafe fn vmalloc(size: usize) -> *mut u8;
// Allocate `size` bytes of virtual address space
// Backed by physical frames via map_page()
// Returns null on failure
pub unsafe fn vfree(ptr: *mut u8);
// Unmap and free all pages for this allocation
// Calls unmap_page() + free_frame() for each page
pub unsafe fn vsize(ptr: *mut u8) -> usize;
// Return the size of the vmalloc allocation at ptr
}
The vmalloc Address Allocator
We need a way to find free virtual address ranges in the vmalloc region. The simplest approach: start at 0xC0000000 and walk forward, skipping ranges that are in use. Maintain a VMALLOC_NEXT pointer similar to the frame allocator’s bump pointer.
Key Differences in vfree
vfree must do more work than kfree:
#![allow(unused)]
fn main() {
pub unsafe fn vfree(ptr: *mut u8) {
// Find the allocation record for ptr
let region = find_vmalloc_region(ptr as u32);
// For each page in the allocation:
for i in 0..region.page_count {
let virt = region.virt_start + i * PAGE_SIZE;
// Get the physical frame address before unmapping
let phys = get_physaddr(virt as *const u8) as u32;
// Unmap the virtual page
unmap_page(virt);
// Return the physical frame to the frame allocator
free_frame(phys & !0xFFF);
}
// Mark the allocation record as free
region.in_use = false;
}
}
When to Use vmalloc vs kmalloc
Use kmalloc for:
- Small structs and buffers (< a few KB)
- Allocations that need physically contiguous memory
- Performance-critical allocations (vmalloc has overhead from page mapping)
Use vmalloc for:
- Large buffers (several MB)
- Allocations where physical contiguity doesn’t matter
- When you need more memory than fits in the kmalloc heap
Testing Strategy
Core Concepts to Validate
1. Heap Allocator Correctness
Before testing edge cases, verify the basics work:
Allocate X bytes → get a non-null pointer
Write to the memory → no crash
Read back what was written → data matches
Free the pointer → no crash
Allocate again → get a valid pointer (ideally the same one, reused)
2. Fragmentation Resistance
The real test of a heap allocator:
Allocate 100 blocks of 256 bytes each
Free every other block (blocks 0, 2, 4, ..., 98)
Allocate a 256-byte block → should succeed (reuse a freed slot)
Allocate a 512-byte block → depends on coalescing
Without coalescing, adjacent freed blocks remain separate. A 512-byte allocation may fail even though 512 bytes of free space exist split across two 256-byte slots.
3. vmalloc Page Mapping
Call vmalloc(8192) → allocates 2 pages
Write to page 0
Write to page 1 → must be accessible even though physically separate
Read back both → data must be correct
Call vfree(ptr) → both pages unmapped, frames returned
Shell Commands to Add
Add a memtest command to NPS:
#![allow(unused)]
fn main() {
"memtest" => self.cmd_memtest(),
"meminfo" => self.cmd_meminfo(),
}
memtest
#![allow(unused)]
fn main() {
fn cmd_memtest(&self) {
println!("=== Memory Test ===");
// Test 1: Basic alloc/free
let p1 = kmalloc(64);
println!("kmalloc(64): {:?} {}", p1, if p1.is_null() { "FAIL" } else { "OK" });
kfree(p1);
// Test 2: Write and read back
let p2: *mut u32 = kmalloc(16) as *mut u32;
*p2 = 0xDEADBEEF;
let ok = *p2 == 0xDEADBEEF;
println!("write/read: {}", if ok { "OK" } else { "FAIL" });
kfree(p2 as *mut u8);
// Test 3: Large allocation
let p3 = kmalloc(1024 * 1024); // 1MB
println!("kmalloc(1MB): {:?} {}", p3, if p3.is_null() { "FAIL" } else { "OK" });
kfree(p3);
// Test 4: vmalloc
let v1 = vmalloc(8192);
println!("vmalloc(8KB): {:?} {}", v1, if v1.is_null() { "FAIL" } else { "OK" });
vfree(v1);
println!("=== Test complete ===");
}
}
meminfo
#![allow(unused)]
fn main() {
fn cmd_meminfo(&self) {
println!("=== Memory Info ===");
println!("Frame allocator:");
println!(" Next free frame: 0x{:08x}", NEXT_FREE_FRAME);
println!(" Max frames: {}", MAX_FRAMES);
println!(" Frames used: {}", /* calculation */);
println!("Heap:");
println!(" Start: 0x{:08x}", HEAP_START);
println!(" Size: {} KB", HEAP_SIZE / 1024);
println!(" Used: {} bytes", /* walk and count */);
println!(" Free: {} bytes", /* walk and count */);
}
}
Red Flags to Watch For
| Symptom | Likely Cause |
|---|---|
Crash on first kmalloc | Heap not initialized, or heap start overlaps kernel |
Null pointer from kmalloc | Heap exhausted, or header corruption |
| Data corruption after alloc | Misaligned pointer, or header size wrong |
Crash on kfree | Double free, freeing a non-heap pointer, or heap corruption |
vmalloc returns null | No free virtual range found, or frame allocator exhausted |
Page fault after vmalloc | map_page failed silently, or wrong virtual address calculation |
System hangs after vfree | unmap_page + TLB flush issue, or infinite loop in free logic |
Alignment Checklist
kmallocmust return pointers aligned to at least 4 bytes (8 is safer)- The block header must be sized so that
header + sizeis also aligned vmallocreturns page-aligned pointers (4KB boundary) by definition- Frame addresses from
allocate_frameare always 4KB-aligned
Recommended Implementation Order
init_heap(start, size)— Create the first free blockkmalloc(size)— First-fit, no splittingkfree(ptr)— Mark as free, no coalescing- Add splitting to
kmalloc(don’t waste memory on small allocations) - Add coalescing to
kfree(merge adjacent free blocks) ksize(ptr)— Read from headervmalloc(size)— Map pages into virtual spacevfree(ptr)— Unmap and return framesvsize(ptr)— Read from metadata table- Add shell commands for testing and debugging
Initialising the Heap
Before we write a single allocation function, we need to establish the heap. This means:
- Decide where it lives in memory
- How big is it
- Create an initial block
Where does the heap start
The kernel heap starts just after the kernel binary and initial page tables, within the identity-mapped 4MB region:
0x00400000 ← End of identity-mapped region
← Heap upper bound (can grow up to here)
(heap) ← kmalloc manages this region
~0x00200000 ← Heap start (approximate, after page tables)
The heap is a fixed region reserved at initialisation time. We’re not doing dynamic expansion (kbrk) in the first pass — just a fixed-size heap.
How big should it be
For now we are going to make this fixed sized. This being said we need to pick a size that fits comfortably in the identity-mapped 4MB but leaves room for the frame allocator above it.
What does the initial state look like?
When init_heap returns, the entire heap region should be represented as a single large free block with a header at the start. That header is the foundation of the free list. Every subsequent allocation and free is just reshaping that initial block
Initial Memory State
HEAP_START → [Header: size=HEAP_SIZE, used=false, next=null]
[ ]
[ (all free space) ]
[ ]
HEAP_START + HEAP_SIZE
BlockHeader
The BlockHeader sits at the start of every single block in the heap. Both free and used ones. It gets stamped into memory millions of times potentially.
But every block does not need to know about the page tables, this is something the heap as a whole knows once at initialisation. Things like page tables, heap start addresses and the free list belong to the heaps global state.
A single block needs to answer three questions:
- Is the block currently free or in use?
- How big is the block?
- Where is the next block? (So we can walk the list)
The BlockHeader should give just enough metadata to navigate and manage one block. We can structure it like this
#[repr(C)]
pub struct BlockHeader {
free: bool,
size: u32,
next: *mut BlockHeader,
}
The size field needs to be precise. The size can differ depending on what the compiler decides. A bool is one byte, but the compiler will likely pad it to align with the u32 that follows. So we need to add #[repr(C)]. Without it we might get:
[bool: 1 byte][padding: 3 bytes][size: 4 bytes][next: 4 bytes] = 12 bytes
This means to find the header of the block of memory, you only need to use ptr - 12 meaning using size_of::<BlockHeader>() will be consistent. So we can now add this const,
pub const HEADER_SIZE: usize = core::mem::size_of::<BlockHeader>();
With the next field, we can walk the blocks to find adjacent free blocks for coalescing later and lets kfree find its neighbours.
Start of the Heap
There one another const we are going to need to provide, and that’s HEAP_START. We can already get the next free frame by checking NEXT_FREE_FRAME as it sits right after the page directory and the first page table. That means, everything from there on is untouched.
But there is a small problem. That is, NEXT_FREE_FRAME is private to paging.rs so we need to pass the heap as a parameter to init_heap and make a getter for NEXT_FREE_FRAME
// paging.rs
pub unsafe fn get_heap_start() -> u32 {
NEXT_FREE_FRAME
}
// lib.rs
unsafe {
init_paging(16 * 1024 * 1024);
init_heap(get_heap_start(), HEAP_SIZE)
}
But for this, we also need to have define HEAP_SIZE. We know that given the identity-mapped region ends at 4MB(x00400000) and NEXT_FREE_FRAME will be somewhere around 0x00200000 after paging, that we have roughly 2MB of useable space. So we can define HEAP_SIZE as so:
pub const HEAP_SIZE: u32 = 1024 * 1024; // 1MB
Free-list Allocator
When the heap is initialised, there will be one BlockHeader containing all of the stack, set to free and the next being null. Then when we allocate 256b and this happens:
At init_heap:
HEAP_START → [Header: free=true, size=HEAP_SIZE, next=null ]
[ ]
[ one big free block ]
[ ]
HEAP_START + HEAP_SIZE
After allocation:
HEAP_START → [Header: free=false, size=256, next=→ ]
[256 bytes of user data ]
[Header: free=true, size=HEAP_SIZE-256-HEADER_SIZE, next=null]
[ remaining free space ]
[ ]
HEAP_START + HEAP_SIZE
The original block is split into two, one allocated block the right size, and one free block with whatever is left. The next pointer of the first header points to the second header.
Every subsequent kmalloc call either:
- Splits the big free block again
- Finds a previously freed block that’s big enough to reuse.
Every kfree marks a block as free again, making it available for reuse.
init_heap
Now lets start filling out the body. We have the start of the heap and its size. We need to save these initial values and create a BlockHeader to send back containing the initial heap.
let header = HEAP_START as *mut BlockHeader;
(*header).free = true;
(*header).size = HEAP_SIZE - HEADER_SIZE as u32; // space minus the header itself
(*header).next = 0 as *mut BlockHeader; // there is no next
It’s important to note that after allocation, the available heap is smaller than what we specified because the header itself takes up space. kmalloc will use the size to decide of the block is big enough and it needs to be accurate.
kmalloc, kfree and ksize
kmalloc
This is the function that allocates memory for the kernel. Its simpler than one might think. All it does is, is walk the list of blocks and finds one that is big enough to hold our data, then splits of a small piece to use and save the rest as free memory to be used.
We have already done block headers so kmalloc just has to edit and create new ones accordingly.
Kmalloc follows a simple loop condition that if the head is free and the available size is enough then continue. Otherwise it means we have run out of space and can’t allocate. In that case we send back None. If it was successful is sends back the pointer as a Option<*mut u8>
Where to begin
First of all we need to find the heap and begin walking. We already know where this is so we can use this call to save it to out head
let mut head = HEAP_START as *mut BlockHeader;
That’s a Bingo
If we find a block that meets our condition…
"if the head is free and the available size is enough then continue"
…then we need to edit the header is some way. We immediately set free to false and decided what to do next. The first option is if the block leaves space over too small for a new allocation. In this case we just give the current block and set the free to false.
(*head).free = false;
Otherwise we need to create a new header, subtracting the new allocation.
// Save current size for later
let old_size = (*head).size;
// This block is now being used
(*head).free = false;
// Save the requested size
(*head).size = size as u32;
// Create a new next to hold the rest of the heap
let next = (head as *mut u8).add(HEADER_SIZE + size) as *mut BlockHeader;
// New block is set to free
(*next).free = true;
// Size is the old size minus both the header size and the requested size
(*next).size = old_size - size as u32 - HEADER_SIZE as u32;
// Set news next to the heads next and the heads next the the new to create a chain
(*next).next = (*head).next;
(*head).next = next;
We can then it returns a pointer to the user data region which immediately starts after the header.
return Some((head as *mut u8).add(HEADER_SIZE));
If for some reason, kmalloc can’t find valid block, it sends back the None:
return None;
kfree
There isn’t really much to say about kfree. All this function does is take the pointer given, moves to its header and sets the free bool to true.
if ptr.is_null() {
return;
}
let header = ptr.sub(HEADER_SIZE) as *mut BlockHeader;
(*header).free = true;
ksize
ksize follows the same procedure. We already have a pointer so we just move to the header and return the size stored in the block
if ptr.is_null() {
return 0;
}
let header = ptr.sub(HEADER_SIZE) as *mut BlockHeader;
(*header).size
Memtest
This acts as our sanity check and as a way to test these functions in our kernel.
We are essentially
- Use kmalloc to allocate a block of 42bytes
- Write
0x2Ato the space - Read from the block
- Check size with
ksize - Free with
kfree
> memtest
=== Testing kmalloc & co. ===
kmalloc(42): OK ptr: 0x25700c
Write 0x2A: OK
Read back: 42 OK
ksize: 44 OK
kfree: OK
vmalloc, vfree and vsize
Now we move onto the virtual allocators. These sit on top of the paging infrastructure. Unlike kmalloc which uses physical frames, vmalloc maps arbitrary virtual ranges using the page directory/tables.
Think of it like this:
kmalloc → physical frames, identity-mapped, fast, contiguous in physical memory
vmalloc → virtual address space, backed by physical frames mapped via page tables,
not necessarily contiguous physically
Vmalloc solves a big problem. Say we want to allocate 12MB of memory but our physical memory looks like this:
- 4MB free here
- 2MB free there
- 6MB free somewhere else
kmalloc won’t be any help here because it needs a contiguous physical region. But vmalloc can, because it only needs contiguous virtual addresses. The physical frames backing it can be scattered anywhere in physical memory — the page tables stitch them together into one seamless virtual region.
The Virtual Address space
Before writing any code, we need to decide where vmalloc allocations live in virtual memory. We can’t just pick a random address — it needs to be a dedicated region that won’t clash with the kernel or the heap.
Our memory layout looks like this:
0x00100000 kernel start (1MB)
...kernel binary...
0x00800000 end of kernel reserved space (8MB)
heap (1MB, managed by kmalloc)
0x00900000 VMALLOC_START ← vmalloc region begins here
...vmalloc virtual space...
0xD0000000 VMALLOC_END ← plenty of room
We track the next free virtual address with a simple cursor:
static mut VMALLOC_NEXT_VADDR: u32 = VMALLOC_START;
Every allocation advances this cursor by pages * PAGE_SIZE. Simple and effective.
Tracking Allocations — VMallocRegion
We need to track each allocation so that vfree and vsize can find them later. For this we use a linked list of VMallocRegion structs, each allocated via kmalloc:
struct VMallocRegion {
start: u32, // virtual address this allocation begins at
pages: u32, // how many pages were allocated
free: bool, // is this region free?
next: *mut VMallocRegion // next region in the linked list
}
Notice that the structs themselves live in the kmalloc heap — we use kmalloc(size_of::<VMallocRegion>()) to create each one. This is a nice separation: kmalloc manages small metadata, vmalloc manages large virtual regions.
vmalloc
The way vmalloc works is quite different to kmalloc. It goes something like this:
- Calculate how many pages are needed
- For each page, allocate a physical frame and map it to the next virtual address
- Create a
VMallocRegionstruct to track the allocation - Add it to the linked list and return the start address
Page Calculation
Since one page holds 4096 bytes, every allocation is rounded up to the nearest page boundary:
pages = (size + 4095) / 4096
This ensures any value under 4096 allocates at least one page. Note that integer division truncates, so without the + 4095 trick, vmalloc(1) would calculate 1 / 4096 = 0 and allocate nothing!
| Bytes | Allocated | Pages |
|---|---|---|
| 1 | 4096 | 1 |
| 5000 | 8182 | 2 |
| 12000 | 12276 | 3 |
Mapping the Pages
for i in 0..page_num {
if VMALLOC_NEXT_VADDR >= VMALLOC_END {
if i > 0 { vfree(old_head as *mut u8); }
return 0 as *mut u8;
}
let frame = allocate_frame();
match frame {
Some(f) => map_page(f, VMALLOC_NEXT_VADDR, 0),
None => {
if i > 0 { vfree(old_head as *mut u8); }
return 0 as *mut u8;
}
}
VMALLOC_NEXT_VADDR += PAGE_SIZE;
}
A couple of things worth noting here. First, we check VMALLOC_END before each page to make sure we don’t walk off the end of our reserved region. Second, if allocate_frame fails midway through, we call vfree on what we already mapped to clean up — no memory leaks.
Tracking the Allocation
After the mapping loop, we kmalloc a VMallocRegion to describe what we just did, and prepend it to the linked list:
let block = kmalloc(size_of::<VMallocRegion>());
match block {
Some(b) => {
let new_block = b as *mut VMallocRegion;
(*new_block).start = old_head; // where this allocation starts
(*new_block).pages = page_num as u32;
(*new_block).free = false;
(*new_block).next = VMALLOC_HEAD; // prepend to list
VMALLOC_HEAD = new_block;
return old_head as *mut u8; // return the virtual address
}
None => return 0 as *mut u8,
}
vfree
vfree needs to reverse everything vmalloc did. It walks the linked list to find the matching region, then for each page it retrieves the physical address, frees the frame, and unmaps the page:
let mut current = VMALLOC_HEAD;
while !current.is_null() {
if (*current).start == ptr as u32 {
for i in 0..(*current).pages {
let vaddr = ((*current).start + (i * PAGE_SIZE)) as *const u8;
let paddr = get_physaddr(vaddr) as u32; // find physical frame
free_frame(paddr); // return frame to allocator
unmap_page(vaddr as u32); // clear page table entry
}
(*current).free = true;
break;
}
current = (*current).next;
}
The order matters here — we call get_physaddr before unmap_page, because once the page table entry is cleared, we can no longer look up the physical address.
vsize
vsize follows the same linked list walk pattern, and simply returns pages * PAGE_SIZE:
while !current.is_null() {
if (*current).start == ptr as u32 {
return ((*current).pages * PAGE_SIZE) as usize;
}
current = (*current).next;
}
return 0;
The Frame Allocator Bug
During testing I hit an interesting bug — kmalloc was returning None immediately after map_page was called, even though the heap was working fine beforehand.
The culprit: The frame allocator starts handing out frames from right after the kernel, but the heap lives in that same region. When map_page internally called allocate_frame to create a new page table, it was handing out a frame that overlapped with the heap — silently corrupting it.
The fix was to advance NEXT_FREE_FRAME past the heap at the end of init_heap:
pub unsafe fn advance_frame_past_heap(heap_start: u32, heap_size: u32) {
NEXT_FREE_FRAME = align_up(heap_start + heap_size);
}
A good reminder that in bare metal programming, nothing enforces memory boundaries for you — you have to reason about every region explicitly.
Kernel Panics
A kernel panic is when a fault occurs in the kernel resulting in the halting of operations. Quite simply, if something goes wrong, we print a message and start an infinite loop to stop the CPU from doing any more damage.
Not all panics are equal though. Some errors are severe enough to warrant a full halt — corrupted memory, invalid CPU state, unrecoverable hardware faults. Others are recoverable — a failed allocation, an invalid argument, a missing resource. So we implement two versions.
Fatal Panic
This is the non-recoverable version. When this occurs there has to be a definite
stop, resulting in the user physically restarting the kernel. The -> ! return
type tells Rust this function never returns.
#![allow(unused)]
fn main() {
pub fn kernel_panic(msg: &str) -> ! {
writer().printc("\n\n!!! KERNEL PANIC !!!", Color::White, Color::Red);
println!("{}", msg);
loop {}
}
}
This is also tied to Rust’s #[panic_handler] — a required function in any
no_std environment that tells the compiler what to do when a Rust panic!()
fires. Without it, the kernel won’t compile. We hook it into kernel_panic so
there’s only one code path:
#![allow(unused)]
fn main() {
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
kernel_panic(info.message().as_str().unwrap_or("unknown panic"));
}
}
info.message().as_str() extracts the panic message as a string slice. The
.unwrap_or("unknown panic") is a fallback in case no message was provided —
for example a bare panic!() with no arguments.
Non-Fatal Panic
In this version, we are simply notifying that an error has occurred but the severity is low enough to allow the CPU to proceed. Good candidates for this are things like failed allocations or invalid input — situations where the caller might be able to recover.
#![allow(unused)]
fn main() {
pub fn kernel_warn(msg: &str) {
writer().printc("\n\n!!! KERNEL WARNING !!!", Color::Black, Color::Yellow);
println!("{}", msg);
}
}
The key difference from kernel_panic is that this function returns normally,
letting execution continue. No loop {}, no -> !.
kbrk and vbrk
What happens if you don’t have enough space left? You’ve done some allocations but
realise you need more room. Well, all you need is a brk.
The brk functions move the “break point” — the boundary between used and free memory.
Everything below the break is in use, everything above is available. kbrk and vbrk
move this boundary for the heap and vmalloc region respectively.
kbrk
The kbrk version needs to do a bit of searching as we need to walk the linked list
to find the end of the heap. It’s relatively easy to do but we need to be careful not
to move outside of valid memory space. We have this check to make sure addr is in bounds:
let addr_u32 = addr as u32;
if addr_u32 < HEAP_START || addr_u32 > HEAP_MAX {
return -1;
}
Notice that HEAP_MAX there? I found that when using kbrk to shrink the heap, that the original value gets lost and cannot be restored. Therefore when the heap is initialed, we save this value so we can always go back to it.
HEAP_MAX = start + size;
Then calculating and updating the size is simple:
HEAP_SIZE = addr as u32 - HEAP_START;
Now we need to walk from the start of the heap until we find the last block, then update its size to reflect the new boundary:
let mut head = HEAP_START as *mut BlockHeader;
while (*head).next != 0 as *mut BlockHeader {
head = (*head).next;
}
if (*head).free == 1 {
(*head).size = addr_u32 - (head as u32 + HEADER_SIZE as u32);
return 1;
}
return -1;
We check (*head).free == 1 before updating — if the last block is occupied, we
can’t safely resize it without corrupting live data. In that case we return -1
and let the caller handle the failure.
vbrk
The vbrk version is much simpler. It still needs the same bounds checks to make
sure we stay within the vmalloc region, but because we already track where the next
valid address is with VMALLOC_NEXT_VADDR, we just update it directly — no linked
list walk needed.
if addr.is_null() {
return 0;
}
let addr_u32 = addr as u32;
if addr_u32 < VMALLOC_START || addr_u32 > VMALLOC_END {
return -1;
}
VMALLOC_NEXT_VADDR = addr_u32;
return 1;
This is the payoff of the cursor design — because VMALLOC_NEXT_VADDR already
represents the break point of the vmalloc region, moving it is a one-liner.
Testing
Both functions are testable via the brk command in NPS:
brk -k // test kbrk
brk -v // test vbrk
Each test prints the current break point, moves it by one page (4096 bytes), verifies the change, then restores it:
Current size: 3522560
kbrk(curr - 4096)
New size: 3518464
Restored: 3522560
The restore is the important part — it proves that HEAP_MAX correctly preserves
the original upper bound even after shrinking. Without it, kbrk would reject the
restore address as out of range since HEAP_START + HEAP_SIZE would already be
pointing to the shrunk value.
Memdump
This is a handy little function mentioned in the bonus and of course we aren’t leaving anything on the table here.
Memdump allows us to dump the raw contents of any mapped memory region directly to the screen in classic hex dump format:
0x00100000: 02 B0 AD 1B 03 00 00 00 FB 4F 52 E4 00 00 00 00
0x00100010: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Those first bytes 02 B0 AD 1B are actually our Multiboot magic number
0x1BADB002 in little-endian format — we’re literally reading our own kernel
binary off the metal. Pretty cool.
NPS Argument Parsing
To make memdump work, NPS needed to support arguments. Rather than a full parser,
I went for a middle ground — splitn from Rust’s core library splits the
command string on spaces without any heap allocation:
#![allow(unused)]
fn main() {
let mut parts = cmd_str.splitn(3, ' ');
let cmd = parts.next().unwrap_or("");
let arg1 = parts.next();
let arg2 = parts.next();
}
splitn(3, ' ') gives us at most 3 parts — the command, and up to two arguments.
Clean and simple, no Vec needed.
klib — convert.rs
Making this function kicked off klib, our standard library replacement that will
grow with each KFS phase. The first two additions live in klib/convert.rs.
parse_hex converts a hex string like "0x100000" to a u32. It strips the
"0x" prefix, then processes each character using the same logic as atoi_base
in C — subtracting ASCII offsets to get the digit value, building up the result
in base 16:
#![allow(unused)]
fn main() {
pub fn parse_hex(s: &str) -> Option<u32> {
let s = s.trim_start_matches("0x");
let mut result: u32 = 0;
for c in s.chars() {
let digit = if c >= '0' && c <= '9' {
c as u32 - '0' as u32
} else if c >= 'a' && c <= 'f' {
c as u32 - 'a' as u32 + 10
} else if c >= 'A' && c <= 'F' {
c as u32 - 'A' as u32 + 10
} else {
return None; // invalid character
};
result = result * 16 + digit;
}
Some(result)
}
}
parse_u32 is the decimal equivalent — same pattern, base 10, no prefix:
#![allow(unused)]
fn main() {
pub fn parse_u32(s: &str) -> Option<u32> {
let mut result: u32 = 0;
for c in s.chars() {
if c >= '0' && c <= '9' {
result = result * 10 + (c as u32 - '0' as u32);
} else {
return None;
}
}
Some(result)
}
}
Both return Option<u32> — None on invalid input — so the caller can handle
bad arguments gracefully.
The Dump
The actual dump loop reads one byte at a time from the requested address, printing 16 bytes per row with a gap in the middle:
#![allow(unused)]
fn main() {
unsafe {
if hex < 0x100000 || hex + num > 0x400000 {
println!("Address out of mapped range (0x100000 - 0x400000)");
return;
}
let ptr = hex as *const u8;
for i in 0..num {
if i % 16 == 0 {
print!("\n0x{:08X}: ", hex + i);
}
if i % 16 == 8 {
print!(" ");
}
let byte = *ptr.add(i as usize);
print!("{:02X} ", byte);
}
println!();
}
}
The safety check is important — in bare metal there’s nothing stopping you from
reading unmapped memory and causing a page fault. We restrict memdump to the
identity mapped kernel region 0x100000 - 0x400000 where we know pages exist.
Dumping vmalloc memory would require checking the page tables first — a future
improvement.
Overview
Ok so we actually survived it to KFS_4, this is a huge achievement!
Now we get to work with interrupts properly! We already had some interrupt stuff handled from KFS_1:
- IDT created, filled and registered
- Hardware interrupts (keyboard)
- Exception handlers (divide by zero, Page Fault, Global Panic Faults etc)
What we needed to implement for KFS_4:
- Signal-callback system — a way to register custom handlers for interrupts at runtime
- Interface to schedule signals
InterruptFrame— capturing full CPU state on interrupt- Register cleaning before panic/halt
- Stack saving before panic
- Bonus: syscall base, get_line foundation, keyboard layouts (QWERTY/QWERTZ/AZERTY)
The biggest pieces were the signal-callback system and stack-saving. The signal
callback system is the foundation everything else builds on — without it, handlers are
hardcoded forever and processes can never register their own signal handlers. The
InterruptFrame is what makes proper panic output and future context switching possible.
What Ive Built
Mandatory:
- Signal table — 256 function pointer slots, swappable at runtime via
register() - Generic dispatcher — all interrupts flow through one assembly stub →
rust_interrupt_dispatcher - Signal scheduling queue — ring buffer,
schedule()andprocess_pending() InterruptFrame— captures full CPU state (registers + CPU-pushed eip/cs/eflags)capture_registers()— snapshots current CPU state for software panicsclear_registers()— XOR zeroes all general purpose registers before halt- Stack dump — prints 8 words around ESP on panic
- Proper
kernel_panic— message + register dump + stack dump + register clear + halt
Bonus:
- Keyboard layouts — QWERTY, QWERTZ, AZERTY with runtime switching via
sys kb get_linefoundation — infrastructure in place, blocked on KFS_5 scheduler- Syscall base —
int 0x80handler, dispatch table,sys_write/sys_read/sys_exitstubs
Signal Callback
A signal callback system does a couple really important things we are going to need.
First off, it means that any part of the kernel can register a function to be called when a specific interrupt fires. It also means that instead of hardcoding handlers in the IDT, I can have a table of function pointers that can be swapped out at runtime. Part of this is also the ability to handle signals like Ctrl+C, Ctrl+Z and so on.
So instead of:
#![allow(unused)]
fn main() {
// Hardcoded forever
IDT.entries[33].set_handler(keyboard_interrupt_handler);
}
I can have:
#![allow(unused)]
fn main() {
// Registerable at runtime
signal::register(SIGKEYBOARD, my_custom_handler);
}
This sets the foundation for processes later — each process can register its own signal handlers, just like in Linux with signal(SIGINT, my_handler).
The Signal Table
Since there are exactly 256 possible interrupts on x86, we don’t need a dynamic data structure. A fixed array of 256 function pointers does the job cleanly with no heap allocation:
#![allow(unused)]
fn main() {
type SignalHandler = fn(interrupt: u32, frame: &InterruptFrame);
static mut SIGNAL_TABLE: [Option<SignalHandler>; 256] = [None; 256];
}
Option<SignalHandler> means each slot is either None (no handler registered) or Some(fn) (handler registered). The registration API is simple:
#![allow(unused)]
fn main() {
pub fn register(interrupt: u8, handler: SignalHandler) {
unsafe {
SIGNAL_TABLE[interrupt as usize] = Some(handler);
}
}
}
During init, we pre-populate the table with default handlers so nothing is ever unhandled:
#![allow(unused)]
fn main() {
pub fn init() {
register(0, handlers::divide_by_zero);
register(6, handlers::invalid_opcode);
register(8, handlers::double_fault);
register(13, handlers::general_protection_fault);
register(14, handlers::page_fault);
register(33, keyboard::kbhandler);
for i in 0..=255u8 {
if SIGNAL_TABLE[i as usize].is_none() {
register(i, handlers::default_interrupt);
}
}
}
}
Generic Dispatcher
Instead of each IDT entry pointing to its own specific handler, they all point to assembly stubs that push their interrupt number and call a single rust_interrupt_dispatcher. The flow changes from:
IRQ fires → IDT → hardcoded handler
To:
IRQ fires → IDT → generic dispatcher → looks up SIGNAL_TABLE[n] → calls registered handler
The dispatcher in Rust:
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn rust_interrupt_dispatcher(interrupt_num: u32, frame: &InterruptFrame) {
unsafe {
if let Some(handler) = SIGNAL_TABLE[interrupt_num as usize] {
handler(interrupt_num, frame);
}
}
}
}
This is a significant improvement — adding a new interrupt handler is now just one register() call rather than touching the IDT directly.
Interrupt Frame
When an interrupt fires, the CPU is in the middle of executing something. To handle the interrupt properly — and especially to produce useful panic output or perform context switching later — we need to capture a snapshot of the CPU state at that exact moment.
This snapshot is the InterruptFrame.
What Gets Captured
When an interrupt fires, the CPU automatically pushes three things onto the stack:
eflags ← pushed first (highest address)
cs ← code segment
eip ← instruction pointer (where the fault occurred)
Then our assembly wrapper calls pusha, which pushes all general purpose registers in this order:
eax, ecx, edx, ebx, esp, ebp, esi, edi
So after pusha, the stack contains a complete snapshot of the CPU state. We represent this in Rust as:
#![allow(unused)]
fn main() {
#[repr(C)]
pub struct InterruptFrame {
// pushed by pusha (in reverse order, edi at top)
pub edi: u32,
pub esi: u32,
pub ebp: u32,
pub esp: u32,
pub ebx: u32,
pub edx: u32,
pub ecx: u32,
pub eax: u32,
// pushed by CPU automatically
pub eip: u32,
pub cs: u32,
pub eflags: u32,
}
}
#[repr(C)] is critical here — it tells Rust to lay out the struct fields in the exact order they’re declared, matching what’s on the stack. Without it, Rust might reorder fields and the values would be garbage.
Passing the Frame to Rust
After pusha, esp points directly to the saved register block — that’s exactly our InterruptFrame. We pass a pointer to it by pushing esp before calling the dispatcher:
divide_by_zero_handler:
cli
pusha
push esp ; pointer to InterruptFrame (2nd arg)
push 0 ; interrupt number (1st arg)
call rust_interrupt_dispatcher
add esp, 8 ; clean up both pushed arguments
popa
iretd
The add esp, 8 after the call cleans up the two arguments we pushed (4 bytes each). Then popa restores all the registers and iretd returns from the interrupt.
Printing the Frame
The InterruptFrame has a print() method that dumps all registers in a readable format:
#![allow(unused)]
fn main() {
pub fn print(&self) {
println!(" EAX: 0x{:08X} EBX: 0x{:08X}", self.eax, self.ebx);
println!(" ECX: 0x{:08X} EDX: 0x{:08X}", self.ecx, self.edx);
println!(" ESI: 0x{:08X} EDI: 0x{:08X}", self.esi, self.edi);
println!(" EBP: 0x{:08X} ESP: 0x{:08X}", self.ebp, self.esp);
println!(" EIP: 0x{:08X} EFL: 0x{:08X}", self.eip, self.eflags);
}
}
EIP is the most useful field in a crash — it tells you exactly which instruction was executing when things went wrong. Combined with a disassembly of the kernel binary, you can pinpoint the exact line of code that caused the fault.
Why This Matters
The InterruptFrame is the foundation for:
- Panic output — print register state when something goes wrong
- Stack saving — preserve the full CPU state before halting
- Context switching — save and restore process state (KFS_5+)
Without it, a kernel panic just says “something went wrong” with no way to know where or why.
Exception Handlers
Exception handlers catch CPU faults before they become triple faults. With the new signal callback system, handlers are no longer hardcoded extern "C" functions — they’re plain Rust functions registered in the signal table.
The New Handler Format
Each handler now matches the SignalHandler type:
#![allow(unused)]
fn main() {
type SignalHandler = fn(interrupt: u32, frame: &InterruptFrame);
}
This gives every handler access to both the interrupt number and the full CPU state. Here’s the complete set:
#![allow(unused)]
fn main() {
pub fn divide_by_zero(interrupt: u32, frame: &InterruptFrame) {
println!("EXCEPTION #0: DIVIDE BY ZERO");
frame.print();
loop {}
}
pub fn invalid_opcode(interrupt: u32, frame: &InterruptFrame) {
println!("EXCEPTION #6: INVALID OPCODE");
frame.print();
loop {}
}
pub fn double_fault(interrupt: u32, frame: &InterruptFrame) {
println!("EXCEPTION #8: DOUBLE FAULT (STACK OVERFLOW?)");
frame.print();
loop {}
}
pub fn general_protection_fault(interrupt: u32, frame: &InterruptFrame) {
println!("EXCEPTION #13: GENERAL PROTECTION FAULT");
frame.print();
loop {}
}
pub fn page_fault(interrupt: u32, frame: &InterruptFrame) {
println!("EXCEPTION #14: PAGE FAULT");
frame.print();
loop {}
}
pub fn default_interrupt(interrupt: u32, frame: &InterruptFrame) {
println!("UNHANDLED INTERRUPT: {}", interrupt);
frame.print();
loop {}
}
}
Notice there’s no #[no_mangle] or extern "C" anymore — these are just regular Rust functions. The assembly stubs handle the calling convention, and the dispatcher handles the lookup. Each handler calls frame.print() to dump the register state, then loops forever since these are all fatal exceptions.
What Changed From KFS_1
In KFS_1, each exception had its own dedicated assembly wrapper that called a specific #[no_mangle] Rust function. Adding a new handler meant touching the IDT, writing a new assembly stub, and writing a new Rust function.
Now adding a handler is just one line:
#![allow(unused)]
fn main() {
signal::register(42, my_handler);
}
The signal table does the rest.
Assembly Stubs
The assembly stubs are the bridge between the CPU’s interrupt mechanism and our Rust dispatcher. Every interrupt goes through one of these before hitting Rust code.
The Standard Pattern
All exception handlers follow the same pattern:
extern rust_interrupt_dispatcher
global divide_by_zero_handler
divide_by_zero_handler:
cli ; disable interrupts during handler
pusha ; save all general purpose registers
push esp ; pointer to saved registers (InterruptFrame)
push 0 ; interrupt number
call rust_interrupt_dispatcher
add esp, 8 ; clean up both pushed arguments
popa ; restore all registers
iretd ; return from interrupt (32-bit)
cli ensures we don’t get interrupted while handling an interrupt. pusha saves all registers onto the stack. At this point esp points directly to our InterruptFrame, so we push it as the second argument. Then we push the interrupt number as the first argument and call the dispatcher.
Error Code Exceptions
Some exceptions push an error code onto the stack before the CPU-saved registers. Double fault (8), General Protection Fault (13), and Page Fault (14) all do this. We need to remove the error code before pusha so the stack layout matches our InterruptFrame:
global double_fault_handler
double_fault_handler:
cli
add esp, 4 ; remove error code pushed by CPU
pusha
push esp
push 8
call rust_interrupt_dispatcher
add esp, 8
popa
iretd
The Keyboard Handler
The keyboard handler follows the same pattern but with sti before iretd to re-enable interrupts after handling:
global kb_pic_handler
extern rust_interrupt_dispatcher
kb_pic_handler:
cli
pusha
push esp ; InterruptFrame pointer
push 33 ; keyboard = IRQ1 = interrupt 33 after PIC remapping
call rust_interrupt_dispatcher
add esp, 8
popa
sti ; re-enable interrupts
iretd
The Default Handler
Catches everything not explicitly handled:
global default_interrupt_handler
default_interrupt_handler:
cli
pusha
push esp
push 0xFF ; 255 = unknown interrupt sentinel
call rust_interrupt_dispatcher
add esp, 8
popa
iretd
Why One Extern?
Previously each assembly file had multiple extern declarations pointing to individual Rust functions. Now there’s just one:
extern rust_interrupt_dispatcher
Everything goes through this single entry point. The signal table inside the dispatcher decides what actually runs.
Panic!
Now that our panic functions are growing, it’s time to create a dedicated panic.rs
instead of leaving everything scattered in lib.rs. We also need to properly display
registers on kernel panic, then clear them before halting — leaving no sensitive data
lingering in CPU registers.
Capturing the Registers
When the kernel panics via Rust’s panic!() macro, there’s no InterruptFrame being
passed in — unlike exception handlers which receive one from the assembly stubs. So we
need to capture the current register state ourselves.
The trick is pusha — we let the CPU push all registers onto the stack, then copy them
directly into an InterruptFrame struct using rep movsd (repeat move string doubleword),
which copies 8 x 4-byte values in one shot:
#![allow(unused)]
fn main() {
pub fn capture_registers() -> InterruptFrame {
let mut frame = InterruptFrame::new();
unsafe {
core::arch::asm!(
"pusha",
"mov esi, esp",
"mov edi, {0}",
"mov ecx, 8",
"rep movsd",
"popa",
in(reg) &mut frame as *mut InterruptFrame as u32,
out("edi") _,
out("ecx") _,
);
}
frame
}
}
pusha dumps all general purpose registers onto the stack. esp then points directly
to them, so we use it as the source (esi = esp) and copy 8 dwords into our frame.
popa restores everything afterward so execution continues normally.
Note: EIP, CS and EFLAGS are left as zero — EIP isn’t directly readable as a
general purpose register, and the others aren’t captured by pusha. For a software
panic this is acceptable; hardware exceptions get the real values via the CPU’s automatic
stack push.
We tried capturing registers one at a time with individual asm! blocks first, but
i386 simply doesn’t have enough free registers to hold all the output values simultaneously
— the compiler uses the same registers as scratch and corrupts the values. The pusha
approach sidesteps this entirely.
Cleaning the Registers
After displaying the register state, we clear all general purpose registers using XOR
operations. XORing a register with itself is the fastest way to zero it and leaves no
sensitive data behind:
#![allow(unused)]
fn main() {
pub fn clear_registers() {
unsafe {
core::arch::asm!(
"xor eax, eax",
"xor ebx, ebx",
"xor ecx, ecx",
"xor edx, edx",
"xor esi, esi",
"xor edi, edi",
"xor ebp, ebp",
options(nomem, nostack)
);
}
}
}
Note: we don’t zero ESP — that’s the stack pointer. Zeroing it would instantly corrupt
the stack and cause a triple fault before we even reach the loop {}.
Updating kernel_panic
With capture and clear available, kernel_panic becomes a proper crash handler:
#![allow(unused)]
fn main() {
pub fn kernel_panic(msg: &str, frame: &InterruptFrame) -> ! {
writer().printc("\n\n!!! KERNEL PANIC !!!", Color::White, Color::Red);
println!("\nError: {}", msg);
println!("Register state at panic:");
frame.print();
clear_registers();
loop {}
}
}
The #[panic_handler] hooks into this by calling capture_registers() first:
#![allow(unused)]
fn main() {
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
let frame = capture_registers();
kernel_panic(info.message().as_str().unwrap_or("unknown panic"), &frame);
}
}
This means every Rust panic!() now automatically captures and displays the register
state — no manual frame passing needed at the call site.
kernel_warn
For non-fatal errors, kernel_warn just displays a warning and returns — no register
dump, no halt:
#![allow(unused)]
fn main() {
pub fn kernel_warn(msg: &str) {
writer().printc("\n\n!!! KERNEL WARNING !!!", Color::Black, Color::Yellow);
println!("\nError: {}", msg);
}
}
Testing
Added sys panic to NPS to test both paths:
> sys panic -w
!!! KERNEL WARN !!!
Error: Test
> sys panic -p
!!! KERNEL PANIC !!!
Error: Test
Register state at panic:
EAX: 0x0025AD7C EBX: 0x0024AA1C
ECX: 0x0025AD7C EDX: 0x00000001
ESI: 0x00000000 EDI: 0x00001000
EBP: 0x00000000 ESP: 0x0025AD64
EIP: 0x00000000 EFL: 0x00000000
The warn returns to the shell. The panic halts the kernel.
Signal Scheduling
Until now, signals have been firing immediately when received. But we actually want a proper queue — a way to schedule signals for later delivery at a controlled point. This is important because some signals shouldn’t be processed in the middle of another operation, and a queue lets us decide when to process them.
The Queue
On a single-threaded kernel without preemption, a fixed-size ring buffer of 32 entries is more than enough. A ring buffer is the classic choice for a fixed-size queue — two cursors chase each other around a circular array:
#![allow(unused)]
fn main() {
const SIGNAL_QUEUE_SIZE: usize = 32;
static mut SIGNAL_QUEUE: [u8; SIGNAL_QUEUE_SIZE] = [0; SIGNAL_QUEUE_SIZE];
static mut QUEUE_HEAD: usize = 0; // next read position
static mut QUEUE_TAIL: usize = 0; // next write position
}
When HEAD == TAIL, the queue is empty. When (TAIL + 1) % SIZE == HEAD, it’s full.
The modulo arithmetic wraps the cursors around automatically.
schedule()
Adds a signal to the queue. Checks if there’s room, writes the interrupt number to
QUEUE_TAIL, then advances the tail:
#![allow(unused)]
fn main() {
pub fn schedule(interrupt: u8) {
unsafe {
if (QUEUE_TAIL + 1) % SIGNAL_QUEUE_SIZE != QUEUE_HEAD {
SIGNAL_QUEUE[QUEUE_TAIL] = interrupt;
QUEUE_TAIL = (QUEUE_TAIL + 1) % SIGNAL_QUEUE_SIZE;
}
}
}
}
If the queue is full, the signal is silently dropped. In a more complete kernel you’d want to handle this more gracefully, but for now it’s fine.
process_pending()
Drains one signal from the queue and calls its registered handler. Checks if the queue
has anything in it, reads from QUEUE_HEAD, advances the head, then looks up and calls
the handler with a captured frame:
#![allow(unused)]
fn main() {
pub fn process_pending() {
unsafe {
if QUEUE_HEAD != QUEUE_TAIL {
let interrupt = SIGNAL_QUEUE[QUEUE_HEAD];
QUEUE_HEAD = (QUEUE_HEAD + 1) % SIGNAL_QUEUE_SIZE;
let frame = capture_registers();
let handler = SIGNAL_TABLE[interrupt as usize];
if let Some(h) = handler {
h(interrupt as u32, &frame);
}
}
}
}
}
Since there’s no real interrupt frame at this point — we’re processing from a queue,
not from an actual hardware interrupt — we call capture_registers() to get the
current CPU state as a best approximation.
Testing
Added sys signal to NPS to prove the queue works end to end:
> sys signal
=== Testing Signal Handler ===
Scheduling interrupt 0 (divide by zero)...
Processing pending signals...
EXCEPTION #0 DIVIDE BY ZERO
EAX: 0x0025AD6C EBX: 0x0024AA1C
ECX: 0x0025AD6C EDX: 0x00000024
ESI: 0x00000000 EDI: 0x00001000
EBP: 0x00000000 ESP: 0x0025AD54
EIP: 0x00000000 EFL: 0x00000000
The signal was queued, then processed — the divide by zero handler fired with a full register dump. Queue in, queue out.
Keyboard Layouts
With all the different languages out there, there are a massive number of keyboard layouts — QWERTY for English, QWERTZ for German/Central European, AZERTY for French, Dvorak, Colemak and more. We need to account for at least the common ones by adding multiple scancode tables and letting the user switch between them at runtime.
Layout Enum
Rather than using raw numbers, an enum makes the code readable and safe. Copy and
Clone are needed so the layout can be stored in a static and matched on:
#![allow(unused)]
fn main() {
#[allow(dead_code)]
#[repr(u8)]
#[derive(Copy, Clone)]
pub enum Layout {
Qwerty = 0,
Qwertz = 1,
Azerty = 2,
}
}
Calling Layout::Qwerty is a lot cleaner than passing around magic numbers.
Scancode Tables
Previously we just had one table called SCANCODE_TO_ASCII loosely based on QWERTZ.
Now we have three named tables, one per layout, and a static to track the current one:
#![allow(unused)]
fn main() {
static mut CURRENT_LAYOUT: Layout = Layout::Qwertz; // default
}
The key differences between the tables:
QWERTY vs QWERTZ — almost identical except y and z are swapped. On a QWERTZ
keyboard, the key where QWERTY has y sends scancode 0x15 which maps to z, and
vice versa at 0x2C. Everything else is the same.
AZERTY — more different. a/q are swapped, z/w are swapped, and m moves
to where ; was (0x27). The number row on a real AZERTY keyboard uses special French
characters for unshifted input, but since those are non-ASCII we leave those slots as 0.
#![allow(unused)]
fn main() {
static QWERTZ_TABLE: [u8; 128] = [
0, 27, b'1', b'2', b'3', b'4', b'5', b'6',
b'7', b'8', b'9', b'0', b'-', b'=', 8, b'\t',
b'q', b'w', b'e', b'r', b't', b'z', b'u', b'i', // z at 0x15
b'o', b'p', b'[', b']', b'\n', 0, b'a', b's',
b'd', b'f', b'g', b'h', b'j', b'k', b'l', b';',
b'\'', b'`', 0, b'\\', b'y', b'x', b'c', b'v', // y at 0x2C
b'b', b'n', b'm', b',', b'.', b'/', 0, b'*',
0, b' ', 0, 0, 0, 0, 0, 0,
// ... rest zeroed
];
}
QWERTY and AZERTY follow the same structure with their respective differences applied.
Keyboard Handler
The handler now matches on CURRENT_LAYOUT to pick the right table for each scancode:
#![allow(unused)]
fn main() {
if scancode < 128 && scancode != 0 {
let ascii = match CURRENT_LAYOUT {
Layout::Qwerty => QWERTY_TABLE[scancode as usize],
Layout::Qwertz => QWERTZ_TABLE[scancode as usize],
Layout::Azerty => AZERTY_TABLE[scancode as usize],
};
if ascii != 0 {
crate::shell::handle_input(ascii);
}
}
}
set_layout()
Switching layouts is a one-liner plus a confirmation message:
#![allow(unused)]
fn main() {
pub fn set_layout(layout: Layout) {
unsafe {
CURRENT_LAYOUT = layout;
let name = match layout {
Layout::Qwerty => "QWERTY",
Layout::Qwertz => "QWERTZ",
Layout::Azerty => "AZERTY",
};
println!("Layout set to: {}", name);
}
}
}
Wired up in NPS via sys kb:
> sys kb qwerty
Layout is now set to: QWERTY
A Note on QEMU Scan Codes
During testing, pressing - on a QWERTZ layout was producing /. This is because
QEMU emulates a US English keyboard at the hardware level regardless of software layout.
The scan codes sent are always US QWERTY scan codes — so key positions that differ
between physical keyboards won’t map correctly in emulation.
The y/z swap works correctly between QWERTY and QWERTZ because those keys are in
the same physical position on both layouts (just labelled differently). Keys that are
in genuinely different positions on the physical keyboard (like - on QWERTZ vs QWERTY)
will still produce the US scan code in QEMU.
This is a hardware emulation limitation, not a bug in the layout system. On real hardware with a matching physical keyboard, the layouts would work correctly.
get_line
We all know readline from C — it collects text from STDIN and saves it to a buffer,
returning when the user presses Enter. We’re implementing the same thing for our kernel,
but there’s a fundamental caveat that’s worth understanding.
How it Works
The design is straightforward. A static flag GET_LINE_ACTIVE tells the keyboard handler
to route input to get_line instead of the shell. A shared LINE_CHAR variable acts as
a one-character mailbox between the interrupt handler and get_line. The main loop uses
hlt to sleep between characters:
#![allow(unused)]
fn main() {
pub fn get_line(buf: &mut [u8]) -> usize {
// Set active flag — keyboard handler now routes to LINE_CHAR
GET_LINE_ACTIVE = true;
let mut pos = 0;
loop {
hlt(); // sleep until next interrupt
if let Some(c) = take LINE_CHAR {
match c {
b'\n' => { GET_LINE_ACTIVE = false; return pos; }
8 => { if pos > 0 { pos -= 1; backspace(); } }
_ => { if pos < buf.len() { buf[pos] = c; pos += 1; echo(c); } }
}
}
}
}
}
The keyboard handler checks the flag and routes accordingly:
#![allow(unused)]
fn main() {
if GET_LINE_ACTIVE {
LINE_CHAR = Some(ascii); // goes to get_line
} else {
shell::handle_input(ascii); // goes to NPS
}
}
The Volatile Problem
Because GET_LINE_ACTIVE and LINE_CHAR are shared between the interrupt handler and
the main loop, the compiler might cache their values in registers and never re-read from
memory. This causes kbhandler to always see GET_LINE_ACTIVE = false even after
get_line sets it to true.
The fix is volatile reads and writes — they force the compiler to always go to memory:
#![allow(unused)]
fn main() {
// Writing
core::ptr::write_volatile(core::ptr::addr_of_mut!(GET_LINE_ACTIVE), true);
// Reading
let active = core::ptr::read_volatile(core::ptr::addr_of!(GET_LINE_ACTIVE));
}
Same treatment for LINE_CHAR. Without this, the whole thing silently breaks.
The Deadlock Problem
Here’s the catch. Our NPS shell calls get_line from inside cmd_getline, which is
called from handle_char, which is called from kbhandler — which runs inside an
interrupt handler.
The flow looks like this:
keyboard interrupt fires
→ cli (interrupts disabled)
→ kb_pic_handler (assembly)
→ kbhandler (Rust)
→ shell::handle_input
→ cmd_getline
→ get_line
→ hlt ← waiting for a keyboard interrupt that can NEVER fire
because cli disabled interrupts at the top!
This causes the deadlock. The kernel freezes waiting for an interrupt that will never arrive.
The KFS_5 Fix
The proper solution is a scheduler. Instead of hlt, get_line should put the current
process to sleep and yield CPU time to other processes. When the keyboard interrupt
fires and a complete line is ready, the scheduler wakes the sleeping process back up.
The infrastructure is already in place:
GET_LINE_ACTIVEflag — tells the keyboard handler which mode we’re inLINE_CHARvolatile shared state — the mailbox between handler and consumertake_char()/is_getline_active()API — clean interface for checking state
All that’s missing is the sleep/wake mechanism — a process_sleep() call instead of
hlt, and a process_wake() call from the keyboard handler when Enter is pressed.
That’s KFS_5’s job.
Syscall Base
Any OS has to have syscalls — there’s no two ways about it. All of the basic operations
we need are syscalls: write, read, open, close. They’re the interface between
user programs and the kernel, and without them no program can do anything useful.
On x86 systems, syscalls traditionally use interrupt 0x80 (Linux style). The caller
puts a syscall number in eax, arguments in ebx, ecx and edx, then fires
int 0x80. The kernel catches it via IDT entry 128, reads the registers, and dispatches
to the right handler. We can’t implement full syscalls yet — that needs processes and
KFS_5 — but we can build the base infrastructure now so it’s ready to wire up later.
The three stubs we’ll want:
sys_write(0) — write to screensys_read(1) — read from keyboardsys_exit(2) — terminate process
The Syscall Table
The syscall system is structurally very similar to the signal system — a fixed array of
function pointers indexed by number, a register function, and a dispatcher. The key
difference is what the index means: signal table indices are interrupt numbers, syscall
table indices are syscall numbers read from eax.
#![allow(unused)]
fn main() {
type SyscallHandler = fn(frame: &InterruptFrame);
static mut SYSCALL_TABLE: [Option<SyscallHandler>; 3] = [None; 3];
}
Initialisation registers the stubs in the syscall table, then registers the syscall
dispatcher itself with the signal system at interrupt 0x80:
#![allow(unused)]
fn main() {
pub fn init() {
register(0, handlers::sys_write);
register(1, handlers::sys_read);
register(2, handlers::sys_exit);
signals::register(0x80, syscall_handler); // hook into IDT via signal system
}
}
Notice two register functions — signals::register puts the dispatcher into the
SIGNAL_TABLE at slot 128, and our own register puts individual syscall handlers
into the SYSCALL_TABLE:
#![allow(unused)]
fn main() {
pub fn register(syscall_num: usize, handler: SyscallHandler) {
unsafe {
SYSCALL_TABLE[syscall_num] = Some(handler);
}
}
}
The Syscall Dispatcher
When int 0x80 fires, the signal system calls syscall_handler with the full
InterruptFrame. The dispatcher reads eax to get the syscall number and looks up
the right handler:
#![allow(unused)]
fn main() {
pub fn syscall_handler(_interrupt: u32, frame: &InterruptFrame) {
let syscall_num = frame.eax as usize;
if syscall_num >= 3 {
println!("Unknown syscall: {}", syscall_num);
return;
}
unsafe {
if let Some(handler) = SYSCALL_TABLE[syscall_num] {
handler(frame);
}
}
}
}
The bounds check is important — without it, a caller passing an invalid eax would
cause an out-of-bounds panic. Unknown syscalls just print an error and return.
The Stub Handlers
For now the handlers are stubs. sys_write is the most fleshed out — it reads its
arguments from ebx, ecx, edx and prints them:
#![allow(unused)]
fn main() {
pub fn sys_write(frame: &InterruptFrame) {
println!("{} {} {}", frame.ebx, frame.ecx, frame.edx);
// TODO: return value via eax — needs mutable frame (KFS_5)
}
pub fn sys_read(frame: &InterruptFrame) {
// TODO: KFS_5 — read from keyboard buffer into user buffer
}
pub fn sys_exit(frame: &InterruptFrame) {
// TODO: KFS_5 — terminate the calling process
}
}
The commented out return value is worth noting. In a real syscall, the return value goes
back in eax. But our InterruptFrame is immutable — we’d need to either make it
mutable or use a different mechanism to write back to the caller’s registers. That’s a
KFS_5 problem.
Testing
Wired up a sys call command in NPS that constructs a test frame and fires syscall 0:
sys call
Testing syscall 0 (sys_write)...
1 2 3
ebx=1, ecx=2, edx=3 came through correctly. The dispatch table is working.
Overview
Next step in our kernel — execution!
KFS_5 is all about processes. A process is the actual execution of the instructions
in a program. This means we’re also going to be dealing with threads, scheduling, and
multitasking — right now we only have one thread of execution with no concurrency at all.
In computing, a process is an instance of a computer program being executed. It contains the program code and its current activity. Several processes may be associated with the same program — opening multiple instances of the same application means multiple processes running simultaneously.
Multitasking is how multiple processes share a single CPU. The CPU can only execute one task at a time, but by switching between tasks rapidly — on I/O operations, voluntary yields, or hardware interrupts — it creates the illusion of parallelism.
Processes have relationships and metadata we need to track:
- PID — unique integer to identify the process
- Parent/Children — process family tree
- Owner — which user owns this process
- Dedicated memory — each process gets its own stack (KFS_3 paging finally earning its keep)
- Signals — pending signal queue plus per-process handler table
- Sockets — inter-process communication
This is gonna be a big one. And it was.
Connection to KFS_4
A lot of what we built in KFS_4 directly feeds into this — it wasn’t busywork:
InterruptFramebecomes the process context save/restore structure for context switching. Every process has a heap-allocated frame that the scheduler saves into on every timer tick.- The signal system gains per-process handler tables — separate from the global
SIGNAL_TABLE, individual processes can now register their own handlers for specific signals. - The
get_line()deadlock from KFS_4 is now architecturally resolved. It deadlocked because it was called from interrupt context — now the shell runs as its own execution context andget_line()works correctly. - The syscall stubs (
sys_write,sys_read,sys_exit) get real process-aware implementations when KFS_7 arrives. The groundwork is in place.
What We Built
The Process Structure
A Process struct that tracks everything the kernel needs to know — pid, status,
parent/child/sibling tree, stack pointer and size, heap region, signal queue,
per-process signal handlers, owner id, and the all-important state pointer to
the saved InterruptFrame. The child/sibling pointers form a proper Unix process
family tree without any extra heap allocation per relationship.
Key Functions
All the mandatory syscall helpers: fork (full process copy including stack contents
via memcpy), exit (frees stack, marks Zombie, sends SIGCHLD to parent), kill
(queues signal or calls exit for SIGKILL), getuid (returns owner id), signal
(registers per-process handler), and wait (spins until a child becomes Zombie).
exec_fn loads a function as a process — creates the process, allocates a stack via
vmalloc_user, builds a dummy_frame pointing to the function, and registers it
in the process table. Demonstrated with sys exec and sys proc in the shell.
Sockets
Inter-process communication via a socket table. socket_create, socket_write,
socket_read, socket_close — mirrors Unix socket semantics so the KFS_7 syscall
layer can plug straight in. Buffers backed by vmalloc, socket metadata by kmalloc.
The Scheduler
Timer-driven round-robin scheduler. On every tick: save current process state via
rust_timer_handler, pick the next valid process via get_next_frame, switch ESP
to its saved frame, popa and iretd into it. Process 0 (kernel idle) is excluded
from scheduling and runs directly in the main loop.
The scheduler infrastructure is complete. It is currently disabled because of a
fundamental ring 0 limitation — iretd at ring 0 cannot switch stacks, so processes
don’t run on their allocated stacks and corruption follows. The proper fix is ring 3
processes, which is KFS_7’s job.
The TSS
The Task State Segment is implemented and loaded. esp0 points to the kernel stack
top, ss0 = 0x10. The GDT entry at index 5 is properly filled and ltr 0x28 is
called at boot. When KFS_7 flips the switch to ring 3, the TSS is already doing its
job — telling the CPU which kernel stack to switch to on every ring 3 → ring 0
transition.
Ring 3 Groundwork
While true ring 3 execution is KFS_7, the foundations are all in place:
user GDT segments have been there since KFS_2, vmalloc_user maps pages with
PAGE_USER set, InterruptFrame has esp_user and ss_user fields for the
extended ring 3 frame layout, and dummy_frame knows how to configure a ring 3
entry. Nothing structural needs to change in KFS_7 — it’s a configuration flip.
Mandatory
Data
A full process structure containing:
- PID
- Status (Running, Zombie, Thread)
- Pointers to parent and children
- Stack and heap with size tracking
- Current signal queue
- Per-process signal handler table
- Owner ID
- Saved CPU state (
*mut InterruptFrame)
And functions:
create_process— allocate and register a process- Queue a signal to a process, delivered on the next CPU tick
- Socket communication helpers between processes
- Memory functions (
vmalloc_userfor process stacks) fork— copy an entire process
Plus syscall helpers: wait, exit, getuid, signal, kill
Memory
In KFS_3 we implemented paging — now we actually use it. Each process gets a
vmalloc_user’d stack on creation, mapped with PAGE_USER flags so it’s accessible
from ring 3 when that arrives. The stack and stack_size fields on the process
struct track the allocation so fork can copy it correctly.
Execution
The kernel handles multitasking via a round-robin timer-based scheduler. The
infrastructure is complete — timer saves state, get_next_frame picks the next
process, iretd jumps into it.
Testing
exec_fn loads a function as a process and registers it in the process table.
Demonstrated in the shell:
> sys exec
Creating test process...
test_process addr: 0x00105900
Process created!
> sys proc
=== Process Table ===
PID 0: state=0x00261060 stack=0x00000000
PID 1: state=0x00261110 stack=0x00900000
Process 0 is the kernel idle process. Process 1 is the test process with its own
vmalloc’d stack at 0x00900000. Shell remains fully responsive throughout.
Bonus
Once the mandatory is done, the fun stuff:
mmap— let a process query and manage its virtual memory- Link IDT and processes to follow future syscall signals
- Create
BSSanddatasectors in the process structure
These are deferred — the mandatory is solid and the bonus items depend on having ring 3 processes working first anyway.
Processes
From the Bible:
A process is an instance of a program being executed.
Essentially it’s a record of what the program is, what it’s doing, and what it’s related to. The kernel needs to track all of this so it can switch between processes, send them signals, and manage their memory.
This is the big one. Everything we’ve built so far — the IDT, the signal system,
the InterruptFrame, the memory allocators — was all leading here. Processes are
the first time the kernel has multiple concurrent execution contexts, and getting
them right took more debugging than everything else combined.
The Struct
A process is a struct that captures everything the kernel needs to know about a running program. Here’s what we’re tracking:
#![allow(unused)]
fn main() {
pub struct Process {
pid: u32, // Process Identification Number
status: ProcessStatus, // Running | Zombie | Thread
parent: *mut Process, // Parent process
first_child: *mut Process, // First child in linked list
next_sibling: *mut Process, // Next sibling in linked list
stack: u32, // Base address of allocated stack
stack_size: u32, // Size of the stack allocation
heap: *mut memory::VMallocRegion, // Heap region (KFS_7)
signal_queue: [u8; signals::SIGNAL_QUEUE_SIZE], // Pending signals
signal_handlers: [Option<ProcessSignalHandler>; 32], // Per-process signal handlers
owner_id: u32, // Owner/User ID
pub state: *mut frame::InterruptFrame, // Saved CPU register state
}
}
The state field is the key one for scheduling — when the kernel switches away from
a process, it saves the full CPU state here. When it switches back, it restores from
here. This is exactly the InterruptFrame we built in KFS_4, now doing double duty
as the context save/restore structure.
stack_size had to be added alongside stack because fork needs to copy the
stack contents — and you can’t memcpy without knowing the size. Seemed obvious in
hindsight.
The child/sibling pointers form a tree rather than a flat list:
Process A (pid=0)
└── Process B (pid=1) → Process C (pid=2) → null
└── Process D (pid=3)
Each process points to its first_child, and children point to their next_sibling.
This represents the full Unix process family tree without any heap allocation per
relationship — just pointer wiring.
Process Status
#![allow(unused)]
fn main() {
pub enum ProcessStatus {
Running, // actively executing or ready to run
Zombie, // finished but parent hasn't called wait() yet
Thread, // a thread of another process (future use)
}
}
A Zombie process has finished executing but its entry stays in the table until the
parent calls wait() to collect the exit status. This is standard Unix behaviour —
without it the parent has no way to know the child finished.
The Process Table
#![allow(unused)]
fn main() {
static mut PROCESS_TABLE: [Option<*mut Process>; 8] = [None; 8];
pub static mut CURRENT_PROCESS: u32 = 0;
static mut PID_COUNT: u32 = 0;
}
Same pattern as SIGNAL_TABLE — a fixed array indexed by slot, with a counter that
hands out unique IDs. We store pointers rather than structs directly because Process
contains raw pointers which can’t be trivially moved in memory.
CURRENT_PROCESS tracks which process is currently running. The scheduler updates
this on every context switch.
Initialisation
During init(), process 0 is created as the kernel idle process. It gets its own
heap-allocated InterruptFrame so the scheduler has somewhere to save state:
#![allow(unused)]
fn main() {
pub fn init() {
let kernel_proc = create_process(); // pid 0
unsafe {
let frame_ptr = kmalloc(core::mem::size_of::<InterruptFrame>());
if let Some(f) = frame_ptr {
(*kernel_proc).state = f as *mut InterruptFrame;
// leave contents zeroed — filled on first timer tick
}
}
}
}
This was a critical fix. Process 0 originally had a null state pointer, which caused the scheduler to jump to address 0 when trying to switch back to it. Every process in the table needs a valid state allocation.
Per-Process Signal Handlers
Each process has its own table of 32 signal handlers — separate from the global
SIGNAL_TABLE in the interrupts module:
#![allow(unused)]
fn main() {
type ProcessSignalHandler = fn(signal: u32);
// On the Process struct:
signal_handlers: [Option<ProcessSignalHandler>; 32],
}
The global SIGNAL_TABLE handles kernel-wide interrupt routing. Per-process handlers
let individual processes respond to signals in their own way — which is how Unix
signal() is supposed to work.
The Scheduler
The scheduler is round-robin — on every timer tick, save the current process state, advance to the next valid process, restore its state.
rust_timer_handler
Called from the timer assembly stub on every tick. Saves the current process’s CPU state by copying the interrupt frame into the process’s heap-allocated state buffer:
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn rust_timer_handler(_interrupt: u32, frame: &InterruptFrame) {
unsafe {
if let Some(p) = PROCESS_TABLE[CURRENT_PROCESS as usize] {
if !(*p).state.is_null() {
core::ptr::write((*p).state, *frame);
}
}
crate::interrupts::pic::send_eoi(0);
}
}
}
The core::ptr::write copies the frame contents — not just a pointer. The original
frame lives on the interrupt stack which is gone after iretd. Saving a pointer
would be a dangling pointer bug. We hit this one.
get_next_frame
Advances CURRENT_PROCESS and returns the next process’s saved frame pointer.
Process 0 is always skipped — it’s the idle process and runs directly in the kernel
main loop, never via iretd:
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn get_next_frame() -> u32 {
unsafe {
CURRENT_PROCESS = (CURRENT_PROCESS + 1) % 8;
for _ in 0..8 {
if CURRENT_PROCESS == 0 {
CURRENT_PROCESS = (CURRENT_PROCESS + 1) % 8;
continue;
}
if let Some(p) = PROCESS_TABLE[CURRENT_PROCESS as usize] {
if !(*p).state.is_null() {
return (*p).state as u32;
}
}
CURRENT_PROCESS = (CURRENT_PROCESS + 1) % 8;
}
0 // no valid process found, timer stub skips the switch
}
}
}
Returning 0 is the signal to the assembly stub to skip the context switch entirely.
If there’s only one user process, the timer fires, saves its state, get_next_frame
returns its own frame pointer again, and iretd resumes it.
The Assembly Context Switch
timer_handler:
cli
pusha
push esp ; pointer to saved InterruptFrame
push 32 ; interrupt number
call rust_timer_handler ; save current state + send EOI
add esp, 8
call get_next_frame ; returns next frame pointer in eax
cmp eax, 0
je .no_switch ; 0 = no valid process, skip switch
mov esp, eax ; switch ESP to next process's frame
.no_switch:
popa ; restore next process's registers
sti
iretd ; jump into the process
mov esp, eax is the magic — pointing ESP at the next process’s InterruptFrame
means popa restores that process’s registers, and iretd pops eip, cs, and
eflags from the same frame, jumping directly into the next process. From the CPU’s
perspective it’s just returning from an interrupt.
Bugs We Hit (And There Were Many)
cli inside a process kills the scheduler. test_process originally did
cli; hlt. This disabled the timer interrupt, so the scheduler could never fire
again and the keyboard handler never ran. Fixed by using a plain hlt loop — the
CPU sleeps but still wakes on interrupts.
Process 0 needs a frame allocation. Without it, get_next_frame returned null
for process 0’s state, the timer asm did mov esp, 0, popa read garbage, and
iretd jumped to address 0. Triple fault. Fixed in init().
Running exec_fn from interrupt context crashes the allocator. The keyboard
handler was calling shell::handle_input() directly, which called execute(), which
called exec_fn() — trying to vmalloc and kmalloc from inside an interrupt
handler. Added an input queue to the shell and a flush_input() function called from
the main loop instead.
Ring 0 iretd can’t switch stacks. On i386, iretd only restores ESP when
there’s a privilege level change (ring 3 → ring 0 transition). At ring 0, ESP stays
wherever it was after the switch. This means user processes run on whatever stack
was active, not their own allocated stack — causing corruption when the timer fires
into that stack. The proper fix is ring 3 processes, which requires a properly loaded
TSS.
The Ring 3 Foundation (For KFS_7)
We’ve done the groundwork for proper ring 3 process isolation even though it’s not active yet:
TSS implemented. The Task State Segment tells the CPU which kernel stack to
switch to when a ring 3 → ring 0 transition happens (interrupt, syscall). esp0
points to the top of the kernel boot stack, ss0 = 0x10 (kernel data segment).
The GDT entry at index 5 is now properly filled and ltr 0x28 loads it.
User page flags. vmalloc_user() allocates with PAGE_USER set, making pages
accessible from ring 3. Process stacks use vmalloc_user so they’ll be accessible
once we flip the privilege switch.
InterruptFrame extended. Added esp_user and ss_user fields for the
additional values the CPU pushes on a ring 3 → ring 0 interrupt. dummy_frame
already knows how to fill these for ring 3 (cs = 0x1B, ss_user = 0x23).
User GDT segments. These have been in place since KFS_2 — kernel code (0x08),
kernel data (0x10), user code (0x18), user data (0x20), TSS (0x28).
Nothing needs to change in the GDT for KFS_7.
When KFS_7 arrives, the switch to ring 3 is: set dummy_frame to use cs = 0x1B
and ss_user = 0x23, add the segment register reload in timer_handler, map process
memory with USER_PAGE_FLAGS, and make sure kernel statics aren’t accessed directly
from user processes (that’s what syscalls are for). The infrastructure is all there.
Testing
> sys exec
Creating test process...
test_process addr: 0x00105900
Process created!
> sys proc
=== Process Table ===
PID 0: state=0x00261060 stack=0x00000000
PID 1: state=0x00261110 stack=0x00900000
> sys exec
Creating test process...
Process created!
> sys proc
=== Process Table ===
PID 0: state=0x00261060 stack=0x00000000
PID 1: state=0x00261110 stack=0x00900000
PID 2: state=0x00261160 stack=0x00901000
Process 0 is the kernel idle process — no stack, frame allocation from init().
Process 1 is the test process with its own vmalloc’d stack at 0x00900000. Shell
remains fully responsive throughout. That’s the process infrastructure working.
Key Functions
In the kernel, we are going to need a few key functions. Things like fork, exit,
kill and wait are all extremely necessary. These are the helpers that prepare the
ground for real syscalls in KFS_7 — they do the actual work, the syscall layer will
just be the gate that lets user processes call them safely.
create_process()
Creates and allocates space for a Process on the heap and registers it in the
process table:
#![allow(unused)]
fn main() {
pub fn create_process() -> *mut Process {
let mut process: Process = Process::new();
unsafe {
process.pid = PID_COUNT;
PID_COUNT += 1;
for i in 0..8 {
match PROCESS_TABLE[i] {
Some(_) => {}
None => {
let ptr = kmalloc(core::mem::size_of::<Process>());
match ptr {
Some(p) => {
let proc_ptr = p as *mut Process;
core::ptr::write(proc_ptr, process);
PROCESS_TABLE[i] = Some(proc_ptr);
return proc_ptr;
}
None => {}
}
}
}
}
return core::ptr::null_mut();
}
}
}
core::ptr::write copies the process data into the kmalloc’d memory without going
through Rust’s normal move semantics — necessary because Process contains raw
pointers that Rust can’t reason about safely.
exec_fn
exec_fn loads a function as a process. This is the mandatory test function from
the subject — it’s how we prove the process system works before we have binary
execution in KFS_7:
#![allow(unused)]
fn main() {
pub fn exec_fn(_addr: u32, function: u32, size: u32) {
unsafe {
let proc = create_process();
if proc.is_null() {
return;
}
(*proc).stack = vmalloc_user(size as usize) as u32;
(*proc).stack_size = size;
let frame_ptr = kmalloc(core::mem::size_of::<InterruptFrame>());
if let Some(f) = frame_ptr {
let frame = f as *mut InterruptFrame;
core::ptr::write(
frame,
InterruptFrame::dummy_frame(function, (*proc).stack as *mut u8, size),
);
(*proc).state = frame;
}
}
}
}
vmalloc_user gives the process a stack mapped with PAGE_USER flags — ready for
ring 3 in KFS_7 without any changes. The dummy_frame builds a fake CPU state that
makes the scheduler think the process was interrupted mid-execution:
#![allow(unused)]
fn main() {
pub fn dummy_frame(function: u32, stack_top: *mut u8, size: u32) -> InterruptFrame {
let mut frame = InterruptFrame::new();
frame.eip = function; // where to start executing
frame.esp = stack_top as u32 + size - 4; // top of stack (stacks grow down!)
frame.eflags = 0x00000202; // IF set + reserved bit 1
frame.cs = 0x08; // kernel code segment for now
frame
}
}
Two things that must be right: esp points to the top of the allocation (stacks
grow downward on x86), and eflags has the interrupt flag set — 0x200. Get that
wrong and the process runs with interrupts permanently disabled and the scheduler
can never fire again.
vmalloc_user
Process stacks need to be mapped with PAGE_USER so ring 3 code can access them.
Rather than changing vmalloc itself, we split it into three:
#![allow(unused)]
fn main() {
pub unsafe fn vmalloc(size: usize) -> *mut u8 {
vmalloc_with_flags(size, KERNEL_PAGE_FLAGS)
}
pub unsafe fn vmalloc_user(size: usize) -> *mut u8 {
vmalloc_with_flags(size, USER_PAGE_FLAGS)
}
unsafe fn vmalloc_with_flags(size: usize, flags: u32) -> *mut u8 {
// ... allocates pages with the given flags
}
}
All existing kernel vmalloc calls are unchanged. Process stacks use vmalloc_user.
When KFS_7 adds ring 3, the stacks are already correctly mapped.
fork
fork copies an entire process — fresh stack allocation, stack contents copied,
fresh InterruptFrame copied from parent, parent/child tree updated:
#![allow(unused)]
fn main() {
pub fn fork(parent: *mut Process) -> *mut Process {
let proc = create_process();
unsafe {
(*proc).parent = parent;
(*proc).stack = vmalloc_user((*parent).stack_size as usize) as u32;
mem::memcpy(
(*proc).stack as *mut u8,
(*parent).stack as *mut u8,
(*parent).stack_size as usize,
);
(*proc).stack_size = (*parent).stack_size;
// TODO: Copy heap — processes don't have dynamic heaps yet (KFS_7)
if !(*parent).heap.is_null() {
// heap copy goes here when heap allocation is implemented
}
(*proc).signal_queue = (*parent).signal_queue;
(*proc).owner_id = (*parent).owner_id;
// allocate a fresh frame and copy parent's state into it
let frame_ptr = kmalloc(core::mem::size_of::<InterruptFrame>());
if let Some(f) = frame_ptr {
let frame = f as *mut InterruptFrame;
core::ptr::write(frame, *(*parent).state);
(*proc).state = frame;
}
// link child into parent's child list
if (*parent).first_child.is_null() {
(*parent).first_child = proc;
} else {
let mut sibling = (*parent).first_child;
while !(*sibling).next_sibling.is_null() {
sibling = (*sibling).next_sibling;
}
(*sibling).next_sibling = proc;
}
proc
}
}
}
The critical part is the frame copy — core::ptr::write(frame, *(*parent).state)
dereferences the parent’s state pointer to get the actual frame contents, then writes
a copy into the freshly allocated frame. If you just copy the pointer, both processes
share the same frame and the scheduler corrupts one when saving the other.
The heap copy is deferred — processes don’t have dynamic heaps yet. That arrives with
malloc in KFS_7.
memcpy is a klib function we wrote ourselves rather than pulling it from anywhere:
#![allow(unused)]
fn main() {
pub fn memcpy(dest: *mut u8, src: *mut u8, size: usize) -> *mut u8 {
if src == 0 as *mut u8 || dest == 0 as *mut u8 {
return 0 as *mut u8;
}
unsafe {
for i in 0..size {
*dest.add(i) = *src.add(i);
}
}
return dest;
}
}
On Rust raw pointers, dest.add(i) is pointer arithmetic — equivalent to dest + i
in C. Then * dereferences it. So *dest.add(i) = *src.add(i) is exactly C’s
dest[i] = src[i].
exit
Frees the stack, marks the process as Zombie, notifies the parent with SIGCHLD,
and removes the process from the table:
#![allow(unused)]
fn main() {
pub fn exit(pid: u32) {
unsafe {
let proc = match get_process(pid) {
Some(p) => p,
None => return,
};
// free the process's stack
vfree((*proc).stack as *mut u8);
// mark as finished
(*proc).status = ProcessStatus::Zombie;
// notify parent with SIGCHLD (signal 17)
let parent = (*proc).parent;
if !parent.is_null() {
for i in 0..signals::SIGNAL_QUEUE_SIZE {
if (*parent).signal_queue[i] == 0 {
(*parent).signal_queue[i] = 17; // SIGCHLD
break;
}
}
}
// remove from table
PROCESS_TABLE[pid as usize] = None;
}
}
}
SIGCHLD is signal 17 — standard Unix. The parent’s signal queue gets the signal
on the next tick so it knows to call wait().
kill
Two cases — SIGKILL (9) terminates immediately, anything else queues the signal
for delivery on the next tick:
#![allow(unused)]
fn main() {
pub fn kill(pid: u32, signal: u8) {
unsafe {
let proc = match get_process(pid) {
Some(p) => p,
None => return,
};
if signal == 9 {
exit(pid);
return;
}
for i in 0..signals::SIGNAL_QUEUE_SIZE {
if (*proc).signal_queue[i] == 0 {
(*proc).signal_queue[i] = signal;
break;
}
}
}
}
}
getuid
Simply returns the owner_id from the process:
#![allow(unused)]
fn main() {
pub fn getuid(pid: u32) -> u32 {
unsafe {
let proc = match get_process(pid) {
Some(p) => p,
None => return 0,
};
(*proc).owner_id
}
}
}
signal
Registers a per-process signal handler. Each process has 32 handler slots — one per
signal number. This is separate from the global SIGNAL_TABLE in the interrupts
module which handles kernel-wide interrupt routing:
#![allow(unused)]
fn main() {
pub fn signal(pid: u32, signum: u32, handler: ProcessSignalHandler) {
unsafe {
let proc = match get_process(pid) {
Some(p) => p,
None => return,
};
if signum < 32 {
(*proc).signal_handlers[signum as usize] = Some(handler);
}
}
}
}
When a signal is delivered from the queue, the kernel checks if the process has a handler registered for it and calls it. Without a registered handler, the default kernel behaviour applies.
wait
Blocks until a child of pid becomes a Zombie, then returns that child’s pid:
#![allow(unused)]
fn main() {
pub fn wait(pid: u32) -> u32 {
unsafe {
loop {
for i in 0..8 {
if let Some(p) = PROCESS_TABLE[i] {
if (*p).parent == get_process(pid).unwrap_or(core::ptr::null_mut()) {
match (*p).status {
ProcessStatus::Zombie => return (*p).pid,
_ => {}
}
}
}
}
}
}
}
}
This is a busy-wait — it spins checking the process table until a child finishes.
The proper implementation puts the waiting process to sleep and wakes it on SIGCHLD,
which requires the scheduler to support process sleeping. That’s KFS_5’s scheduler
work done properly. For now this satisfies the subject requirement — it works
correctly, it’s just not efficient.
Sockets
A socket is a communication channel between two processes. Similar in concept to how WebSockets work — two endpoints sharing a buffer. One process writes into it, the other reads out of it.
The design mirrors Unix socket semantics deliberately. When KFS_7 adds the syscall
layer, sys_socket, sys_write, and sys_read will just be thin gates into these
same functions. No redesign needed.
The Table
#![allow(unused)]
fn main() {
const SOCKET_TABLE_SIZE: usize = 8;
pub static mut SOCKET_TABLE: [Option<*mut Socket>; SOCKET_TABLE_SIZE] = [None; SOCKET_TABLE_SIZE];
static mut SOCKET_COUNT: u32 = 0;
}
Same pattern as the process table — fixed array of optional pointers, a counter that
hands out unique IDs. SOCKET_TABLE_SIZE is a constant so the array size is
reusable without magic numbers.
The Struct
#![allow(unused)]
fn main() {
pub struct Socket {
id: u32,
sender: u32, // pid of the sending process
receiver: u32, // pid of the receiving process
buffer: *mut u8, // vmalloc'd data buffer
capacity: u32, // total buffer size
length: u32, // bytes currently in buffer
status: SocketStatus,
}
#[derive(PartialEq)]
enum SocketStatus {
Open,
Closed,
}
}
buffer is a pointer to a vmalloc’d region — capacity can vary per socket.
length tracks how much data is currently in the buffer so reads and writes know
where they are. The PartialEq derive on SocketStatus is needed so we can do
if (*s).status == SocketStatus::Open — Rust doesn’t give you == for free on
enums.
socket_create()
Allocates the socket struct on the heap, allocates the data buffer, initialises everything, and registers it in the table:
#![allow(unused)]
fn main() {
pub fn socket_create(sender: u32, receiver: u32, capacity: u32) -> i32 {
unsafe {
let ptr = memory::kmalloc(core::mem::size_of::<Socket>());
match ptr {
Some(p) => {
let socket = p as *mut Socket;
(*socket).id = SOCKET_COUNT;
(*socket).sender = sender;
(*socket).receiver = receiver;
(*socket).capacity = capacity;
(*socket).length = 0;
(*socket).status = SocketStatus::Open;
(*socket).buffer = memory::vmalloc(capacity as usize);
for i in 0..SOCKET_TABLE_SIZE {
if SOCKET_TABLE[i].is_none() {
SOCKET_TABLE[i] = Some(socket);
break;
}
}
SOCKET_COUNT += 1;
(*socket).id as i32
}
None => -1,
}
}
}
}
Returns the socket id on success, -1 on failure — Unix convention. The socket
struct goes on the heap via kmalloc, the data buffer goes in virtual memory via
vmalloc. Two separate allocations because they have different lifetimes and sizes.
socket_write()
Finds the socket by id, checks it’s open and has room, then copies data in using
memcpy from klib:
#![allow(unused)]
fn main() {
pub fn socket_write(id: u32, data: *const u8, len: u32) -> i32 {
unsafe {
for i in 0..SOCKET_TABLE_SIZE {
if let Some(s) = SOCKET_TABLE[i] {
if (*s).id == id {
if (*s).status == SocketStatus::Open {
if (*s).length + len <= (*s).capacity {
crate::klib::mem::memcpy(
(*s).buffer.add((*s).length as usize),
data as *mut u8,
len as usize,
);
(*s).length += len;
return len as i32;
}
return -1; // buffer full
}
return -1; // socket closed
}
}
}
-1 // socket not found
}
}
}
(*s).buffer.add((*s).length as usize) — writes start at the current end of the
buffered data, not at the beginning. This means multiple writes accumulate rather
than overwriting each other. The overflow check length + len <= capacity prevents
writing past the end of the allocation.
socket_read()
Copies from the buffer into the destination, then decrements length to mark the
data as consumed:
#![allow(unused)]
fn main() {
pub fn socket_read(id: u32, dest: *mut u8, len: u32) -> i32 {
unsafe {
for i in 0..SOCKET_TABLE_SIZE {
if let Some(s) = SOCKET_TABLE[i] {
if (*s).id == id {
if len <= (*s).length {
crate::klib::mem::memcpy(dest, (*s).buffer, len as usize);
(*s).length -= len;
return len as i32;
}
return -1; // not enough data available
}
}
}
-1 // socket not found
}
}
}
Note the check is len <= (*s).length — you can only read as much as is available.
Trying to read more than the buffer contains returns -1 rather than reading garbage.
socket_close()
Frees the data buffer, frees the socket struct, and clears the table entry:
#![allow(unused)]
fn main() {
pub fn socket_close(id: u32) {
unsafe {
for i in 0..SOCKET_TABLE_SIZE {
if let Some(s) = SOCKET_TABLE[i] {
if (*s).id == id {
memory::vfree((*s).buffer);
memory::kfree(s as *mut u8);
SOCKET_TABLE[i] = None;
}
}
}
}
}
}
Two frees — vfree for the vmalloc’d buffer, kfree for the kmalloc’d socket
struct. Order matters: free the contents before freeing the container.
Testing
> sys socket
=== Testing Sockets ===
Socket created: id=0
Write result: 20 bytes
Read result: 20 bytes
Message: hello from process 0
Socket closed
The test creates a socket between process 0 and process 1, writes a message, reads it back, verifies the contents, and closes the socket cleanly. Twenty bytes written, twenty bytes read, message intact.
The Scheduler
The scheduler is what makes multitasking real. Without it, the kernel runs one thing forever. With it, the CPU switches between processes on every timer tick — fast enough that from the outside it looks like everything is running at the same time.
How It Works
The timer fires at a regular interval via IRQ0. Every tick, the kernel:
- Saves the current process’s CPU state
- Picks the next process with a valid frame
- Loads that process’s saved state
- Returns into it as if nothing happened
From the CPU’s perspective, it’s just returning from an interrupt. It has no idea it’s now in a completely different process.
The Timer Assembly Stub
The context switch happens in assembly — it has to, because you can’t switch stacks from Rust:
timer_handler:
cli
pusha ; save all general purpose registers
push esp ; pass frame pointer to Rust handler
push 32 ; interrupt number
call rust_timer_handler ; save current state + send EOI
add esp, 8 ; clean up args
call get_next_frame ; returns next process frame pointer in eax
cmp eax, 0
je .no_switch ; 0 = no valid next process, skip
mov esp, eax ; switch ESP to next process's frame
.no_switch:
popa ; restore next process's registers
sti
iretd ; jump into the process
The magic line is mov esp, eax. By pointing ESP at the next process’s saved
InterruptFrame, popa restores that process’s registers, and iretd pops eip,
cs, and eflags from the same frame — jumping directly into the next process at
exactly the instruction it was interrupted at.
rust_timer_handler
Called from the stub. Saves the current process’s CPU state, then sends EOI to the PIC so future timer interrupts can fire:
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn rust_timer_handler(_interrupt: u32, frame: &InterruptFrame) {
unsafe {
if let Some(p) = PROCESS_TABLE[CURRENT_PROCESS as usize] {
if !(*p).state.is_null() {
core::ptr::write((*p).state, *frame);
}
}
crate::interrupts::pic::send_eoi(0);
}
}
}
core::ptr::write((*p).state, *frame) copies the frame contents into the
process’s heap-allocated state buffer. This is important — the original frame lives
on the interrupt stack which disappears after iretd. Saving a pointer to it would
be a dangling pointer. We hit this bug. *frame dereferences the reference to get
the actual InterruptFrame value, then ptr::write copies it to the safe location.
get_next_frame
Advances CURRENT_PROCESS and returns the next valid process’s frame pointer:
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn get_next_frame() -> u32 {
unsafe {
CURRENT_PROCESS = (CURRENT_PROCESS + 1) % 8;
for _ in 0..8 {
if CURRENT_PROCESS == 0 {
CURRENT_PROCESS = (CURRENT_PROCESS + 1) % 8;
continue;
}
if let Some(p) = PROCESS_TABLE[CURRENT_PROCESS as usize] {
if !(*p).state.is_null() {
return (*p).state as u32;
}
}
CURRENT_PROCESS = (CURRENT_PROCESS + 1) % 8;
}
0
}
}
}
Process 0 is always skipped — it’s the kernel idle process and runs directly in the
main loop, never via iretd. Skipping it in the scheduler means the timer only
switches between real user processes.
Returning 0 is the signal to the assembly stub to skip the context switch. If
there’s no valid next process, the timer fires, saves state, and iretd returns to
wherever was interrupted — no harm done.
The dummy_frame
When a process is first created via exec_fn, it has never actually run — there’s
no real CPU state to save. Instead we build a fake InterruptFrame that looks like
the process was interrupted at its entry point:
#![allow(unused)]
fn main() {
pub fn dummy_frame(function: u32, stack_top: *mut u8, size: u32) -> InterruptFrame {
let mut frame = InterruptFrame::new();
frame.eip = function; // start here when scheduled
frame.esp = stack_top as u32 + size - 4; // top of allocated stack
frame.eflags = 0x00000202; // IF set + reserved bit 1
frame.cs = 0x08; // kernel code segment
frame
}
}
eflags = 0x00000202 is critical — bit 9 is the Interrupt Flag. If it’s not set,
iretd restores eflags with interrupts disabled, and the process runs forever with
the timer blocked. The scheduler never fires again and the keyboard handler never
runs. We hit this bug too.
esp points to the top of the allocation because x86 stacks grow downward. The
bottom of the allocated region is the highest address the stack will ever reach
before overflowing.
The Ring 0 Stack Problem
Here’s the honest limitation: at ring 0, iretd cannot switch stacks.
On i386, iretd only restores ESP and SS from the frame when there’s a privilege
level change — specifically when the target CS has a lower privilege than the current
CPL (ring 3 → ring 0 transition in reverse). When staying at ring 0, iretd pops
only eip, cs, and eflags. ESP stays wherever it was.
This means our processes don’t actually run on their allocated stacks — they run on
whatever stack was active at the time of the switch. When the timer fires into a new
process, that process’s hlt loop runs fine, but when the next timer fires,
pusha pushes registers onto whatever ESP currently points at — which might be the
frame struct itself, or the kernel stack, or something else entirely. This causes
corruption.
The proper fix is ring 3 processes. When a ring 3 process is interrupted:
- The CPU automatically saves
SS:ESPalong witheip/cs/eflags - It switches to the kernel stack (via the TSS
esp0field) - On
iretdback to ring 3, it restoresSS:ESP— the stack switch happens for free
This is why we built the TSS. Everything is ready. The switch to ring 3 is deferred to KFS_7 where it belongs architecturally — that’s when the syscall interface and user/kernel address space separation happen anyway.
Current State
The scheduler infrastructure is fully built:
- Timer fires and saves process state ✅
- Round-robin process selection ✅
- Context switch via
iretd✅ dummy_framefor new processes ✅- TSS loaded and configured ✅
- Process 0 correctly excluded from scheduling ✅
The scheduler is currently disabled (get_next_frame returns 0) because ring 0
stack corruption makes it unstable with multiple processes. The shell runs directly
on the kernel stack via shell_main(), and exec_fn creates processes that are
registered in the table but not switched to.
This is the right call for KFS_5. Enabling it properly is KFS_7’s job.
Task State Segment (TSS)
The TSS is a hardware structure on i386 that the CPU reads automatically when switching privilege levels. We don’t use it for its original purpose — hardware task switching — but we need it for one specific thing: telling the CPU which kernel stack to switch to when a ring 3 process is interrupted.
Why It Exists
When a ring 3 process is interrupted (timer fires, keyboard fires, syscall happens), the CPU needs to switch to a kernel stack before it can do anything. It can’t use the process’s user stack for this — that would let user processes corrupt kernel execution. The CPU looks up the kernel stack address from the TSS automatically, before the interrupt handler even starts.
Without a properly configured TSS, ring 3 processes simply cannot be interrupted. Any attempt results in a General Protection Fault.
The Structure
The TSS is a fixed hardware layout — 104 bytes, with fields at specific offsets the CPU reads directly. Most fields are zeroed; we only care about two:
#![allow(unused)]
fn main() {
#[repr(C, packed)]
pub struct TSS {
link: u16, _link: u16, // previous task link (unused)
esp0: u32, // kernel stack pointer ← we set this
ss0: u16, _ss0: u16, // kernel stack segment ← and this
esp1: u32,
ss1: u16, _ss1: u16,
esp2: u32,
ss2: u16, _ss2: u16,
cr3: u32,
eip: u32,
eflags: u32,
eax: u32, ecx: u32, edx: u32, ebx: u32,
esp: u32, ebp: u32, esi: u32, edi: u32,
es: u16, _es: u16,
cs: u16, _cs: u16,
ss: u16, _ss: u16,
ds: u16, _ds: u16,
fs: u16, _fs: u16,
gs: u16, _gs: u16,
ldt: u16, _ldt: u16,
trap: u16,
iomap_base: u16,
}
}
The _ prefixed fields are padding — the CPU accesses these by byte offset, not by
name. The struct must be #[repr(C, packed)] so Rust doesn’t add any alignment
padding of its own.
Initialisation
The TSS is set up in src/kernel/tss.rs, called from kernel_main after the GDT
is loaded:
#![allow(unused)]
fn main() {
static mut KERNEL_TSS: TSS = TSS { /* all zeros */ };
pub fn init(kernel_stack_top: u32) {
unsafe {
// point to kernel stack for ring 3 → ring 0 transitions
KERNEL_TSS.ss0 = 0x10; // kernel data segment
KERNEL_TSS.esp0 = kernel_stack_top;
// fill GDT entry 5 with the TSS descriptor
let tss_base = core::ptr::addr_of!(KERNEL_TSS) as u32;
let tss_limit = (core::mem::size_of::<TSS>() - 1) as u32;
gdt::GDT[5] = gdt::GdtEntry::new(tss_base, tss_limit, 0x89, 0x00);
// reload GDT so the new entry takes effect
let gdt_ptr = gdt::GdtPointer {
limit: (core::mem::size_of::<[gdt::GdtEntry; 6]>() - 1) as u16,
base: core::ptr::addr_of!(gdt::GDT) as u32,
};
gdt::gdt_flush(&gdt_ptr);
// load the task register so the CPU knows where the TSS is
core::arch::asm!("ltr ax", in("ax") 0x28u16);
}
}
}
ss0 = 0x10 is the kernel data segment selector. esp0 is the top of the kernel
boot stack, exported from boot.asm:
global stack_top
stack_bottom:
resb 65536
stack_top:
#![allow(unused)]
fn main() {
extern "C" {
pub static stack_top: u8;
}
// in kernel_main:
let kstack = core::ptr::addr_of!(tss::stack_top) as u32;
tss::init(kstack);
}
The GDT Entry
The TSS descriptor in the GDT is different from regular segment descriptors. Access
byte 0x89 means: present (0x80), type = TSS available (0x09). Granularity
0x00 means the limit is in bytes, not 4KB pages — the TSS is 104 bytes, not
multiple pages.
The GDT slot at index 5 (offset 0x28) was reserved as a null entry since KFS_2
with a comment saying “properly set up when we implement task switching”. That day
has arrived.
ltr — Load Task Register
#![allow(unused)]
fn main() {
core::arch::asm!("ltr ax", in("ax") 0x28u16);
}
ltr loads the Task Register with the TSS selector, telling the CPU “the TSS is at
GDT entry 0x28”. Without this instruction, the CPU has no idea the TSS exists even
if the GDT entry is correctly filled.
What This Unlocks in KFS_7
When KFS_7 adds ring 3 processes, the TSS is what makes interrupts work correctly. On a ring 3 → ring 0 transition:
- CPU reads
esp0andss0from the TSS - Switches ESP to the kernel stack
- Pushes
ss,esp,eflags,cs,eiponto the kernel stack - Calls the interrupt handler
On iretd back to ring 3, ESP and SS are restored from the frame — the stack switch
happens automatically in both directions. No manual stack manipulation needed.
Everything else for ring 3 is also already in place:
- User code segment
0x18(DPL=3) and user data segment0x20(DPL=3) — in the GDT since KFS_2 vmalloc_user()— maps pages withPAGE_USERset, accessible from ring 3InterruptFramehasesp_userandss_userfields for the extended frame layoutdummy_frameknows how to setcs = 0x1Bandss_user = 0x23for ring 3
The switch to ring 3 in KFS_7 is: change dummy_frame to use ring 3 selectors,
reload the data segment registers in the timer stub after a switch, and route
process memory through vmalloc_user. The TSS is already doing its job.
Mmap
Direct from man mmap(2),
mmap() creates a new mapping in the virtual space of the calling process. The starting address for the new mapping is specified in addr. The length argument specifies the length of the mapping (which must be greater than 0).
The prototype we need would look something like this:
pub fn mmap(addr: u32, size: u32, prot: u32, flags: u32) -> Option<>
Sources & Resources
Everything referenced during the build of KFS.
Essential Reading
Kernel Development
- OSDev Wiki — The single most useful resource for OS development. Comprehensive, community-maintained, and full of worked examples.
- Bare Bones — Minimal kernel tutorial
- Interrupts
- IDT
- PIC 8259
- PS/2 Keyboard
- Exceptions
- GDT
- Paging
- Memory Allocation
- Page Frame Allocation
Rust OS Development
- Writing an OS in Rust — Philipp Oppermann’s blog series. Different approach (x86-64, higher-half kernel) but excellent explanations of the same concepts.
- Rust Embedded Book — Bare-metal Rust programming guide.
Theory
- How to make an Operating System by Samy Pesse — The source for much of the paging theory in KFS_3.
Specifications
- Multiboot Specification — Official GRUB/Multiboot spec. The Multiboot header format is defined here.
- GNU Linker Manual — Linker script syntax reference.
- Intel Software Developer Manuals — The ultimate reference. Very dense, but authoritative on everything CR0, CR3, EFLAGS, exceptions, paging.
Assembly & x86
- x86 Assembly Guide (Virginia) — Beginner-friendly introduction.
- NASM Tutorial — NASM syntax reference.
- x86 Instruction Reference — Look up any instruction.
- OSDev Inline Assembly Examples — The source for
outb,inb,io_wait.
Tools
- QEMU — Hardware emulation for testing.
qemu-system-i386for our 32-bit kernel. - NASM — Netwide Assembler. Used for
boot.asm,kb.asm,exc.asm,gdt.asm. - mdBook — What this documentation is built with.
Installing mdBook (Arch Linux)
cargo install mdbook
# or
yay -S mdbook
Building This Book
cd kfs-book/
mdbook build # Build static HTML to ./book/
mdbook serve # Build and serve at http://localhost:3000
mdbook watch # Rebuild automatically when files change