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
+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;
}