feat(UI/采集/104): 通讯设置页 + 开机账本 + adc_cal 可诊断化——三车道同树一次构建入库

三条道在同一棵树上交付,故合并成**一次权威构建**(@build 0 错 0 警,Code=235236 RO=26308 RW=1572
ZI=136924)后同笔入库;台账已按该构建回填(`ram_baseline.py --update` + `--write`、`chec
k_mem_numbers.py
 --write`),凭证与实际 .map 同源。归属如下:

· **通讯设置页**(道G task-9):新增 `Drivers/BSP/UI/ui_page_comm.{c,h}`(通道六注入,与死
区页同骨架同纪
  律),`#page=comm` 绑定使 bound **21 → 22**、内容页黄金 **47 → 53**(旧 47 页字节零变化,
新增六态含
  WAIT/OK 两张);`iec104_comm` 脏位 + store_save(**无事件位**)挂 defaultTask 同点位
轮询;`port_104` 由
  宏改走 getter、`IEC104_SERV_PORT` 删除;字体 +听/监(185 字全覆)。**实机链已闭环**:FTP 外改
[comm]
  的 ca/port → 复位 → 串口出现 `section 'comm' (2 keys)` 与 `i104: listening o
n 9004` → 还原 2404,全程无
  FAIL ⇒「重启即生效 + 端口真值」不是推断是实测。上限核对 6/8 段、2/16 键,超限现象由 TC-CONFIG-056/057
  钉死(注册 -1 + `comm init: FAIL` 锚 + getter 回 ROM),**不抬宏**。
· **开机账本**(道F task-12):新增 `App/util/boot_stats.{c,h}`——上电次数落 SD2506 用户
 SRAM(magic +
  小端计数 + 版本 + 异或校验),一次上电 = 1 读 + ≤1 写,**零 NAND、零卷锁、零配置段**;四态互斥
  UNKNOWN / FRESH / CONTINUED / RESTARTED(BLF=1 才算电池域掉过电),**读失败绝不回写**;
文案口径「自 RTC
  电池周期起」,**禁写终身次数**。复位原因优先级链从 `app_main.c` 的 if-else 收进一份真源(串口与屏幕同
  源);104 四个只增不清计数 + `iec104_serv_get_stats()` 留在 serv、未塞进 link(会被 open
/close 洗掉)。
  运行时长按用户裁定 = **只报本次开机以来**,零新增代码。新套件 `test/host/boot_stats` 21/21,五处注入
  **全部有牙**(含「漂一字节被校验和挡下而不是报 256 倍错账」)。
· **adc_cal 可诊断化**(道E task-43):三个 volatile 计数 + `adc_cal_get_stats()`
/ `adc_cal_note_persisted()`
  (水位**盖写非自增**,因为一次 save 覆盖整档,数 save 会留假未落盘);「差额 × 脏位」三态语义唯一落在
  `adc_cal.h`,其中「差额 > 0 且脏位恒 0」= 绕过语义 setter 的静默丢失;TC-ADCCAL-019~023。

三条**必须留档的自纠**(都是执行道自己报的,我不替他们润色):
① stdint 那条断链是**宿主套件抓到、通讯道先修**,采集道承认自己是重复添加者并删掉自己那份;
② 「exit 2 不是证据,断言变红才是」——采集道第一版反证注入了未声明标识符导致编译错,差点当成功;
③ 套件汇总行原写 `s_total/s_total`,失败时会打印「21/21 PASSED (failed=1)」这种自相矛盾的行,已改。

另修:`check_registration.py` 的 `SIM_EXEMPT` 加一行豁免 `App/util/boot_stats.c
`(依赖 I2C 与 RCC 位掩码,
MCU 专属;核过 `sys_clock.c`/`sha256.c` 同形态先例),注册反向校验 exit 1 → 0。发号 TC-IEC1
04-034、
TC-UIDATA-029 已实抬游标(`TC-IEC104-033→034`、`TC-UIDATA-026→029`)。

