Files
danos/docs/timers.md
Daniel Samson 452080e997 Add runtime.time, drop demo drivers, harden TSC timekeeping
Time is a kernel concern in danos: the kernel owns the scheduling timer and
already exposes monotonic time via the clock/sleep/timer_bind syscalls, so a
userspace time service would be a redundant, slower path. This adds the generic
runtime.time module over those syscalls, retires the two demonstration drivers,
reorganizes the milestone docs, and makes the monotonic clock correct on Intel,
AMD, and inside any VM.

runtime.time (library/runtime/time.zig)
- Instant/Duration interface: now, sleep, spin, after, monotonicNanos, available
- a thin layer over system.clock/sleep/timerOnce; unit-tested arithmetic

Remove the demo drivers hpet and bus (a teaching example belongs in the docs,
not shipped in the tree)
- system/drivers/ now holds only real drivers: pci-bus, ps2-bus, usb-xhci-bus
- device-manager end-to-end test repointed to pci-bus (asserts on kernel state:
  the process table and the device tree, not a racy serial marker)
- device_register containment moved to a new in-kernel `containment` test
- the driver-model worked example moved inline into docs/drivers.md

Reorganize milestone docs into topic docs
- m17-m18 / m19-m20 / m21 plans dissolved into process-lifecycle, device-manager,
  discovery, and acpi docs; new docs/power.md and docs/timers.md; ~20 citations
  repointed; plan docs deleted

TSC reliability (apic.zig, smp.zig, cpu.zig, kernel.zig)
- check the invariant-TSC bit (CPUID 0x80000007 EDX[8]) on Intel and AMD
- cross-core "warp" check at SMP bring-up, pairwise BSP<->AP as each core comes up
- fall back to the HPET clocksource when the TSC is not invariant (a bare VM) or
  not synchronized (a warp), switched continuously so time never jumps
- boot log reports the outcome; new tsc-sync test exercises the TSC + warp path

Verified: zig build; zig build test; 60/60 QEMU cases (incl. new containment and
tsc-sync).
2026-07-13 11:56:43 +01:00

6.3 KiB

Timers and time

Two different needs hide under the word "timer", and danos keeps them apart:

  • Reading the clock — what time is it? A read of a free-running counter.
  • Waiting — wake me in N milliseconds, or notify me when a deadline passes.

Both are answered by the kernel, because the kernel already owns a timer: it has to, to preempt tasks. The LAPIC heartbeat and the calibrated TSC that back all of this are built in device-interrupts.md; the scheduler's blocking and wait queues are in scheduling.md. This page is about the surface a ring-3 program actually uses, and one deliberate absence: there is no user-space time service.

Why time is a syscall, not a service

The tempting microkernel move is to put a timer driver in user space and have applications ask it for the time over IPC. For a monotonic clock that is wrong — reading now() should never cost an IPC round trip. The kernel is already holding the answer: it computes the current time every time it schedules, from the TSC, in a couple of instructions. Surfacing that as a system call is pure mechanism; routing it through a message to another process would be slower and redundant, and a device like the HPET (uncacheable MMIO reads) is a particularly bad thing to read on every now().

This is the same conclusion every serious system reaches: Linux and Zircon read the counter in the vDSO, L4 exposes a clock field in a shared kernel page, seL4 reads the cycle counter directly. None of them make a clock read an IPC. danos makes it a syscall.

That "from the TSC" hides a portability question, because the TSC is only a valid clock when the CPU guarantees it is invariant and when every core's TSC is synchronized. danos checks both — the invariant-TSC CPUID bit (0x80000007 EDX[8], set on Intel and AMD), and a cross-core "warp" check as the cores come up — and falls back to the HPET counter when either fails. So now() stays accurate on a real Intel box, a real AMD box, and inside a VM alike; only the source behind it differs. The mechanism is in device-interrupts.md.

So the timer hardware lives in the kernel, and there is no hpet driver and no time server to consume. (An earlier HPET driver existed only to demonstrate the driver model; that role now lives in drivers.md, as documentation.) The one place a user-space time service is justified — wall-clock / calendar time — is discussed at the end; it is deliberately not built yet.

The three system calls

Time and waiting are three entries in the small syscall table (syscall.md):

  • clock (#23) → monotonic nanoseconds since boot. It only moves forward. Not wall-clock: no date, no timezone. Backed by architecture.nanos() (TSC, scaled with a 128-bit intermediate so a long uptime can't overflow) — a few nanoseconds of resolution, and just an rdtsc plus a multiply.
  • sleep (#3) → block the caller for N milliseconds. The scheduler records a wake deadline and the tick sweep wakes it (scheduler.sleep).
  • timer_bind (#31) → arm a one-shot timer that, after N milliseconds, posts a timer notification to an IPC endpoint. Unlike sleep it does not block: a service can keep answering messages on the same endpoint while a deadline is pending. This is the timed wait that stop-sequence escalation, hello deadlines, and restart backoff are built from (process-lifecycle.md, device-manager.md).

The kernel's own scheduling timer (the LAPIC, vector 32) is never exposed to user space; programs read the TSC through clock and get timed wakeups through sleep/timer_bind, both riding the scheduler tick.

runtime.time — the generic interface

Applications don't call the syscalls directly; they use runtime.time (library/runtime/time.zig), a thin Instant/Duration layer over them — an ergonomic front door, not new mechanism.

const time = @import("runtime").time;

const start = time.now();                 // Instant — monotonic
doWork();
const took = start.elapsed();             // Duration
time.sleep(time.Duration.fromMillis(5));  // block ~5 ms

// A deadline delivered as a notification, so a service keeps serving meanwhile:
_ = time.after(endpoint, time.Duration.fromMillis(200));
  • Duration is nanoseconds under the hood, with fromNanos/fromMicros/fromMillis/ fromSeconds and asNanos/asMillis. ceilMillis rounds up to the kernel's millisecond granularity, so a sub-millisecond sleep never rounds down to zero and returns early. All arithmetic saturates rather than wraps.
  • Instant is a point on the monotonic clock: since, elapsed, plus, reached — built for deadline loops (while (!deadline.reached()) …).
  • now() / monotonicNanos() wrap clock. available() reports whether the clock is calibrated at all (the kernel returns 0 until the TSC frequency is known, so a caller that needs real time can treat 0 as "unavailable" rather than assume it advances).
  • sleep(d) wraps sleep; spin(d) busy-polls now() for the sub-millisecond delays the millisecond tick can't express; after(endpoint, d) wraps timer_bind.

The raw wrappers (system.clock, system.sleep, system.timerOnce) stay in library/runtime/system.zig; runtime.time is the layer meant for everyday use.

Wall-clock time (not built)

Everything above is monotonic: elapsed time since boot, perfect for timeouts and measurement, useless for "what is the date?" Calendar time — a real-time clock, time zones, leap seconds — is genuinely a user-space concern, and it is the case a time service is for. It would be backed by an RTC driver (the CMOS real-time clock), not the HPET, and exposed as a CLOCK_REALTIME-style service alongside the monotonic syscall. It is deferred until something needs it; the monotonic clock the kernel already owns covers every current use.

Verifying it

runtime.time's Instant/Duration arithmetic has unit tests that run on the host:

$ zig build test        # includes library/runtime/time.zig

End to end, the proof the clock is real is that it advances: read now(), sleep a Duration, read now() again, and the second reading is later — the kernel's timer driving a ring-3 program with no service in between.