Cross-architecture test suite

This commit is contained in:
2026-07-03 12:39:26 +01:00
parent 9cf135302d
commit c8e89e8115
10 changed files with 500 additions and 8 deletions
+12
View File
@@ -8,10 +8,22 @@ const gdt = @import("gdt.zig");
const tss = @import("tss.zig");
const idt = @import("idt.zig");
const paging = @import("paging.zig");
const serial = @import("serial.zig");
/// The saved register/trap frame passed to a fault handler.
pub const CpuState = idt.CpuState;
/// Bring up the serial port (the kernel's machine-readable log). No dependencies,
/// so it can be the very first thing called.
pub fn serialInit() void {
serial.init();
}
/// Write bytes to the serial port.
pub fn serialWrite(bytes: []const u8) void {
serial.write(bytes);
}
/// Set up the CPU's descriptor tables: our own GDT, the TSS (with an interrupt
/// stack for double faults), then the IDT with exception handlers. After this a
/// CPU fault is reported instead of triple-faulting. Install the fault handler
+46
View File
@@ -0,0 +1,46 @@
//! 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.
const port = 0x3F8; // COM1 base
fn outb(p: u16, value: u8) void {
asm volatile ("outb %[value], %[p]"
:
: [value] "{al}" (value),
[p] "{dx}" (p),
);
}
fn inb(p: u16) u8 {
return asm volatile ("inb %[p], %[value]"
: [value] "={al}" (-> u8),
: [p] "{dx}" (p),
);
}
/// Configure the UART: 38400 baud, 8N1, FIFO on. Safe to call before anything
/// else; it has no dependencies.
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
}
fn writeByte(c: u8) void {
while (inb(port + 5) & 0x20 == 0) {} // wait until the transmit holding register is empty
outb(port, c);
}
/// Write bytes, translating LF to CRLF so terminals and logs line up.
pub fn write(bytes: []const u8) void {
for (bytes) |c| {
if (c == '\n') writeByte('\r');
writeByte(c);
}
}
+3
View File
@@ -4,6 +4,7 @@
const std = @import("std");
const danos = @import("danos");
const arch = @import("arch");
/// The console font, embedded at compile time. cp850-8x16, PSF2 format:
/// a 32-byte header, then 256 glyphs of 16 bytes each (one byte per 8-pixel
@@ -40,6 +41,8 @@ pub const Console = struct {
}
pub fn write(self: *Console, bytes: []const u8) void {
// Mirror everything to the serial port so it's captured in logs / tests.
arch.serialWrite(bytes);
for (bytes) |c| self.putChar(c);
}
+11
View File
@@ -3,6 +3,8 @@ const danos = @import("danos");
const arch = @import("arch");
const console = @import("console.zig");
const pmm = @import("pmm.zig");
const tests = @import("tests.zig");
const build_options = @import("build_options");
const BootInfo = danos.BootInfo;
/// The calling convention used to enter the kernel. Pinned to SysV explicitly:
@@ -25,6 +27,8 @@ export fn _start(boot_info: *const BootInfo) callconv(kernel_abi) noreturn {
}
fn kmain(boot_info: *const BootInfo) noreturn {
arch.serialInit(); // machine-readable log; console mirrors to it
const fb = boot_info.framebuffer;
con = console.Console.init(fb);
con.clear();
@@ -87,6 +91,13 @@ fn kmain(boot_info: *const BootInfo) noreturn {
con.print("\ndanos: paging enabled\n", .{});
con.print(" page tables: CR3 = 0x{x:0>16}\n", .{arch.readCr3()});
// In a test build (`zig build -Dtest-case=<name>`), run that case and stop.
// Normal builds fall through to the idle halt.
if (build_options.test_case) |case| {
tests.run(case, boot_info);
arch.halt();
}
con.write("\nkernel initialised; nothing left to do, halting.\n");
arch.halt();
+122
View File
@@ -0,0 +1,122 @@
//! 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 pmm = @import("pmm.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});
}
}
pub fn run(case: []const u8, boot_info: *const BootInfo) void {
if (eql(case, "smoke")) {
smoke(boot_info);
} else if (eql(case, "fault-ud")) {
faultInvalidOpcode();
} else if (eql(case, "fault-pf")) {
faultPageFault();
} else if (eql(case, "fault-df")) {
faultDoubleFault();
} else {
log("DANOS-TEST-RESULT: FAIL (unknown case '{s}')\n", .{case});
}
}
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);
log("DANOS-TEST-RESULT: {s} ({d} passed, {d} failed)\n", .{
if (failed == 0) "PASS" else "FAIL",
passed,
failed,
});
log("DANOS-TEST-DONE\n", .{});
}
fn faultInvalidOpcode() void {
log("DANOS-TEST-BEGIN: fault-ud\n", .{});
asm volatile ("ud2");
}
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; // above our identity-mapped 4 GiB
const p: *volatile u64 = @ptrFromInt(addr);
p.* = 1;
addr += 0;
}
fn faultDoubleFault() void {
log("DANOS-TEST-BEGIN: fault-df\n", .{});
// 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;
}