diff --git a/src/kernel/arch/x86_64/cpu.zig b/src/kernel/arch/x86_64/cpu.zig index 9ef6385..f585391 100644 --- a/src/kernel/arch/x86_64/cpu.zig +++ b/src/kernel/arch/x86_64/cpu.zig @@ -282,3 +282,11 @@ pub fn readCr2() u64 { pub fn halt() noreturn { while (true) asm volatile ("hlt"); } + +/// Spin-wait hint (`pause`). Emitted in the body of a spinlock's busy-wait: it +/// relaxes the core while it polls a contended lock — yielding pipeline resources +/// to a hyperthread sibling and easing the cache-coherency traffic on the lock +/// line. Purely a performance/power hint; correct to omit, but kinder on the bus. +pub fn cpuRelax() void { + asm volatile ("pause"); +} diff --git a/src/kernel/arch/x86_64/isr.s b/src/kernel/arch/x86_64/isr.s index 67b149c..0b43191 100644 --- a/src/kernel/arch/x86_64/isr.s +++ b/src/kernel/arch/x86_64/isr.s @@ -63,9 +63,14 @@ switch_context: ret # return into the new task's saved instruction pointer # task_trampoline: the first thing a freshly-spawned task runs. init_task_stack -# leaves its entry function in r15. New tasks start with interrupts enabled. +# leaves its entry function in r15. A fresh task is switched to with the big kernel +# lock held (the hand-off rule in sync.zig) but has no enter/leave frame of its own, +# so it releases the lock here before running its body. r15 survives the call (it's +# callee-saved). New tasks then start with interrupts enabled. +.extern releaseForFreshTask .global task_trampoline task_trampoline: + call releaseForFreshTask # drop the kernel lock we inherited across the switch sti call *%r15 # call the task entry (fn() void) 1: hlt # if the entry returns, idle (still preemptible) diff --git a/src/kernel/ipc.zig b/src/kernel/ipc.zig index a7bcd1e..34de07c 100644 --- a/src/kernel/ipc.zig +++ b/src/kernel/ipc.zig @@ -11,8 +11,8 @@ //! When user mode arrives, the same primitive carries messages across the //! isolation boundary (with the payload copied between address spaces). -const arch = @import("arch"); const sched = @import("scheduler.zig"); +const sync = @import("sync.zig"); /// A bounded blocking channel of `capacity` messages of type `T`. pub fn Channel(comptime T: type, comptime capacity: usize) type { @@ -28,7 +28,7 @@ pub fn Channel(comptime T: type, comptime capacity: usize) type { /// Send a message, blocking while the channel is full. pub fn send(self: *Self, msg: T) void { - const flags = arch.saveInterrupts(); + const flags = sync.enter(); // Recheck the condition in a loop: a wakeup only means "try again" // (another waiter may have taken the slot first). while (self.count == capacity) sched.waitLocked(&self.not_full); @@ -36,18 +36,18 @@ pub fn Channel(comptime T: type, comptime capacity: usize) type { self.tail = (self.tail + 1) % capacity; self.count += 1; sched.wakeLocked(&self.not_empty); // a receiver can now proceed - arch.restoreInterrupts(flags); + sync.leave(flags); } /// Receive a message, blocking while the channel is empty. pub fn recv(self: *Self) T { - const flags = arch.saveInterrupts(); + const flags = sync.enter(); while (self.count == 0) sched.waitLocked(&self.not_empty); const msg = self.buffer[self.head]; self.head = (self.head + 1) % capacity; self.count -= 1; sched.wakeLocked(&self.not_full); // a sender can now proceed - arch.restoreInterrupts(flags); + sync.leave(flags); return msg; } }; diff --git a/src/kernel/scheduler.zig b/src/kernel/scheduler.zig index 2ea3858..c1f4d91 100644 --- a/src/kernel/scheduler.zig +++ b/src/kernel/scheduler.zig @@ -9,10 +9,18 @@ //! Switching happens both cooperatively (`yield`) and preemptively (the timer //! calls `tick`). See docs/scheduling.md for the interrupt-flag discipline that //! makes those two paths coexist. +//! +//! Cross-core safety is the **big kernel lock** (`sync.zig`): every critical +//! section here runs under it, and it is held across a context switch and released +//! by the task that resumes (see sync.zig's hand-off rule). On a single core the +//! lock is never contended, so the behaviour is exactly the old interrupt-flag +//! model; it's what lets a second core enter `schedule()` without corrupting the +//! shared queues. const std = @import("std"); const arch = @import("arch"); const heap = @import("heap.zig"); +const sync = @import("sync.zig"); /// Priority level: 0 (lowest) .. 7 (highest). 8 levels total. pub const Priority = u3; @@ -121,19 +129,19 @@ fn schedule() void { /// Voluntarily give up the CPU to the next ready task. pub fn yield() void { - const flags = arch.saveInterrupts(); + const flags = sync.enter(); schedule(); - arch.restoreInterrupts(flags); + sync.leave(flags); } /// Block the current task for `ms` milliseconds, then let it become runnable /// again. The idle task (or other work) runs in the meantime. pub fn sleep(ms: u64) void { - const flags = arch.saveInterrupts(); + const flags = sync.enter(); current.wake_at = arch.millis() + ms; current.state = .blocked; schedule(); // current is blocked, so schedule() won't re-enqueue it - arch.restoreInterrupts(flags); + sync.leave(flags); } // --- event-based blocking ------------------------------------------------- @@ -147,9 +155,10 @@ pub const WaitQueue = struct { head: ?*Task = null, }; -/// Block the current task on `wq` and switch away. Precondition: interrupts are -/// disabled (the caller holds them, so a condition can be checked and the block -/// committed atomically). On return — when woken — interrupts are still disabled. +/// Block the current task on `wq` and switch away. Precondition: the big kernel +/// lock is held (so a condition can be checked and the block committed atomically; +/// it also keeps local interrupts disabled). On return — when woken — the lock is +/// still held. pub fn waitLocked(wq: *WaitQueue) void { current.state = .blocked; current.next = wq.head; @@ -158,7 +167,7 @@ pub fn waitLocked(wq: *WaitQueue) void { } /// Move the highest-priority waiter on `wq` (if any) to the ready queue. -/// Precondition: interrupts disabled. Does not preempt — the caller decides. +/// Precondition: the big kernel lock is held. Does not preempt — the caller decides. pub fn wakeLocked(wq: *WaitQueue) void { // Find the highest-priority waiter (bounded scan) and unlink it. var best_prev: ?*Task = null; @@ -182,20 +191,20 @@ pub fn wakeLocked(wq: *WaitQueue) void { /// Block on `wq` (a self-contained critical section). pub fn wait(wq: *WaitQueue) void { - const flags = arch.saveInterrupts(); + const flags = sync.enter(); waitLocked(wq); - arch.restoreInterrupts(flags); + sync.leave(flags); } /// Wake the highest-priority waiter on `wq`, preempting if it outranks us. pub fn wake(wq: *WaitQueue) void { - const flags = arch.saveInterrupts(); + const flags = sync.enter(); wakeLocked(wq); // If a higher-priority task is now ready, run it immediately. if (highestReadyPriority()) |p| { if (p > current.priority) schedule(); } - arch.restoreInterrupts(flags); + sync.leave(flags); } fn highestReadyPriority() ?Priority { @@ -217,10 +226,15 @@ fn wakeExpired() void { } /// Called from the timer interrupt (interrupts already disabled): wake due -/// sleepers, then preempt. +/// sleepers, then preempt. Takes the kernel lock like any other critical section, +/// but releases it *without* touching the interrupt flag — the handler's `iretq` +/// restores the interrupted context's flags, so re-enabling here would open a +/// nested-interrupt window before the return. pub fn tick() void { + _ = sync.enter(); wakeExpired(); if (preemption_enabled) schedule(); + sync.leaveIsr(); } /// Enable or disable timer-driven preemption (cooperative-only when off). @@ -229,9 +243,10 @@ pub fn setPreemption(enabled: bool) void { } /// End the current task and switch away for good; never returns. The task's stack -/// is leaked for now (no reaper yet). +/// is leaked for now (no reaper yet). Acquires the kernel lock and hands it off to +/// the task we switch into (which releases it) — this frame never returns to leave. pub fn exit() noreturn { - arch.disableInterrupts(); + _ = sync.enter(); current.state = .free; const next = dequeueHighest() orelse @panic("sched: no task left to run"); next.state = .running; diff --git a/src/kernel/sync.zig b/src/kernel/sync.zig new file mode 100644 index 0000000..ffa03f8 --- /dev/null +++ b/src/kernel/sync.zig @@ -0,0 +1,80 @@ +//! The big kernel lock (BKL) — the coarse mutual exclusion that lets more than one +//! CPU run kernel code safely. +//! +//! Until SMP, the kernel's mutual exclusion *was* the interrupt flag: a critical +//! section did `cli`, and since only one core existed, nothing else could touch +//! kernel state (the discipline in docs/scheduling.md). That invariant dies the +//! instant a second core runs kernel code — `cli` on one core does nothing to +//! another. So the kernel's shared state (the scheduler queues, IPC channels) is +//! guarded by a spinlock, and the lock is **always held with local interrupts +//! disabled**, so a core's own timer interrupt can't re-enter the kernel and +//! deadlock against the lock it already holds. +//! +//! This is deliberately *one coarse lock*, not many fine ones: it's philosophically +//! aligned with a tiny kernel and it keeps the single-core correctness model +//! (docs/scheduling.md) largely intact — one lock around kernel entry instead of +//! rethinking every critical section. It's the first-design choice seL4 makes and +//! docs/smp.md endorses; per-core run queues + fine-grained locking come later, if +//! contention ever bites. Because the kernel does little, the lock is held briefly. +//! +//! **The hand-off rule.** The lock is held *across* a context switch and released +//! by whichever task resumes, not by the one that switched away. A task that blocks +//! or yields calls `enter`, mutates the queues, `schedule()`s — switching to another +//! task *with the lock still held* — and only calls `leave` once it is eventually +//! resumed and its critical section runs to the end. So every call into `schedule()` +//! (and thus `switch_context`) happens with the lock held, and every task resumes +//! from a switch holding it. A freshly-spawned task has no `enter`/`leave` frame to +//! resume into, so `task_trampoline` releases the lock explicitly on its behalf via +//! `releaseForFreshTask` before running the task body. + +const std = @import("std"); +const arch = @import("arch"); + +/// 0 = free, 1 = held. A single global lock for the whole kernel. +var held = std.atomic.Value(u32).init(0); + +/// Enter the kernel: disable interrupts on this core, then spin until we own the +/// lock. Returns the caller's prior interrupt flags for `leave` to restore. +/// Interrupts stay off for the whole critical section so this core's timer tick +/// can't try to re-acquire the lock we're holding. +pub fn enter() u64 { + const flags = arch.saveInterrupts(); + acquire(); + return flags; +} + +/// Release the lock and restore the interrupt flags `enter` returned (re-enabling +/// interrupts only if they were on beforehand). The normal exit for a critical +/// section reached from task context (`yield`, `sleep`, `wait`, `wake`, IPC). +pub fn leave(flags: u64) void { + release(); + arch.restoreInterrupts(flags); +} + +/// Release the lock but leave interrupts as they are. The exit for a critical +/// section running inside an interrupt handler (the timer `tick`): the handler's +/// `iretq` is what restores the interrupted context's flags, so restoring them +/// here too would open a nested-interrupt window before the return. Release only. +pub fn leaveIsr() void { + release(); +} + +/// Release the lock on behalf of a freshly-spawned task. Such a task is switched to +/// (with the lock held) but has no `enter`/`leave` frame of its own to release +/// through — `task_trampoline` calls this before running the task body. Interrupts +/// are enabled separately by the trampoline. Exported for the assembly trampoline. +export fn releaseForFreshTask() callconv(.c) void { + release(); +} + +fn acquire() void { + // Test-and-test-and-set: try once, then spin read-only until the lock looks + // free before retrying the (bus-locked) swap — cheaper on the coherency fabric. + while (held.swap(1, .acquire) != 0) { + while (held.load(.monotonic) != 0) arch.cpuRelax(); + } +} + +fn release() void { + held.store(0, .release); +}