Cross-architecture test suite
This commit is contained in:
@@ -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).
|
// 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"));
|
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 ---
|
// --- 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,
|
// 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
|
// 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 = &.{
|
.imports = &.{
|
||||||
.{ .name = "danos", .module = mod },
|
.{ .name = "danos", .module = mod },
|
||||||
.{ .name = "arch", .module = arch_mod },
|
.{ .name = "arch", .module = arch_mod },
|
||||||
|
.{ .name = "build_options", .module = build_options_mod },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
+7
-2
@@ -33,6 +33,9 @@ Cutting across all of these:
|
|||||||
- **[arch.md](arch.md) — the architecture split.** How CPU-specific code is kept
|
- **[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,
|
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.
|
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
|
## 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` |
|
| Kernel entry, panic, bring-up | `src/main.zig` |
|
||||||
| Shared loader↔kernel contract (`BootInfo`, `Framebuffer`, `MemoryMap`, ABI) | `src/root.zig` |
|
| Shared loader↔kernel contract (`BootInfo`, `Framebuffer`, `MemoryMap`, ABI) | `src/root.zig` |
|
||||||
| Physical frame allocator | `src/pmm.zig` |
|
| Physical frame allocator | `src/pmm.zig` |
|
||||||
| Framebuffer text console | `src/console.zig` |
|
| Framebuffer text console (mirrors to serial) | `src/console.zig` |
|
||||||
| Arch-specific kernel code (`halt`, GDT/IDT/TSS, exception stubs, page tables, linker script) | `src/arch/x86_64/` |
|
| 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` |
|
| Build + `run-efi` (QEMU/OVMF) | `build.zig` |
|
||||||
|
| QEMU integration test harness | `test/qemu_test.py` |
|
||||||
|
|||||||
+10
-6
@@ -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
|
- **`src/arch/x86_64/cpu.zig`** — the `arch` module root. Exposes `halt()` (see
|
||||||
[halting.md](halting.md)), `init()` (bring up the descriptor tables),
|
[halting.md](halting.md)), `init()` (bring up the descriptor tables),
|
||||||
`setFaultHandler`, `readCr2`, and the `CpuState` trap frame. Paging will join it
|
`enablePaging()`, `setFaultHandler`, `readCr2`/`readCr3`, and the `CpuState`
|
||||||
here as the kernel grows.
|
trap frame.
|
||||||
- **`src/arch/x86_64/gdt.zig`** / **`idt.zig`** — the GDT and IDT plus CPU-exception
|
- **`src/arch/x86_64/gdt.zig`** / **`idt.zig`** / **`tss.zig`** — the GDT, IDT and
|
||||||
handling (see [interrupts.md](interrupts.md)).
|
TSS plus CPU-exception handling (see [interrupts.md](interrupts.md)).
|
||||||
- **`src/arch/x86_64/isr.s`** — the exception stubs and the `lgdt`/`lidt` load
|
- **`src/arch/x86_64/paging.zig`** — the kernel's page tables (see
|
||||||
helpers, in real assembly because Zig inline asm can't express them.
|
[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
|
- **`src/arch/x86_64/linker.ld`** — the kernel link layout (fixed low load
|
||||||
address, one PT_LOAD per permission set).
|
address, one PT_LOAD per permission set).
|
||||||
|
|
||||||
|
|||||||
+113
@@ -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 tss = @import("tss.zig");
|
||||||
const idt = @import("idt.zig");
|
const idt = @import("idt.zig");
|
||||||
const paging = @import("paging.zig");
|
const paging = @import("paging.zig");
|
||||||
|
const serial = @import("serial.zig");
|
||||||
|
|
||||||
/// The saved register/trap frame passed to a fault handler.
|
/// The saved register/trap frame passed to a fault handler.
|
||||||
pub const CpuState = idt.CpuState;
|
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
|
/// 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
|
/// 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
|
/// 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 std = @import("std");
|
||||||
const danos = @import("danos");
|
const danos = @import("danos");
|
||||||
|
const arch = @import("arch");
|
||||||
|
|
||||||
/// The console font, embedded at compile time. cp850-8x16, PSF2 format:
|
/// 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
|
/// 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 {
|
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);
|
for (bytes) |c| self.putChar(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ const danos = @import("danos");
|
|||||||
const arch = @import("arch");
|
const arch = @import("arch");
|
||||||
const console = @import("console.zig");
|
const console = @import("console.zig");
|
||||||
const pmm = @import("pmm.zig");
|
const pmm = @import("pmm.zig");
|
||||||
|
const tests = @import("tests.zig");
|
||||||
|
const build_options = @import("build_options");
|
||||||
const BootInfo = danos.BootInfo;
|
const BootInfo = danos.BootInfo;
|
||||||
|
|
||||||
/// The calling convention used to enter the kernel. Pinned to SysV explicitly:
|
/// 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 {
|
fn kmain(boot_info: *const BootInfo) noreturn {
|
||||||
|
arch.serialInit(); // machine-readable log; console mirrors to it
|
||||||
|
|
||||||
const fb = boot_info.framebuffer;
|
const fb = boot_info.framebuffer;
|
||||||
con = console.Console.init(fb);
|
con = console.Console.init(fb);
|
||||||
con.clear();
|
con.clear();
|
||||||
@@ -87,6 +91,13 @@ fn kmain(boot_info: *const BootInfo) noreturn {
|
|||||||
con.print("\ndanos: paging enabled\n", .{});
|
con.print("\ndanos: paging enabled\n", .{});
|
||||||
con.print(" page tables: CR3 = 0x{x:0>16}\n", .{arch.readCr3()});
|
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");
|
con.write("\nkernel initialised; nothing left to do, halting.\n");
|
||||||
|
|
||||||
arch.halt();
|
arch.halt();
|
||||||
|
|||||||
+122
@@ -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())
|
||||||
Reference in New Issue
Block a user