门禁:id-check --strict 与 plain 均 rc=0、doc-links --all rc=0、注册校验 0、cppche
ck / coding-standard /
bom-drift 0;iec104 179/179、ui_data 295/295、boot_stats 21/21、adc_cal 11
7/117、run_par 67/67;菜单 8 状态
池 5368 不变、内容页 53/53。**已知遗留**:内容页数 47 → 53 的全仓文本同步尚未做(另卡跟踪),本笔只保
证代码与黄金一致;面板注键真机那一腿缺 CLI,交台架道补。
This commit is contained in:
2026-10-27 11:29:23 +08:00
parent 0ae9c10f24
commit 728d60675f
47 changed files with 2364 additions and 123 deletions
+70
View File
@@ -36,6 +36,14 @@
* 闭锁 / 两路齐第三路缺仍闭锁(全或无)/ 三路齐放行且 UB 仍无 VALID
* (证明它是"三路"而不是 feed_eng 那个"8 路")/ 复位后立刻回闭锁
* (现查、无自有状态可残留)
* —— 以下四项是"改了没落盘"的可诊断化(道G task-43 第②项;四态**定义只在
* App/algo/adc_cal.h 一处**,本表只描述断言、不重定义)——
* TC-ADCCAL-019 观测件不得反过来制造故障:get_stats(NULL) 静默返回、随时可读
* TC-ADCCAL-020 一次成功整定 → set_ok +1、水位不动 ⇒ 未落盘差额 = 1(改盘上没跟上,看得见)
* TC-ADCCAL-021 多次整定 + 一次落盘回执 → 差额归 0(水位是**盖写**不是自增:一次 save
* 覆盖整档,若自增会留下"假未落盘"——这条就是那个坑的回归锁)
* TC-ADCCAL-022 被拒整定(非法通道 / 越出合法带)只加 set_reject,不污染 set_ok 与差额
* TC-ADCCAL-023 空转回执不倒挂:水位永不越过 set_ok(uint32 相减一绕回就是巨大的假差额)
*
* 通过:全部 TC PASS(main 返回 0)。
* 创建日期:2026-10-25(道A 卡 2/卡 4)
@@ -524,6 +532,67 @@ static void tc_018_prot_blocked(void) {
CHECK(adc_cal_prot_blocked() == 1u, "repeat call stable (idempotent pure read)");
}
/* ============ 观测计数(改了没落盘 → 可诊断,道G task-43 第②项) ============ */
/*
* 四态定义只在 App/algo/adc_cal.h 一处;本用例**全部按增量断言**——静态计数跨用例
* 累计,任何"绝对值等于几"的写法都会因为前面 TC 调过多少次 adc_cal_set() 而假失败
* (也与 vol_lock 套件同一套写法先例)。
*/
static void tc_019_023_persist_obs(void) {
adc_cal_stats_t s;
uint32_t ok0;
uint32_t rj0;
printf("-- TC-ADCCAL-019..023 persist observability --\n");
/* TC-ADCCAL-019:观测件不得反过来制造故障 */
adc_cal_get_stats(NULL);
CHECK(1, "get_stats(NULL) returns silently (an observer must not create a fault)");
/* 先立基线:把"到目前为止的改写"记为已落盘,后面全部按增量看 */
adc_cal_note_persisted();
adc_cal_get_stats(&s);
CHECK(s.set_ok == s.persisted_set_ok, "after a sync point water catches count (gap == 0)");
/* TC-ADCCAL-020:一次成功整定 → 差额 1,水位不动 */
ok0 = s.set_ok;
rj0 = s.set_reject;
CHECK(adc_cal_set(ADC_SEM_UA, ADC_CAL_K_Q16_TPAFE * 2, 100L) == 0, "in-band set accepted");
adc_cal_get_stats(&s);
CHECK(s.set_ok == ok0 + 1u, "set_ok incremented by exactly one");
CHECK(s.set_reject == rj0, "set_reject untouched by a successful set");
CHECK(s.persisted_set_ok == ok0, "water still at the old level (nothing saved yet)");
CHECK(s.set_ok - s.persisted_set_ok == 1u, "gap == 1: RAM changed, disk not (the diagnosable fact)");
/* TC-ADCCAL-021:多次整定 + 一次落盘回执 → 差额归 0(水位是盖写不是自增) */
CHECK(adc_cal_set(ADC_SEM_UN, ADC_CAL_K_Q16_TPAFE, -50L) == 0, "second channel set accepted");
/* 合法带是 ROM 系数的 [0.5x, 2x](含端点,TC-ADCCAL-008 已钉)——这里必须留在带内,
* 否则第二次改写被拒,差额就不是我声称的 3 了(写用例时踩过一次,改成 2x 上界) */
CHECK(adc_cal_set(ADC_SEM_IA, ADC_CAL_K_Q16_TPAFE * 2, 7L) == 0, "third channel set accepted (band upper end)");
adc_cal_get_stats(&s);
CHECK(s.set_ok - s.persisted_set_ok == 3u, "three pending sets before the single save");
adc_cal_note_persisted(); /* 一次 save 覆盖整档:等价 run_par_store_save() 成功路径 */
adc_cal_get_stats(&s);
CHECK(s.set_ok - s.persisted_set_ok == 0u, "one save clears all three pending sets (water is a snapshot, not a tick)");
/* TC-ADCCAL-022:被拒的两类整定只加 set_reject,不污染差额 */
ok0 = s.set_ok;
rj0 = s.set_reject;
CHECK(adc_cal_set(ADC_SEM_TEMP, ADC_CAL_K_Q16_TPAFE, 0L) == -1, "TEMP rejected");
CHECK(adc_cal_set(ADC_SEM_UA, ADC_CAL_K_Q16_TPAFE * 100, 0L) == -1, "out-of-band k rejected (RAM untouched)");
adc_cal_get_stats(&s);
CHECK(s.set_reject == rj0 + 2u, "set_reject counts both reject reasons");
CHECK(s.set_ok == ok0, "rejected sets never inflate set_ok");
CHECK(s.set_ok == s.persisted_set_ok, "gap stays zero across rejected attempts (no phantom pending write)");
/* TC-ADCCAL-023:空转回执不倒挂 */
adc_cal_note_persisted();
adc_cal_note_persisted();
adc_cal_get_stats(&s);
CHECK(s.persisted_set_ok == s.set_ok, "idle acks keep water exactly at the level (never overrun)");
CHECK(s.set_ok >= s.persisted_set_ok, "subtraction can never wrap (gap stays non-negative)");
}
/* ===================== main ===================== */
int main(void) {
printf("=== ADC Cal Layer Tests ===\n");
@@ -544,6 +613,7 @@ int main(void) {
tc_012_file_corrupt();
tc_013_section_missing();
tc_018_prot_blocked();
tc_019_023_persist_obs();
printf("\n=== ADC Cal Layer Tests: %d/%d PASSED (failed=%d) ===\n",
s_passed, s_total, s_failed);
+374
View File
@@ -0,0 +1,374 @@
/*
* 模块名称:开机账本(boot_stats)主机侧单元测试
* 模块功能:在 PC 上离线验证 App/util/boot_stats.c 的**上电次数四态判定、记录校验、
* I2C 失败分支(读失败绝不回写 / 写失败只少一笔账)、幂等、饱和**,
* 以及复位原因优先级链的唯一出处。
*
* 被测对象:真实源文件 App/util/boot_stats.c(#include 原样纳入)。桩:
* - stub/main.h:六个 RCC_CSR 复位标志位掩码(位号照抄 CMSIS);
* - sd2506.h / bsp_err.h / boot_stats.h / app_main.h 用**真实头**;
* - sd2506_read_sram / sd2506_write_sram / sd2506_read_ctr1 由本文件实现
* 为可注入错误的假件(含 70 B 假 SRAM,越界返 BSP_ERR_PARAM,与真驱动
* 的契约一致);g_rcc_csr_latched 也由本文件给出。
*
* 为什么这些判据必须在这儿测而不在真机上测(真机只覆盖"一切正常"那一条):
* 1. **读失败 ≠ 没有记录**:I2C 抖动一次就把计数当 0 重来,屏幕上是个很像样的小
* 数字、日志里一个字都没有;读失败还顺手回写的话,故障就从"这次读不到"升级成
* "永久少账"(004 用"假 SRAM 内容一字不许变"把这条钉住)。
* 2. **首次上电 ≠ RTC 掉过电**:合并成一态,文案就把前者冒充后者(001 vs 003)。
* 3. **只信 magic 不信校验**:坏区常是整片全 0/全 FF,但"个别字节翻转"时 magic
* 可能仍然好、次数错 256 倍(007)。
* 4. **饱和 vs 回绕**:停在 42 亿一眼看出到头;回绕成个位数会被读成"刚上电"(009)。
* 5. BLF 读不到不该把好记录判坏(012)——判定顺序写反的代价是"RTC 抖一下,
* 装置变成第一次上电"。
*
* 编号:TC-BOOTSTAT-001 起(小写子项 = 同一判据的另一面)。族需已登记进
* scripts/id_registry.json;未登记时 check_ids 校验 1 会报"未登记前缀"。
* 创建日期:2026-10-27(道G task-12)
*/
#include <stdio.h>
#include <string.h>
#include "boot_stats.h"
#include "sd2506.h" /* 真实头:bsp_err_t / SD2506_CTR1_BLF / 三个原型 */
#include "app_main.h" /* extern volatile uint32_t g_rcc_csr_latched */
/* 被测生产源(照仓内既有套件:include_dirs 里有 App/util) */
#include "boot_stats.c"
/* ---------------- g_rcc_csr_latched(真实定义在未编译的 app_main.c) ---------------- */
volatile uint32_t g_rcc_csr_latched;
/* ---------------- SD2506 假件 ---------------- */
#define FAKE_SRAM_SIZE 70u
static uint8_t s_sram[FAKE_SRAM_SIZE];
static uint8_t s_ctr1_val;
static int s_read_err; /* != BSP_OK ⇒ 注入读失败 */
static int s_write_err; /* != BSP_OK ⇒ 注入写失败 */
static int s_ctr1_err;
static int s_read_calls;
static int s_write_calls;
static int s_ctr1_calls;
static void fake_reset(void)
{
unsigned int i;
for (i = 0u; i < FAKE_SRAM_SIZE; i++) {
s_sram[i] = 0xFFu; /* 芯片出厂/掉电后的常见形态:成片全 1 */
}
s_ctr1_val = 0u;
s_read_err = BSP_OK;
s_write_err = BSP_OK;
s_ctr1_err = BSP_OK;
s_read_calls = 0;
s_write_calls = 0;
s_ctr1_calls = 0;
g_rcc_csr_latched = 0u;
/* 被测模块的静态状态也要归零——它在真实机器上"每 boot 一次",
* 测试里靠这条模拟"又开了一次机"(复位 SRAM 侧但不清假 SRAM = 跨掉电保持)。 */
(void)memset(&s_bs, 0, sizeof(s_bs));
}
/* 造一条合法记录(校验按生产同一算法算,别把算法抄第二份在这里) */
static void fake_put_count(uint32_t cnt, uint8_t ver)
{
uint8_t i;
uint8_t sum = 0u;
s_sram[0] = 0xB5u;
s_sram[1] = 0x72u;
s_sram[2] = (uint8_t)(cnt & 0xFFu);
s_sram[3] = (uint8_t)((cnt >> 8) & 0xFFu);
s_sram[4] = (uint8_t)((cnt >> 16) & 0xFFu);
s_sram[5] = (uint8_t)((cnt >> 24) & 0xFFu);
s_sram[6] = ver;
for (i = 0u; i < BS_REC_LEN - 1u; i++) {
sum = (uint8_t)(sum ^ s_sram[i]);
}
s_sram[7] = sum;
}
static uint32_t fake_get_count(void)
{
return ((uint32_t)s_sram[2]) | ((uint32_t)s_sram[3] << 8) |
((uint32_t)s_sram[4] << 16) | ((uint32_t)s_sram[5] << 24);
}
bsp_err_t sd2506_read_sram(uint8_t addr, uint8_t *p_buf, uint8_t len)
{
uint8_t i;
s_read_calls++;
if (s_read_err != BSP_OK) {
return (bsp_err_t)s_read_err;
}
if ((p_buf == 0) || (len == 0u) || (len > FAKE_SRAM_SIZE) ||
((uint16_t)addr + (uint16_t)len > FAKE_SRAM_SIZE)) {
return BSP_ERR_PARAM;
}
for (i = 0u; i < len; i++) {
p_buf[i] = s_sram[addr + i];
}
return BSP_OK;
}
bsp_err_t sd2506_write_sram(uint8_t addr, const uint8_t *p_buf, uint8_t len)
{
uint8_t i;
s_write_calls++;
if (s_write_err != BSP_OK) {
return (bsp_err_t)s_write_err;
}
if ((p_buf == 0) || (len == 0u) || (len > FAKE_SRAM_SIZE) ||
((uint16_t)addr + (uint16_t)len > FAKE_SRAM_SIZE)) {
return BSP_ERR_PARAM;
}
for (i = 0u; i < len; i++) {
s_sram[addr + i] = p_buf[i];
}
return BSP_OK;
}
bsp_err_t sd2506_read_ctr1(uint8_t *p_val)
{
s_ctr1_calls++;
if (s_ctr1_err != BSP_OK) {
return (bsp_err_t)s_ctr1_err;
}
if (p_val == 0) {
return BSP_ERR_PARAM;
}
*p_val = s_ctr1_val;
return BSP_OK;
}
/* sd2506_init 不测(属 SD2506 自己的套件),给个链接期够用的定义 */
bsp_err_t sd2506_init(void) { return BSP_OK; }
/* ---------------- 断言骨架 ---------------- */
static int s_total;
static int s_passed;
static int s_failed;
#define CHECK(cond, name) \
do { \
s_total++; \
if (cond) { s_passed++; printf(" [PASS] %s\n", name); } \
else { s_failed++; printf(" [FAIL] %s\n", name); } \
} while (0)
static boot_stats_t snap(void)
{
boot_stats_t b;
boot_stats_get(&b);
return b;
}
/* ===================== 上电次数四态 ===================== */
static void tc_seq_states(void)
{
boot_stats_t b;
uint8_t before[BS_REC_LEN];
/* 001 首次启用:SRAM 成片全 1(magic 过不去)+ RTC 没报欠压 ⇒ FRESH,不是 RESTARTED */
fake_reset();
boot_stats_init();
b = snap();
CHECK((b.ready == 1u) && (b.seq_state == BOOT_STATS_SEQ_FRESH) && (b.boot_count == 1u)
&& (b.write_failed == 0u) && (s_write_calls == 1) && (fake_get_count() == 1u),
"TC-BOOTSTAT-001 blank SRAM without BLF starts the ledger at 1 and marks it FRESH, not RESTARTED");
/* 002 延续:合法记录 count=7 ⇒ count=8,且回写后介质里也是 8 */
fake_reset();
fake_put_count(7u, BS_REC_VER);
boot_stats_init();
b = snap();
CHECK((b.seq_state == BOOT_STATS_SEQ_CONTINUED) && (b.boot_count == 8u)
&& (fake_get_count() == 8u),
"TC-BOOTSTAT-002 a valid record continues the count and persists the incremented value");
/* 002b 跨掉电保持的本体:假 SRAM 不清、只重开被测模块(等价于"又上电一次") */
(void)memset(&s_bs, 0, sizeof(s_bs));
boot_stats_init();
b = snap();
CHECK((b.seq_state == BOOT_STATS_SEQ_CONTINUED) && (b.boot_count == 9u),
"TC-BOOTSTAT-002b re-initialising over an intact medium keeps counting up rather than restarting at 1");
/* 003 电池域掉过电:记录无效 + BLF 置位 ⇒ RESTARTED(与 001 是两件事) */
fake_reset();
s_ctr1_val = SD2506_CTR1_BLF;
boot_stats_init();
b = snap();
CHECK((b.seq_state == BOOT_STATS_SEQ_RESTARTED) && (b.boot_count == 1u) && (b.rtc_blf == 1u),
"TC-BOOTSTAT-003 an invalid record with the battery-low flag set is reported as RESTARTED, never as first power-on");
/* 004 I2C 读失败 ⇒ 不可信,且**绝不回写**(把可能完好的记录覆盖掉是不可接受的) */
fake_reset();
fake_put_count(41u, BS_REC_VER);
(void)memcpy(before, s_sram, BS_REC_LEN);
s_read_err = BSP_ERR_TIMEOUT;
boot_stats_init();
b = snap();
CHECK((b.seq_state == BOOT_STATS_SEQ_UNKNOWN) && (b.boot_count == 0u)
&& (s_write_calls == 0) && (memcmp(s_sram, before, BS_REC_LEN) == 0),
"TC-BOOTSTAT-004 an I2C read failure marks the ledger untrusted and writes nothing back, leaving the stored record byte-identical");
/* 004b 读不到时页面要能分辨"不可信"与"零次":同一张表里 ready=1 且 seq=UNKNOWN */
CHECK((b.ready == 1u) && (b.seq_state == BOOT_STATS_SEQ_UNKNOWN),
"TC-BOOTSTAT-004b the untrusted state is distinguishable from both a real count and an uninitialised table");
/* 005 写失败 ⇒ 本次仍报数,但打上"少一笔账"的记号(下次开机读旧值,只会少不会重) */
fake_reset();
s_write_err = BSP_ERR_IO;
boot_stats_init();
b = snap();
CHECK((b.boot_count == 1u) && (b.write_failed == 1u) && (s_write_calls == 1),
"TC-BOOTSTAT-005 a failed write-back still reports this boot but flags it, so the ledger can only lose one count");
/* 006 幂等:bring-up 重试不得重复计数(真实机器上 sd2506 之后可能重进一次) */
fake_reset();
boot_stats_init();
boot_stats_init();
b = snap();
CHECK((b.boot_count == 1u) && (s_write_calls == 1),
"TC-BOOTSTAT-006 a second init in the same boot neither counts twice nor writes twice");
/* 007 校验和不符(magic 与版本都好,中段漂了一位)⇒ 不当真账读 */
fake_reset();
fake_put_count(9u, BS_REC_VER);
s_sram[4] = (uint8_t)(s_sram[4] ^ 0x01u); /* 高位字节翻转:256 倍的错账 */
boot_stats_init();
b = snap();
CHECK((b.seq_state == BOOT_STATS_SEQ_FRESH) && (b.boot_count == 1u),
"TC-BOOTSTAT-007 a single flipped byte that leaves the magic intact is caught by the checksum instead of reporting a count off by 256");
/* 008 格式版本不符(将来改了布局)⇒ 整条作废,且回写时落回当前版本 */
fake_reset();
fake_put_count(1234u, 2u);
boot_stats_init();
b = snap();
CHECK((b.seq_state == BOOT_STATS_SEQ_FRESH) && (b.boot_count == 1u)
&& (s_sram[6] == BS_REC_VER) && (s_sram[7] == s_rec_sum(s_sram)),
"TC-BOOTSTAT-008 a record from a future layout is rejected outright and rewritten under the current version with a fresh checksum");
/* 009 饱和:到顶不再自增也不再写(回绕成个位数会被读成"刚上电") */
fake_reset();
fake_put_count(0xFFFFFFF0u, BS_REC_VER);
boot_stats_init();
b = snap();
CHECK((b.boot_count == 0xFFFFFFF0u) && (s_write_calls == 0) && (fake_get_count() == 0xFFFFFFF0u),
"TC-BOOTSTAT-009 the counter saturates at the ceiling and stops writing instead of wrapping to a small number");
/* 010 BLF 读不到但记录有效 ⇒ 仍按延续判(读不到佐证不该把好账判坏) */
fake_reset();
fake_put_count(20u, BS_REC_VER);
s_ctr1_err = BSP_ERR_IO;
boot_stats_init();
b = snap();
CHECK((b.seq_state == BOOT_STATS_SEQ_CONTINUED) && (b.boot_count == 21u) && (b.rtc_blf == 0u),
"TC-BOOTSTAT-010 an unreadable battery flag never invalidates an otherwise sound record");
/* 011 出厂全 0 的 SRAM(另一形态:不是全 FF)⇒ FRESH 且第一格就是 1,不报 0 次 */
fake_reset();
(void)memset(s_sram, 0, sizeof(s_sram));
boot_stats_init();
b = snap();
CHECK((b.seq_state == BOOT_STATS_SEQ_FRESH) && (b.boot_count == 1u),
"TC-BOOTSTAT-011 an all-zero SRAM (the other erase pattern) starts the ledger at one rather than showing zero boots");
}
/* ===================== 复位原因链 ===================== */
static uint8_t is_ascii(const char *p)
{
while (*p != '\0') {
if (((unsigned char)*p) > 127u) {
return 0u;
}
p++;
}
return 1u;
}
static void tc_cause_chain(void)
{
/* 020 优先级唯一出处:IWDG 压过软件复位(崩溃钩子常以 NVIC_SystemReset 收尾,
* 两个标志同时置位时报看门狗才是我们要看的自动恢复事件) */
CHECK(boot_stats_cause_from_csr(RCC_CSR_IWDGRSTF | RCC_CSR_SFTRSTF) == BOOT_STATS_CAUSE_IWDG,
"TC-BOOTSTAT-020 the independent watchdog outranks a software reset when both flags are set");
/* 020b WWDG 压过软件复位;单独的软件复位仍归软件 */
CHECK((boot_stats_cause_from_csr(RCC_CSR_WWDGRSTF | RCC_CSR_SFTRSTF) == BOOT_STATS_CAUSE_WWDG)
&& (boot_stats_cause_from_csr(RCC_CSR_SFTRSTF) == BOOT_STATS_CAUSE_SFT),
"TC-BOOTSTAT-020b the window watchdog likewise outranks a software reset while a lone software reset stays itself");
/* 020c POR 压过 PIN、PIN 压过 BOR;全零 ⇒ UNKNOWN */
CHECK((boot_stats_cause_from_csr(RCC_CSR_PORRSTF | RCC_CSR_PINRSTF) == BOOT_STATS_CAUSE_POR)
&& (boot_stats_cause_from_csr(RCC_CSR_PINRSTF | RCC_CSR_BORRSTF) == BOOT_STATS_CAUSE_PIN)
&& (boot_stats_cause_from_csr(RCC_CSR_BORRSTF) == BOOT_STATS_CAUSE_BOR)
&& (boot_stats_cause_from_csr(0u) == BOOT_STATS_CAUSE_UNKNOWN),
"TC-BOOTSTAT-020c power-on beats pin which beats brown-out, and no flag at all stays UNKNOWN rather than guessing");
/* 020d 每一格编码都有可读串、全是纯 ASCII(无 BOM 文件里字符串必须 ASCII),
* 且未知编码回落 unknown 而不是断言——这条链跑在开机极早期,不能挡启动 */
CHECK((boot_stats_cause_str(BOOT_STATS_CAUSE_UNKNOWN) != 0)
&& (boot_stats_cause_str(BOOT_STATS_CAUSE_BOR) != 0)
&& (strcmp(boot_stats_cause_str(BOOT_STATS_CAUSE_IWDG), "IWDG (independent watchdog)") == 0)
&& (strcmp(boot_stats_cause_str(200u), "unknown") == 0)
&& is_ascii(boot_stats_cause_str(BOOT_STATS_CAUSE_SFT)),
"TC-BOOTSTAT-020d every code maps to a pure-ASCII description and an out-of-range code falls back to unknown instead of blocking startup");
/* 020e 快照:开机那次读的就是锁存影子里的复位原因(与串口那条同源同序) */
fake_reset();
g_rcc_csr_latched = RCC_CSR_IWDGRSTF;
boot_stats_init();
CHECK(snap().rst_cause == BOOT_STATS_CAUSE_IWDG,
"TC-BOOTSTAT-020e the boot snapshot exposes the latched RCC_CSR cause so the screen and the serial line can never disagree");
}
/* ===================== 读口契约 ===================== */
static void tc_get_contract(void)
{
/* 030 未 init:整表零、ready=0(页面该显示占位而不是"0 次") */
fake_reset();
CHECK((snap().ready == 0u) && (snap().boot_count == 0u),
"TC-BOOTSTAT-030 before init the table reads zero with ready clear so a page can show a placeholder instead of zero boots");
/* 030b NULL 无害(观测口在任何上下文都不该因为调用者手滑而炸) */
boot_stats_get(0);
CHECK(1, "TC-BOOTSTAT-030b a null output pointer is a harmless no-op");
/* 030c 读口不碰总线:init 之后再读 100 次,I2C 调用数纹丝不动
* ——「任意上下文可读」不是口头承诺,是这条计数断言 */
fake_reset();
boot_stats_init();
s_read_calls = 0;
s_write_calls = 0;
s_ctr1_calls = 0;
{
int i;
for (i = 0; i < 100; i++) {
(void)snap();
}
}
CHECK((s_read_calls == 0) && (s_write_calls == 0) && (s_ctr1_calls == 0),
"TC-BOOTSTAT-030c the getter touches neither I2C nor the clock, so reading the ledger cannot become part of a fault");
}
int main(void)
{
printf("=== Boot Stats Tests ===\n");
printf("-- TC-BOOTSTAT-001..011 power-on ledger (four states, checksum, I2C failures, idempotence, saturation) --\n");
tc_seq_states();
printf("-- TC-BOOTSTAT-020..020e reset cause chain (single source of the priority order) --\n");
tc_cause_chain();
printf("-- TC-BOOTSTAT-030..030c read-only contract --\n");
tc_get_contract();
/* 分子必须是"真过了几条":写成 s_total/s_total 会出现
* 「21/21 PASSED (failed=1)」这种自相矛盾的汇总行——人只看前半就放行了。 */
printf("\n=== Boot Stats Tests: %d/%d PASSED (failed=%d) ===\n", s_passed, s_total, s_failed);
return (s_failed == 0) ? 0 : 1;
}
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""开机账本(boot_stats)的主机侧单元测试运行器(L0.5,纯标准库)。
做什么:
用本机 MSVC 编译 test/host/boot_stats/boot_stats_test.c(以 #include 纳入**生产源**
App/util/boot_stats.c),在 PC 上离线验证上电次数的**四态判定**(不可信 / 首次启用 /
延续 / 电池域掉过电)、记录校验与格式版本、I2C 读失败时**绝不回写**、写失败时
"少一笔账"的记号、幂等、饱和,以及复位原因优先级链的唯一出处。
工具链与判定委托 test/host/common/unit_test_harness.py。
为什么要主机侧单测(而不是只靠真机):
这一格数字的唯一用途是告诉人"这台装置上了多少次电",而它错的时候**永远不像错**:
1. I2C 读失败若当成"没有记录",计数会在某次通信抖动后悄悄从 1 重来——屏幕上
是个很像样的小数字,没有任何一行日志会说它假;
2. 读失败若还顺手回写,就把可能完好的旧记录**覆盖掉**,故障从"读不到"升级成
"永久少账";
3. "第一次上电"与"RTC 掉过电"合并成一个状态,就等于拿前者冒充后者(文案会写错
整整一格的口径);
4. 只信 magic 不信校验:电池坏区常是整片全 0 / 全 FF(magic 那关自然过不去),
但更常见的"个别字节翻转"里 magic 可能仍然好,次数会错 256 倍。
这些都要能把 SRAM 内容、BLF、I2C 错误码逐个构造出来才测得到;真机上跑一次
只覆盖"一切正常"那一条。桩边界见下面的注释;上板另有一组判据(断电 ≥5 min 再
上电 +1、换电池后落 RESTARTED),见 task-12 卡片,主机测不出来。
桩的边界:
- stub/main.h:只给 RCC_CSR 六个复位标志位掩码(位号照抄 CMSIS,见该文件注释);
真实 main.h 拉整棵 HAL 头树,PC 上编不动。
- sd2506.h / bsp_err.h / boot_stats.h / app_main.h 一律用**真实头**(它们不依赖
HAL 类型);被测源用到的三个 SD2506 函数
sd2506_read_sram / sd2506_write_sram / sd2506_read_ctr1 由本测试单元实现为
可注入失败的假件(含一片 70 B 假 SRAM,地址越界按真驱动语义返 BSP_ERR_PARAM)。
- g_rcc_csr_latched 的真实定义在 App/app/app_main.c(本套件不编译它),
由本测试单元给出。
用法(仓库根目录):
python test/host/boot_stats/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误。
设计依据:App/util/boot_stats.h、docs/任务规划/UI补齐.md §2(运行报告四个数的口径)
创建日期:2026-10-27(道G task-12)
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
sys.path.insert(0, os.path.join(ROOT, "test", "host", "common"))
from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402
# 顺序敏感:stub 必须排在最前,才能用桩 main.h 覆盖真实 main.h。
BUILD = TestBuild(
name="Boot Stats Tests",
src=os.path.join(HERE, "boot_stats_test.c"),
include_dirs=[
os.path.join(HERE, "stub"),
os.path.join(ROOT, "App", "util"),
os.path.join(ROOT, "App", "app"),
os.path.join(ROOT, "Drivers", "BSP"),
os.path.join(ROOT, "Drivers", "BSP", "SD2506"),
HERE,
],
summary_marker="=== Boot Stats Tests:",
)
if __name__ == "__main__":
sys.exit(run_host_unit_test(BUILD))
+34
View File
@@ -0,0 +1,34 @@
/*
* 模块名称:main.h 桩(boot_stats 主机单测专用)
* 模块功能:只提供 App/util/boot_stats.c 真正用到的 **RCC_CSR 复位标志位掩码**,
* 让被测源在 PC 上编得动(真实 main.h 会拉整棵 HAL/CMSIS 头树)。
* 创建日期:2026-10-27(道G task-12)
*
* ⚠ 位号与宏名必须与 CMSIS 一致(本仓真值取自
* Drivers/CMSIS/Device/ST/STM32F4xx/Include/stm32f407xx.h 的 RCC_CSR_xxx_Pos):
* BOR=25 / PIN=26 / POR=27 / SFT=28 / IWDG=29 / WWDG=30。
* 改了被测代码而这里缺宏 → 编译直接失败,不会出现"桩悄悄放过"的假绿;
* 改了位号(不会发生,芯片固定)则断言会红——优先级链是本项目唯一出处,
* 见 App/util/boot_stats.h 的 boot_stats_cause_from_csr 函数头。
*/
#ifndef MAIN_H
#define MAIN_H
#ifdef __cplusplus
extern "C" {
#endif
#include <stdint.h>
#define RCC_CSR_BORRSTF ((uint32_t)0x02000000u) /* bit25 掉电复位 */
#define RCC_CSR_PINRSTF ((uint32_t)0x04000000u) /* bit26 引脚复位 */
#define RCC_CSR_PORRSTF ((uint32_t)0x08000000u) /* bit27 上电复位 */
#define RCC_CSR_SFTRSTF ((uint32_t)0x10000000u) /* bit28 软件复位 */
#define RCC_CSR_IWDGRSTF ((uint32_t)0x20000000u) /* bit29 独立看门狗 */
#define RCC_CSR_WWDGRSTF ((uint32_t)0x40000000u) /* bit30 窗口看门狗 */
#ifdef __cplusplus
}
#endif
#endif /* MAIN_H */
+22
View File
@@ -38,6 +38,8 @@
* setter 守卫(0 拒 / 1023 拒 / 65535 收 / 带内提交)
* TC-IEC104-033 ca 端到端:ca=comm 值时 C_RD 正答 COT5 且 TX 头回显同值;
* 旧写死值 ca=1 变错地址 → COT13+EVI(证明过滤真源已迁移)
* TC-IEC104-034 [comm] 落盘脏位:置脏/清脏/空转/带外拒不置脏(道G task-9,
* 通讯设置页保存行 WAIT/OK 的判据源)
* TC-IEC104-014 定值两段写(真实 prot_set):越界 PN;执行后 get_f + selfcheck
* TC-IEC104-015 C_RD:YC 命中 / 定值命中 / 未知 IOA COT14+EVI
* TC-IEC104-016 C_CS:clock_sync 收正确 epoch;IV → PN;clock_sync 失败 → PN
@@ -1553,6 +1555,26 @@ static void tc_comm(void)
"TC-IEC104-033 the retired hardwired ca=1 now answers COT13+EVI");
(void)iec104_comm_ca_set(IEC104_COMM_DEF_CA); /* 收尾:复位出厂 ca */
(void)iec104_comm_port_set(IEC104_COMM_DEF_PORT);
/* ===== TC-IEC104-034 [comm] 落盘脏位与 store_save(道G task-9,页面链前置)=====
* 契约 = iec104_comm.h 的两个新出口:setter 成功置脏 / store_save 成功清脏 /
* 无脏空转返 0 / 带外拒不置脏。defaultTask 每轮无条件调 store_save(挂载点
* 在 App/task/default_task.c,run_par_store_save 同点位),屏上 WAIT/OK 全看
* 这枚脏位——脏位错 = 通讯页保存行说谎,这是本条用例钉的东西。
* 编号 034 = 向 Lead 申请发号(cursor_owner_only),cursor 现 033。 */
printf("[TC-IEC104-034] comm dirty bit drives the store_save cycle\n");
CHECK(iec104_comm_dirty_peek() == 1u,
"TC-IEC104-034 successful setters leave the section dirty");
CHECK((iec104_comm_store_save() == 0) && (iec104_comm_dirty_peek() == 0u),
"TC-IEC104-034 store_save clears dirty only on write success");
CHECK((strstr(comm_vfs_head(rp_s_vfs_len), "ca=1") != NULL) &&
(strstr(comm_vfs_head(rp_s_vfs_len), "port=2404") != NULL),
"TC-IEC104-034 the saved file carries the committed values");
CHECK((iec104_comm_store_save() == 0) && (iec104_comm_dirty_peek() == 0u),
"TC-IEC104-034 a save with no dirty is a silent no-op (poll cost)");
CHECK((iec104_comm_ca_set(0u) == -1) && (iec104_comm_dirty_peek() == 0u) &&
(iec104_comm_ca() == IEC104_COMM_DEF_CA),
"TC-IEC104-034 out-of-band reject neither dirties nor moves the copy");
}
+102
View File
@@ -76,6 +76,8 @@
* TC-UIDATA-026 注入后转发言行:epoch 0 哨兵不下发、reset_req 自行投操作记录=0 次
* TC-UIDATA-027 遥测整定通道未注入防御:八个口一律安全返回、不碰出参;
* 残表整表拒绝且不覆盖好表(与 025 同一条纪律的第五张表复刻)
* TC-UIDATA-029 通讯参数通道六(道G task-9):未注入防御 + 注入后转发
* (行现状逐字段 / 带外拒不置脏 / 脏位零缓存跟随 / 撤销脱离)
* TC-UIDATA-028 遥测整定注入后转发言行:行现状逐字段原样上抛、写口的 idx 与
* 工程量原样下传、返回码直通、pending 无状态直读(本层零缓存——
* 页面 OK/WAIT 的唯一依据必须是执行侧此刻的脏位)
@@ -1480,6 +1482,103 @@ static void tc_yt_forwarding(void)
CHECK(s_yt_pending_calls == 2, "TC-UIDATA-028q unbind stops reaching executor entirely");
}
/* ── 道G task-9 通道六:通讯参数注入(027/028 的第五张表复刻到第六张表)──
* 编号 029 = 向 Lead 申请发号(cursor_owner_only),cursor 现 026。
* 假件语义 = simulator/main.c 同形(带内写副本、带外拒、写成功置脏)。 */
static uint32_t s_cf_val[2] = { 1u, 2404u };
static uint32_t s_cf_lo[2] = { 1u, 1024u };
static uint32_t s_cf_hi[2] = { 65535u, 65535u };
static uint8_t s_cf_dirty;
static uint8_t s_cf_last_idx = 0xFFu;
static uint32_t s_cf_last_val;
static int s_cf_calls;
static int cf_stub_row(uint8_t a_idx, ui_data_comm_row_t *a_out)
{
s_cf_calls++;
if (a_idx >= UI_DATA_COMM_ROW_N) {
return -1;
}
a_out->val = s_cf_val[a_idx];
a_out->lo = s_cf_lo[a_idx];
a_out->hi = s_cf_hi[a_idx];
return 0;
}
static int cf_stub_set(uint8_t a_idx, uint32_t a_val)
{
s_cf_calls++;
s_cf_last_idx = a_idx;
s_cf_last_val = a_val;
if ((a_idx >= UI_DATA_COMM_ROW_N) || (a_val < s_cf_lo[a_idx]) ||
(a_val > s_cf_hi[a_idx])) {
return -1;
}
s_cf_val[a_idx] = a_val;
s_cf_dirty = 1u;
return 0;
}
static uint8_t cf_stub_pending(void)
{
s_cf_calls++;
return s_cf_dirty;
}
static const ui_data_comm_ops_t s_cf_stub = {
cf_stub_row, cf_stub_set, cf_stub_pending
};
/* 残表:只缺 pending——三口里最容易被"顺手少注一件"漏掉的那个(027l 同法) */
static const ui_data_comm_ops_t s_cf_broken = {
cf_stub_row, cf_stub_set, NULL
};
/* TC-UIDATA-029a-e 未注入防御:三个口一律安全返回、不碰出参、不假报脏 */
static void tc_comm_ch6_not_bound(void)
{
ui_data_comm_row_t r;
const uint32_t sentinel = 0xA5A5A5A5uL;
(void)ui_data_comm_bind(NULL);
r.val = sentinel;
CHECK(ui_data_comm_row(0u, &r) == -1, "TC-UIDATA-029a row not bound is -1");
CHECK(r.val == sentinel, "TC-UIDATA-029b row not bound wrote nothing");
CHECK(ui_data_comm_set(0u, 2u) == -1, "TC-UIDATA-029c set not bound is -1");
CHECK(ui_data_comm_pending() == 0u, "TC-UIDATA-029d pending not bound is 0 (never fabricate WAIT)");
CHECK(ui_data_comm_bind(&s_cf_broken) == -1, "TC-UIDATA-029e incomplete table refused");
}
/* TC-UIDATA-029f-o 注入后转发言行:现状逐字段原样上抛、写口 idx/值精确送达、
* 脏位零缓存跟随(页面 WAIT/OK 的正确性全系于这两条) */
static void tc_comm_ch6_forwarding(void)
{
ui_data_comm_row_t r;
CHECK(ui_data_comm_bind(&s_cf_stub) == 0, "TC-UIDATA-029f complete table accepted");
CHECK(ui_data_comm_row(UI_DATA_COMM_ROW_PORT, &r) == 0, "TC-UIDATA-029g row forwarded");
CHECK((r.val == 2404u) && (r.lo == 1024u) && (r.hi == 65535u),
"TC-UIDATA-029h row fields copied verbatim (band = executor's, not a copy)");
CHECK(ui_data_comm_row(UI_DATA_COMM_ROW_N, &r) == -1,
"TC-UIDATA-029i out-of-range idx rejected at the port too");
CHECK(ui_data_comm_row(0u, NULL) == -1, "TC-UIDATA-029j NULL out refused even bound");
CHECK(ui_data_comm_set(UI_DATA_COMM_ROW_CA, 0u) == -1,
"TC-UIDATA-029k out-of-band write rejected (band owned by executor)");
CHECK(s_cf_dirty == 0u, "TC-UIDATA-029l rejection left the section clean");
CHECK(ui_data_comm_set(UI_DATA_COMM_ROW_CA, 7u) == 0, "TC-UIDATA-029m in-band write accepted");
CHECK((s_cf_last_idx == UI_DATA_COMM_ROW_CA) && (s_cf_last_val == 7u),
"TC-UIDATA-029n executor got exact idx and value");
CHECK(ui_data_comm_pending() == 1u, "TC-UIDATA-029o dirty forwarded (WAIT leg)");
s_cf_dirty = 0u;
CHECK(ui_data_comm_pending() == 0u, "TC-UIDATA-029p clean follows immediately (OK leg, zero cache)");
(void)ui_data_comm_bind(NULL);
CHECK(ui_data_comm_pending() == 0u, "TC-UIDATA-029q unbind restores safe defaults");
s_cf_dirty = 1u; /* 桩此刻仍脏:撤销后连问都不该问到桩 */
CHECK(ui_data_comm_pending() == 0u, "TC-UIDATA-029r unbind stops reaching executor entirely");
s_cf_dirty = 0u;
s_cf_val[0] = 1u; /* 复位假件,别给后面的用例留串台状态 */
}
/* ===================== main ===================== */
int main(void)
@@ -1539,6 +1638,9 @@ int main(void)
printf("-- yt channel: forwarding --\n");
tc_yt_forwarding();
tc_comm_ch6_not_bound(); /* 道G task-9 通道六(TC-UIDATA-029) */
tc_comm_ch6_forwarding();
printf("\n=== UI Data Snapshot Tests: %d/%d PASSED (failed=%d) ===\n",
s_passed, s_total, s_failed);
return (s_failed == 0) ? 0 : 1;