From 330df1bce8b0bc163718958f8ff264134b11501c Mon Sep 17 00:00:00 2001
From: ultronstudio
Date: Sat, 24 Oct 2026 13:04:47 +0200
Subject: [PATCH 1/4] docs: define pvKernel 0.0.8 task and scheduler model
---
docs/architecture/kernel-tasks.md | 220 ++++++++++++++++++++++++++++++
1 file changed, 220 insertions(+)
create mode 100644 docs/architecture/kernel-tasks.md
diff --git a/docs/architecture/kernel-tasks.md b/docs/architecture/kernel-tasks.md
new file mode 100644
index 0000000..347f48a
--- /dev/null
+++ b/docs/architecture/kernel-tasks.md
@@ -0,0 +1,220 @@
+# 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.
+* A same-CPL (CPL0) hardware interrupt pushes `RIP`, `CS`, `RFLAGS` on the
+ current stack. A CPL3-to-CPL0 interrupt instead switches to TSS.RSP0 and
+ also pushes the interrupted `RSP` and `SS`. The IDT Rust interrupt ABI
+ represents this with `InterruptStackFrame`; it is not a uniform stack
+ layout suitable for blindly switching from that handler.
+* 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
+CPL0/CPL3 hardware-frame difference 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 conceptually stores ID, lifecycle state, saved
+context pointer, stack slot, queue-membership flag, and kernel entry pointer.
+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. 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
+ old RSP (only when hardware interrupted CPL3)
+ old SS (only when hardware interrupted CPL3)
+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.
+For the scheduler-managed path, saved `CS.RPL` must be zero, so the selected
+frame returns with the three-word CPL0 `iretq` frame. CPL3-origin IRQs use
+the existing no-switch return path and their five-word hardware frame is
+preserved untouched. No CPL3 frame is interpreted as a kernel task context.
+
+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.
+
+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 trampoline calls the trusted `fn()` entry pointer
+stored in scheduler metadata, 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. Bootstrap/idle reaps Exited slots and permits reuse.
+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.
From 1978a389d7ea58d8b7c0b183b7a6991bdd29756c Mon Sep 17 00:00:00 2001
From: ultronstudio
Date: Sat, 24 Oct 2026 13:24:41 +0200
Subject: [PATCH 2/4] feat: add preemptive kernel task scheduler
---
Cargo.lock | 4 +-
Cargo.toml | 6 +-
kernel/Cargo.toml | 6 +
kernel/build.rs | 25 +
kernel/src/arch/x86_64/gdt.rs | 9 +
kernel/src/arch/x86_64/interrupts.rs | 60 ++-
kernel/src/arch/x86_64/mod.rs | 1 +
kernel/src/arch/x86_64/task/context.rs | 33 ++
kernel/src/arch/x86_64/task/mod.rs | 7 +
kernel/src/arch/x86_64/task/stack.rs | 159 +++++++
kernel/src/arch/x86_64/task/switch.rs | 82 ++++
kernel/src/main.rs | 20 +-
kernel/src/task/mod.rs | 619 +++++++++++++++++++++++++
13 files changed, 1005 insertions(+), 26 deletions(-)
create mode 100644 kernel/build.rs
create mode 100644 kernel/src/arch/x86_64/task/context.rs
create mode 100644 kernel/src/arch/x86_64/task/mod.rs
create mode 100644 kernel/src/arch/x86_64/task/stack.rs
create mode 100644 kernel/src/arch/x86_64/task/switch.rs
create mode 100644 kernel/src/task/mod.rs
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/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();
+ }
+}
From 34b664435d84a8078ce77624c7cc927c3fe8f218 Mon Sep 17 00:00:00 2001
From: ultronstudio
Date: Sat, 24 Oct 2026 13:24:43 +0200
Subject: [PATCH 3/4] test: add deterministic scheduler QEMU scenarios
---
.github/workflows/ci.yml | 56 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 56 insertions(+)
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.
From dc35e397cd5005006cf806f0b569dbfb96918f4e Mon Sep 17 00:00:00 2001
From: ultronstudio
Date: Sat, 24 Oct 2026 13:24:46 +0200
Subject: [PATCH 4/4] docs: document pvKernel 0.0.8 scheduler model
---
ARCHITECTURE.md | 34 ++++++++++------
CHANGELOG.md | 14 +++++++
README.md | 15 +++++--
ROADMAP.md | 29 +++++++-------
docs/architecture/boot.md | 20 ++++++----
docs/architecture/interrupts.md | 2 +-
docs/architecture/kernel-tasks.md | 61 +++++++++++++++++------------
docs/architecture/timer.md | 14 ++++---
docs/architecture/virtual-memory.md | 24 ++++++++++++
docs/development/testing.md | 31 +++++++++++----
10 files changed, 171 insertions(+), 73 deletions(-)
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/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
index 347f48a..e62e4b5 100644
--- a/docs/architecture/kernel-tasks.md
+++ b/docs/architecture/kernel-tasks.md
@@ -18,11 +18,14 @@ The implementation baseline is the stable v0.0.7 commit
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.
-* A same-CPL (CPL0) hardware interrupt pushes `RIP`, `CS`, `RFLAGS` on the
- current stack. A CPL3-to-CPL0 interrupt instead switches to TSS.RSP0 and
- also pushes the interrupted `RSP` and `SS`. The IDT Rust interrupt ABI
- represents this with `InterruptStackFrame`; it is not a uniform stack
- layout suitable for blindly switching from that handler.
+* 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
@@ -56,8 +59,8 @@ The implementation baseline is the stable v0.0.7 commit
These findings rule out switching by returning from the current
`extern "x86-interrupt"` Rust handler: its compiler-managed frame and the
-CPL0/CPL3 hardware-frame difference make an arbitrary stack swap there
-unsafe. The scheduler design uses a dedicated IRQ0 assembly entry that saves
+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.
@@ -74,9 +77,10 @@ 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 conceptually stores ID, lifecycle state, saved
-context pointer, stack slot, queue-membership flag, and kernel entry pointer.
-No scheduler metadata operation allocates.
+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
@@ -108,7 +112,9 @@ 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. No frames are returned to the monotonic boot allocator.
+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
@@ -124,17 +130,19 @@ low addresses / saved RSP
RIP
CS
RFLAGS
- old RSP (only when hardware interrupted CPL3)
- old SS (only when hardware interrupted CPL3)
+ 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.
-For the scheduler-managed path, saved `CS.RPL` must be zero, so the selected
-frame returns with the three-word CPL0 `iretq` frame. CPL3-origin IRQs use
-the existing no-switch return path and their five-word hardware frame is
-preserved untouched. No CPL3 frame is interpreted as a kernel task context.
+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
@@ -142,22 +150,27 @@ 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.
+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 trampoline calls the trusted `fn()` entry pointer
-stored in scheduler metadata, 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.
+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. Bootstrap/idle reaps Exited slots and permits reuse.
-No task can reuse its own active stack.
+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
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