Cross-architecture test suite
This commit is contained in:
parent
9cf135302d
commit
c8e89e8115
|
|
@ -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 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
16
docs/arch.md
16
docs/arch.md
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
src/main.zig
11
src/main.zig
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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())
|
||||
Loading…
Reference in New Issue