Cross-architecture test suite

This commit is contained in:
Daniel Samson 2026-07-03 12:39:26 +01:00
parent 9cf135302d
commit c8e89e8115
10 changed files with 500 additions and 8 deletions

View File

@ -21,6 +21,13 @@ pub fn build(b: *std.Build) void {
// jumps/calls that Zig inline asm can't express (see the file's header).
arch_mod.addAssemblyFile(b.path("src/arch/x86_64/isr.s"));
// Compile-time config the kernel reads as `@import("build_options")`. The
// QEMU test harness sets -Dtest-case=<name> to run one self-test at boot.
const test_case = b.option([]const u8, "test-case", "Kernel self-test case to run at boot (see src/tests.zig)");
const build_options = b.addOptions();
build_options.addOption(?[]const u8, "test_case", test_case);
const build_options_mod = build_options.createModule();
// --- Kernel: freestanding x86_64 ELF, jumped to by the bootloader ---
// SSE2 is part of the x86_64 baseline and UEFI leaves it enabled at handoff,
// so we keep it: disabling it forces soft-float and makes the compiler unable
@ -46,6 +53,7 @@ pub fn build(b: *std.Build) void {
.imports = &.{
.{ .name = "danos", .module = mod },
.{ .name = "arch", .module = arch_mod },
.{ .name = "build_options", .module = build_options_mod },
},
}),
});

View File

@ -33,6 +33,9 @@ Cutting across all of these:
- **[arch.md](arch.md) — the architecture split.** How CPU-specific code is kept
behind a build-time `arch` module so the generic kernel never names x86_64,
leaving room for other systems (e.g. an AArch64 Raspberry Pi) later.
- **[testing.md](testing.md) — testing.** How the kernel is tested by booting it in
QEMU and asserting on its serial output — reproducibly, and structured so the
same tests run across architectures.
## How the pieces relate
@ -54,6 +57,8 @@ finished, or panics, it **halts** ([halting.md](halting.md)).
| Kernel entry, panic, bring-up | `src/main.zig` |
| Shared loader↔kernel contract (`BootInfo`, `Framebuffer`, `MemoryMap`, ABI) | `src/root.zig` |
| Physical frame allocator | `src/pmm.zig` |
| Framebuffer text console | `src/console.zig` |
| Arch-specific kernel code (`halt`, GDT/IDT/TSS, exception stubs, page tables, linker script) | `src/arch/x86_64/` |
| Framebuffer text console (mirrors to serial) | `src/console.zig` |
| In-kernel test cases | `src/tests.zig` |
| Arch-specific kernel code (`halt`, GDT/IDT/TSS, exception stubs, page tables, serial, linker script) | `src/arch/x86_64/` |
| Build + `run-efi` (QEMU/OVMF) | `build.zig` |
| QEMU integration test harness | `test/qemu_test.py` |

View File

@ -63,12 +63,16 @@ There are really two independent questions, and it's worth not conflating them:
- **`src/arch/x86_64/cpu.zig`** — the `arch` module root. Exposes `halt()` (see
[halting.md](halting.md)), `init()` (bring up the descriptor tables),
`setFaultHandler`, `readCr2`, and the `CpuState` trap frame. Paging will join it
here as the kernel grows.
- **`src/arch/x86_64/gdt.zig`** / **`idt.zig`** — the GDT and IDT plus CPU-exception
handling (see [interrupts.md](interrupts.md)).
- **`src/arch/x86_64/isr.s`** — the exception stubs and the `lgdt`/`lidt` load
helpers, in real assembly because Zig inline asm can't express them.
`enablePaging()`, `setFaultHandler`, `readCr2`/`readCr3`, and the `CpuState`
trap frame.
- **`src/arch/x86_64/gdt.zig`** / **`idt.zig`** / **`tss.zig`** — the GDT, IDT and
TSS plus CPU-exception handling (see [interrupts.md](interrupts.md)).
- **`src/arch/x86_64/paging.zig`** — the kernel's page tables (see
[paging.md](paging.md)).
- **`src/arch/x86_64/serial.zig`** — the COM1 UART, the kernel's machine-readable
log channel (see [testing.md](testing.md)).
- **`src/arch/x86_64/isr.s`** — the exception stubs and the `lgdt`/`lidt`/`ltr`
load helpers, in real assembly because Zig inline asm can't express them.
- **`src/arch/x86_64/linker.ld`** — the kernel link layout (fixed low load
address, one PT_LOAD per permission set).

