This commit is contained in:
Daniel Samson 2026-07-03 15:09:16 +01:00
parent df774691c8
commit 31bf86af56
12 changed files with 492 additions and 41 deletions

View File

@ -32,9 +32,12 @@ rather than restate it. Roughly in the order things happen at runtime:
the VMM, exposed as a `std.mem.Allocator` so std containers work — dynamic the VMM, exposed as a `std.mem.Allocator` so std containers work — dynamic
allocation for the kernel. allocation for the kernel.
10. **[scheduling.md](scheduling.md) — the scheduler.** Fixed-priority preemptive 10. **[scheduling.md](scheduling.md) — the scheduler.** Fixed-priority preemptive
multitasking: kernel threads, the context switch, and O(1) priority selection — multitasking: kernel threads, the context switch, O(1) priority selection, and
the leap to a running system. blocking (sleep, wait queues) — the leap to a running system.
11. **[halting.md](halting.md) — halting.** Why a kernel can't just "exit", and 11. **[ipc.md](ipc.md) — inter-process communication.** Bounded blocking
message-passing channels — the backbone the microkernel's isolated servers will
talk over.
12. **[halting.md](halting.md) — halting.** Why a kernel can't just "exit", and
how `while (true) hlt` parks the CPU safely once there's nothing left to do. how `while (true) hlt` parks the CPU safely once there's nothing left to do.
Start with the north star: Start with the north star:
@ -65,8 +68,9 @@ its **descriptor tables** so CPU faults are caught ([interrupts.md](interrupts.m
builds its own **page tables** and switches onto them ([paging.md](paging.md)), builds its own **page tables** and switches onto them ([paging.md](paging.md)),
brings up the **heap** for dynamic allocation ([heap.md](heap.md)), starts the brings up the **heap** for dynamic allocation ([heap.md](heap.md)), starts the
**scheduler** ([scheduling.md](scheduling.md)) and the **timer** that preempts it **scheduler** ([scheduling.md](scheduling.md)) and the **timer** that preempts it
([device-interrupts.md](device-interrupts.md)), runs — its CPU-specific bits behind ([device-interrupts.md](device-interrupts.md)) — with tasks blocking, sleeping and
the [arch](arch.md) boundary — and when idle, or on a panic, it **halts** passing messages over **[IPC](ipc.md)** channels — runs, its CPU-specific bits
behind the [arch](arch.md) boundary, and when idle, or on a panic, it **halts**
([halting.md](halting.md)). ([halting.md](halting.md)).
## Source map ## Source map
@ -78,7 +82,8 @@ the [arch](arch.md) boundary — and when idle, or on a panic, it **halts**
| Shared loader↔kernel contract (`BootInfo`, `Framebuffer`, `MemoryMap`, ABI) | `src/root.zig` | | Shared loader↔kernel contract (`BootInfo`, `Framebuffer`, `MemoryMap`, ABI) | `src/root.zig` |
| Physical frame allocator | `src/pmm.zig` | | Physical frame allocator | `src/pmm.zig` |
| Kernel heap (`std.mem.Allocator`) | `src/heap.zig` | | Kernel heap (`std.mem.Allocator`) | `src/heap.zig` |
| Scheduler (fixed-priority preemptive) | `src/sched.zig` | | Scheduler (fixed-priority preemptive; blocking, wait queues) | `src/sched.zig` |
| IPC channels (message passing) | `src/ipc.zig` |
| Framebuffer text console (mirrors to serial) | `src/console.zig` | | Framebuffer text console (mirrors to serial) | `src/console.zig` |
| In-kernel test cases | `src/tests.zig` | | In-kernel test cases | `src/tests.zig` |
| Arch-specific kernel code (`halt`, GDT/IDT/TSS, exception + interrupt stubs, page tables, APIC/timer, serial, linker script) | `src/arch/x86_64/` | | Arch-specific kernel code (`halt`, GDT/IDT/TSS, exception + interrupt stubs, page tables, APIC/timer, serial, linker script) | `src/arch/x86_64/` |

View File

@ -46,8 +46,25 @@ it against the **PIT** (the legacy 8254, whose 1.193182 MHz is fixed): run the
LAPIC timer one-shot from its maximum count while the PIT counts out a known 10 ms LAPIC timer one-shot from its maximum count while the PIT counts out a known 10 ms
(polling channel 2, no interrupt needed), then see how far the LAPIC got. That (polling channel 2, no interrupt needed), then see how far the LAPIC got. That
yields its counts-per-millisecond, from which `initTimer(hz)` computes the reload yields its counts-per-millisecond, from which `initTimer(hz)` computes the reload
count for any target frequency. danos runs it at **1000 Hz** (a 1 ms tick), and the count for any target frequency. danos runs it at **1000 Hz** (a 1 ms tick).
tick count times the known period gives a monotonic `uptimeMs()`.
## The high-resolution clock (TSC)
The timer tick gives *scheduling* — a 1 ms quantum — but 1 ms is coarse for a
real-time system to *measure* with (interrupt latency, jitter, timeouts). So the
same calibration also measures the **TSC** (Time Stamp Counter): a per-core cycle
counter read with `rdtsc` in a couple of cycles, giving roughly **nanosecond**
resolution — a million times finer than the tick. We snapshot the TSC across the
same 10 ms PIT window to get its frequency (measured ~3.6 GHz on the test host).
The monotonic clock is exposed as one function per resolution — `nanos()`,
`micros()`, `millis()` — each scaling the cycle delta directly at its unit (with a
128-bit intermediate so a long uptime doesn't overflow) rather than chaining
divisions. `millis()` is what the scheduler uses for `sleep` deadlines; `nanos()`
is there for fine measurement. Note the two clocks are distinct: the **tick** drives
preemption and wakeups (1 ms granularity); the **TSC** is the resolution you read
time at. Making `sleep` itself sub-millisecond would take a tickless one-shot
timer — a later step.
## Two kinds of vector, one dispatch ## Two kinds of vector, one dispatch

57
docs/ipc.md Normal file
View File

@ -0,0 +1,57 @@
# IPC: message-passing channels
Inter-process communication is the **backbone of a microkernel**. Once drivers and
services run isolated in their own address spaces ([vision](vision.md)), they can't
just call each other — a request becomes a **message**. In a microkernel, whatever
was a function call across a monolithic kernel is IPC, so it's a first-class
concern, not an afterthought.
This first form is a **bounded blocking channel** (`src/ipc.zig`): a fixed-size
ring buffer of messages with a producer/consumer rendezvous, built on the
scheduler's [wait queues](scheduling.md).
## The channel
`Channel(T, capacity)` is generic over the message type and buffer size. It holds a
ring buffer, a count, and two wait queues:
- **`send(msg)`** — if the channel is full, block on the *not-full* queue; otherwise
write the message, bump the count, and wake a waiting receiver.
- **`recv()`** — if the channel is empty, block on the *not-empty* queue; otherwise
take a message, drop the count, and wake a waiting sender.
Neither side busy-waits: a full channel parks the sender, an empty one parks the
receiver, and each operation wakes the other side when it makes progress possible.
Two details make it correct:
- **Recheck in a loop.** A woken task re-tests the condition (`while (full) wait`)
rather than assuming the slot is still available — another waiter may have taken
it first. This is the standard guard against spurious or racing wakeups.
- **One critical section.** `send`/`recv` run under `saveInterrupts` /
`restoreInterrupts` (the composable form, see [scheduling.md](scheduling.md)), so
checking the condition and committing the block/enqueue happen atomically with
respect to the timer preempting mid-operation. `waitLocked` / `wakeLocked` are the
variants that assume the caller already holds that critical section.
## Verifying it
The `ipc` test (see [testing.md](testing.md)) runs a producer and a consumer passing
**100 messages through a 4-slot channel**. The small buffer means the channel goes
full and empty over and over, so both the blocking-send and blocking-recv paths are
exercised heavily. The messages arrive intact and in order (their sum is the
expected `5050`), and neither task busy-waits — they block and wake each other.
## What's next (not done here)
- **Across address spaces.** Today both endpoints are kernel threads sharing the
kernel's memory, so the message is copied within one address space. When user
mode arrives, the same channel carries messages between *isolated* processes,
copying the payload across the boundary — which is where IPC earns its place as
the microkernel's backbone.
- **Synchronous call/reply.** A request/response pattern (send-and-wait-for-reply)
on top of channels, the shape most driver/service calls take.
- **Interrupts as messages.** A hardware interrupt delivered to the driver task
that owns the device, as an IPC message.
- **Priority inheritance** through IPC, so a high-priority client blocked on a
low-priority server doesn't suffer unbounded priority inversion.

View File

@ -67,21 +67,56 @@ exist, which is what a real-time scheduler needs.
- **Round-robin within a level.** When a task is descheduled it goes to the *back* - **Round-robin within a level.** When a task is descheduled it goes to the *back*
of its level's queue, so equal-priority tasks share the CPU fairly. of its level's queue, so equal-priority tasks share the CPU fairly.
## Sleeping and the idle task
A task can **block** — give up the CPU until an event, rather than busy-wait
(busy-waiting is the enemy of a real-time system: it wastes cycles a
higher-priority task should get). The first form is time-based: **`sleep(ms)`**
marks the task blocked with a wake deadline and switches away. On every tick the
timer wakes any task whose deadline has passed (a bounded scan, so it stays
deterministic), which makes it ready again; the scheduler then runs it when its
priority comes up. `sleep` measures its deadline on the [calibrated
clock](device-interrupts.md), so it's real time.
When *every* task is blocked, something still has to run — so there's an **idle
task** at the lowest priority that just `hlt`s until the next interrupt (see
[halting.md](halting.md)). Because it's always runnable, the scheduler always has a
task to pick, and the "nothing to run" case never arises.
## Event-based blocking
The other form of blocking is waiting for an **event** rather than a duration. A
**wait queue** is a set of tasks parked until something happens: `wait(wq)` blocks
the caller on it, `wake(wq)` moves the highest-priority waiter back to ready
(preempting if it now outranks the running task). A task links into a wait queue
through the same field the ready queues use — it's in exactly one queue at a time.
These are the primitives locks, semaphores and [IPC](ipc.md) are built on.
Blocking safely needs **composable critical sections**. A blanket `cli`/`sti` pair
doesn't nest: an IPC channel that `cli`s and then calls `wait` would have `wait`'s
`sti` re-enable interrupts too early, mid-operation. So the blocking primitives use
`saveInterrupts` / `restoreInterrupts` — capture the interrupt flag, disable, and
later restore *only if it was set* — which nests correctly. The invariant that
makes it all work: `schedule()` is always entered with interrupts disabled, so a
task always resumes from a switch with interrupts disabled and can restore its
caller's state.
## Verifying it ## Verifying it
Two tests (see [testing.md](testing.md)) prove the two guarantees: Three tests (see [testing.md](testing.md)) prove the guarantees:
- **`sched`** spawns three tasks that busy-loop *without ever yielding*. They all - **`sched`** spawns three tasks that busy-loop *without ever yielding*. They all
make progress — which can only happen if the timer is **preempting** between them make progress — which can only happen if the timer is **preempting** between them
and the context switch is correct (nothing yields voluntarily). and the context switch is correct (nothing yields voluntarily).
- **`priority`** (with preemption off, for determinism) spawns tasks at three - **`priority`** (with preemption off, for determinism) spawns tasks at three
priorities; they run and exit **highest-priority first**`[6, 4, 2]`. priorities; they run and exit **highest-priority first**`[6, 4, 2]`.
- **`sleep`** blocks a task for 50 ms and checks the elapsed time on the clock — a
real block (the idle task runs meanwhile), not a busy-wait.
- **`event`** blocks a task on a wait queue; waking it (from another task) resumes
it, and since it's higher priority it preempts immediately.
## What's next (not done here) ## What's next (not done here)
- **Blocking and sleep.** Right now a task can only yield or exit; it can't wait for
a condition or a duration. `sleep(ms)` (on the calibrated clock) and blocking
come next, and are what a real-time task really needs.
- **Priority inheritance.** Once tasks block on shared resources (locks, IPC), - **Priority inheritance.** Once tasks block on shared resources (locks, IPC),
danos will need it to bound priority inversion — a [real-time](vision.md) danos will need it to bound priority inversion — a [real-time](vision.md)
requirement. requirement.

View File

@ -47,11 +47,14 @@ Current cases:
|------|----------------|-----------------------------| |------|----------------|-----------------------------|
| `smoke` | memory map has usable RAM; frame alloc/free; paging active | `DANOS-TEST-RESULT: PASS` | | `smoke` | memory map has usable RAM; frame alloc/free; paging active | `DANOS-TEST-RESULT: PASS` |
| `timer` | device interrupts fire and return (tick count advances) | `DANOS-TEST-RESULT: PASS` | | `timer` | device interrupts fire and return (tick count advances) | `DANOS-TEST-RESULT: PASS` |
| `clock` | calibrated LAPIC frequency is sane; monotonic uptime advances | `DANOS-TEST-RESULT: PASS` | | `clock` | LAPIC + TSC calibrated; monotonic uptime advances; `nanos()` has sub-ms resolution | `DANOS-TEST-RESULT: PASS` |
| `vmm` | on-demand `map` works: a mapped page is writable and reads back | `DANOS-TEST-RESULT: PASS` | | `vmm` | on-demand `map` works: a mapped page is writable and reads back | `DANOS-TEST-RESULT: PASS` |
| `heap` | kernel heap: alloc/free, block reuse, growth, and a std container on it | `DANOS-TEST-RESULT: PASS` | | `heap` | kernel heap: alloc/free, block reuse, growth, and a std container on it | `DANOS-TEST-RESULT: PASS` |
| `sched` | preemption: three non-yielding tasks all make progress | `DANOS-TEST-RESULT: PASS` | | `sched` | preemption: three non-yielding tasks all make progress | `DANOS-TEST-RESULT: PASS` |
| `priority` | fixed-priority tasks run highest-first | `DANOS-TEST-RESULT: PASS` | | `priority` | fixed-priority tasks run highest-first | `DANOS-TEST-RESULT: PASS` |
| `sleep` | a task blocks for ~50 ms (real block, not a busy-wait) | `DANOS-TEST-RESULT: PASS` |
| `event` | a task blocks on a wait queue and is woken (preempting) | `DANOS-TEST-RESULT: PASS` |
| `ipc` | producer/consumer pass 100 messages through a 4-slot channel intact | `DANOS-TEST-RESULT: PASS` |
| `fault-ud` | invalid-opcode exception is caught | serial shows `invalid opcode (vector 6)` | | `fault-ud` | invalid-opcode exception is caught | serial shows `invalid opcode (vector 6)` |
| `fault-pf` | page fault caught with CR2 | `page fault (vector 14)` | | `fault-pf` | page fault caught with CR2 | `page fault (vector 14)` |
| `fault-df` | double fault caught on IST1 (not a triple-fault reset) | `double fault (vector 8)` | | `fault-df` | double fault caught on IST1 (not a triple-fault reset) | `double fault (vector 8)` |

View File

@ -44,6 +44,23 @@ var ticks_per_ms: u32 = 0;
/// The periodic-interrupt frequency the timer is armed at, once initTimer runs. /// The periodic-interrupt frequency the timer is armed at, once initTimer runs.
var timer_hz: u32 = 0; var timer_hz: u32 = 0;
/// TSC (Time Stamp Counter) calibration: cycles per second, and the count at boot.
/// The TSC is a per-core cycle counter, giving a ~nanosecond high-resolution
/// monotonic clock far finer than the millisecond timer tick.
var tsc_hz: u64 = 0;
var tsc_base: u64 = 0;
/// Read the 64-bit Time Stamp Counter.
fn rdtsc() u64 {
var low: u32 = undefined;
var high: u32 = undefined;
asm volatile ("rdtsc"
: [low] "={eax}" (low),
[high] "={edx}" (high),
);
return (@as(u64, high) << 32) | low;
}
fn read(reg: u32) u32 { fn read(reg: u32) u32 {
return @as(*volatile u32, @ptrFromInt(base + reg)).*; return @as(*volatile u32, @ptrFromInt(base + reg)).*;
} }
@ -78,10 +95,10 @@ pub fn init() void {
write(reg_spurious, 0x100 | spurious_vector); // bit 8 = software enable write(reg_spurious, 0x100 | spurious_vector); // bit 8 = software enable
} }
/// Measure the LAPIC timer's counting rate against the PIT (channel 2, which can /// Measure the LAPIC timer's and the TSC's rates against the PIT (channel 2, which
/// be polled without interrupts). We run the LAPIC timer one-shot from its max /// can be polled without interrupts). We run the LAPIC timer one-shot from its max
/// count while the PIT counts out a known 10 ms, then see how far the LAPIC got. /// count and snapshot the TSC while the PIT counts out a known 10 ms, then see how
/// This gives real time, which the RTOS quanta guarantees depend on. /// far each got. This gives real time, which the RTOS timing guarantees depend on.
pub fn calibrate() void { pub fn calibrate() void {
const pit_hz = 1_193_182; const pit_hz = 1_193_182;
const calib_ms = 10; const calib_ms = 10;
@ -99,13 +116,18 @@ pub fn calibrate() void {
io.outb(0x43, 0xB0); // channel 2, lo/hi byte, mode 0 io.outb(0x43, 0xB0); // channel 2, lo/hi byte, mode 0
io.outb(0x42, @truncate(pit_count)); io.outb(0x42, @truncate(pit_count));
io.outb(0x42, @truncate(pit_count >> 8)); io.outb(0x42, @truncate(pit_count >> 8));
io.outb(0x61, (io.inb(0x61) & 0xFC) | 0x01); // gate high -> start
const tsc_start = rdtsc();
io.outb(0x61, (io.inb(0x61) & 0xFC) | 0x01); // gate high -> start
while (io.inb(0x61) & 0x20 == 0) {} // poll channel-2 output until terminal count while (io.inb(0x61) & 0x20 == 0) {} // poll channel-2 output until terminal count
const tsc_end = rdtsc();
const elapsed = 0xFFFFFFFF - read(reg_timer_current); const elapsed = 0xFFFFFFFF - read(reg_timer_current);
write(reg_timer_initial, 0); // stop the timer write(reg_timer_initial, 0); // stop the timer
ticks_per_ms = elapsed / calib_ms; ticks_per_ms = elapsed / calib_ms;
tsc_hz = (tsc_end -% tsc_start) * (1000 / calib_ms); // cycles/10ms -> cycles/s
tsc_base = rdtsc(); // the clock's zero point (boot)
} }
/// Arm the LAPIC timer to fire on `timer_vector` at `hz` (periodic). Requires /// Arm the LAPIC timer to fire on `timer_vector` at `hz` (periodic). Requires
@ -128,10 +150,29 @@ pub fn lapicHz() u64 {
return @as(u64, ticks_per_ms) * 1000; return @as(u64, ticks_per_ms) * 1000;
} }
/// Milliseconds since the timer started (monotonic). Ticks accrue at timer_hz. /// Measured TSC frequency (Hz).
pub fn uptimeMs() u64 { pub fn tscHz() u64 {
if (timer_hz == 0) return 0; return tsc_hz;
return ticks() * 1000 / timer_hz; }
// Monotonic high-resolution clock, from the TSC. A function per resolution, each
// scaling the cycle delta directly at its unit (the 128-bit intermediate avoids
// overflow across a long uptime). nanos() resolves to a few ns; millis() is what
// the scheduler uses for sleep deadlines.
pub fn nanos() u64 {
if (tsc_hz == 0) return 0;
return @intCast(@as(u128, rdtsc() -% tsc_base) * 1_000_000_000 / tsc_hz);
}
pub fn micros() u64 {
if (tsc_hz == 0) return 0;
return @intCast(@as(u128, rdtsc() -% tsc_base) * 1_000_000 / tsc_hz);
}
pub fn millis() u64 {
if (tsc_hz == 0) return 0;
return @intCast(@as(u128, rdtsc() -% tsc_base) * 1_000 / tsc_hz);
} }
/// Acknowledge the current interrupt so the LAPIC will deliver the next one. /// Acknowledge the current interrupt so the LAPIC will deliver the next one.

View File

@ -78,15 +78,24 @@ pub fn ticks() u64 {
return apic.ticks(); return apic.ticks();
} }
/// Milliseconds since the timer started (monotonic). // Monotonic high-resolution clock (from the TSC), one function per resolution.
pub fn uptimeMs() u64 { pub fn nanos() u64 {
return apic.uptimeMs(); return apic.nanos();
}
pub fn micros() u64 {
return apic.micros();
}
pub fn millis() u64 {
return apic.millis();
} }
/// Measured LAPIC timer frequency in Hz (from calibration). /// Measured LAPIC timer / TSC frequencies in Hz (from calibration).
pub fn lapicHz() u64 { pub fn lapicHz() u64 {
return apic.lapicHz(); return apic.lapicHz();
} }
pub fn tscHz() u64 {
return apic.tscHz();
}
/// Unmask maskable interrupts (`sti`) so device interrupts get delivered. /// Unmask maskable interrupts (`sti`) so device interrupts get delivered.
pub fn enableInterrupts() void { pub fn enableInterrupts() void {
@ -98,6 +107,27 @@ pub fn disableInterrupts() void {
asm volatile ("cli"); asm volatile ("cli");
} }
/// Disable interrupts and return the previous flags, so a nested critical section
/// can restore the caller's state rather than blindly re-enabling. Pairs with
/// restoreInterrupts.
pub fn saveInterrupts() u64 {
var flags: u64 = undefined;
asm volatile (
\\pushfq
\\pop %[f]
\\cli
: [f] "=r" (flags),
:
: .{ .memory = true }
);
return flags;
}
/// Re-enable interrupts only if they were enabled when `flags` was captured.
pub fn restoreInterrupts(flags: u64) void {
if (flags & 0x200 != 0) asm volatile ("sti" ::: .{ .memory = true }); // bit 9 = IF
}
/// Register a callback the timer interrupt invokes each tick (e.g. the scheduler). /// Register a callback the timer interrupt invokes each tick (e.g. the scheduler).
pub fn setTickHook(hook: *const fn () void) void { pub fn setTickHook(hook: *const fn () void) void {
apic.setTickHook(hook); apic.setTickHook(hook);

54
src/ipc.zig Normal file
View File

@ -0,0 +1,54 @@
//! Inter-process communication: message-passing channels.
//!
//! IPC is the backbone of a microkernel ([vision](../docs/vision.md)): once
//! drivers and services live in separate address spaces, a message is how they
//! talk. This first form is a **bounded blocking channel** a ring buffer of
//! messages with a producer/consumer rendezvous, built on the scheduler's
//! [wait queues](scheduling.md). `send` blocks when the channel is full, `recv`
//! blocks when it's empty; neither busy-waits.
//!
//! For now both endpoints are kernel threads sharing the kernel address space.
//! 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("sched.zig");
/// A bounded blocking channel of `capacity` messages of type `T`.
pub fn Channel(comptime T: type, comptime capacity: usize) type {
return struct {
const Self = @This();
buffer: [capacity]T = undefined,
head: usize = 0, // next slot to read
tail: usize = 0, // next slot to write
count: usize = 0,
not_full: sched.WaitQueue = .{}, // senders wait here
not_empty: sched.WaitQueue = .{}, // receivers wait here
/// Send a message, blocking while the channel is full.
pub fn send(self: *Self, msg: T) void {
const flags = arch.saveInterrupts();
// 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);
self.buffer[self.tail] = msg;
self.tail = (self.tail + 1) % capacity;
self.count += 1;
sched.wakeLocked(&self.not_empty); // a receiver can now proceed
arch.restoreInterrupts(flags);
}
/// Receive a message, blocking while the channel is empty.
pub fn recv(self: *Self) T {
const flags = arch.saveInterrupts();
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);
return msg;
}
};
}

View File

@ -106,7 +106,7 @@ fn kmain(boot_info: *const BootInfo) noreturn {
// the timer preempts among tasks. // the timer preempts among tasks.
arch.startTimer(); arch.startTimer();
arch.enableInterrupts(); arch.enableInterrupts();
con.print("danos: timer online ({d} Hz tick, LAPIC {d} MHz measured)\n", .{ arch.timer_hz, arch.lapicHz() / 1_000_000 }); con.print("danos: timer online ({d} Hz tick; LAPIC {d} MHz, TSC {d} MHz measured)\n", .{ arch.timer_hz, arch.lapicHz() / 1_000_000, arch.tscHz() / 1_000_000 });
// In a test build (`zig build -Dtest-case=<name>`), run that case and stop. // In a test build (`zig build -Dtest-case=<name>`), run that case and stop.
// Normal builds fall through to the idle halt. // Normal builds fall through to the idle halt.

View File

@ -21,7 +21,7 @@ const num_priorities = 8;
const stack_size = 16 * 1024; // per-task kernel stack const stack_size = 16 * 1024; // per-task kernel stack
const max_tasks = 16; const max_tasks = 16;
const State = enum { free, ready, running }; const State = enum { free, ready, running, blocked };
const Task = struct { const Task = struct {
id: u32 = 0, id: u32 = 0,
@ -29,6 +29,7 @@ const Task = struct {
priority: Priority = 0, priority: Priority = 0,
rsp: usize = 0, // saved stack pointer, valid while not running rsp: usize = 0, // saved stack pointer, valid while not running
stack: []u8 = &.{}, stack: []u8 = &.{},
wake_at: u64 = 0, // uptime (ms) to wake a sleeping task; 0 = not sleeping
next: ?*Task = null, // ready-queue link next: ?*Task = null, // ready-queue link
}; };
@ -43,14 +44,21 @@ var ready_bitmap: u8 = 0;
var preemption_enabled = true; var preemption_enabled = true;
/// Register the currently-running kernel context as the first task, and hook the /// Register the currently-running kernel context as the first task, spawn the
/// timer for preemption. /// idle task, and hook the timer for preemption.
pub fn init(boot_priority: Priority) void { pub fn init(boot_priority: Priority) void {
tasks[0] = .{ .id = 0, .state = .running, .priority = boot_priority }; tasks[0] = .{ .id = 0, .state = .running, .priority = boot_priority };
current = &tasks[0]; current = &tasks[0];
spawn(idle, 0); // lowest priority, always runnable runs when nothing else is
arch.setTickHook(tick); arch.setTickHook(tick);
} }
/// The idle task: run when every other task is blocked or sleeping. `hlt` waits
/// for the next interrupt at near-zero power (see docs/halting.md).
fn idle() void {
while (true) asm volatile ("hlt");
}
fn enqueue(t: *Task) void { fn enqueue(t: *Task) void {
t.next = null; t.next = null;
const p: usize = t.priority; const p: usize = t.priority;
@ -113,13 +121,105 @@ fn schedule() void {
/// Voluntarily give up the CPU to the next ready task. /// Voluntarily give up the CPU to the next ready task.
pub fn yield() void { pub fn yield() void {
arch.disableInterrupts(); const flags = arch.saveInterrupts();
schedule(); schedule();
arch.enableInterrupts(); arch.restoreInterrupts(flags);
} }
/// Called from the timer interrupt (interrupts already disabled) to preempt. /// 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();
current.wake_at = arch.millis() + ms;
current.state = .blocked;
schedule(); // current is blocked, so schedule() won't re-enqueue it
arch.restoreInterrupts(flags);
}
// --- event-based blocking -------------------------------------------------
//
// A WaitQueue is a set of tasks blocked waiting for something (a resource, a
// message). Tasks link into it through the same `next` field the ready queues
// use a task is in exactly one queue at a time. These are the primitive locks,
// semaphores and IPC channels are built on.
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.
pub fn waitLocked(wq: *WaitQueue) void {
current.state = .blocked;
current.next = wq.head;
wq.head = current;
schedule();
}
/// Move the highest-priority waiter on `wq` (if any) to the ready queue.
/// Precondition: interrupts disabled. 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;
var best: ?*Task = null;
var prev: ?*Task = null;
var cur = wq.head;
while (cur) |t| : ({
prev = t;
cur = t.next;
}) {
if (best == null or t.priority > best.?.priority) {
best = t;
best_prev = prev;
}
}
const t = best orelse return;
if (best_prev) |p| p.next = t.next else wq.head = t.next;
t.state = .ready;
enqueue(t);
}
/// Block on `wq` (a self-contained critical section).
pub fn wait(wq: *WaitQueue) void {
const flags = arch.saveInterrupts();
waitLocked(wq);
arch.restoreInterrupts(flags);
}
/// Wake the highest-priority waiter on `wq`, preempting if it outranks us.
pub fn wake(wq: *WaitQueue) void {
const flags = arch.saveInterrupts();
wakeLocked(wq);
// If a higher-priority task is now ready, run it immediately.
if (highestReadyPriority()) |p| {
if (p > current.priority) schedule();
}
arch.restoreInterrupts(flags);
}
fn highestReadyPriority() ?Priority {
if (ready_bitmap == 0) return null;
return @intCast(num_priorities - 1 - @clz(ready_bitmap));
}
/// Wake any sleeping task whose deadline has passed. Bounded by the task count,
/// so it stays deterministic. Called from the timer tick (interrupts disabled).
fn wakeExpired() void {
const now = arch.millis();
for (&tasks) |*t| {
if (t.state == .blocked and t.wake_at != 0 and now >= t.wake_at) {
t.wake_at = 0;
t.state = .ready;
enqueue(t);
}
}
}
/// Called from the timer interrupt (interrupts already disabled): wake due
/// sleepers, then preempt.
pub fn tick() void { pub fn tick() void {
wakeExpired();
if (preemption_enabled) schedule(); if (preemption_enabled) schedule();
} }

View File

@ -15,6 +15,7 @@ const arch = @import("arch");
const pmm = @import("pmm.zig"); const pmm = @import("pmm.zig");
const heap = @import("heap.zig"); const heap = @import("heap.zig");
const sched = @import("sched.zig"); const sched = @import("sched.zig");
const ipc = @import("ipc.zig");
/// Formatted write straight to serial, independent of the framebuffer console. /// Formatted write straight to serial, independent of the framebuffer console.
fn log(comptime fmt: []const u8, args: anytype) void { fn log(comptime fmt: []const u8, args: anytype) void {
@ -60,6 +61,12 @@ pub fn run(case: []const u8, boot_info: *const BootInfo) void {
schedTest(); schedTest();
} else if (eql(case, "priority")) { } else if (eql(case, "priority")) {
priorityTest(); priorityTest();
} else if (eql(case, "sleep")) {
sleepTest();
} else if (eql(case, "event")) {
eventTest();
} else if (eql(case, "ipc")) {
ipcTest();
} else if (eql(case, "fault-ud")) { } else if (eql(case, "fault-ud")) {
faultInvalidOpcode(); faultInvalidOpcode();
} else if (eql(case, "fault-pf")) { } else if (eql(case, "fault-pf")) {
@ -211,27 +218,46 @@ fn heapTest() void {
result(); result();
} }
/// Verify the calibrated clock: a plausible measured LAPIC frequency, the /// Verify the calibrated clocks: sane measured frequencies, monotonic uptime that
/// configured tick rate, and monotonic uptime that advances with real ticks. /// advances with real ticks, and the point of the TSC clock nanosecond
/// resolution far finer than the 1 ms tick, with the unit functions consistent.
fn clock() void { fn clock() void {
log("DANOS-TEST-BEGIN: clock\n", .{}); log("DANOS-TEST-BEGIN: clock\n", .{});
// Calibration produced a sane LAPIC frequency (roughly 1 MHz .. 100 GHz).
const lapic = arch.lapicHz(); const lapic = arch.lapicHz();
check("LAPIC frequency measured", lapic > 1_000_000 and lapic < 100_000_000_000); check("LAPIC frequency measured", lapic > 1_000_000 and lapic < 100_000_000_000);
const tsc = arch.tscHz();
check("TSC frequency measured", tsc > 100_000_000 and tsc < 100_000_000_000);
// Wait for ~5 real ticks and confirm uptime advanced by about that many ms // Uptime advances over ~5 real ticks (1000 Hz => 1 tick == 1 ms).
// (tick rate is 1000 Hz, so 1 tick == 1 ms).
const start_ticks = arch.ticks(); const start_ticks = arch.ticks();
const start_ms = arch.uptimeMs(); const start_ms = arch.millis();
var spins: u64 = 0; var spins: u64 = 0;
while (arch.ticks() < start_ticks + 5 and spins < 5_000_000_000) spins +%= 1; while (arch.ticks() < start_ticks + 5 and spins < 5_000_000_000) spins +%= 1;
const elapsed_ms = arch.uptimeMs() - start_ms; const elapsed_ms = arch.millis() - start_ms;
check("uptime advances with ticks", elapsed_ms >= 5 and elapsed_ms < 100); check("uptime advances with ticks", elapsed_ms >= 5 and elapsed_ms < 100);
// Sub-millisecond resolution: spin until nanos() first advances, then confirm
// that first step happened within a millisecond so nanos() resolves finer
// than the 1 ms tick (a tick clock's smallest step *is* 1 ms). Spinning to the
// first change is robust to QEMU's coarse TSC update granularity.
const n1 = arch.nanos();
var s2: u64 = 0;
while (arch.nanos() == n1 and s2 < 10_000_000) s2 +%= 1;
const n2 = arch.nanos();
check("nanos() has sub-millisecond resolution", n2 > n1 and (n2 - n1) < 1_000_000);
// The unit functions agree (within rounding).
const ns = arch.nanos();
check("nanos/micros/millis are consistent", diffWithin(arch.micros(), ns / 1000, 1000) and diffWithin(arch.millis(), ns / 1_000_000, 2));
result(); result();
} }
fn diffWithin(a: u64, b: u64, tol: u64) bool {
return if (a > b) a - b <= tol else b - a <= tol;
}
// --- scheduler tests ------------------------------------------------------ // --- scheduler tests ------------------------------------------------------
var counters = [_]u64{0} ** 3; var counters = [_]u64{0} ** 3;
@ -292,7 +318,7 @@ fn taskLow() void {
fn priorityTest() void { fn priorityTest() void {
log("DANOS-TEST-BEGIN: priority\n", .{}); log("DANOS-TEST-BEGIN: priority\n", .{});
sched.setPreemption(false); sched.setPreemption(false);
sched.setPriority(0); // run this observer task last, after all workers sched.setPriority(1); // above the idle task (0), below the workers runs last
run_n = 0; run_n = 0;
sched.spawn(taskLow, 2); sched.spawn(taskLow, 2);
@ -308,6 +334,80 @@ fn priorityTest() void {
result(); result();
} }
var event_wq: sched.WaitQueue = .{};
var event_stage: u32 = 0;
fn eventWaiter() void {
event_stage = 1; // reached the wait
sched.wait(&event_wq); // block until woken
event_stage = 3; // woken and resumed
sched.exit();
}
/// Event-based blocking: a task blocks on a wait queue and is woken. The waiter is
/// higher priority, so waking it preempts us and it runs to completion at once.
fn eventTest() void {
log("DANOS-TEST-BEGIN: event\n", .{});
event_stage = 0;
sched.spawn(eventWaiter, 6); // higher priority than this task (4)
var spins: u64 = 0;
while (event_stage != 1 and spins < 1_000_000_000) : (spins += 1) sched.yield();
check("waiter reached the wait and blocked", event_stage == 1);
sched.wake(&event_wq);
check("wake resumed the blocked waiter (preempting)", event_stage == 3);
result();
}
var channel: ipc.Channel(u64, 4) = .{};
var recv_sum: u64 = 0;
var recv_count: u64 = 0;
fn producer() void {
var i: u64 = 1;
while (i <= 100) : (i += 1) channel.send(i);
sched.exit();
}
fn consumer() void {
var n: u64 = 0;
while (n < 100) : (n += 1) {
recv_sum += channel.recv();
recv_count += 1;
}
sched.exit();
}
/// IPC: a producer and consumer pass 100 messages through a 4-slot channel. The
/// small buffer forces the channel full and empty repeatedly, exercising both the
/// blocking-send and blocking-recv paths. The messages must arrive intact.
fn ipcTest() void {
log("DANOS-TEST-BEGIN: ipc\n", .{});
channel = .{};
recv_sum = 0;
recv_count = 0;
sched.spawn(consumer, 5); // above this task (4) so they run and we observe after
sched.spawn(producer, 5);
var spins: u64 = 0;
while (recv_count < 100 and spins < 2_000_000_000) : (spins += 1) sched.yield();
check("all 100 messages received", recv_count == 100);
check("messages arrived intact (sum 1..100 == 5050)", recv_sum == 5050);
result();
}
/// Blocking: sleep(50) should block this task for about 50 ms (measured on the
/// calibrated clock) not busy-wait while the idle task runs.
fn sleepTest() void {
log("DANOS-TEST-BEGIN: sleep\n", .{});
const t0 = arch.millis();
sched.sleep(50);
const elapsed = arch.millis() - t0;
check("sleep(50) blocked for ~50 ms", elapsed >= 50 and elapsed <= 70);
result();
}
fn faultInvalidOpcode() void { fn faultInvalidOpcode() void {
log("DANOS-TEST-BEGIN: fault-ud\n", .{}); log("DANOS-TEST-BEGIN: fault-ud\n", .{});
asm volatile ("ud2"); asm volatile ("ud2");

View File

@ -81,6 +81,15 @@ CASES = [
{"name": "priority", {"name": "priority",
"expect": r"DANOS-TEST-RESULT: PASS", "expect": r"DANOS-TEST-RESULT: PASS",
"fail": r"DANOS-TEST-RESULT: FAIL"}, "fail": r"DANOS-TEST-RESULT: FAIL"},
{"name": "sleep",
"expect": r"DANOS-TEST-RESULT: PASS",
"fail": r"DANOS-TEST-RESULT: FAIL"},
{"name": "event",
"expect": r"DANOS-TEST-RESULT: PASS",
"fail": r"DANOS-TEST-RESULT: FAIL"},
{"name": "ipc",
"expect": r"DANOS-TEST-RESULT: PASS",
"fail": r"DANOS-TEST-RESULT: FAIL"},
{"name": "fault-ud", "expect": r"invalid opcode \(vector 6\)"}, {"name": "fault-ud", "expect": r"invalid opcode \(vector 6\)"},
{"name": "fault-pf", "expect": r"page fault \(vector 14\)"}, {"name": "fault-pf", "expect": r"page fault \(vector 14\)"},
{"name": "fault-df", "expect": r"double fault \(vector 8\)"}, {"name": "fault-df", "expect": r"double fault \(vector 8\)"},