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.