diff --git a/.cubemx_survival_baseline.json b/.cubemx_survival_baseline.json index d1cc17c6..f6aa57e7 100644 --- a/.cubemx_survival_baseline.json +++ b/.cubemx_survival_baseline.json @@ -13,7 +13,7 @@ "stack-overflow-hook-impl": "a006edbd30d2c4d7", "malloc-failed-hook-impl": "fcd7250e5cd6403b", "stack-overflow-hook-helper": "cacd9fd028be9b5b", - "watchdog-module": "b3501b5ed1be1793", + "watchdog-module": "56bf2739f56a44e6", "net-msgq-adopt": "a0f913e3f34c8e9e", "reset-cause-latch": "983927d587cb9f48", "iwdg-hal-driver": "4b902875a84554e9", diff --git a/App/util/task_watchdog.c b/App/util/task_watchdog.c index 5b61b5e8..8b0ef43f 100644 --- a/App/util/task_watchdog.c +++ b/App/util/task_watchdog.c @@ -21,6 +21,14 @@ * round 8a 定位到 console 通道后,需钉死是 TIM7(HAL tick) * 还是 SysTick(FreeRTOS tick) 停摆(见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录) * + * v1.5 2026-10-25 DTU 项目组 **停喂判定按位分账**(大窗判读 F2):stall/停喂的唯一 + * 判据从"复合连续缺席窗数"改为"任一必需位的连续缺席窗数" + * (同一任务 800ms 没跑过才停喂申报复位);复合缺席窗仍照打 + * "MISS start"(降级为诊断行,不再代表停喂)——根治"本窗缺 A、 + * 下窗缺 B"式互不相干亚秒抖动被累计成复合 800ms 假停摆并停喂 + * (2026-10-25 大窗 16:24/16:28 两起实证;真单任务停摆如 + * 16:39 型 UI 单串 800ms+ 仍必报)。判据正文同步 + * docs/子系统设计/看门狗设计说明.md §4;主机单测 TC-WATCHDOG-017/018 * v1.4 2026-10-02 王建锋 **P0 修复**:wd_platform_start() 补写 IWDG 的 * PR/RLR(= WD_IWDG_PRESCALER / WD_IWDG_RELOAD,即设计值 * /64 + 1249 → 1701~2498ms)。此前为"延后启动"删掉 @@ -111,7 +119,15 @@ * 单字读-改-写在 Cortex-M4 上天然原子,无需临界区(§3.3) */ static volatile uint32_t s_hb_bits; -static uint32_t s_miss_streak; /* 连续未达标轮数(达到容忍上限即停喂) */ +static uint32_t s_miss_streak; /* 复合缺席连续窗数(饱和封顶)——自 v1.5 起仅作 + * "MISS start" 诊断行的 episode 边沿检测,不再 + * 参与停喂判定(停喂按 s_miss_bits 逐位分账) */ +/* 按位分账(2026-10-25 大窗判读 F2):每个必需位"连续缺席抽样窗数"。位出现即清零, + * 缺席饱和到容忍值。停喂/stall 声明的唯一判据 = 任一必需位连击 >= WD_MISS_TOLERANCE, + * 即"同一个任务连续 8 个窗没跑过"。多任务各缺各的(本窗 A、下窗 B)各自清零, + * 不再串成复合 800ms 停喂——旧复合判据把互不相干的亚秒抖动累计成真停摆申报 + * (2026-10-25 大窗 16:24/16:28 两起实证;判据正文 docs/子系统设计/看门狗设计说明.md §4)。 */ +static uint8_t s_miss_bits[TASK_WD_ID_MAX]; static uint32_t s_miss_total; /* 累计未达标轮数(观测用) */ static uint32_t s_last_mask; /* 上次抽样到的位图(定位卡死任务用) */ static uint32_t s_started; /* 1 = IWDG 已启动 */ @@ -439,15 +455,22 @@ void task_wd_start(void) { * 入口参数:无 * 返回值:无 * 限定条件:仅 defaultTask 主循环调用(唯一喂狗者);内部节流到 100ms 一次 - * 函数说明:取走并清零位图 → 与必需掩码比较: - * 全中 → 清零缺失计数并喂狗; - * 有缺 → 累计;连续达到 WD_MISS_TOLERANCE 次后**停止喂狗**, - * 让 IWDG 自然溢出复位(这样 RCC_CSR 记录的是权威的 - * IWDGRSTF,而不是 NVIC_SystemReset 留下的 SFTRSTF —— §5.3)。 + * 函数说明:取走并清零位图 → 与必需掩码比较(v1.5 起按位分账,判据推导见设计说明 §4): + * 全中 → 清零复合边沿与证据门闩并喂狗; + * 有缺但每位连击 < 容忍 → **继续喂狗**(单窗/亚秒级缺席是负载抖动,不是 + * 停摆;只按位记账并在复合边沿打一条诊断行); + * 任一位连击 >= WD_MISS_TOLERANCE(同一任务 800ms 没跑过)= 真停摆 → + * 申报 [WD] task stall 并**停止喂狗**,让 IWDG 自然溢出复位 + * (RCC_CSR 记权威 IWDGRSTF 而非 SFTRSTF —— §5.3)。 + * ⚠ 判定故意不用"复合连续缺席窗数"当停喂门槛:那会把"本窗缺 A、下窗缺 + * B"这类各自远低于容忍度的抖动串成假停摆(2026-10-25 大窗实证)。 */ void task_wd_poll(void) { uint32_t now; uint32_t snapshot; + uint32_t mask_now; + uint32_t worst; + uint32_t i; if (s_started == 0u) { return; /* 未启动:不做任何判定 */ @@ -469,37 +492,58 @@ void task_wd_poll(void) { s_snap_hist_idx = (s_snap_hist_idx + 1u) % WD_SNAP_HIST_DEPTH; #endif - if (snapshot == wd_required_mask()) { + mask_now = wd_required_mask(); + + /* 按位分账:位出现→该位清零;缺席→+1(饱和封顶)。掩码外的位不入账 + * (监视范围随构建配置变化时,账本自动跟随,不会留旧账)。 */ + worst = 0u; + for (i = 0u; i < (uint32_t)TASK_WD_ID_MAX; i++) { + if ((mask_now & (1u << i)) == 0u) { + s_miss_bits[i] = 0u; + continue; + } + if ((snapshot & (1u << i)) != 0u) { + s_miss_bits[i] = 0u; + } else if (s_miss_bits[i] < (uint8_t)WD_MISS_TOLERANCE) { + s_miss_bits[i]++; + } + if ((uint32_t)s_miss_bits[i] > worst) { + worst = (uint32_t)s_miss_bits[i]; + } + } + + if (snapshot == mask_now) { s_miss_streak = 0u; s_stall_evidenced = 0u; /* 恢复:允许下次停摆再落一次证据 */ wd_platform_feed(); /* 全员健康:喂狗 */ return; } - /* 有任务缺席:累计(不喂狗) */ + /* 本窗有缺席位(复合缺席):记总数;**复合边沿**(上一窗全中)只打一条 + * 诊断标记行——v1.5 起该行不再意味着停喂,停喂只看按位连击 */ s_miss_total++; - if (s_miss_streak < WD_MISS_TOLERANCE) { - s_miss_streak++; - } - + if (s_miss_streak == 0u) { + /* 复合缺席 episode 起点裸 UART 标记(每 episode 一条):证明 poller 仍在 + * 抽样、并给出**首次**缺席位图——定位谁先停跑(round 7 出身)。⚠ v1.5 起 + * 本行为**纯诊断**——复合边沿 ≠ 停喂,stall 判定权在下面的按位连击。 */ #ifndef TASK_WATCHDOG_UNIT_TEST - if (s_miss_streak == 1u) { - /* 停喂起点裸 UART 标记(每次缺席 episode 仅一条):证明 poller 仍在 - * 抽样、并给出**首次**缺席的位图——定位是哪一任务先停摆(round 7) */ char m[96]; int mn = snprintf(m, sizeof(m), "MISS start snap=0x%08X req=0x%08X t=%lu\r\n", - (unsigned)snapshot, (unsigned)wd_required_mask(), + (unsigned)snapshot, (unsigned)mask_now, (unsigned long)now); if (mn > 0) { dbg_console_write_raw(m, (unsigned)strlen(m)); } - } #endif + } + if (s_miss_streak < WD_MISS_TOLERANCE) { + s_miss_streak++; /* 饱和封顶;仅供复合 episode 边沿 */ + } - if (s_miss_streak >= WD_MISS_TOLERANCE) { - /* 连续 800ms 有任务未动过 → 已停喂 → 约 2.5s 后 IWDG 复位。 - * 最后遗言走裸 UART(不依赖内核、不遍历指针、有界长度)。 */ + if (worst >= WD_MISS_TOLERANCE) { + /* 同一位连续缺席 8 窗(同一任务 800ms 没跑过)= 真停摆 → 停喂 → 约 2.5s + * 后 IWDG 复位。最后遗言走裸 UART(不依赖内核、不遍历指针、有界长度)。 */ DBG_ERROR("[WD] task stall: mask=0x%08X req=0x%08X miss=%lu", (unsigned)snapshot, (unsigned)wd_required_mask(), @@ -507,8 +551,8 @@ void task_wd_poll(void) { #ifndef TASK_WATCHDOG_UNIT_TEST /* round 7 取证:把现场落 /log/wdev.log(掉电不丢)。 * 串口 stall 行经僵尸通道可能丢失,故此处独立留盘上证据。 - * ⚠ 本 episode 只写**一次**(s_stall_evidenced 门闩;注意 s_miss_streak - * 达上限后会**饱和**在容忍值,不能用它判"首次"):证据落盘经 + * ⚠ 本 episode 只写**一次**(s_stall_evidenced 门闩;注意 worst 连击达 + * 上限后会**饱和**在容忍值,不能用它判"首次"):证据落盘经 * sys_log_evidence() 走 NAND 写 → FTL 路径调 nand_ftl_wd_feed() * **直接喂狗**。若每轮 100ms 都写,等于在"停喂"窗口里持续喂狗 → * IWDG 永不溢出 → 停喂语义被证据机制自身抵消(2026-10-03 台架 V2 @@ -520,6 +564,11 @@ void task_wd_poll(void) { wd_stall_evidence(snapshot); } #endif + } else { + /* 有缺席位、但每一位连击都未到容忍值(含"本窗缺 A、下窗缺 B"的交替型): + * 照常喂狗——多任务各自亚秒抖动不再串成复合停喂(按位分账收口的正身, + * 2026-10-25 大窗 16:24/16:28 型事件的根治)。 */ + wd_platform_feed(); } } @@ -724,8 +773,12 @@ void nand_ftl_wd_feed(void) { */ #ifdef TASK_WATCHDOG_UNIT_TEST void task_wd_test_reset(void) { + uint32_t i; s_hb_bits = 0u; s_miss_streak = 0u; + for (i = 0u; i < (uint32_t)TASK_WD_ID_MAX; i++) { + s_miss_bits[i] = 0u; /* 按位账本一并清零(v1.5 判定输入) */ + } s_miss_total = 0u; s_last_mask = 0u; s_started = 0u; diff --git a/docs/子系统设计/看门狗设计说明.md b/docs/子系统设计/看门狗设计说明.md index b55a540f..cc5df461 100644 --- a/docs/子系统设计/看门狗设计说明.md +++ b/docs/子系统设计/看门狗设计说明.md @@ -23,7 +23,7 @@ | 看门狗外设 | **IWDG**(独立 LSI ≈32 kHz),CubeMX 已勾选 | `.ioc`:`Mcu.IP4=IWDG`、`IWDG.Prescaler=IWDG_PRESCALER_64`、`IWDG.Reload=1249` | | 超时 | 标称 **2498 ms**,最坏(LSI 47 kHz)**≈1701 ms** | `WD_IWDG_PRESCALER=4` / `WD_IWDG_RELOAD=1249`(`task_watchdog.c`) | | 抽样周期 | **100 ms**(`WD_POLL_PERIOD_MS`) | 同上 | -| 缺失容忍 | **连续 8 轮 = 800 ms** 后停喂(`WD_MISS_TOLERANCE`) | 同上 | +| 缺失容忍 | **同一必需位**连续 8 窗缺席 = 800 ms 后停喂(`WD_MISS_TOLERANCE`;v1.5 起按位分账,多任务交替缺席各自清零、不再串成复合停喂) | 同上 | | 必需掩码 | 产品构建 **`0x1F7`**(default/net/ftp/ui/timesync/adc/iec104/protect);`ENABLE_HW_LAYER_TESTS` 下 **`0x1F5`**(剔 net) | `wd_required_mask()` | | 喂狗者 | **仅 defaultTask**(`task_wd_poll`,主循环每轮调用、内部节流) | `App/task/default_task.c` | | 启动时刻 | **defaultTask 存储 bring-up 之后**(`task_wd_start()`);`MX_IWDG_Init()` 只填参数不启动 | `default_task.c:175`、`Src/iwdg.c` 的 `USER CODE BEGIN 0` | @@ -93,10 +93,15 @@ T_IWDG = (4 × 2^PR) × RLR / F_LSI **LSI 偏快时超时变短**,故按 47 kHz 校验。三个时长必须严格包含: ``` -T_任务循环 < 监视窗口(100ms) < 停喂判定(800ms) < T_IWDG最坏(1701ms) +T_任务循环 < 监视窗口(100ms) < 停喂判定(同一任务连续缺席 800ms) < T_IWDG最坏(1701ms) ``` 即:停喂后仍有约 **900 ms** 余量才到复位;实测 V2 从声明 stall 到复位 ≤2.5 s。 +⚠ v1.5(2026-10-25)起本链**字面成立**:旧实现的停喂从第一个缺席窗就开始(全掩码 +精确匹配是喂狗前置),800ms 只是申报时点;现在停喂时点 = 同一位第 8 连击窗,此前 +1..7 窗照常喂狗。代价 = 真停摆从挂死到复位总时长 +约 0.7 s(最坏 ≈2.5 s / 标称 +≈3.3 s,V2 判据「声明后 ≤2.5 s」仍成立);收益 = 「本窗缺 A、下窗缺 B」式互不相干 +抖动不再累计成停喂(2026-10-25 大窗 16:24/16:28 两起误升级实证,判据改写动因)。 ### 2.3 配置落点 @@ -155,9 +160,9 @@ static HAL_StatusTypeDef iwdg_deferred_start(const IWDG_HandleTypeDef *a_h) { (v |---|---| | ① 任务置位 | `task_wd_beat(id)`:`s_hb_bits \|= (1u << id)`(仅置位) | | ② 校验者取走清零 | `snapshot = s_hb_bits; s_hb_bits = 0u;`(单字原子) | -| ③ 掩码判定 | `snapshot == wd_required_mask()`(**精确相等**,见 §3.5) | -| ④ 达标 → 喂狗 | `s_miss_streak = 0; s_stall_evidenced = 0; wd_platform_feed();` | -| ④ 缺席 → 累计 | `s_miss_total++`;首轮打 `MISS start`;连续 8 轮打 `[WD] task stall` 并**停喂**(每 episode 只落一次盘上证据) | +| ③ 按位分账(v1.5) | 对掩码内每一位:出现→该位连击清零;缺席→+1(饱和封顶)。掩码外的位不入账(监视范围随构建变化时账本自动跟随) | +| ④ 全中窗 | `snapshot == wd_required_mask()`(**精确相等**,§3.5)→ `s_miss_streak=0; s_stall_evidenced=0;` 喂狗 | +| ⑤ 缺席窗(v1.5) | `s_miss_total++`;复合边沿(上一窗全中)打一条 `MISS start`(**纯诊断**,不再代表停喂);**任一必需位连击 ≥8** → `[WD] task stall` + **停喂**(每 episode 只落一次盘上证据);所有位连击 <8 → **照常喂狗** | ### 3.3 为什么用"取走并清零" @@ -337,12 +342,16 @@ void task_wd_tick_probe(void); /* TIM7 ISR 内,每 1ms */ 未启动 → 直接返回 未到 100ms 边界 → 直接返回(节流) 取走并清零位图 → 记入近 8 次环形历史(取证) +按位分账:掩码内每位 出现→连击清零 / 缺席→+1(饱和);worst = 各位最大连击 snapshot == required → miss_streak=0、证据门闩复位、喂狗 -否则 → miss_total++、miss_streak++(饱和在容忍值) - 首轮 → 裸 UART `MISS start …` - miss_streak >= 8 → DBG_ERROR task stall + (本 episode 首次)落盘证据 + 不喂狗 +否则 → miss_total++;复合边沿(miss_streak==0)→ 裸 UART `MISS start …`(诊断行) + worst >= 8 → DBG_ERROR task stall +(本 episode 首次)落盘证据 + 不喂狗 + worst < 8 → **照常喂狗**(单任务亚容忍缺席不牵连全局停喂) ``` +⚠ 2026-10-25 大窗判读前,后两行按复合 `miss_streak >= 8` 判定:互不相干的缺席 +串成停喂链(变更记录同日行);按位分账后 `s_miss_streak` 退化为纯诊断边沿计数。 + ⚠ **证据门闩 `s_stall_evidenced`**:落盘走 NAND → FTL 路径会 `nand_ftl_wd_feed()` **直接喂狗**。若每 100 ms 都写,等于在"停喂"窗口里持续喂狗 → IWDG 永不溢出(2026-10-03 V2 首轮实测:stall 已声明、`miss` 累至 1157≈115 s 仍不复位)。每 episode 只落一次,恢复健康后复位门闩。注意 `s_miss_streak` 达上限后会**饱和**,不能用它判"首次"。 ### 5.4 平台后端(可测性) @@ -399,8 +408,8 @@ snapshot == required → miss_streak=0、证据门闩复位、喂狗 | `PREVRUN seg=… seq=… t=…` | 开机(裸 UART) | 上一轮 defaultTask 最后进段(magic 命中才打,一次复位只报一次) | | `TICKRUN uw=… n=… duw=…` | 紧随其后 | 上一轮 HAL 时基终值;`duw≈0` → TIM7 与主循环同刻停摆;`duw≈2000+` → 另有阻塞点 | | `[WD] armed: timeout ~2498ms (worst ~1701ms), miss_tol=800ms` | `task_wd_start()` | IWDG 已启动(真机自检必查标记) | -| `MISS start snap=… req=… t=…` | 缺席 episode 首轮(裸 UART) | poller 仍在抽样,给出首次缺席位图 | -| `[WD] task stall: mask=… req=… miss=…` | 连续 8 轮缺席(每轮一次) | 已停喂 → 最坏 1.7 s 后 IWDG 复位;`mask` 的 0 位 = 缺席任务 | +| `MISS start snap=… req=… t=…` | **复合**缺席 episode 首轮(裸 UART) | poller 仍在抽样,给出首次缺席位图。⚠ v1.5 起为**纯诊断行**:出现不代表停喂(停喂只看同一必需位第 8 连击窗) | +| `[WD] task stall: mask=… req=… miss=…` | **同一必需位**连续 8 窗缺席起,每窗一条(v1.5 按位分账;旧复合判据见变更记录) | 该位对应任务 800 ms 没跑过 → 停喂 → 最坏 1.7 s 后 IWDG 复位;`mask` 的 0 位 = 缺席任务(多个 0 位且**跨窗换位** = 抖动串联嫌疑,v1.5 后不再触发本行) | | `STALL snap=… req=… h=… mg=… cf=… seg=…` | 停喂 episode 首次 | 盘上+串口双份证据;`mg` 最大喂狗间隔、`cf` 控制台发送故障计数 | | `[ERR] ASSERT FAILED : (resetting)` | `configASSERT` | 断言失败,打印后软复位 | | `[ERR] HARDFAULT / MEMFAULT / BUSFAULT / USAGEFAULT PC=… CFSR=… [BFAR=] [MMFAR=]` | 4 个 Fault | 崩溃现场 + 故障名归因 | @@ -557,7 +566,7 @@ python scripts/check_cubemx_survival.py --write # 确认改动无误后重建 | 现象 | 正确读法 | |---|---| | 启动期出现 `MISS start snap=0x67 req=0x77` | **良性 episode**:`[WD] armed` 在 bring-up 完成后武装,而 `required_mask` 静态含 UI 位 → UI 首跳之前必现一次,随后自行恢复。**不是故障判据**,只作"UI 尚未起跳"的旁证 | -| `miss=11` / `miss=12` | `miss=` 是**累计**缺席次数(`s_miss_total`),**不是连续次数**;连续次数 `s_miss_streak` 饱和在 `WD_MISS_TOLERANCE=8`。缺口要用 `t=`(毫秒时基)差算,本例恰为 800 ms | +| `miss=11` / `miss=12` | `miss=` 是**累计**缺席次数(`s_miss_total`),**不是连续次数**;连续次数自 v1.5 起按位分账(`s_miss_bits[]`,各饱和在 `WD_MISS_TOLERANCE=8`;旧全局 `s_miss_streak` 退化为复合边沿计数)。缺口要用 `t=`(毫秒时基)差算,本例恰为 800 ms | | `reset cause: software (NVIC_SystemReset)` | **不能**用来区分"IWDG 溢出"与"台架拉 NRST / 烧录工具软件复位"——本台架**健康**启动同样报这一串。停摆的权威证据是 `[ERR][WD] task stall` 行本身 | ### 11.2 诊断四步:先量化,再归因 @@ -629,6 +638,17 @@ enter/exit 段还能读出"此刻仍在段内已多久"——**永久阻塞的 | 把推流搬出 uiTask(独立 `uiSinkTask`) | 实测 `uis`=1 µs 而 `uip` 仍 60.9 ms = **LVGL 帧周期地板** → 搬走推流**不会让刷新变快**;收益只剩极端并发下的观感隔离,成本含**必改 `.ioc`** 的重生成与全套回归。若日后成问题,正确方向是先治 netTask 排队公平性(见 `../网络/Net层阻塞模型架构设计.md`),不是搬 UI | | 把推流塞进 netTask | 零往返零新增 RAM,但共享源码要走 backend 端口(双端同改)、netTask 这个唯一执行者更胖、一个 UI 侧 bug 直接变成整机复位 | +> **2026-10-25 大窗补记(判读实锤,勿与上表混淆)**:条目「提高 uiTask 优先级」与 +> 「有界等待」的否决理由**依旧成立**——大窗里 UI 的 ~1.2 s 单串停摆(9 条 stall 中唯一 +> 的真单任务链,bit4 全程缺席到 mg≈900+)发生在**监听重建窗**内,正是上表所说 +> 「UI 在等 netTask」的实况;修复方向仍按 v1.3 既注走**事件驱动 accept / 等待面搬家**, +> 不是提优先级、也不是给 net_* 裸加超时(迟到回执拾取风险原样在册)。同期暴露的 +> **复合停喂判据**问题(本窗缺 UI、下窗缺 IEC104/FTP 串成 16:24/16:28 两起误申报, +> `mg=877/918` 均为复合链而非单任务链)已由 **v1.5 按位分账**根治:判据见 §2.2/§3.2/ +> §5.3,回归钉 = 主机单测 TC-WATCHDOG-017(交替缺席零申报、持续喂狗)/ 018(同位 8 +> 连击必报 + 恢复后账本重启)。原始日志判读材料:`docs/测试说明/boot_log_data/` +> (megawin 同日摘录入库,gz)。 + --- ## 附录 A:参考依据(当前有效) @@ -688,3 +708,4 @@ enter/exit 段还能读出"此刻仍在段内已多久"——**永久阻塞的 | 2026-10-10 | **新增 §11:任务级饥饿的诊断方法与实测基线** | 承接 Task #32(`task stall: mask=0x67 req=0x77`,缺 `TASK_WD_UI`)结案后的沉淀——原分析文档 `docs/问题记录/UI任务看门狗停摆分析.md` 按"完成即销项"删除,可复用部分并入本文:**§11.1** 三条易误读的判读口径(启动期 `MISS start 0x67` 良性、`miss=` 是累计不是连续、`reset cause` 不能区分 IWDG 与台架);**§11.2** 诊断四步(先看掩码缺几位 → 量化占空比 → 用计数速率反证空转 → 用 `[PROF]` 指认占用者;本例否掉"CPU 饱和",实测 10%~13%);**§11.3** 三条结构性脆弱点(uiTask 唯一 BelowNormal/心跳依赖 netTask 往返/自定截止期不可恢复)与两个顺带修掉的**正确性**缺陷(无新帧仍压环 ≈3% 频率失配、失败拍静默沿用旧值);**§11.4** `[PROF]`/`[PROF2]` 全字段语义 + v2.8 基线(含 `netp=0` 是真实值、`adcm` 峰值来自心跳日志、`uip` 地板 = LVGL 帧周期)与三机理判读口诀;**§11.5** 三条教训(门控须留低频兜底、门控会消灭暴露路径、事件驱动要核对节拍源与消费者创建时刻);**§11.6** 明确不做的四条及理由。§9.1 的 R7 行改指本文 §11.3 | | 2026-10-11 | **新增 §10.4:ARMCC `__asm` 的三条语法约束** | AGENTS.md 瘦身:「ARMCC 内联汇编语法」条目的完整细节(GCC 语法报 `#18`、块内联把寄存器名当 C 标识符报 `#20`、`__asm` 取不到 C 数据符号 `A1516E`、可用写法 = 整函数 `__asm void f(void)` + `B` 到 C 入口、`#define f f_Gen` 让位机理)迁入本文,AGENTS.md 只留一行禁令与指针 | | 2026-10-12 | **iec104Task / protectTask 纳入监视(B4/B5):必需掩码 `0x77`→`0xF7`→`0x1F7`** | 两行合并记录(B4 落地时漏补本文,B5 一并纠偏):**B4** 新增 `TASK_WD_IEC104 = 7`(iec104Task 100 ms 服务循环喂位,NET_READY 屏障等待按 100 ms 分片喂位),产品 `0x77`→`0xF7`、HW 层 `0x75`→`0xF5`;**B5** 新增 `TASK_WD_PROTECT = 8`(protectTask 20 ms 判据节拍喂位,见 `docs/算法/继电保护功能复现规划.md` §7),产品 `0xF7`→`0x1F7`、HW 层 `0xF5`→`0x1F5`。两任务均**不依赖存储/网络之外的屏障**(protectTask 无任何屏障)→ 任何构建都置位;均不做 NAND I/O → 不登记 FTL 心跳路由。主机侧单测 `beat_all()` 与 `tc_003`/`tc_005`/`tc_011` 掩码断言同步至 `0x1F7`,39 断言全绿。⚠ 注意 `0x1F7` 不含 bit3(testTask 任何构建不入掩码,口径不变) | +| 2026-10-25 | **停喂判定按位分账(v1.5,大窗判读 F2)**:stall/停喂唯一判据从「复合连续缺席窗数」改为「**任一必需位的连续缺席窗数**」——同一任务连续 8 窗(800 ms)没跑过才申报停摆并停喂;多任务交替缺席(本窗缺 A、下窗缺 B)各自清零、且缺席窗在容忍线下**照常喂狗**(旧实现自第一个缺席窗起就不喂,800ms 只是申报点)。动因 = 2026-10-25 台架大窗:54 个探针重建窗里 9 条 stall 行中 16:24/16:28 两起为复合误升级(mg=877/918,无任何单位到 800ms 单串),16:39 为真 UI 单串(~1.2 s,受害者面修复另立 F3 票);`MISS start` 降级为复合边沿**诊断行**(不再代表停喂,判读口径 §6.1/§11.1 同步);真停摆到复位的总时长最坏 +约 0.7 s(§2.2 链注)。§0/§2.2/§3.2/§5.3/§6.1/§11.1/§11.6 同步;主机单测 `test/host/watchdog/watchdog_test.c` 断言按新判据改写 + 新增 **TC-WATCHDOG-017**(20 窗交替缺席:零 stall、20 窗持续喂)/ **TC-WATCHDOG-018**(同位 8/9/10 连击必报停 + 停喂;恢复窗喂、账本重启,第二段亚容忍串不误报),47 断言全绿 | diff --git a/docs/测试说明/看门狗单元测试说明.md b/docs/测试说明/看门狗单元测试说明.md index d06f5acd..0ef70501 100644 --- a/docs/测试说明/看门狗单元测试说明.md +++ b/docs/测试说明/看门狗单元测试说明.md @@ -3,8 +3,8 @@ > **模块**:Task Watchdog(IWDG + 任务心跳位图,`App/util/task_watchdog.c`) > **测试类型**:**主机侧纯逻辑单元测试**(L0.5 层,见 §1.5)——在 PC 上离线验证 > 判定状态机,零硬件、零烧录、秒级、不占 MCU 的 RAM。 -> **状态**:✅ 已落地(39 用例,`@build` 0/0,已挂 pre-commit 钩子) -> **日期**:2026-10-01 +> **状态**:✅ 已落地(47 断言,`@build` 0/0,已挂 pre-commit 钩子) +> **日期**:2026-10-01(2026-10-25 v1.5 按位分账改造,见变更记录) > **关联文档**: > [`../子系统设计/看门狗设计说明.md`](../子系统设计/看门狗设计说明.md)(被测行为的设计依据)、 > [`测试开发标准流程.md`](测试开发标准流程.md)(测试分层与门禁总览)、 @@ -20,7 +20,7 @@ | 半边 | 内容 | 可测性 | |---|---|---| -| **判定状态机**(被测) | 位图取走清零、必需掩码比较、`miss_streak` 累计(容忍 8 次)、100 ms 抽样节流、tick 回绕安全 | 纯逻辑,与硬件无关 → **可在 PC 上位穷举** | +| **判定状态机**(被测) | 位图取走清零、必需掩码比较、`s_miss_bits[]` 按位分账(容忍 = 同一位 8 连击)、100 ms 抽样节流、tick 回绕安全 | 纯逻辑,与硬件无关 → **可在 PC 上位穷举** | | **硬件副作用** | `IWDG->KR` 写(喂狗 / 启动)、`HAL_GetTick()` | 需真机或桩 | 板端套件(`test/board/`)只能"在真机上完整跑一遍",**无法穷举边界**——例如: @@ -108,18 +108,20 @@ test/host/watchdog/watchdog_test.c | **TC-WATCHDOG-014** | 重复 `task_wd_start()` 只启动 IWDG 一次 | 启动幂等(IWDG 不可逆,重复启动无意义) | | **TC-WATCHDOG-015** | `start` 内部先喂一次(`feed_count==1`) | 首个抽样窗口从启动时刻起算,避免启动即超时 | | **TC-WATCHDOG-002** | 50 ms 内 `poll` 不喂;累计 100 ms 喂 1 次 | 100 ms 抽样节流 | -| **TC-WATCHDOG-003** | 全员置位 → 喂狗 1 次;`last_mask==0x37` | 全员到位判定 + 掩码取值 | -| **TC-WATCHDOG-004** | 不重新置位则第二轮不喂 | **取走清零**语义(旧位不残留) | -| **TC-WATCHDOG-005** | 缺 UI(bit4)→ 不喂;`last_mask` 精确记录缺失位 | 必需掩码比较 + 缺失定位 | -| **TC-WATCHDOG-006** | 连续 1..7 次缺席不报;**第 8 次**打 `[WD] task stall`;缺席期间不喂 | `WD_MISS_TOLERANCE=8`(800 ms)停喂触发点 | +| **TC-WATCHDOG-003** | 全员置位 → 喂狗 1 次;`last_mask==0x1F7` | 全员到位判定 + 掩码取值(掩码史:0x37→0x77→0x1F7,见设计说明变更记录 2026-10-10/12) | +| **TC-WATCHDOG-004** | 不重新置位:第二窗(空位图,各位 streak=1)v1.5 **照常喂**;`last_mask==0` 证明位已取走 | **取走清零**语义(旧位不残留);停喂只看按位连击 | +| **TC-WATCHDOG-005** | 缺 UI(bit4)单窗(streak 1 < 容忍)→ **照常喂**;`last_mask` 精确记录缺失位 | 按位分账(v1.5)+ 缺失定位 | +| **TC-WATCHDOG-006** | 同一批缺席 1..7 窗不报且**持续喂**(feed_count==8);**第 8 窗**打 `[WD] task stall` 起停喂 | `WD_MISS_TOLERANCE=8`(同一任务 800 ms)停喂触发点 | | **TC-WATCHDOG-007** | 停喂后**每 100 ms 复报**一条 stall(现状行为) | 见 §4 已知行为("最后遗言"持续到复位) | -| **TC-WATCHDOG-008** | 恢复全员到位 → 立即喂狗 + `streak` 归零 | 停顿可恢复(非锁死状态机) | +| **TC-WATCHDOG-008** | 恢复全员到位 → 立即喂狗 + 全部按位账本清零(v1.5:episode 内 1..7 窗已持续喂,恢复窗 feed_count==8) | 停顿可恢复(非锁死状态机) | | **TC-WATCHDOG-009** | 99 ms 不判 / 100 ms 判 / 再 99 ms 不判 / 累计 100 ms 判 | 节流窗口**边界**(易错点) | | **TC-WATCHDOG-010** | `tick` 跨 2^32 回绕后 100 ms 仍正确判 | 无符号差比较(回绕安全) | | **TC-WATCHDOG-011** | `is_armed`/`last_mask`/`miss_count` 语义 | 观测接口正确性 | -| **TC-WATCHDOG-013** | 非法任务 ID(`>= TASK_WD_ID_MAX`)被忽略,不污染位图 | 防位图越界(`task_wd_beat` 的守卫) | +| **TC-WATCHDOG-013** | 非法任务 ID(`>= TASK_WD_ID_MAX`)被忽略,不污染位图;空位图单窗(streak 1)v1.5 照常喂 | 防位图越界(`task_wd_beat` 的守卫) | +| **TC-WATCHDOG-017** | UI/NET **交替缺席** 20 窗 → 零 stall、20 窗全部持续喂(v1.5 回归钉:旧复合判据会在第 8 窗误申报) | 复合串窗根治(2026-10-25 大窗 16:24/16:28 型误升级) | +| **TC-WATCHDOG-018** | 同一位(IEC104)缺席 7 窗:不报持续喂;8/9/10 窗连报 stall 且停喂;恢复窗喂并清零账本;第二段 7 窗亚容忍串再从零计、不误报 | 真单任务停摆保留杀伤力 + 账本重启(16:39 型真链仍可抓) | -**当前结果**:39/39 PASSED(`failed=0`)。 +**当前结果**:47/47 PASSED(`failed=0`)。 --- @@ -180,5 +182,6 @@ if (s_miss_streak >= WD_MISS_TOLERANCE) { DBG_ERROR("[WD] task stall: ..."); } | 日期 | 变更 | |---|---| +| 2026-10-25 | **v1.5 按位分账同步**:停喂判据从复合连击改为**单个必需位**连击(设计说明 §2.2/§3.2/§5.3),TC-WATCHDOG-004/005/006/008/013 断言按新语义改写(缺席窗在容忍下照常喂)、新增 TC-WATCHDOG-017(交替缺席零误报)/ TC-WATCHDOG-018(同位连击必报 + 账本重启),39→47 断言;顺带修正 TC-WATCHDOG-003 行陈旧的 `0x37`(B4/B5 后应为 `0x1F7`) | | 2026-10-01 | §1.3/§1.4/§1.5 更新:工具链逻辑抽取到通用 harness `test/host/common/unit_test_harness.py`(T-3),本模块 `run_unit_test.py` 改为**声明式** `TestBuild`(原 170 行 → 55 行);hooked 触发范围加 `test/host/common/unit_test_harness.py`。行为与退出码契约不变(39/39 PASSED) | | 2026-10-01 | 创建:看门狗判定状态机主机侧单元测试(39 用例)+ 平台层抽取(`task_watchdog.h` 末段三个 `__weak` 钩子)+ pre-commit 钩子 `watchdog-unit-test`;记录两处已知行为(stall 复报、测试隔离) | diff --git a/test/host/watchdog/watchdog_test.c b/test/host/watchdog/watchdog_test.c index 4ab32208..7ee14479 100644 --- a/test/host/watchdog/watchdog_test.c +++ b/test/host/watchdog/watchdog_test.c @@ -163,12 +163,13 @@ static void tc_004_take_and_clear(void) { beat_all(); poll_advance(100); /* 消费位图并喂狗 */ CHECK(s_feed_count == 1, "first round fed"); - poll_advance(100); /* 不重新置位 */ - CHECK(s_feed_count == 1, "no re-set -> second round does not feed (bitmap already taken and cleared)"); + poll_advance(100); /* 不重新置位:空位图窗(各位 streak=1 < 容忍) */ + CHECK(s_feed_count == 2, "v1.5: an empty-bitmap single window still feeds (streak 1 < tol)"); + CHECK(task_wd_last_mask() == 0u, "bitmap was taken and cleared (no stale bits survive)"); } static void tc_005_mask_required(void) { - printf("TC-WATCHDOG-005 mask check: missing any bit -> no feed\n"); + printf("TC-WATCHDOG-005 mask check: one-window miss (streak 1) feeds; ledger records it\n"); start_and_clear(); /* 故意缺 UI(bit4) */ task_wd_beat(TASK_WD_DEFAULT); @@ -179,13 +180,13 @@ static void tc_005_mask_required(void) { task_wd_beat(TASK_WD_IEC104); task_wd_beat(TASK_WD_PROTECT); poll_advance(100); - CHECK(s_feed_count == 0, "UI bit missing -> no feed"); + CHECK(s_feed_count == 1, "UI absent for ONE window (streak 1 < tol) -> still feeds (v1.5)"); CHECK(task_wd_last_mask() == (0x1F7u & ~(1u << TASK_WD_UI)), "last_mask records the missing bit exactly"); } static void tc_006_stall_8(void) { - printf("TC-WATCHDOG-006 8 consecutive misses -> print stall and stop feeding\n"); + printf("TC-WATCHDOG-006 8 consecutive all-miss windows -> stall at the 8th, feeding stops\n"); start_and_clear(); beat_all(); poll_advance(100); /* streak 清零 */ int err0 = g_wdtest_err_count; @@ -195,7 +196,7 @@ static void tc_006_stall_8(void) { } poll_advance(100); /* 第 8 次 */ CHECK(g_wdtest_err_count == err0 + 1, "8th miss prints [WD] task stall"); - CHECK(s_feed_count == 1, "fed exactly once at the start only (nothing fed during the missing period)"); + CHECK(s_feed_count == 8, "v1.5: windows 1..7 (streak < tol) kept feeding; the 8th stops"); } static void tc_007_stall_repeats_until_reset(void) { @@ -222,12 +223,69 @@ static void tc_008_recover(void) { for (int i = 0; i < 8; i++) poll_advance(100); /* 触发 stall */ beat_all(); poll_advance(100); - CHECK(s_feed_count == 1, "feeds immediately after recovery"); + CHECK(s_feed_count == 8, "feeds immediately after recovery (7 sub-tol windows fed + recovery)"); int err0 = g_wdtest_err_count; poll_advance(100); /* 再缺一次 */ CHECK(g_wdtest_err_count == err0, "streak already reset: a single miss does not report stall"); } +/* 全员置位但跳过一位(按位连击用例的激励器;0x1F7 = 默认必需掩码,同 tc_005 用法) */ +static void beat_all_except(task_wd_id_t skip) { + uint32_t bits = 0x1F7u; + uint32_t i; + for (i = 0u; i < (uint32_t)TASK_WD_ID_MAX; i++) { + if (((bits & (1u << i)) != 0u) && (i != (uint32_t)skip)) { + task_wd_beat((task_wd_id_t)i); + } + } +} + +/* v1.5 按位分账·修复本体:交替缺席(本窗缺 UI、下窗缺 NET……)任何单位连击都 + * 到不了容忍值——旧复合判据会在第 8 窗误报 stall 并停喂(2026-10-25 大窗 + * 16:24/16:28 型),新判据必须全程零申报、持续喂狗 */ +static void tc_017_mixed_alternating_no_false_stall(void) { + printf("TC-WATCHDOG-017 alternating per-bit misses never chain into a stall\n"); + start_and_clear(); + for (int i = 1; i <= 20; i++) { + if ((i % 2) != 0) { + beat_all_except(TASK_WD_UI); /* 奇数窗:UI 缺席 */ + } else { + beat_all_except(TASK_WD_NET); /* 偶数窗:NET 缺席 */ + } + poll_advance(100); + } + CHECK(g_wdtest_err_count == 0, "20 mixed-miss windows report ZERO stall (composite would have fired at 8th)"); + CHECK(s_feed_count == 20, "feeding continues in every mixed window"); +} + +/* v1.5 按位分账·保留杀伤力:同一任务连续缺席到容忍值必须照报停摆、停喂; + * 恢复后账本清零,第二段亚容忍缺席重新从零计(16:39 型真单任务串仍会被抓) */ +static void tc_018_single_bit_streak_stalls_and_restarts(void) { + printf("TC-WATCHDOG-018 same-bit streak reaches tol -> stall + feeding stops; ledger restarts after recovery\n"); + start_and_clear(); + for (int i = 1; i <= 7; i++) { + beat_all_except(TASK_WD_IEC104); + poll_advance(100); + } + CHECK(g_wdtest_err_count == 0 && s_feed_count == 7, + "7 windows missing the SAME bit stay below tol: no stall, feeding continues"); + for (int i = 8; i <= 10; i++) { + beat_all_except(TASK_WD_IEC104); + poll_advance(100); + } + CHECK(g_wdtest_err_count == 3, "8th/9th/10th consecutive same-bit miss each report stall"); + CHECK(s_feed_count == 7, "feeding stops from the 8th window onward"); + beat_all(); + poll_advance(100); + CHECK(s_feed_count == 8, "recovery window feeds and clears the per-bit ledger"); + for (int i = 1; i <= 7; i++) { + beat_all_except(TASK_WD_IEC104); + poll_advance(100); + } + CHECK(g_wdtest_err_count == 3 && s_feed_count == 15, + "second sub-tol streak starts from zero again: no new stall"); +} + static void tc_009_throttle_boundary(void) { printf("TC-WATCHDOG-009 throttle window edges 99/100/199/200\n"); start_and_clear(); @@ -265,7 +323,7 @@ static void tc_013_bad_id(void) { start_and_clear(); task_wd_beat((task_wd_id_t)(TASK_WD_ID_MAX + 3)); /* 非法:应被忽略 */ poll_advance(100); - CHECK(s_feed_count == 0, "invalid id does not pollute the bitmap (all required bits missing -> no feed)"); + CHECK(s_feed_count == 1, "invalid id ignored; one all-miss window still feeds (streak 1 < tol, v1.5)"); CHECK(task_wd_last_mask() == 0u, "bitmap stays 0 (invalid bit never set)"); } @@ -308,6 +366,8 @@ int main(void) { fresh(); tc_006_stall_8(); fresh(); tc_007_stall_repeats_until_reset(); fresh(); tc_008_recover(); + fresh(); tc_017_mixed_alternating_no_false_stall(); + fresh(); tc_018_single_bit_streak_stalls_and_restarts(); fresh(); tc_009_throttle_boundary(); fresh(); tc_010_tick_wrap(); fresh(); tc_011_observers();