113
docs/testing.md Normal file
View File

@ -0,0 +1,113 @@
# Testing
danos is a freestanding kernel — it can't be unit-tested like a normal library,
because most of what it does only means anything on a booted CPU. So the main test
strategy is **boot it in QEMU and assert on what it does**, reproducibly and
without a human staring at the screen.
There are two layers:
- **Host unit tests** (`zig build test`) — for pure, platform-independent logic in
the shared `danos` module (the handoff layout in `src/root.zig`). These compile
for the host and run natively.
- **QEMU integration tests** (`python3 test/qemu_test.py`) — boot the real kernel
and check its behaviour. This is the interesting part.
## The key enabler: serial output
The framebuffer console draws pixels, which a test can't read without
screen-scraping. So the kernel also writes everything to a **serial port**
(`src/arch/x86_64/serial.zig`, a 16550 UART on COM1). `Console.write` mirrors every
byte to it, so all kernel output — boot log, memory summary, exception reports —
appears on serial as plain text.
QEMU captures that with `-serial file:serial.log`, giving a machine-readable
transcript. Serial is per-architecture (x86 uses port I/O; an ARM board uses a
memory-mapped UART), so it lives behind the [arch](arch.md) boundary — and adding
a new architecture's UART is what makes the same tests run there.
## In-kernel test cases
Building with `-Dtest-case=<name>` makes the kernel, after normal bring-up, run one
self-test from `src/tests.zig` instead of idling. Each case writes structured
markers to serial:
```
DANOS-TEST-BEGIN: smoke
[PASS] memory map reports usable RAM
[PASS] alloc returns distinct frames
...
DANOS-TEST-RESULT: PASS (6 passed, 0 failed)
DANOS-TEST-DONE
```
Current cases:
| Case | What it checks | How the harness confirms it |
|------|----------------|-----------------------------|
| `smoke` | memory map has usable RAM; frame alloc/free; paging active | `DANOS-TEST-RESULT: PASS` |
| `fault-ud` | invalid-opcode exception is caught | serial shows `invalid opcode (vector 6)` |
| `fault-pf` | page fault caught with CR2 | `page fault (vector 14)` |
| `fault-df` | double fault caught on IST1 (not a triple-fault reset) | `double fault (vector 8)` |
The faulting cases don't print a result line — they deliberately raise a CPU
exception, and the harness asserts on the [exception report](interrupts.md) the
handler prints (which also reaches serial). This reuses the real fault path as the
test oracle: if the IDT/TSS weren't wired up, `fault-df` would triple-fault and the
marker would never appear.
## The harness
`test/qemu_test.py` ties it together. For each case it:
1. builds the kernel with `-Dtest-case=<name>`,
2. assembles a fresh EFI System Partition from the built binaries,
3. boots it headless in QEMU with serial captured to a file and `-no-reboot`
(so a triple fault exits rather than looping),
4. polls the serial log until the case's expected regex appears (**pass**), a
failure marker appears, or a timeout elapses (**fail**),
5. kills QEMU and moves on.
```
$ python3 test/qemu_test.py
danos qemu tests arch=x86_64 cases=4
smoke ... PASS (matched 'DANOS-TEST-RESULT: PASS')
fault-ud ... PASS (matched 'invalid opcode \(vector 6\)')
fault-pf ... PASS (matched 'page fault \(vector 14\)')
fault-df ... PASS (matched 'double fault \(vector 8\)')
4/4 passed
```
It exits non-zero if any case fails, so it drops straight into CI. Run a subset
with `python3 test/qemu_test.py smoke fault-pf`.
(It's a standalone script rather than a `zig build` step on purpose: a build step
that shells out to a harness which itself runs `zig build` would contend on the
build cache lock.)
## Built for multiple architectures
The runner separates *what* is tested (the cases and their expected markers) from
*how a given CPU is built and booted* (the `ARCHES` table: the QEMU binary,
firmware, boot method, serial device). The cases are architecture-neutral —
"a page fault is reported", not "this x86 encoding faults".
So bringing up a second architecture — an AArch64 Raspberry Pi is the motivating
one — means:
1. implement `src/arch/aarch64/` (CPU ops, its UART, exception vectors, page
tables) behind the same `arch` interface,
2. add an `aarch64` entry to `ARCHES` with its `qemu-system-aarch64` invocation,
and the *same* `smoke` / `fault-*` cases run against it: `python3 test/qemu_test.py
--arch aarch64`. A green suite on both is the definition of "it works across
architectures".
## Writing a new case
1. Add a function to `src/tests.zig` and dispatch it in `run` on its name.
2. Emit `[PASS]/[FAIL]` lines and a `DANOS-TEST-RESULT:` line (non-faulting cases),
or trigger the condition and rely on the handler's output (faulting cases).
3. Add an entry to `CASES` in `test/qemu_test.py` with the regex that proves it.

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

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);
}
}

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);
}

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
src/tests.zig Normal file
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;
}

