tools: config-lint — MCU-load-vs-reserved-memory gate (0x40000 brick class)
The c8a3 brick was a memory-map fault no behavioural sim can catch: a boot-loaded coprocessor firmware dropped at 0x40000, which is a reserved-memory carve-out on Thunder-Boot boards but plain kernel RAM on ours. config-lint is the static gate for it — parse the rkbin loader .ini for every LOADERn=Hpmcu LOAD_ADDR, parse the target devicetree for reserved-memory ranges, fail if any MCU load lands outside a reservation. Tests encode the brick as a regression against the REAL Thunder-Boot .ini (Hpmcu@0x40000): fails with no rtos@40000 node, passes once reserved; our board's non-TB loader (no boot-loaded MCU) always passes. 6/6 green; CLI verified against the on-disk rkbin .ini files. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017wB8KB3MMQztRDXCMCkPrf
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
840085bd18
commit
4a1ec1d84e
@@ -83,7 +83,7 @@ patches/ the vendor-SDK delta (mirrors flare-edge/sdk-patches until it moves
|
||||
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.
|
||||
tools/ dev tooling. config-lint: static target-config gates (MCU-load-vs-reserved-memory — the 0x40000 brick class).
|
||||
```
|
||||
|
||||
## Principles
|
||||
|
||||
@@ -106,6 +106,16 @@ node." warden-sdk owns these config-lint checks (idblock loader `.ini` vs DT
|
||||
reservations, partition table vs image sizes, vermagic vs kernel) as CI gates, so a
|
||||
mistake is caught before a flash rather than on the bench.
|
||||
|
||||
**Built:** `tools/config-lint` implements the first and most important of these —
|
||||
the MCU-load-vs-`reserved-memory` gate. It parses the rkbin loader `.ini` for
|
||||
every `LOADERn=Hpmcu` firmware and its `[LOADERn_PARAM] LOAD_ADDR`, parses the
|
||||
target devicetree (`.dts`, or `dtc -I dtb` output in CI) for `reserved-memory`
|
||||
ranges, and fails if any MCU load lands outside a reservation. Its test suite
|
||||
encodes the c8a3 brick itself: the real Thunder-Boot `.ini` (Hpmcu @ `0x40000`)
|
||||
fails against a DT with no `rtos@40000` node and passes once the reservation is
|
||||
added. **Next** target-config checks: partition-table-vs-image-size and
|
||||
vermagic-vs-kernel.
|
||||
|
||||
## 6. Kernel forward-port (separate, bounded phase)
|
||||
|
||||
Move to **plan44's OpenWrt RV1106 fork — Linux 6.6** (152 RV1106 patches + our
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "warden-config-lint"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Static target-config checks for WardenOS: catch memory-map faults (the 0x40000 MCU-load brick class) and other flash-time config mistakes before a flash, not on the bench."
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[[bin]]
|
||||
name = "config-lint"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "warden_config_lint"
|
||||
path = "src/lib.rs"
|
||||
@@ -0,0 +1,42 @@
|
||||
# config-lint — static target-config gates
|
||||
|
||||
Catches flash-time config faults the behavioural sim cannot: mistakes in the
|
||||
*memory map*, not the logic. The first check is the one that would have caught the
|
||||
**c8a3 brick** — a boot-loaded coprocessor firmware dropped at `0x40000`, which is
|
||||
a `reserved-memory` carve-out on Thunder-Boot boards but plain kernel RAM on ours,
|
||||
so the MCU and the kernel fought over the same DRAM and the board hung before eth0.
|
||||
|
||||
## The check
|
||||
|
||||
Every address the idblock loader drops MCU firmware to must sit inside a
|
||||
`reserved-memory` node in the target devicetree.
|
||||
|
||||
- **MCU loads** come from the rkbin loader `.ini`: each `LOADERn=Hpmcu` (any
|
||||
hpmcu/mcu/amp entry) in `[LOADER_OPTION]`, with its `LOAD_ADDR` from
|
||||
`[LOADERn_PARAM]`.
|
||||
- **Reserved ranges** come from the devicetree: every `reg = <addr size>` inside a
|
||||
`reserved-memory { … }` node.
|
||||
|
||||
A load outside all reservations is a failure (non-zero exit).
|
||||
|
||||
## Use
|
||||
|
||||
cargo run -p warden-config-lint -- --ini <loader.ini> --dt <devicetree.dts>
|
||||
|
||||
In CI, feed the *flattened* devicetree so includes and overlays are resolved:
|
||||
|
||||
dtc -I dtb -O dts build/.../rv1106g-warden.dtb > /tmp/warden.dts
|
||||
config-lint --ini .../RKBOOT/RV1106MINIALL*.ini --dt /tmp/warden.dts
|
||||
|
||||
Exit `0` = every MCU load is reserved (or there are none); `1` = a collision was
|
||||
found; `2` = usage/IO error.
|
||||
|
||||
## Test
|
||||
|
||||
cargo test -p warden-config-lint
|
||||
|
||||
The suite encodes the brick as a regression: the real Thunder-Boot `.ini`
|
||||
(Hpmcu @ `0x40000`) *fails* against a DT with no `rtos@40000` node and *passes*
|
||||
once the reservation is added — and our board's non-TB loader (no boot-loaded MCU)
|
||||
always passes. See `../../docs/architecture.md` §5 and, for the hardware hazard,
|
||||
the `boot-loaded-mcu-0x40000-hazard` note.
|
||||
@@ -0,0 +1,248 @@
|
||||
//! config-lint — static target-config checks the behavioural sim cannot cover.
|
||||
//!
|
||||
//! The c8a3 brick was a memory-map fault, not a logic bug: the boot-loaded MCU's
|
||||
//! load address (`0x40000`) is a `reserved-memory` carve-out on Thunder-Boot
|
||||
//! boards but plain kernel RAM on ours, so the coprocessor firmware and the kernel
|
||||
//! fought over the same DRAM and the board hung before eth0. No sim catches that —
|
||||
//! it needs a static check against the target devicetree: **every address the
|
||||
//! idblock loader drops MCU firmware to must sit inside a `reserved-memory` node.**
|
||||
//!
|
||||
//! This module parses the two authoritative artifacts (the rkbin loader `.ini`
|
||||
//! and the built/target devicetree) and reports any MCU load that would collide.
|
||||
//! Wire the CLI into CI so the mistake is caught before a flash, not on a bench.
|
||||
|
||||
/// One MCU/coprocessor firmware load declared by the loader `.ini`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McuLoad {
|
||||
pub loader: String, // e.g. "LOADER2"
|
||||
pub name: String, // e.g. "Hpmcu"
|
||||
pub load_addr: u64,
|
||||
}
|
||||
|
||||
/// A `reserved-memory` range `[start, start+size)`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Range {
|
||||
pub start: u64,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
impl Range {
|
||||
pub fn contains(&self, addr: u64) -> bool {
|
||||
addr >= self.start && addr < self.start.saturating_add(self.size)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Finding {
|
||||
pub load: McuLoad,
|
||||
pub msg: String,
|
||||
}
|
||||
|
||||
fn parse_addr(s: &str) -> Option<u64> {
|
||||
let s = s.trim();
|
||||
if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
|
||||
u64::from_str_radix(hex, 16).ok()
|
||||
} else {
|
||||
s.parse::<u64>().ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Does a loader entry name a coprocessor/MCU firmware (whose LOAD_ADDR matters)?
|
||||
fn is_mcu_loader(name: &str) -> bool {
|
||||
let n = name.to_ascii_lowercase();
|
||||
n.contains("hpmcu") || n.contains("mcu") || n.contains("amp")
|
||||
}
|
||||
|
||||
/// Extract MCU firmware loads from an rkbin loader `.ini`: for each `LOADERn=<name>`
|
||||
/// in `[LOADER_OPTION]` that names an MCU loader, read `LOAD_ADDR` from the matching
|
||||
/// `[LOADERn_PARAM]` section.
|
||||
pub fn parse_ini_mcu_loads(ini: &str) -> Vec<McuLoad> {
|
||||
let mut section = String::new();
|
||||
// loader index (e.g. "LOADER2") -> firmware name (e.g. "Hpmcu")
|
||||
let mut loaders: Vec<(String, String)> = Vec::new();
|
||||
// section name -> LOAD_ADDR
|
||||
let mut load_addrs: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
|
||||
|
||||
for raw in ini.lines() {
|
||||
let line = raw.split(['#', ';']).next().unwrap_or("").trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(sec) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
|
||||
section = sec.trim().to_string();
|
||||
continue;
|
||||
}
|
||||
let Some((k, v)) = line.split_once('=') else { continue };
|
||||
let (k, v) = (k.trim(), v.trim());
|
||||
if section == "LOADER_OPTION" && k.to_ascii_uppercase().starts_with("LOADER") {
|
||||
loaders.push((k.to_ascii_uppercase(), v.to_string()));
|
||||
} else if k.eq_ignore_ascii_case("LOAD_ADDR") {
|
||||
if let Some(a) = parse_addr(v) {
|
||||
load_addrs.insert(section.to_ascii_uppercase(), a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
for (loader, name) in loaders {
|
||||
if !is_mcu_loader(&name) {
|
||||
continue;
|
||||
}
|
||||
// LOADER2 -> [LOADER2_PARAM]
|
||||
if let Some(&addr) = load_addrs.get(&format!("{loader}_PARAM")) {
|
||||
out.push(McuLoad { loader, name, load_addr: addr });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Extract `reserved-memory` child ranges from devicetree source (a `.dts`/`.dtsi`,
|
||||
/// or the flattened output of `dtc -I dtb -O dts`). Brace-tracked so only `reg`s
|
||||
/// inside a `reserved-memory { ... }` node are taken. Handles `#size-cells = <1>`
|
||||
/// (RV1106): each `reg = <addr size>` is one range.
|
||||
pub fn parse_reserved_ranges(dt: &str) -> Vec<Range> {
|
||||
let mut out = Vec::new();
|
||||
let bytes = dt.as_bytes();
|
||||
let mut i = 0;
|
||||
while let Some(pos) = dt[i..].find("reserved-memory") {
|
||||
let mut j = i + pos;
|
||||
// find the opening brace of this node
|
||||
while j < bytes.len() && bytes[j] != b'{' {
|
||||
j += 1;
|
||||
}
|
||||
if j >= bytes.len() {
|
||||
break;
|
||||
}
|
||||
let mut depth = 0i32;
|
||||
let start = j;
|
||||
while j < bytes.len() {
|
||||
match bytes[j] {
|
||||
b'{' => depth += 1,
|
||||
b'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
j += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
// scan reg = < a b > inside [start, j)
|
||||
let block = &dt[start..j.min(dt.len())];
|
||||
for reg in block.split("reg").skip(1) {
|
||||
let Some(lt) = reg.find('<') else { continue };
|
||||
let Some(gt) = reg[lt..].find('>') else { continue };
|
||||
let nums: Vec<&str> = reg[lt + 1..lt + gt].split_whitespace().collect();
|
||||
if nums.len() >= 2 {
|
||||
if let (Some(a), Some(s)) = (parse_addr(nums[0]), parse_addr(nums[1])) {
|
||||
out.push(Range { start: a, size: s });
|
||||
}
|
||||
}
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The gate: every MCU load must land inside a reserved-memory range.
|
||||
pub fn check(loads: &[McuLoad], reserved: &[Range]) -> Vec<Finding> {
|
||||
loads
|
||||
.iter()
|
||||
.filter(|l| !reserved.iter().any(|r| r.contains(l.load_addr)))
|
||||
.map(|l| Finding {
|
||||
load: l.clone(),
|
||||
msg: format!(
|
||||
"{}={} loads MCU firmware at {:#x} but no reserved-memory node covers it \
|
||||
— kernel/MCU DRAM collision (the 0x40000 brick class)",
|
||||
l.loader, l.name, l.load_addr
|
||||
),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// The exact idblock .ini that bricked c8a3: Hpmcu at 0x40000.
|
||||
const TB_INI: &str = r#"
|
||||
[LOADER_OPTION]
|
||||
NUM=3
|
||||
LOADER1=FlashData
|
||||
LOADER2=Hpmcu
|
||||
LOADER3=FlashBoot
|
||||
FlashData=bin/rv11/rv1106_ddr.bin
|
||||
Hpmcu=bin/rv11/rv1106_hpmcu_tb.bin
|
||||
FlashBoot=bin/rv11/rv1106_spl.bin
|
||||
[LOADER2_PARAM]
|
||||
LOAD_ADDR=0x40000
|
||||
FLAG=0x10007
|
||||
"#;
|
||||
|
||||
// Our board's actual loader: no Hpmcu at all.
|
||||
const NONTB_INI: &str = "[LOADER_OPTION]\nNUM=2\nLOADER1=FlashData\nLOADER2=FlashBoot\n";
|
||||
|
||||
const DT_WITH_RTOS: &str = r#"
|
||||
reserved-memory {
|
||||
#address-cells = <1>;
|
||||
#size-cells = <1>;
|
||||
ranges;
|
||||
rtos@40000 { reg = <0x40000 0x3c000>; no-map; };
|
||||
ramoops@0f000000 { compatible = "ramoops"; reg = <0x0f000000 0x00100000>; };
|
||||
};
|
||||
"#;
|
||||
const DT_NO_RTOS: &str = r#"
|
||||
reserved-memory {
|
||||
ranges;
|
||||
ramoops@0f000000 { compatible = "ramoops"; reg = <0x0f000000 0x00100000>; };
|
||||
};
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parses_hpmcu_load_addr() {
|
||||
let loads = parse_ini_mcu_loads(TB_INI);
|
||||
assert_eq!(loads.len(), 1);
|
||||
assert_eq!(loads[0].name, "Hpmcu");
|
||||
assert_eq!(loads[0].load_addr, 0x40000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nontb_ini_has_no_mcu_loads() {
|
||||
assert!(parse_ini_mcu_loads(NONTB_INI).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_reserved_ranges() {
|
||||
let r = parse_reserved_ranges(DT_WITH_RTOS);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert!(r.iter().any(|x| x.start == 0x40000 && x.size == 0x3c000));
|
||||
assert!(r.iter().any(|x| x.start == 0x0f00_0000));
|
||||
}
|
||||
|
||||
/// The c8a3 brick, prevented: Hpmcu@0x40000 with NO reserving node -> a finding.
|
||||
#[test]
|
||||
fn flags_the_c8a3_brick() {
|
||||
let loads = parse_ini_mcu_loads(TB_INI);
|
||||
let reserved = parse_reserved_ranges(DT_NO_RTOS);
|
||||
let f = check(&loads, &reserved);
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(f[0].load.load_addr, 0x40000);
|
||||
}
|
||||
|
||||
/// The fix: add the rtos@40000 reservation -> the same config passes.
|
||||
#[test]
|
||||
fn passes_when_rtos_reserved() {
|
||||
let loads = parse_ini_mcu_loads(TB_INI);
|
||||
let reserved = parse_reserved_ranges(DT_WITH_RTOS);
|
||||
assert!(check(&loads, &reserved).is_empty());
|
||||
}
|
||||
|
||||
/// Our board (no boot-loaded MCU) always passes, reserved or not.
|
||||
#[test]
|
||||
fn nontb_board_always_passes() {
|
||||
let loads = parse_ini_mcu_loads(NONTB_INI);
|
||||
assert!(check(&loads, &parse_reserved_ranges(DT_NO_RTOS)).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! config-lint CLI — the MCU-load-vs-reserved-memory gate as a CI check.
|
||||
//!
|
||||
//! Usage:
|
||||
//! config-lint --ini <loader.ini> --dt <devicetree.dts>
|
||||
//!
|
||||
//! The `--dt` argument accepts a `.dts`/`.dtsi` or the flattened output of
|
||||
//! `dtc -I dtb -O dts built.dtb` (preferred in CI — it resolves includes and
|
||||
//! overlays, so it reflects what the board will actually boot). Exits non-zero
|
||||
//! and prints each offending MCU load if any lands outside a `reserved-memory`
|
||||
//! node — the 0x40000 brick class.
|
||||
|
||||
use std::process::ExitCode;
|
||||
use warden_config_lint::{check, parse_ini_mcu_loads, parse_reserved_ranges};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut ini_path = None;
|
||||
let mut dt_path = None;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--ini" => ini_path = args.next(),
|
||||
"--dt" => dt_path = args.next(),
|
||||
"-h" | "--help" => {
|
||||
eprintln!("usage: config-lint --ini <loader.ini> --dt <devicetree.dts>");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
other => {
|
||||
eprintln!("config-lint: unknown argument {other:?}");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (Some(ini_path), Some(dt_path)) = (ini_path, dt_path) else {
|
||||
eprintln!("usage: config-lint --ini <loader.ini> --dt <devicetree.dts>");
|
||||
return ExitCode::from(2);
|
||||
};
|
||||
|
||||
let ini = match std::fs::read_to_string(&ini_path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("config-lint: cannot read {ini_path}: {e}");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
let dt = match std::fs::read_to_string(&dt_path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("config-lint: cannot read {dt_path}: {e}");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
|
||||
let loads = parse_ini_mcu_loads(&ini);
|
||||
let reserved = parse_reserved_ranges(&dt);
|
||||
let findings = check(&loads, &reserved);
|
||||
|
||||
if loads.is_empty() {
|
||||
println!("config-lint: no boot-loaded MCU firmware in {ini_path} — nothing to reserve.");
|
||||
} else {
|
||||
println!(
|
||||
"config-lint: {} MCU load(s) in {ini_path}, {} reserved-memory range(s) in {dt_path}.",
|
||||
loads.len(),
|
||||
reserved.len()
|
||||
);
|
||||
}
|
||||
|
||||
if findings.is_empty() {
|
||||
println!("config-lint: OK — every MCU load is inside a reserved-memory node.");
|
||||
ExitCode::SUCCESS
|
||||
} else {
|
||||
for f in &findings {
|
||||
eprintln!("config-lint: FAIL — {}", f.msg);
|
||||
}
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user