Calibrated timer / clock

This commit is contained in:
2026-07-03 14:17:00 +01:00
parent 9a834c91ff
commit e80043b611
9 changed files with 212 additions and 14 deletions
+6
View File
@@ -34,6 +34,12 @@ rather than restate it. Roughly in the order things happen at runtime:
10. **[halting.md](halting.md) — halting.** Why a kernel can't just "exit", and 10. **[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:
- **[vision.md](vision.md) — the vision.** danos is aiming to be a real-time
microkernel: minimal kernel, drivers/services isolated in user space, preemptive
scheduling with timing guarantees. The *why* that shapes everything below.
Cutting across all of these: Cutting across all of these:
- **[arch.md](arch.md) — the architecture split.** How CPU-specific code is kept - **[arch.md](arch.md) — the architecture split.** How CPU-specific code is kept
+11 -5
View File
@@ -39,10 +39,15 @@ LVT-timer entry giving it a **vector** (32) and **periodic** mode, then an initi
count that becomes the reload value. From then on it fires vector 32 repeatedly, on count that becomes the reload value. From then on it fires vector 32 repeatedly, on
its own, forever. its own, forever.
> The count isn't calibrated to real time yet — the tick *rate* is arbitrary The reload count isn't picked arbitrarily — it's **calibrated to real time**,
> (bus-clock dependent). Turning it into a known frequency (say 100 Hz) needs a which the [real-time](vision.md) scheduling guarantees depend on. Since the LAPIC
> reference clock to measure against (the PIT, HPET, or the TSC). That's a later timer's raw rate is bus-clock dependent and unknown up front, `calibrate` measures
> step; for now it just needs to tick. 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
(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
count for any target frequency. danos runs it at **1000 Hz** (a 1 ms tick), and the
tick count times the known period gives a monotonic `uptimeMs()`.
## Two kinds of vector, one dispatch ## Two kinds of vector, one dispatch
@@ -101,7 +106,8 @@ spinning in unrelated code — is the whole mechanism working end to end.
- **The keyboard**: bring up the IO-APIC, route its IRQ to a vector, and read - **The keyboard**: bring up the IO-APIC, route its IRQ to a vector, and read
scancodes from the PS/2 controller — the first *input* device. scancodes from the PS/2 controller — the first *input* device.
- **A calibrated timer** at a known frequency, and a monotonic clock. - **`sleep()` / timeouts** built on the calibrated clock (the monotonic
`uptimeMs()` is in place).
- **Uncacheable MMIO**: the LAPIC page is currently mapped writeback-cacheable like - **Uncacheable MMIO**: the LAPIC page is currently mapped writeback-cacheable like
the rest of the identity map. QEMU tolerates it, but real hardware wants MMIO the rest of the identity map. QEMU tolerates it, but real hardware wants MMIO
marked uncacheable (via the page's cache bits or an MTRR). marked uncacheable (via the page's cache bits or an MTRR).
+1
View File
@@ -47,6 +47,7 @@ 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` |
| `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` |
| `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)` |
+84
View File
@@ -0,0 +1,84 @@
# Vision: a real-time microkernel
danos is aiming to be a **real-time operating system built on a microkernel** —
where drivers and services run isolated in user space for maximum stability, and
scheduling gives real guarantees about timing. This page is the north star: the
*why* that shapes every design decision below it. Read it before adding anything
structural.
## Microkernel
The kernel stays **minimal** — only what genuinely must run in privileged mode:
- scheduling,
- inter-process communication (IPC),
- memory management (address spaces, page tables),
- low-level interrupt dispatch.
Everything else — device drivers, filesystems, the network stack — runs as an
**isolated user-space server**, each in its own address space with only the
privileges it needs.
The payoff is **stability through isolation**. A driver bug can't corrupt the
kernel or another driver; a crashing service is contained and can be restarted,
while the rest of the system keeps running. That's the opposite of a monolithic
kernel, where a single driver fault can take everything down.
The cost is that **IPC becomes the backbone**: whatever used to be a function call
across a monolithic kernel is now a message between address spaces. In a
microkernel, IPC performance essentially *is* system performance (the lesson of
L4). So IPC must be fast, and it's a first-class concern, not an afterthought.
Hardware interrupts, too, become IPC: the kernel turns an IRQ into a message to the
driver task that owns that device.
## Real-time
danos schedules **preemptively, with guarantees about quanta** — the system must
be able to promise that a task runs when it's supposed to, within bounded time.
That imposes concrete requirements:
- **Fixed-priority preemptive scheduling.** The highest-priority ready task always
runs; a higher-priority task that becomes ready preempts a lower one immediately.
Not round-robin (which is fair but not predictable).
- **A calibrated, deterministic clock.** Guarantees measured in "quanta" are
meaningless on an arbitrary tick rate — real time requires a timer calibrated to
a known frequency.
- **Bounded interrupt latency.** Interrupt-disabled sections must be short and
bounded, so a ready high-priority task is never delayed by an unbounded kernel
operation.
- **Deterministic kernel operations.** Scheduling decisions should be O(1) (e.g. a
priority bitmap), not "walk a list of unknown length."
- **Priority inheritance** (once there are locks/IPC), so a high-priority task
blocked on a resource held by a low-priority one can't be delayed indefinitely by
a middle-priority task — bounding priority inversion.
A consequence worth stating early: the current [kernel heap](heap.md) is a
first-fit free list, which has **unbounded allocation time** and can fragment — it
is *not* real-time safe. It's fine for one-time kernel setup, but real-time paths
must pre-allocate or use a bounded (fixed-size pool) allocator. Don't allocate on a
hot real-time path.
## What this means for the roadmap
The vision reorders the obvious hobby-kernel path. Notably, **drivers are not
built into the kernel** — so an in-kernel keyboard driver would be throwaway work.
Input devices arrive later, as the *first user-space drivers*, once the machinery
to isolate them exists. The trajectory:
1. **Calibrated timer / clock** — a known-frequency, deterministic tick. The
foundation real-time quanta rest on. *(next)*
2. **Real-time scheduler** — fixed-priority preemptive, kernel threads first:
context switch, task struct, priority run-queue, timer-driven preemption.
3. **User mode + address-space isolation** — higher-half kernel, ring 3, per-process
page tables. The substrate for isolated servers.
4. **IPC** — fast message passing between address spaces. The microkernel's heart.
5. **User-space drivers** — interrupts delivered as IPC, plus MMIO/port-access
grants. The keyboard becomes the first one, validating the whole model.
## Where we are
The foundation is in place: UEFI boot, framebuffer + [serial](testing.md),
[physical frames](frame-allocator.md), [paging](paging.md) with W^X, [exceptions
and interrupts](interrupts.md), a [timer](device-interrupts.md), and a
[heap](heap.md) — plus a [test harness](testing.md). The kernel boots and has its
core services; the next milestones make it *schedule*, then *isolate*.
+65 -5
View File
@@ -22,8 +22,13 @@ const reg_spurious = 0x0F0;
const reg_eoi = 0x0B0; const reg_eoi = 0x0B0;
const reg_lvt_timer = 0x320; const reg_lvt_timer = 0x320;
const reg_timer_initial = 0x380; const reg_timer_initial = 0x380;
const reg_timer_current = 0x390;
const reg_timer_divide = 0x3E0; const reg_timer_divide = 0x3E0;
const lvt_masked = 1 << 16;
const lvt_periodic = 1 << 17;
const timer_divide_16 = 0x3;
const ia32_apic_base_msr = 0x1B; const ia32_apic_base_msr = 0x1B;
/// LAPIC MMIO base. A runtime var (not a constant) both because we read it from /// LAPIC MMIO base. A runtime var (not a constant) both because we read it from
@@ -33,6 +38,12 @@ var base: usize = 0xFEE00000;
var tick_count: u64 = 0; var tick_count: u64 = 0;
/// LAPIC timer counts per millisecond, measured against the PIT (see calibrate).
/// At divide-by-16, this is the effective counting rate.
var ticks_per_ms: u32 = 0;
/// The periodic-interrupt frequency the timer is armed at, once initTimer runs.
var timer_hz: u32 = 0;
fn read(reg: u32) u32 { fn read(reg: u32) u32 {
return @as(*volatile u32, @ptrFromInt(base + reg)).*; return @as(*volatile u32, @ptrFromInt(base + reg)).*;
} }
@@ -67,11 +78,60 @@ pub fn init() void {
write(reg_spurious, 0x100 | spurious_vector); // bit 8 = software enable write(reg_spurious, 0x100 | spurious_vector); // bit 8 = software enable
} }
/// Arm the LAPIC timer in periodic mode on `timer_vector`. /// Measure the LAPIC timer's counting rate against the PIT (channel 2, which can
pub fn initTimer() void { /// be polled without interrupts). We run the LAPIC timer one-shot from its max
write(reg_timer_divide, 0x3); // divide bus clock by 16 /// count while the PIT counts out a known 10 ms, then see how far the LAPIC got.
write(reg_lvt_timer, timer_vector | (1 << 17)); // periodic mode /// This gives real time, which the RTOS quanta guarantees depend on.
write(reg_timer_initial, 1_000_000); // reload count -> periodic ticks pub fn calibrate() void {
const pit_hz = 1_193_182;
const calib_ms = 10;
const pit_count: u16 = @intCast(pit_hz / 1000 * calib_ms);
// LAPIC timer: divide 16, masked (no interrupt — we just want the count),
// counting down from the maximum.
write(reg_timer_divide, timer_divide_16);
write(reg_lvt_timer, lvt_masked);
write(reg_timer_initial, 0xFFFFFFFF);
// PIT channel 2, mode 0 (interrupt on terminal count): load the count with the
// gate low, then raise the gate to start it counting.
io.outb(0x61, io.inb(0x61) & 0xFC); // speaker off, gate low
io.outb(0x43, 0xB0); // channel 2, lo/hi byte, mode 0
io.outb(0x42, @truncate(pit_count));
io.outb(0x42, @truncate(pit_count >> 8));
io.outb(0x61, (io.inb(0x61) & 0xFC) | 0x01); // gate high -> start
while (io.inb(0x61) & 0x20 == 0) {} // poll channel-2 output until terminal count
const elapsed = 0xFFFFFFFF - read(reg_timer_current);
write(reg_timer_initial, 0); // stop the timer
ticks_per_ms = elapsed / calib_ms;
}
/// Arm the LAPIC timer to fire on `timer_vector` at `hz` (periodic). Requires
/// calibrate() to have run.
pub fn initTimer(hz: u32) void {
timer_hz = hz;
const count = @as(u64, ticks_per_ms) * 1000 / hz; // counts per (1/hz) second
write(reg_timer_divide, timer_divide_16);
write(reg_lvt_timer, timer_vector | lvt_periodic);
write(reg_timer_initial, @intCast(count));
}
/// Configured periodic-interrupt frequency (Hz).
pub fn frequencyHz() u32 {
return timer_hz;
}
/// Measured LAPIC timer frequency (Hz), for reporting/sanity checks.
pub fn lapicHz() u64 {
return @as(u64, ticks_per_ms) * 1000;
}
/// Milliseconds since the timer started (monotonic). Ticks accrue at timer_hz.
pub fn uptimeMs() u64 {
if (timer_hz == 0) return 0;
return ticks() * 1000 / timer_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.
+18 -3
View File
@@ -60,12 +60,17 @@ pub fn readCr3() u64 {
); );
} }
/// Enable the Local APIC and start its periodic timer, the kernel's heartbeat. /// Kernel tick rate: 1000 Hz (1 ms), the scheduler's time quantum.
/// Interrupts still have to be unmasked with enableInterrupts() to be delivered. pub const timer_hz = 1000;
/// Enable the Local APIC, calibrate its timer against the PIT, and start it firing
/// at `timer_hz` — the kernel's real-time heartbeat. Interrupts still have to be
/// unmasked with enableInterrupts() to be delivered.
pub fn startTimer() void { pub fn startTimer() void {
apic.init(); apic.init();
apic.calibrate();
idt.setHandler(apic.timer_vector, apic.timerTick); idt.setHandler(apic.timer_vector, apic.timerTick);
apic.initTimer(); apic.initTimer(timer_hz);
} }
/// Number of timer ticks since startTimer(). /// Number of timer ticks since startTimer().
@@ -73,6 +78,16 @@ pub fn ticks() u64 {
return apic.ticks(); return apic.ticks();
} }
/// Milliseconds since the timer started (monotonic).
pub fn uptimeMs() u64 {
return apic.uptimeMs();
}
/// Measured LAPIC timer frequency in Hz (from calibration).
pub fn lapicHz() u64 {
return apic.lapicHz();
}
/// 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 {
asm volatile ("sti"); asm volatile ("sti");
+1 -1
View File
@@ -100,7 +100,7 @@ fn kmain(boot_info: *const BootInfo) noreturn {
// Start the timer and unmask interrupts — the kernel now has a heartbeat. // Start the timer and unmask interrupts — the kernel now has a heartbeat.
arch.startTimer(); arch.startTimer();
arch.enableInterrupts(); arch.enableInterrupts();
con.write("danos: timer interrupts enabled\n"); con.print("danos: timer online ({d} Hz tick, LAPIC {d} MHz measured)\n", .{ arch.timer_hz, arch.lapicHz() / 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.
+23
View File
@@ -49,6 +49,8 @@ pub fn run(case: []const u8, boot_info: *const BootInfo) void {
smoke(boot_info); smoke(boot_info);
} else if (eql(case, "timer")) { } else if (eql(case, "timer")) {
timer(); timer();
} else if (eql(case, "clock")) {
clock();
} else if (eql(case, "vmm")) { } else if (eql(case, "vmm")) {
vmm(); vmm();
} else if (eql(case, "heap")) { } else if (eql(case, "heap")) {
@@ -204,6 +206,27 @@ fn heapTest() void {
result(); result();
} }
/// Verify the calibrated clock: a plausible measured LAPIC frequency, the
/// configured tick rate, and monotonic uptime that advances with real ticks.
fn clock() void {
log("DANOS-TEST-BEGIN: clock\n", .{});
// Calibration produced a sane LAPIC frequency (roughly 1 MHz .. 100 GHz).
const lapic = arch.lapicHz();
check("LAPIC frequency measured", lapic > 1_000_000 and lapic < 100_000_000_000);
// Wait for ~5 real ticks and confirm uptime advanced by about that many ms
// (tick rate is 1000 Hz, so 1 tick == 1 ms).
const start_ticks = arch.ticks();
const start_ms = arch.uptimeMs();
var spins: u64 = 0;
while (arch.ticks() < start_ticks + 5 and spins < 5_000_000_000) spins +%= 1;
const elapsed_ms = arch.uptimeMs() - start_ms;
check("uptime advances with ticks", elapsed_ms >= 5 and elapsed_ms < 100);
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");
+3
View File
@@ -66,6 +66,9 @@ CASES = [
{"name": "timer", {"name": "timer",
"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": "clock",
"expect": r"DANOS-TEST-RESULT: PASS",
"fail": r"DANOS-TEST-RESULT: FAIL"},
{"name": "vmm", {"name": "vmm",
"expect": r"DANOS-TEST-RESULT: PASS", "expect": r"DANOS-TEST-RESULT: PASS",
"fail": r"DANOS-TEST-RESULT: FAIL"}, "fail": r"DANOS-TEST-RESULT: FAIL"},