168
test/qemu_test.py Normal file
View File

@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Reproducible QEMU integration tests for danos.
For each test case it builds the kernel with `-Dtest-case=<name>`, boots it
headless in QEMU with the serial port captured to a file, and asserts that the
expected marker appears in that output before a timeout. The kernel's serial log
is machine-readable (see src/tests.zig and src/arch/*/serial.zig), so no
screen-scraping is involved.
Structured per-architecture so a second CPU (e.g. an AArch64 Raspberry Pi) is a
matter of adding an ARCHES entry and its serial/boot support the test cases and
the runner stay the same.
Usage:
python3 test/qemu_test.py # run all cases on the default arch
python3 test/qemu_test.py --arch x86_64 # pick an architecture
python3 test/qemu_test.py smoke fault-pf # run only named cases
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
import time
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
WORK = os.path.join(REPO, "zig-out", "qemu-test")
# --- Architecture configurations -------------------------------------------
# Each describes how to build and boot that architecture. Only the fields that
# actually differ between architectures live here.
ARCHES = {
"x86_64": {
"zig_flags": [], # build.zig currently pins the kernel to x86_64
"qemu": "qemu-system-x86_64",
"ovmf_code": "/usr/share/edk2/x64/OVMF_CODE.4m.fd",
"ovmf_vars": "/usr/share/edk2/x64/OVMF_VARS.4m.fd",
"efi_app": ("EFI/BOOT/BOOTX64.efi", "BOOTX64.efi"), # (dest in ESP, name in zig-out/bin)
"kernel": ("danos", "danos"),
# Built as a function so we can splice in per-run paths.
"qemu_args": lambda a, esp, vars_fd, serial: [
"-machine", "q35", "-m", "128M",
"-drive", f"if=pflash,format=raw,readonly=on,file={a['ovmf_code']}",
"-drive", f"if=pflash,format=raw,file={vars_fd}",
"-drive", f"format=raw,file=fat:rw:{esp}",
"-net", "none",
"-vga", "none", "-device", "VGA,edid=on,xres=1280,yres=720",
"-display", "none",
"-serial", f"file:{serial}",
"-no-reboot", # exit instead of rebooting on a triple fault
],
},
# To add an architecture, e.g. "aarch64": provide its qemu binary, firmware,
# boot method, and the AArch64 kernel/serial support in src/arch/aarch64/.
}
# --- Test cases ------------------------------------------------------------
# `expect`: a regex that must appear in serial output => pass.
# `fail`: optional regex whose appearance => immediate fail.
CASES = [
{"name": "smoke",
"expect": r"DANOS-TEST-RESULT: PASS",
"fail": r"DANOS-TEST-RESULT: FAIL"},
{"name": "fault-ud", "expect": r"invalid opcode \(vector 6\)"},
{"name": "fault-pf", "expect": r"page fault \(vector 14\)"},
{"name": "fault-df", "expect": r"double fault \(vector 8\)"},
]
TIMEOUT = 30 # seconds per case
def build(arch, case):
cmd = ["zig", "build", f"-Dtest-case={case}"] + arch["zig_flags"]
r = subprocess.run(cmd, cwd=REPO, capture_output=True, text=True)
if r.returncode != 0:
return r.stderr.strip() or r.stdout.strip()
return None
def make_esp(arch):
"""Assemble a fresh EFI System Partition from the freshly built binaries."""
esp = os.path.join(WORK, "esp")
if os.path.exists(esp):
shutil.rmtree(esp)
efi_dest, efi_name = arch["efi_app"]
kern_dest, kern_name = arch["kernel"]
os.makedirs(os.path.join(esp, os.path.dirname(efi_dest)), exist_ok=True)
shutil.copy(os.path.join(REPO, "zig-out", "bin", efi_name), os.path.join(esp, efi_dest))
shutil.copy(os.path.join(REPO, "zig-out", "bin", kern_name), os.path.join(esp, kern_dest))
return esp
def run_case(arch, case):
err = build(arch, case["name"])
if err:
return False, "build failed:\n" + err
esp = make_esp(arch)
vars_fd = os.path.join(WORK, "vars.fd")
shutil.copy(arch["ovmf_vars"], vars_fd)
serial = os.path.join(WORK, "serial.log")
if os.path.exists(serial):
os.remove(serial)
expect = re.compile(case["expect"])
fail = re.compile(case["fail"]) if case.get("fail") else None
cmd = [arch["qemu"]] + arch["qemu_args"](arch, esp, vars_fd, serial)
qemu = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
deadline = time.monotonic() + TIMEOUT
while time.monotonic() < deadline:
time.sleep(0.2)
text = ""
if os.path.exists(serial):
with open(serial, "r", errors="replace") as f:
text = f.read()
if fail and fail.search(text):
return False, "hit failure marker"
if expect.search(text):
return True, "matched " + repr(case["expect"])
if qemu.poll() is not None: # QEMU exited on its own
if expect.search(text):
return True, "matched " + repr(case["expect"])
return False, "QEMU exited before matching (triple fault?)"
return False, f"timed out after {TIMEOUT}s without matching {case['expect']!r}"
finally:
if qemu.poll() is None:
qemu.terminate()
try:
qemu.wait(timeout=5)
except subprocess.TimeoutExpired:
qemu.kill()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--arch", default="x86_64", choices=sorted(ARCHES))
ap.add_argument("cases", nargs="*", help="case names to run (default: all)")
args = ap.parse_args()
arch = ARCHES[args.arch]
if not shutil.which(arch["qemu"]):
print(f"error: {arch['qemu']} not found on PATH", file=sys.stderr)
return 2
selected = [c for c in CASES if not args.cases or c["name"] in args.cases]
os.makedirs(WORK, exist_ok=True)
print(f"danos qemu tests arch={args.arch} cases={len(selected)}\n")
failures = 0
for case in selected:
print(f" {case['name']:<12} ... ", end="", flush=True)
ok, detail = run_case(arch, case)
print(("PASS" if ok else "FAIL") + f" ({detail})")
if not ok:
failures += 1
print()
total = len(selected)
print(f"{total - failures}/{total} passed")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())