diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e536833..908cc96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,6 +106,10 @@ jobs: - "qemu-test heap-test" - "qemu-test heap-oom-test" - "qemu-test heap-guard-fault-test" + - scheduler-test + - scheduler-preemption-test + - task-stack-guard-fault-test + - task-capacity-test steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -267,3 +271,55 @@ jobs: description: >- Reads the unmapped supervisor heap guard page and validates the expected non-present page fault. + + qemu-scheduler: + name: QEMU / kernel scheduler smoke + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/qemu-scenario + with: + features: "qemu-test scheduler-test" + description: >- + Runs two kernel tasks on distinct guarded stacks, validates their + lifecycle, and confirms bootstrap idle resumes after task exit. + + qemu-scheduler-preemption: + name: QEMU / timer-driven task preemption + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/qemu-scenario + with: + features: scheduler-preemption-test + description: >- + Uses two CPU-bound tasks with no voluntary yields; each can finish + only after timer preemption has run the other task. + + qemu-task-stack-guard: + name: QEMU / task stack guard page + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/qemu-scenario + with: + features: task-stack-guard-fault-test + description: >- + Deliberately reads a kernel task's unmapped lower stack guard and + validates the CPL0 non-present read fault. + + qemu-task-capacity: + name: QEMU / task capacity and reuse + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/qemu-scenario + with: + features: "qemu-test task-capacity-test" + description: >- + Fills the fixed task table, checks deterministic exhaustion, + reuses an exited slot with a new generation, and rejects the old ID. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ea9657b..359f619 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,7 +1,7 @@ # Architecture This document describes pvKernel's current architecture as implemented in -version 0.0.7. It does not describe planned or aspirational architecture; see +version 0.0.8. It does not describe planned or aspirational architecture; see [ROADMAP.md](ROADMAP.md) for that. ## Components @@ -29,6 +29,11 @@ pvKernel ELF (kernel_main) ## Responsibility boundary +The root `task` module owns the fixed task table and runnable ring. +`arch::x86_64::task` owns task stack mapping, the saved frame layout, and the +small timer-entry/trampoline assembly. Policy stays in Rust; CR3 remains +shared across all kernel tasks. + * **UEFI firmware**: powers on the machine, initializes basic platform devices, and runs the boot manager. * **rust-osdev bootloader**: locates and loads the pvKernel ELF binary, @@ -41,12 +46,15 @@ pvKernel ELF (kernel_main) legacy 8259 PIC and PIT drivers, the physical frame allocator and active page-table access, a provisional `int 0x80` syscall ABI, and (test-only) CPL0 → CPL3 userspace entry, and framebuffer rendering. +* **Kernel tasks**: 0.0.8 adds bounded, preemptive, single-core ring-0 tasks + with separate guarded stacks. It does not schedule CPL3 payloads or switch + CR3; see [docs/architecture/kernel-tasks.md](docs/architecture/kernel-tasks.md). * **Future pvOS userspace**: 0.0.6 adds only a minimal, test-only CPL0 → CPL3 transition and bootstrap syscall ABI (see [docs/architecture/userspace-entry.md](docs/architecture/userspace-entry.md) and [docs/architecture/syscalls.md](docs/architecture/syscalls.md)); a - process model, scheduler, and ELF userspace loading remain future - roadmap items (see [ROADMAP.md](ROADMAP.md)); pvKernel does not + process model and ELF userspace loading remain future roadmap items (see + [ROADMAP.md](ROADMAP.md)); pvKernel does not currently support them. ## Initialization order @@ -99,23 +107,27 @@ pvKernel ELF (kernel_main) 0.0.1: dark background, centered blue `P` (`#00B7EF`). 7. A controlled `int3` breakpoint test verifies the IDT is functioning and execution resumes correctly. -8. A boot-time timer verification unmasks PIT IRQ0, enables maskable - hardware interrupts, and confirms the tick counter advances (see - [docs/architecture/timer.md](docs/architecture/timer.md)). -9. If the `fault-tests` Cargo feature is enabled, a deliberate page fault is +8. `task::init` reserves and verifies task stacks. Two finite smoke tasks + are created, but not run yet, before IRQ0 is unmasked. +9. PIT IRQ0 is unmasked, maskable interrupts are enabled, the two tasks run + under preemption and return to bootstrap, and timer progress is confirmed + (see [docs/architecture/timer.md](docs/architecture/timer.md)). +10. If the `fault-tests` Cargo feature is enabled, a deliberate page fault is triggered and matched against its expected faulting address. -10. If the `mem-fault-tests` Cargo feature is enabled, a temporary page is +11. If the `mem-fault-tests` Cargo feature is enabled, a temporary page is mapped, written to, unmapped, and deliberately re-accessed, matching the resulting page fault against its expected address (see [docs/development/memory-testing.md](docs/development/memory-testing.md)). -11. If `supervisor-write-protection-test` is enabled, a CPL0 write to a +12. If `supervisor-write-protection-test` is enabled, a CPL0 write to a supervisor-only read-only page verifies CR0.WP behavior through an expected protection-violation page fault. -12. If exactly one `userspace-*-test` Cargo feature is enabled, a hand-written +13. If exactly one `userspace-*-test` Cargo feature is enabled, a hand-written test payload is mapped into the reserved userspace range and entered at CPL3 via `iretq`; the scenario ends deterministically (see [docs/development/userspace-testing.md](docs/development/userspace-testing.md)). -13. The CPU enters an interrupt-enabled idle loop (`halt::idle_loop`), unless +14. The timer entry saves the complete interrupt context, schedules CPL0 + contexts only, and resumes bootstrap after both smoke tasks exit. +15. The CPU enters an interrupt-enabled idle loop (`halt::idle_loop`), unless the `qemu-test` Cargo feature is enabled, in which case QEMU exits deterministically instead (see [docs/development/testing.md](docs/development/testing.md)). diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cdbcc2..997157b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [0.0.8] - Unreleased + +### Added + +* Bounded ring-0 kernel tasks with generation-aware `TaskId`s, eight + pre-mapped 16 KiB supervisor RW/NX stacks, and unmapped guards on both + ends. +* A dedicated x86_64 PIT IRQ entry that saves the complete GPR and long-mode + interrupt frame, preserves CPL3 returns without scheduling, and performs + allocation-free round-robin decisions. +* A bounded ready ring, safe task-return/exit lifecycle, and deterministic + QEMU coverage for task execution, timer preemption, capacity/reuse, stale + IDs, and task stack guard faults. + ## [0.0.7] - Unreleased ### Added diff --git a/Cargo.lock b/Cargo.lock index 7ccbed7..e642fa9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -340,7 +340,7 @@ dependencies = [ [[package]] name = "kernel" -version = "0.0.7" +version = "0.0.8" dependencies = [ "bootloader_api", "spin", @@ -453,7 +453,7 @@ dependencies = [ [[package]] name = "pvkernel-runner" -version = "0.0.7" +version = "0.0.8" dependencies = [ "bootloader", "kernel", diff --git a/Cargo.toml b/Cargo.toml index a6cf17d..417a13a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = ["kernel"] # docs/security/vulnerability-management.md and the release workflows for # why that consistency matters at release time. [workspace.package] -version = "0.0.7" +version = "0.0.8" license = "MIT OR Apache-2.0" edition = "2024" repository = "https://github.com/ultronstudio/pvkernel" @@ -54,6 +54,10 @@ userspace-supervisor-fault-test = ["kernel/userspace-supervisor-fault-test"] heap-test = ["kernel/heap-test"] heap-oom-test = ["kernel/heap-oom-test"] heap-guard-fault-test = ["kernel/heap-guard-fault-test"] +scheduler-test = ["kernel/scheduler-test"] +scheduler-preemption-test = ["kernel/scheduler-preemption-test"] +task-stack-guard-fault-test = ["kernel/task-stack-guard-fault-test"] +task-capacity-test = ["kernel/task-capacity-test"] [build-dependencies] kernel = { path = "kernel", artifact = "bin", target = "x86_64-unknown-none" } diff --git a/README.md b/README.md index 0ba6364..0e01682 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ written in Rust and developed as the foundation of pvOS. ## Current milestone -Version: `0.0.7` — Kernel Heap and Rust `alloc` +Version: `0.0.8` — Scheduler + Kernel Tasks Implemented: @@ -59,6 +59,14 @@ Implemented: coalescing of adjacent free allocation units * Rust `alloc` support for `Box`, growing `Vec`, and `String` * deterministic heap success, OOM, and guard-page QEMU tests +* bounded single-core preemptive round-robin scheduling for ring-0 kernel + tasks, with generation-aware IDs and deterministic task lifecycle +* eight centrally reserved 16 KiB kernel task stacks, supervisor-only RW/NX, + with unmapped guard pages above and below +* dedicated timer IRQ context switching with complete general-purpose + register and interrupt-frame preservation; CPL3 timer IRQs resume in place +* deterministic QEMU scheduler, CPU-bound preemption, stack-guard, capacity, + and stale-ID/reuse coverage * structured serial diagnostics * panic handler * an interrupt-enabled idle loop (plus a separate fatal halt path) @@ -70,8 +78,9 @@ Not implemented: * general physical-frame deallocation/reuse (heap backing frames stay owned by the heap for the kernel lifetime) * dynamic heap growth, per-process heaps, or a slab allocator -* guard pages for stacks or other subsystems (the kernel heap has two) -* processes, threads, or a scheduler +* dynamic task stack growth, task join, blocking, sleep, or priorities +* APIC, SMP, and multicore scheduling +* userspace process scheduling, processes, or per-process address spaces * `SYSCALL`/`SYSRET`, a process model, or general (non-test) userspace execution * ELF userspace loading diff --git a/ROADMAP.md b/ROADMAP.md index 894530b..a348533 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -99,26 +99,27 @@ freeing heap objects returns memory to the heap allocator, not the physical frame allocator. Dynamic heap growth and general physical-frame reclamation remain future work. -## 0.0.8 — Kernel Tasks and Scheduler +## 0.0.8 — Scheduler + Kernel Tasks -* kernel-level tasks -* context switching -* a preemptive scheduler +* bounded, single-core, ring-0 kernel tasks with dedicated guarded stacks +* generation-aware task IDs and safe task lifecycle/slot reuse +* complete x86_64 timer-interrupt context switching and deterministic + round-robin preemption +* QEMU coverage for scheduler operation, true timer preemption, stack guards, + and capacity/reuse -## 0.0.9 — ELF Userspace Loader and Init - -* loading ELF userspace binaries -* a minimal init process -* a process model built on top of 0.0.6's ring-3/syscall foundation and - 0.0.8's scheduler (superseding the standalone "Ring 3 and System Calls" - milestone originally planned here, since that foundation now exists - earlier) +## 0.0.9 — Processes and Userspace Address Spaces +* process model and process lifecycle foundation +* per-process address spaces and CR3 switching +* userspace ELF execution foundation +* builds on the existing provisional CPL3 entry/syscall boundary; does not + imply a stable userspace ABI ## 0.0.10 — VFS and Initial Ramdisk -* a virtual filesystem layer -* initial ramdisk support +* VFS foundation +* initrd and root filesystem foundation ## 0.1.0 — First pvOS-Ready Kernel Foundation diff --git a/docs/architecture/boot.md b/docs/architecture/boot.md index bc6d814..f8d6d6b 100644 --- a/docs/architecture/boot.md +++ b/docs/architecture/boot.md @@ -33,13 +33,18 @@ crate; it is not part of pvKernel. `P` logo (`kernel/src/graphics/framebuffer.rs`). 13. pvKernel verifies breakpoint handling with a controlled `int3` call and confirms execution resumes afterward. -14. pvKernel unmasks PIT IRQ0, enables maskable hardware interrupts, and - confirms the tick counter advances by at least 5 ticks (see - [docs/architecture/timer.md](timer.md)). -15. If the `fault-tests` Cargo feature is enabled, pvKernel triggers a +14. Before enabling IRQ0, pvKernel initializes the bounded kernel scheduler, + pre-maps and verifies its guarded task stacks, and prepares two finite + kernel-task smoke entries. +15. pvKernel unmasks PIT IRQ0, enables maskable hardware interrupts, runs the + two tasks under timer preemption, confirms bootstrap resumes after task + exit, and verifies the tick counter advances (see + [docs/architecture/timer.md](timer.md) and + [docs/architecture/kernel-tasks.md](kernel-tasks.md)). +16. If the `fault-tests` Cargo feature is enabled, pvKernel triggers a deliberate page fault and matches it against its expected faulting address. -16. pvKernel enters an interrupt-enabled idle loop +17. pvKernel enters an interrupt-enabled idle loop (`kernel/src/halt.rs::idle_loop`), unless the `qemu-test` Cargo feature is enabled, in which case it exits QEMU deterministically instead (see [docs/development/testing.md](../development/testing.md)). @@ -51,10 +56,11 @@ crate; it is not part of pvKernel. management is a future milestone (see [ROADMAP.md](../../ROADMAP.md)). * Steps 10-11 (PIC/PIT setup) happen before the framebuffer is initialized, but every IRQ line stays masked and maskable interrupts stay disabled - until step 14, so no hardware interrupt can be delivered before the IDT, + until step 15, so no hardware interrupt can be delivered before the IDT, PIC, and PIT are all fully configured. * The boot flow above matches `cargo run`. `cargo run --features - fault-tests` performs one additional step after the timer verification: a + fault-tests` performs one additional step after the scheduler/timer + verification: a deliberate page fault, described in [docs/development/debugging.md](../development/debugging.md). * `cargo run --features qemu-test -- --headless-test` (optionally combined diff --git a/docs/architecture/interrupts.md b/docs/architecture/interrupts.md index ec1dfbf..5c1bd43 100644 --- a/docs/architecture/interrupts.md +++ b/docs/architecture/interrupts.md @@ -55,7 +55,7 @@ release; every other line stays masked for its entire lifetime. | General protection fault | `general_protection_fault_handler` | Logs the event, the CPL it originated from, error code, and stack frame, then calls `fatal_exit` — unless it matches an armed `userspace-privilege-fault-test` expectation, in which case it exits QEMU successfully instead (see [docs/development/userspace-testing.md](../development/userspace-testing.md)). | | Page fault | `page_fault_handler` | Logs CR2 (faulting address), the CPL it originated from, the page-fault error flags, and the stack frame. Under `qemu-test`, an expected deliberate fault (including the 0.0.6 `userspace-supervisor-fault-test` scenario) exits QEMU successfully instead; otherwise calls `fatal_exit`. | | Double fault | `double_fault_handler` | Runs on the dedicated IST stack; logs the event, error code, and stack frame, then calls `fatal_exit`. Return type is `!`. | -| Timer (IRQ0 / vector 32) | `timer_interrupt_handler` | Records a tick (`pit::record_tick`) and sends EOI. No logging, to avoid stalling or recursing while interrupting arbitrary kernel state. | +| Timer (IRQ0 / vector 32) | `arch::x86_64::task::timer_irq_entry` | Assembly saves every GPR and the long-mode interrupt frame, calls the bounded Rust round-robin decision path for CPL0 frames, records one tick, and sends one EOI. CPL3 frames are returned unchanged. No allocation, ordinary logging, or blocking occurs in the IRQ path. | | `Lpt1`/IRQ7 (vector 39) | `master_spurious_interrupt_handler` | Checks the master's in-service register; sends EOI only if the interrupt was real, and records it as unexpected either way. | | `SecondaryAta`/IRQ15 (vector 47) | `slave_spurious_interrupt_handler` | Checks the slave's in-service register; sends master-only EOI if spurious, full slave+master EOI if real, and records real ones as unexpected. | | Every other remapped IRQ (1-6, 8-14, vectors 33-38, 40-46) | generated by `generic_irq_handler!` | Records the IRQ as unexpected and sends EOI. These lines stay masked in 0.0.4, so reaching one of these handlers indicates a spurious or misbehaving device, not normal operation. | diff --git a/docs/architecture/kernel-tasks.md b/docs/architecture/kernel-tasks.md new file mode 100644 index 0000000..e62e4b5 --- /dev/null +++ b/docs/architecture/kernel-tasks.md @@ -0,0 +1,233 @@ +# pvKernel 0.0.8 kernel task and scheduler design + +## Scope and inspected baseline + +This design is for pvKernel 0.0.8 only: single CPU, ring-0 kernel tasks, +bounded preemptive round-robin scheduling. It does not add processes, user +task scheduling, CR3 switching, blocking, or SMP. + +The implementation baseline is the stable v0.0.7 commit +`1621819015def0b1cca8d7b702eab1831e5a67b8`. Inspection of +`kernel/src/arch/x86_64/interrupts.rs`, `pit.rs`, `pic.rs`, `gdt.rs`, +`syscall/entry.rs`, and `userspace/` established these current semantics: + +* PIT IRQ0 is remapped to IDT vector 32 (`IrqLine::Timer.vector()`). The + current entry is a Rust `extern "x86-interrupt"` function. It increments + `pit::TICKS` (`AtomicU64`, relaxed) and sends one master-PIC EOI. +* `set_handler_fn` installs an interrupt gate (the IDT default); on entry, + hardware clears IF. Thus maskable IRQ nesting is already disabled in the + handler. The handler itself does not enable IF, allocate, log, format, or + lock. A replacement scheduler entry must retain these properties. +* The long-mode interrupt frame observed by the existing + `InterruptStackFrame` path and confirmed by QEMU's synthetic-context probe + includes `RIP`, `CS`, `RFLAGS`, `RSP`, and `SS`. A CPL3-to-CPL0 interrupt + additionally switches to TSS.RSP0, while same-CPL interrupts use the + current stack. QEMU confirmed `iretq` consumes the RSP/SS words for a + synthetic CPL0 return: omitting them faults at the stack top. A dedicated + entry still owns the saved context explicitly and avoids the Rust + handler's compiler-managed frame. +* CPL3 payloads run with PIT IRQ0 enabled. The syscall test observes a tick + that interrupted CPL3 via a relaxed atomic set by the timer handler. A + kernel scheduler must therefore inspect saved `CS.RPL` and skip scheduling + on CPL3-origin IRQs while still recording the tick and sending EOI. +* The PIC's EOI helper writes the appropriate PIC command port. For IRQ0 it + sends one master EOI. EOI is currently sent after recording the tick. +* The kernel has no task scheduler yet. It initializes the GDT/TSS and IDT + with interrupts disabled, then validates paging, initializes the frame + allocator/page-table access and heap, then the PIC/PIT. `kernel_main` + enables IF and waits on PIT ticks before entering a `hlt` idle loop. +* `without_interrupts` from `x86_64` saves/restores the prior IF state; + nesting is safe because an inner call observes IF=0 and does not re-enable + interrupts on return. Scheduler/task state APIs must use it around their + spin lock. The heap allocator and page-table access already do this. +* Serial output uses a `try_lock` and skips output when contended; it does not + spin while holding the serial lock. It is not suitable for the IRQ hot path. + Framebuffer boot output has no persistent scheduler-facing lock. Heap and + page-table critical sections mask interrupts. +* `x86_64-unknown-none` for the pinned nightly reports `disable-redzone: true` + and features `-mmx,-sse,-sse2,-sse3,-ssse3,-sse4.1,-sse4.2,-avx,-avx2,+soft-float`. + The Rust ABI is soft-float. This provides the 0.0.8 FPU/SIMD policy: kernel + task code is compiled without compiler-generated FPU/SIMD use. Do not add + target features that enable it without adding eager state preservation. +* The target's 16-byte stack alignment is enforced at the Rust call boundary. + Synthetic task contexts enter an assembly trampoline which makes an ABI + call with a 16-byte-aligned pre-call RSP. The trampoline owns task return. +* The kernel uses `RFLAGS` as an interrupt-return image. Initial contexts use + reserved bit 1 set, IF set, and clear IOPL, DF, and TF. Interrupts remain + disabled while scheduler bookkeeping and the actual context selection run; + IF becomes enabled only as part of restoring a task's frame with `iretq`. + +These findings rule out switching by returning from the current +`extern "x86-interrupt"` Rust handler: its compiler-managed frame and the +different stack source for CPL0/CPL3 hardware frames make an arbitrary stack +swap there unsafe. The scheduler design uses a dedicated IRQ0 assembly entry that saves +the machine state in one documented format, calls a small Rust policy +function with an ABI-aligned stack, and restores the selected frame with +`iretq`. Other exception and IRQ handlers remain unchanged. + +## Task IDs, states, and storage + +`TaskId` is a checked pair of a bounded slot index and a nonzero `u64` +generation. It is never a pointer. Every slot has a generation counter. On +reuse, generation advances with `checked_add`; a slot whose generation would +overflow is permanently retired. Lookup validates the index, generation, +and slot state before accessing a TCB. Old IDs return a stale/invalid-ID +error. + +The task table and ready queue are fixed-size arrays with capacity +`MAX_KERNEL_TASKS = 8`. Eight tasks are sufficient for the scheduler tests +and early kernel tasks while keeping static metadata and pre-mapped stack +memory bounded. The TCB stores ID, lifecycle state, saved context pointer, +stack slot, and queue-membership flag. The initial entry pointer lives in +the synthetic RDI register until the assembly trampoline consumes it. No +scheduler metadata operation allocates. + +States are `Vacant`, `Ready`, `Running`, `Exiting`, `Exited`, and `Retired`. +Exactly one ordinary task can be `Running`; the bootstrap context is a +separate scheduler-owned context and is the idle fallback. Ready tasks are +queued exactly once. A Running, Exiting, Exited, Vacant, or Retired task is +not queued. Invalid queue/state relationships are kernel-fatal invariants. + +## Task stacks and ownership + +The reserved virtual region begins at `0xffff_9000_2000_0000`. Each of the +eight slots occupies six 4 KiB pages: one unmapped lower guard, four mapped +stack pages (16 KiB), and one unmapped upper guard. Region end and every slot +address are calculated with checked arithmetic and canonical/page-alignment +validation. The whole region is checked against existing mappings before +mapping; any collision or mapping failure aborts scheduler initialization. +This range is separate from the v0.0.7 temporary mapping probe at +`0xffff_9000_0000_0000`, the 1 MiB heap at `0xffff_9000_1000_0000`, and the +low userspace test range at `0x0000_6000_0000_0000`; bootloader-selected +mappings are still checked at runtime. + +Stacks grow down. The low-address guard is the overflow boundary; the +high-address guard prevents accidental use beyond the initial top. Initial +RSP is the exclusive high end of the mapped pages, aligned to 16 bytes before +the synthetic interrupt frame is placed. Every mapped page must translate +as present, writable, supervisor-only, and NX; both guards must translate as +not mapped. These checks inspect actual page-table translations after +mapping. + +All stack slots are pre-mapped during scheduler initialization, before IRQ0 +can schedule tasks. Their physical frames transfer to the task-stack +subsystem and remain owned for kernel lifetime; slot reuse reuses those same +backing pages. Reusing a slot first clears its mapped stack bytes before the +new task context is published. No frames are returned to the monotonic boot +allocator. + +## Context frame and switch boundary + +Preemption occurs only at the PIT IRQ0 return boundary. The timer assembly +entry saves all 15 general-purpose registers in a fixed software prefix, +followed by the hardware frame. With the saved-context pointer at the lowest +address, the intended layout is: + +```text +low addresses / saved RSP + r15, r14, r13, r12, r11, r10, r9, r8 + rdi, rsi, rbp, rdx, rcx, rbx, rax + RIP + CS + RFLAGS + RSP + SS +high addresses +``` + +The precise push order and offsets in the assembly implementation are the +source of truth and must match named constants / compile-time layout checks. +In the inspected long-mode configuration `iretq` consumes all five hardware +frame words for CPL0 and CPL3 returns. CPL3-origin IRQs use the existing +no-switch return path and their frame is preserved untouched. No CPL3 frame +is interpreted as a kernel task context. Synthetic CPL0 contexts include +explicit RSP and the kernel's null SS value; initial RSP points at the +aligned stack top. + +Before calling Rust, assembly retains the interrupted saved-context pointer, +aligns a temporary call stack to the SysV x86_64 16-byte ABI, and calls only +the allocation-free scheduler decision function. The Rust call returns the +selected context pointer. The lock has been released before assembly adopts +that pointer. IRQ0 EOI is sent exactly once after tick recording and the +scheduler decision, before restoring the selected context. IF remains clear +until `iretq` restores the selected task's flags. Assembly executes `cld` +before calling Rust to satisfy the ABI direction-flag invariant; `iretq` +restores the interrupted task's original DF value from RFLAGS. + +Initial task contexts contain defined zero values for all GPRs, the kernel +code selector, a deliberate safe RFLAGS image, and the task-entry assembly +trampoline as RIP, the trusted `fn()` entry pointer in RDI, the aligned stack +top in RSP, and the kernel's null SS value. The trampoline makes the Rust +call with correct ABI stack alignment. If the entry returns, it calls the +non-returning task-exit path; it never returns into a fabricated or +uninitialized address. + +The task stack uses `iretq` to enter the trampoline. The trampoline then +establishes ordinary Rust call alignment explicitly. Task code returning +marks its task `Exiting` with interrupts masked and waits for the next PIT +IRQ to switch away. Only after the IRQ has saved the exiting frame and +selected another context is that task marked `Exited`, proving no instruction +continues on its stack. The IRQ gate keeps IF clear through the assembly +restore, so no ordinary spawn API can run between marking Exited and leaving +that stack. A later spawn can reuse the Exited slot and stack; no task can +reuse its own active stack. + +## Queue and scheduling policy + +The runnable queue is a fixed-size ring of task slot/generation identities. +Each TCB's `queued` flag prevents duplicate insertion in O(1); dequeue +validates ID generation and Ready state. Capacity is bounded by eight and +normal enqueue/dequeue is O(1). Queue corruption is fatal, while spawning +when all slots are occupied returns deterministic capacity exhaustion. +Wrap-around is explicitly covered by tests. + +Round-robin uses a one-tick quantum at the existing approximately 99 Hz PIT +rate. If the current task remains runnable at IRQ0, it transitions to Ready +and is enqueued once, then the next Ready task is selected. If no Ready task +exists, bootstrap/idle is selected. A task is not switched to itself when +there is no other runnable work. The tick counter is not reset by scheduler +initialization. + +## Bootstrap, idle, locks, and timer behavior + +The pre-scheduler boot execution is a special bootstrap/idle context, not a +spawned task and not a queue member. Its first scheduler-managed timer IRQ +saves its actual stack/context; if there is no runnable work, the same +context resumes. After boot checks and task smoke tests, idle uses `hlt`; a +PIT interrupt wakes it. While ordinary tasks exist, timer IRQs round-robin +between their saved contexts and bootstrap. There is no busy-spin idle loop. + +Scheduler state APIs mask interrupts before acquiring the scheduler spin +lock, and restore the prior IF state on return. This prevents a task from +being interrupted while holding that lock and deadlocking in IRQ context. +IRQ policy runs with IF already clear due to the interrupt gate. The lock is +released before context selection is handed to assembly. No lock guard, +allocator call, logging, blocking, page mapping, or unbounded scan crosses +the switch boundary. + +Kernel code must mask interrupts around any scheduler-unsafe spin lock held +across arbitrary work, because a timer preemption while holding such a lock +could let another task deadlock on the same single CPU. Existing heap and +page-table locks already mask interrupts. Serial uses nonblocking `try_lock` +and drops output under contention. Additions must follow the same rule. + +IRQ0 handling increments the existing monotonic tick once, optionally +records the existing CPL3 test observation, makes a scheduling decision only +for CPL0-origin interrupts, sends exactly one PIC EOI, and restores the +selected interrupt frame. The scheduler does not enable interrupts itself. +No scheduler task is scheduled from a CPL3 IRQ. The existing provisional +`int 0x80` path and TSS.RSP0 remain unchanged. + +## Failure, panic, and limitations + +Task table exhaustion is a normal deterministic spawn error. Invalid +virtual ranges, mapping failures, wrong stack flags, guard collisions, +invalid contexts, and state/queue corruption are fatal kernel invariants. +Panic remains kernel-fatal because panic strategy is abort; there is no +per-task unwind/recovery. + +Limitations: one CPU, 8259 PIC/PIT only, eight fixed task slots, 16 KiB +fixed-size stacks, fixed virtual stack region, no stack growth, no blocking, +sleep, wait queues, join, priorities, user-task scheduling, CR3 switching, +SMP, or physical-frame reclamation. Compiler-generated FPU/SIMD is disabled +for this target; enabling it requires eager context preservation first. diff --git a/docs/architecture/timer.md b/docs/architecture/timer.md index a25fd19..a9b19e6 100644 --- a/docs/architecture/timer.md +++ b/docs/architecture/timer.md @@ -43,7 +43,8 @@ below). ## Tick counter `pit::TICKS` is an `AtomicU64` incremented by exactly one -(`Ordering::Relaxed`) each time `timer_interrupt_handler` (vector 32, IRQ0) +(`Ordering::Relaxed`) each time the dedicated assembly IRQ0 entry +(`arch::x86_64::task::timer_irq_entry`, vector 32) runs, via `pit::record_tick()`. `Relaxed` ordering is sufficient because the counter is only ever read as a plain numeric observation (has at least N ticks elapsed?); it is not used to publish or synchronize any other memory @@ -66,9 +67,8 @@ the call never returns. ## Enabling the timer interrupt -`kernel_main` performs a controlled, boot-time verification that timer -interrupts are actually being delivered, after the breakpoint test and -before the optional page-fault test: +`kernel_main` prepares finite scheduler smoke tasks before unmasking IRQ0, +then verifies that timer interrupts are being delivered: 1. record `ticks_before`; 2. `pic::unmask(IrqLine::Timer)` — enables IRQ0 specifically, leaving all @@ -76,8 +76,10 @@ before the optional page-fault test: 3. `x86_64::instructions::interrupts::enable()` — sets the CPU's maskable interrupt flag, the first point at which any hardware interrupt can actually reach the CPU in pvKernel's boot flow; -4. `pit::wait_for_ticks(TIMER_TEST_TICKS)` (5 ticks); -5. record `ticks_after` and log the observed tick advance and approximate +4. restore IF; timer preemption can now occur only at IRQ0 return; +5. `pit::wait_for_ticks(TIMER_TEST_TICKS)` (5 ticks), during which both + smoke tasks run and return to bootstrap; +6. record `ticks_after` and log the observed tick advance and approximate uptime. This ordering (unmask, then enable) matters: unmasking first with diff --git a/docs/architecture/virtual-memory.md b/docs/architecture/virtual-memory.md index a1151b9..024942b 100644 --- a/docs/architecture/virtual-memory.md +++ b/docs/architecture/virtual-memory.md @@ -375,6 +375,30 @@ deallocation returns space to the heap allocator; it does not return frames to the monotonic physical frame allocator. Heap mapping failures after frame allocation are fatal and do not attempt unsafe rollback. +## Kernel task stacks (pvKernel 0.0.8) + +The scheduler reserves eight fixed stack slots beginning at +`0xffff900020000000`. Each slot occupies 24 KiB of virtual address space: +one lower unmapped guard page, four mapped pages (16 KiB stack), and one +upper unmapped guard page. The full region ends at +`0xffff900020030000`. Initialization checks the entire range for existing +mappings before mapping any stack and validates canonicality and checked +arithmetic. + +Every stack page is present, writable, supervisor-only, and NX. Both guard +pages stay unmapped. The subsystem verifies actual translations for each +slot's first, middle, and last stack pages and both guards after mapping. +The high mapped address is the exclusive initial stack top; x86_64 stacks +grow downward toward the lower guard. + +The stack subsystem pre-maps all slots before scheduler activation. Physical +frames remain owned by the task-stack subsystem for kernel lifetime. A task +slot may reuse its existing virtual mapping/backing only after an IRQ context +switch has stopped executing on the previous task's stack. No stack backing +frame is returned to the monotonic boot frame allocator. See +[docs/architecture/kernel-tasks.md](kernel-tasks.md) for context, lifecycle, +and preemption details. + ## Future work and known limitations * No general physical-frame deallocation/reuse — heap backing frames remain diff --git a/docs/development/testing.md b/docs/development/testing.md index 4c6a250..8d4def6 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -23,15 +23,17 @@ status byte to QEMU's `isa-debug-exit` I/O port (`0xF4`) and never return (falling back to `halt::halt_forever` if QEMU somehow does not exit). With `qemu-test` enabled: -* `kernel_main` calls `qemu::exit_success()` once the boot-time timer - verification (see [docs/architecture/timer.md](../architecture/timer.md)) - and, if `fault-tests` is also enabled, the expected page-fault check - complete successfully, instead of entering the normal idle loop. +* `kernel_main` calls `qemu::exit_success()` once task smoke, boot-time timer + verification (see [docs/architecture/timer.md](../architecture/timer.md)), + and any selected finite scheduler scenario complete, instead of entering + the normal idle loop. Expected-fault scenarios exit from their matching + handler after validating the fault. * the panic handler calls `qemu::exit_failure()` instead of `halt::halt_forever()`. -* `interrupts::page_fault_handler` recognizes a deliberately triggered fault - (address and in-flight flag set by `run_page_fault_test`) and calls - `qemu::exit_success()` directly, instead of treating it as fatal. +* `interrupts::page_fault_handler` recognizes deliberately armed expected + faults, including the kernel-task stack guard scenario, and calls + `qemu::exit_success()` directly instead of treating a matching fault as + fatal. Without `qemu-test`, none of this code is compiled in at all — a plain `cargo build`/`cargo run` never links against the isa-debug-exit port. @@ -76,6 +78,18 @@ timeout 60 cargo run --features qemu-test -- --headless-test # Same as above, plus a deliberate page fault matched against its expected # faulting address, exercised after the timer test. timeout 60 cargo run --features "qemu-test fault-tests" -- --headless-test + +# Two finite kernel tasks, guarded stacks, exit, and bootstrap resume. +timeout 60 cargo run --features "qemu-test scheduler-test" -- --headless-test + +# Two CPU-bound tasks that can finish only after IRQ-driven preemption. +timeout 60 cargo run --features scheduler-preemption-test -- --headless-test + +# Expected task stack lower-guard page fault. +timeout 60 cargo run --features task-stack-guard-fault-test -- --headless-test + +# Fixed task capacity, safe stack-slot reuse, and stale TaskId rejection. +timeout 60 cargo run --features "qemu-test task-capacity-test" -- --headless-test ``` Both commands should print the full boot log to standard output and exit @@ -92,6 +106,9 @@ See [docs/development/memory-testing.md](memory-testing.md) for the `userspace-privilege-fault-test`, `userspace-supervisor-fault-test`), which build on this same mechanism. +The timer IRQ context-switch boundary and kernel-task limitations are +documented in [docs/architecture/kernel-tasks.md](../architecture/kernel-tasks.md). + ## CI integration `.github/workflows/ci.yml`'s `qemu-tests` job installs `qemu-system-x86` and diff --git a/kernel/Cargo.toml b/kernel/Cargo.toml index 408da46..39e6492 100644 --- a/kernel/Cargo.toml +++ b/kernel/Cargo.toml @@ -61,6 +61,12 @@ heap-test = [] heap-oom-test = ["heap-test"] # Reads the unmapped heap guard page and expects a supervisor #PF (QEMU only). heap-guard-fault-test = ["heap-test"] +# Deterministic kernel-task scheduling coverage; the normal boot smoke also +# exercises two tasks, while this feature gives it a dedicated QEMU job. +scheduler-test = [] +scheduler-preemption-test = ["qemu-test"] +task-stack-guard-fault-test = ["qemu-test"] +task-capacity-test = [] [dependencies] bootloader_api = "=0.11.17" diff --git a/kernel/build.rs b/kernel/build.rs new file mode 100644 index 0000000..d83661c --- /dev/null +++ b/kernel/build.rs @@ -0,0 +1,25 @@ +use std::env; + +fn main() { + let target = env::var("TARGET").expect("Cargo did not report the kernel target triple"); + assert_eq!( + target, "x86_64-unknown-none", + "the kernel task scheduler is validated only for x86_64-unknown-none" + ); + let architecture = env::var("CARGO_CFG_TARGET_ARCH") + .expect("Cargo did not report the kernel target architecture"); + assert_eq!(architecture, "x86_64", "kernel tasks require x86_64"); + + let enabled = env::var("CARGO_CFG_TARGET_FEATURE") + .expect("Cargo did not report enabled kernel target features"); + for forbidden in [ + "mmx", "sse", "sse2", "sse3", "ssse3", "sse4.1", "sse4.2", "avx", "avx2", + ] { + assert!( + !enabled.split(',').any(|feature| feature == forbidden), + "kernel task preemption requires {forbidden} to remain disabled" + ); + } + println!("cargo:rerun-if-env-changed=TARGET"); + println!("cargo:rerun-if-env-changed=CARGO_CFG_TARGET_FEATURE"); +} diff --git a/kernel/src/arch/x86_64/gdt.rs b/kernel/src/arch/x86_64/gdt.rs index 36ae5d9..32c6b4f 100644 --- a/kernel/src/arch/x86_64/gdt.rs +++ b/kernel/src/arch/x86_64/gdt.rs @@ -268,6 +268,15 @@ pub fn user_code_selector() -> SegmentSelector { .user_code_selector } +/// Returns the ring-0 code selector installed by [`init`], for constructing +/// synthetic kernel-task interrupt-return frames. +pub fn kernel_code_selector() -> SegmentSelector { + GDT.get() + .expect("gdt::init has not run yet") + .1 + .code_selector +} + /// Returns the ring-3 user data segment selector (RPL 3) installed by /// [`init`]. Used for the `SS` (and, informally, `DS`/`ES`) value in an /// `iretq` frame that enters CPL3. diff --git a/kernel/src/arch/x86_64/interrupts.rs b/kernel/src/arch/x86_64/interrupts.rs index 3432ade..2cec4a4 100644 --- a/kernel/src/arch/x86_64/interrupts.rs +++ b/kernel/src/arch/x86_64/interrupts.rs @@ -48,7 +48,13 @@ pub fn init() { .set_stack_index(DOUBLE_FAULT_IST_INDEX); } - idt[IrqLine::Timer.vector()].set_handler_fn(timer_interrupt_handler); + // SAFETY: the dedicated assembly entry saves all GPRs and the + // interrupt-return frame before calling Rust; unlike the Rust + // `x86-interrupt` ABI, it can return a different saved context. + unsafe { + idt[IrqLine::Timer.vector()] + .set_handler_addr(VirtAddr::new(super::task::timer_irq_entry())); + } idt[IrqLine::Keyboard.vector()].set_handler_fn(irq1_handler); idt[IrqLine::Cascade.vector()].set_handler_fn(irq2_handler); idt[IrqLine::Com2.vector()].set_handler_fn(irq3_handler); @@ -108,6 +114,22 @@ static EXPECTED_PAGE_FAULT: AtomicBool = AtomicBool::new(false); #[cfg(feature = "qemu-test")] static EXPECTED_PAGE_FAULT_ADDRESS: AtomicU64 = AtomicU64::new(0); +#[cfg(all(feature = "qemu-test", feature = "task-stack-guard-fault-test"))] +static EXPECTED_TASK_GUARD_FAULT_ADDRESS: AtomicU64 = AtomicU64::new(0); +#[cfg(all(feature = "qemu-test", feature = "task-stack-guard-fault-test"))] +static EXPECTED_TASK_GUARD_FAULT_ARMED: AtomicBool = AtomicBool::new(false); + +#[cfg(feature = "task-stack-guard-fault-test")] +pub(crate) fn arm_expected_task_stack_guard_fault(address: u64) { + #[cfg(feature = "qemu-test")] + { + EXPECTED_TASK_GUARD_FAULT_ADDRESS.store(address, Ordering::Relaxed); + EXPECTED_TASK_GUARD_FAULT_ARMED.store(true, Ordering::Relaxed); + } + #[cfg(not(feature = "qemu-test"))] + let _ = address; +} + /// Set while the `userspace-privilege-fault-test` scenario's deliberate /// CPL3 `cli` fault is in flight. Only meaningful under `qemu-test`; without /// it, the fault is fatal and the kernel halts, matching the existing @@ -303,6 +325,21 @@ extern "x86-interrupt" fn page_fault_handler( let faulting_address = Cr2::read_raw(); let cpl = stack_frame.code_segment.rpl(); + #[cfg(all(feature = "qemu-test", feature = "task-stack-guard-fault-test"))] + if EXPECTED_TASK_GUARD_FAULT_ARMED.load(Ordering::Relaxed) + && faulting_address == EXPECTED_TASK_GUARD_FAULT_ADDRESS.load(Ordering::Relaxed) + && cpl == x86_64::PrivilegeLevel::Ring0 + && !error_code.contains(PageFaultErrorCode::PROTECTION_VIOLATION) + && !error_code.contains(PageFaultErrorCode::USER_MODE) + && !error_code.contains(PageFaultErrorCode::CAUSED_BY_WRITE) + && !error_code.contains(PageFaultErrorCode::INSTRUCTION_FETCH) + { + EXPECTED_TASK_GUARD_FAULT_ARMED.store(false, Ordering::Relaxed); + kprintln!("[exception] expected task guard page fault matched at {faulting_address:#x}"); + kprintln!("[scheduler-test] task stack guard fault passed"); + qemu::exit_success(); + } + #[cfg(all(feature = "qemu-test", feature = "supervisor-write-protection-test"))] if take_expected_write_protection_fault_if_matches(faulting_address, cpl, error_code) { kprintln!("[exception] page fault"); @@ -377,27 +414,6 @@ extern "x86-interrupt" fn double_fault_handler( fatal_exit(); } -/// IRQ0 / vector 32. Records the tick before sending EOI, so a tick is never -/// counted as delivered before the kernel has actually observed it. Must not -/// log, allocate, lock, or format text: any of those could stall or recurse -/// while interrupting arbitrary kernel state. -/// -/// Under `userspace-syscall-test`, also records (in a single atomic store, -/// no lock, no formatting) whether this tick interrupted CPL3 execution - -/// this is the only instrumentation the userspace syscall test needs, and -/// it must stay this cheap so the fast path above is never affected. -#[cfg_attr(not(feature = "userspace-syscall-test"), allow(unused_variables))] -extern "x86-interrupt" fn timer_interrupt_handler(stack_frame: InterruptStackFrame) { - super::pit::record_tick(); - - #[cfg(feature = "userspace-syscall-test")] - if stack_frame.code_segment.rpl() == x86_64::PrivilegeLevel::Ring3 { - super::syscall::test::TIMER_INTERRUPTED_CPL3.store(true, Ordering::Relaxed); - } - - pic::notify_end_of_interrupt(IrqLine::Timer); -} - /// IRQ7 / vector 39. A spurious IRQ7 is raised by the master PIC itself (for /// example due to electrical noise) without a real device requesting /// service; the in-service register distinguishes the two cases. diff --git a/kernel/src/arch/x86_64/mod.rs b/kernel/src/arch/x86_64/mod.rs index ff5676b..1b17df9 100644 --- a/kernel/src/arch/x86_64/mod.rs +++ b/kernel/src/arch/x86_64/mod.rs @@ -6,6 +6,7 @@ pub mod pit; #[cfg(feature = "qemu-test")] pub mod qemu; pub mod syscall; +pub mod task; #[cfg(feature = "userspace-test")] pub mod userspace; diff --git a/kernel/src/arch/x86_64/task/context.rs b/kernel/src/arch/x86_64/task/context.rs new file mode 100644 index 0000000..66a31a2 --- /dev/null +++ b/kernel/src/arch/x86_64/task/context.rs @@ -0,0 +1,33 @@ +/// General registers followed by the hardware long-mode interrupt frame. +/// The timer entry's push order and these offsets are kept in sync here. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct Context { + pub r15: u64, + pub r14: u64, + pub r13: u64, + pub r12: u64, + pub r11: u64, + pub r10: u64, + pub r9: u64, + pub r8: u64, + pub rdi: u64, + pub rsi: u64, + pub rbp: u64, + pub rdx: u64, + pub rcx: u64, + pub rbx: u64, + pub rax: u64, + pub rip: u64, + pub cs: u64, + pub rflags: u64, + pub rsp: u64, + pub ss: u64, +} + +const _: () = assert!(core::mem::size_of::() == 20 * 8); +const _: () = assert!(core::mem::offset_of!(Context, rip) == 15 * 8); +const _: () = assert!(core::mem::offset_of!(Context, cs) == 16 * 8); +const _: () = assert!(core::mem::offset_of!(Context, rflags) == 17 * 8); +const _: () = assert!(core::mem::offset_of!(Context, rsp) == 18 * 8); +const _: () = assert!(core::mem::offset_of!(Context, ss) == 19 * 8); diff --git a/kernel/src/arch/x86_64/task/mod.rs b/kernel/src/arch/x86_64/task/mod.rs new file mode 100644 index 0000000..c5725ce --- /dev/null +++ b/kernel/src/arch/x86_64/task/mod.rs @@ -0,0 +1,7 @@ +mod context; +mod stack; +mod switch; + +pub use context::Context; +pub use stack::{MAX_KERNEL_TASKS, STACK_SIZE, init_stacks, stack_bounds, verify_stack_mappings}; +pub use switch::{task_trampoline, timer_irq_entry}; diff --git a/kernel/src/arch/x86_64/task/stack.rs b/kernel/src/arch/x86_64/task/stack.rs new file mode 100644 index 0000000..81185f5 --- /dev/null +++ b/kernel/src/arch/x86_64/task/stack.rs @@ -0,0 +1,159 @@ +use x86_64::{ + VirtAddr, + structures::paging::{Page, PageTableFlags, Size4KiB}, +}; + +use crate::{ + arch::x86_64::memory::{Permissions, Translation, with_kernel_address_space}, + kprintln, +}; + +pub const MAX_KERNEL_TASKS: usize = 8; +pub const STACK_SIZE: u64 = 16 * 1024; +const PAGE_SIZE: u64 = 4096; +const GUARD_PAGES: u64 = 2; +const SLOT_PAGES: u64 = STACK_SIZE / PAGE_SIZE + GUARD_PAGES; +pub const STACK_REGION_START: u64 = 0xffff_9000_2000_0000; +const STACK_REGION_SIZE: u64 = stack_region_size(); + +const fn stack_region_size() -> u64 { + let slot_size = match SLOT_PAGES.checked_mul(PAGE_SIZE) { + Some(size) => size, + None => panic!("task stack slot size overflows"), + }; + match slot_size.checked_mul(MAX_KERNEL_TASKS as u64) { + Some(size) => size, + None => panic!("task stack region size overflows"), + } +} + +pub fn init_stacks() { + let end = STACK_REGION_START + .checked_add(STACK_REGION_SIZE) + .expect("task stack virtual region overflow"); + let last_address = end.checked_sub(1).expect("task stack region is empty"); + assert_eq!(STACK_REGION_START % PAGE_SIZE, 0); + assert!(VirtAddr::try_new(STACK_REGION_START).is_ok()); + assert!(VirtAddr::try_new(last_address).is_ok()); + kprintln!("[scheduler] stack region: {STACK_REGION_START:#x}..{end:#x}"); + with_kernel_address_space(|space| { + for slot in 0..MAX_KERNEL_TASKS { + let (low, high) = bounds_unchecked(slot); + let lower_guard = low + .checked_sub(PAGE_SIZE) + .expect("lower stack guard underflow"); + let upper_guard = high; + for address in (lower_guard..=upper_guard).step_by(PAGE_SIZE as usize) { + assert!( + matches!( + space.translate(VirtAddr::new(address)), + Translation::NotMapped + ), + "task stack or guard page already mapped at {address:#x}" + ); + } + } + for slot in 0..MAX_KERNEL_TASKS { + let (low, high) = bounds_unchecked(slot); + let middle = low + .checked_add(STACK_SIZE / 2) + .expect("middle stack address overflow"); + let last = high + .checked_sub(PAGE_SIZE) + .expect("last stack page underflow"); + let pages = usize::try_from(STACK_SIZE / PAGE_SIZE).expect("stack page count overflow"); + for index in 0..pages { + let offset = u64::try_from(index) + .ok() + .and_then(|index| index.checked_mul(PAGE_SIZE)) + .expect("stack page offset overflow"); + let address = low.checked_add(offset).expect("stack page overflow"); + let page = Page::::from_start_address(VirtAddr::new(address)) + .expect("task stack page is unaligned"); + space + .map_new_page(page, Permissions::KERNEL_DATA) + .unwrap_or_else(|error| { + panic!("failed to map task stack page {address:#x}: {error}") + }); + } + for address in [low, middle, last] { + match space.translate(VirtAddr::new(address)) { + Translation::Mapped { flags, .. } => { + assert!(flags.contains( + PageTableFlags::PRESENT + | PageTableFlags::WRITABLE + | PageTableFlags::NO_EXECUTE + )); + assert!(!flags.contains(PageTableFlags::USER_ACCESSIBLE)); + } + other => panic!("task stack mapping missing at {address:#x}: {other:?}"), + } + } + assert!(matches!( + space.translate(VirtAddr::new( + low.checked_sub(PAGE_SIZE) + .expect("lower stack guard underflow") + )), + Translation::NotMapped + )); + assert!(matches!( + space.translate(VirtAddr::new(high)), + Translation::NotMapped + )); + } + }); + kprintln!("[scheduler] task stacks verified supervisor RW/NX with guard pages"); +} + +pub fn stack_bounds(slot: usize) -> Option<(u64, u64)> { + (slot < MAX_KERNEL_TASKS).then(|| bounds_unchecked(slot)) +} + +fn bounds_unchecked(slot: usize) -> (u64, u64) { + let stride = SLOT_PAGES + .checked_mul(PAGE_SIZE) + .expect("task stack slot overflow"); + let slot_start = STACK_REGION_START + .checked_add( + u64::try_from(slot) + .expect("task stack slot index does not fit in u64") + .checked_mul(stride) + .expect("stack slot offset overflow"), + ) + .expect("stack slot address overflow"); + let low = slot_start + .checked_add(PAGE_SIZE) + .expect("stack low address overflow"); + let high = low + .checked_add(STACK_SIZE) + .expect("stack high address overflow"); + (low, high) +} + +pub fn verify_stack_mappings() { + with_kernel_address_space(|space| { + for slot in 0..MAX_KERNEL_TASKS { + let (low, high) = bounds_unchecked(slot); + let lower_guard = low + .checked_sub(PAGE_SIZE) + .expect("lower stack guard underflow"); + let middle = low + .checked_add(STACK_SIZE / 2) + .expect("middle stack address overflow"); + let last = high + .checked_sub(PAGE_SIZE) + .expect("last stack page underflow"); + for address in [lower_guard, low, middle, last, high] { + let mapped = !matches!( + space.translate(VirtAddr::new(address)), + Translation::NotMapped + ); + assert_eq!( + mapped, + address != lower_guard && address != high, + "task stack guard/mapping mismatch at {address:#x}" + ); + } + } + }); +} diff --git a/kernel/src/arch/x86_64/task/switch.rs b/kernel/src/arch/x86_64/task/switch.rs new file mode 100644 index 0000000..8e104e0 --- /dev/null +++ b/kernel/src/arch/x86_64/task/switch.rs @@ -0,0 +1,82 @@ +use core::arch::global_asm; + +use super::Context; + +// IRQ0 enters with hardware's five-word long-mode frame (RIP, CS, RFLAGS, +// RSP, SS) at the current RSP. The pushes below create the 15-word software +// prefix matching Context. Before Rust, we preserve that RSP in R12, align +// the temporary call stack down to 16 bytes, and clear DF for the ABI call. +// Rust returns a context RSP; the pops reverse the software prefix and IRETQ +// consumes the selected five-word hardware/synthetic frame. The IDT is an +// interrupt gate, so IF remains clear from entry until the selected RFLAGS +// image is restored. +global_asm!( + ".global pvkernel_timer_irq_entry", + "pvkernel_timer_irq_entry:", + "push rax", + "push rbx", + "push rcx", + "push rdx", + "push rbp", + "push rsi", + "push rdi", + "push r8", + "push r9", + "push r10", + "push r11", + "push r12", + "push r13", + "push r14", + "push r15", + "mov r12, rsp", + "mov rdi, rsp", + "and rsp, -16", + "cld", + "call pvkernel_timer_schedule", + "mov rsp, rax", + "pop r15", + "pop r14", + "pop r13", + "pop r12", + "pop r11", + "pop r10", + "pop r9", + "pop r8", + "pop rdi", + "pop rsi", + "pop rbp", + "pop rdx", + "pop rcx", + "pop rbx", + "pop rax", + "iretq", + ".global pvkernel_task_trampoline", + "pvkernel_task_trampoline:", + "and rsp, -16", + "call rdi", + "call pvkernel_task_exit", + "2: hlt", + "jmp 2b", +); + +unsafe extern "C" { + fn pvkernel_timer_irq_entry(); + fn pvkernel_task_trampoline(); +} + +pub fn timer_irq_entry() -> u64 { + pvkernel_timer_irq_entry as *const () as u64 +} +pub fn task_trampoline() -> u64 { + pvkernel_task_trampoline as *const () as u64 +} + +#[unsafe(no_mangle)] +extern "C" fn pvkernel_timer_schedule(context: *mut Context) -> *mut Context { + crate::task::timer_schedule(context) +} + +#[unsafe(no_mangle)] +extern "C" fn pvkernel_task_exit() -> ! { + crate::task::task_exit() +} diff --git a/kernel/src/main.rs b/kernel/src/main.rs index 47c839e..fe4336c 100644 --- a/kernel/src/main.rs +++ b/kernel/src/main.rs @@ -9,6 +9,7 @@ mod arch; mod drivers; mod graphics; mod halt; +mod task; use bootloader_api::config::{BootloaderConfig, Mapping}; use bootloader_api::{BootInfo, entry_point}; @@ -80,6 +81,7 @@ fn kernel_main(boot_info: &'static mut BootInfo) -> ! { arch::x86_64::memory::run_boot_smoke_test(); arch::x86_64::memory::run_heap_smoke_tests(); + task::init(); if let Some(framebuffer) = framebuffer.as_mut() { let info = framebuffer.info(); @@ -102,6 +104,8 @@ fn kernel_main(boot_info: &'static mut BootInfo) -> ! { arch::x86_64::interrupts::run_breakpoint_test(); + let scheduler_smoke_tasks = task::prepare_smoke_test(); + let ticks_before = pit::ticks(); kprintln!("[test] waiting for timer interrupts"); @@ -110,8 +114,16 @@ fn kernel_main(boot_info: &'static mut BootInfo) -> ! { x86_64::instructions::interrupts::enable(); kprintln!("[cpu] maskable hardware interrupts enabled"); + kprintln!("[scheduler] timer preemption enabled"); pit::wait_for_ticks(TIMER_TEST_TICKS); + task::run_smoke_test(scheduler_smoke_tasks.0, scheduler_smoke_tasks.1); + + #[cfg(feature = "scheduler-preemption-test")] + task::run_preemption_test(); + + #[cfg(feature = "task-capacity-test")] + task::run_capacity_test(); let ticks_after = pit::ticks(); kprintln!( @@ -146,7 +158,13 @@ fn kernel_main(boot_info: &'static mut BootInfo) -> ! { #[cfg(feature = "userspace-test")] arch::x86_64::userspace::run_test(); - #[cfg(not(feature = "userspace-test"))] + #[cfg(feature = "task-stack-guard-fault-test")] + task::run_stack_guard_fault_test(); + + #[cfg(all( + not(feature = "userspace-test"), + not(feature = "task-stack-guard-fault-test") + ))] { kprintln!("pvKernel {} initialized", env!("CARGO_PKG_VERSION")); kprintln!("entering interrupt-enabled idle loop"); diff --git a/kernel/src/task/mod.rs b/kernel/src/task/mod.rs new file mode 100644 index 0000000..3dad645 --- /dev/null +++ b/kernel/src/task/mod.rs @@ -0,0 +1,619 @@ +use core::sync::atomic::{AtomicU64, Ordering}; + +use spin::{Mutex, Once}; +use x86_64::instructions::{hlt, interrupts}; + +use crate::{ + arch::x86_64::{ + gdt, + pic::{self, IrqLine}, + pit, task as arch_task, + }, + kprintln, +}; + +pub const MAX_KERNEL_TASKS: usize = arch_task::MAX_KERNEL_TASKS; +pub type KernelTaskEntry = fn(); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TaskId { + slot: u8, + generation: u64, +} + +impl TaskId { + pub const fn slot(self) -> usize { + self.slot as usize + } + pub const fn generation(self) -> u64 { + self.generation + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TaskState { + Vacant, + Ready, + Running, + Exiting, + Exited, + Retired, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SpawnError { + SchedulerNotInitialized, + TaskTableFull, + NoTaskStackAvailable, + QueueFull, +} + +#[derive(Clone, Copy)] +struct TaskSlot { + generation: u64, + state: TaskState, + saved_rsp: u64, + queued: bool, +} + +impl TaskSlot { + const EMPTY: Self = Self { + generation: 0, + state: TaskState::Vacant, + saved_rsp: 0, + queued: false, + }; +} + +struct ReadyQueue { + ids: [Option; MAX_KERNEL_TASKS], + head: usize, + len: usize, +} + +impl ReadyQueue { + const fn new() -> Self { + Self { + ids: [None; MAX_KERNEL_TASKS], + head: 0, + len: 0, + } + } + fn push(&mut self, id: TaskId) -> Result<(), SpawnError> { + if self.len == MAX_KERNEL_TASKS { + return Err(SpawnError::QueueFull); + } + let tail = (self.head + self.len) % MAX_KERNEL_TASKS; + assert!(self.ids[tail].is_none(), "runnable queue tail was occupied"); + self.ids[tail] = Some(id); + self.len += 1; + Ok(()) + } + fn pop(&mut self) -> Option { + if self.len == 0 { + return None; + } + let id = self.ids[self.head].take(); + self.head = (self.head + 1) % MAX_KERNEL_TASKS; + self.len -= 1; + id + } +} + +struct Scheduler { + initialized: bool, + slots: [TaskSlot; MAX_KERNEL_TASKS], + queue: ReadyQueue, + current: Option, + bootstrap_rsp: u64, +} + +impl Scheduler { + const fn new() -> Self { + Self { + initialized: false, + slots: [TaskSlot::EMPTY; MAX_KERNEL_TASKS], + queue: ReadyQueue::new(), + current: None, + bootstrap_rsp: 0, + } + } +} + +static SCHEDULER: Once> = Once::new(); +static SMOKE_A: AtomicU64 = AtomicU64::new(0); +static SMOKE_B: AtomicU64 = AtomicU64::new(0); +static SMOKE_A_SP: AtomicU64 = AtomicU64::new(0); +static SMOKE_B_SP: AtomicU64 = AtomicU64::new(0); +#[cfg(feature = "scheduler-preemption-test")] +static PREEMPT_A_PROGRESS: AtomicU64 = AtomicU64::new(0); +#[cfg(feature = "scheduler-preemption-test")] +static PREEMPT_B_PROGRESS: AtomicU64 = AtomicU64::new(0); +#[cfg(feature = "scheduler-preemption-test")] +static PREEMPT_A_DONE: AtomicU64 = AtomicU64::new(0); +#[cfg(feature = "scheduler-preemption-test")] +static PREEMPT_B_DONE: AtomicU64 = AtomicU64::new(0); +#[cfg(feature = "task-capacity-test")] +static CAPACITY_DONE: AtomicU64 = AtomicU64::new(0); + +pub fn init() { + kprintln!("[scheduler] initializing"); + test_queue_wraparound(); + arch_task::init_stacks(); + arch_task::verify_stack_mappings(); + let scheduler = SCHEDULER.call_once(|| Mutex::new(Scheduler::new())); + interrupts::without_interrupts(|| { + let mut scheduler = scheduler.lock(); + assert!(!scheduler.initialized, "scheduler initialized twice"); + scheduler.initialized = true; + }); + kprintln!("[scheduler] task capacity: {MAX_KERNEL_TASKS}"); + kprintln!("[scheduler] runnable queue initialized"); + kprintln!("[scheduler] initialized"); +} + +fn test_queue_wraparound() { + let mut queue = ReadyQueue::new(); + for slot in 0..MAX_KERNEL_TASKS { + queue + .push(TaskId { + slot: slot as u8, + generation: 1, + }) + .expect("queue fill failed"); + } + assert_eq!( + queue.push(TaskId { + slot: u8::MAX, + generation: 1, + }), + Err(SpawnError::QueueFull) + ); + for expected in 0..5 { + assert_eq!( + queue.pop(), + Some(TaskId { + slot: expected, + generation: 1 + }) + ); + } + for slot in MAX_KERNEL_TASKS..MAX_KERNEL_TASKS + 5 { + queue + .push(TaskId { + slot: slot as u8, + generation: 1, + }) + .expect("queue wrap push failed"); + } + for expected in 5..MAX_KERNEL_TASKS + 5 { + assert_eq!( + queue.pop(), + Some(TaskId { + slot: expected as u8, + generation: 1 + }) + ); + } + assert_eq!(queue.pop(), None); +} + +pub fn spawn_kernel_task(entry: KernelTaskEntry) -> Result { + let scheduler = SCHEDULER.get().ok_or(SpawnError::SchedulerNotInitialized)?; + interrupts::without_interrupts(|| { + let mut scheduler = scheduler.lock(); + if !scheduler.initialized { + return Err(SpawnError::SchedulerNotInitialized); + } + let mut reusable = None; + for (index, slot) in scheduler.slots.iter_mut().enumerate() { + if !matches!(slot.state, TaskState::Vacant | TaskState::Exited) { + continue; + } + if let Some(generation) = slot.generation.checked_add(1) { + reusable = Some((index, generation)); + break; + } + slot.state = TaskState::Retired; + } + let (index, generation) = reusable.ok_or(SpawnError::TaskTableFull)?; + assert!( + !scheduler.slots[index].queued, + "reusable task slot still queued" + ); + let id = TaskId { + slot: u8::try_from(index).expect("task capacity exceeds TaskId slot"), + generation, + }; + let (_, top) = arch_task::stack_bounds(index).ok_or(SpawnError::NoTaskStackAvailable)?; + let context_size = u64::try_from(core::mem::size_of::()) + .expect("task context size does not fit in u64"); + let context_addr = top + .checked_sub(context_size) + .ok_or(SpawnError::NoTaskStackAvailable)?; + if context_addr % 16 != 0 { + return Err(SpawnError::NoTaskStackAvailable); + } + let context = arch_task::Context { + r15: 0, + r14: 0, + r13: 0, + r12: 0, + r11: 0, + r10: 0, + r9: 0, + r8: 0, + rdi: entry as usize as u64, + rsi: 0, + rbp: 0, + rdx: 0, + rcx: 0, + rbx: 0, + rax: 0, + rip: arch_task::task_trampoline(), + cs: u64::from(gdt::kernel_code_selector().0), + rflags: 0x202, + rsp: top, + ss: 0, + }; + let (stack_low, _) = + arch_task::stack_bounds(index).ok_or(SpawnError::NoTaskStackAvailable)?; + // SAFETY: the slot's mapped pages were verified writable at init; + // the slot is Vacant/Exited and interrupts are masked under the + // scheduler lock, so no CPU context can be using this stack. Clear + // old task data before publishing the slot as Ready. + let stack_size = + usize::try_from(arch_task::STACK_SIZE).expect("task stack size does not fit in usize"); + unsafe { + core::ptr::write_bytes(stack_low as *mut u8, 0, stack_size); + } + // SAFETY: init verified this slot's mapped stack pages writable and + // supervisor-only; the slot is Vacant/Exited and cannot be active. + unsafe { + (context_addr as *mut arch_task::Context).write(context); + } + scheduler.queue.push(id)?; + scheduler.slots[index] = TaskSlot { + generation, + state: TaskState::Ready, + saved_rsp: context_addr, + queued: true, + }; + Ok(id) + }) +} + +pub fn task_state(id: TaskId) -> Option { + let scheduler = SCHEDULER.get()?; + interrupts::without_interrupts(|| { + let scheduler = scheduler.lock(); + let slot = scheduler.slots.get(id.slot())?; + (slot.generation == id.generation && slot.state != TaskState::Vacant).then_some(slot.state) + }) +} + +/// Called only by the assembly IRQ0 boundary with IF cleared by the IDT +/// interrupt gate. The pointer is the exact register+hardware frame created +/// by `switch.rs`; CPL3 frames are returned untouched after tick/EOI work. +pub(crate) fn timer_schedule(context: *mut arch_task::Context) -> *mut arch_task::Context { + pit::record_tick(); + // SAFETY: assembly passes the address of its just-saved register prefix; + // the hardware frame immediately follows it and is readable here. + let from_user = unsafe { (*context).cs & 3 == 3 }; + #[cfg(feature = "userspace-syscall-test")] + if from_user { + crate::arch::x86_64::syscall::test::TIMER_INTERRUPTED_CPL3.store(true, Ordering::Relaxed); + } + if from_user { + pic::notify_end_of_interrupt(IrqLine::Timer); + return context; + } + + let scheduler = SCHEDULER + .get() + .expect("timer IRQ before scheduler initialization"); + let selected = interrupts::without_interrupts(|| { + let mut scheduler = scheduler.lock(); + assert!( + scheduler.initialized, + "timer IRQ before scheduler initialization" + ); + match scheduler.current { + Some(id) => { + let slot = scheduler + .slots + .get_mut(id.slot()) + .expect("current task index invalid"); + assert_eq!( + slot.generation, id.generation, + "current task generation stale" + ); + assert!( + matches!(slot.state, TaskState::Running | TaskState::Exiting), + "current task not Running/Exiting" + ); + assert!(!slot.queued, "Running task appeared in queue"); + slot.saved_rsp = context as u64; + assert_task_stack_pointer(id.slot(), slot.saved_rsp); + if slot.state == TaskState::Exiting { + slot.state = TaskState::Exited; + scheduler.current = None; + } else { + slot.state = TaskState::Ready; + slot.queued = true; + scheduler.queue.push(id).expect("runnable queue overflow"); + } + } + None => scheduler.bootstrap_rsp = context as u64, + } + + let next = scheduler.queue.pop(); + if let Some(id) = next { + let slot = scheduler + .slots + .get_mut(id.slot()) + .expect("queued task index invalid"); + assert_eq!( + slot.generation, id.generation, + "stale TaskId in runnable queue" + ); + assert_eq!(slot.state, TaskState::Ready, "queued task is not Ready"); + assert!(slot.queued, "queued task missing queued flag"); + slot.queued = false; + slot.state = TaskState::Running; + let saved_rsp = slot.saved_rsp; + assert_task_stack_pointer(id.slot(), saved_rsp); + scheduler.current = Some(id); + saved_rsp + } else { + scheduler.current = None; + assert_ne!( + scheduler.bootstrap_rsp, 0, + "bootstrap context was never captured" + ); + scheduler.bootstrap_rsp + } + }); + pic::notify_end_of_interrupt(IrqLine::Timer); + selected as *mut arch_task::Context +} + +fn assert_task_stack_pointer(slot: usize, rsp: u64) { + let (low, high) = arch_task::stack_bounds(slot).expect("invalid task stack slot"); + assert!( + (low..high).contains(&rsp), + "task saved RSP outside owned stack" + ); +} + +pub(crate) fn task_exit() -> ! { + let scheduler = SCHEDULER + .get() + .expect("task exit before scheduler initialization"); + interrupts::without_interrupts(|| { + let mut scheduler = scheduler.lock(); + let id = scheduler + .current + .expect("bootstrap context cannot exit as a task"); + let slot = scheduler + .slots + .get_mut(id.slot()) + .expect("current task index invalid during exit"); + assert_eq!(slot.generation, id.generation); + assert_eq!(slot.state, TaskState::Running); + slot.state = TaskState::Exiting; + }); + // The task remains on its own stack until the next PIT interrupt saves + // this context and switches elsewhere. STI;HLT is the safe idle pair. + loop { + unsafe { + core::arch::asm!("sti; hlt", options(nomem, nostack)); + } + } +} + +fn smoke_a() { + let rsp: u64; + // SAFETY: reading RSP is a non-mutating observation used to verify that + // the task executes inside its assigned mapped stack. + unsafe { + core::arch::asm!("mov {}, rsp", out(reg) rsp, options(nomem, nostack, preserves_flags)); + } + SMOKE_A_SP.store(rsp, Ordering::Relaxed); + SMOKE_A.store(1, Ordering::Relaxed); +} +fn smoke_b() { + let rsp: u64; + // SAFETY: see `smoke_a`; this only observes the current task stack. + unsafe { + core::arch::asm!("mov {}, rsp", out(reg) rsp, options(nomem, nostack, preserves_flags)); + } + SMOKE_B_SP.store(rsp, Ordering::Relaxed); + SMOKE_B.store(1, Ordering::Relaxed); +} + +pub fn prepare_smoke_test() -> (TaskId, TaskId) { + SMOKE_A.store(0, Ordering::Relaxed); + SMOKE_B.store(0, Ordering::Relaxed); + SMOKE_A_SP.store(0, Ordering::Relaxed); + SMOKE_B_SP.store(0, Ordering::Relaxed); + let task_a = spawn_kernel_task(smoke_a).expect("could not create scheduler smoke task A"); + let task_b = spawn_kernel_task(smoke_b).expect("could not create scheduler smoke task B"); + assert_ne!(task_a, task_b, "scheduler issued duplicate TaskIds"); + assert_ne!( + task_a.slot(), + task_b.slot(), + "scheduler reused a live stack slot" + ); + assert!(task_a.generation() > 0 && task_b.generation() > 0); + kprintln!("[scheduler-test] task stacks verified"); + (task_a, task_b) +} + +pub fn run_smoke_test(task_a: TaskId, task_b: TaskId) { + let start = pit::ticks(); + while SMOKE_A.load(Ordering::Relaxed) == 0 || SMOKE_B.load(Ordering::Relaxed) == 0 { + assert!( + pit::ticks().wrapping_sub(start) < 1000, + "scheduler smoke timed out" + ); + hlt(); + } + for (task, observed_sp) in [ + (task_a, SMOKE_A_SP.load(Ordering::Relaxed)), + (task_b, SMOKE_B_SP.load(Ordering::Relaxed)), + ] { + let (low, high) = arch_task::stack_bounds(task.slot()).expect("invalid task stack slot"); + assert!( + (low..high).contains(&observed_sp), + "task stack pointer outside assigned stack" + ); + } + let (a_low, a_high) = arch_task::stack_bounds(task_a.slot()).unwrap(); + let (b_low, b_high) = arch_task::stack_bounds(task_b.slot()).unwrap(); + assert!( + a_high <= b_low || b_high <= a_low, + "task stack ranges overlap" + ); + kprintln!("[scheduler-test] task A completed"); + kprintln!("[scheduler-test] task B completed"); + while task_state(task_a) != Some(TaskState::Exited) + || task_state(task_b) != Some(TaskState::Exited) + { + hlt(); + } + assert_eq!(task_state(task_a), Some(TaskState::Exited)); + assert_eq!(task_state(task_b), Some(TaskState::Exited)); + kprintln!("[scheduler-test] task state transitions passed"); + kprintln!("[scheduler-test] idle resume passed"); + kprintln!("[scheduler-test] completed successfully"); + kprintln!("[scheduler] kernel task smoke passed"); +} + +#[cfg(feature = "scheduler-preemption-test")] +fn preemption_task_a() { + let start = pit::ticks(); + while PREEMPT_B_PROGRESS.load(Ordering::Relaxed) == 0 { + PREEMPT_A_PROGRESS.fetch_add(1, Ordering::Relaxed); + if pit::ticks().wrapping_sub(start) > 200 { + kprintln!( + "[scheduler-test] task A watchdog fired; B progress={}", + PREEMPT_B_PROGRESS.load(Ordering::Relaxed) + ); + #[cfg(feature = "qemu-test")] + crate::arch::x86_64::qemu::exit_failure(); + } + } + PREEMPT_A_DONE.store(1, Ordering::Relaxed); +} + +#[cfg(feature = "scheduler-preemption-test")] +fn preemption_task_b() { + let start = pit::ticks(); + loop { + PREEMPT_B_PROGRESS.fetch_add(1, Ordering::Relaxed); + if PREEMPT_A_PROGRESS.load(Ordering::Relaxed) >= 100 { + break; + } + if pit::ticks().wrapping_sub(start) > 200 { + kprintln!( + "[scheduler-test] task B watchdog fired; A progress={}", + PREEMPT_A_PROGRESS.load(Ordering::Relaxed) + ); + #[cfg(feature = "qemu-test")] + crate::arch::x86_64::qemu::exit_failure(); + } + } + PREEMPT_B_DONE.store(1, Ordering::Relaxed); +} + +#[cfg(feature = "scheduler-preemption-test")] +pub fn run_preemption_test() { + PREEMPT_A_PROGRESS.store(0, Ordering::Relaxed); + PREEMPT_B_PROGRESS.store(0, Ordering::Relaxed); + PREEMPT_A_DONE.store(0, Ordering::Relaxed); + PREEMPT_B_DONE.store(0, Ordering::Relaxed); + let before = pit::ticks(); + let task_a = spawn_kernel_task(preemption_task_a).expect("could not spawn preemption task A"); + let task_b = spawn_kernel_task(preemption_task_b).expect("could not spawn preemption task B"); + assert_ne!(task_a, task_b); + while PREEMPT_A_DONE.load(Ordering::Relaxed) == 0 + || PREEMPT_B_DONE.load(Ordering::Relaxed) == 0 + || task_state(task_a) != Some(TaskState::Exited) + || task_state(task_b) != Some(TaskState::Exited) + { + hlt(); + } + assert!(PREEMPT_A_PROGRESS.load(Ordering::Relaxed) > 0); + assert!(PREEMPT_B_PROGRESS.load(Ordering::Relaxed) > 0); + assert!(pit::ticks() > before); + kprintln!("[scheduler-test] timer preemption observed"); + kprintln!("[scheduler-test] task A made progress"); + kprintln!("[scheduler-test] task B made progress"); + kprintln!("[scheduler-test] timer remained monotonic"); + kprintln!("[scheduler-test] preemption completed successfully"); +} + +#[cfg(feature = "task-capacity-test")] +fn capacity_task() { + CAPACITY_DONE.fetch_add(1, Ordering::Relaxed); +} + +#[cfg(feature = "task-capacity-test")] +pub fn run_capacity_test() { + CAPACITY_DONE.store(0, Ordering::Relaxed); + let mut ids = [None; MAX_KERNEL_TASKS]; + for id in &mut ids { + *id = Some(spawn_kernel_task(capacity_task).expect("task capacity filled too early")); + } + assert_eq!( + spawn_kernel_task(capacity_task), + Err(SpawnError::TaskTableFull) + ); + kprintln!("[scheduler-test] capacity limit passed"); + while CAPACITY_DONE.load(Ordering::Relaxed) < MAX_KERNEL_TASKS as u64 + || ids + .iter() + .flatten() + .any(|id| task_state(*id) != Some(TaskState::Exited)) + { + hlt(); + } + let stale = ids[0].expect("capacity task id missing"); + let replacement = spawn_kernel_task(capacity_task).expect("exited task slot was not reusable"); + assert_eq!(replacement.slot(), stale.slot()); + assert!(replacement.generation() > stale.generation()); + assert_eq!(task_state(stale), None); + kprintln!("[scheduler-test] task slot reuse passed"); + kprintln!("[scheduler-test] stale TaskId rejected"); + while CAPACITY_DONE.load(Ordering::Relaxed) < MAX_KERNEL_TASKS as u64 + 1 + || task_state(replacement) != Some(TaskState::Exited) + { + hlt(); + } +} + +#[cfg(feature = "task-stack-guard-fault-test")] +pub fn run_stack_guard_fault_test() -> ! { + fn fault_task() { + let id = { + let scheduler = SCHEDULER.get().expect("scheduler not initialized"); + interrupts::without_interrupts(|| scheduler.lock().current.expect("no current task")) + }; + let (low, _) = arch_task::stack_bounds(id.slot()).expect("invalid task stack slot"); + let guard = low + .checked_sub(4096) + .expect("task stack lower guard underflow"); + crate::arch::x86_64::interrupts::arm_expected_task_stack_guard_fault(guard); + kprintln!("[scheduler-test] triggering expected task stack guard-page fault"); + // SAFETY: this deliberate read targets the task's verified unmapped + // lower guard and is only called in its dedicated QEMU fault feature. + unsafe { + core::ptr::read_volatile(guard as *const u8); + } + } + spawn_kernel_task(fault_task).expect("could not spawn stack guard fault task"); + loop { + hlt(); + } +}