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.