From c1331a582b96d3ff4d6294cda0810eb984eb5a82 Mon Sep 17 00:00:00 2001 From: BFE Engineering Date: Sun, 23 Aug 2026 09:23:37 -0600 Subject: [PATCH] warden-sdk: bootstrap repo + HPMCU (RISC-V) watchdog simulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017wB8KB3MMQztRDXCMCkPrf --- .gitignore | 9 ++ README.md | 108 +++++++++++++++++++ sim/Cargo.toml | 14 +++ sim/src/hpmcu.rs | 257 ++++++++++++++++++++++++++++++++++++++++++++++ sim/src/lib.rs | 16 +++ sim/src/membus.rs | 74 +++++++++++++ 6 files changed, 478 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 sim/Cargo.toml create mode 100644 sim/src/hpmcu.rs create mode 100644 sim/src/lib.rs create mode 100644 sim/src/membus.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d0ae7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Rust +target/ +**/target/ +Cargo.lock + +# scratch / editor +*.swp +*~ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..60257a6 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# warden-sdk + +The build, driver, and simulation SDK for WardenOS (the Luckfox Pico 86-Panel / +RV1106 HMI). A from-scratch replacement for the twice-ported vendor stack +(Rockchip SDK → Luckfox SDK → our patched fork), built to the same standard as +the rest of the firmware: tested, benchmarked, reproducible, and honest about +what runs on real silicon versus what we simulate. + +> Status: **bootstrapping.** This repo is being stood up incrementally; today it +> hosts the hardware **simulator** and its tests. The kernel forward-port and the +> hermetic image build move in as each is proven. Until then, flare-edge still +> builds firmware from the vendored SDK + `sdk-patches/`; nothing here is on the +> production build path yet. + +## Why a new SDK + +The vendored SDK is a ~2 GB opaque fork of a fork. Our real changes to it lived, +until recently, as uncommitted edits in one working copy (`flare-edge/sdk-patches/` +is the tracked form). It bakes absolute paths, needs `python` (not python3), +silently drops Kconfig options, and — the failure that motivated this repo — gives +us **no way to test hardware-dependent code off the device.** Every driver change +had to be validated by flashing a panel. That is slow, and it is dangerous: it is +how a boot-loaded-watchdog change bricked a bench unit (the load address collided +with unreserved kernel RAM — a mistake a target-config check or a memory-map model +would have caught before any flash). + +The SDK's job is to make the firmware **buildable, testable, and hardenable +without a panel in the loop**, and to move us onto a modern, maintained kernel. + +## Goals (from future-features) + +1. **Modern kernel.** Move to the newest stable Linux we can run on our current + Buildroot LTS (2025.02.x). The realistic ceiling is **plan44's OpenWrt RV1106 + fork — Linux 6.6**, which carries 152 RV1106 patches and a devicetree for our + exact board. Mainline is not viable (no DT/clk/display/RGA/NPU/flash-boot). + This is a bounded, evidence-backed forward-port, not a mainline chase. +2. **Ported, hardened drivers → 100% MC/DC on the code we own.** "100% MC/DC on + 100% of drivers" is infeasible as literally stated: ~97% of driver LOC is + vendor blobs (the AIC8800 wifi driver alone is 88.5K lines). So the target is + **tiered**: real MC/DC on *our* hardware code (modbus master, relays, RGA + wrapper, HPMCU supervisor, devmem/reset ladder); fault-injection + branch + hardening for the vendor blobs behind a stable seam. +3. **A proper simulator.** Simulate the hardware the vendor SDK cannot: **RGA** + (2D blitter), the **RISC-V HPMCU** coprocessor, and the **NPU** — plus the + register/SRAM (`/dev/mem`) and sysfs surfaces the drivers touch — so driver and + supervisor logic runs and is tested on the host, in CI, with no panel. +4. **Its own repo, held to firmware standards.** Tests, benchmarks, reproducible + builds, CI. This repo. + +## Architecture — one seam, two backends + +The organizing idea is a thin **Hardware Abstraction Seam** per hardware block. +Firmware code talks to the seam (a trait in Rust, a function table in C); the seam +has two backends: + +``` + firmware / driver logic + │ + Hardware Abstraction Seam (devmem, hpmcu, rga, npu, modbus, gpio) + ┌────┴────┐ + real backend sim backend + (/dev/mem, ioctl, (software model, + /proc, serial) host-testable) +``` + +- **On-device**, the seam binds the real backend (mmap `/dev/mem`, `librga` + ioctls, the serial port, `/proc/rknpu`). +- **On the host**, it binds the **sim backend** — a faithful software model of the + block. The HPMCU sim, for example, runs the SCR1 watchdog firmware's exact state + machine (boot-grace, heartbeat-timeout, fire) against an in-memory mailbox, so + the flared supervisor's arm/beat protocol is exercised end-to-end in a unit test. + +The seam is the same object the driver-hardening effort measures MC/DC against, +and the same object the simulator implements — so the two goals reinforce rather +than duplicate each other. + +## Layout + +``` +sim/ the hardware simulator (Rust): mailbox/devmem model, HPMCU, RGA, NPU. +drivers/ our own hardened drivers + their seams (as they migrate in). +patches/ the vendor-SDK delta (mirrors flare-edge/sdk-patches until it moves here). +build/ the hermetic image-build wrapper (kernel → rootfs → image), incremental. +ci/ CI: patches-still-apply, host tests, coverage, benchmarks. +docs/ architecture + ADRs (decisions/). +tools/ dev tooling. +``` + +## Principles + +Evaluated against the stack philosophy — **openness, hardness, modernness**: + +- **Open** over closed where we can: `rkdeveloptool` over the closed `upgrade_tool`; + source-buildable `librga` over blobs where a source path exists; the simulator is + fully open and ours. +- **Hard**: every seam has a fault-injection path (a wedged SDIO link, a stalled + MCU, an RGA timeout) so recovery code is tested against failure, not just success. + On-device claims still need on-device evidence; the sim narrows *which* claims + need a panel, it does not replace that rule. +- **Modern**: newest kernel we can actually run; current Buildroot LTS; Rust for new + host-testable code; reproducible builds. + +## Relationship to flare-edge + +flare-edge (WardenOS: the LVGL UI + the `flared` daemon) is the product; warden-sdk +is what builds and tests it. During bootstrap, flare-edge consumes warden-sdk piece +by piece: first the simulator (as a dev/test dependency), later the image build. +No flare-edge code moves here — only the SDK/build/sim/driver-seam layer. diff --git a/sim/Cargo.toml b/sim/Cargo.toml new file mode 100644 index 0000000..956ebde --- /dev/null +++ b/sim/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "warden-sim" +version = "0.1.0" +edition = "2021" +description = "Host-side hardware simulator for WardenOS: register/SRAM bus, HPMCU (RISC-V) watchdog, RGA, NPU. Lets driver and supervisor logic run and be tested with no panel." +license = "MIT OR Apache-2.0" + +[lib] +name = "warden_sim" +path = "src/lib.rs" + +[dependencies] + +[dev-dependencies] diff --git a/sim/src/hpmcu.rs b/sim/src/hpmcu.rs new file mode 100644 index 0000000..7c8c933 --- /dev/null +++ b/sim/src/hpmcu.rs @@ -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 { + bus: B, + base: u64, + t0: u64, + last_change: u64, + last_seen: u32, + polls: u32, + armed_ever: bool, + fired: bool, + fire_count: u64, +} + +impl HpmcuSim { + /// 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 { + 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"); + } +} diff --git a/sim/src/lib.rs b/sim/src/lib.rs new file mode 100644 index 0000000..a8dc954 --- /dev/null +++ b/sim/src/lib.rs @@ -0,0 +1,16 @@ +//! warden-sim — host-side hardware simulator for WardenOS. +//! +//! Lets driver and supervisor logic run and be tested on the host, with no panel, +//! by modelling the RV1106 hardware the vendor SDK cannot: the register/SRAM bus +//! ([`membus`]), the RISC-V HPMCU watchdog coprocessor ([`hpmcu`]), and — as they +//! land — the RGA blitter and the NPU. +//! +//! Design: one [`membus::MemBus`] seam, two backends. On the host, [`membus::SimBus`] +//! is an in-memory word map; on the device, flared's `devmem.rs` implements the same +//! trait over `/dev/mem`, so the same code runs against either. See the repo README. + +pub mod hpmcu; +pub mod membus; + +pub use hpmcu::HpmcuSim; +pub use membus::{MemBus, SimBus}; diff --git a/sim/src/membus.rs b/sim/src/membus.rs new file mode 100644 index 0000000..cb51ede --- /dev/null +++ b/sim/src/membus.rs @@ -0,0 +1,74 @@ +//! The register / SRAM access seam. +//! +//! Every hardware block on the RV1106 that our code touches through `/dev/mem` +//! (the CRU reset ladder, the HPMCU mailbox, the SGRF boot-addr register) reaches +//! it as a 32-bit poke or peek at a physical address. `MemBus` is that operation, +//! abstracted so the same driver/supervisor code runs against either: +//! +//! * the **real** backend — an mmap of `/dev/mem` (lives in flared's +//! `devmem.rs`; it will implement this trait so its logic is host-testable), or +//! * the **sim** backend — [`SimBus`], an in-memory word map. +//! +//! `SimBus` is `Clone` + internally `Arc>`, so the simulated MCU core +//! and the "Linux side" can each hold a handle and read/write the *same* shared +//! memory — exactly the two-core mailbox the real system uses — with no +//! cache-maintenance dance to model (the real mailbox sits in the GRF uncached +//! window). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +/// A 32-bit physical-address register/SRAM bus. Addresses must be 4-byte aligned. +pub trait MemBus { + fn peek32(&self, phys: u64) -> u32; + fn poke32(&self, phys: u64, val: u32); +} + +/// In-memory bus for host tests. Unwritten words read as 0. Shared handles +/// (via `clone`) alias the same backing store. +#[derive(Clone, Default)] +pub struct SimBus { + words: Arc>>, +} + +impl SimBus { + pub fn new() -> Self { + Self::default() + } + + /// Snapshot every written word (address-sorted) — for test assertions/dumps. + pub fn dump(&self) -> Vec<(u64, u32)> { + let g = self.words.lock().unwrap(); + let mut v: Vec<(u64, u32)> = g.iter().map(|(&a, &w)| (a, w)).collect(); + v.sort_by_key(|&(a, _)| a); + v + } +} + +impl MemBus for SimBus { + fn peek32(&self, phys: u64) -> u32 { + debug_assert_eq!(phys & 0x3, 0, "unaligned peek32 @ {phys:#x}"); + *self.words.lock().unwrap().get(&phys).unwrap_or(&0) + } + + fn poke32(&self, phys: u64, val: u32) { + debug_assert_eq!(phys & 0x3, 0, "unaligned poke32 @ {phys:#x}"); + self.words.lock().unwrap().insert(phys, val); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unwritten_reads_zero_and_writes_alias_through_clones() { + let a = SimBus::new(); + let b = a.clone(); + assert_eq!(a.peek32(0xff6f_ff00), 0); + a.poke32(0xff6f_ff00, 0xdead_beef); + // The clone sees it: same shared store (two-core shared memory). + assert_eq!(b.peek32(0xff6f_ff00), 0xdead_beef); + assert_eq!(a.dump(), vec![(0xff6f_ff00, 0xdead_beef)]); + } +}