test: on-hardware SMP self-test (revoke/kill must stop every hart) + make test-qemu

This commit is contained in:
2026-10-27 12:54:42 +01:00
parent 909b3f0503
commit b814bd9570
10 changed files with 262 additions and 12 deletions

8
Cargo.lock generated
View File

@@ -294,6 +294,14 @@ dependencies = [
"types", "types",
] ]
[[package]]
name = "smptest"
version = "0.1.0"
dependencies = [
"libkos",
"types",
]
[[package]] [[package]]
name = "snake" name = "snake"
version = "0.1.0" version = "0.1.0"

View File

@@ -3,6 +3,7 @@ members = [
"crates/adapter-riscv", "crates/adapter-riscv",
"crates/apps/hello", "crates/apps/hello",
"crates/apps/echo", "crates/apps/echo",
"crates/apps/smptest",
"crates/apps/snake", "crates/apps/snake",
"crates/domain", "crates/domain",
"crates/elf", "crates/elf",

View File

@@ -1,4 +1,4 @@
.PHONY: check fmt clippy test kani build all image run ci kernel-fmt kernel-clippy kernel-test .PHONY: check fmt clippy test kani build all image run ci kernel-fmt kernel-clippy kernel-test test-qemu
RISCV_TARGET = riscv64gc-unknown-none-elf RISCV_TARGET = riscv64gc-unknown-none-elf
KERNEL_BIN = target/$(RISCV_TARGET)/release/adapter-riscv KERNEL_BIN = target/$(RISCV_TARGET)/release/adapter-riscv
@@ -52,11 +52,17 @@ build:
cargo build --target $(RISCV_TARGET) --release \ cargo build --target $(RISCV_TARGET) --release \
-p adapter-riscv -p init -p echo -p snake \ -p adapter-riscv -p init -p echo -p snake \
-p display-qemu -p input-qemu -p storage-qemu \ -p display-qemu -p input-qemu -p storage-qemu \
-p log-server -p permission-manager -p session-server -p log-server -p permission-manager -p session-server -p smptest
image: build image: build
scripts/make-disk-image.sh scripts/make-disk-image.sh
# Boots a test image (normal image + smptest.elf) on $(SMP) harts; init runs
# the SMP self-test and halts. Passes only on "SMPTEST PASS".
test-qemu: build
IMG=test.img SMPTEST=1 scripts/make-disk-image.sh > /dev/null
SMP=$(SMP) scripts/test-qemu.sh test.img
run: build run: build
qemu-system-riscv64 -machine virt -smp $(SMP) -nographic -bios default \ qemu-system-riscv64 -machine virt -smp $(SMP) -nographic -bios default \
-kernel $(KERNEL_BIN) -kernel $(KERNEL_BIN)

View File

@@ -0,0 +1,13 @@
[package]
name = "smptest"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "smptest"
test = false
bench = false
[dependencies]
types = { workspace = true }
libkos = { workspace = true }

View File

@@ -0,0 +1,51 @@
//! The spinner half of init's SMP self-test (see init's `selftest`):
//! maps the counter page it was given in slot 0 and runs one thread per
//! hart, each incrementing its own counter for ever. Init watches the
//! counters through its own mapping of the same page.
#![no_std]
#![no_main]
extern crate libkos;
use core::sync::atomic::{AtomicUsize, Ordering};
use libkos::syscall;
use types::address::{EntryPoint, StackPointer, VirtualAddress};
use types::bounded::{CapSlot, PageCount};
use types::mapping::PagePerms;
/// Where the counter page is mapped. Layout (words): 0 the next free
/// counter index, 1..=THREADS the counters.
const COUNTERS_VA: usize = 0x0100_0000;
const THREADS: usize = 4;
#[unsafe(no_mangle)]
extern "C" fn main() {
let page = CapSlot::const_new(0);
if syscall::map_region(page, VirtualAddress::from_raw(COUNTERS_VA), PagePerms::ReadWrite).is_err() {
libkos::println!("smptest: cannot map the counter page");
syscall::exit();
}
for _ in 1..THREADS {
let Some(stack) = PageCount::new(1).and_then(syscall::request_pages) else {
break;
};
let top = StackPointer::from_raw(stack.raw() + 4096);
syscall::spawn_thread(EntryPoint::from_raw(spin as *const () as usize), top);
}
spin();
}
extern "C" fn spin() -> ! {
// SAFETY: COUNTERS_VA maps the counter page; every access is atomic.
let word = |i: usize| unsafe { &*((COUNTERS_VA + i * 8) as *const AtomicUsize) };
let mine = 1 + word(0).fetch_add(1, Ordering::Relaxed) % THREADS;
loop {
// Yield now and then: a spinner sharing init's hart would
// otherwise starve it until the next (1 s) timer tick.
for _ in 0..1024 {
word(mine).fetch_add(1, Ordering::Relaxed);
}
syscall::yield_now();
}
}

View File

@@ -49,11 +49,24 @@ fn virtio_id(region: CapSlot) -> u32 {
id id
} }
fn boot_info() -> &'static BootInfo {
// SAFETY: boot maps the BootInfo page read-only at BOOT_INFO_VA before
// init runs; readers check its magic.
unsafe { &*(BOOT_INFO_VA as *const BootInfo) }
}
/// The capability that halts the machine, if boot said where it is.
pub fn system_control() -> Option<CapSlot> {
let info = boot_info();
if info.magic != types::bootinfo::MAGIC {
return None;
}
slot(info.system_control_slot)
}
/// Sorts the board's devices out. /// Sorts the board's devices out.
pub fn discover() -> Devices { pub fn discover() -> Devices {
// SAFETY: boot maps the BootInfo page read-only at BOOT_INFO_VA before let info = boot_info();
// init runs; `devices()` checks its magic.
let info = unsafe { &*(BOOT_INFO_VA as *const BootInfo) };
let mut found = Devices { disk: Device::NONE, gpu: Device::NONE, input: Device::NONE }; let mut found = Devices { disk: Device::NONE, gpu: Device::NONE, input: Device::NONE };
let mut uart = Device::NONE; let mut uart = Device::NONE;
for d in info.devices() { for d in info.devices() {

View File

@@ -3,6 +3,7 @@
extern crate alloc; extern crate alloc;
mod devices; mod devices;
mod selftest;
use types::address::{EntryPoint, StackPointer, VirtualAddress}; use types::address::{EntryPoint, StackPointer, VirtualAddress};
use types::bounded::{CapSlot, PageCount}; use types::bounded::{CapSlot, PageCount};
@@ -12,7 +13,7 @@ use libkos::syscall;
const PAGE_SIZE: usize = 4096; const PAGE_SIZE: usize = 4096;
const USER_CODE_BASE: usize = 0x10000; const USER_CODE_BASE: usize = 0x10000;
const USER_STACK_TOP: StackPointer = StackPointer::from_raw(USER_CODE_BASE); pub(crate) const USER_STACK_TOP: StackPointer = StackPointer::from_raw(USER_CODE_BASE);
const USER_STACK_PAGES: usize = 4; const USER_STACK_PAGES: usize = 4;
/// Where a program file is read to before it is loaded (16 pages: the /// Where a program file is read to before it is loaded (16 pages: the
/// largest program init will load). /// largest program init will load).
@@ -36,12 +37,12 @@ fn read_file(storage: &mut Storage, scratch: &mut [u8], filename: &[u8]) -> Opti
read.filter(|r| Some(*r) == size) read.filter(|r| Some(*r) == size)
} }
struct LoadedProcess { pub(crate) struct LoadedProcess {
process_cap: CapSlot, pub(crate) process_cap: CapSlot,
entry_point: EntryPoint, pub(crate) entry_point: EntryPoint,
} }
fn load_binary(storage: &mut Storage, scratch: &mut [u8], filename: &[u8]) -> Option<LoadedProcess> { pub(crate) fn load_binary(storage: &mut Storage, scratch: &mut [u8], filename: &[u8]) -> Option<LoadedProcess> {
let file_size = read_file(storage, scratch, filename)?; let file_size = read_file(storage, scratch, filename)?;
let elf_data = &scratch[..file_size]; let elf_data = &scratch[..file_size];
let elf_info = match elf::parse(elf_data) { let elf_info = match elf::parse(elf_data) {
@@ -156,6 +157,13 @@ pub fn run() -> ! {
libkos::println!("ok (entry={:#x})", loaded.entry_point.raw()); libkos::println!("ok (entry={:#x})", loaded.entry_point.raw());
} }
// The test image carries the SMP self-test; it runs instead of the
// desktop and halts the machine.
if storage.open(b"smptest.elf").is_ok_and(|h| storage.close(h).is_ok()) {
libkos::println!("init: running the SMP self-test");
selftest::run(&mut storage, scratch);
}
libkos::print!("init: loading snake... "); libkos::print!("init: loading snake... ");
if let Some(snake) = load_binary(&mut storage, scratch, b"snake.elf") { if let Some(snake) = load_binary(&mut storage, scratch, b"snake.elf") {
// A client only calls: Execute, never Read, so it cannot pose as // A client only calls: Execute, never Read, so it cannot pose as
@@ -189,7 +197,7 @@ fn scratch() -> Option<&'static mut [u8]> {
/// Nothing to supervise yet: block on a notification nobody signals /// Nothing to supervise yet: block on a notification nobody signals
/// rather than spin a core on Yield. /// rather than spin a core on Yield.
fn idle() -> ! { pub(crate) fn idle() -> ! {
let idle = syscall::create_notification(); let idle = syscall::create_notification();
loop { loop {
match idle { match idle {

119
crates/init/src/selftest.rs Normal file
View File

@@ -0,0 +1,119 @@
//! SMP self-test, run only when the disk carries `smptest.elf` (the test
//! image built by `make test-qemu`). It checks on real harts what host
//! tests cannot: that unmapping a page and killing a process take effect
//! on every other hart before the call returns.
//!
//! A spinner process runs one thread per hart, each incrementing its own
//! counter in a page init also maps. Init then cuts the spinner off
//! (revoking its copy of the page, or killing it) and requires that no
//! counter moves afterwards: a stale TLB entry or a thread left running
//! on another hart would keep counting.
use core::sync::atomic::{AtomicUsize, Ordering};
use libkos::fs::Storage;
use libkos::syscall;
use types::address::VirtualAddress;
use types::bounded::{CapSlot, PageCount};
use types::mapping::PagePerms;
use types::permission::{Permission, PermissionSet};
/// Where init sees the counter page; same layout as the spinner's.
const OBSERVE_VA: usize = 0x0140_0000;
const THREADS: usize = 4;
/// QEMU virt's timebase is 10 MHz.
const TICKS_PER_MS: u64 = 10_000;
fn word(i: usize) -> &'static AtomicUsize {
// SAFETY: OBSERVE_VA maps the counter page for the whole test.
unsafe { &*((OBSERVE_VA + i * 8) as *const AtomicUsize) }
}
fn counters() -> [usize; THREADS] {
core::array::from_fn(|i| word(1 + i).load(Ordering::SeqCst))
}
/// Spins (yielding) until `done` or `ms` milliseconds pass.
fn wait_until(ms: u64, mut done: impl FnMut() -> bool) -> bool {
let deadline = syscall::get_time().raw() + ms * TICKS_PER_MS;
while syscall::get_time().raw() < deadline {
if done() {
return true;
}
syscall::yield_now();
}
done()
}
/// Starts a spinner on `page`; its process capability.
fn start_spinner(storage: &mut Storage, scratch: &mut [u8], page: CapSlot) -> Result<CapSlot, &'static str> {
for i in 0..=THREADS {
word(i).store(0, Ordering::SeqCst);
}
let spinner = crate::load_binary(storage, scratch, b"smptest.elf").ok_or("cannot load smptest.elf")?;
let rw = PermissionSet::empty()
.with(Permission::Read)
.with(Permission::Write)
.with(Permission::Delegate);
if !syscall::delegate(page, spinner.process_cap, rw) {
return Err("cannot give the spinner its page");
}
syscall::spawn_thread_in(spinner.process_cap, spinner.entry_point, crate::USER_STACK_TOP);
if !wait_until(3000, || counters().iter().all(|c| *c > 0)) {
return Err("spinner threads did not all start");
}
Ok(spinner.process_cap)
}
/// After `stop`, no counter may move.
fn check_stopped(what: &'static str) -> Result<(), &'static str> {
let before = counters();
wait_until(200, || false);
if counters() != before {
return Err(what);
}
Ok(())
}
fn run_phases(storage: &mut Storage, scratch: &mut [u8]) -> Result<(), &'static str> {
let page = syscall::create_region(PageCount::new(1).ok_or("page count")?).map_err(|_| "no counter page")?;
syscall::map_region(page, VirtualAddress::from_raw(OBSERVE_VA), PagePerms::ReadWrite)
.map_err(|_| "cannot map the counter page")?;
// 1. Unmapping: revoking the spinner's copy of the page must unmap it
// on every hart before Revoke returns. The spinner then faults,
// which kills it from whichever hart faults first.
let spinner = start_spinner(storage, scratch, page)?;
if !syscall::revoke(page) {
return Err("revoke failed");
}
check_stopped("a counter moved after its page was revoked (stale TLB)")?;
if syscall::kill(spinner) {
return Err("the spinner survived losing its page");
}
libkos::println!("smptest: revoke stops every hart: ok");
// 2. Killing: a process whose threads are running on other harts must
// stop everywhere before Kill returns.
let spinner = start_spinner(storage, scratch, page)?;
if !syscall::kill(spinner) {
return Err("kill failed");
}
check_stopped("a counter moved after its process was killed")?;
libkos::println!("smptest: kill stops every hart: ok");
Ok(())
}
/// Runs the test, reports, and halts the machine.
pub fn run(storage: &mut Storage, scratch: &mut [u8]) -> ! {
match run_phases(storage, scratch) {
Ok(()) => libkos::println!("SMPTEST PASS"),
Err(why) => libkos::println!("SMPTEST FAIL: {}", why),
}
match crate::devices::system_control() {
Some(control) => syscall::halt(control),
None => {
libkos::println!("smptest: no SystemControl capability to halt with");
crate::idle()
}
}
}

View File

@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
IMG="boot.img" IMG="${IMG:-boot.img}"
IMG_SIZE_KB=4096 IMG_SIZE_KB=4096
TARGET="riscv64gc-unknown-none-elf" TARGET="riscv64gc-unknown-none-elf"
BUILD_DIR="target/${TARGET}/release" BUILD_DIR="target/${TARGET}/release"
@@ -30,6 +30,10 @@ copy log-server "log.elf"
copy permission-manager "permis.elf" copy permission-manager "permis.elf"
copy storage-qemu "storage.elf" copy storage-qemu "storage.elf"
copy session-server "session.elf" copy session-server "session.elf"
# Only the test image: its presence makes init run the SMP self-test.
if [ -n "${SMPTEST:-}" ]; then
copy smptest "smptest.elf"
fi
echo "" echo ""
echo "Disk contents:" echo "Disk contents:"

27
scripts/test-qemu.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
# Boots IMAGE headless and waits for init's SMP self-test to report; the
# machine halts itself when done. Exit 0 only on "SMPTEST PASS".
set -uo pipefail
IMAGE="${1:?usage: test-qemu.sh IMAGE}"
SMP="${SMP:-4}"
KERNEL="target/riscv64gc-unknown-none-elf/release/adapter-riscv"
LOG="$(mktemp -t kos-test-qemu.XXXXXX)"
timeout ${QT:-120} qemu-system-riscv64 -machine virt -smp "${SMP}" -bios default \
-kernel "${KERNEL}" \
-drive "file=${IMAGE},if=none,format=raw,id=hd0" -device virtio-blk-device,drive=hd0 \
-device virtio-gpu-device -device virtio-keyboard-device \
-display none -serial "file:${LOG}" -monitor none
status=$?
grep -a "smptest:\|SMPTEST" "${LOG}"
if grep -aq "SMPTEST PASS" "${LOG}"; then
echo "test-qemu: pass (${SMP} harts)"
rm -f "${LOG}"
exit 0
fi
[ "${status}" -eq 124 ] && echo "test-qemu: timed out"
echo "test-qemu: FAIL, last serial output (${LOG}):"
tail -20 "${LOG}"
exit 1