From 8f05ea44f69cb796e7b1f581ba1b8045c98d615b Mon Sep 17 00:00:00 2001 From: salmonik Date: Sat, 1 Aug 2026 17:38:57 +0200 Subject: rsnix: preemptive kernel tasks (ADR 0003) — sched, IST fault stacks, 176-byte frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sched: 16-slot static table, shell=task 0, idle=task 1, spawn with guard page + 8 KiB stack, RR on the 100 Hz tick, int 0x60 yield, sleep_ms, lazy reap, jobs/bg/sleep/kill shell commands - gdt: own GDT mirroring UEFI selectors + TSS with IST1/IST2 (guard-page overflows die gracefully instead of triple-faulting) - interrupts: isr_dispatch returns next frame in rax; IntFrame grows rsp/ss fields — QEMU's 64-bit iretq always pops all five slots from a 176-byte frame, so synthetic task frames must carry rsp=top+ss (a 160-byte frame made the first iretq pop rsp/ss from the unmapped page beyond the frame) - panic handler: task panics abort the task, shell keeps running - main: sched::init moved after page allocator + adopted tables (the idle's stack+guard need both; it was silently not spawning) --- rsnix/src/gdt.rs | 242 +++++++++++++++++++++ rsnix/src/interrupts.rs | 139 ++++++++++--- rsnix/src/klog.rs | 17 +- rsnix/src/lib.rs | 2 + rsnix/src/sched.rs | 544 ++++++++++++++++++++++++++++++++++++++++++++++++ rsnix/src/serial.rs | 35 +++- 6 files changed, 932 insertions(+), 47 deletions(-) create mode 100644 rsnix/src/gdt.rs create mode 100644 rsnix/src/sched.rs (limited to 'rsnix/src') diff --git a/rsnix/src/gdt.rs b/rsnix/src/gdt.rs new file mode 100644 index 0000000..514ed2e --- /dev/null +++ b/rsnix/src/gdt.rs @@ -0,0 +1,242 @@ +// --------------------------------------------------------------------------- +// GDT + TSS — kernel-owned segment tables and interrupt stack tables (IST). +// +// The kernel boots on UEFI's GDT. We take over with our own GDT that +// mirrors the current selector layout (flat ring-0 code/data descriptors +// at the same indices UEFI chose), so CS/DS/SS/ES keep working without +// any far-jump or segment-reload gymnastics. On top of that we add a +// TSS with two interrupt stack tables: +// +// - IST1: page-fault (vector 14) stack. A stack-overflowing task +// faults inside its unmapped guard page; the CPU pushes the fault +// frame onto this dedicated stack instead of the faulting (also +// unmapped) stack, so we can kill the task gracefully instead of +// double/triple-faulting into a machine reset. +// - IST2: double-fault (vector 8) stack. If anything else goes wrong +// during exception handling we land here and halt cleanly. +// +// We never change privilege level, so the TSS's RSP0..2 are unused — +// only the IST pointers matter. +// --------------------------------------------------------------------------- +use core::arch::asm; + +const GDT_ENTRIES: usize = 64; +const TSS_INDEX: usize = GDT_ENTRIES - 1; // 63 +const TSS_SEL: u16 = (TSS_INDEX << 3) as u16; // 0x1F8 + +/// 8-byte segment descriptor. +#[repr(C, packed)] +#[derive(Clone, Copy)] +struct GdtEntry { + limit_lo: u16, + base_lo: u16, + base_mid: u8, + access: u8, + flags_limit: u8, // limit 19:16 | flags nibble (G D/B L AVL) + base_hi: u8, +} + +impl GdtEntry { + const fn null() -> Self { + Self { + limit_lo: 0, + base_lo: 0, + base_mid: 0, + access: 0, + flags_limit: 0, + base_hi: 0, + } + } + /// Flat ring-0 64-bit code segment (present, exec/read, L=1). + const fn code() -> Self { + Self { + limit_lo: 0xFFFF, + base_lo: 0, + base_mid: 0, + access: 0x9A, + flags_limit: 0xAF, // G=1, D/B=0, L=1, AVL=0; limit 0xF + base_hi: 0, + } + } + /// Flat ring-0 data segment (present, read/write). + const fn data() -> Self { + Self { + limit_lo: 0xFFFF, + base_lo: 0, + base_mid: 0, + access: 0x92, + flags_limit: 0xCF, // G=1, D/B=1, L=0, AVL=0; limit 0xF + base_hi: 0, + } + } +} + +/// 64-bit TSS: only the IST pointers are used (no privilege changes). +#[repr(C, packed)] +#[derive(Clone, Copy)] +struct Tss { + _res1: u32, + rsp0: u64, + rsp1: u64, + rsp2: u64, + _res2: u64, + ist1: u64, + ist2: u64, + ist3: u64, + ist4: u64, + ist5: u64, + ist6: u64, + ist7: u64, + _res3: u64, + _res4: u16, + iomap_base: u16, +} + +const TSS_SIZE: usize = 104; + +impl Tss { + const fn zero() -> Self { + Self { + _res1: 0, + rsp0: 0, + rsp1: 0, + rsp2: 0, + _res2: 0, + ist1: 0, + ist2: 0, + ist3: 0, + ist4: 0, + ist5: 0, + ist6: 0, + ist7: 0, + _res3: 0, + _res4: 0, + iomap_base: 0, + } + } +} + +/// 4 KiB page-aligned block for the IST stacks. +#[repr(align(4096))] +struct Page([u8; 4096]); + +static mut PF_STACK: Page = Page([0; 4096]); +static mut DF_STACK: Page = Page([0; 4096]); +static mut TSS: Tss = Tss::zero(); +// GDT slots are 8 bytes except the TSS descriptor at index 63, which is +// 16 bytes — total 64*8 + 16. +static mut GDT: [u8; GDT_ENTRIES * 8 + 16] = [0; GDT_ENTRIES * 8 + 16]; +static mut GDTR: [u16; 5] = [0; 5]; + +unsafe fn read_seg(which: u8) -> u16 { + // `which` picks the segment register to read (0=cs, 1=ds, 2=ss, ...). + let v: u64; + unsafe { + match which { + 0 => asm!("mov {0}, cs", out(reg) v, options(nostack, preserves_flags)), + 1 => asm!("mov {0}, ds", out(reg) v, options(nostack, preserves_flags)), + 2 => asm!("mov {0}, ss", out(reg) v, options(nostack, preserves_flags)), + 3 => asm!("mov {0}, es", out(reg) v, options(nostack, preserves_flags)), + 4 => asm!("mov {0}, fs", out(reg) v, options(nostack, preserves_flags)), + _ => asm!("mov {0}, gs", out(reg) v, options(nostack, preserves_flags)), + } + } + v as u16 +} + +/// Install the kernel's own GDT (mirroring UEFI's selectors) and a TSS +/// with IST stacks for page-fault and double-fault handling. +/// +/// Returns false if UEFI's selector layout cannot be mirrored (any +/// nonzero selector at an index >= TSS_INDEX) — then we keep UEFI's GDT +/// and the IST-based fault stacks stay off (guard pages still exist but +/// an overflow would fault while pushing the fault frame → machine +/// reset instead of a graceful task death). +/// +/// Must run with interrupts disabled and before `interrupts::init` (the +/// IDT's vector-8/14 entries carry the IST indices). +pub fn init() -> bool { + unsafe { + asm!("cli", options(nostack)); + + let cs = read_seg(0); + let ds = read_seg(1); + let ss = read_seg(2); + let es = read_seg(3); + let fs = read_seg(4); + let gs = read_seg(5); + let sels = [cs, ds, ss, es, fs, gs]; + + // Every nonzero selector must land on a descriptor slot below + // TSS_INDEX (63 holds the 16-byte TSS descriptor). + for &s in &sels { + if s != 0 && (s >> 3) as usize >= TSS_INDEX { + return false; + } + } + + let gdt = core::ptr::addr_of_mut!(GDT[0]); + let write_entry = |idx: usize, e: GdtEntry| { + let p = gdt.add(idx * 8) as *mut GdtEntry; + core::ptr::write_unaligned(p, e); + }; + + write_entry(0, GdtEntry::null()); + write_entry((cs >> 3) as usize, GdtEntry::code()); + let cs_idx = (cs >> 3) as usize; + for &s in &sels { + let idx = (s >> 3) as usize; + if s != 0 && idx != 0 && idx != cs_idx { + write_entry(idx, GdtEntry::data()); + } + } + + // TSS descriptor (16 bytes) at index 63. + let tss_base = core::ptr::addr_of_mut!(TSS) as usize as u64; + TSS.ist1 = core::ptr::addr_of_mut!(PF_STACK.0[0]) as usize as u64 + 4096; + TSS.ist2 = core::ptr::addr_of_mut!(DF_STACK.0[0]) as usize as u64 + 4096; + TSS.iomap_base = TSS_SIZE as u16; // no I/O bitmap + + let mut td = [0u8; 16]; + td[0..2].copy_from_slice(&((TSS_SIZE - 1) as u16).to_le_bytes()); + td[2..4].copy_from_slice(&(tss_base as u16).to_le_bytes()); + td[4] = (tss_base >> 16) as u8; + td[5] = 0x89; // present, available 64-bit TSS + td[6] = 0x00; // limit 19:16 + flags = 0 + td[7] = (tss_base >> 24) as u8; + td[8..12].copy_from_slice(&((tss_base >> 32) as u32).to_le_bytes()); + // td[12..16] reserved — zero. + core::ptr::copy_nonoverlapping(td.as_ptr(), gdt.add(TSS_INDEX * 8), 16); + + // GDTR: limit = 63*8 + 16 - 1 = 519. + GDTR = [ + (TSS_INDEX * 8 + 16 - 1) as u16, + gdt as u16, + (gdt as u64 >> 16) as u16, + (gdt as u64 >> 32) as u16, + (gdt as u64 >> 48) as u16, + ]; + asm!("lgdt [{}]", in(reg) core::ptr::addr_of!(GDTR), options(nostack, preserves_flags)); + asm!("ltr ax", in("ax") TSS_SEL, options(nostack, preserves_flags)); + + // Re-load the data segments so the CPU re-validates them against + // the new GDT (CS cannot be reloaded by mov; its descriptor is + // unchanged at the same index). + if ds != 0 { + asm!("mov ds, ax", in("ax") ds, options(nostack, preserves_flags)); + } + if ss != 0 { + asm!("mov ss, ax", in("ax") ss, options(nostack, preserves_flags)); + } + if es != 0 { + asm!("mov es, ax", in("ax") es, options(nostack, preserves_flags)); + } + if fs != 0 { + asm!("mov fs, ax", in("ax") fs, options(nostack, preserves_flags)); + } + if gs != 0 { + asm!("mov gs, ax", in("ax") gs, options(nostack, preserves_flags)); + } + true + } +} diff --git a/rsnix/src/interrupts.rs b/rsnix/src/interrupts.rs index c176276..6abd54d 100644 --- a/rsnix/src/interrupts.rs +++ b/rsnix/src/interrupts.rs @@ -22,14 +22,14 @@ struct IdtEntry { } /// Fill one IDT entry at the given offset (in bytes). -unsafe fn idt_set_entry(idt: *mut u8, vec: u8, handler: u64, selector: u16, flags: u8) { +unsafe fn idt_set_entry(idt: *mut u8, vec: u8, handler: u64, selector: u16, flags: u8, ist: u8) { unsafe { let off = (vec as usize) * 16; let p = idt.add(off) as *mut IdtEntry; p.write(IdtEntry { offset_lo: handler as u16, selector, - ist: 0, + ist, flags: flags | 0x80, // bit 7 = present offset_mid: (handler >> 16) as u16, offset_hi: (handler >> 32) as u32, @@ -213,6 +213,19 @@ static KEYBUF: KeyBuf = KeyBuf::new(); core::arch::global_asm!( // ISR stubs — push vector number + error code, jump to common. + // Exceptions that push their own error code (8, 14) skip the push-0. + ".globl isr_stub_08", + ".p2align 4", + "isr_stub_08:", + " push 8", // vector (CPU already pushed the error code) + " jmp isr_common", + "", + ".globl isr_stub_14", + ".p2align 4", + "isr_stub_14:", + " push 14", + " jmp isr_common", + "", ".globl isr_stub_32", ".p2align 4", "isr_stub_32:", @@ -227,6 +240,15 @@ core::arch::global_asm!( " push 33", " jmp isr_common", "", + // Software yield (vector 0x60 — outside the PIC's 32-47 range, which + // is why 0x40 was rejected: 0x40 = 64 decimal = slave IRQ 8 slot). + ".globl isr_stub_60", + ".p2align 4", + "isr_stub_60:", + " push 0", + " push 0x60", + " jmp isr_common", + "", // Catch-all for unexpected interrupts. ".globl isr_stub_spurious", ".p2align 4", @@ -235,7 +257,8 @@ core::arch::global_asm!( " push 0xFF", " jmp isr_common", "", - // Common handler — save all registers, call into Rust, restore, iretq. + // Common handler — save all registers, call into Rust (which returns + // the next task's frame pointer in rax), switch rsp, restore, iretq. "isr_common:", " push rax", " push rcx", @@ -255,6 +278,7 @@ core::arch::global_asm!( "", " mov rcx, rsp", // first arg (rcx = MS x64 ABI) " call isr_dispatch", + " mov rsp, rax", // context switch: rax = next task's frame ptr "", " pop r15", " pop r14", @@ -277,37 +301,49 @@ core::arch::global_asm!( /// Saved register frame (as pushed by `isr_common`). #[repr(C)] -struct IntFrame { - r15: u64, - r14: u64, - r13: u64, - r12: u64, - r11: u64, - r10: u64, - r9: u64, - r8: u64, - rdi: u64, - rsi: u64, - rbp: u64, - rbx: u64, - rdx: u64, - rcx: u64, - rax: u64, - vector: u64, - error: u64, - rip: u64, - cs: u64, - rflags: u64, +pub(crate) struct IntFrame { + pub(crate) r15: u64, + pub(crate) r14: u64, + pub(crate) r13: u64, + pub(crate) r12: u64, + pub(crate) r11: u64, + pub(crate) r10: u64, + pub(crate) r9: u64, + pub(crate) r8: u64, + pub(crate) rdi: u64, + pub(crate) rsi: u64, + pub(crate) rbp: u64, + pub(crate) rbx: u64, + pub(crate) rdx: u64, + pub(crate) rcx: u64, + pub(crate) rax: u64, + pub(crate) vector: u64, + pub(crate) error: u64, + pub(crate) rip: u64, + pub(crate) cs: u64, + pub(crate) rflags: u64, + // QEMU's 64-bit interrupt entry ALWAYS pushes SS/RSP/RFLAGS/CS/RIP + // (40 bytes) and iretq always pops all five, so the tail must be + // 40 bytes — these two fields are the CPU-pushed RSP/SS. Synthetic + // task frames set them to the task's initial stack top and the + // kernel SS so the first iretq lands with a valid stack. + pub(crate) rsp: u64, + pub(crate) ss: u64, } -/// Called from the asm common handler — dispatches to the right Rust handler. +/// Called from the asm common handler — dispatches to the right Rust +/// handler and returns the frame pointer the asm should switch to (the +/// next task's, or the same one when no switch happens). #[unsafe(no_mangle)] -unsafe extern "C" fn isr_dispatch(frame: &IntFrame) { - unsafe { +unsafe extern "C" fn isr_dispatch(frame: &IntFrame) -> usize { + let fp = frame as *const IntFrame as usize; + crate::sched::set_in_isr(true); + let next = unsafe { match frame.vector { 32 => { TICKS.fetch_add(1, Ordering::Relaxed); eoi(0); + crate::sched::on_tick(fp) } 33 => { let byte: u8; @@ -316,10 +352,24 @@ unsafe extern "C" fn isr_dispatch(frame: &IntFrame) { // Push scancode to ring buffer (called with interrupts disabled). KEYBUF.push(byte); eoi(1); + fp + } + 8 => { + crate::serial_println!("double fault — halting"); + halt() } - _ => {} + 14 => { + let cr2: u64; + core::arch::asm!("mov {}, cr2", out(reg) cr2, + options(nostack, preserves_flags)); + crate::sched::page_fault(cr2, fp) + } + 0x60 => crate::sched::on_yield(fp), + _ => fp, } - } + }; + crate::sched::set_in_isr(false); + next } // --------------------------------------------------------------------------- @@ -350,12 +400,25 @@ pub fn init() { // Fill all 256 entries with the spurious handler. for vec in 0..=255u8 { - idt_set_entry(idt_ptr, vec, spurious, cs, 0x0Eu8); + idt_set_entry(idt_ptr, vec, spurious, cs, 0x0Eu8, 0); } // Override vectors for interrupt we actually handle. - idt_set_entry(idt_ptr, 32, isr_stub_32 as *const () as u64, cs, 0x0Eu8); - idt_set_entry(idt_ptr, 33, isr_stub_33 as *const () as u64, cs, 0x0Eu8); + idt_set_entry(idt_ptr, 32, isr_stub_32 as *const () as u64, cs, 0x0Eu8, 0); + idt_set_entry(idt_ptr, 33, isr_stub_33 as *const () as u64, cs, 0x0Eu8, 0); + // Yield (software interrupt). + idt_set_entry( + idt_ptr, + 0x60, + isr_stub_60 as *const () as u64, + cs, + 0x0Eu8, + 0, + ); + // Double fault and page fault run on dedicated IST stacks (see + // gdt.rs) so a task fault can't corrupt the faulting task's stack. + idt_set_entry(idt_ptr, 8, isr_stub_08 as *const () as u64, cs, 0x0Eu8, 2); + idt_set_entry(idt_ptr, 14, isr_stub_14 as *const () as u64, cs, 0x0Eu8, 1); // Build the IDTR pseudo-descriptor. IDTR = [ @@ -389,11 +452,23 @@ pub fn init() { } } +/// Halt the CPU forever with interrupts disabled. +pub fn halt() -> ! { + loop { + unsafe { + core::arch::asm!("cli; hlt", options(nostack, preserves_flags)); + } + } +} + // --------------------------------------------------------------------------- // Extern symbols from global_asm! // --------------------------------------------------------------------------- unsafe extern "C" { + fn isr_stub_08(); + fn isr_stub_14(); fn isr_stub_32(); fn isr_stub_33(); + fn isr_stub_60(); fn isr_stub_spurious(); } diff --git a/rsnix/src/klog.rs b/rsnix/src/klog.rs index 0c7dc06..5da5094 100644 --- a/rsnix/src/klog.rs +++ b/rsnix/src/klog.rs @@ -79,16 +79,19 @@ pub fn append(line: &str, mut ms: u64) { r.seq += 1; } -/// Append the formatted output of a `serial_println!` — the macro hook. -/// `fmt::Write` may deliver the line in several chunks, so accumulate -/// into one line buffer first, then append the whole line. The -/// timestamp is taken when the line is emitted (monotonic ms since -/// boot, from the PIT tick + live-counter interpolation). -pub fn append_fmt(args: core::fmt::Arguments<'_>) { - let ms = crate::interrupts::uptime_ms(); +/// Format one serial line, write it to the serial port, and append it +/// to the ring — all under the scheduler lock, so a line from one task +/// never interleaves with another task's line (line atomicity). +/// `serial_println!` funnels through here. +pub fn emit(args: core::fmt::Arguments<'_>) { let mut line = LineBuf::new(); let _ = core::fmt::write(&mut line, args); + let ms = crate::interrupts::uptime_ms(); + crate::sched::lock(); + crate::serial::write_raw(line.as_str()); + crate::serial::write_raw("\r\n"); append(line.as_str(), ms); + crate::sched::unlock(); } struct LineBuf { diff --git a/rsnix/src/lib.rs b/rsnix/src/lib.rs index a2d41da..08ac6c2 100644 --- a/rsnix/src/lib.rs +++ b/rsnix/src/lib.rs @@ -6,6 +6,7 @@ pub mod ahci; pub mod fat; pub mod font; pub mod fs; +pub mod gdt; pub mod gop; pub mod interrupts; pub mod klog; @@ -15,6 +16,7 @@ pub mod pci; pub mod power; pub mod ps2; pub mod rtc; +pub mod sched; pub mod serial; pub mod strbuf; pub mod tty; diff --git a/rsnix/src/sched.rs b/rsnix/src/sched.rs new file mode 100644 index 0000000..4b10503 --- /dev/null +++ b/rsnix/src/sched.rs @@ -0,0 +1,544 @@ +// --------------------------------------------------------------------------- +// sched — preemptive round-robin kernel tasks. +// +// 16 static task slots. The 100 Hz PIT tick (vector 32) and the +// software yield vector (0x60) go through `isr_common`, which saves the +// running task's full IntFrame on its own stack and asks the scheduler +// for the next frame pointer — the asm then moves rsp there and iretq's, +// which *is* the context switch. +// +// - task 0: the shell (boot stack, never allocated by us) +// - task 1: idle (hlt loop, always ready — the lowest-priority +// fallback) +// - tasks 2..: spawn'd, 8 KiB stack + one unmapped guard page below +// +// Preemption is disabled while the scheduler lock (PREEMPT_DEPTH) is +// held: the tick/yield handlers bail out entirely when the depth is +// > 0, so table mutations and klog/serial line writes are atomic with +// respect to switching. The lock is a counter, not a mutex — re-entrant +// by design (a panic mid-emit increments again; the panic path clears +// it, because a dead task's lock is meaningless). +// +// Task page faults (vector 14) run on a dedicated IST stack (see gdt.rs) +// so a stack overflow inside a guard page kills the task gracefully +// instead of double-faulting the machine. +// --------------------------------------------------------------------------- +use core::arch::asm; +use core::fmt; +use core::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + +extern crate alloc; +use alloc::string::String; +use alloc::vec::Vec; + +use crate::interrupts::IntFrame; +use crate::memory; +use crate::paging; + +pub const MAX_TASKS: usize = 16; +pub const SHELL_TID: usize = 0; +pub const IDLE_TID: usize = 1; +const STACK_PAGES: usize = 3; // 1 guard page + 8 KiB stack +const NAME_MAX: usize = 24; +const PANIC_MAX: usize = 64; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum TaskState { + Ready, + Sleeping, + Blocked, + Done, +} + +struct Task { + name: [u8; NAME_MAX], + name_len: u8, + state: TaskState, + /// Frame pointer to restore on switch-in (set at spawn; stored on + /// every switch-away). + saved_rsp: usize, + /// Physical base of the stack allocation (0 = none, e.g. the shell). + stack: usize, + /// Guard page address (unmapped; 0 = none). + guard: usize, + wake_at: u64, + ticks: u64, + panic: [u8; PANIC_MAX], + panic_len: u8, +} + +const TASK_NONE: Task = Task { + name: [0; NAME_MAX], + name_len: 0, + state: TaskState::Done, + saved_rsp: 0, + stack: 0, + guard: 0, + wake_at: 0, + ticks: 0, + panic: [0; PANIC_MAX], + panic_len: 0, +}; + +static mut TASKS: [Task; MAX_TASKS] = [TASK_NONE; MAX_TASKS]; +static mut CURRENT: usize = SHELL_TID; +static mut KERNEL_CS: u16 = 0; +static mut KERNEL_SS: u16 = 0; + +static SCHED_READY: AtomicBool = AtomicBool::new(false); +static IN_ISR: AtomicBool = AtomicBool::new(false); +static PREEMPT_DEPTH: AtomicU64 = AtomicU64::new(0); + +// --------------------------------------------------------------------------- +// Preemption lock +// --------------------------------------------------------------------------- + +/// Disable preemption (re-entrant counter). The tick/yield handlers +/// skip all scheduling work while the depth is > 0. +pub fn lock() { + PREEMPT_DEPTH.fetch_add(1, Ordering::Relaxed); +} + +/// Re-enable preemption (must pair with `lock`). +pub fn unlock() { + PREEMPT_DEPTH.fetch_sub(1, Ordering::Relaxed); +} + +pub fn in_isr() -> bool { + IN_ISR.load(Ordering::Relaxed) +} +pub fn set_in_isr(v: bool) { + IN_ISR.store(v, Ordering::Relaxed); +} +pub fn is_ready() -> bool { + SCHED_READY.load(Ordering::Relaxed) +} +pub fn current() -> usize { + unsafe { CURRENT } +} + +// --------------------------------------------------------------------------- +// Tick / yield entry points (called from the ISR dispatch) +// --------------------------------------------------------------------------- + +/// Timer tick: wake sleepers, account ticks, pick the next task. +/// Receives the interrupted task's frame pointer; returns the frame +/// pointer the asm should switch to (the same one when no switch). +pub fn on_tick(cur: usize) -> usize { + if !is_ready() || PREEMPT_DEPTH.load(Ordering::Relaxed) > 0 { + return cur; + } + unsafe { + TASKS[CURRENT].saved_rsp = cur; + TASKS[CURRENT].ticks += 1; + let now = crate::interrupts::uptime_ms(); + for t in 0..MAX_TASKS { + if TASKS[t].state == TaskState::Sleeping && TASKS[t].wake_at <= now { + TASKS[t].state = TaskState::Ready; + } + } + pick_next(cur) + } +} + +/// Software yield (vector 0x60). +pub fn on_yield(cur: usize) -> usize { + if !is_ready() || PREEMPT_DEPTH.load(Ordering::Relaxed) > 0 { + return cur; + } + unsafe { + TASKS[CURRENT].saved_rsp = cur; + pick_next(cur) + } +} + +/// Round-robin over Ready tasks, skipping the idle task unless nothing +/// else can run. Returns the next frame pointer (or `cur` to stay). +unsafe fn pick_next(cur: usize) -> usize { + unsafe { + for i in 1..MAX_TASKS { + let idx = (CURRENT + i) % MAX_TASKS; + if idx == IDLE_TID { + continue; + } + if TASKS[idx].state == TaskState::Ready { + CURRENT = idx; + return TASKS[idx].saved_rsp; + } + } + if CURRENT != IDLE_TID && TASKS[IDLE_TID].state == TaskState::Ready { + CURRENT = IDLE_TID; + return TASKS[IDLE_TID].saved_rsp; + } + cur + } +} + +// --------------------------------------------------------------------------- +// Spawn / lifecycle +// --------------------------------------------------------------------------- + +/// Spawn a kernel task running `entry` on its own 8 KiB stack with a +/// guard page below it. Returns the task id, or None when the table is +/// full (Done slots are reaped first). `entry` runs inside a +/// trampoline, so returning from it exits the task cleanly. +pub fn spawn(entry: fn(), name: &str) -> Option { + lock(); + let id = unsafe { + // Reap a Done task with a stack, or take a never-used slot. + let mut slot = None; + for i in 2..MAX_TASKS { + if TASKS[i].state == TaskState::Done && TASKS[i].stack != 0 { + reap(i); + slot = Some(i); + break; + } + if TASKS[i].state == TaskState::Done && TASKS[i].stack == 0 && TASKS[i].name_len == 0 { + slot = Some(i); + break; + } + } + slot? + }; + let ok = spawn_at(id, entry, name); + unlock(); + if ok { + Some(id) + } else { + None + } +} + +unsafe fn reap(id: usize) { + unsafe { + let t = &mut TASKS[id]; + if t.stack != 0 { + // Restore the guard page's mapping so the pages are reusable. + paging::map_page(t.stack, paging::PAGE_WRITABLE); + memory::free_pages(t.stack, STACK_PAGES); + } + *t = TASK_NONE; + } +} + +fn spawn_at(id: usize, entry: fn(), name: &str) -> bool { + let Some(base) = memory::alloc_pages(STACK_PAGES) else { + return false; + }; + // The first page is the guard — unmap it so any stack overflow + // faults there (caught on the vector-14 IST stack). + if !paging::unmap_page(base) { + memory::free_pages(base, STACK_PAGES); + return false; + } + let top = base + STACK_PAGES * memory::PAGE_SIZE; + let frame = (top - core::mem::size_of::()) as *mut IntFrame; + let mut name_buf = [0u8; NAME_MAX]; + let n = name.len().min(NAME_MAX); + name_buf[..n].copy_from_slice(&name.as_bytes()[..n]); + unsafe { + core::ptr::write( + frame, + IntFrame { + r15: 0, + r14: 0, + r13: 0, + r12: 0, + r11: 0, + r10: 0, + r9: 0, + r8: 0, + rdi: 0, + rsi: 0, + rbp: 0, + rbx: 0, + rdx: 0, + rcx: entry as usize as u64, // MS x64 ABI: first arg + rax: 0, + vector: 0, + error: 0, + rip: trampoline as *const () as u64, + cs: KERNEL_CS as u64, + rflags: 0x202, // IF | reserved bit 1 + // QEMU's iretq always pops RSP/SS from the frame tail. + // rsp = the stack top so the first iretq lands with a + // valid stack pointer (the trampoline then descends from + // it normally); ss mirrors the kernel data selector. + rsp: top as u64, + ss: KERNEL_SS as u64, + }, + ); + TASKS[id] = Task { + name: name_buf, + name_len: n as u8, + state: TaskState::Ready, + saved_rsp: frame as usize, + stack: base, + guard: base, + wake_at: 0, + ticks: 0, + panic: [0; PANIC_MAX], + panic_len: 0, + }; + } + true +} + +/// Entry trampoline: run the task body, then exit the task when it +/// returns. Runs with the synthetic frame's registers (rcx = entry). +unsafe extern "C" fn trampoline(entry: usize) -> ! { + let f: fn() = unsafe { core::mem::transmute(entry as *const ()) }; + f(); + task_exit() +} + +fn task_exit() -> ! { + lock(); + unsafe { + TASKS[CURRENT].state = TaskState::Done; + } + unlock(); + yield_int(); + unreachable!() +} + +/// Block the current task for `ms` milliseconds. The shell uses this +/// for `sleep`; background tasks keep running meanwhile. +pub fn sleep_ms(ms: u64) { + lock(); + unsafe { + TASKS[CURRENT].state = TaskState::Sleeping; + TASKS[CURRENT].wake_at = crate::interrupts::uptime_ms() + ms; + } + unlock(); + yield_int(); +} + +/// Cooperatively yield the rest of the current quantum. +pub fn yield_now() { + yield_int(); +} + +/// Software-interrupt the scheduler (vector 0x60). Net stack usage is +/// zero (the frame is popped by iretq), so no `nostack` — the compiler +/// must not use the red zone around the interrupt. +#[inline(never)] +fn yield_int() { + unsafe { + asm!("int 0x60"); + } +} + +// --------------------------------------------------------------------------- +// Task panics and faults +// --------------------------------------------------------------------------- + +/// Kill the current task with a panic message: logs it, marks the task +/// Done, drops its scheduler lock (a dead task's lock is meaningless), +/// and switches away forever. Called by the panic hook (task context) +/// and by the page-fault handler (IST stack, ISR context). +pub fn abort_current(msg: &str) -> ! { + unsafe { + let id = CURRENT; + let n = msg.len().min(PANIC_MAX); + TASKS[id].panic[..n].copy_from_slice(&msg.as_bytes()[..n]); + TASKS[id].panic_len = n as u8; + TASKS[id].state = TaskState::Done; + crate::serial_println!( + "sched: task {} \"{}\" panicked: {} (rsp_saved={:#x} stack={:#x} guard={:#x})", + id, + name_str(id), + msg, + TASKS[id].saved_rsp, + TASKS[id].stack, + TASKS[id].guard + ); + PREEMPT_DEPTH.store(0, Ordering::Relaxed); + } + yield_int(); + unreachable!() +} + +/// Vector 14 (page fault) handler — runs on the dedicated IST stack. +/// A fault in a real task (id >= 2) kills the task gracefully; anything +/// else (shell, idle, pre-scheduler) is a kernel bug: halt. +pub fn page_fault(cr2: u64, frame_ptr: usize) -> usize { + if !is_ready() || unsafe { CURRENT } <= IDLE_TID { + crate::serial_println!("pgfault: kernel page fault at {:#x} — halting", cr2); + crate::interrupts::halt(); + } + let g = unsafe { TASKS[CURRENT].guard }; + let in_guard = g != 0 && cr2 >= g as u64 && cr2 < (g + memory::PAGE_SIZE) as u64; + let rip = unsafe { (frame_ptr as *const IntFrame).read().rip }; + let mut buf = [0u8; 96]; + let n = { + let mut f = FmtBuf { b: &mut buf, n: 0 }; + let _ = fmt::write( + &mut f, + core::format_args!( + "{} at {:#x} (rip={:#x})", + if in_guard { + "stack overflow (guard page)" + } else { + "page fault" + }, + cr2, + rip + ), + ); + f.n + }; + abort_current(core::str::from_utf8(&buf[..n]).unwrap_or("task fault")) +} + +// --------------------------------------------------------------------------- +// Introspection (shell: jobs / kill) +// --------------------------------------------------------------------------- + +/// Snapshot the task table for `jobs` — one line per used slot. +pub fn jobs() -> Vec { + let mut v = Vec::new(); + lock(); + unsafe { + for i in 0..MAX_TASKS { + let t = &TASKS[i]; + if t.name_len == 0 { + continue; // never used + } + let state = match t.state { + TaskState::Ready => "ready", + TaskState::Sleeping => "sleeping", + TaskState::Blocked => "blocked", + TaskState::Done => { + if t.panic_len > 0 { + "done (panicked)" + } else { + "done" + } + } + }; + let mut line = alloc::format!( + " {:>2} {:<12} {:<15} {:>6} ticks", + i, + name_str(i), + state, + t.ticks + ); + if t.panic_len > 0 { + let m = core::str::from_utf8(&t.panic[..t.panic_len as usize]).unwrap_or("?"); + line.push_str(" "); + line.push_str(m); + } + v.push(line); + } + } + unlock(); + v +} + +/// Mark a task Done so it stops running and gets reaped at the next +/// spawn. The shell (0) and idle (1) are protected. +pub fn kill(id: usize) -> Result<(), &'static str> { + if id == SHELL_TID { + return Err("cannot kill the shell (task 0)"); + } + if id == IDLE_TID { + return Err("cannot kill the idle task (task 1)"); + } + if id >= MAX_TASKS { + return Err("no such task"); + } + lock(); + let alive = unsafe { + let t = &TASKS[id]; + t.name_len != 0 && t.state != TaskState::Done + }; + if !alive { + unlock(); + return Err("no such task"); + } + unsafe { + TASKS[id].state = TaskState::Done; + } + unlock(); + Ok(()) +} + +pub fn name_str(id: usize) -> &'static str { + unsafe { + let t = &TASKS[id]; + if t.name_len == 0 { + return ""; + } + core::str::from_utf8(&t.name[..t.name_len as usize]).unwrap_or("") + } +} + +// --------------------------------------------------------------------------- +// Init +// --------------------------------------------------------------------------- + +/// Set up the task table: task 0 is the shell (boot stack), task 1 is +/// the idle `hlt` fallback. Called after `interrupts::init` (the tick +/// must be live so `sleep`/wake scheduling works). +pub fn init() { + unsafe { + let cs64: u64; + asm!("mov {0}, cs", out(reg) cs64, options(nostack, preserves_flags)); + KERNEL_CS = cs64 as u16; + let ss64: u64; + asm!("mov {0}, ss", out(reg) ss64, options(nostack, preserves_flags)); + KERNEL_SS = ss64 as u16; + + TASKS[SHELL_TID] = Task { + name: { + let mut n = [0u8; 24]; + n[..5].copy_from_slice(b"shell"); + n + }, + name_len: 5, + state: TaskState::Ready, + saved_rsp: 0, // captured on the first switch-away + stack: 0, + guard: 0, + wake_at: 0, + ticks: 0, + panic: [0; PANIC_MAX], + panic_len: 0, + }; + spawn_at(IDLE_TID, idle_main, "idle"); + SCHED_READY.store(true, Ordering::Relaxed); + } + crate::serial_println!( + "sched: ready — {} slots, shell=task 0, idle=task 1", + MAX_TASKS + ); + crate::serial_println!( + "sched: trampoline={:#x} idle={:#x} emit={:#x}", + trampoline as *const () as usize, + idle_main as *const () as usize, + crate::klog::emit as *const () as usize + ); +} + +fn idle_main() { + loop { + unsafe { + asm!("hlt", options(nostack, preserves_flags)); + } + } +} + +struct FmtBuf<'a> { + b: &'a mut [u8], + n: usize, +} + +impl fmt::Write for FmtBuf<'_> { + fn write_str(&mut self, s: &str) -> fmt::Result { + let m = s.len().min(self.b.len() - self.n); + self.b[self.n..self.n + m].copy_from_slice(&s.as_bytes()[..m]); + self.n += m; + Ok(()) + } +} diff --git a/rsnix/src/serial.rs b/rsnix/src/serial.rs index 484a114..2267225 100644 --- a/rsnix/src/serial.rs +++ b/rsnix/src/serial.rs @@ -60,8 +60,17 @@ pub fn try_get_byte() -> Option { } } -/// Write a string to serial. +/// Write a string to serial, holding the scheduler lock for the whole +/// write so a line can't be interleaved with another task's output. pub fn write_str(s: &str) { + crate::sched::lock(); + write_raw(s); + crate::sched::unlock(); +} + +/// Raw serial write — no scheduler lock. Callers must hold the lock +/// (klog::emit does) or be in a context where preemption is impossible. +pub fn write_raw(s: &str) { for &b in s.as_bytes() { if b == b'\n' { put_byte(b'\r'); @@ -70,6 +79,16 @@ pub fn write_str(s: &str) { } } +/// One scheduler-locked call: the line plus its CRLF, atomic with +/// respect to context switches (used by the shell's term_println, where +/// the line and its newline must not be split by a tick). +pub fn write_line(s: &str) { + crate::sched::lock(); + write_raw(s); + write_raw("\r\n"); + crate::sched::unlock(); +} + // --------------------------------------------------------------------------- // fmt::Write implementation // --------------------------------------------------------------------------- @@ -96,17 +115,17 @@ macro_rules! serial_print { /// here, so the ring captures the whole boot sequence for free. /// User-facing shell output must NOT use this macro (shell output would /// pollute the boot log); it writes to the serial port directly. +/// +/// The whole line is formatted, written, and logged under one scheduler +/// lock (klog::emit), so lines from different tasks never interleave. #[macro_export] macro_rules! serial_println { () => { - $crate::serial::write_str("\r\n"); - $crate::klog::append_fmt(core::format_args!("")); + $crate::klog::emit(core::format_args!("")) + }; + ($($arg:tt)*) => { + $crate::klog::emit(core::format_args!($($arg)*)) }; - ($($arg:tt)*) => {{ - $crate::serial_print!($($arg)*); - $crate::serial::write_str("\r\n"); - $crate::klog::append_fmt(core::format_args!($($arg)*)); - }}; } // --------------------------------------------------------------------------- -- cgit v1.2.3