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

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:

  1. Saves the current process’s CPU state
  2. Picks the next process with a valid frame
  3. Loads that process’s saved state
  4. 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:ESP along with eip/cs/eflags
  • It switches to the kernel stack (via the TSS esp0 field)
  • On iretd back to ring 3, it restores SS: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_frame for 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.