Files
danos/src/kernel/tests.zig
T

492 lines
16 KiB
Zig

//! In-kernel test cases, run at the end of bring-up when the kernel is built with
//! `-Dtest-case=<name>`. Each case writes structured markers to the serial port
//! that the QEMU harness (test/qemu_test.py) asserts on:
//!
//! [PASS]/[FAIL] <check> per assertion
//! DANOS-TEST-RESULT: PASS|FAIL overall, for non-faulting cases
//!
//! Faulting cases (fault-ud, fault-pf, fault-df) deliberately don't return a
//! result line — they trigger a CPU exception, and the harness asserts on the
//! exception report the handler prints (which also reaches serial).
const std = @import("std");
const danos = @import("danos");
const arch = @import("arch");
const platform = @import("platform");
const pmm = @import("pmm.zig");
const heap = @import("heap.zig");
const sched = @import("scheduler.zig");
const ipc = @import("ipc.zig");
/// Formatted write straight to serial, independent of the framebuffer console.
fn log(comptime fmt: []const u8, args: anytype) void {
var buf: [128]u8 = undefined;
arch.serialWrite(std.fmt.bufPrint(&buf, fmt, args) catch return);
}
var passed: u32 = 0;
var failed: u32 = 0;
fn check(name: []const u8, ok: bool) void {
if (ok) {
passed += 1;
log("[PASS] {s}\n", .{name});
} else {
failed += 1;
log("[FAIL] {s}\n", .{name});
}
}
/// Emit the overall result line the harness matches, then the done sentinel.
fn result() void {
log("DANOS-TEST-RESULT: {s} ({d} passed, {d} failed)\n", .{
if (failed == 0) "PASS" else "FAIL",
passed,
failed,
});
log("DANOS-TEST-DONE\n", .{});
}
pub fn run(case: []const u8, boot_info: *const BootInfo) void {
if (eql(case, "smoke")) {
smoke(boot_info);
} else if (eql(case, "timer")) {
timer();
} else if (eql(case, "clock")) {
clock();
} else if (eql(case, "vmm")) {
vmm();
} else if (eql(case, "heap")) {
heapTest();
} else if (eql(case, "sched")) {
schedTest();
} else if (eql(case, "priority")) {
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")) {
faultInvalidOpcode();
} else if (eql(case, "fault-pf")) {
faultPageFault();
} else if (eql(case, "fault-df")) {
faultDoubleFault();
} else if (eql(case, "fault-nx")) {
faultNoExecute();
} else if (eql(case, "fault-null")) {
faultNull();
} else if (eql(case, "poweroff")) {
powerTest(.off);
} else if (eql(case, "reboot")) {
powerTest(.reboot);
} else {
log("DANOS-TEST-RESULT: FAIL (unknown case '{s}')\n", .{case});
}
}
fn platformHal() platform.Hal {
return .{
.mapMmio = arch.mapPage,
.pioRead = arch.pioRead,
.pioWrite = arch.pioWrite,
};
}
/// Drive an ACPI power transition. On success the machine powers off or resets,
/// so QEMU exits — the harness observes the process exit. If control returns, the
/// transition failed and we emit a FAIL result.
fn powerTest(comptime action: enum { off, reboot }) void {
const name = if (action == .off) "poweroff" else "reboot";
log("DANOS-TEST-BEGIN: {s}\n", .{name});
const hal = platformHal();
log("DANOS-POWER: attempting {s}\n", .{name});
switch (action) {
.off => platform.shutdown(hal),
.reboot => platform.reboot(hal),
}
check("power transition took effect", false);
result();
}
const BootInfo = danos.BootInfo;
fn eql(a: []const u8, b: []const u8) bool {
return std.mem.eql(u8, a, b);
}
/// Non-destructive checks of the memory map and frame allocator.
fn smoke(boot_info: *const BootInfo) void {
log("DANOS-TEST-BEGIN: smoke\n", .{});
// The memory map has some usable RAM.
const mm = boot_info.memory_map;
const regions = @as([*]const danos.MemoryRegion, @ptrFromInt(mm.regions))[0..mm.len];
var usable: u64 = 0;
for (regions) |r| {
if (r.kind == .usable) usable += r.pages;
}
check("memory map reports usable RAM", usable > 0);
// The frame allocator hands out distinct, page-aligned frames.
const a = pmm.alloc();
const b = pmm.alloc();
check("alloc returns a frame", a != null);
check("alloc returns distinct frames", a != null and b != null and a.? != b.?);
check("frames are page-aligned", (a orelse 1) % danos.page_size == 0);
// Freeing restores the count.
const before = pmm.stats().free_frames;
if (a) |p| pmm.free(p);
if (b) |p| pmm.free(p);
check("free returns frames to the pool", pmm.stats().free_frames == before + 2);
// Paging is active on our own tables (CR3 is non-zero and page-aligned).
const cr3 = arch.readCr3();
check("paging active (CR3 set)", cr3 != 0 and cr3 % danos.page_size == 0);
result();
}
/// Verify device interrupts fire and return: the timer tick counter must advance
/// on its own. Interrupts are already enabled by kmain before tests run.
fn timer() void {
log("DANOS-TEST-BEGIN: timer\n", .{});
const start = arch.ticks();
// Busy-wait for the counter to advance. arch.ticks() is a volatile load, so
// the compiler re-reads it each iteration and sees the interrupt's update.
// The cap is only a safety net; the harness timeout is the real backstop.
var spins: u64 = 0;
while (arch.ticks() == start and spins < 5_000_000_000) spins +%= 1;
check("timer interrupts advance the tick count", arch.ticks() > start);
result();
}
/// Verify the on-demand VMM: map a fresh frame at an unused virtual address, and
/// check it's writable and reads back.
fn vmm() void {
log("DANOS-TEST-BEGIN: vmm\n", .{});
const frame = pmm.alloc();
check("frame available to map", frame != null);
if (frame) |phys| {
var virt: u64 = 0x0000_4000_0000_0000; // canonical, well clear of everything mapped
arch.mapPage(virt, phys, true);
const p: *volatile u64 = @ptrFromInt(virt);
p.* = 0xdead_c0de_cafe_babe;
check("mapped page is writable and reads back", p.* == 0xdead_c0de_cafe_babe);
arch.unmapPage(virt);
pmm.free(phys);
virt += 0;
}
result();
}
/// Exercise the kernel heap: basic alloc/write/free, reuse, growth beyond the
/// initial region, and a std container backed by it.
fn heapTest() void {
log("DANOS-TEST-BEGIN: heap\n", .{});
const a = heap.allocator();
// Allocate, write a pattern, read it back, free.
const buf = a.alloc(u8, 4096) catch null;
check("alloc 4096 bytes", buf != null);
if (buf) |b| {
@memset(b, 0xAB);
check("heap memory is writable and reads back", b[0] == 0xAB and b[4095] == 0xAB);
a.free(b);
}
// Freeing then re-allocating the same size should reuse the block.
const p1 = a.alloc(u64, 8) catch null;
const addr1 = if (p1) |p| @intFromPtr(p.ptr) else 0;
if (p1) |p| a.free(p);
const p2 = a.alloc(u64, 8) catch null;
const addr2 = if (p2) |p| @intFromPtr(p.ptr) else 0;
check("freed block is reused", addr1 != 0 and addr1 == addr2);
if (p2) |p| a.free(p);
// Force growth past the initial page and check every block is usable.
var blocks: [64]?[]u8 = .{null} ** 64;
var ok = true;
for (&blocks, 0..) |*slot, i| {
const b = a.alloc(u8, 4096) catch null;
slot.* = b;
if (b) |bb| @memset(bb, @intCast(i & 0xff)) else {
ok = false;
}
}
for (blocks, 0..) |slot, i| {
if (slot) |bb| {
if (bb[0] != @as(u8, @intCast(i & 0xff)) or bb[4095] != @as(u8, @intCast(i & 0xff))) ok = false;
}
}
check("many allocations (heap growth) stay valid", ok);
for (blocks) |slot| {
if (slot) |bb| a.free(bb);
}
// A std container backed by the kernel heap.
var list: std.ArrayList(u32) = .empty;
var sum: u64 = 0;
var expected: u64 = 0;
var i: u32 = 0;
var list_ok = true;
while (i < 1000) : (i += 1) {
list.append(a, i) catch {
list_ok = false;
};
expected += i;
}
for (list.items) |v| sum += v;
list.deinit(a);
check("std.ArrayList on the kernel heap", list_ok and sum == expected);
result();
}
/// Verify the calibrated clocks: sane measured frequencies, monotonic uptime that
/// 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 {
log("DANOS-TEST-BEGIN: clock\n", .{});
const lapic = arch.lapicHz();
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);
// Uptime advances over ~5 real ticks (1000 Hz => 1 tick == 1 ms).
const start_ticks = arch.ticks();
const start_ms = arch.millis();
var spins: u64 = 0;
while (arch.ticks() < start_ticks + 5 and spins < 5_000_000_000) spins +%= 1;
const elapsed_ms = arch.millis() - start_ms;
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();
}
fn diffWithin(a: u64, b: u64, tol: u64) bool {
return if (a > b) a - b <= tol else b - a <= tol;
}
// --- scheduler tests ------------------------------------------------------
var counters = [_]u64{0} ** 3;
fn spin0() void {
const p: *volatile u64 = &counters[0];
while (true) p.* = p.* +% 1;
}
fn spin1() void {
const p: *volatile u64 = &counters[1];
while (true) p.* = p.* +% 1;
}
fn spin2() void {
const p: *volatile u64 = &counters[2];
while (true) p.* = p.* +% 1;
}
/// Preemption: spawn three tasks that busy-loop *without* yielding. If they all
/// make progress, the timer must be preempting between them (and the context
/// switch works) — because nothing yields voluntarily.
fn schedTest() void {
log("DANOS-TEST-BEGIN: sched\n", .{});
counters = .{ 0, 0, 0 };
sched.spawn(spin0, 4);
sched.spawn(spin1, 4);
sched.spawn(spin2, 4);
const c0: *volatile u64 = &counters[0];
const c1: *volatile u64 = &counters[1];
const c2: *volatile u64 = &counters[2];
var spins: u64 = 0;
while ((c0.* == 0 or c1.* == 0 or c2.* == 0) and spins < 5_000_000_000) spins +%= 1;
check("all three non-yielding tasks made progress (preemption)", c0.* > 0 and c1.* > 0 and c2.* > 0);
result();
}
var run_order = [_]u8{0} ** 4;
var run_n: usize = 0;
fn recordExit(priority: u8) void {
run_order[run_n] = priority;
run_n += 1;
sched.exit();
}
fn taskHigh() void {
recordExit(6);
}
fn taskMid() void {
recordExit(4);
}
fn taskLow() void {
recordExit(2);
}
/// Fixed priority: with preemption off (deterministic), spawn tasks at three
/// priorities and let them run cooperatively. They must run highest-first.
fn priorityTest() void {
log("DANOS-TEST-BEGIN: priority\n", .{});
sched.setPreemption(false);
sched.setPriority(1); // above the idle task (0), below the workers — runs last
run_n = 0;
sched.spawn(taskLow, 2);
sched.spawn(taskMid, 4);
sched.spawn(taskHigh, 6);
while (run_n < 3) sched.yield(); // regain control only once the workers are done
check("tasks ran highest-priority first", run_order[0] == 6 and run_order[1] == 4 and run_order[2] == 2);
sched.setPriority(4);
sched.setPreemption(true);
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 {
log("DANOS-TEST-BEGIN: fault-ud\n", .{});
asm volatile ("ud2");
}
/// Verify NX: fetching an instruction from a data page (mapped no-execute) faults.
fn faultNoExecute() void {
log("DANOS-TEST-BEGIN: fault-nx\n", .{});
var scratch: u64 = 0xC3; // a lone `ret` — harmless if NX somehow let it run
const f: *const fn () void = @ptrFromInt(@intFromPtr(&scratch));
f(); // instruction fetch from an NX page -> #PF before it executes
log("DANOS-TEST-RESULT: FAIL (NX not enforced)\n", .{});
}
/// Verify the null guard: dereferencing address 0 (page 0 left unmapped) faults.
fn faultNull() void {
log("DANOS-TEST-BEGIN: fault-null\n", .{});
// Launder the address through empty asm so the compiler no longer knows it's
// 0 (otherwise it folds a null-pointer safety panic instead of doing the real
// access). `allowzero` skips the same null check on the cast. The write then
// hits the unmapped page 0 and takes a real hardware #PF.
var addr: u64 = 0;
addr = asm ("" : [ret] "=r" (-> u64) : [in] "0" (addr));
const p: *allowzero volatile u64 = @ptrFromInt(addr);
p.* = 1;
}
fn faultPageFault() void {
log("DANOS-TEST-BEGIN: fault-pf\n", .{});
// Runtime address so the backend emits a register store (not a `mov moffs`,
// which the self-hosted x86_64 backend can't encode).
var addr: u64 = 0xdeadbeef000; // well above all mapped RAM
const p: *volatile u64 = @ptrFromInt(addr);
p.* = 1;
addr += 0;
}
fn faultDoubleFault() void {
log("DANOS-TEST-BEGIN: fault-df\n", .{});
arch.disableInterrupts(); // so only the ud2 delivery (not a timer tick) triggers the #DF
// Point RSP at unmapped memory, then fault: the CPU can't push the fault
// frame, which escalates to #DF — survivable only because #DF runs on IST1.
var bad_sp: u64 = 0x5000000000;
asm volatile (
\\mov %[sp], %%rsp
\\ud2
:
: [sp] "r" (bad_sp),
: .{ .memory = true }
);
bad_sp += 0;
}