Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.