Files
bfe-core1106-sdk/sim/src/hpmcu.rs
T
BFE EngineeringandClaude Opus 4.8 4ee4dfcf88 review: fix review findings (correctness, hardening, doc-accuracy)
First recursive code-review-harness pass over the authored SDK code (sim/,
config-lint, flowgen, drivers/{relays,freshness}, build/, ci.yml). Four parallel
dimension reviewers; all findings at every severity corrected per workspace rule.

Correctness / reliability:
- freshness.c min_budget_ms: use a `seen` flag, not `best==0`, as the empty
  sentinel — a zero-tolerance (max_stale_ms==0) binding was silently widened to a
  looser neighbour's budget. Regression test added; still 66/66 MC/DC.
- config-lint parse_reserved_ranges: match `reg` as a whole property token (ident
  boundary before, `=` after) so `reg-names` / a `region-*` label no longer
  mis-parses into a bogus reserved range.
- config-lint loader check: fail closed — flag any loader with a LOAD_ADDR that is
  not a known-safe boot component, instead of only known MCU names, so a future
  coprocessor ("Rtos"/"Bl32") can't slip past the 0x40000-brick gate.
- build-kernel.sh: sha256 verification is now mandatory (refuse to build if the pin
  is missing) and the mktemp scratch tree is removed on exit (trap), while a
  caller-provided WORK is left intact for CI artifact upload.

Test quality:
- freshness: added the age==max_stale boundary case and a clock-wraparound
  (now < last_ok) fail-safe-to-UNKNOWN test.
- relays: unsetenv(WARDEN_GPIO_ROOT) at main() so the NULL-env arm is hermetic.

Security / CI:
- ci.yml: top-level `permissions: contents: read` (badges overrides to write);
  pin taiki-e/install-action to commit SHA (v2.86.7).

Maintainability / docs:
- drivers/enforce-mcdc.sh: one shared, name-derived gate replaces the two
  copy-pasted per-driver scripts; Makefiles call ../../enforce-mcdc.sh.
- docs/architecture.md: §3/§4/§6/§7 rewritten to match reality — NPU/RGA models,
  config-lint, and the relays+freshness MC/DC harnesses are done; kernel §6 now
  reflects the 5.10->6.18.46 forward-port (ADR-0001), not the superseded plan44/6.6.
- README: status blurb + layout table corrected (kernel/, .github/; stale ci/ and
  patches/ descriptions fixed). hpmcu "8 tests" -> 7 in docs.
- freshness.{c,h}: ADR reference points at flare-edge ADR-0004 (warden-sdk's
  ADR-0004 is the CI runner — number collision).
- normalize rustfmt drift across sim/ + config-lint.

All green: sim 37 tests, config-lint 8 tests, both drivers 100% MC/DC (relays
40/40, freshness 66/66), clippy clean under -D warnings, gitleaks clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wB8KB3MMQztRDXCMCkPrf
2026-08-25 15:48:52 -06:00

270 lines
9.6 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"
);
}
}