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
+7 -2
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` |
+10 -6
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
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.