warden-sdk: bootstrap repo + HPMCU (RISC-V) watchdog simulator
New from-scratch SDK for WardenOS (RV1106). First substance: the hardware simulator's core — a MemBus register/SRAM seam (one trait, real /dev/mem backend on device + in-memory SimBus on host) and a faithful port of the HPMCU watchdog firmware (hpmcu/watchdog/main.c) as HpmcuSim, with 8 tests validating boot-grace fire, heartbeat-timeout, disarm stand-down, and the flared arm-within-grace safety property (no boot-loop) — all off-device, in a virtual clock, in <1ms. This closes the gap the Explore map flagged: flared's devmem.rs/hpmcu.rs are the only register-touching modules with zero tests. README lays out the SDK vision (modern kernel, tiered driver MC/DC, proper RGA/HPMCU/NPU simulator, own repo). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017wB8KB3MMQztRDXCMCkPrf
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
//! Simulator of the RV1106 HPMCU (Syntacore SCR1) watchdog firmware.
|
||||
//!
|
||||
//! A faithful software port of `flare-edge/hpmcu/watchdog/main.c`'s poll loop:
|
||||
//! it reads the Linux-owned mailbox words (magic + heartbeat counter), writes the
|
||||
//! MCU-owned state word, and fires (records a CRU reset) on the same deadlines —
|
||||
//! boot-grace if userspace never arms it, heartbeat-timeout if a live heartbeat
|
||||
//! stops. Because it runs on a [`MemBus`], the *same* flared arm/beat protocol can
|
||||
//! be driven against it in a host unit test, with a virtual clock, in
|
||||
//! microseconds — the validation that was missing when a boot-loaded build of this
|
||||
//! firmware had to be tested by flashing a panel.
|
||||
//!
|
||||
//! Deadlines are modelled in whole seconds (the firmware's cycle math exists only
|
||||
//! to convert the core clock to these wall-clock seconds); a `tick(now_secs)` runs
|
||||
//! exactly one iteration of the firmware's `for(;;)` body at that virtual time.
|
||||
|
||||
use crate::membus::MemBus;
|
||||
|
||||
pub const MB_BASE: u64 = 0xff6f_ff00;
|
||||
const OFF_MAGIC: u64 = 0x00; // Linux-owned
|
||||
const OFF_COUNTER: u64 = 0x04; // Linux-owned
|
||||
const OFF_STATE: u64 = 0x08; // MCU-owned
|
||||
const OFF_SEEN: u64 = 0x0c; // MCU-owned
|
||||
const OFF_CYC: u64 = 0x10; // MCU-owned (coarse runtime)
|
||||
|
||||
pub const MAGIC_ARMED: u32 = 0x5741_5244; // "WARD"
|
||||
pub const MAGIC_DISARM: u32 = 0x4449_5341; // "DISA"
|
||||
|
||||
pub const STATE_BOOT: u32 = 0xB007_0000;
|
||||
pub const STATE_ARMED: u32 = 0xA07D_0000;
|
||||
pub const STATE_DISARMED: u32 = 0xD15A_0000;
|
||||
pub const STATE_FIRED: u32 = 0xF17E_0000;
|
||||
|
||||
pub const HEARTBEAT_TIMEOUT_S: u64 = 90;
|
||||
pub const BOOT_GRACE_S: u64 = 300;
|
||||
|
||||
/// The simulated MCU core running the watchdog firmware against `bus`.
|
||||
pub struct HpmcuSim<B: MemBus> {
|
||||
bus: B,
|
||||
base: u64,
|
||||
t0: u64,
|
||||
last_change: u64,
|
||||
last_seen: u32,
|
||||
polls: u32,
|
||||
armed_ever: bool,
|
||||
fired: bool,
|
||||
fire_count: u64,
|
||||
}
|
||||
|
||||
impl<B: MemBus> HpmcuSim<B> {
|
||||
/// Bring the MCU up at `start_now` (models the firmware's `main()` prologue:
|
||||
/// state=BOOT, seen=0, boot-grace clock starts here).
|
||||
pub fn new(bus: B, base: u64, start_now: u64) -> Self {
|
||||
bus.poke32(base + OFF_STATE, STATE_BOOT);
|
||||
bus.poke32(base + OFF_SEEN, 0);
|
||||
Self {
|
||||
bus,
|
||||
base,
|
||||
t0: start_now,
|
||||
last_change: start_now,
|
||||
last_seen: 0,
|
||||
polls: 0,
|
||||
armed_ever: false,
|
||||
fired: false,
|
||||
fire_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fired(&self) -> bool {
|
||||
self.fired
|
||||
}
|
||||
pub fn fire_count(&self) -> u64 {
|
||||
self.fire_count
|
||||
}
|
||||
/// The sentinel half (high 16 bits) of the MCU state word.
|
||||
pub fn state(&self) -> u32 {
|
||||
self.bus.peek32(self.base + OFF_STATE) & 0xffff_0000
|
||||
}
|
||||
|
||||
fn fire(&mut self) {
|
||||
self.bus.poke32(self.base + OFF_STATE, STATE_FIRED);
|
||||
self.fired = true;
|
||||
self.fire_count += 1;
|
||||
// Real firmware then writes the CRU global reset and spins forever; a
|
||||
// harness modelling the resulting reboot re-creates the MCU (new boot
|
||||
// grace). Here we just record that it fired.
|
||||
}
|
||||
|
||||
/// One iteration of the firmware poll loop at virtual time `now` (seconds).
|
||||
/// Once fired, the real core spins in `fire()`, so further ticks are no-ops.
|
||||
pub fn tick(&mut self, now: u64) {
|
||||
if self.fired {
|
||||
return;
|
||||
}
|
||||
self.polls = self.polls.wrapping_add(1);
|
||||
self.bus.poke32(self.base + OFF_CYC, (now & 0xffff_ffff) as u32);
|
||||
let magic = self.bus.peek32(self.base + OFF_MAGIC);
|
||||
let counter = self.bus.peek32(self.base + OFF_COUNTER);
|
||||
let poll_lo = self.polls & 0xffff;
|
||||
|
||||
if magic == MAGIC_DISARM {
|
||||
// Deliberate stand-down: resume the moment Linux re-arms; the grace
|
||||
// clock restarts so a disarm-then-silence never fires.
|
||||
self.bus.poke32(self.base + OFF_STATE, STATE_DISARMED | poll_lo);
|
||||
self.t0 = now;
|
||||
self.last_change = now;
|
||||
} else if magic == MAGIC_ARMED {
|
||||
self.armed_ever = true;
|
||||
if counter != self.last_seen {
|
||||
self.last_seen = counter;
|
||||
self.bus.poke32(self.base + OFF_SEEN, counter);
|
||||
self.last_change = now;
|
||||
}
|
||||
self.bus.poke32(self.base + OFF_STATE, STATE_ARMED | poll_lo);
|
||||
if now.saturating_sub(self.last_change) > HEARTBEAT_TIMEOUT_S {
|
||||
self.fire();
|
||||
}
|
||||
} else {
|
||||
// Not (yet) armed. Catches "kernel never brought userspace up" via
|
||||
// boot-grace, and a live-then-dead heartbeat via the timeout.
|
||||
self.bus.poke32(self.base + OFF_STATE, STATE_BOOT | poll_lo);
|
||||
if !self.armed_ever && now.saturating_sub(self.t0) > BOOT_GRACE_S {
|
||||
self.fire();
|
||||
}
|
||||
if self.armed_ever && now.saturating_sub(self.last_change) > HEARTBEAT_TIMEOUT_S {
|
||||
self.fire();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::membus::SimBus;
|
||||
|
||||
fn mcu(bus: &SimBus) -> HpmcuSim<SimBus> {
|
||||
HpmcuSim::new(bus.clone(), MB_BASE, 0)
|
||||
}
|
||||
fn arm_beat(bus: &SimBus, counter: u32) {
|
||||
bus.poke32(MB_BASE + OFF_COUNTER, counter);
|
||||
bus.poke32(MB_BASE + OFF_MAGIC, MAGIC_ARMED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boots_into_boot_state() {
|
||||
let bus = SimBus::new();
|
||||
let m = mcu(&bus);
|
||||
assert_eq!(m.state(), STATE_BOOT);
|
||||
assert!(!m.fired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boot_grace_fires_only_after_300s_when_never_armed() {
|
||||
let bus = SimBus::new();
|
||||
let mut m = mcu(&bus);
|
||||
// Poll steadily; magic stays 0 (never armed).
|
||||
for now in (0..=300).step_by(10) {
|
||||
m.tick(now);
|
||||
assert!(!m.fired(), "must not fire at or before boot-grace ({now}s)");
|
||||
}
|
||||
m.tick(301);
|
||||
assert!(m.fired(), "must fire just past the 300s boot grace");
|
||||
assert_eq!(m.state(), STATE_FIRED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armed_and_beating_never_fires() {
|
||||
let bus = SimBus::new();
|
||||
let mut m = mcu(&bus);
|
||||
// flared arms + beats every 5s for a simulated hour.
|
||||
for (i, now) in (0..3600).step_by(5).enumerate() {
|
||||
arm_beat(&bus, i as u32 + 1);
|
||||
m.tick(now);
|
||||
assert!(!m.fired(), "a beating heartbeat must never fire (@{now}s)");
|
||||
}
|
||||
assert_eq!(m.state(), STATE_ARMED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_beating_fires_after_heartbeat_timeout() {
|
||||
let bus = SimBus::new();
|
||||
let mut m = mcu(&bus);
|
||||
arm_beat(&bus, 1);
|
||||
m.tick(0);
|
||||
assert_eq!(m.state(), STATE_ARMED);
|
||||
// Heartbeat stops (counter frozen). Fires just past 90s.
|
||||
for now in (5..=90).step_by(5) {
|
||||
m.tick(now);
|
||||
assert!(!m.fired(), "must not fire within the heartbeat window (@{now}s)");
|
||||
}
|
||||
m.tick(91);
|
||||
assert!(m.fired(), "must fire just past the 90s heartbeat timeout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disarm_stands_down_indefinitely() {
|
||||
let bus = SimBus::new();
|
||||
let mut m = mcu(&bus);
|
||||
bus.poke32(MB_BASE + OFF_MAGIC, MAGIC_DISARM);
|
||||
// Silence for well past both deadlines: a disarmed MCU never fires.
|
||||
for now in (0..=1000).step_by(10) {
|
||||
m.tick(now);
|
||||
assert!(!m.fired(), "a disarmed MCU must never fire (@{now}s)");
|
||||
}
|
||||
assert_eq!(m.state(), STATE_DISARMED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flared_arms_within_boot_grace_no_boot_loop() {
|
||||
// The exact safety property the boot-loaded watchdog needs: on a healthy
|
||||
// boot, flared comes up well before the 300s boot grace, arms the MCU, and
|
||||
// keeps beating — so it transitions BOOT -> ARMED and never fires. (A
|
||||
// failure here would be the boot-loop we must never ship.)
|
||||
let bus = SimBus::new();
|
||||
let mut m = mcu(&bus);
|
||||
// 0..40s: kernel booting, flared not up yet (magic 0). MCU polls in BOOT.
|
||||
for now in (0..40).step_by(5) {
|
||||
m.tick(now);
|
||||
assert_eq!(m.state(), STATE_BOOT);
|
||||
assert!(!m.fired());
|
||||
}
|
||||
// flared starts at 40s and arms+beats every 5s thereafter.
|
||||
let mut counter = 0u32;
|
||||
for now in (40..1000).step_by(5) {
|
||||
counter += 1;
|
||||
arm_beat(&bus, counter);
|
||||
m.tick(now);
|
||||
assert!(!m.fired(), "flared armed before boot-grace: must never fire (@{now}s)");
|
||||
}
|
||||
assert_eq!(m.state(), STATE_ARMED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flared_dies_after_arming_fires_and_would_roll_back() {
|
||||
// flared arms, runs a while, then crashes (stops beating). The MCU fires
|
||||
// after the heartbeat timeout -> reset -> (with A/B) rollback. This is the
|
||||
// recovery the whole supervisor exists for.
|
||||
let bus = SimBus::new();
|
||||
let mut m = mcu(&bus);
|
||||
let mut counter = 0u32;
|
||||
// Beat through t=600 (inclusive) — the last heartbeat lands at 600s.
|
||||
for now in (0..=600).step_by(5) {
|
||||
counter += 1;
|
||||
arm_beat(&bus, counter);
|
||||
m.tick(now);
|
||||
}
|
||||
assert!(!m.fired());
|
||||
// flared is gone: counter frozen, magic still ARMED. last_change=600, so
|
||||
// the 90s heartbeat window closes at 690s; it must not fire before then.
|
||||
for now in (605..=690).step_by(5) {
|
||||
m.tick(now);
|
||||
assert!(!m.fired(), "within the heartbeat window (@{now}s)");
|
||||
}
|
||||
m.tick(695); // 695 - 600 = 95 > 90
|
||||
assert!(m.fired(), "flared dead > heartbeat timeout: MCU fires the reset");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user