refactor kernel to use device platform discovery

This commit is contained in:
Daniel Samson
2026-07-08 10:06:46 +01:00
parent 2ee898a91e
commit 7d3417fe86
9 changed files with 572 additions and 43 deletions
+171 -15
View File
@@ -10,6 +10,30 @@
//! the LAPIC won't deliver the next one.
const io = @import("io.zig");
const paging = @import("paging.zig");
/// The ACPI PM timer, as a calibration reference: an I/O port or MMIO counter.
pub const PmTimer = struct { mmio: bool, address: u64, is_32bit: bool };
// Platform facts from discovery (set by `configure` before bring-up). Defaults are
// the legacy-safe assumptions so the code still works if discovery never ran.
var cfg_pic_present: bool = true;
var cfg_hpet_base: u64 = 0; // 0 = no HPET discovered
var cfg_pm_timer: ?PmTimer = null;
/// Which reference the last calibration used, for logging.
var cal_source: []const u8 = "none";
/// Hand the LAPIC bring-up the discovered platform facts. Call before `init`.
pub fn configure(pic_present: bool, hpet_base: u64, pm_timer: ?PmTimer) void {
cfg_pic_present = pic_present;
cfg_hpet_base = hpet_base;
cfg_pm_timer = pm_timer;
}
/// The calibration reference the timer was measured against ("cpuid"/"hpet"/…).
pub fn calibrationSource() []const u8 {
return cal_source;
}
/// IDT vector the timer fires on (in the device range, >= 32).
pub const timer_vector = 32;
@@ -83,10 +107,11 @@ fn remapAndMaskPic() void {
io.outb(0xA1, 0xFF);
}
/// Enable the Local APIC: mask the PIC, set the global-enable MSR bit, and
/// Enable the Local APIC: mask the PIC (only if one is present — a legacy-free
/// UEFI Class 3 machine may have none), set the global-enable MSR bit, and
/// software-enable the APIC via its spurious-vector register.
pub fn init() void {
remapAndMaskPic();
if (cfg_pic_present) remapAndMaskPic();
const msr = io.rdmsr(ia32_apic_base_msr);
base = @intCast(msr & 0xFFFFF000); // physical base is bits 12+
@@ -95,23 +120,91 @@ pub fn init() void {
write(reg_spurious, 0x100 | spurious_vector); // bit 8 = software enable
}
/// Measure the LAPIC timer's and the TSC's rates against the PIT (channel 2, which
/// can be polled without interrupts). We run the LAPIC timer one-shot from its max
/// count and snapshot the TSC while the PIT counts out a known 10 ms, then see how
/// far each got. This gives real time, which the RTOS timing guarantees depend on.
/// The calibration window: we time everything against a 10 ms reference interval.
const calib_ms = 10;
/// Measure the LAPIC timer's and the TSC's rates. The PIT (legacy 8254) can be
/// absent on UEFI Class 3 firmware — and polling it would hang — so we pick a
/// reference clock in order of preference: the CPU's own TSC frequency (CPUID leaf
/// 0x15, no external timer needed), then the discovered HPET, then the ACPI PM
/// timer, and only the PIT as a last resort. Each path yields the same two rates.
pub fn calibrate() void {
var done = false;
// 1. CPUID leaf 0x15 gives the TSC frequency directly — measure the LAPIC
// against the TSC itself, needing no external timer at all.
if (cpuidTscHz()) |hz| {
measure(hz, ~@as(u64, 0), rdtsc);
tsc_hz = hz; // keep the exact enumerated value
cal_source = "cpuid";
done = true;
}
// 2. The discovered HPET.
if (!done and cfg_hpet_base != 0) {
if (hpetHz()) |hpet_hz| {
measure(hpet_hz, hpetMask(), readHpet);
cal_source = "hpet";
done = true;
}
}
// 3. The ACPI PM timer (fixed 3.579545 MHz).
if (!done) {
if (cfg_pm_timer) |pt| {
measure(3_579_545, if (pt.is_32bit) 0xFFFF_FFFF else 0xFF_FFFF, readPmTimer);
cal_source = "pm-timer";
done = true;
}
}
// 4. The legacy PIT, last resort.
if (!done) {
calibratePit();
cal_source = "pit";
}
// A bad measurement (no reference actually ticked) leaves nonsense; fall back.
if (ticks_per_ms == 0 or tsc_hz == 0) {
calibratePit();
cal_source = "pit";
}
tsc_base = rdtsc(); // the clock's zero point (boot)
}
/// Run the LAPIC timer one-shot from its max count while a monotonic reference
/// clock (frequency `ref_hz`, counter width `ref_mask`) counts out `calib_ms`, and
/// snapshot the TSC across the same window. Yields `ticks_per_ms` and `tsc_hz`.
fn measure(ref_hz: u64, ref_mask: u64, refNow: *const fn () u64) void {
const calib_ticks = ref_hz / (1000 / calib_ms); // reference ticks in calib_ms
write(reg_timer_divide, timer_divide_16);
write(reg_lvt_timer, lvt_masked);
write(reg_timer_initial, 0xFFFFFFFF);
const ref0 = refNow();
const tsc0 = rdtsc();
while (((refNow() -% ref0) & ref_mask) < calib_ticks) {}
const tsc1 = rdtsc();
const elapsed = 0xFFFFFFFF - read(reg_timer_current);
write(reg_timer_initial, 0);
ticks_per_ms = elapsed / calib_ms;
tsc_hz = (tsc1 -% tsc0) * (1000 / calib_ms);
}
/// The PIT fallback (legacy 8254 channel 2, polled). Only reached when no better
/// reference exists — on a legacy-free machine this path isn't taken.
fn calibratePit() 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));
@@ -119,15 +212,78 @@ pub fn calibrate() void {
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
var guard: u64 = 0;
while (io.inb(0x61) & 0x20 == 0 and guard < 100_000_000) : (guard += 1) {} // bounded
const tsc_end = rdtsc();
const elapsed = 0xFFFFFFFF - read(reg_timer_current);
write(reg_timer_initial, 0); // stop the timer
write(reg_timer_initial, 0);
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)
tsc_hz = (tsc_end -% tsc_start) * (1000 / calib_ms);
}
// --- reference clocks ------------------------------------------------------
/// TSC frequency from CPUID leaf 0x15 (crystal_hz * numerator / denominator), or
/// null if the CPU doesn't enumerate it (common under QEMU).
fn cpuidTscHz() ?u64 {
if (cpuid(0).eax < 0x15) return null;
const r = cpuid(0x15);
if (r.eax == 0 or r.ebx == 0 or r.ecx == 0) return null; // ratio/crystal not given
return @as(u64, r.ecx) * r.ebx / r.eax;
}
const CpuidRegs = struct { eax: u32, ebx: u32, ecx: u32, edx: u32 };
fn cpuid(leaf: u32) CpuidRegs {
var a: u32 = undefined;
var b: u32 = undefined;
var c: u32 = undefined;
var d: u32 = undefined;
asm volatile ("cpuid"
: [a] "={eax}" (a),
[b] "={ebx}" (b),
[c] "={ecx}" (c),
[d] "={edx}" (d),
: [leaf] "{eax}" (leaf),
[sub] "{ecx}" (@as(u32, 0)),
);
return .{ .eax = a, .ebx = b, .ecx = c, .edx = d };
}
// HPET registers: capabilities at +0x00 (period in the high dword, in fs; bit 13 =
// 64-bit-counter capable), general config at +0x10, main counter at +0xF0.
fn hpetRead64(off: usize) u64 {
return @as(*volatile u64, @ptrFromInt(cfg_hpet_base + off)).*;
}
fn hpetWrite64(off: usize, value: u64) void {
@as(*volatile u64, @ptrFromInt(cfg_hpet_base + off)).* = value;
}
/// Map + enable the HPET and return its tick frequency, or null if unusable.
fn hpetHz() ?u64 {
paging.map(cfg_hpet_base & ~@as(u64, 0xFFF), cfg_hpet_base & ~@as(u64, 0xFFF), true);
const caps = hpetRead64(0x00);
const period_fs = caps >> 32; // femtoseconds per tick
if (period_fs == 0) return null;
hpetWrite64(0x10, hpetRead64(0x10) | 1); // ENABLE_CNF: start the main counter
return 1_000_000_000_000_000 / period_fs; // 1e15 fs/s ÷ fs/tick
}
/// The HPET counter width mask (64- or 32-bit, per caps bit 13).
fn hpetMask() u64 {
return if (hpetRead64(0x00) & (1 << 13) != 0) ~@as(u64, 0) else 0xFFFF_FFFF;
}
fn readHpet() u64 {
return hpetRead64(0xF0);
}
fn readPmTimer() u64 {
const pt = cfg_pm_timer.?;
if (pt.mmio) return @as(*volatile u32, @ptrFromInt(pt.address)).*;
return io.inl(@intCast(pt.address));
}
/// Arm the LAPIC timer to fire on `timer_vector` at `hz` (periodic). Requires
+53 -3
View File
@@ -11,6 +11,7 @@ const idt = @import("idt.zig");
const paging = @import("paging.zig");
const serial = @import("serial.zig");
const apic = @import("apic.zig");
const ioapic = @import("ioapic.zig");
const io = @import("io.zig");
/// The saved register/trap frame passed to a fault handler.
@@ -64,9 +65,58 @@ pub fn readCr3() u64 {
/// Kernel tick rate: 1000 Hz (1 ms), the scheduler's time quantum.
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.
/// The ACPI PM timer, as a calibration reference (re-exported for the config).
pub const PmTimer = apic.PmTimer;
/// A MADT interrupt-source override (re-exported for the config).
pub const IsoEntry = ioapic.IsoEntry;
/// Discovered platform facts the arch layer needs so it makes no legacy
/// assumptions — sourced from the device tree + ACPI, passed in by the kernel.
pub const PlatformConfig = struct {
/// Whether the legacy 8259 PIC is present (skip programming it if not).
pic_present: bool = true,
/// HPET MMIO base (0 = none) — a calibration reference for the timer.
hpet_base: u64 = 0,
/// The ACPI PM timer, another calibration reference.
pm_timer: ?PmTimer = null,
/// I/O APIC MMIO base + its first global system interrupt (0 = none).
ioapic_base: u64 = 0,
ioapic_gsi_base: u32 = 0,
/// MADT ISA-IRQ overrides, for I/O APIC routing.
overrides: []const IsoEntry = &.{},
};
/// Apply the discovered platform config. Must run before `startTimer` (the timer
/// calibration reads `hpet_base`/`pm_timer`) and before any interrupt routing.
/// Maps + masks the I/O APIC immediately.
pub fn configurePlatform(cfg: PlatformConfig) void {
apic.configure(cfg.pic_present, cfg.hpet_base, cfg.pm_timer);
ioapic.configure(cfg.ioapic_base, cfg.ioapic_gsi_base, cfg.overrides);
ioapic.init();
}
/// Point the serial console at the UART ACPI's SPCR table named (MMIO or I/O port).
pub fn serialReconfigure(is_mmio: bool, addr: u64) void {
serial.reconfigure(is_mmio, addr);
}
/// The reference clock the timer was calibrated against ("cpuid"/"hpet"/…).
pub fn timerCalibrationSource() []const u8 {
return apic.calibrationSource();
}
/// I/O APIC diagnostics (for boot logging / verification).
pub fn ioapicEntryCount() u32 {
return ioapic.entryCount();
}
pub fn ioapicEntryLow(n: u32) u32 {
return ioapic.entryLow(n);
}
/// Enable the Local APIC, calibrate its timer against the best available reference
/// (see apic.calibrate — no longer the PIT by default), and start it firing at
/// `timer_hz` — the kernel's real-time heartbeat. Interrupts still have to be
/// unmasked with enableInterrupts() to be delivered. Run `configurePlatform` first.
pub fn startTimer() void {
apic.init();
apic.calibrate();
+97
View File
@@ -0,0 +1,97 @@
//! I/O APIC — routes external device interrupts (a device's line) to a LAPIC
//! vector on a chosen CPU. Its address and the ISA-IRQ-to-GSI remappings come from
//! ACPI's MADT (via discovery), never assumed.
//!
//! Status: groundwork. The only interrupt danos handles today is the LAPIC's own
//! timer, which needs no I/O APIC — so nothing calls `routeIrq` yet. What runs now
//! is `init`, which maps the I/O APIC and **masks every input**, the correct
//! quiescent state on a legacy-free machine. `routeIrq` is ready for the first real
//! device driver (a keyboard, say).
const paging = @import("paging.zig");
/// A MADT Interrupt Source Override: an ISA IRQ that appears at a different global
/// system interrupt, with its own polarity/trigger (MPS INTI `flags`).
pub const IsoEntry = struct { source: u8, gsi: u32, flags: u16 };
var base: u64 = 0; // 0 = no I/O APIC discovered
var gsi_base: u32 = 0;
var max_entries: u32 = 0;
var overrides: [16]IsoEntry = undefined;
var override_count: usize = 0;
// The I/O APIC exposes an index register (IOREGSEL) and a data window (IOWIN).
const reg_ioregsel = 0x00;
const reg_iowin = 0x10;
const reg_version = 0x01;
const redir_base = 0x10; // redirection table: two 32-bit regs per entry
const redir_mask = 1 << 16; // mask bit in the low dword
/// Supply the discovered I/O APIC location + the MADT IRQ overrides. Call before `init`.
pub fn configure(ioapic_base: u64, ioapic_gsi_base: u32, isos: []const IsoEntry) void {
base = ioapic_base;
gsi_base = ioapic_gsi_base;
override_count = @min(isos.len, overrides.len);
for (isos[0..override_count], 0..) |iso, i| overrides[i] = iso;
}
fn regRead(index: u32) u32 {
@as(*volatile u32, @ptrFromInt(base + reg_ioregsel)).* = index;
return @as(*volatile u32, @ptrFromInt(base + reg_iowin)).*;
}
fn regWrite(index: u32, value: u32) void {
@as(*volatile u32, @ptrFromInt(base + reg_ioregsel)).* = index;
@as(*volatile u32, @ptrFromInt(base + reg_iowin)).* = value;
}
fn writeEntry(n: u32, low: u32, high: u32) void {
regWrite(redir_base + 2 * n, low);
regWrite(redir_base + 2 * n + 1, high);
}
/// Map the I/O APIC and mask every redirection entry — the safe quiescent state.
pub fn init() void {
if (base == 0) return;
paging.map(base & ~@as(u64, 0xFFF), base & ~@as(u64, 0xFFF), true);
max_entries = ((regRead(reg_version) >> 16) & 0xFF) + 1;
var n: u32 = 0;
while (n < max_entries) : (n += 1) writeEntry(n, redir_mask, 0);
}
/// Route ISA `irq` to `vector` on the LAPIC `apic_id`, honouring a MADT override
/// for its GSI/polarity/trigger, and unmask it. No caller yet — groundwork for the
/// first device driver.
pub fn routeIrq(irq: u8, vector: u8, apic_id: u8) void {
if (base == 0) return;
var gsi: u32 = irq;
var flags: u16 = 0;
for (overrides[0..override_count]) |o| {
if (o.source == irq) {
gsi = o.gsi;
flags = o.flags;
}
}
if (gsi < gsi_base) return;
const n = gsi - gsi_base;
if (n >= max_entries) return;
// Low dword: vector + delivery mode fixed(0) + physical dest(0), unmasked.
// MPS INTI flags: bits [1:0] polarity (3 = active low), [3:2] trigger (3 = level).
var low: u32 = vector;
if (flags & 0x3 == 3) low |= (1 << 13);
if ((flags >> 2) & 0x3 == 3) low |= (1 << 15);
const high: u32 = @as(u32, apic_id) << 24; // destination APIC ID
writeEntry(n, low, high);
}
/// Number of redirection entries the I/O APIC advertises (0 until `init`).
pub fn entryCount() u32 {
return max_entries;
}
/// The low dword of redirection entry `n` — for diagnostics/read-back.
pub fn entryLow(n: u32) u32 {
if (base == 0) return 0;
return regRead(redir_base + 2 * n);
}
+53 -17
View File
@@ -1,11 +1,21 @@
//! COM1 serial port (16550 UART) — the kernel's machine-readable output channel.
//! Unlike the framebuffer console, serial text can be captured to a file by QEMU
//! (`-serial file:...`), which is what the test harness asserts on. Each
//! architecture has its own UART; this is the x86 one, driven by port I/O.
//! Serial console (16550-compatible UART) — the kernel's machine-readable output
//! channel. Unlike the framebuffer console, serial text can be captured to a file
//! by QEMU (`-serial file:...`), which is what the test harness asserts on.
//!
//! The UART defaults to the legacy PC COM1 at I/O port `0x3F8`, but a UEFI Class 3
//! (legacy-free) machine may have no COM1 — or its debug UART somewhere else, and
//! reachable via MMIO rather than port I/O. So the location is a runtime value:
//! `reconfigure` repoints it once ACPI's SPCR table has been read. Early boot logs
//! optimistically to COM1 (harmless if absent); the framebuffer console is the
//! always-present log.
const port = 0x3F8; // COM1 base
/// How the UART registers are reached: legacy I/O ports or memory-mapped.
const Access = enum { port, mmio };
fn outb(p: u16, value: u8) void {
var access: Access = .port;
var base: u64 = 0x3F8; // COM1
fn portOut(p: u16, value: u8) void {
asm volatile ("outb %[value], %[p]"
:
: [value] "{al}" (value),
@@ -13,28 +23,54 @@ fn outb(p: u16, value: u8) void {
);
}
fn inb(p: u16) u8 {
fn portIn(p: u16) u8 {
return asm volatile ("inb %[p], %[value]"
: [value] "={al}" (-> u8),
: [p] "{dx}" (p),
);
}
/// Read UART register `off` through the active access method.
fn reg(off: u64) u8 {
if (access == .mmio) return @as(*volatile u8, @ptrFromInt(base + off)).*;
return portIn(@intCast(base + off));
}
/// Write UART register `off` through the active access method.
fn setReg(off: u64, value: u8) void {
if (access == .mmio) {
@as(*volatile u8, @ptrFromInt(base + off)).* = value;
} else {
portOut(@intCast(base + off), value);
}
}
/// Configure the UART: 38400 baud, 8N1, FIFO on. Safe to call before anything
/// else; it has no dependencies.
/// else; it has no dependencies, and is a harmless no-op if the port is absent.
pub fn init() void {
outb(port + 1, 0x00); // disable interrupts
outb(port + 3, 0x80); // enable DLAB (set baud divisor)
outb(port + 0, 0x03); // divisor low: 38400 baud
outb(port + 1, 0x00); // divisor high
outb(port + 3, 0x03); // 8 bits, no parity, one stop bit; DLAB off
outb(port + 2, 0xC7); // enable + clear FIFO, 14-byte threshold
outb(port + 4, 0x0B); // RTS/DSR set
setReg(1, 0x00); // disable interrupts
setReg(3, 0x80); // enable DLAB (set baud divisor)
setReg(0, 0x03); // divisor low: 38400 baud
setReg(1, 0x00); // divisor high
setReg(3, 0x03); // 8 bits, no parity, one stop bit; DLAB off
setReg(2, 0xC7); // enable + clear FIFO, 14-byte threshold
setReg(4, 0x0B); // RTS/DSR set
}
/// Point the console at the UART ACPI's SPCR table names (MMIO or I/O port) and
/// re-run the UART setup there. Called after discovery when an SPCR entry exists.
pub fn reconfigure(is_mmio: bool, addr: u64) void {
access = if (is_mmio) .mmio else .port;
base = addr;
init();
}
fn writeByte(c: u8) void {
while (inb(port + 5) & 0x20 == 0) {} // wait until the transmit holding register is empty
outb(port, c);
// Wait for the transmit-holding register to empty — but bounded, so an absent
// UART (whose line-status register reads back as 0x00) can't hang the kernel.
var guard: u32 = 0;
while (reg(5) & 0x20 == 0 and guard < 100_000) : (guard += 1) {}
setReg(0, c);
}
/// Write bytes, translating LF to CRLF so terminals and logs line up.