Files
bfe-core1106-sdk/sim/src/hpmcu.rs
T
BFE Engineering 42fb386f60 docs: ASCII typography and style normalization across all repo text
Replace every em dash with real punctuation (rewrites, not hyphen swaps)
in docs, code comments, scripts, configs, and the port records; convert
en dashes, curly quotes, ellipsis glyphs, arrows, and section signs to
ASCII; drop machine-writing tell phrases from living docs. ADR titles
now use a colon. The M2 bring-up DTS model string carried an em dash
into the patch series and its record echoes; fixed at both, and the full
series re-verified to apply cleanly onto pristine 6.18.46. One comment
in freshness.h deliberately names the em dash glyph the UI renders as
the unknown mark; that is data, kept as prose naming it.

Verified: cargo tests (sim, config-lint, rs485-bridge), shellcheck,
both driver MC/DC harnesses, patches-apply.
2026-08-31 12:31:27 -06:00

270 lines
9.5 KiB
Rust

//! 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"
);
}
}