From 513becd61d2bf5b9aeee79a798b9fbcfa9c4b1ae Mon Sep 17 00:00:00 2001
From: Wanderingss <1624155937@qq.com>
Date: Wed, 21 Oct 2026 13:42:36 +0800
Subject: [PATCH 1/4] =?UTF-8?q?docs(algo):=20=E6=96=B0=E5=A2=9E=20adc=5Fhe?=
=?UTF-8?q?alth=20=E5=8D=95=E6=96=87=E4=BB=B6=E6=B7=B1=E8=AE=BE=E8=AE=A1?=
=?UTF-8?q?=E4=B8=8E=E4=B8=89=E5=BC=A0=E9=85=8D=E5=9B=BE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新建 docs/算法/采样有效性校验设计说明.md(15 章):判据推导
(100 ms 门限的三条独立依据)、电平/锁存/事件三种位语义与
位号对齐的收益代价、闭锁与 IWDG 兜底责任矩阵、观测盲区、
主机侧验证分工(11 组 TC / 31 断言映射)、现场判读手册、
自测问题与改前必读清单
- 配图走 draw-diagram 生成器路线(脚本 + 产物一并提交):
tools/arch_diagram/gen_adc_health_flow_svg.py 等三个脚本 →
docs/images/ 下三张 SVG;文内另有 2 张 Mermaid(判定主干
flowchart、静默位生命周期 stateDiagram-v2,沿用仓内既有方言)
- 登记与指针:docs/README.md §5.2 加一行 + 变更记录;
docs/算法/采集与算法层设计说明.md §4 与 §13 加深读指针;
本文只写「为什么」,接口表仍由该文档属主,不重复
- 读码确认并如实登记三项(本提交不改代码,待裁):
1) adc_task_init() 全库无调用点,adc_health_init() 因此不在
产品路径,判定正确只依赖 .bss 清零 + 首拍建基线
2) tgap 计帧点数(d-1 加权),adc1_gap 计次数——两侧单位
不对称,心跳里不可直接对比
3) TIM3 停摆时判定层随消费者一起停,最坏约 800 ms + 2498 ms
窗口内 protectTask 仍以旧相量判据(细节见 §10 / §14)
- 门禁自查:pre-commit 目标集全绿(doc-links / id-check /
空白 / EOF / 大文件);主机单测 adc_health 31/31 复跑通过;
9 个新增或改动文件 BOM=无、CR=0、U+FFFD=0
---
docs/README.md | 2 +
docs/images/采样有效性位图与掩码.svg | 218 +++++++
docs/images/采样有效性判定时间轴.svg | 193 ++++++
docs/images/采样有效性校验数据流.svg | 120 ++++
docs/算法/采样有效性校验设计说明.md | 582 ++++++++++++++++++
docs/算法/采集与算法层设计说明.md | 4 +
.../arch_diagram/gen_adc_health_bitmap_svg.py | 227 +++++++
tools/arch_diagram/gen_adc_health_flow_svg.py | 256 ++++++++
.../gen_adc_health_timeline_svg.py | 300 +++++++++
9 files changed, 1902 insertions(+)
create mode 100644 docs/images/采样有效性位图与掩码.svg
create mode 100644 docs/images/采样有效性判定时间轴.svg
create mode 100644 docs/images/采样有效性校验数据流.svg
create mode 100644 docs/算法/采样有效性校验设计说明.md
create mode 100644 tools/arch_diagram/gen_adc_health_bitmap_svg.py
create mode 100644 tools/arch_diagram/gen_adc_health_flow_svg.py
create mode 100644 tools/arch_diagram/gen_adc_health_timeline_svg.py
diff --git a/docs/README.md b/docs/README.md
index 68e70532..9c796324 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -106,6 +106,7 @@ docs/
| [`傅氏算法详解.md`](算法/傅氏算法详解.md) | 全周波傅氏(单频 DFT)原理与工程实现:正交性、梳状带通、滑动窗、系数表折算有效值;保护/遥测双轨有效值的算法基础 |
| [`采集与算法层设计说明.md`](算法/采集与算法层设计说明.md) | **`App/algo/` 的现状设计说明**:L1 语义取数与有效性判定、L2 采样环/相量/测频、L3 定值模型与元件框架的契约、关键机制(双区原子切换、乒乓半区、自适应测频窗、定值封存-自检-闭锁)、精度判据 J1~J6 与资源足迹 |
| [`继电保护功能复现规划.md`](算法/继电保护功能复现规划.md) | **保护功能复现规划**:把参考系统(CLS100)保护链路映射到本项目(STM32F4)的分层功能/性能/参数清单、平台适配(N-18 等)与分阶段路线图 |
+| [`采样有效性校验设计说明.md`](算法/采样有效性校验设计说明.md) | **`App/algo/adc_health.c` 单文件深设计**:四类判据(静默 / 丢帧 / 序号倒退 / 错误计数增量)的判据推导与 100 ms 门限依据、电平-锁存-事件三种位语义与位号对齐、闭锁与看门狗兜底的责任划分、观测盲区、主机侧验证分工与现场判读手册(含三张配图生成器) |
---
@@ -272,6 +273,7 @@ docs/
| 日期 | 变更 | 说明 |
|---|---|---|
+| 2026-10-21 | 新增算法专题 | 新建 `docs/算法/采样有效性校验设计说明.md`(`App/algo/adc_health.c` 单文件深设计:四类判据推导、位语义与位号对齐、闭锁与兜底责任划分、盲区与验证分工),并在 `tools/arch_diagram/` 新增三个配图生成器与产物 SVG;本索引 §5.2 补登记,`docs/算法/采集与算法层设计说明.md` §4 与 §13 加指针 |
| 2026-10-14 | 网络文档合并 | 《网络Socket监听生命周期设计说明.md》(B10 根治,587 行)并入 [`网络/网络自愈架构设计.md`](网络/网络自愈架构设计.md):原理与规范层论证 → §1.1~§1.4、FUN_PARA bit3 契约 → §4.9、代码级事实清点 → §9、决策记录 → §10、`net_listener.c` 模块化边界 → §11,原「引用与归档约定」顺延为 §12;过程类内容(分阶段勾选清单、台架验收记录)不迁、留 git 历史;原文档删除。全库引用同步 13 处(`net_listener.c`/`.h` 注释、Net层阻塞模型、BSD_Socket_API 两篇、陷阱记录 B10、编号规范 §4.2/§5.2/§7.2);本索引 §5.1 补登记 |
| 2026-10-14 | E14"丢中断"复核与归因更正 | 48 轮直读 SINT 矩阵复核历史"丢中断"说法:**芯片侧事件丢失 = 0**("丢失"= drain 读清消费 3/48 + 门控竞态 2/48 + 主机同步 1/48,幽灵态 0/48 未复现);根除性修复 = listen/relisten 路径排事件挪至 LISTEN 命令**之前**(吞新会话 CONNECT 的生产侧自伤窗);全局读收口(全局寄存器唯一读者=`net_poll`,I7 护栏);全库 17 文件约 40 处"丢中断"归因更正为"事件未收到/读清消费竞态",`docs/网络/网络自愈架构设计.md` §5.2 新增 E14 锚点;台架保留复核探针(drain-int/SINT 补读×3/超时归类);同日 E15 幽灵态归因复核(官网+手册实锤:0x04+旧端口 = 芯片**如实报告**旧连接拆链进行中——bit3=1 保留现场为自家配置、拆链窗与 §5.17 重传默认值吻合、WCH 官方博客确认断连即重连被拒且官方方案 = 关闭完成后重开,即 settle 等待) |
| 2026-10-13 | 网络自愈架构文档瘦身与改名 | 合并文档 `网络/N34-自愈审计.md`(2062 行审计全记录)瘦身为**终版设计描述**(281 行:不变量 I1~I8 / 架构与机制详解 / 参数与 E 锚点速查 / 红线 / 延后项),并经 `rename_ref.py` 改名 [`网络/网络自愈架构设计.md`](网络/网络自愈架构设计.md)(全库 16 文件 30 处引用同步);审计过程(T1~T4、E0~E13)归档 git 历史,考古命令见该文档「引用与归档约定」 |
diff --git a/docs/images/采样有效性位图与掩码.svg b/docs/images/采样有效性位图与掩码.svg
new file mode 100644
index 00000000..cd102b91
--- /dev/null
+++ b/docs/images/采样有效性位图与掩码.svg
@@ -0,0 +1,218 @@
+
diff --git a/docs/images/采样有效性判定时间轴.svg b/docs/images/采样有效性判定时间轴.svg
new file mode 100644
index 00000000..56816031
--- /dev/null
+++ b/docs/images/采样有效性判定时间轴.svg
@@ -0,0 +1,193 @@
+
diff --git a/docs/images/采样有效性校验数据流.svg b/docs/images/采样有效性校验数据流.svg
new file mode 100644
index 00000000..0dc64fb0
--- /dev/null
+++ b/docs/images/采样有效性校验数据流.svg
@@ -0,0 +1,120 @@
+
diff --git a/docs/算法/采样有效性校验设计说明.md b/docs/算法/采样有效性校验设计说明.md
new file mode 100644
index 00000000..95b063a6
--- /dev/null
+++ b/docs/算法/采样有效性校验设计说明.md
@@ -0,0 +1,582 @@
+# 采样有效性校验设计说明
+
+> **本文回答的问题**:`App/algo/adc_health.c` 这 200 行为什么长成现在这样——判据是怎么定下来的、
+> 每个设计选择换到了什么、代价与盲区在哪里、改它之前必须一起动什么。
+> **阅读前提**:采样节拍与乒乓双缓冲 → `docs/子系统设计/ADC采样节拍设计说明.md`;
+> 8 路电气量为何分属两片转换器 → `docs/硬件平台/ADC通道映射说明.md`。
+> **不在本文范围**:接口签名与全层约定速览在 `docs/算法/采集与算法层设计说明.md`,本文不重复其接口表;
+> 需求与验收口径登记在 `docs/算法/继电保护功能复现规划.md` §3.1(功能项 L1-6)与同文档 §3.5(L5 自检与闭锁)。
+> **配图**:三张 SVG 的生成器在 `tools/arch_diagram/`(改图 = 改脚本顶部数据后重跑,勿手改 `.svg`)。
+
+## 目录
+
+- [1. 三分钟建立心智模型](#1-三分钟建立心智模型)
+- [2. 它要解决的问题](#2-它要解决的问题)
+- [3. 在采集链路里的位置](#3-在采集链路里的位置)
+- [4. 四条设计思想](#4-四条设计思想)
+ - [4.1 判「前进量」,不判「数据合理性」](#41-判前进量不判数据合理性)
+ - [4.2 差值法:把回绕问题一次解决](#42-差值法把回绕问题一次解决)
+ - [4.3 三种位语义:电平 / 锁存 / 事件](#43-三种位语义电平--锁存--事件)
+ - [4.4 纯逻辑换取免桩单测](#44-纯逻辑换取免桩单测)
+- [5. 判定主干走读](#5-判定主干走读)
+- [6. 四类判据逐个说透](#6-四类判据逐个说透)
+ - [6.1 静默 stall:门限 100 ms 是怎么定的](#61-静默-stall门限-100-ms-是怎么定的)
+ - [6.2 丢帧 gap:只计数,不告警](#62-丢帧-gap只计数不告警)
+ - [6.3 序号倒退 backward:锁存不可自愈,代价是永久闭锁](#63-序号倒退-backward锁存不可自愈代价是永久闭锁)
+ - [6.4 错误计数增量:事件 + 锁存,但都不闭锁](#64-错误计数增量事件--锁存但都不闭锁)
+- [7. 位图、掩码与告警编排](#7-位图掩码与告警编排)
+- [8. 闭锁链路与兜底责任划分](#8-闭锁链路与兜底责任划分)
+- [9. 状态机视角:静默位的生命周期](#9-状态机视角静默位的生命周期)
+- [10. 代价与观测盲区](#10-代价与观测盲区)
+- [11. 怎么验证](#11-怎么验证)
+ - [11.1 最容易读错的一处:「未消费的那次 +1」](#111-最容易读错的一处未消费的那次-1)
+ - [11.2 台架证据](#112-台架证据)
+- [12. 现场判读手册](#12-现场判读手册)
+- [13. 自测问题(读完能不能真的懂)](#13-自测问题读完能不能真的懂)
+- [14. 改它之前必读](#14-改它之前必读)
+- [15. 相关文档](#15-相关文档)
+- [变更记录](#变更记录)
+
+---
+
+## 1. 三分钟建立心智模型
+
+**一句话**:本模块不看数据、不看寄存器,只回答一个问题——**两片转换器还在不在往前吐帧**。
+
+| 维度 | 内容 |
+|---|---|
+| 输入 | 7 个 `uint32_t` 计数快照 `adc_health_in_t`(两条车道的序号 + 四类错误计数 + 一份 `now_ms`),由调用方在**本拍 `adc_input_poll()` 之后**取好 |
+| 输出 | `adc_health_status_t`:`flags` 电平位 / `sticky` 锁存位 / `events` 当拍事件位 / `tpafe_gaps` 累计丢帧点数 / `tpafe_stall_ms` 当前静默时长 |
+| 内部状态 | 12 个 `static`(1 个基线标志 + 11 个计数、时刻、位图)= **48 B** `.bss`,无堆、无栈上缓冲 |
+| 依赖 | 只有 ``:不碰 HAL、不取数、不打日志、不建任何内核对象 |
+| 调用节奏 | 每拍一次(与 `adc_input_poll()` 同频,1 kHz),常数次整数运算 |
+
+**它不是**(先把边界钉住,后面每节都从这里出发):
+
+- **不是采集层**——取数是 `App/algo/adc_input.c` 的事,见 `docs/算法/采集与算法层设计说明.md` §3。
+- **不是告警器**——日志与留痕的分级编排在 `App/task/adc_task.c` 的 `adc_health_report()`(§7)。
+- **不是看门狗**——它判不了「消费者自己卡死」,那由 IWDG 兜,见 §8 的责任划分表。
+- **不是通道自检**——码值越界不在本层(V0.1 无标定,判据不成立,见 §4.1)。
+
+---
+
+## 2. 它要解决的问题
+
+| 故障 | 现场表现 | 没有本层时谁来发现 | 有本层后 |
+|---|---|---|---|
+| 节拍源/转换器静默 | 相量停在最后一帧的数值上,看着完全合理 | 只能靠人盯 10 s 一条的心跳日志 | 100 ms 内置电平位 → 保护直接闭锁 |
+| 消费者超拍丢帧 | 相量窗出现时间空洞,幅值/相位带系统性偏差 | 同上(`adc1_gap` / `tgap` 在心跳里) | 计数 + 当拍事件,**不告警**(§6.2) |
+| 帧序号倒退 | 序号与数据的对应关系失效,快照可能是两帧混合 | 无 | 锁存位 + 永久闭锁(§6.3) |
+| 错误计数增量 | 个别帧不可信(FRSTDATA 无跳变、HAL 报错、L1 停摆) | 无(驱动只累计不上报) | 事件 + 锁存,供事后归因(§6.4) |
+
+最关键的是第一行。**节拍停而任务照跑时,上层拿到的是旧数据而不是空数据**:`App/task/adc_task.c`
+只在 `adc_input_poll()` 返回 1 时才压环重算,于是 L2 相量**保持最后一帧**;`App/task/protect_task.c`
+每 20 ms 读到的 rms 数值合理、`phasor_get()` 返回 0(成功),定时限判据却在这条冻住的 rms 上继续
+累计延时——这比「没有数据」危险得多。**本层的全部价值就是把「数据是否新鲜」变成一个可以被
+保护的消费者直接消费的电平量**。
+
+---
+
+## 3. 在采集链路里的位置
+
+
+
+| 层 | 落点 | 在本机制里只负责什么 |
+|---|---|---|
+| 驱动 ISR | `Drivers/BSP/TPAFE5160/tpafe5160.c` | BUSY EXTI 每读完一帧 `s_frame_seq++`;无跳变帧只累计 `s_frstdata_err` |
+| L1 采集 | `App/algo/adc_input.c` | DMA 半区/整区完成计数 + 自算丢帧(次数)/停摆(连续 >=2 拍无新帧)/HAL 错误四类计数 |
+| 快照装配 | `App/task/adc_task.c` | **本拍 poll 之后**把 6 个计数 + 一份 `now_ms` 填进 `adc_health_in_t` |
+| 判定 | `App/algo/adc_health.c` | 只做判定与位图维护;不知道寄存器、不知道日志 |
+| 编排 | `App/task/adc_task.c` | 按位分级:`DBG_ERROR` / `SYSLOG_SELFTEST` / `DBG_INFO`(恢复) |
+| 闭锁 | `App/task/protect_task.c` | 每 20 ms 读 `adc_health_flags()`,命中掩码即复位元件并停判据(L5-4) |
+
+两条边界是刻意的,破坏它们的代价写在右边:
+
+1. **ISR 侧只加计数,不在中断里打日志**——FRSTDATA 异常只累计(E-44),帧序号只自增(E-45)。
+ 中断里做重活会直接推后下一拍采样,见 `docs/子系统设计/ADC采样节拍设计说明.md` §5。
+2. **判定层不许出现 HAL / 日志 / 取数**——`test/host/adc_health/adc_health_test.c` 用 `#include`
+ 原样纳入生产源文件、**零桩**跑全套。判定层里多一个 `DBG_ERROR`,主机侧就要为它写一份桩,
+ 「离线穷举故障」这条唯一的验证路径随之失效(§11)。
+
+---
+
+## 4. 四条设计思想
+
+### 4.1 判「前进量」,不判「数据合理性」
+
+| 候选判据 | 为什么不用 |
+|---|---|
+| 码值越界 / 超量程 | L1-5 通道标定未实现(`docs/算法/继电保护功能复现规划.md` §3.1),码值还没有工程量含义;且两片转换器增益不同(TPAFE 16 位双极性 / STM32 12 位单极性),不存在统一阈值 |
+| 波形合理性(方差、过零、单调) | 这是「信号对不对」,属 L5-2 的另一半;空载与零值都是合法工况,判据只能靠猜阈值 |
+| **序号是否前进** | 唯一与信号内容无关的新鲜度证据:不管输入是正弦、零值还是断线,正常态就是每拍 +1 |
+
+于是本层只看 5 个计数器加 1 个时基。这也是它能保持纯逻辑、能被穷举测试的根因——**判据越朴素,
+可测面越完整**。
+
+### 4.2 差值法:把回绕问题一次解决
+
+两侧序号都是 `uint32_t` 自由 running 计数,判定只看差值:
+
+```c
+d = in->tpafe_seq - s_tpafe_seq; /* 无符号减,自然吸收 2^32 回绕 */
+if ((int32_t)d < 0) { /* 倒退 */ } /* 借符号位区分「倒退」与「前进很多」 */
+```
+
+| 无符号差值 d | 按 `(int32_t)` 读 | 判定动作 |
+|---|---|---|
+| 0 | 0 | 没前进:静默计时继续长大(§6.1) |
+| 1 | 1 | 正常:刷新「最后前进时刻」 |
+| 2 .. 2^31-1 | 正 | 丢帧:生产者活着、消费者没跟上(§6.2) |
+| 2^31 .. 0xFFFFFFFF | 负 | 单调性破坏,锁存(§6.3) |
+
+任何「先判大小、再判是否跨过 0」的写法都要为「这次跳变是不是回绕」写特例;取有符号差值后,
+跨 0 的正常推进天然落在 `d = 1` 那一格,**特例消失**。代价是一个对称的放弃面:可判定窗为
+±2^31(1 kHz 下约 ±24.85 天),超过该跨度的倒退会被读成前进。这个场景被有意放弃——它要求
+计数器一次性被改写 24 天以上的量,真到那一步,问题已经不在这条判据的职责范围内。
+
+`now_ms` 同理用 `(int32_t)` 差值,因此 1 ms tick 的 49.7 天回绕不需要任何特殊分支;
+`TC-ADCHEALTH-009` 把「跨 0xFFFFFFFF 扫 512 拍不误报 + 回绕后冻结仍要报」钉成回归。
+
+### 4.3 三种位语义:电平 / 锁存 / 事件
+
+| 类别 | 语义 | 谁清 | 典型消费者 | 如果只留这一种 |
+|---|---|---|---|---|
+| 电平位 `flags` | 当前是否正在故障 | 条件消失自动清(stall);倒退位不清 | `protectTask` 每 20 ms 闭锁判定 | 现场偶发一次 30 ms 停摆后自愈 → 事后查不到任何痕迹 |
+| 锁存位 `sticky` | 本次开机内是否出现过 | 只有 `adc_health_init()` | 自检留痕、UI、事后归因 | 一次瞬态就会把保护永久闭锁 |
+| 事件位 `events` | 仅本次调用发生 | 出参,不落内部状态 | 编排层打日志、去重 | 20 ms 轮询的消费者大概率错过只活 1 ms 的那一拍 |
+
+三者不是冗余,而是**三种时间尺度**:ms 级(事件)/ 拍级持续(电平)/ 开机级(锁存)。
+把三种语义塞进一个 `uint32_t` 的代价是调用方要背注释;分开三张位图后,注释只剩一句
+「取哪张位图对应哪种时间尺度」——§7 的掩码表就是照这张表写的。
+
+### 4.4 纯逻辑换取免桩单测
+
+入参是「值」而不是「指针 + 接口」,因此测试可以把时间与计数**任意摆**:
+
+```c
+in.now_ms = 0xFFFFFF00u; /* 直接站在 tick 回绕边界上 */
+in.tpafe_seq = 995u; /* 人为制造倒退 */
+```
+
+这些工况在台架上要靠「拔芯片 / 改驱动」才造得出来,而且造出来也说不清落在哪一拍、
+是否恰好跨过门限。**故障注入的可复现性 + 边界的可精确性**是这一层被单独拆成纯逻辑文件的
+直接动因(完整理由见 `test/host/adc_health/adc_health_test.c` 文件头)。
+
+---
+
+## 5. 判定主干走读
+
+```mermaid
+flowchart TD
+ IN["本拍 poll 之后的计数快照"] --> NUL{"in 为空 ?"}
+ NUL -- "是:本拍整段跳过,out 一个字段都不写" --> NEXT
+ NUL -- 否 --> PRIME{"基线已建立 ?"}
+ PRIME -- "否:只记基线与两侧最后前进时刻" --> OUT
+ PRIME -- 是 --> D1["算 TPAFE 序号差值 d"]
+ D1 --> B1{"d < 0 ?"}
+ B1 -- "是:置 SEQ_BACKWARD 电平+锁存,并用新值重新对齐基线" --> D2
+ B1 -- 否 --> G1{"d > 1 ?"}
+ G1 -- "是:tgap += d-1,置当拍丢帧事件" --> A1
+ G1 -- 否 --> A1{"d >= 1 ?"}
+ A1 -- "是:刷新 TPAFE 最后前进时刻" --> D2["算 STM32 序号差值 d"]
+ A1 -- "否:不改任何状态,静默计时继续长大" --> D2
+ D2 --> A2{"STM32 侧 d < 0 或 d >= 1 ?"}
+ A2 -- "d < 0:同一个 SEQ_BACKWARD 位(两车道共用)" --> ST
+ A2 -- "d >= 1:刷新 STM32 最后前进时刻" --> ST
+ A2 -- "d = 0:本拍什么都不改" --> ST{"两侧 elapsed >= 100 ms ?"}
+ ST -- "是:置 stall 电平位并进 sticky" --> ERR["四类错误计数做增量比较"]
+ ST -- "否:清 stall 电平位(自愈路径)" --> ERR
+ ERR --> OUT["写 out 四字段与 tpafe_stall_ms"]
+ OUT --> RPT["adc_health_report() 编排层分级处置"]
+ RPT --> NEXT["等下一拍 TIM3 唤醒"] --> IN
+```
+
+| 步 | 条件 | 动作 | 关键:不动什么 |
+|---|---|---|---|
+| 1 建基线 | `s_primed == 0` | 记 6 个计数与 `now_ms`,两侧前进时刻都设为本拍 | **不判定、不产生事件**——开机首拍没有参照系(此刻 `tpafe5160_frame_seq()` 已非 0,因为 TIM3 比调度器早约 100 ms 就在生产帧) |
+| 2 TPAFE 差值 | `d < 0` / `d > 1` / `d >= 1` | 见图中三个分支 | 倒退分支**只重新对齐序号**,不动错误计数基线,避免一次倒退顺手抹掉别的证据 |
+| 3 STM32 差值 | 同一口径,但不在此累计丢帧 | 前进/倒退两态 | **不重算 gap**:L1 已有细账 `adc_input_stm32_gaps()`,两处各算一套就会打架(§6.2) |
+| 4 静默判定 | `elapsed >= 100` | 置电平位并进 sticky;否则**清**电平位 | 判定排在前进更新之后 → 同一拍内「前进」与「静默」互斥,不会出现刚推进又报静默 |
+| 5 错误增量 | 四个计数各自 `!=` 基线 | 事件位 + 锁存位,并更新基线 | 用 `!=` 不用 `>`:见 §6.4 |
+| 6 出参 | `out != 0` | 写 4 个字段 + `tpafe_stall_ms`(非静默态给 0) | 事件位只在本次出参里出现,绝不写进 `flags`/`sticky` |
+
+三条容易被漏掉的行为:
+
+1. `in == NULL` **提前返回**,`out` 保持原值(不是写全 0)——调用方复用同一个结构体时看不到被抹平的历史。
+2. `out == NULL` 仍照常更新内部状态——支持「只喂状态、不取结果」的调用法(`TC-ADCHEALTH-010` 钉住)。
+3. **首拍也会写 `out`**(全 0):`events` 在首拍恒为 0,`flags` 反映当前电平(首拍必为 0)。
+
+---
+
+## 6. 四类判据逐个说透
+
+
+
+四块面板分别是:A 健康对照、B 静默检出与自愈、C 消费者超拍、D 序号倒退。红线 =
+`ADC_HEALTH_STALL_MS` = 100 ms;下方两条条带 = 位为 1 的存续区间(上 = `flags`,
+下 = `sticky` / `events`)。
+
+### 6.1 静默 stall:门限 100 ms 是怎么定的
+
+```c
+if ((uint32_t)(int32_t)(in->now_ms - s_tpafe_adv_ms) >= ADC_HEALTH_STALL_MS) { 置位并留痕 }
+else { 清电平位 } /* 只有 stall 类有 else 分支,SEQ_BACKWARD 没有 */
+```
+
+门限的三条独立依据(缺一条就不成立):
+
+| 依据 | 内容 |
+|---|---|
+| 判据本身不需要为抖动留余量 | `d = 0` 表示生产者这一拍**没有产出新帧**。消费者慢只会让 `d > 1`,永远不会让 `d = 0`——所以门限不是为了躲抖动,而是为了把「还能解释成别的」的空间压到 0 |
+| 与工频窗对齐 | 1 kHz 下 100 拍 = 100 ms = 5 个 50 Hz 周波 = 连续 5 个全周波 DFT 窗没有任何新点 → 此刻相量必然是 5 周波之前的旧值,任何电气量判据都不可信 |
+| 与兜底层级成阶梯 | 本层 100 ms → `protectTask` 20 ms 一拍 → 最坏 120 ms 完成闭锁;再往下是看门狗层级(100 ms 抽样、连续 800 ms 缺席起停喂、IWDG 标称 2498 ms / LSI 偏快约 1701 ms 溢出)。100 ms 既不去抢看门狗的活,也不让保护拿着旧值走进动作时限 |
+
+- **边界含等号**:恰好 100 ms 那一拍即报,检测延迟 = 门限 + 最多 1 拍采样相位;
+ `TC-ADCHEALTH-003` 把 99 ms 不报 / 100 ms 必报钉成回归。
+- **恢复语义**:只清电平位,`sticky` 永久留痕——「曾经停摆过」是自检要的东西。
+- **两车道独立判定**:一片转换器停而另一片照跑,只报对应那一位(`TC-ADCHEALTH-004`)。
+ 位号分开正是为了把故障定位到「哪一侧」,合并成一位就只剩「采集坏了」。
+- 门限是**策略参数不是物理常数**:改它只需同步 §11 的三条 TC 与本文,不必动判据结构。
+
+### 6.2 丢帧 gap:只计数,不告警
+
+`d > 1` 的语义是「生产者活着,消费者没跟上」:中间那些帧已被双缓冲覆盖。判定层
+`s_tpafe_gaps += (d - 1)` 并发一个**当拍**事件位。
+
+| 车道 | 丢帧证据来源 | 单位 | 为什么这样分 |
+|---|---|---|---|
+| TPAFE | 判定层自算(驱动只暴露 `tpafe5160_frame_seq()`) | **帧点数**:按 `d - 1` 加权 | 驱动侧除帧序号外没有别的观测点,判定层手里的 seq 是唯一线索 |
+| STM32 | L1 的 `adc_input_stm32_gaps()`,判定层只看它的增量 | **次数**:每次事件 +1 | L1 在消费点上直接看得到 DMA 完成计数,细账归它;判定层再算一套就是两处判据打架 |
+
+⚠ 读心跳日志时**别把 `tgap` 与 `adc1_gap` 当同一单位**(一个是点、一个是次),
+字段口径见 `docs/子系统设计/ADC采样节拍设计说明.md` §8。
+
+为什么刻意不告警、不锁存、不闭锁:消费者超拍是**常态**。同一批台架日志里,
+启动日志突发期 `adc1_gap` 已累计 23 次,稳态每 10 s 心跳 +1~2 次(`tgap` 1 → 2)。
+这类事件若进 `DBG_ERROR` + `SYSLOG_SELFTEST`,开机噪声会把真故障完全盖掉,还会持续吃掉
+32 KB 的 `selftest.log` 配额。真正要紧张的信号是组合而非事件本身:`tgap` 持续快速上涨
+**且** `[PROF]` 行的整拍耗时超过 1 ms → 相量窗里出现时间空洞(探针字段与基线见
+`docs/子系统设计/看门狗设计说明.md` §11.4)。
+
+### 6.3 序号倒退 backward:锁存不可自愈,代价是永久闭锁
+
+`d < 0` 时同时置 `flags` 与 `sticky` 的 `ADC_HF_SEQ_BACKWARD`,然后**用新序号重新对齐基线**,
+且**没有清除分支**。三个动作各有各的理由:
+
+| 动作 | 理由 |
+|---|---|
+| 同时置电平位 | 让 `protectTask` 立刻闭锁——单调性破坏意味着帧序号与缓冲内容的对应关系不再可信,快照可能是两帧混合,此时任何电气量判据都不可信 |
+| 置位后重新对齐 | 若不重新对齐,基线永远大于当前序号,之后的正常推进会长期被读成异常,`tgap` 与静默计时同时被污染 |
+| 不做自愈清除 | 「后面恢复正常」不能证明计数器已经可信;能证明它可信的只有重启或显式 `adc_health_init()`。取舍方向与保护一致:**宁拒动不误动** |
+
+现实里造成倒退的三条路径:驱动改了自增时机;计数器被别的代码写坏;**运行期重跑了会清零
+计数的 `*_init()` 而没有同时 `adc_health_init()`**——`App/algo/adc_input.c` 的 init 会把
+`s_dma_seq` 归零,判定层看到的就是从大值跳回小值,**第一拍即判倒退并永久闭锁**。当前启动
+序列里两个 init 都只执行一次(驱动在内核前、判定在任务入口),因此撞不上;改这条链时它是
+头号陷阱(§14)。
+
+倒退还有一条必须知道的**可判定窗**边界:`(int32_t)` 解读只在 ±2^31 内单调,1 kHz 下约
+±24.85 天;倒退幅度超过这个跨度会被读成前进(§4.2 已说明这是有意放弃的场景)。
+
+### 6.4 错误计数增量:事件 + 锁存,但都不闭锁
+
+| 计数(快照字段) | 生产者 | 正常增量 | 出现增量意味着 | 事件位 | 锁存位 | 闭锁 |
+|---|---|---|---|---|---|---|
+| `tpafe_frst_err` | TPAFE BUSY EXTI | 0 | 该帧 8 个通道不可信(FRSTDATA 无跳变,E-44) | `ADC_HE_FRSTDATA` | `ADC_HF_FRSTDATA` | 否 |
+| `stm32_errs` | L1 消费侧 | 0 | 连续 >=2 拍无新帧,或 DMA 挂载失败 | `ADC_HE_STM32_ERR` | `ADC_HF_STM32_ERR` | 否 |
+| `stm32_gaps` | L1 消费侧 | 少量 | 超拍丢帧(§6.2) | `ADC_HE_STM32_GAP` | 不入锁存 | 否 |
+| `stm32_hw_errs` | HAL ADC/DMA 错误回调 | 0 | 硬件层面报过错 | `ADC_HE_HW_ERR` | `ADC_HF_HW_ERR` | 否 |
+
+- **用 `!=` 而不是 `>`**:① 驱动侧一次 `+N` 的跳变不会因为「已经大于基线」被吞掉;
+ ② 计数器被归零(例如重跑 init)也能立刻看到,而不是等它慢慢爬回原值。
+- **`uint16` → `uint32` 的放宽**:`tpafe5160_frstdata_err()` 是 `uint16_t` 且在 0xFFFF 饱和,
+ 快照字段放宽到 `uint32_t` 是为了不让「截断」变成一条假增量。
+- **为什么不闭锁**:一次性事件痕迹不等于「当前测量不可信」;闭锁权只交给电平类
+ (判据见 `App/task/protect_task.c` 的 `PROT_HF_LOCK_MASK`)。
+- **两层如何互补**:HAL 报错本身不足以停保护,但它之后大概率紧跟 `STM32_STALL` ——
+ 锁存位负责事后把「先是 hw 报错、随后静默」这条因果链复原出来。
+
+---
+
+## 7. 位图、掩码与告警编排
+
+
+
+| 位 | 名称 | 类别 | 置位条件 | 清除条件 |
+|---|---|---|---|---|
+| 0 | `ADC_HF_TPAFE_STALL` | 电平 | TPAFE 车道 elapsed >= 100 ms | elapsed < 100 ms |
+| 1 | `ADC_HF_STM32_STALL` | 电平 | STM32 车道 elapsed >= 100 ms | elapsed < 100 ms |
+| 2 | `ADC_HF_SEQ_BACKWARD` | 电平 + 锁存 | 任一侧 `(int32_t)d < 0` | 仅 `adc_health_init()` |
+| 3 | `ADC_HF_FRSTDATA` / `ADC_HE_FRSTDATA` | 锁存 + 事件 | `tpafe_frst_err` 有变化 | 仅 `adc_health_init()` |
+| 4 | `ADC_HF_STM32_ERR` / `ADC_HE_STM32_ERR` | 锁存 + 事件 | `stm32_errs` 有变化 | 仅 `adc_health_init()` |
+| 5 | `ADC_HF_HW_ERR` / `ADC_HE_HW_ERR` | 锁存 + 事件 | `stm32_hw_errs` 有变化 | 仅 `adc_health_init()` |
+| 6 | `ADC_HE_TPAFE_GAP` | 仅事件 | 本次 TPAFE `d > 1` | 出参,不落状态 |
+| 7 | `ADC_HE_STM32_GAP` | 仅事件 | `stm32_gaps` 有变化 | 出参,不落状态 |
+| 8..31 | 保留 | — | 未使用 | — |
+
+**位号对齐是刻意设计**:bit3 / bit4 / bit5 在 `events` 与 `sticky` 里编号相同,编排层因此可以
+拿事件位直接去操作「已留痕位图」,一行搞定每类每次开机只留痕一次:
+
+```c
+if (((serious | rise) & ~s_health_logged) != 0u) { /* 位图当去重键 */
+ (void)syslog_post(SYSLOG_SELFTEST, "adc sampling fault");
+ s_health_logged |= (serious | rise);
+}
+```
+
+代价也随之而来:新增一个故障位必须一次想清楚**三张位图 + 编排层记忆位图**的归属,
+漏一格就可能出现「事件报了但永远不去重」或「锁存位与事件位串位」。规则写在 §14。
+
+编排层的三级策略(`adc_health_report()`,`App/task/adc_task.c`):
+
+| 级别 | 触发 | 动作 | 为什么这样分 |
+|---|---|---|---|
+| 严重事件 | `events` 命中 bit3/bit4/bit5 | 每次都 `DBG_ERROR`(带 `events` 与 `sticky`) | 这三类计数增量恒应为 0,出现即故障,且不会连续刷屏 |
+| 电平上升沿 | `flags & ~prev` | `DBG_ERROR`(带 `stall_ms` 与 `tgap`) | 只报一次沿,持续 5 s 的停摆也只有一条错误行;`stall_ms` 直接回答「停多久」 |
+| 电平下降沿 | `prev & ~flags` | `DBG_INFO`(恢复) | 「恢复」必须可见,否则只看到一次错误、无法判断现在还在不在故障 |
+| 自检留痕 | 上述两类首次出现 | `SYSLOG_SELFTEST` 一条,按位去重 | 持续故障若逐条写会把 32 KB `selftest.log` 写满,并淹没别的模块 |
+| 丢帧 | bit6 / bit7 | **不处置** | §6.2 的实测理由 |
+
+两个容易忽略的细节:
+
+- 控制台日志受 `dbg_console_writer_ready()` 门控(写者注册前直发会与 `defaultTask` 的存储初始化
+ 日志字节级交错),但 **`SYSLOG_SELFTEST` 不受门控**——内核前有 `s_preboot` 缓冲,
+ 开机窗口里的故障痕迹不能丢。
+- 每条 `DBG_*` 约 90 B Flash + 216 B 栈(每个调用点内联行缓冲与时间戳缓冲,见根 `AGENTS.md`
+ 平台约束)。**「判定层不打日志」不只为了可测性,也因为日志在 1 kHz 判定路径上是奢侈品**:
+ 编排把成本集中到少数几个沿上。
+
+---
+
+## 8. 闭锁链路与兜底责任划分
+
+`App/task/protect_task.c` 每 20 ms 读一次电平位:
+
+```c
+#define PROT_HF_LOCK_MASK (ADC_HF_TPAFE_STALL | ADC_HF_STM32_STALL | ADC_HF_SEQ_BACKWARD)
+```
+
+| 时刻 | 事件 |
+|---|---|
+| t = 0 | 生产者最后一次让序号前进(此后无新帧) |
+| t = 100 ms | 判定层置 stall 电平位并进 `sticky`;编排层打一条 `DBG_ERROR` + 一条自检留痕 |
+| t <= 120 ms | `protectTask` 下一拍命中掩码 → `prot_elm_init()` 复位全部元件 + 本拍不跑判据;`SOE_T_SAMPLE_BLOCK`(value = `hf & PROT_HF_LOCK_MASK`,能反查是哪一位) |
+| 恢复时 | 序号重新前进 → 电平位清 → 下一拍自动复归,`SOE_T_SAMPLE_RESUME` + `DBG_INFO` |
+
+**谁兜住什么**——这张表是本层与看门狗的分界,也是最常见的误读点:
+
+| 故障位置 | 谁先发现 | 多久 | 结果 |
+|---|---|---|---|
+| TIM3 节拍源停 | **不是本层**:`adcTask` 永久阻塞在 `osThreadFlagsWait`,没人跑判定 | 100 ms 抽样 + 连续 800 ms 缺席起停喂 | IWDG 溢出复位(标称 2498 ms,LSI 偏快约 1701 ms) |
+| TPAFE BUSY EXTI 停 | 本层 `ADC_HF_TPAFE_STALL` | 100 ms | 闭锁保护(任务仍活着,不复位) |
+| ADC1 / DMA 停 | 本层 `ADC_HF_STM32_STALL`,L1 的 `stm32_errs` 增量同时给出事件 | 100 ms | 闭锁 + 留痕 |
+| HAL 报错 | L1 计数 → 本层 `HE_HW_ERR` | 下一拍 | 事件 + 锁存,不闭锁 |
+| `adcTask` 超拍 | 本层 `HE_TPAFE_GAP` + L1 `adc1_gap` | 当拍 | 只计数 |
+| 帧序号倒退 | 本层 `SEQ_BACKWARD` | 当拍 | 闭锁且不自愈,直到复位 |
+
+一句话概括本层的**能力边界**:它判的是「生产者相对消费者是否前进」,因此**判不了消费者自己**
+是否还活着。后者必须换一个独立机制(IWDG)来实现——两层不能互相替代,也不要指望把门限调小
+就能替代它。
+
+**如实登记的已知窗口**:TIM3 停摆到 IWDG 复位之间(最坏约 800 ms + 2498 ms ≈ 3.3 s,标称值),
+`protectTask` 仍在 20 ms 轮询、电平位却还没人置(判定随消费者一起停了),相量保持旧值参与判据。
+V0.1 没有出口硬件,后果限于遥信与日志;真要压这个窗口,正解是给相量侧加「帧年龄」判据
+(让 `prot_elm` 自己看数据新鲜度),而不是把 stall 门限调小。
+
+---
+
+## 9. 状态机视角:静默位的生命周期
+
+```mermaid
+stateDiagram-v2
+ ADVANCING : elapsed = 0,flags 为 0
+ TIMING : 0 < elapsed < 100 ms,仍不报
+ STALLED : TPAFE_STALL = 1(同时进 sticky)
+ [*] --> ADVANCING : 首拍建基线
+ ADVANCING --> TIMING : 本拍 d = 0
+ TIMING --> ADVANCING : 序号重新前进
+ TIMING --> STALLED : elapsed 达 100 ms(含等号)
+ STALLED --> ADVANCING : 序号重新前进
电平位清,sticky 永久留痕
+ STALLED --> STALLED : 继续无前进,elapsed 每拍 +1
+ note right of STALLED
+ SEQ_BACKWARD 不在这台状态机里:
+ 它是前进 -> 已破坏的终态,无自愈路径
+ end note
+```
+
+| 状态 | `flags` | `sticky` | 编排层可见动作 |
+|---|---|---|---|
+| 前进中 | 0 | 不变 | 若上一拍为 1 → 打一条恢复 `DBG_INFO` |
+| 计时中 | 0 | 不变 | 无 |
+| 停摆 | 1 | 1(首次置位时留痕一次) | 上升沿 `DBG_ERROR` + 自检一条 |
+
+倒退位是**另一台状态机**:前进 → 已破坏(终态),没有任何路径回到前进——这正是 §6.3 的取舍。
+把两台机器画在一张图上是常见的误读来源(会让人以为 `SEQ_BACKWARD` 也会自愈),
+所以 §14 的清单要求:任何新位都必须先写明它属于哪一类。
+
+---
+
+## 10. 代价与观测盲区
+
+| 项 | 实测/核算 | 说明 |
+|---|---|---|
+| 静态 RAM | 48 B(11 个 `uint32_t` = 44 B + 1 个 `uint8_t` 与对齐 4 B) | 判定层的全部驻留成本;无堆分配(自研代码禁 `malloc`,见根 `AGENTS.md` 平台约束) |
+| 每拍 CPU | 约 20 条以内的整数加减与比较;无乘除、无浮点、无数组访问 | 与 1 kHz 节拍同频,量级远小于一次 `adc_input_poll()`(实测 8 us) |
+| 栈 | 判定本体只有 3 个局部量;最坏链在编排层的日志调用尾 | 判定层的日志尾是 `adcTask` 这条链加深的起点,曾直接触发抬栈一档;当前值与余量台账见 `docs/内存管理/RAM使用分析与优化方案.md` §1.4 |
+| Flash | 主要花在编排层的日志调用点,判定层本身很小 | 每条 `DBG_*` 约 90 B Flash,故新增位**不要**顺手加日志 |
+
+**观测盲区**(如实登记,别在别处再假设它们存在):
+
+1. `adc_health_status_t` 只给 `tpafe_stall_ms`,**STM32 侧没有对应的停滞时长字段**——
+ 只知道「STM32 静默了」,不知道「静默多久」。
+2. `tpafe_stall_ms` 只在电平位置位时给值,正常态恒为 0,因此它不能当「距上次前进的时间」趋势量用。
+3. **观察者死亡不可自证**(§8 的责任矩阵),判定层的存活依赖 `adcTask`。
+4. `tgap` 与 `adc1_gap` 单位不同(点 / 次,§6.2),跨字段做除法或对比会得出错误结论。
+5. 倒退的可判定窗是 ±2^31 拍(约 ±24.85 天),窗口外的倒退读作前进(§6.3)。
+
+---
+
+## 11. 怎么验证
+
+**验证分工先说清**:台架 `@test` 只能证明「健康时不误报」(要求零 `[ERR]`),
+**证不了「故障时必报」**——静默与倒退在健康台架上不可复现(复现 = 拔芯片或改驱动)。
+「必报」这一半完全由主机侧单测承担,这就是本层必须是纯逻辑的原因(§4.4)。
+
+| 判据 | 用例 | 钉住的具体边界 |
+|---|---|---|
+| 首拍只建基线 | `TC-ADCHEALTH-001` | 开机时序号已非 0 也不误报;随后 1 ms 无前进不算静默 |
+| 健康不误报 | `TC-ADCHEALTH-002` | 1 kHz 连扫 2000 拍,`flags` / `events` / `sticky` 全程为 0 |
+| TPAFE 静默 | `TC-ADCHEALTH-003` | 99 ms 不报 / 100 ms 必报(门限含等号);`sticky` 留痕;恢复只清电平位 |
+| STM32 静默 | `TC-ADCHEALTH-004` | 两车道独立:TPAFE 正常时只报 STM32 位 |
+| TPAFE 丢帧 | `TC-ADCHEALTH-005` | `d = 3` → `tpafe_gaps` 恰好 +2;事件只活一拍;计数是累计量 |
+| STM32 丢帧 | `TC-ADCHEALTH-006` | 计数增量 → 事件;**不进锁存**(超拍不是故障) |
+| 序号倒退 | `TC-ADCHEALTH-007` | 电平 + 锁存同时置位;随后 50 拍正常推进仍不清 |
+| 三类错误增量 | `TC-ADCHEALTH-008` | 事件位与锁存位**位号一致**;计数不变则不重复触发 |
+| tick 回绕 | `TC-ADCHEALTH-009` | 跨 0xFFFFFFFF 扫 512 拍不误报;回绕后冻结仍能检出 |
+| 空指针守卫 | `TC-ADCHEALTH-010` | `in == NULL` 不动 `out`;`out == NULL` 仍更新内部状态 |
+| 幂等复位 | `TC-ADCHEALTH-011` | 故障后 `adc_health_init()` 全清,且重新建基线干净 |
+
+合计 **11 组用例 / 31 条断言**(用例清单与被测源文件同一处:
+`test/host/adc_health/adc_health_test.c` 文件头)。跑法:
+
+```powershell
+# 直接跑(MSVC 离线编译真实源文件,毫秒级)
+& '.venv\Scripts\python.exe' test/host/adc_health/run_unit_test.py
+# 走门禁(改动被测源、头、编排文件或测试用例时自动触发)
+& '.venv\Scripts\python.exe' -m pre_commit run adc-health-unit-test --all-files
+```
+
+门禁的触发文件集是 `App/algo/adc_health.*` + `App/task/adc_task.c` + `test/host/adc_health/`,
+**即改判定层或改编排层都会跑这套单测**——编排层的位图语义也是它的被保护对象之一。
+
+### 11.1 最容易读错的一处:「未消费的那次 +1」
+
+测试里普遍写成 `for (...) { check(&in); seq++; now++; }`:**递增发生在 check 之后**,
+于是本轮循环末尾那次 `+1` 还没被任何一次 check 看过。下一段(冻结场景)的第一拍会先
+消费掉它,看见 `d = 1` → 顺带把静默计时起点重置为该拍的 `now_ms`。结果:
+
+| 用例 | 表面写法 | 实际含义 |
+|---|---|---|
+| `TC-ADCHEALTH-003` | 100 拍不报,再一拍报 | 冻结后 elapsed 依次是 0..99(不报),下一拍正好 100(报) |
+| `TC-ADCHEALTH-004` / `009` | 需要 101 拍才报 | 同样先吃掉那 1 拍,elapsed 从 0 起算,第 101 次 check 才是 100 ms |
+
+读这些 TC **不要按循环次数理解门限**,要按「本次 check 传入的 `now_ms` 与当时的
+`*_adv_ms` 之差」理解。真实系统里消费者每拍一次、生产者每拍前进一帧 → elapsed 恒为 0;
+生产者停摆后 elapsed 从 1 开始每拍 +1,第 100 拍置位。
+
+### 11.2 台架证据
+
+`docs/测试说明/boot_log_data/20261011_prod_adc_health_boot_log.txt`:两条心跳行
+(`adc1_err=0 adc1_hw=0 adc1_gap=23 → 25`、`tgap=1 → 2`、`frames` 每 10 s +10000)
+加上全程零 `[ERR]` —— 即「健康不误报」+「gap 常态被正确压住」。
+
+---
+
+## 12. 现场判读手册
+
+| 日志行(原样) | 含义 | 下一步查什么 |
+|---|---|---|
+| `[ADC_TASK] health: flags=0x1 stall_ms=137 tgap=2` | TPAFE 静默上升沿,本拍已停 137 ms | 先看 `seq` 是否还在涨(10 s 心跳):不涨 = 生产者侧;再确认 TIM3 与 CONVST |
+| `[ADC_TASK] health: flags=0x2 ...` | STM32 静默上升沿 | 看 `adc1_seq` / `adc1_hw`:`adc1_hw` 有增量 → DMA/ADC 报错在先 |
+| `[ADC_TASK] health: flags=0x4 ...` | 序号倒退 | 直接找谁改了计数器(§6.3 三条路径);此位不会自愈,只能复位 |
+| `[ADC_TASK] sampling fault: events=0x18 sticky=0x18` | 错误计数增量(这里 = bit4 + bit3) | `sticky` 与 `events` 同值 = 首次出现;`sticky` 更大 = 之前还出过别的类 |
+| `[ADC_TASK] health: recovered flags=0x0 sticky=0x3` | 静默自愈 | 别把「恢复」读成「没问题」:`sticky=0x3` 说明两侧都停过 |
+| `[PROT] sampling FAULT -> protection blocked (hf=0x00000001)` | 闭锁真的生效了 | 括号里就是命中位;此后保护不判据,元件状态全复位 |
+| `[PROT] sampling recovered -> protection resumed` | 闭锁解除 | 与上一条配对看时长 |
+| 心跳 `tgap=` 持续上涨 | 消费者超拍 | 看 `[PROF]` 的 `adcm` 是否 > 1 ms(`docs/子系统设计/看门狗设计说明.md` §11.4) |
+
+两个必须记住的读法:
+
+- **`sticky` 是「曾经」,`flags` 是「现在」**。只看 `sticky` 会把历史故障读成当前故障;
+ 只看 `flags` 会漏掉「已经自愈但其实停过」。SOE 反查同理:类型 5 的 value 是触发瞬间的
+ `hf & PROT_HF_LOCK_MASK`,解码用 `test/host/soe/decode_soe.py`。
+- **看到 IWDG 复位就不要再看 `sticky`**:复位后全部状态清零,`sticky` 只覆盖本次开机。
+
+三个典型剧本:
+
+| 剧本 | 判定路径 | 结论 |
+|---|---|---|
+| 现场报「保护退出」 | 查 `[PROT] sampling FAULT` 的 `hf` → 回到 `flags` 上升沿行看 `stall_ms` | `hf=0x4` → 计数器被动过,查软件;`hf=0x1/0x2` → 采样侧真停,查节拍与转换器供电 |
+| 心跳里 `idle` / `skip` 在涨但无 `health` 行 | 那不是本层的判据(本层只看序号) | 属 L1 相位伪影或通道首次未就绪,见 `docs/子系统设计/ADC采样节拍设计说明.md` §8 |
+| 偶发一条 `health` + 一条 `recovered` | 一次瞬态静默(几十 ms 到几百 ms) | 查同窗口的日志突发、NAND 擦写、FTP 传输等长耗时窗口(`docs/子系统设计/看门狗设计说明.md` §4.3) |
+
+---
+
+## 13. 自测问题(读完能不能真的懂)
+
+| 问题 | 答案要点 |
+|---|---|
+| 为什么 `d > 1` 绝不能算静默? | 它证明生产者**还在**产出,只是被覆盖了;算静默就会把「自己慢」误判成「硬件坏」并闭锁保护 |
+| 为什么 stall 闭锁而 FRSTDATA 增量不闭锁? | 前者是「当前测量不可信」的电平条件,后者是一次性事件痕迹;闭锁权只给电平类 |
+| 为什么 `SEQ_BACKWARD` 不自愈,代价是什么? | 恢复正常不能证明计数器可信;代价是永久闭锁直到复位,IWDG 也不会救(任务都活着) |
+| tick 回绕要不要特殊处理? | 不要,`(int32_t)` 差值天然吸收;被放弃的只有 ±2^31 之外的倒退 |
+| 判定层能不能顺手加一条日志? | 不能,见 §4.4:免桩单测立刻失效,且每条约 90 B Flash + 216 B 栈 |
+| 消费者自己卡死谁能发现? | 不是本层;`adcTask` 缺席 → 看门狗 800 ms 起停喂 → IWDG 复位 |
+| 首拍为什么不判定? | 此刻序号已非 0(TIM3 早于调度器约 100 ms 启动),没有「上一拍」就没有参照系 |
+
+---
+
+## 14. 改它之前必读
+
+| 想改什么 | 必须一起动 |
+|---|---|
+| `ADC_HEALTH_STALL_MS` | `TC-ADCHEALTH-003` / `004` / `009` 的拍数假设、本文 §6.1 的推导与 §8 的闭锁时间预算、`docs/算法/采集与算法层设计说明.md` §4 的门限描述 |
+| 新增一个故障位 | 一次定清四件事:① 电平还是锁存还是仅事件;② 是否与事件位**位号对齐**;③ 进不进 `PROT_HF_LOCK_MASK`;④ 编排层 `s_health_logged` 去重位段够不够(bit0..bit5 已被占用) |
+| 改判定顺序 | §5 的走读表与流程图;尤其「前进更新必须在静默判定之前」,反过来会出现同一拍既刷新又报静默 |
+| 动驱动侧计数器(自增时机、清零点、位宽) | 必须同时复核 §6.3 的倒退路径与 §6.4 的 `!=` 增量口径;位宽一变就可能造出假增量 |
+| 把任何 `*_init()` 挪到运行期重跑 | **必须同时 `adc_health_init()`**——`App/algo/adc_input.c` 的 init 会把 `s_dma_seq` 归零,不配套就第一拍报 `SEQ_BACKWARD` 并永久闭锁 |
+| 在判定层引入 HAL / 日志 / 内核 API | 先回答「主机单测怎么免桩跑」;答不上来就别改 |
+
+三条禁令(都对应已写死的机制,不是风格问题):
+
+1. 判定层**不取数、不打日志、不碰寄存器**——否则 §11 的整条验证路径作废。
+2. 判定层**不用浮点、不做除法**——它在 1 kHz 路径上,且主机侧要能逐拍精确复现每个分支。
+3. 位图**不复用已冻结位号**:bit0..bit7 已分配,新位从 bit8 起;`events` 与 `sticky` 若要共享
+ 语义就必须共享位号(§7 的去重写法依赖这点)。
+
+**当前事实登记**(改这段链时会撞上):产品启动路径上 `adc_health_init()` 并未被调用——
+`adc_task_init()` 目前在仓库里没有调用点(只有 `adc_task_func` 被 `Src/freertos.c` 建为任务)。
+判定仍然正确,因为判定层状态全在 `.bss`(复位即 0),而**首拍只建基线**这条设计让 init 变成
+可选动作。但「init 可选」不等于「init 无用」:运行期任何需要重建参照系的场合都必须显式调用。
+
+---
+
+## 15. 相关文档
+
+- `docs/算法/采集与算法层设计说明.md` §4 —— 本模块在全层中的位置与接口速览(本文不重复)
+- `docs/子系统设计/ADC采样节拍设计说明.md` §8 —— 心跳字段全表与判读(`tgap` 所在行)
+- `docs/子系统设计/看门狗设计说明.md` §4.3 —— 长耗时窗口的三类处置(瞬态静默的候选来源)
+- `docs/子系统设计/看门狗设计说明.md` §11.4 —— `[PROF]` 探针字段与耗时基线(判「超拍」要看它)
+- `docs/硬件平台/ADC通道映射说明.md` —— 语义映射唯一权威口径(与「哪一侧静默」直接相关)
+- `docs/算法/继电保护功能复现规划.md` §3.1 —— L1 功能项与验收口径(本层 = L1-6 行)
+- `docs/算法/继电保护功能复现规划.md` §3.5 —— L5 自检与闭锁(本层供数的 L5-4 在此登记)
+- `docs/内存管理/RAM使用分析与优化方案.md` §1.4 —— `adcTask` 栈最坏链台账
+- `App/algo/README.md` —— 目录级文件清单与构建测试入口
+
+---
+
+## 变更记录
+
+| 日期 | 变更 | 说明 |
+|---|---|---|
+| 2026-10-21 | 新建 | 首版单文件深设计:判据推导、四种位语义、闭锁与兜底责任矩阵、验证分工与现场判读手册;新增三张配图生成器(`tools/arch_diagram/gen_adc_health_flow_svg.py`、`gen_adc_health_bitmap_svg.py`、`gen_adc_health_timeline_svg.py`)与产物 SVG |
diff --git a/docs/算法/采集与算法层设计说明.md b/docs/算法/采集与算法层设计说明.md
index bbe4cc50..c58937c7 100644
--- a/docs/算法/采集与算法层设计说明.md
+++ b/docs/算法/采集与算法层设计说明.md
@@ -138,6 +138,9 @@ flowchart TD
`SYSLOG_SELFTEST`(每类每次开机一次,防刷屏写满 `selftest.log`);下降沿 `DBG_INFO`
("恢复"必须可见,否则只看到一次错误无从判断是否还在)。
+**深读**:本模块的判据推导(100 ms 门限的三条依据)、位号对齐的收益与代价、闭锁与兜底的责任
+划分、观测盲区、验证分工与现场判读手册 → `docs/算法/采样有效性校验设计说明.md`。
+
---
## 5. L2 输入 `sample_ring.c`
@@ -406,6 +409,7 @@ adcTask 静态最坏链与余量的**台账在** `docs/内存管理/RAM使用分
## 13. 相关文档
- 目录索引与构建 / 测试入口:`App/algo/README.md`
+- L1-6 判定层深设计(判据推导 / 盲区 / 验证分工):`docs/算法/采样有效性校验设计说明.md`
- 分层与功能项清单(L1~L5 的属主登记处):`docs/算法/继电保护功能复现规划.md`
- 通道映射权威口径:`docs/硬件平台/ADC通道映射说明.md`
- 采样节拍与 DMA 搬运:`docs/子系统设计/ADC采样节拍设计说明.md`
diff --git a/tools/arch_diagram/gen_adc_health_bitmap_svg.py b/tools/arch_diagram/gen_adc_health_bitmap_svg.py
new file mode 100644
index 00000000..67da3320
--- /dev/null
+++ b/tools/arch_diagram/gen_adc_health_bitmap_svg.py
@@ -0,0 +1,227 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""生成 docs/images/采样有效性位图与掩码.svg —— L1-6 出口位图与消费者掩码对照(纯标准库)。
+
+为什么不用 Mermaid:本图的核心信息是**位号对齐**(同一 bit 在三张位图与四个消费者掩码
+里落在同一列),必须等宽网格逐列对齐;Mermaid 无法控制列位。
+
+用法(仓库根目录,任意 python3):
+ python tools/arch_diagram/gen_adc_health_bitmap_svg.py
+输出:
+ docs/images/采样有效性位图与掩码.svg
+改内容时:编辑本文件顶部的 GROUPS 数据后重跑。
+"""
+import html
+import os
+import xml.etree.ElementTree as ET
+
+ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+OUT = os.path.join(ROOT, "docs", "images", "采样有效性位图与掩码.svg")
+
+W = 1580
+PAD = 40
+TITLE_FONT = 21
+SUB_FONT = 13
+ROW_LABEL_FONT = 12.5
+BIT_FONT = 11
+TAG_FONT = 10.5
+NOTE_FONT = 11.5
+HEAD_FONT = 12.5
+
+LABEL_X, LABEL_W = PAD, 262
+GRID_X = PAD + LABEL_W + 8
+BIT_W, BIT_H, BIT_GAP = 112, 46, 4
+NOTE_W = 340
+GROUP_GAP = 26
+
+FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif"
+INK, MUTED = "#0f172a", "#64748b"
+C_LEVEL = ("#0d9488", "#f0fdfa") # 电平(可自愈)
+C_LATCH = ("#e11d48", "#fff1f2") # 锁存(不自愈)
+C_EVENT = ("#d97706", "#fffbeb") # 仅边沿事件
+C_MASK = ("#7c3aed", "#f5f3ff") # 消费者掩码命中
+C_NONE = ("#94a3b8", "#f8fafc") # 未使用 / 不适用
+
+# ----------------------------- 数据(改这里) -----------------------------
+# kind: level / latch / event / mask / none
+GROUPS = [
+ dict(head="判定层写出的三张位图(adc_health.c)", rows=[
+ dict(label="flags 电平位", sub="adc_health_flags(),当前活跃",
+ note="protectTask 每 20 ms 读它做 L5-4 闭锁;不看事件位",
+ bits=[("bit0", "TPAFE_STALL", "level"), ("bit1", "STM32_STALL", "level"),
+ ("bit2", "SEQ_BACKWARD", "latch"), ("bit3", "—", "none"),
+ ("bit4", "—", "none"), ("bit5", "—", "none"),
+ ("bit6", "—", "none"), ("bit7", "—", "none")]),
+ dict(label="sticky 锁存位", sub="adc_health_sticky(),直到 init 才清",
+ note="只留痕不闭锁:曾停摆/曾出错必须可追溯",
+ bits=[("bit0", "TPAFE_STALL", "latch"), ("bit1", "STM32_STALL", "latch"),
+ ("bit2", "SEQ_BACKWARD", "latch"), ("bit3", "FRSTDATA", "latch"),
+ ("bit4", "STM32_ERR", "latch"), ("bit5", "HW_ERR", "latch"),
+ ("bit6", "不入锁存", "none"), ("bit7", "不入锁存", "none")]),
+ dict(label="events 事件位", sub="adc_health_check() 出参,仅当拍有效",
+ note="adc_health_report() 的唯一输入:按位分三级处置",
+ bits=[("bit0", "—", "none"), ("bit1", "—", "none"), ("bit2", "—", "none"),
+ ("bit3", "FRSTDATA", "event"), ("bit4", "STM32_ERR", "event"),
+ ("bit5", "HW_ERR", "event"), ("bit6", "TPAFE_GAP", "event"),
+ ("bit7", "STM32_GAP", "event")]),
+ ]),
+ dict(head="消费者侧的掩码与记忆位图", rows=[
+ dict(label="PROT_HF_LOCK_MASK", sub="protect_task.c 编译期常量",
+ note="命中即 prot_elm_init() 复位元件 + 本拍不 eval;SOE 类型 5/6",
+ bits=[("bit0", "闭锁", "mask"), ("bit1", "闭锁", "mask"),
+ ("bit2", "闭锁", "mask"), ("bit3", "不闭锁", "none"),
+ ("bit4", "不闭锁", "none"), ("bit5", "不闭锁", "none"),
+ ("bit6", "—", "none"), ("bit7", "—", "none")]),
+ dict(label="serious 严重事件", sub="adc_task.c 的 adc_health_report()",
+ note="三类计数增量恒应为 0 → 每次 DBG_ERROR + 自检留痕",
+ bits=[("bit0", "—", "none"), ("bit1", "—", "none"), ("bit2", "—", "none"),
+ ("bit3", "FRSTDATA", "mask"), ("bit4", "STM32_ERR", "mask"),
+ ("bit5", "HW_ERR", "mask"), ("bit6", "—", "none"),
+ ("bit7", "—", "none")]),
+ dict(label="gap 丢帧事件", sub="同一函数里的丢帧分支",
+ note="刻意不告警:超拍是常态(启动突发期即累计 23 次),只累计",
+ bits=[("bit0", "—", "none"), ("bit1", "—", "none"), ("bit2", "—", "none"),
+ ("bit3", "—", "none"), ("bit4", "—", "none"), ("bit5", "—", "none"),
+ ("bit6", "TPAFE_GAP", "mask"), ("bit7", "STM32_GAP", "mask")]),
+ dict(label="s_health_logged", sub="编排层记忆位图(每类一次留痕)",
+ note=" |= (serious | rise):用同一位号把 SYSLOG 压成每类每次开机一条",
+ bits=[("bit0", "记过", "mask"), ("bit1", "记过", "mask"),
+ ("bit2", "记过", "mask"), ("bit3", "记过", "mask"),
+ ("bit4", "记过", "mask"), ("bit5", "记过", "mask"),
+ ("bit6", "不记", "none"), ("bit7", "不记", "none")]),
+ ]),
+]
+
+FOOT = ("bit3 / bit4 / bit5 在 events 与 sticky 中刻意用同一位号 → 消费者能把事件位直接"
+ "套在锁存位图上做每类一次留痕;bit8 以上全 32 位保留未用。"
+ "详见 docs/算法/采样有效性校验设计说明.md")
+
+KIND_COLOR = dict(level=C_LEVEL, latch=C_LATCH, event=C_EVENT, mask=C_MASK, none=C_NONE)
+
+
+# ----------------------------- 工具函数 -----------------------------
+def esc(s):
+ return html.escape(s, quote=True)
+
+
+def text_w(s, fs):
+ return sum(fs * (1.0 if ord(ch) >= 0x2E80 else 0.58) for ch in s)
+
+
+WARN = []
+
+
+def fit(s, fs, avail, where):
+ if text_w(s, fs) > avail:
+ WARN.append("%s: %.0f > %.0f [%s]" % (where, text_w(s, fs), avail, s))
+
+
+# ----------------------------- 生成 -----------------------------
+def build():
+ body = []
+ addb = body.append
+
+ for g in GROUPS:
+ fit(g["head"], HEAD_FONT, W - 2 * PAD, "group head")
+ for r in g["rows"]:
+ fit(r["label"], ROW_LABEL_FONT, LABEL_W - 16, "row label")
+ fit(r["sub"], TAG_FONT, LABEL_W - 16, "row sub")
+ for ln in wrap(r["note"], NOTE_FONT, NOTE_W - 24):
+ fit(ln, NOTE_FONT, NOTE_W - 24, "row note")
+ for b, tag, kind in r["bits"]:
+ fit(tag, TAG_FONT, BIT_W - 12, "bit tag " + b)
+ fit(FOOT, NOTE_FONT, W - 2 * PAD - 32, "foot")
+ for w in WARN:
+ print("OVERFLOW WARN " + w)
+
+ row_h = BIT_H + 10
+ y = 178
+
+ for g in GROUPS:
+ addb('%s'
+ % (PAD, y, HEAD_FONT, esc(g["head"])))
+ y += 22
+ for r in g["rows"]:
+ cy = y
+ addb('%s'
+ % (PAD, cy + 20, ROW_LABEL_FONT, INK, esc(r["label"])))
+ addb('%s'
+ % (PAD, cy + 38, TAG_FONT, MUTED, esc(r["sub"])))
+ for i, (b, tag, kind) in enumerate(r["bits"]):
+ fg, bg = KIND_COLOR[kind]
+ x = GRID_X + i * (BIT_W + BIT_GAP)
+ addb(''
+ % (x, cy, BIT_W, BIT_H, bg, fg, "0.55" if kind != "none" else "0.35"))
+ addb(''
+ '%s' % (x + 10, cy + 19, BIT_FONT, fg, esc(b)))
+ addb('%s'
+ % (x + 10, cy + 36, TAG_FONT, esc(tag)))
+ nx = GRID_X + 8 * (BIT_W + BIT_GAP) + 12
+ addb('' % (nx, cy, NOTE_W, BIT_H))
+ lines = wrap(r["note"], NOTE_FONT, NOTE_W - 24)
+ ty = cy + (BIT_H - (len(lines) - 1) * 15 + 4) / 2.0
+ for ln in lines:
+ addb('%s'
+ % (nx + 12, ty, NOTE_FONT, esc(ln)))
+ ty += 15
+ y += row_h
+ y += GROUP_GAP
+
+ # 图例
+ lx, ly = GRID_X, 118
+ for label, kind in [("电平位(可自愈)", "level"), ("锁存位(不自愈)", "latch"),
+ ("边沿事件位(仅当拍)", "event"), ("消费者掩码命中 / 记忆位", "mask"),
+ ("不适用", "none")]:
+ fg, bg = KIND_COLOR[kind]
+ addb('' % (lx, ly - 12, bg, fg))
+ lx += 22
+ addb('%s'
+ % (lx, ly, esc(label)))
+ lx += text_w(label, 12) + 30
+
+ total_h = y + 74
+ foot_y = total_h - 58
+ body.insert(0, '' % (W, total_h))
+ head = ['%s'
+ % (PAD, TITLE_FONT, INK, esc("采样有效性校验 · 出口位图与消费者掩码(逐位对齐)")),
+ '%s'
+ % (PAD, SUB_FONT, MUTED,
+ esc("同一 bit 在三张位图与四个掩码里始终落在同一列:位号对齐 = 消费者可以位图代打表"))]
+
+ svg = ('\n'
+ % (W, total_h, W, total_h, FONT, "\n".join(head), "\n".join(body),
+ PAD, foot_y, W - 2 * PAD, PAD + 16, foot_y + 27, NOTE_FONT, esc(FOOT)))
+
+ d = os.path.dirname(OUT)
+ if not os.path.isdir(d):
+ os.makedirs(d)
+ with open(OUT, "w", encoding="utf-8", newline="\n") as fp:
+ fp.write(svg)
+ ET.fromstring(svg)
+ print("OK %d bytes, canvas %dx%d -> %s" % (len(svg.encode("utf-8")), W, total_h, OUT))
+ if len(WARN):
+ print("!! %d 处文字可能溢出" % len(WARN))
+
+
+def wrap(s, fs, avail):
+ """按估算宽度做中文换行(无空格语言:逐字累计)。"""
+ lines, cur = [], ""
+ for ch in s:
+ if text_w(cur + ch, fs) > avail and cur:
+ lines.append(cur)
+ cur = ""
+ cur += ch
+ if cur:
+ lines.append(cur)
+ return lines
+
+
+if __name__ == "__main__":
+ build()
diff --git a/tools/arch_diagram/gen_adc_health_flow_svg.py b/tools/arch_diagram/gen_adc_health_flow_svg.py
new file mode 100644
index 00000000..aeed6d0f
--- /dev/null
+++ b/tools/arch_diagram/gen_adc_health_flow_svg.py
@@ -0,0 +1,256 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""生成 docs/images/采样有效性校验数据流.svg —— L1-6「观测计数 → 快照 → 判定 → 位图 → 消费者」(纯标准库)。
+
+为什么不用 Mermaid:本图要表达**三条量车道(TPAFE / STM32 / 时基)平行流过五个阶段**,
+Mermaid 自动布局会把三条车道挤成交叉线、也保不住"同一拍取数"的横向对齐。
+这里按车道手工排布:每车道一行,箭头全部水平,行内可直接横向读一遍判据。
+
+用法(仓库根目录,任意 python3):
+ python tools/arch_diagram/gen_adc_health_flow_svg.py
+输出:
+ docs/images/采样有效性校验数据流.svg
+改内容时:只编辑本文件顶部的 STAGES / LANES 数据后重跑。
+"""
+import html
+import os
+import xml.etree.ElementTree as ET
+
+ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+OUT = os.path.join(ROOT, "docs", "images", "采样有效性校验数据流.svg")
+
+# ----------------------------- 画布与尺寸 -----------------------------
+W = 1580
+PAD = 40
+TITLE_FONT = 21
+SUB_FONT = 13
+HEAD_FONT = 13
+CELL_TITLE_FONT = 12.5
+CELL_NOTE_FONT = 11
+LANE_FONT = 12
+FOOT_FONT = 12
+
+CELL_H = 148
+LANE_GAP = 34
+MAIN_Y = 214
+LANE_LABEL_W = 96
+COLS = [
+ (PAD + LANE_LABEL_W, 268), # 阶段 1:观测计数(ISR / 消费侧)
+ (PAD + LANE_LABEL_W + 288, 236), # 阶段 2:本拍快照
+ (PAD + LANE_LABEL_W + 544, 276), # 阶段 3:判定(纯逻辑)
+ (PAD + LANE_LABEL_W + 840, 264), # 阶段 4:出口位图
+ (PAD + LANE_LABEL_W + 1124, 250), # 阶段 5:消费者
+]
+
+FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif"
+C_LANE = ["#0d9488", "#2563eb", "#7c3aed"]
+BG_LANE = ["#f0fdfa", "#eff6ff", "#f5f3ff"]
+C_HEAD, BG_HEAD = "#475569", "#f1f5f9"
+INK, MUTED = "#0f172a", "#64748b"
+
+# ----------------------------- 数据(改这里) -----------------------------
+STAGES = [
+ "1. 观测计数(ISR / 驱动侧)",
+ "2. 本拍计数快照",
+ "3. 判定(adc_health.c 纯逻辑)",
+ "4. 出口位图(32 位)",
+ "5. 消费者与处置",
+]
+
+# 每条车道 = 一行;cells 与 STAGES 一一对应
+LANES = [
+ dict(
+ name="TPAFE",
+ color=0,
+ cells=[
+ dict(title="tpafe5160.c 的 BUSY EXTI",
+ lines=["s_frame_seq++:每读完一帧 +1",
+ "s_frstdata_err++:无跳变帧累计(E-44)",
+ "ISR 内只加计数,不打日志"] ),
+ dict(title="adcTask 每拍 poll 之后取值",
+ lines=["tpafe_seq",
+ "tpafe_frst_err(uint16 放宽)",
+ "⚠ 必须取本拍 poll 之后的值"] ),
+ dict(title="序号差值 d = seq - 上次",
+ lines=["d < 0 → 置倒退位并重新对齐",
+ "d > 1 → tgap += d-1 + 当拍事件",
+ "d >= 1 → 刷新「最后前进时刻」"] ),
+ dict(title="本车道出口",
+ lines=["bit0 ADC_HF_TPAFE_STALL(自愈)",
+ "bit2 ADC_HF_SEQ_BACKWARD(锁存)",
+ "bit6/bit3 ADC_HE_TPAFE_GAP / FRSTDATA"] ),
+ dict(title="闭锁 + 留痕 + 计数",
+ lines=["protectTask 每 20 ms 读 bit0/bit2",
+ "gap / FRSTDATA 事件 → 编排层日志",
+ "tgap → 10 s 心跳字段"] ),
+ ],
+ ),
+ dict(
+ name="STM32",
+ color=1,
+ cells=[
+ dict(title="adc_input.c(DMA2 Stream4)",
+ lines=["s_dma_seq++:半区/整区完成各一次",
+ "s_stm32_gaps++:完成计数一次前进 >1",
+ "s_stm32_errs++:连续 >=2 拍无新帧",
+ "s_stm32_hw_errs++:HAL 错误回调"] ),
+ dict(title="同一拍的四个 STM32 计数",
+ lines=["stm32_seq / stm32_errs",
+ "stm32_gaps / stm32_hw_errs",
+ "gap 细账不重复取(L1 已算)"] ),
+ dict(title="序号差值 d + 三处计数增量",
+ lines=["d < 0 → 同一个 SEQ_BACKWARD 位",
+ "d >= 1 → 刷新最后前进时刻",
+ "增量 != 0 → 事件 + 锁存"] ),
+ dict(title="本车道出口",
+ lines=["bit1 ADC_HF_STM32_STALL(自愈)",
+ "bit2 共用(两车道同一位)",
+ "bit7/bit4/bit5 GAP / ERR / HW_ERR"] ),
+ dict(title="闭锁 + 严重事件",
+ lines=["bit1/bit2 → 同左闭锁路径",
+ "STM32_ERR / HW_ERR:恒 0 增量",
+ "出现即 DBG_ERROR + 自检留痕"] ),
+ ],
+ ),
+ dict(
+ name="时基",
+ color=2,
+ cells=[
+ dict(title="osKernelGetTickCount()",
+ lines=["HAL 时基 TIM7,tick = 1 ms",
+ "2^32 ms 后自然回绕"] ),
+ dict(title="now_ms:本拍唯一一份",
+ lines=["健康判定与心跳上报共用",
+ "不在判定层内再取一次"] ),
+ dict(title="差值法 (int32_t)(now - adv)",
+ lines=["回绕安全(取有符号差)",
+ ">= ADC_HEALTH_STALL_MS(100)",
+ "可判定窗 ±2^31 ≈ ±24.85 天"] ),
+ dict(title="tpafe_stall_ms",
+ lines=["静默期内给出停滞时长",
+ "正常态恒为 0",
+ "STM32 侧未上报(观测盲区)"] ),
+ dict(title="上升沿日志可读性",
+ lines=["health: flags=… stall_ms=…",
+ "回答到底停了多少毫秒"] ),
+ ],
+ ),
+]
+
+FOOT = ("判定层零平台依赖(只 include 、不碰寄存器 / 不取数 / 不打日志)→ 主机侧以 #include 原样纳入、免桩全覆盖单测。"
+ "详见 docs/算法/采样有效性校验设计说明.md")
+
+
+# ----------------------------- 工具函数 -----------------------------
+def esc(s):
+ return html.escape(s, quote=True)
+
+
+def text_w(s, fs):
+ """粗略估算文本像素宽:CJK/全角 ≈ 1.0em,其余 ≈ 0.58em(宁可偏大)。"""
+ return sum(fs * (1.0 if ord(ch) >= 0x2E80 else 0.58) for ch in s)
+
+
+OVERFLOW = []
+
+
+def check_fit(s, fs, avail, where):
+ if text_w(s, fs) > avail:
+ OVERFLOW.append("%s: %.0f > %.0f [%s]" % (where, text_w(s, fs), avail, s))
+
+
+# ----------------------------- 生成 -----------------------------
+def build():
+ out = []
+ add = out.append
+
+ n_cells = max(len(l["cells"]) for l in LANES)
+ for li, lane in enumerate(LANES):
+ for ci, cell in enumerate(lane["cells"]):
+ _, cw = COLS[ci]
+ avail = cw - 26
+ check_fit(cell["title"], CELL_TITLE_FONT, avail,
+ "lane %s col %d title" % (lane["name"], ci + 1))
+ for ln in cell["lines"]:
+ check_fit(ln, CELL_NOTE_FONT, avail,
+ "lane %s col %d line" % (lane["name"], ci + 1))
+ for ci, st in enumerate(STAGES):
+ check_fit(st, HEAD_FONT, COLS[ci][1] - 20, "stage head %d" % (ci + 1))
+ if len(OVERFLOW):
+ for o in OVERFLOW:
+ print("OVERFLOW WARN " + o)
+
+ lane_h = CELL_H + LANE_GAP
+ bottom = MAIN_Y + len(LANES) * lane_h - LANE_GAP
+ total_h = bottom + 92
+
+ add('' % (W, total_h))
+ add('%s'
+ % (PAD, TITLE_FONT, INK, esc("采样有效性校验(adc_health)· 数据流与出口")))
+ add('%s'
+ % (PAD, SUB_FONT, MUTED,
+ esc("三条量车道平行流过五个阶段;判定层每拍一次、常数次整数运算,输入只是计数快照")))
+
+ # ---- 阶段标题 ----
+ for ci, st in enumerate(STAGES):
+ x, cw = COLS[ci]
+ add('' % (x, cw, BG_HEAD, C_HEAD))
+ add('%s'
+ % (x + 12, HEAD_FONT, esc(st)))
+
+ # ---- 车道 ----
+ for li, lane in enumerate(LANES):
+ color, bg = C_LANE[lane["color"]], BG_LANE[lane["color"]]
+ y0 = MAIN_Y + li * lane_h
+ # 车道标签
+ add('' % (PAD, y0, LANE_LABEL_W - 14, CELL_H, bg, color))
+ add('%s'
+ % (PAD + (LANE_LABEL_W - 14) / 2.0, y0 + CELL_H / 2.0 + 5, LANE_FONT,
+ color, esc(lane["name"])))
+
+ for ci, cell in enumerate(lane["cells"]):
+ x, cw = COLS[ci]
+ add('' % (x, y0, cw, CELL_H, bg, color))
+ add('%s'
+ % (x + 13, y0 + 30, CELL_TITLE_FONT, color, esc(cell["title"])))
+ yy = y0 + 56
+ for ln in cell["lines"]:
+ add('%s'
+ % (x + 13, yy, CELL_NOTE_FONT, esc(ln)))
+ yy += 22
+ # 水平箭头到下一列
+ if ci + 1 < len(lane["cells"]):
+ nx = COLS[ci + 1][0]
+ cy = y0 + CELL_H / 2.0
+ add('' % (x + cw, cy, nx - 9, cy, color))
+ add(''
+ % (nx, cy, nx - 11, cy - 5.5, nx - 11, cy + 5.5, color))
+
+ # ---- 底部说明 ----
+ add('' % (PAD, bottom + 30, W - 2 * PAD))
+ add('%s'
+ % (PAD + 16, bottom + 58, FOOT_FONT, esc(FOOT)))
+
+ svg = ('\n'
+ % (W, total_h, W, total_h, FONT, "\n".join(out)))
+
+ d = os.path.dirname(OUT)
+ if not os.path.isdir(d):
+ os.makedirs(d)
+ with open(OUT, "w", encoding="utf-8", newline="\n") as fp:
+ fp.write(svg)
+ ET.fromstring(svg)
+ print("OK %d bytes, canvas %dx%d -> %s" % (len(svg.encode("utf-8")), W, total_h, OUT))
+ if len(OVERFLOW):
+ print("!! %d 处文字可能溢出,见上方 WARN" % len(OVERFLOW))
+
+
+if __name__ == "__main__":
+ build()
diff --git a/tools/arch_diagram/gen_adc_health_timeline_svg.py b/tools/arch_diagram/gen_adc_health_timeline_svg.py
new file mode 100644
index 00000000..54c1ca72
--- /dev/null
+++ b/tools/arch_diagram/gen_adc_health_timeline_svg.py
@@ -0,0 +1,300 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""生成 docs/images/采样有效性判定时间轴.svg —— L1-6 四个工况的时序与门限(纯标准库)。
+
+为什么不用 Mermaid:本图要表达**时间与门限的比例关系**(100 ms 门限线、静默计时线、
+电平位/锁存位的存续区间),必须按比例画坐标与条形;Mermaid 只画拓扑、不保比例。
+
+用法(仓库根目录,任意 python3):
+ python tools/arch_diagram/gen_adc_health_timeline_svg.py
+输出:
+ docs/images/采样有效性判定时间轴.svg
+改内容时:编辑本文件顶部的 PANELS 数据后重跑。
+"""
+import html
+import os
+import xml.etree.ElementTree as ET
+
+ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+OUT = os.path.join(ROOT, "docs", "images", "采样有效性判定时间轴.svg")
+
+W = 1580
+PAD = 40
+TITLE_FONT = 21
+SUB_FONT = 13
+AXIS_FONT = 10.5
+TXT_FONT = 11.5
+HEAD_FONT = 13.5
+
+PLOT_X0, PLOT_X1 = 430, 1120
+NOTE_X, NOTE_W = 1150, 390
+T_MAX = 200.0
+V_MAX = 150.0 # 纵轴:静默计时 ms(面板 C 的第二序列另按自身标度)
+PLOT_H = 96
+STRIP_H = 15
+PANEL_H = 186
+FIRST_Y = 220
+PLOT_W = PLOT_X1 - PLOT_X0
+
+FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif"
+INK, MUTED = "#0f172a", "#64748b"
+C_OK = "#16a34a"
+C_LEVEL = "#0d9488"
+C_LATCH = "#e11d48"
+C_EVENT = "#d97706"
+C_AXIS = "#cbd5e1"
+C_TH = "#e11d48"
+BG_STRIP = "#f1f5f9"
+
+# ----------------------------- 数据(改这里) -----------------------------
+THRESHOLD = 100.0
+
+PANELS = [
+ dict(
+ title="A 对照:健康 1 kHz(d 恒为 1)",
+ seq=["seq: 1000 1001 1002 ... 每拍恰好 +1",
+ "每拍都刷新最后前进时刻 adv_ms"],
+ verdict="elapsed = now - adv_ms 恒为 0 → 永不进门限带",
+ elapsed=[[(0, 0), (200, 0)]],
+ strips=[],
+ notes=["判定层对健康态**零输出**:events=0、flags=0、",
+ "sticky=0。主机单测 TC-ADCHEALTH-002 用 2000 拍",
+ "扫描把这个结论钉死(健康不误报)。"],
+ ),
+ dict(
+ title="B TPAFE 静默:100 ms 检出,恢复只清电平位",
+ seq=["seq: 1000 1001 ... 冻结在 1050",
+ "STM32 车道照常前进(分车道独立判定)"],
+ verdict="elapsed 每拍 +1,达到 100 ms 那拍即置位",
+ elapsed=[[(0, 0), (100, 100), (150, 150)], [(150, 0), (200, 0)]],
+ strips=[("flags", "TPAFE_STALL 电平", C_LEVEL, 100, 150),
+ ("sticky", "TPAFE_STALL 锁存留痕", C_LATCH, 100, 200)],
+ marks=[(100, 100, "1"), (150, 150, "2")],
+ notes=["1 t=100 ms:ADC_HF_TPAFE_STALL 置位 + 进 sticky;",
+ " 闭锁路径 = protectTask 下一拍(间隔 <= 20 ms)",
+ " 命中掩码 → prot_elm_init() + 本拍不 eval",
+ "2 t=150 ms:序号重新前进 → 电平位清、sticky 仍留痕"],
+ ),
+ dict(
+ title="C 消费者超拍:d=3 只算丢帧,永不判静默",
+ seq=["seq: 1000 1003 1006 ... 每拍 +3",
+ "生产者活着,消费者没跟上"],
+ verdict="d >= 1 → 刷新 adv_ms,elapsed 恒 0;tgap += d-1",
+ elapsed=[[(0, 0), (200, 0)]],
+ series=dict(label="tpafe_gaps 累计点数(右轴 0~8)", color=C_EVENT,
+ points=[(0, 0), (20, 0), (20, 2), (60, 2), (60, 4),
+ (100, 4), (100, 6), (200, 6)]),
+ strips=[("events", "ADC_HE_TPAFE_GAP 仅当拍", C_EVENT, 20, 22),
+ ("events", "同上", C_EVENT, 60, 62),
+ ("events", "同上", C_EVENT, 100, 102)],
+ marks=[(20, 9, "1"), (60, 9, "2")],
+ notes=["1 t=20 ms:d=3 → tgap += 2 + 当拍事件,下一拍事件消失",
+ " (边沿语义,不是电平)",
+ "2 丢帧不入锁存、不告警、不闭锁;两者口径也不同:tgap",
+ " 计帧点数(d-1 加权),adc1_gap 计次数(每次 +1)。",
+ " 台架日志:启动突发期累计 23 次,稳态每 10 s +1~2 次"],
+ ),
+ dict(
+ title="D 序号倒退:锁存不可自愈,代价是永久闭锁",
+ seq=["seq: 1050 1051 ... 跳到 995",
+ "d = 995 - 1051 < 0(有符号差)"],
+ verdict="置位后重新对齐基线,不重复触发;位永不自动清",
+ elapsed=[[(0, 0), (200, 0)]],
+ strips=[("flags", "SEQ_BACKWARD 不自动清", C_LATCH, 60, 200),
+ ("sticky", "SEQ_BACKWARD 锁存", C_LATCH, 60, 200)],
+ marks=[(60, 9, "1"), (194, 9, "2")],
+ notes=["1 t=60 ms:倒退被检出 → 重新对齐避免每拍重复置位",
+ "2 电平位与锁存位同时置位 → 保护永久退出,直到",
+ " 复位或显式 adc_health_init();这是刻意取舍:",
+ " 计数被改写时相量不可信,宁可拒动不误动"],
+ ),
+]
+
+FOOT = ("横轴 = 距最后一次序号前进的时间(ms,tick 回绕由有符号差值吸收);红线 = ADC_HEALTH_STALL_MS = 100 ms。"
+ "详见 docs/算法/采样有效性校验设计说明.md")
+
+
+# ----------------------------- 工具函数 -----------------------------
+def esc(s):
+ return html.escape(s, quote=True)
+
+
+def text_w(s, fs):
+ return sum(fs * (1.0 if ord(ch) >= 0x2E80 else 0.58) for ch in s)
+
+
+WARN = []
+
+
+def fit(s, fs, avail, where):
+ if text_w(s, fs) > avail:
+ WARN.append("%s: %.0f > %.0f [%s]" % (where, text_w(s, fs), avail, s))
+
+
+def X(t):
+ return PLOT_X0 + PLOT_W * (t / T_MAX)
+
+
+def Y(v):
+ return v
+
+
+# ----------------------------- 生成 -----------------------------
+def build():
+ b = []
+ add = b.append
+
+ for pn in PANELS:
+ fit(pn["title"], HEAD_FONT, PLOT_W - 40, "panel title")
+ for s in pn["seq"] + [pn["verdict"]]:
+ fit(s, TXT_FONT, 360, "panel seq/verdict")
+ for s in pn["notes"]:
+ fit(s, TXT_FONT, NOTE_W - 24, "panel note")
+ for w in WARN:
+ print("OVERFLOW WARN " + w)
+
+ total_h = FIRST_Y + len(PANELS) * PANEL_H + 74
+
+ add('%s'
+ % (PAD, TITLE_FONT, INK, esc("采样有效性判定时间轴 · 四个工况的 elapsed 与位存续区间")))
+ add('%s'
+ % (PAD, SUB_FONT, MUTED,
+ esc("elapsed = (int32_t)(now_ms - 最后一次序号前进的时刻);条带 = 位为 1 的存续区间")))
+
+ # 图例
+ lx, ly = PLOT_X0, 116
+ for label, color in [("电平位 flags(可自愈)", C_LEVEL), ("锁存位 sticky / 不自愈", C_LATCH),
+ ("边沿事件 events(仅当拍)", C_EVENT), ("静默门限 100 ms", C_TH)]:
+ add('' % (lx, ly - 10, color))
+ lx += 22
+ add('%s' % (lx, ly, esc(label)))
+ lx += text_w(label, 12) + 30
+
+ # 列头
+ add('%s'
+ % (PAD, 178, HEAD_FONT, esc("工况与计数")))
+ add('%s'
+ % (PLOT_X0, 178, HEAD_FONT, esc("静默计时 elapsed(ms)与位存续")))
+ add('%s'
+ % (NOTE_X, 178, HEAD_FONT, esc("关键时刻与判读")))
+
+ for pi, pn in enumerate(PANELS):
+ ptop = FIRST_Y + pi * PANEL_H
+ base = ptop + PLOT_H
+ strip_y0 = base + 12
+
+ # 左侧说明块
+ add('' % (PAD, ptop, 370, PLOT_H + 46))
+ add('%s'
+ % (PAD + 14, ptop + 26, HEAD_FONT, INK, esc(pn["title"])))
+ yy = ptop + 50
+ for s in pn["seq"]:
+ add('%s'
+ % (PAD + 14, yy, TXT_FONT, esc(s)))
+ yy += 17
+ add('%s'
+ % (PAD + 14, yy + 6, TXT_FONT, esc(pn["verdict"])))
+
+ # 坐标框 + 网格
+ add('' % (PLOT_X0, ptop + 4, PLOT_W, PLOT_H, C_AXIS))
+ for gv in (0, 50, 100, 150):
+ gy = base - PLOT_H * (gv / V_MAX)
+ add('' % (PLOT_X0, gy, PLOT_X1, gy, C_AXIS))
+ add('%s'
+ % (PLOT_X0 - 6, gy + 4, AXIS_FONT, MUTED, str(gv)))
+ for tv in (0, 50, 100, 150, 200):
+ tx = X(tv)
+ add(''
+ % (tx, base, tx, base + 4, C_AXIS))
+ add('%s'
+ % (tx, base + 16, AXIS_FONT, MUTED, str(tv)))
+
+ # 门限线
+ ty = base - PLOT_H * (THRESHOLD / V_MAX)
+ add('' % (PLOT_X0, ty, PLOT_X1, ty, C_TH))
+ add('%s'
+ % (PLOT_X1 - 176, ty - 6, AXIS_FONT, C_TH, esc("ADC_HEALTH_STALL_MS = 100")))
+
+ # elapsed 折线
+ for seg in pn["elapsed"]:
+ pts = " ".join("%.1f,%.1f" % (X(t), base - PLOT_H * (v / V_MAX)) for t, v in seg)
+ add('' % (pts, C_LEVEL))
+
+ # 第二序列(丢帧累计,右轴标度)
+ sd = pn.get("series")
+ if sd:
+ vmax = max(v for _, v in sd["points"]) or 1.0
+ pts = " ".join("%.1f,%.1f" % (X(t), base - PLOT_H * (v / (vmax * 1.15)))
+ for t, v in sd["points"])
+ add('' % (pts, sd["color"]))
+ add('%s'
+ % (PLOT_X0 + 8, ptop + 20, AXIS_FONT, sd["color"], esc(sd["label"])))
+
+ # 标记点
+ for t, v, tag in pn.get("marks", []):
+ mx, my = X(t), base - PLOT_H * (v / V_MAX)
+ add('' % (mx, my, mx, base + 4, MUTED))
+ add('' % (mx, my, INK))
+ add('%s' % (mx, my + 3.6, esc(tag)))
+
+ # 位存续条带
+ for i in range(2):
+ add('' % (PLOT_X0, strip_y0 + i * (STRIP_H + 4), PLOT_W,
+ STRIP_H, BG_STRIP))
+ add('%s'
+ % (PLOT_X0 - 6, strip_y0 + 11, AXIS_FONT, MUTED, esc("flags")))
+ add('%s'
+ % (PLOT_X0 - 6, strip_y0 + STRIP_H + 15, AXIS_FONT, MUTED, esc("sticky/events")))
+ for name, label, color, t0, t1 in pn["strips"]:
+ row = 0 if name == "flags" else 1
+ sx = X(t0)
+ sw = max(4.0, X(t1) - X(t0))
+ sy = strip_y0 + row * (STRIP_H + 4)
+ add('' % (sx, sy, sw, STRIP_H, color))
+ lab_x = sx + sw + 8
+ if lab_x + text_w(label, AXIS_FONT) > PLOT_X1 + 4:
+ lab_x = max(PLOT_X0 + 6, sx - text_w(label, AXIS_FONT) - 8)
+ add('%s'
+ % (lab_x, sy + 11, AXIS_FONT, color, esc(label)))
+
+ # 右侧判读
+ ny = ptop + 18
+ for s in pn["notes"]:
+ add('%s'
+ % (NOTE_X, ny, TXT_FONT, esc(s)))
+ ny += 17
+
+ fy = total_h - 56
+ add('' % (PAD, fy, W - 2 * PAD))
+ add('%s'
+ % (PAD + 16, fy + 26, TXT_FONT, esc(FOOT)))
+
+ svg = ('\n'
+ % (W, total_h, W, total_h, FONT, W, total_h, "\n".join(b)))
+
+ d = os.path.dirname(OUT)
+ if not os.path.isdir(d):
+ os.makedirs(d)
+ with open(OUT, "w", encoding="utf-8", newline="\n") as fp:
+ fp.write(svg)
+ ET.fromstring(svg)
+ print("OK %d bytes, canvas %dx%d -> %s" % (len(svg.encode("utf-8")), W, total_h, OUT))
+ if len(WARN):
+ print("!! %d 处文字可能溢出" % len(WARN))
+
+
+if __name__ == "__main__":
+ build()
From 60a650c175492e2c079bfe63bb10b101e8b2ac3e Mon Sep 17 00:00:00 2001
From: Wanderingss <1624155937@qq.com>
Date: Wed, 21 Oct 2026 22:59:55 +0800
Subject: [PATCH 2/4] =?UTF-8?q?fix(adc=5Ftask):=20=E6=8E=A5=E4=B8=8A?=
=?UTF-8?q?=E4=BB=8E=E6=9C=AA=E8=A2=AB=E8=B0=83=E7=94=A8=E7=9A=84=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=E5=88=9D=E5=A7=8B=E5=8C=96=E5=85=A5=E5=8F=A3=EF=BC=88?=
=?UTF-8?q?=E5=90=AB=E5=88=A4=E5=AE=9A=E5=B1=82=E5=8F=82=E7=85=A7=E7=B3=BB?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
问题:adc_task_func 函数头写「限定条件:adc_task_init() 已调用」,但
adc_task_init() 全库无调用点;同类空壳 net_task_init()(体内只有一条
DBG_INFO,函数头却写「创建 TCP Server 监听 Socket」)同样无人调用。
判定层为什么今天还没坏:状态全在 .bss(复位即 0),且 adc_health 的
首拍只建基线、不判定,所以漏调 init 只是「少清一次」。但假契约会让
下一个接手者照它推理,故必须修在成灾之前。
改动:
- adc_task_func 进入循环之前(开闸 s_consumer_ready 之前)自己调用
adc_task_init(),不依赖外部接线;init 与首拍之间无抢占窗口
- adc_task_init() 去掉 DBG_INFO 并在函数头标注禁止加日志:它落在
adcTask 静态最坏调用链上,每条 DBG_* 约 216 B 栈,而这条启动行
无诊断价值(存活由首帧日志与 TASK_WD_ADC 心跳证明)
- 删除 net_task.c/.h 里的空壳 net_task_init()(声明 + 定义 + 误述)
- docs/算法/采样有效性校验设计说明.md §14 与变更记录同步改写:
原来登记的「init 不在产品路径」事实已修复,不留过时结论
实测(任务工作树 adc-health,构建 + 静态分析):
- @build 0 错 0 警;Code 213356 → 213424(+68 B)
- RW-data 1184 / ZI-data 131480 逐字节不变 ⇒ RAM 零增量
- stack_report:adcTask 静态最坏链 1320 B → 1320 B 不变,
余 1240 B / 48% 仍 PASS(netTask 1912 B、protectTask 872 B 不变)
- ADC 有效性单测 31/31 通过(钩子按 adc_task.c 自动触发)
- 字节卫生自查:BOM 无 / CR 0 / U+FFFD 0,字符串字面量纯 ASCII
(中文只在注释里),新增行无非 ASCII 字面量
gate: only mem-budget/mem-numbers pending lead ledger backfill
pre-commit run --all-files = 33 Passed / 2 Failed,失败仅这两条,
均为台账受管区与本树 .map 的 Flash 差异(233,120 → 233,188,即本次
+68 B);RW_IRAM1 75,240 与 RW_CCM 57,376 与台账完全一致。按并行
分支约定本分支不跑 --write,台账由 Lead 合并后统一回填。
另记一处门禁缺口(非本次引入):ui-golden 的黄金参考 simulator/
tests/ref/ 被 .gitignore 排除、从未入库,新工作树里必然失败;本树
用 check_content_pages.py --update 生成 11 页基线后 ui-golden Passed
(即本次改动对 UI 像素中性)。是否让基线入库待裁。
---
App/task/adc_task.c | 21 ++++++++++++++++++---
App/task/net_task.c | 16 ++++------------
App/task/net_task.h | 2 +-
docs/算法/采样有效性校验设计说明.md | 15 +++++++++++----
4 files changed, 34 insertions(+), 20 deletions(-)
diff --git a/App/task/adc_task.c b/App/task/adc_task.c
index c462ca6f..6917bc65 100644
--- a/App/task/adc_task.c
+++ b/App/task/adc_task.c
@@ -77,6 +77,13 @@
* 恢复打 DBG_INFO;心跳日志增 tgap(TPAFE 侧丢帧,
* 与 adc1_gap 对称)。判定不在本文件(A-4:算法与
* I/O 分离),本文件只做"消费计数 → 告警"的编排
+ * v3.0 2026-10-21 王建锋 修复"任务初始化入口从未被调用":adc_task_func 进入
+ * 循环之前先调 adc_task_init()。原实现下判定层状态靠
+ * .bss 清零 + 首拍建基线兜住,功能没错,但函数头写的
+ * "限定条件:adc_task_init() 已调用"是假的,会误导后来
+ * 者;该入口同时不再打日志——它落在本任务静态最坏链上,
+ * 每条 DBG_* 约 216 B 栈,而这一行毫无诊断价值(本任务
+ * 存活由首帧日志与 TASK_WD_ADC 心跳证明)
*/
#include "adc_task.h"
@@ -230,14 +237,17 @@ static void adc_health_report(const adc_health_status_t *hs) {
* 入口参数:无
* 返 回 值:0 - 成功,-1 - 失败
* 限定条件:tpafe5160_init() 已在 app_main_init()(内核启动前)完成
- * 函数说明:驱动初始化不在本任务(见限定条件);此处仅清帧统计。
+ * 函数说明:驱动初始化不在本任务(见限定条件);此处清帧统计 + 清判定层参照系
+ * (adc_health_init())。由 adc_task_func 进入循环之前调用一次;幂等,
+ * 可重复调用(运行期要重建判定参照系,走的也是这里)。
+ * ⚠ 本函数内禁止加日志:它在 adcTask 的静态最坏调用链上,每条 DBG_*
+ * 约 216 B 栈,而本任务栈余量按 1024 B ISR 底线核算。
*/
int adc_task_init(void) {
s_frames_consumed = 0u;
s_frames_idle = 0u;
s_frames_skip = 0u;
adc_health_init();
- DBG_INFO("adc_task_init");
return 0;
}
@@ -245,7 +255,8 @@ int adc_task_init(void) {
* 函数功能:ADC 任务主函数
* 入口参数:arg - FreeRTOS 传入参数(未使用)
* 返 回 值:无
- * 限定条件:adc_task_init() 已调用;内核已启动
+ * 限定条件:内核已启动。本函数自己在进入循环前调 adc_task_init()——不依赖外部
+ * 调用(历史上该入口全库无调用点,判定层状态只靠 .bss 清零兜住)
* 函数说明:每拍采集一次(adc_input_poll():TPAFE 原子快照 + 消费一帧 STM32 ADC1 DMA 乒乓缓冲)
* 并统计。采样节拍由 TIM3 硬件定时器提供(ISR 内触发 CONVST),本任务不再触发;
* **本任务的唤醒同样由该 TIM3 ISR 置位 ADC_WAKE_FLAG 驱动**(事件驱动,
@@ -261,6 +272,10 @@ void adc_task_func(void *arg) {
(void)arg;
+ /* 任务自有状态清零(含判定层参照系)。放在开闸之前:此刻 ISR 还不会唤醒
+ * 本任务,init 与首拍之间不存在"用半初始化状态做判定"的窗口 */
+ (void)adc_task_init();
+
/* 开闸:此刻任务已存在、调度器必已运行 → ISR 的唤醒从此生效。
* 放在进入循环之前:置位与等待之间不会漏唤醒(线程标志粘滞) */
s_consumer_ready = 1u;
diff --git a/App/task/net_task.c b/App/task/net_task.c
index 154e33dc..7d093f06 100644
--- a/App/task/net_task.c
+++ b/App/task/net_task.c
@@ -21,6 +21,10 @@
* v1.8 2026-10-10 王建锋 任务耗时测量埋点:net_poll() 单独计时——
* 本任务每 ~1ms 分派一次消息队列,若驱动轮询被拉长,
* 其它任务(尤其 UI 的 net_accept 往返)的等待随之拉长
+ * v1.9 2026-10-21 王建锋 删除 net_task_init():空壳入口(体内只有一条
+ * DBG_INFO),全库无调用点,而函数头却写"创建 TCP
+ * Server 监听 Socket,注册回调等"——描述与实际不符,
+ * 监听实际在 net_stack_bring_up() 与各服务里做
*/
#include
@@ -145,18 +149,6 @@ int net_stack_bring_up(void) {
return 0;
}
-/*
- * 函数功能:网络任务初始化
- * 入口参数:无
- * 返 回 值:0 - 成功,-1 - 失败
- * 限定条件:CH395F 协议栈已初始化
- * 函数说明:创建 TCP Server 监听 Socket,注册回调等
- */
-int net_task_init(void) {
- DBG_INFO("net_task_init");
- return 0;
-}
-
/*
* 函数功能:网络任务主函数
* 入口参数:arg - FreeRTOS 传入参数(未使用)
diff --git a/App/task/net_task.h b/App/task/net_task.h
index ef36fb26..98e75e88 100644
--- a/App/task/net_task.h
+++ b/App/task/net_task.h
@@ -9,6 +9,7 @@
* v1.1 2026-08-24 王建锋 提取 net_stack_bring_up() 供硬件层/NET 层两种测试模式共用
* v1.2 2026-09-15 王建锋 硬件层模式描述修正:netTask 恒创建(基础线程),
* 该模式下自挂起不运行(P1-6 守卫)
+ * v1.3 2026-10-21 王建锋 删除 net_task_init() 声明(空壳入口、无调用点)
*/
#ifndef NET_TASK_H
@@ -23,7 +24,6 @@ extern "C" {
* netTask 自挂起(创建但不运行),由 testTask 调用后独占 CH395F。 */
int net_stack_bring_up(void);
-int net_task_init(void);
void net_task_func(void const *arg);
#ifdef __cplusplus
diff --git a/docs/算法/采样有效性校验设计说明.md b/docs/算法/采样有效性校验设计说明.md
index 95b063a6..1d0279de 100644
--- a/docs/算法/采样有效性校验设计说明.md
+++ b/docs/算法/采样有效性校验设计说明.md
@@ -554,10 +554,16 @@ stateDiagram-v2
3. 位图**不复用已冻结位号**:bit0..bit7 已分配,新位从 bit8 起;`events` 与 `sticky` 若要共享
语义就必须共享位号(§7 的去重写法依赖这点)。
-**当前事实登记**(改这段链时会撞上):产品启动路径上 `adc_health_init()` 并未被调用——
-`adc_task_init()` 目前在仓库里没有调用点(只有 `adc_task_func` 被 `Src/freertos.c` 建为任务)。
-判定仍然正确,因为判定层状态全在 `.bss`(复位即 0),而**首拍只建基线**这条设计让 init 变成
-可选动作。但「init 可选」不等于「init 无用」:运行期任何需要重建参照系的场合都必须显式调用。
+**启动路径事实**:`adc_health_init()` 由 `adc_task_init()` 调用,而 `adc_task_init()` 由
+`adc_task_func()` 在进入循环之前自己调用(在「开闸」`s_consumer_ready = 1` 之前,所以 init 与
+首拍之间不存在被抢占的窗口)。三点设计含义:
+
+- 判定层的正确性**不依赖**这次调用——状态全在 `.bss`(复位即 0),加上**首拍只建基线**,
+ 漏调一次只是「少清一次」。这正是把 init 设计成幂等 + 首拍建基线换来的韧性。
+- 但入口必须真的存在并被调用:否则函数头的「限定条件」就是假的,而下一个人(或 agent)
+ 会照着它推理。运行期要重建参照系(例如重新跑了会清零计数的驱动 `*_init()`),调用的就是它。
+- 该入口**禁止加日志**:它在 adcTask 的静态最坏调用链上(栈预算口径见本节表末与
+ `docs/内存管理/RAM使用分析与优化方案.md` §1.4)。
---
@@ -580,3 +586,4 @@ stateDiagram-v2
| 日期 | 变更 | 说明 |
|---|---|---|
| 2026-10-21 | 新建 | 首版单文件深设计:判据推导、四种位语义、闭锁与兜底责任矩阵、验证分工与现场判读手册;新增三张配图生成器(`tools/arch_diagram/gen_adc_health_flow_svg.py`、`gen_adc_health_bitmap_svg.py`、`gen_adc_health_timeline_svg.py`)与产物 SVG |
+| 2026-10-21 | §14 更新 | 初始化入口已接线:`adc_task_func()` 进循环前调 `adc_task_init()`(该入口不再打日志);同时删除 `App/task/net_task.c` 里的空壳 `net_task_init()` |
From 9f826dbb4ddbeed9dfa90567726555a33551e661 Mon Sep 17 00:00:00 2001
From: Wanderingss <1624155937@qq.com>
Date: Wed, 21 Oct 2026 23:54:01 +0800
Subject: [PATCH 3/4] =?UTF-8?q?fix(protect):=20=E9=87=87=E6=A0=B7=E9=99=88?=
=?UTF-8?q?=E6=97=A7=E6=97=B6=E7=8B=AC=E7=AB=8B=E9=97=AD=E9=94=81=E4=BF=9D?=
=?UTF-8?q?=E6=8A=A4=EF=BC=88=E5=88=A4=E5=AE=9A=E5=B1=82=E5=A4=B1=E6=98=8E?=
=?UTF-8?q?=E7=9A=84=E5=85=9C=E5=BA=95=E9=97=A8=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
问题:L5-4 闭锁读的 adc_health_flags() 只有 adcTask 每拍跑
adc_health_check() 才可能置位。TIM3 一停,adcTask 就永久阻塞在
osThreadFlagsWait(刻意的 fail-safe),check() 不再执行 → stall 位
永远是 0,而 protectTask 仍每 20 ms 拿冻在最后一帧的相量继续判据。
窗口最坏约 800 ms(停喂阈值)+ 2498 ms(IWDG 标称)≈ 3.3 s。
方案:保护侧建一条与消费者存活完全解耦的兜底新鲜度门。
- adc_health.c/h 新增 adc_health_age_stale():无模块状态的纯判据,
参照系 adc_health_age_t 由调用方自存。故意不让 protectTask 去调
adc_health_check()——那是「每拍一次、单写者」的契约,两个上下文
同写会互相刷新差值基线,把现有判定层一起弄坏。
- 新增 ADC_HF_STALE = bit8(与判定层 bit0..bit7 位段错开)。本位
不由判定层写、不进 sticky、不进编排层 logged 位图。
- protect_task.c 用自己的时基直接读两个**中断侧递增**的计数器
(tpafe5160_frame_seq / adc_input_stm32_seq),并入掩码。两条车道
各自一份 primed:共用会让先观测的那条把标志置 1,另一条首拍拿
adv_ms = 0 算年龄 → 开机即误锁保护。
- 门限 PROT_STALE_AGE_MS = 100 + 20 = 120 ms,买的是时序阶梯:
判定层活着时它先报 0x1/0x2(带日志与自检留痕),死了只出现
0x100。闭锁最迟 140 ms,仍远小于最小动作延时(默认 1000 ms)。
实测:
- @build 0 错 0 警:Code 213424 → 213520(+96 B);ZI 131480 →
131504(+24 B = 两份 age 结构);RW-data 1184 不变
- stack_report:protectTask 静态最坏链 872 B 不变(余 1176 B / 57%)、
adcTask 1320 B 不变——新判据无日志,不进最坏链
- 主机单测 45/45(新增 TC-ADCHEALTH-012..017 共 14 条断言:首拍只建
基线 / 超拍不判老 / 门限含等号 / 跨 tick 回绕 / 倒退按分工不判老 /
双观察者互不污染 / 空参数不判老)
- 台架注入(一次性停 TIM3 300 ms 探针,验完已撤;探针固件与最终
固件 Program Size 逐字节相同):
[+5462ms] [ERR][PROT] sampling FAULT -> protection blocked (hf=0x00000100)
[+5757ms] [PROT] sampling recovered -> protection resumed
同次开机 [ADC_TASK] health: 出现 0 次;MISS 快照缺的正是 bit6
(0x1F7 - 0x1B7 = 0x40,实证 adcTask 当时确实阻塞,不是"在跑但没报");
reset cause: 仅 1 次(300 ms 被 800 ms 容忍吸收,闭锁不靠复位)
证据 docs/测试说明/boot_log_data/20261021_probe_stale_gate_boot_log.txt
- 字节卫生:BOM 无 / CR 0 / U+FFFD 0;新增行无非法字面量
gate: only mem-budget/mem-numbers pending lead ledger backfill
pre-commit run --all-files 失败仅这两条(台账受管快照与本树 .map 的
Flash/RAM 水位漂移,即本次 +96 B Code、+24 B ZI)。按并行分支约定
本分支不跑 --write,台账由 Lead 合并后统一回填。
文档同步:设计说明新增 §15(原相关文档改 §16),§7 位表加 bit8 行、
§8 责任矩阵与「已知窗口」段改写为已闭合、§10 盲区第 3 条、§11 用例
表、§12 判读手册、§14 改前清单全部跟上;位图配图补 bit8 列,并修掉
生成器把列数硬编码成 8 列导致新列被备注框盖住的问题。
---
App/algo/adc_health.c | 40 ++
App/algo/adc_health.h | 40 ++
App/task/protect_task.c | 37 +-
docs/images/采样有效性位图与掩码.svg | 366 ++++++++++--------
.../20261021_probe_stale_gate_boot_log.txt | 172 ++++++++
docs/测试说明/boot_log_data/README.md | 2 +
docs/算法/采样有效性校验设计说明.md | 96 ++++-
test/host/adc_health/adc_health_test.c | 131 +++++++
.../arch_diagram/gen_adc_health_bitmap_svg.py | 33 +-
9 files changed, 718 insertions(+), 199 deletions(-)
create mode 100644 docs/测试说明/boot_log_data/20261021_probe_stale_gate_boot_log.txt
diff --git a/App/algo/adc_health.c b/App/algo/adc_health.c
index 77cccc38..a0182bf0 100644
--- a/App/algo/adc_health.c
+++ b/App/algo/adc_health.c
@@ -7,6 +7,9 @@
* 创建日期:2026-10-11
* 修改记录:
* v1.0 2026-10-11 王建锋 创建:L1-6 采样有效性校验
+ * v1.1 2026-10-21 王建锋 新增 adc_health_age_stale():无模块状态的帧年龄
+ * 纯判据(参照系由调用方自存),供 protectTask 的
+ * 兜底新鲜度门使用,见 adc_health.h 同名声明
*/
#include "adc_health.h"
@@ -166,6 +169,43 @@ void adc_health_check(const adc_health_in_t *in, adc_health_status_t *out) {
}
}
+/*
+ * 函数功能:帧年龄判定(无模块状态,保护侧兜底门专用)
+ * 入口参数:见 adc_health.h 同名声明
+ * 返 回 值:1 - 已陈旧;0 - 新鲜或仍在建立基线
+ * 限定条件:任意任务上下文;不读写本模块任何 static(与 check() 的关键区别)
+ * 函数说明:三步——建基线 → 有前进即刷新参照系 → 原地不动才量"距最后一次前进"。
+ * 倒退也按"有变化"处理(本函数不判单调性,那归 ADC_HF_SEQ_BACKWARD)。
+ */
+uint8_t adc_health_age_stale(adc_health_age_t *a_p_age, uint32_t a_cur_seq,
+ uint32_t a_now_ms, uint32_t a_age_ms) {
+ if (a_p_age == 0) {
+ return 0u;
+ }
+
+ /* 1) 首拍建基线:此刻序号往往已非 0(TIM3 比调度器早约 100 ms 就在生产帧),
+ * 没有"上一拍"就没有参照系——开机窗口内绝不允许把保护锁掉 */
+ if (a_p_age->primed == 0u) {
+ a_p_age->primed = 1u;
+ a_p_age->seq = a_cur_seq;
+ a_p_age->adv_ms = a_now_ms;
+ return 0u;
+ }
+
+ /* 2) 有任何前进(含跨多帧、含跨回绕、含倒退)即算新鲜,并刷新参照系 */
+ if (a_cur_seq != a_p_age->seq) {
+ a_p_age->seq = a_cur_seq;
+ a_p_age->adv_ms = a_now_ms;
+ return 0u;
+ }
+
+ /* 3) 序号原地不动:只看"距最后一次前进多久",含等号(与 stall 判据同边界) */
+ if ((uint32_t)(int32_t)(a_now_ms - a_p_age->adv_ms) >= a_age_ms) {
+ return 1u;
+ }
+ return 0u;
+}
+
/*
* 函数功能:读当前电平故障位
* 入口参数:无
diff --git a/App/algo/adc_health.h b/App/algo/adc_health.h
index 2e24df6f..d05dd594 100644
--- a/App/algo/adc_health.h
+++ b/App/algo/adc_health.h
@@ -14,6 +14,13 @@
* 创建日期:2026-10-11
* 修改记录:
* v1.0 2026-10-11 王建锋 创建:L1-6 采样有效性校验(静默/丢帧/单调性/错误增量)
+ * v1.1 2026-10-21 王建锋 新增 adc_health_age_stale()(无模块状态的帧年龄
+ * 纯判据,参照系由调用方自存)与 ADC_HF_STALE 位:
+ * 判定层的 stall/backward 位只在 adcTask 每拍跑
+ * check() 时才可能置起,节拍源停摆会把本任务卡在
+ * osThreadFlagsWait 上 → 那些位永远是 0,而相量冻在
+ * 最后一帧仍被 protectTask 用于判据。该纯判据让保护侧
+ * 能用**自己的时基 + 中断侧计数器**独立判"数据还新不新"
*/
#ifndef ADC_HEALTH_H
@@ -51,6 +58,13 @@ extern "C" {
#define ADC_HE_FRSTDATA (1uL << 3) /* 本次 FRSTDATA 计数有增量 */
#define ADC_HE_HW_ERR (1uL << 5) /* 本次 HAL 错误计数有增量 */
+/* 兜底新鲜度位(bit8,与判定层 bit0..bit7 位段错开)
+ * ⚠ 本位**不由本模块置位**:由 protectTask 调 adc_health_age_stale() 独立观测后
+ * OR 进闭锁位图。因此它不进 sticky、不进 adc_task 编排层的 logged 位图、
+ * 也不由本模块打日志——留痕走 protect 侧既有的闭锁边沿日志与 SOE。
+ * 位段纪律见 docs/算法/采样有效性校验设计说明.md §14 */
+#define ADC_HF_STALE (1uL << 8) /* 帧年龄超限(电平,序号恢复前进即清) */
+
/* 计数快照(由调用方采集,本模块不触碰任何驱动/外设) */
typedef struct {
uint32_t tpafe_seq; /* tpafe5160_frame_seq() */
@@ -71,6 +85,15 @@ typedef struct {
uint32_t tpafe_stall_ms; /* TPAFE 当前静默时长(0=正常) */
} adc_health_status_t;
+/* 帧年龄参照系(adc_health_age_stale() 用)。状态**归调用方所有**,所以多个观察者
+ * 各判各的、互不污染——这是它与本模块其余接口的关键区别(check() 用的是模块级
+ * 单写者状态,只能有一个每拍调用者) */
+typedef struct {
+ uint32_t seq; /* 上次看到的序号 */
+ uint32_t adv_ms; /* 最后一次看到序号前进的时刻(与调用方时基同源) */
+ uint8_t primed; /* 0 = 尚未建立基线(首拍只记录、不判定) */
+} adc_health_age_t;
+
/*
* 函数功能:采样有效性校验初始化(幂等,内核启动前可调)
* 入口参数:无
@@ -91,6 +114,23 @@ void adc_health_init(void);
*/
void adc_health_check(const adc_health_in_t *in, adc_health_status_t *out);
+/*
+ * 函数功能:帧年龄判定(无模块状态,供与消费者存活解耦的兜底门使用)
+ * 入口参数:a_p_age - 调用方自存的参照系
+ * a_cur_seq - 本次读到的序号(须是**中断侧**递增的计数器,如
+ * tpafe5160_frame_seq() / adc_input_stm32_seq())
+ * a_now_ms - 本次时基(与 a_p_age->adv_ms 取同一个任务的 tick)
+ * a_age_ms - 判老门限(ms)
+ * 返 回 值:1 - 已陈旧(调用方应据此闭锁);0 - 新鲜,或仍在建立基线
+ * 限定条件:任意任务上下文;零内核依赖、零共享状态 → 主机侧免桩可直接单测
+ * 函数说明:差值口径与 adc_health_check() 一致((int32_t) 差值,计数与 tick
+ * 回绕均安全)。首次调用只建基线并返回 0;序号有任何前进即返回 0
+ * (一次前进多帧属消费者超拍,不是故障);倒退不在此判老,单调性
+ * 破坏归 ADC_HF_SEQ_BACKWARD 管。参数错返回 0——宁可不锁,不可误锁。
+ */
+uint8_t adc_health_age_stale(adc_health_age_t *a_p_age, uint32_t a_cur_seq,
+ uint32_t a_now_ms, uint32_t a_age_ms);
+
/*
* 函数功能:读当前电平故障位
* 入口参数:无
diff --git a/App/task/protect_task.c b/App/task/protect_task.c
index 6647094e..24191694 100644
--- a/App/task/protect_task.c
+++ b/App/task/protect_task.c
@@ -13,6 +13,13 @@
* 线程约定:prot_elm_run / prot_set_selfcheck 仅本任务上下文(prot_elm.h);
* 跨上下文复归(104 YK11)经 prot_elm_request_reset 落请求。
* 创建日期:2026-10-12(B5)
+ * 修改记录:
+ * v1.0 2026-10-12 王建锋 创建:L5-4 采样故障闭锁 + L5-1 定值巡检 + 判据节拍
+ * v1.1 2026-10-21 王建锋 ①之外增加**兜底新鲜度门**:本任务用自己的时基直接
+ * 看两个中断侧计数器是否还在前进(ADC_HF_STALE)。
+ * adc_health 的 stall 位依赖 adcTask 每拍跑 check(),
+ * 节拍源一停本任务就再没有任何人判"数据新不新",
+ * 而相量冻在最后一帧上还会被本任务继续用于判据
*/
#include "protect_task.h"
@@ -22,17 +29,33 @@
#define DBG_LINE_BUF_MAX 128U
#include "dbg_log.h"
+#include "adc_input.h" /* adc_input_stm32_seq():DMA 完成计数(中断侧写) */
#include "adc_health.h"
#include "console_log.h" /* dbg_console_writer_ready():启动锚点行门控(B6) */
#include "prot_elm.h"
#include "prot_set.h"
#include "soe_store.h" /* B9:动作/复归边沿 → SOE 投递(零 FS,队列异步) */
#include "task_watchdog.h"
+#include "tpafe5160.h" /* tpafe5160_frame_seq():帧序号(BUSY EXTI 写) */
/* L5-4 采样故障电平闭锁位:stall(可自愈)+ backward(锁存)都须停判据。
* 锁存类(FRSTDATA/HW_ERR 等)不在此列——它们是一次性事件痕迹,不构成
* "当前测量不可信";持续 stall/backward 才是。 */
-#define PROT_HF_LOCK_MASK (ADC_HF_TPAFE_STALL | ADC_HF_STM32_STALL | ADC_HF_SEQ_BACKWARD)
+#define PROT_HF_LOCK_MASK (ADC_HF_TPAFE_STALL | ADC_HF_STM32_STALL | \
+ ADC_HF_SEQ_BACKWARD | ADC_HF_STALE)
+
+/* 兜底新鲜度门限。判定层的 stall 是 100 ms,但那是"每拍都在跑的观察者"的口径;
+ * 本门每 20 ms 才观测一次,最坏比判定层晚一整周期 → 取 100 + 20 = 120 ms,让
+ * "判定层先报(带日志与自检留痕)、本门兜底(判定层死了也报)"的时序阶梯成立。
+ * 排查时 hf 里 0x1/0x2 与 0x100 的先后,本身就是"判定层当时活着吗"的答案。 */
+#define PROT_STALE_AGE_MS (ADC_HEALTH_STALL_MS + PROTECT_TASK_CYCLE_MS)
+
+/* 兜底门的参照系:两条车道各存一份。⚠ primed 标志也必须各自独立——若共用一个,
+ * 先被观测的那条会把它置 1,另一条首拍就拿 adv_ms = 0 去算年龄 → 开机即误锁。
+ * 状态放调用方而不是复用 adc_health 的模块级状态:那个是"每拍一次、单写者"的
+ * 契约,两个任务同写会互相刷新差值基线,把现有判定层一起弄坏。 */
+static adc_health_age_t s_age_tp; /* TPAFE 车道 */
+static adc_health_age_t s_age_a1; /* STM32 车道 */
static uint32_t s_prev_active; /* 上拍动作位图(边沿日志用) */
static uint8_t s_prev_hf_lock; /* 上拍采样闭锁态(边沿日志用) */
@@ -89,6 +112,18 @@ void protect_task(void *argument) {
uint32_t now = osKernelGetTickCount();
uint32_t hf = adc_health_flags();
+ /* 兜底新鲜度门(L5-4 的独立通道):节拍源停摆时 adcTask 会永久阻塞在
+ * osThreadFlagsWait(那是刻意的 fail-safe),check() 不再执行 → stall 位
+ * 永远是 0。这里用本任务自己的时基直接读两个**中断侧递增**的计数器,
+ * 与 adcTask 的存亡无关;两者都只要 TIM3 还在触发就会前进。
+ * STM32 车道只更新参照系、不额外置位:任一侧老了就同一个位闭锁。 */
+ if (adc_health_age_stale(&s_age_tp, tpafe5160_frame_seq(), now,
+ PROT_STALE_AGE_MS) != 0u) {
+ hf |= ADC_HF_STALE;
+ }
+ (void)adc_health_age_stale(&s_age_a1, adc_input_stm32_seq(), now,
+ PROT_STALE_AGE_MS);
+
/* 启动锚点行:控制台写者注册后首拍补打(2026-10-10 门控纪律,adcTask
* 首帧同款)。写者注册前 con_append 走无锁裸发,与 defaultTask 启动日志
* 逐字节交错——B6 台架实证:本行曾损坏为 "st[art: cycle=220ms elm=70"
diff --git a/docs/images/采样有效性位图与掩码.svg b/docs/images/采样有效性位图与掩码.svg
index cd102b91..68d64670 100644
--- a/docs/images/采样有效性位图与掩码.svg
+++ b/docs/images/采样有效性位图与掩码.svg
@@ -5,204 +5,226 @@
判定层写出的三张位图(adc_health.c)
flags 电平位
adc_health_flags(),当前活跃
-
+
bit0
TPAFE_STALL
-
-bit1
-STM32_STALL
-
-bit2
-SEQ_BACKWARD
-
-bit3
-—
-
-bit4
-—
-
-bit5
-—
-
-bit6
-—
-
-bit7
-—
-
-protectTask 每 20 ms 读它做 L5-4 闭锁;不看事件位
+
+bit1
+STM32_STALL
+
+bit2
+SEQ_BACKWARD
+
+bit3
+—
+
+bit4
+—
+
+bit5
+—
+
+bit6
+—
+
+bit7
+—
+
+bit8
+STALE
+
+protectTask 每 20 ms 读它做 L5-4 闭锁;bit8 由
+本任务自己判老后 OR 入(判定层不写)
sticky 锁存位
adc_health_sticky(),直到 init 才清
-
+
bit0
TPAFE_STALL
-
-bit1
-STM32_STALL
-
-bit2
-SEQ_BACKWARD
-
-bit3
-FRSTDATA
-
-bit4
-STM32_ERR
-
-bit5
-HW_ERR
-
-bit6
-不入锁存
-
-bit7
-不入锁存
-
-只留痕不闭锁:曾停摆/曾出错必须可追溯
+
+bit1
+STM32_STALL
+
+bit2
+SEQ_BACKWARD
+
+bit3
+FRSTDATA
+
+bit4
+STM32_ERR
+
+bit5
+HW_ERR
+
+bit6
+不入锁存
+
+bit7
+不入锁存
+
+bit8
+不入锁存
+
+只留痕不闭锁:曾停摆/曾出错必须可追溯
events 事件位
adc_health_check() 出参,仅当拍有效
-
+
bit0
—
-
-bit1
-—
-
-bit2
-—
-
-bit3
-FRSTDATA
-
-bit4
-STM32_ERR
-
-bit5
-HW_ERR
-
-bit6
-TPAFE_GAP
-
-bit7
-STM32_GAP
-
-adc_health_report() 的唯一输入:按位分三级处置
+
+bit1
+—
+
+bit2
+—
+
+bit3
+FRSTDATA
+
+bit4
+STM32_ERR
+
+bit5
+HW_ERR
+
+bit6
+TPAFE_GAP
+
+bit7
+STM32_GAP
+
+bit8
+—
+
+adc_health_report() 的唯一输入:按位分三级处置
消费者侧的掩码与记忆位图
PROT_HF_LOCK_MASK
protect_task.c 编译期常量
-
+
bit0
闭锁
-
-bit1
-闭锁
-
-bit2
-闭锁
-
-bit3
-不闭锁
-
-bit4
-不闭锁
-
-bit5
-不闭锁
-
-bit6
-—
-
-bit7
-—
-
-命中即 prot_elm_init() 复位元件 + 本拍不 eval;SOE
- 类型 5/6
+
+bit1
+闭锁
+
+bit2
+闭锁
+
+bit3
+不闭锁
+
+bit4
+不闭锁
+
+bit5
+不闭锁
+
+bit6
+—
+
+bit7
+—
+
+bit8
+闭锁
+
+命中即 prot_elm_init() 复位元件 + 本拍不 eval;
+SOE 5/6 带位图
serious 严重事件
adc_task.c 的 adc_health_report()
-
+
bit0
—
-
-bit1
-—
-
-bit2
-—
-
-bit3
-FRSTDATA
-
-bit4
-STM32_ERR
-
-bit5
-HW_ERR
-
-bit6
-—
-
-bit7
-—
-
-三类计数增量恒应为 0 → 每次 DBG_ERROR + 自检留痕
+
+bit1
+—
+
+bit2
+—
+
+bit3
+FRSTDATA
+
+bit4
+STM32_ERR
+
+bit5
+HW_ERR
+
+bit6
+—
+
+bit7
+—
+
+bit8
+—
+
+三类计数增量恒应为 0 → 每次 DBG_ERROR + 自检留痕
gap 丢帧事件
同一函数里的丢帧分支
-
+
bit0
—
-
-bit1
-—
-
-bit2
-—
-
-bit3
-—
-
-bit4
-—
-
-bit5
-—
-
-bit6
-TPAFE_GAP
-
-bit7
-STM32_GAP
-
-刻意不告警:超拍是常态(启动突发期即累计 23 次),只累
-计
+
+bit1
+—
+
+bit2
+—
+
+bit3
+—
+
+bit4
+—
+
+bit5
+—
+
+bit6
+TPAFE_GAP
+
+bit7
+STM32_GAP
+
+bit8
+—
+
+刻意不告警:超拍是常态(启动突发期即累计 23 次),
+只累计
s_health_logged
编排层记忆位图(每类一次留痕)
-
+
bit0
记过
-
-bit1
-记过
-
-bit2
-记过
-
-bit3
-记过
-
-bit4
-记过
-
-bit5
-记过
-
-bit6
-不记
-
-bit7
-不记
-
- |= (serious | rise):用同一位号把 SYSLOG 压成每类
-每次开机一条
+
+bit1
+记过
+
+bit2
+记过
+
+bit3
+记过
+
+bit4
+记过
+
+bit5
+记过
+
+bit6
+不记
+
+bit7
+不记
+
+bit8
+不记
+
+ |= (serious | rise):用同一位号把 SYSLOG 压成
+每类每次开机一条
电平位(可自愈)
@@ -214,5 +236,5 @@
不适用
-bit3 / bit4 / bit5 在 events 与 sticky 中刻意用同一位号 → 消费者能把事件位直接套在锁存位图上做每类一次留痕;bit8 以上全 32 位保留未用。详见 docs/算法/采样有效性校验设计说明.md
+bit3 / bit4 / bit5 在 events 与 sticky 中刻意用同一位号 → 消费者能把事件位直接套在锁存位图上做每类一次留痕;bit8 = 保护侧兜底门专用(判定层不写它)。详见 docs/算法/采样有效性校验设计说明.md
diff --git a/docs/测试说明/boot_log_data/20261021_probe_stale_gate_boot_log.txt b/docs/测试说明/boot_log_data/20261021_probe_stale_gate_boot_log.txt
new file mode 100644
index 00000000..2ed0ae56
--- /dev/null
+++ b/docs/测试说明/boot_log_data/20261021_probe_stale_gate_boot_log.txt
@@ -0,0 +1,172 @@
+[+0ms] [----/--/-- --:--:--][APP] boot: syslog init OK
+[+0ms] [----/--/-- --:--:--][APP] fw build: Oct 21 2026 14:36:36 suite=NONE dstr=off
+[+10ms] [----/--/-- --:--:--][APP] reset cause: software (NVIC_SystemReset) (CSR=0x14000000)
+[+21ms] PREVRUN seg=2 seq=2099812320 t=118563808
+[+21ms] TICKRUN uw=118563818 n=118563818 duw=10
+[+32ms] [----/--/-- --:--:--][RTC] sd2506_init: OK
+[+32ms] [----/--/-- --:--:--][APP] SD2506 init: OK (ret=0)
+[+42ms] [2026-10-21 15:40:36][CLK] sys_clock_init: RTC initial time 2026-10-21 15:40:36
+[+42ms] [2026-10-21 15:40:36][CLK] sys_clock_init: wall clock base = unix 1792597236, base_tick=140
+[+53ms] [2026-10-21 15:40:36][APP] RS485 init: OK (UART4, DE=PA15)
+[+74ms] [2026-10-21 15:40:36][NAND] Resetting NAND...
+[+84ms] [2026-10-21 15:40:36][NAND] Reading NAND ID...
+[+84ms] [2026-10-21 15:40:36][NAND] NAND ID: MID=0xC8, DID=0x52
+[+96ms] [2026-10-21 15:40:36][NAND] Scanning bad blocks...
+[+232ms] [2026-10-21 15:40:36][NAND] BBT scan: 0 bad blocks found
+[+232ms] [2026-10-21 15:40:36][NAND] Enabling ECC...
+[+242ms] [2026-10-21 15:40:36][NAND] Unlocking block protection...
+[+242ms] [2026-10-21 15:40:36][NAND] Locating BBT pool...
+[+253ms] [2026-10-21 15:40:36][NAND] Loading persisted BBT...
+[+253ms] [2026-10-21 15:40:36][NAND] NAND init OK
+[+263ms] [2026-10-21 15:40:36][APP] NAND init: OK
+[+263ms] [2026-10-21 15:40:36][ADC] Init OK
+[+263ms] [2026-10-21 15:40:36][APP] ADC init: OK
+[+274ms] [2026-10-21 15:40:36][APP] ADC input layer init: OK
+[+274ms] [2026-10-21 15:40:36][APP] phasor layer init: OK (inst=5)
+[+284ms] [2026-10-21 15:40:36][APP] freq layer init: OK
+[+284ms] [2026-10-21 15:40:36][APP] protection init: OK (set=40 elm=9)
+[+295ms] [2026-10-21 15:40:36][APP] SD2506 ID: 2512153425482405
+[+295ms] [2026-10-21 15:40:36][APP] SD2506 time: 2026-10-21 15:40:37 (week 3)
+[+305ms] [2026-10-21 15:40:36][APP] SD2506 temp: 25 C
+[+305ms] [2026-10-21 15:40:36][APP] SD2506 battery: 3.12 V
+[+315ms] [2026-10-21 15:40:36][APP] App init OK
+[+315ms] [2026-10-21 15:40:36][NAND_FTL] Initializing NAND FTL...
+[+419ms] [2026-10-21 15:40:36][NAND_FTL] Map resumed: 14094/97504 sectors used
+[+419ms] [2026-10-21 15:40:36][NAND_FTL] Map head=38176 tail=16200 epoch=3
+[+430ms] [2026-10-21 15:40:36][NAND_FTL] NAND FTL ready (capacity: 97504 pages = 190 MB)
+[+514ms] [2026-10-21 15:40:36] [FS] FATFS mounted
+[+576ms] [2026-10-21 15:40:36][SYSLOG] runlog boot entry: power on
+[+586ms] [2026-10-21 15:40:36][CFG] register: section 'network' (4 keys)
+[+586ms] [2026-10-21 15:40:36][CFG] register: section 'ftp' (3 keys)
+[+596ms] [2026-10-21 15:40:36][CFG] load: OK (523B, heap free 16552B)
+[+607ms] [2026-10-21 15:40:36][NET_CFG] load: OK ip=192.168.1.100
+[+607ms] [2026-10-21 15:40:36] [CFG] prot set loaded (/prot_set.bin)
+[+628ms] [2026-10-21 15:40:36][ADC_TASK] first: UA=11428 UB=1079 UC=919 UN=11452 IA=11452 IB=1027 IC=1005 IN=951 T=984
+[+638ms] [2026-10-21 15:40:36][ADC_TASK] phasor(first) C01-IA-F1: Re=0 Im=0 rms=0 (x0.1)
+[+649ms] [2026-10-21 15:40:36][ADC_TASK] phasor(first) C02-UA-F1: Re=0 Im=3 rms=3 (x0.1)
+[+659ms] [2026-10-21 15:40:36][ADC_TASK] phasor(first) C03-UN-F1: Re=3 Im=1 rms=3 (x0.1)
+[+659ms] [2026-10-21 15:40:36][ADC_TASK] phasor(first) C04-IA-F2: Re=0 Im=3 rms=3 (x0.1)
+[+670ms] [2026-10-21 15:40:36][ADC_TASK] phasor(first) C05-IA-F5: Re=0 Im=0 rms=0 (x0.1)
+[+681ms] [2026-10-21 15:40:36][ADC_TASK] freq(first): f=0 valid=0 (x0.01)
+[+681ms] [2026-10-21 15:40:36][NET_TASK] === Network Init ===
+[+681ms] [2026-10-21 15:40:36][PROT] protect task start: cycle=20ms elm=9
+[+691ms] [2026-10-21 15:40:36][NET] ch395f ver=0x4A fun_para=0x08
+[+702ms] MISS start snap=0x000001E7 req=0x000001F7 t=800
+[+702ms] [2026-10-21 15:40:36][WD] [WD] armed: timeout ~2498ms (worst ~1701ms), miss_tol=800ms
+[+713ms] [2026-10-21 15:40:36][NET_TASK] net init: OK
+[+713ms] [2026-10-21 15:40:36][NET_TASK] MAC: 3C:AB:72:53:2B:12
+[+723ms] [2026-10-21 15:40:36][NET_TASK] IP_INF: IP=192.168.1.100 GW=192.168.1.1 MASK=255.255.255.0
+[+733ms] [2026-10-21 15:40:36][UI_TASK] UI task: started, wait net ready
+[+2727ms] [2026-10-21 15:40:38][NET_TASK] PHY: 100M FULL
+[+2738ms] [2026-10-21 15:40:38][NET_TASK] netTask: started (single-socket mode)
+[+2769ms] [2026-10-21 15:40:38] [FTP] using factory default password
+[+2800ms] [2026-10-21 15:40:38][TIME_SYNC] time_sync: TCP listen on :8888 (sock=0)
+[+2832ms] [2026-10-21 15:40:38][I104] i104: listening on 2404
+[+2842ms] [2026-10-21 15:40:38] [FTP] waiting for connection...
+[+2853ms] [2026-10-21 15:40:38][UI_TASK] UI task: net ready, init port
+[+2853ms] [2026-10-21 15:40:38][UI_TASK] UI task: ui_port_init enter
+[+2863ms] [2026-10-21 15:40:38][UI_PORT] theme: mono dark_bg=true applied
+[+2863ms] [2026-10-21 15:40:38][UI_TASK] UI port init: OK
+[+2874ms] [2026-10-21 15:40:38][UI_TASK] UI task: ui_menu_init enter
+[+2874ms] [2026-10-21 15:40:38][UI_MENU] menu build start: total=15136 free=12692 big=12692
+[+2884ms] [2026-10-21 15:40:38][UI_MENU_VIEW] view init: root frame w=66 h=107
+[+2895ms] [2026-10-21 15:40:38][UI_MENU] menu build done: total=15020 free=11828 big=11816 used=3192
+[+2905ms] [2026-10-21 15:40:38][UI_TASK] UI menu init: OK
+[+2905ms] [2026-10-21 15:40:38][UI_STANDBY] standby: ready (idle=60000ms)
+[+2916ms] [2026-10-21 15:40:38][UI_TASK] UI standby: ready (idle=60s)
+[+2968ms] [2026-10-21 15:40:38][UI_TASK] UI net sink: started (port 7003)
+[+2968ms] MISS start snap=0x000001E7 req=0x000001F7 t=3071
+[+2968ms] [PROF] t=3075 uic=0 uip=0 uipn=0 uil=0 uiw=0 uis=0 uisn=0 adc=2655 adcm=2286 adcn=0 gl=0
+[+2979ms] [PROF2] t=3075 defd=130915 defs=14 netp=3013 netn=577 adcp=11 adcq=0 gl=0
+[+4936ms] [PROF] t=5040 uic=176 uip=21760 uipn=2849 uil=11000 uiw=11852 uis=1292 uisn=0 adc=4619 adcm=2286 adcn=0 gl=0
+[+4947ms] [PROF2] t=5040 defd=130915 defs=14 netp=3013 netn=0 adcp=11 adcq=0 gl=0
+[+5356ms] [2026-10-21 15:40:41] [STACK] default peak_used=2152/5120 free_min=2968 (57%)
+[+5367ms] [2026-10-21 15:40:41] [STACK] net peak_used=1116/3840 free_min=2724 (70%)
+[+5367ms] [2026-10-21 15:40:41] [STACK] ftp peak_used=740/4096 free_min=3356 (81%)
+[+5378ms] [2026-10-21 15:40:41] [STACK] ui peak_used=4016/16384 free_min=12368 (75%)
+[+5388ms] [2026-10-21 15:40:41] [STACK] timesync peak_used=616/2560 free_min=1944 (75%)
+[+5388ms] [2026-10-21 15:40:41] [STACK] adc peak_used=952/2560 free_min=1608 (62%)
+[+5399ms] [2026-10-21 15:40:41] [STACK] iec104 peak_used=540/2560 free_min=2020 (78%)
+[+5410ms] [2026-10-21 15:40:41] [STACK] protect peak_used=620/2048 free_min=1428 (69%)
+[+5410ms] [2026-10-21 15:40:41] [STACK] heap free=15800 min_ever=8464
+[+5462ms] [2026-10-21 15:40:41][ERR][PROT] sampling FAULT -> protection blocked (hf=0x00000100)
+[+5555ms] MISS start snap=0x000001B7 req=0x000001F7 t=5656
+[+5555ms] [PROF] t=5660 uic=222 uip=67678 uipn=10394 uil=10999 uiw=11852 uis=1292 uisn=0 adc=5019 adcm=2286 adcn=0 gl=0
+[+5566ms] [PROF2] t=5660 defd=130915 defs=38287 netp=3013 netn=0 adcp=11 adcq=0 gl=0
+[+5757ms] [2026-10-21 15:40:41][PROT] sampling recovered -> protection resumed
+[+6959ms] [PROF] t=7056 uic=344 uip=67678 uipn=6848 uil=11000 uiw=11852 uis=1292 uisn=0 adc=6235 adcm=2286 adcn=0 gl=0
+[+6959ms] [PROF2] t=7056 defd=130915 defs=38944 netp=4997 netn=0 adcp=11 adcq=0 gl=0
+[+8972ms] [PROF] t=9074 uic=526 uip=67678 uipn=9857 uil=10999 uiw=11852 uis=1292 uisn=0 adc=8253 adcm=2286 adcn=0 gl=0
+[+8982ms] [PROF2] t=9074 defd=130915 defs=38944 netp=4997 netn=0 adcp=11 adcq=0 gl=0
+[+10322ms] [2026-10-21 15:40:46][ADC_TASK] frames=9601 idle=0 skip=65 seq=9606 frst_err=0 ring=9536 adc1_rc=0 adc1_err=0 adc1_seq=9604 adc1_gap=65 adc1_hw=0 tgap=1
+[+10332ms] [2026-10-21 15:40:46][ADC_TASK] phasor(hb) C01-IA-F1: Re=0 Im=0 rms=0 (x0.1)
+[+10343ms] [2026-10-21 15:40:46][ADC_TASK] phasor(hb) C02-UA-F1: Re=0 Im=0 rms=0 (x0.1)
+[+10343ms] [2026-10-21 15:40:46][ADC_TASK] phasor(hb) C03-UN-F1: Re=0 Im=0 rms=0 (x0.1)
+[+10354ms] [2026-10-21 15:40:46][ADC_TASK] phasor(hb) C04-IA-F2: Re=3 Im=-1 rms=3 (x0.1)
+[+10364ms] [2026-10-21 15:40:46][ADC_TASK] phasor(hb) C05-IA-F5: Re=0 Im=-2 rms=2 (x0.1)
+[+10364ms] [2026-10-21 15:40:46][ADC_TASK] freq(hb): f=0 valid=0 (x0.01)
+[+10974ms] [PROF] t=11076 uic=702 uip=67678 uipn=5857 uil=10999 uiw=11852 uis=1292 uisn=0 adc=10254 adcm=2286 adcn=0 gl=0
+[+10984ms] [PROF2] t=11076 defd=130915 defs=38944 netp=5012 netn=0 adcp=11 adcq=0 gl=0
+[+12996ms] [PROF] t=13094 uic=884 uip=67678 uipn=9850 uil=10999 uiw=11852 uis=1292 uisn=0 adc=12272 adcm=2286 adcn=0 gl=0
+[+13007ms] [PROF2] t=13094 defd=130915 defs=38944 netp=5012 netn=0 adcp=11 adcq=0 gl=0
+[+15014ms] [PROF] t=15112 uic=1066 uip=67678 uipn=9849 uil=10999 uiw=11852 uis=1292 uisn=0 adc=14290 adcm=2286 adcn=0 gl=0
+[+15025ms] [PROF2] t=15112 defd=130915 defs=38944 netp=5012 netn=0 adcp=11 adcq=0 gl=0
+[+15770ms] [2026-10-21 15:40:51][PROT] elm UV-UA asserted=1 picked=1
+[+17016ms] [PROF] t=17113 uic=1243 uip=67678 uipn=1848 uil=11000 uiw=11852 uis=1292 uisn=0 adc=16291 adcm=2286 adcn=0 gl=0
+[+17016ms] [PROF2] t=17113 defd=130915 defs=40473 netp=5012 netn=0 adcp=11 adcq=0 gl=0
+[+17433ms] [2026-10-21 15:40:53] [STACK] default peak_used=2152/5120 free_min=2968 (57%)
+[+17433ms] [2026-10-21 15:40:53] [STACK] net peak_used=1116/3840 free_min=2724 (70%)
+[+17444ms] [2026-10-21 15:40:53] [STACK] ftp peak_used=740/4096 free_min=3356 (81%)
+[+17454ms] [2026-10-21 15:40:53] [STACK] ui peak_used=4016/16384 free_min=12368 (75%)
+[+17454ms] [2026-10-21 15:40:53] [STACK] timesync peak_used=616/2560 free_min=1944 (75%)
+[+17465ms] [2026-10-21 15:40:53] [STACK] adc peak_used=952/2560 free_min=1608 (62%)
+[+17475ms] [2026-10-21 15:40:53] [STACK] iec104 peak_used=540/2560 free_min=2020 (78%)
+[+17475ms] [2026-10-21 15:40:53] [STACK] protect peak_used=624/2048 free_min=1424 (69%)
+[+17486ms] [2026-10-21 15:40:53] [STACK] heap free=15800 min_ever=8464
+[+19045ms] [PROF] t=19144 uic=1421 uip=67990 uipn=9849 uil=10997 uiw=11852 uis=1292 uisn=0 adc=18322 adcm=2286 adcn=0 gl=0
+[+19055ms] [PROF2] t=19144 defd=130915 defs=40473 netp=5012 netn=0 adcp=11 adcq=0 gl=0
+[+20322ms] [2026-10-21 15:40:56][ADC_TASK] frames=19600 idle=0 skip=85 seq=19606 frst_err=0 ring=19515 adc1_rc=0 adc1_err=0 adc1_seq=19604 adc1_gap=85 adc1_hw=0 tgap=2
+[+20333ms] [2026-10-21 15:40:56][ADC_TASK] phasor(hb) C01-IA-F1: Re=-2 Im=0 rms=2 (x0.1)
+[+20343ms] [2026-10-21 15:40:56][ADC_TASK] phasor(hb) C02-UA-F1: Re=0 Im=-1 rms=1 (x0.1)
+[+20343ms] [2026-10-21 15:40:56][ADC_TASK] phasor(hb) C03-UN-F1: Re=0 Im=0 rms=0 (x0.1)
+[+20354ms] [2026-10-21 15:40:56][ADC_TASK] phasor(hb) C04-IA-F2: Re=0 Im=0 rms=0 (x0.1)
+[+20365ms] [2026-10-21 15:40:56][ADC_TASK] phasor(hb) C05-IA-F5: Re=-1 Im=0 rms=1 (x0.1)
+[+20365ms] [2026-10-21 15:40:56][ADC_TASK] freq(hb): f=0 valid=0 (x0.01)
+[+21070ms] [PROF] t=21176 uic=1600 uip=67990 uipn=6848 uil=10999 uiw=11852 uis=1292 uisn=0 adc=20353 adcm=2286 adcn=0 gl=0
+[+21081ms] [PROF2] t=21176 defd=130915 defs=40473 netp=5012 netn=0 adcp=11 adcq=0 gl=0
+[+23093ms] [PROF] t=23194 uic=1782 uip=67990 uipn=9858 uil=10999 uiw=11852 uis=1292 uisn=0 adc=22371 adcm=2286 adcn=0 gl=0
+[+23103ms] [PROF2] t=23194 defd=130915 defs=40473 netp=5012 netn=0 adcp=11 adcq=0 gl=0
+[+25111ms] [PROF] t=25212 uic=1964 uip=67990 uipn=9849 uil=10999 uiw=11852 uis=1292 uisn=0 adc=24389 adcm=2286 adcn=0 gl=0
+[+25121ms] [PROF2] t=25212 defd=130915 defs=40473 netp=5012 netn=0 adcp=11 adcq=0 gl=0
+[+27128ms] [PROF] t=27230 uic=2146 uip=67990 uipn=9848 uil=10999 uiw=11852 uis=1292 uisn=0 adc=26407 adcm=2286 adcn=0 gl=0
+[+27138ms] [PROF2] t=27230 defd=130915 defs=40473 netp=5012 netn=0 adcp=11 adcq=0 gl=0
+[+29152ms] [PROF] t=29248 uic=2328 uip=67990 uipn=9849 uil=10999 uiw=11852 uis=1292 uisn=0 adc=28425 adcm=2286 adcn=0 gl=0
+[+29152ms] [PROF2] t=29248 defd=130915 defs=40473 netp=5012 netn=0 adcp=11 adcq=0 gl=0
+[+30326ms] [2026-10-21 15:41:06][ADC_TASK] frames=29599 idle=0 skip=107 seq=29606 frst_err=0 ring=29492 adc1_rc=0 adc1_err=0 adc1_seq=29604 adc1_gap=107 adc1_hw=0 tgap=3
+[+30337ms] [2026-10-21 15:41:06][ADC_TASK] phasor(hb) C01-IA-F1: Re=0 Im=0 rms=0 (x0.1)
+[+30337ms] [2026-10-21 15:41:06][ADC_TASK] phasor(hb) C02-UA-F1: Re=0 Im=0 rms=0 (x0.1)
+[+30348ms] [2026-10-21 15:41:06][ADC_TASK] phasor(hb) C03-UN-F1: Re=0 Im=0 rms=0 (x0.1)
+[+30358ms] [2026-10-21 15:41:06][ADC_TASK] phasor(hb) C04-IA-F2: Re=0 Im=-1 rms=1 (x0.1)
+[+30358ms] [2026-10-21 15:41:06][ADC_TASK] phasor(hb) C05-IA-F5: Re=0 Im=1 rms=1 (x0.1)
+[+30369ms] [2026-10-21 15:41:06][ADC_TASK] freq(hb): f=0 valid=0 (x0.01)
+[+31170ms] [PROF] t=31276 uic=2506 uip=67990 uipn=8849 uil=10936 uiw=56623 uis=56617 uisn=0 adc=30452 adcm=2286 adcn=0 gl=0
+[+31181ms] [PROF2] t=31276 defd=130915 defs=40473 netp=5012 netn=0 adcp=11 adcq=0 gl=0
+[+33190ms] [PROF] t=33294 uic=2688 uip=67990 uipn=9849 uil=10994 uiw=56623 uis=56617 uisn=0 adc=32470 adcm=2286 adcn=0 gl=0
+[+33201ms] [PROF2] t=33294 defd=130915 defs=40473 netp=5012 netn=0 adcp=11 adcq=0 gl=0
+[+35214ms] [PROF] t=35312 uic=2870 uip=67990 uipn=9856 uil=10999 uiw=56623 uis=56617 uisn=0 adc=34488 adcm=2286 adcn=0 gl=0
+[+35224ms] [PROF2] t=35312 defd=130915 defs=40473 netp=5012 netn=0 adcp=11 adcq=0 gl=0
+[+37231ms] [PROF] t=37330 uic=3052 uip=67990 uipn=9849 uil=10997 uiw=56623 uis=56617 uisn=0 adc=36506 adcm=2286 adcn=0 gl=0
+[+37242ms] [PROF2] t=37330 defd=130915 defs=40473 netp=5012 netn=0 adcp=11 adcq=0 gl=0
+[+39251ms] [PROF] t=39348 uic=3234 uip=67990 uipn=9849 uil=10999 uiw=56623 uis=56617 uisn=0 adc=38524 adcm=2286 adcn=0 gl=0
+[+39251ms] [PROF2] t=39348 defd=130915 defs=40473 netp=5012 netn=0 adcp=11 adcq=0 gl=0
+[+40326ms] [2026-10-21 15:41:16][ADC_TASK] frames=39598 idle=0 skip=115 seq=39606 frst_err=0 ring=39483 adc1_rc=0 adc1_err=0 adc1_seq=39604 adc1_gap=115 adc1_hw=0 tgap=4
+[+40336ms] [2026-10-21 15:41:16][ADC_TASK] phasor(hb) C01-IA-F1: Re=-1 Im=0 rms=1 (x0.1)
+[+40336ms] [2026-10-21 15:41:16][ADC_TASK] phasor(hb) C02-UA-F1: Re=0 Im=-1 rms=1 (x0.1)
+[+40347ms] [2026-10-21 15:41:16][ADC_TASK] phasor(hb) C03-UN-F1: Re=-1 Im=1 rms=1 (x0.1)
+[+40358ms] [2026-10-21 15:41:16][ADC_TASK] phasor(hb) C04-IA-F2: Re=3 Im=0 rms=3 (x0.1)
+[+40358ms] [2026-10-21 15:41:16][ADC_TASK] phasor(hb) C05-IA-F5: Re=2 Im=0 rms=2 (x0.1)
+[+40368ms] [2026-10-21 15:41:16][ADC_TASK] freq(hb): f=0 valid=0 (x0.01)
+[+41275ms] [PROF] t=41376 uic=3412 uip=67990 uipn=7849 uil=10934 uiw=56623 uis=56617 uisn=0 adc=40551 adcm=2286 adcn=0 gl=0
+[+41286ms] [PROF2] t=41376 defd=130915 defs=40473 netp=5012 netn=0 adcp=11 adcq=0 gl=0
+[+43296ms] [PROF] t=43394 uic=3594 uip=67990 uipn=9849 uil=10999 uiw=56623 uis=56617 uisn=0 adc=42569 adcm=2286 adcn=0 gl=0
+[+43307ms] [PROF2] t=43394 defd=130915 defs=40473 netp=5012 netn=0 adcp=11 adcq=0 gl=0
diff --git a/docs/测试说明/boot_log_data/README.md b/docs/测试说明/boot_log_data/README.md
index 413d2435..14520a5b 100644
--- a/docs/测试说明/boot_log_data/README.md
+++ b/docs/测试说明/boot_log_data/README.md
@@ -20,6 +20,7 @@
| 构建标识 | 建议含:产品 / 测试套件(`prod_none` / `suite_ch395f` …)+ 负载(`idle` / `v5_30min` / `ftp_stress`) |
| 随文件登记 | 在下方 §3 清单登记日期、构建、负载、关键 `[STACK]` 水位(如 defaultTask 峰值) |
| 入库门槛 | **只存有对照价值的快照**——常规 `@test` 通过日志不必入库 |
+| 故障注入日志 | 含**刻意产生**的 `[ERR]` 行(如兜底门闭锁)的日志用自定义标记集采集:`--required` 加闭锁/复归两行、`--forbidden` 去掉 `[ERR]` 保留 `FAIL;ERROR;HardFault`。此类日志的 PASS 只代表注入行为符合预期,不代表标记集等价于日常门禁 |
## 3. 清单
@@ -35,6 +36,7 @@
| 2026-10-12 | 同 PASS 固件(B7+fix)/ **PC 主站 2404 往返**(复测) | 同上全链 18/18 PASS——B7 `set_sealed`+持久化写路径下 C_SE→YX 联动仍成立(B5 遗留"104 YX 联动"腿闭环) | `20261012_B6_iec104_roundtrip_log.txt` |
| 2026-10-12 | 同 PASS 固件 / **B7 定值持久化 + 软压板 YK12 台架腿**(`test/host/iec104_master.py --b7-pre/--b7-post/--b7-mark/--b7-check`,中间 ST-LINK 复位) | pre **21/21**:C_SE 写 7.5+C_RD 回读、YK12 两段 OFF → 0.05 A 低定值**不动作**(保护退出)、YK12 ON → YX15/16 立即动作(COT=3)、收尾留压板退出;post **13/13**:**压板退出态跨复位保持**(开机日志 `global plate OUT -> protection disabled` + 低定值仍不动作)、重启后 YK12 ON 即动作(联动在位);mark/check **4/4+4/4**:**非默认定值 7.5 跨复位保持**(C_RD=7.500,与出厂默认可区分);开机日志两次 `[CFG] prot set loaded (/prot_set.bin)` | `20261012_B7_iec104_persist_log.txt` / `20261012_B7_persist_boot_log.txt` |
| 2026-10-12 | `prod`(B9 `1d96fc9` sync 追平 B8/N33 后重跑,merge `6cbf400` 覆盖代码)/ **B9 SOE 动作记录 + 时标台架腿** | `@test` **PASS**:锚点 `protection init: OK (set=40 elm=9)`、`freq layer init: OK`、`freq(hb): f=0 valid=0`(浮空不发散)、**`prot set blob version change (ver=1 cnt=32), defaults in use`**(B8 换代 INFO ≠ CORRUPT 实证)、零 `[ERR]`、`protect` 828/2048(59%,与静态 872 相符);主站回归 `--b7-pre` **21/21** + `--b7-post` **13/13** 不回归,**自发 YX = M_SP_TB_1 带 CP56 时标**(动作/复归/重启后联动三段,iv=0);FTP 下载 `/log/soe.log` **56 条** `test/host/soe/decode_soe.py --max-elm 16` **PASS**(boot 段内 seq 严格 +1、ELM_ASSERT 26、CP56↔epoch UTC 交叉一致);`.bin` 为原始 64 B 记录可复解码 | `20261012_B9_prod_boot_log.txt` / `20261012_B9_iec104_soe_pre_log.txt` / `20261012_B9_iec104_soe_post_log.txt` / `20261012_B9_soe_pre.bin` + `20261012_B9_soe_after_post.bin` / `20261012_B9_soe_decode_pre.txt` + `20261012_B9_soe_decode_post.txt` |
+| 2026-10-21 | `prod` + **兜底新鲜度门停 TIM3 注入探针**(`task/adc-health-design-doc`,探针已撤)/ 开机 + 45 s | `protect` **620/2048(69%)**、`adc` 与 heap 水位同前;`[ERR][PROT] … blocked (hf=0x00000100)` +5462ms、`recovered` +5757ms、`health:` 0 次、`reset cause:` ×1 | `20261021_probe_stale_gate_boot_log.txt` |
## 4. 关联
diff --git a/docs/算法/采样有效性校验设计说明.md b/docs/算法/采样有效性校验设计说明.md
index 1d0279de..8c465166 100644
--- a/docs/算法/采样有效性校验设计说明.md
+++ b/docs/算法/采样有效性校验设计说明.md
@@ -34,9 +34,9 @@
- [12. 现场判读手册](#12-现场判读手册)
- [13. 自测问题(读完能不能真的懂)](#13-自测问题读完能不能真的懂)
- [14. 改它之前必读](#14-改它之前必读)
-- [15. 相关文档](#15-相关文档)
+- [15. 兜底新鲜度门(保护侧独立通道)](#15-兜底新鲜度门保护侧独立通道)
+- [16. 相关文档](#16-相关文档)
- [变更记录](#变更记录)
-
---
## 1. 三分钟建立心智模型
@@ -312,7 +312,8 @@ else { 清电平位 } /* 只有 stall 类有 else 分支,SEQ_BACKWARD 没有
| 5 | `ADC_HF_HW_ERR` / `ADC_HE_HW_ERR` | 锁存 + 事件 | `stm32_hw_errs` 有变化 | 仅 `adc_health_init()` |
| 6 | `ADC_HE_TPAFE_GAP` | 仅事件 | 本次 TPAFE `d > 1` | 出参,不落状态 |
| 7 | `ADC_HE_STM32_GAP` | 仅事件 | `stm32_gaps` 有变化 | 出参,不落状态 |
-| 8..31 | 保留 | — | 未使用 | — |
+| 8 | `ADC_HF_STALE` | 电平(**由 protectTask 置位**,判定层不写) | 帧年龄 >= 120 ms(§15) | 序号恢复前进 |
+| 9..31 | 保留 | — | 未使用 | — |
**位号对齐是刻意设计**:bit3 / bit4 / bit5 在 `events` 与 `sticky` 里编号相同,编排层因此可以
拿事件位直接去操作「已留痕位图」,一行搞定每类每次开机只留痕一次:
@@ -367,7 +368,7 @@ if (((serious | rise) & ~s_health_logged) != 0u) { /* 位图当去重键 */
| 故障位置 | 谁先发现 | 多久 | 结果 |
|---|---|---|---|
-| TIM3 节拍源停 | **不是本层**:`adcTask` 永久阻塞在 `osThreadFlagsWait`,没人跑判定 | 100 ms 抽样 + 连续 800 ms 缺席起停喂 | IWDG 溢出复位(标称 2498 ms,LSI 偏快约 1701 ms) |
+| TIM3 节拍源停 | 本层**看不见**(`adcTask` 永久阻塞,没人跑判定)→ 由 §15 兜底门判老 | §15:120 ms;看门狗:100 ms 抽样 + 连续 800 ms 停喂 | §15 闭锁保护(≤140 ms);IWDG 溢出复位(标称 2498 ms,LSI 偏快约 1701 ms) |
| TPAFE BUSY EXTI 停 | 本层 `ADC_HF_TPAFE_STALL` | 100 ms | 闭锁保护(任务仍活着,不复位) |
| ADC1 / DMA 停 | 本层 `ADC_HF_STM32_STALL`,L1 的 `stm32_errs` 增量同时给出事件 | 100 ms | 闭锁 + 留痕 |
| HAL 报错 | L1 计数 → 本层 `HE_HW_ERR` | 下一拍 | 事件 + 锁存,不闭锁 |
@@ -375,13 +376,27 @@ if (((serious | rise) & ~s_health_logged) != 0u) { /* 位图当去重键 */
| 帧序号倒退 | 本层 `SEQ_BACKWARD` | 当拍 | 闭锁且不自愈,直到复位 |
一句话概括本层的**能力边界**:它判的是「生产者相对消费者是否前进」,因此**判不了消费者自己**
-是否还活着。后者必须换一个独立机制(IWDG)来实现——两层不能互相替代,也不要指望把门限调小
-就能替代它。
+是否还活着。过去只能靠 IWDG 兜命——它判的是"任务还在不在",不是"数据新不新";现在另有一条与本层
+完全解耦的兜底门专门管这件事(§15)。三者不可互相替代,也别指望把本层门限调小来顶班。
-**如实登记的已知窗口**:TIM3 停摆到 IWDG 复位之间(最坏约 800 ms + 2498 ms ≈ 3.3 s,标称值),
-`protectTask` 仍在 20 ms 轮询、电平位却还没人置(判定随消费者一起停了),相量保持旧值参与判据。
-V0.1 没有出口硬件,后果限于遥信与日志;真要压这个窗口,正解是给相量侧加「帧年龄」判据
-(让 `prot_elm` 自己看数据新鲜度),而不是把 stall 门限调小。
+**这条盲区已闭合(原已知窗口)**:TIM3 停摆到 IWDG 复位之间,原本最坏约 800 ms + 2498 ms(标称)内
+`protectTask` 仍在 20 ms 轮询、而电平位还没人置——相量冻在最后一帧继续参与判据。现在 §15 的兜底门
+在同一工况下 120 ms 判老并闭锁。
+
+台架实测(一次性停 TIM3 探针,验完即撤):
+
+```text
+[+5410ms] [STACK] protect peak_used=620/2048 free_min=1428 (69%)
+[+5462ms] [ERR][PROT] sampling FAULT -> protection blocked (hf=0x00000100)
+[+5555ms] MISS start snap=0x000001B7 req=0x000001F7 t=5656
+[+5757ms] [PROT] sampling recovered -> protection resumed
+```
+
+三条证据同时成立才算闭合:`hf` **只有 bit8、没有 stall 位**(闭锁由兜底门独立给出);同一次开机里
+`[ADC_TASK] health:` 行出现 **0 次**(判定层确实全程失明);`snap` 相对 `req` 缺的正是 bit6 =
+`TASK_WD_ADC`(`0x1F7 − 0x1B7 = 0x40`,实证 adcTask 当时真的阻塞,而不是"还在跑但没报");
+`reset cause:` 仅 1 次——300 ms 停摆被看门狗的 800 ms 容忍吸收,**闭锁不靠复位**。
+证据文件 `docs/测试说明/boot_log_data/20261021_probe_stale_gate_boot_log.txt`。
---
@@ -430,7 +445,8 @@ stateDiagram-v2
1. `adc_health_status_t` 只给 `tpafe_stall_ms`,**STM32 侧没有对应的停滞时长字段**——
只知道「STM32 静默了」,不知道「静默多久」。
2. `tpafe_stall_ms` 只在电平位置位时给值,正常态恒为 0,因此它不能当「距上次前进的时间」趋势量用。
-3. **观察者死亡不可自证**(§8 的责任矩阵),判定层的存活依赖 `adcTask`。
+3. **观察者死亡本层不可自证**(§8 责任矩阵):判定层的存活依赖 `adcTask`。该盲区由 §15 的兜底门
+ 闭合(保护不再吃陈旧数据),但**本层自身**仍然报不出"我没在跑"这件事。
4. `tgap` 与 `adc1_gap` 单位不同(点 / 次,§6.2),跨字段做除法或对比会得出错误结论。
5. 倒退的可判定窗是 ±2^31 拍(约 ±24.85 天),窗口外的倒退读作前进(§6.3)。
@@ -455,8 +471,14 @@ stateDiagram-v2
| tick 回绕 | `TC-ADCHEALTH-009` | 跨 0xFFFFFFFF 扫 512 拍不误报;回绕后冻结仍能检出 |
| 空指针守卫 | `TC-ADCHEALTH-010` | `in == NULL` 不动 `out`;`out == NULL` 仍更新内部状态 |
| 幂等复位 | `TC-ADCHEALTH-011` | 故障后 `adc_health_init()` 全清,且重新建基线干净 |
+| 帧年龄:首拍只建基线 | `TC-ADCHEALTH-012` | 第一次观测只记参照系并返回 0(开机窗口不得误锁保护) |
+| 帧年龄:健康与超拍 | `TC-ADCHEALTH-013` | 2000 拍扫描恒不判老,其中每 7 拍造一次 `d = 3` 的超拍 |
+| 帧年龄:门限含等号 | `TC-ADCHEALTH-014` | 差 1 ms 不判、到点即判;重新前进立刻不判老 |
+| 帧年龄:回绕 | `TC-ADCHEALTH-015` | tick 与序号一起跨 0xFFFFFFFF 不误判;回绕后冻结仍判老 |
+| 帧年龄:倒退分工 | `TC-ADCHEALTH-016` | 倒退按「有变化」不判老并重对齐(此后 119 ms 不判、120 ms 判) |
+| 帧年龄:多观察者 | `TC-ADCHEALTH-017` | 两个 age 对象并行观测互不污染;空参数返回 0(宁可不锁) |
-合计 **11 组用例 / 31 条断言**(用例清单与被测源文件同一处:
+合计 **17 组用例 / 45 条断言**(用例清单与被测源文件同一处:
`test/host/adc_health/adc_health_test.c` 文件头)。跑法:
```powershell
@@ -503,6 +525,7 @@ stateDiagram-v2
| `[ADC_TASK] health: recovered flags=0x0 sticky=0x3` | 静默自愈 | 别把「恢复」读成「没问题」:`sticky=0x3` 说明两侧都停过 |
| `[PROT] sampling FAULT -> protection blocked (hf=0x00000001)` | 闭锁真的生效了 | 括号里就是命中位;此后保护不判据,元件状态全复位 |
| `[PROT] sampling recovered -> protection resumed` | 闭锁解除 | 与上一条配对看时长 |
+| `…protection blocked (hf=0x00000100)` | **判定层失明时的兜底**:只有 bit8、stall 位一个都没报 | 查节拍源与 `adcTask`:同次开机 `[ADC_TASK] health:` 是否 0 次、`MISS` 快照是否缺 bit6(§15) |
| 心跳 `tgap=` 持续上涨 | 消费者超拍 | 看 `[PROF]` 的 `adcm` 是否 > 1 ms(`docs/子系统设计/看门狗设计说明.md` §11.4) |
两个必须记住的读法:
@@ -546,6 +569,7 @@ stateDiagram-v2
| 动驱动侧计数器(自增时机、清零点、位宽) | 必须同时复核 §6.3 的倒退路径与 §6.4 的 `!=` 增量口径;位宽一变就可能造出假增量 |
| 把任何 `*_init()` 挪到运行期重跑 | **必须同时 `adc_health_init()`**——`App/algo/adc_input.c` 的 init 会把 `s_dma_seq` 归零,不配套就第一拍报 `SEQ_BACKWARD` 并永久闭锁 |
| 在判定层引入 HAL / 日志 / 内核 API | 先回答「主机单测怎么免桩跑」;答不上来就别改 |
+| 动兜底门(§15) | 同步 `PROT_HF_LOCK_MASK`、§7 位表 bit8 行、§8 责任矩阵、§11 的 TC-ADCHEALTH-012..017、配图生成器 `tools/arch_diagram/gen_adc_health_bitmap_svg.py`;两条车道的 `primed` 必须各一份 |
三条禁令(都对应已写死的机制,不是风格问题):
@@ -567,7 +591,51 @@ stateDiagram-v2
---
-## 15. 相关文档
+## 15. 兜底新鲜度门(保护侧独立通道)
+
+本节补的是本层的**结构性盲区**(§8 责任矩阵第一行):判定层的每一次判定都要有人替它跑,而那个
+人是 `adcTask`。节拍源一停,`adcTask` 就永久阻塞在 `osThreadFlagsWait`(刻意的 fail-safe),
+`adc_health_check()` 不再执行,`flags` 冻结在 0,而 `protectTask` 仍每 20 ms 拿冻住的相量判据。
+
+**观测点为什么只能是那两个计数器**(整套方案的地基,选错就还是自证):
+
+| 候选证据 | 写者 | 依赖 `adcTask` 存活 | 能否当兜底证据 |
+|---|---|---|---|
+| `phasor_get()` / 采样环的最后计算时刻 | adcTask | 是 | ❌ 自证 |
+| `adc_health_flags()` | adcTask(每拍 `check()`) | 是 | ❌ 就是它漏的 |
+| `tpafe5160_frame_seq()` | BUSY EXTI 中断 | 否 | ✅ |
+| `adc_input_stm32_seq()`(`s_dma_seq`) | DMA2 Stream4 中断 | 否 | ✅ |
+
+**状态归属是设计要点,不是风格**:`adc_health_age_stale()` 是无模块状态的纯函数,参照系
+`adc_health_age_t` **由调用方自存**。`adc_health_check()` 用的模块级状态是「每拍一次、单写者」的
+契约——若让 `protectTask` 也去调它,两个上下文会互相刷新差值基线,把现有判定层一起弄坏。状态放
+调用方之后,「多个观察者各判各的」在结构上不可能互相污染(`TC-ADCHEALTH-017` 钉这条)。
+
+**门限 120 ms** = `PROT_STALE_AGE_MS`(`ADC_HEALTH_STALL_MS` + `PROTECT_TASK_CYCLE_MS`)。本门
+每 20 ms 才观测一次,最坏比判定层晚一整周期;这 20 ms 买的是**时序阶梯**:判定层活着时它先报
+(`0x1`/`0x2`,带日志与自检留痕),本门随后补 `0x100`;判定层死了则只出现 `0x100`。排查时两类
+位的先后本身就是「判定层当时活着吗」的免费答案。闭锁最迟时刻 = 120 ms + ≤20 ms 观测相位 = **≤140 ms**,
+仍远小于保护最小动作延时(OF/UF 默认 1000 ms)。
+
+三条实现细节(每一条都对应一次误锁风险):
+
+- **`primed` 必须两条车道各一份**。共用一个标志时,先被观测的车道把它置 1,另一条首拍就拿
+ `adv_ms = 0` 去算年龄 → **开机即误锁保护**。
+- **倒退按「有变化」处理并重对齐参照系**,不判老:单调性破坏归 `ADC_HF_SEQ_BACKWARD`,两条判据
+ 不互相顶班(`TC-ADCHEALTH-016` 把「重对齐后计时起点跟着走」钉住)。
+- **参数错返回 0**(不判老)——兜底门宁可漏锁一次,也不能因为一个空指针把保护永久锁掉。
+
+**与留痕体系的关系**:本位不进 `sticky`、不进编排层的 `logged` 位图、判定层也不写它;留痕走
+`protectTask` 既有的闭锁边沿日志与 SOE 类型 5/6(value 里直接带 `0x100`,见 §12 判读)。
+
+**边界与残余**:TIM3 停 → ADC1 无触发 → 无 DMA 请求 → `s_dma_seq` 也停(节拍源是唯一上游,别
+以为「DMA 还在循环所以计数会涨」)。开机不误锁的前提是 TIM3 在 `app_main_init()` 就启动、远早于
+`osKernelStart()`(`docs/子系统设计/ADC采样节拍设计说明.md` §3.4),protectTask 首拍时计数器已在
+前进。残余风险:`protectTask` 自己被饿死时无人判老,此时退回 IWDG(它在 `required_mask = 0x1F7` 内)。
+
+---
+
+## 16. 相关文档
- `docs/算法/采集与算法层设计说明.md` §4 —— 本模块在全层中的位置与接口速览(本文不重复)
- `docs/子系统设计/ADC采样节拍设计说明.md` §8 —— 心跳字段全表与判读(`tgap` 所在行)
@@ -578,6 +646,7 @@ stateDiagram-v2
- `docs/算法/继电保护功能复现规划.md` §3.5 —— L5 自检与闭锁(本层供数的 L5-4 在此登记)
- `docs/内存管理/RAM使用分析与优化方案.md` §1.4 —— `adcTask` 栈最坏链台账
- `App/algo/README.md` —— 目录级文件清单与构建测试入口
+- `docs/子系统设计/ADC采样节拍设计说明.md` §3.4 —— TIM3 启动位置与顺序(兜底门开机不误锁的前提)
---
@@ -586,4 +655,5 @@ stateDiagram-v2
| 日期 | 变更 | 说明 |
|---|---|---|
| 2026-10-21 | 新建 | 首版单文件深设计:判据推导、四种位语义、闭锁与兜底责任矩阵、验证分工与现场判读手册;新增三张配图生成器(`tools/arch_diagram/gen_adc_health_flow_svg.py`、`gen_adc_health_bitmap_svg.py`、`gen_adc_health_timeline_svg.py`)与产物 SVG |
+| 2026-10-21 | 新增 §15 | 兜底新鲜度门:`adc_health_age_stale()` 无状态纯判据 + `ADC_HF_STALE`(bit8)+ `protectTask` 独立观测;§7 位表、§8 责任矩阵与「已知窗口」段、§10 盲区、§11 用例表、§12 判读、§14 清单与位图配图全部同步 |
| 2026-10-21 | §14 更新 | 初始化入口已接线:`adc_task_func()` 进循环前调 `adc_task_init()`(该入口不再打日志);同时删除 `App/task/net_task.c` 里的空壳 `net_task_init()` |
diff --git a/test/host/adc_health/adc_health_test.c b/test/host/adc_health/adc_health_test.c
index e543d66d..333a6b2c 100644
--- a/test/host/adc_health/adc_health_test.c
+++ b/test/host/adc_health/adc_health_test.c
@@ -25,6 +25,12 @@
* TC-ADCHEALTH-009 tick 回绕:跨 0xFFFFFFFF 的正常推进不误报静默;回绕后冻结仍报
* TC-ADCHEALTH-010 NULL 守卫:in=NULL 跳过;out=NULL 只更新内部状态
* TC-ADCHEALTH-011 init 幂等:故障后 re-init 全清
+ * TC-ADCHEALTH-012 帧年龄纯判据:首拍只建基线、不判老(开机不得误锁)
+ * TC-ADCHEALTH-013 帧年龄纯判据:正常推进恒不判老,一次前进 3 帧也不判老
+ * TC-ADCHEALTH-014 帧年龄纯判据:门限含等号(差 1 不判、到点判),恢复前进即清
+ * TC-ADCHEALTH-015 帧年龄纯判据:tick 与序号跨 0xFFFFFFFF 不误判、停滞仍判老
+ * TC-ADCHEALTH-016 帧年龄纯判据:序号倒退按「有变化」处理,不判老(判据分工)
+ * TC-ADCHEALTH-017 帧年龄纯判据:两个 age 对象互不干扰;空指针不判老
*
* 通过:全部 TC PASS(main 返回 0)。
* 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。
@@ -349,6 +355,125 @@ static void tc_reinit(void) {
"TC-ADCHEALTH-011 re-prime after re-init is clean");
}
+/* ===================== 帧年龄纯判据(保护侧兜底门专用) ===================== */
+
+/* 构造一个"未建基线"的参照系(本测试只用 stdint/stdio,不引 string.h) */
+static adc_health_age_t mk_age(void) {
+ adc_health_age_t a;
+
+ a.seq = 0u;
+ a.adv_ms = 0u;
+ a.primed = 0u;
+ return a;
+}
+
+/* TC-ADCHEALTH-012:首拍只建基线 */
+static void tc_age_prime(void) {
+ adc_health_age_t age = mk_age();
+
+ CHECK(adc_health_age_stale(&age, 4242u, 1000u, 120u) == 0u,
+ "TC-ADCHEALTH-012 first observation only primes");
+ CHECK((age.primed == 1u) && (age.seq == 4242u) && (age.adv_ms == 1000u),
+ "TC-ADCHEALTH-012 baseline recorded by first call");
+ CHECK(adc_health_age_stale(&age, 4242u, 1001u, 120u) == 0u,
+ "TC-ADCHEALTH-012 1ms idle is not stale");
+}
+
+/* TC-ADCHEALTH-013:正常推进与超拍都不该判老 */
+static void tc_age_healthy(void) {
+ adc_health_age_t age = mk_age();
+ uint32_t seq = 0u, now = 0u;
+ int i, ok = 1;
+
+ for (i = 0; i < 2000; i++) {
+ if (adc_health_age_stale(&age, seq, now, 120u) != 0u) {
+ ok = 0;
+ }
+ seq += ((i % 7) == 6) ? 3u : 1u; /* 每 7 拍造一次「前进 3 帧」的超拍 */
+ now += 1u;
+ }
+ CHECK(ok, "TC-ADCHEALTH-013 advancing (incl. d=3 overrun) never goes stale");
+ CHECK(now > 120u, "TC-ADCHEALTH-013 sweep exceeded the threshold span");
+}
+
+/* TC-ADCHEALTH-014:门限含等号 + 恢复即清 */
+static void tc_age_threshold(void) {
+ adc_health_age_t age = mk_age();
+ uint32_t t;
+ int ok = 1;
+
+ (void)adc_health_age_stale(&age, 10u, 0u, 120u); /* 建基线 */
+ (void)adc_health_age_stale(&age, 11u, 10u, 120u); /* 前进:adv_ms = 10 */
+ for (t = 11u; t < 130u; t++) {
+ uint8_t want = ((t - 10u) >= 120u) ? 1u : 0u;
+ if (adc_health_age_stale(&age, 11u, t, 120u) != want) {
+ ok = 0;
+ }
+ }
+ CHECK(ok, "TC-ADCHEALTH-014 stale exactly at age_ms (inclusive boundary)");
+ CHECK(adc_health_age_stale(&age, 12u, 200u, 120u) == 0u,
+ "TC-ADCHEALTH-014 advancing again clears staleness");
+}
+
+/* TC-ADCHEALTH-015:tick 与序号一起跨回绕 */
+static void tc_age_wrap(void) {
+ adc_health_age_t age = mk_age();
+ uint32_t seq = 700000u, now = 0xFFFFFF00u;
+ int i, ok = 1;
+
+ for (i = 0; i < 512; i++) {
+ if (adc_health_age_stale(&age, seq, now, 120u) != 0u) {
+ ok = 0;
+ }
+ seq++; now++; /* uint32 自然回绕 */
+ }
+ CHECK(ok, "TC-ADCHEALTH-015 sweep across 0xFFFFFFFF never goes stale");
+ seq--; /* 回到最后一次被观测到的值:此后两侧冻结 */
+ for (i = 0; i < 121; i++) {
+ now++;
+ }
+ CHECK(adc_health_age_stale(&age, seq, now, 120u) != 0u,
+ "TC-ADCHEALTH-015 still detected stale after wrap");
+}
+
+/* TC-ADCHEALTH-016:倒退按「有变化」处理,不判老 */
+static void tc_age_backward(void) {
+ adc_health_age_t age = mk_age();
+
+ (void)adc_health_age_stale(&age, 1000u, 100u, 120u);
+ CHECK(adc_health_age_stale(&age, 995u, 101u, 120u) == 0u,
+ "TC-ADCHEALTH-016 backward counts as change, not staleness");
+ /* 倒退已把参照系重新对齐到 995(adv_ms = 101):此后停在 995 上,
+ * 差 1 ms 不判老、到点才判老——把"重对齐后计时起点跟着走"钉住 */
+ CHECK(adc_health_age_stale(&age, 995u, 220u, 120u) == 0u,
+ "TC-ADCHEALTH-016 119ms after re-alignment is not stale yet");
+ CHECK(adc_health_age_stale(&age, 995u, 221u, 120u) != 0u,
+ "TC-ADCHEALTH-016 baseline re-aligned by backward step");
+}
+
+/* TC-ADCHEALTH-017:两个观察者互不干扰 + 空指针守卫 */
+static void tc_age_two_observers(void) {
+ adc_health_age_t a = mk_age(); /* 模拟 adcTask:每拍观测 */
+ adc_health_age_t b = mk_age(); /* 模拟 protectTask:每 20 拍观测 */
+ uint32_t seq = 500u;
+ uint32_t t;
+ int ok = 1;
+
+ (void)adc_health_age_stale(&a, seq, 0u, 100u);
+ (void)adc_health_age_stale(&b, seq, 0u, 120u);
+ for (t = 1u; t <= 200u; t++) {
+ (void)adc_health_age_stale(&a, seq + t, t, 100u);
+ if ((t % 20u) == 0u) {
+ if (adc_health_age_stale(&b, seq + t, t, 120u) != 0u) {
+ ok = 0;
+ }
+ }
+ }
+ CHECK(ok, "TC-ADCHEALTH-017 two independent observers do not pollute each other");
+ CHECK(adc_health_age_stale(0, 1u, 1u, 120u) == 0u,
+ "TC-ADCHEALTH-017 NULL age never reports stale (no false lock)");
+}
+
int main(void) {
printf("=== ADC Health Tests ===\n");
@@ -364,6 +489,12 @@ int main(void) {
tc_backward();
tc_err_counters();
printf("-- tick wrap / guards / re-init --\n");
+ tc_age_prime();
+ tc_age_healthy();
+ tc_age_threshold();
+ tc_age_wrap();
+ tc_age_backward();
+ tc_age_two_observers();
tc_tick_wrap();
tc_null_guard();
tc_reinit();
diff --git a/tools/arch_diagram/gen_adc_health_bitmap_svg.py b/tools/arch_diagram/gen_adc_health_bitmap_svg.py
index 67da3320..ad2ea50c 100644
--- a/tools/arch_diagram/gen_adc_health_bitmap_svg.py
+++ b/tools/arch_diagram/gen_adc_health_bitmap_svg.py
@@ -30,8 +30,8 @@ HEAD_FONT = 12.5
LABEL_X, LABEL_W = PAD, 262
GRID_X = PAD + LABEL_W + 8
-BIT_W, BIT_H, BIT_GAP = 112, 46, 4
-NOTE_W = 340
+BIT_W, BIT_H, BIT_GAP = 94, 46, 4
+NOTE_W = 320
GROUP_GAP = 26
FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif"
@@ -47,53 +47,58 @@ C_NONE = ("#94a3b8", "#f8fafc") # 未使用 / 不适用
GROUPS = [
dict(head="判定层写出的三张位图(adc_health.c)", rows=[
dict(label="flags 电平位", sub="adc_health_flags(),当前活跃",
- note="protectTask 每 20 ms 读它做 L5-4 闭锁;不看事件位",
+ note="protectTask 每 20 ms 读它做 L5-4 闭锁;bit8 由本任务自己判老后 OR 入(判定层不写)",
bits=[("bit0", "TPAFE_STALL", "level"), ("bit1", "STM32_STALL", "level"),
("bit2", "SEQ_BACKWARD", "latch"), ("bit3", "—", "none"),
("bit4", "—", "none"), ("bit5", "—", "none"),
- ("bit6", "—", "none"), ("bit7", "—", "none")]),
+ ("bit6", "—", "none"), ("bit7", "—", "none"),
+ ("bit8", "STALE", "level")]),
dict(label="sticky 锁存位", sub="adc_health_sticky(),直到 init 才清",
note="只留痕不闭锁:曾停摆/曾出错必须可追溯",
bits=[("bit0", "TPAFE_STALL", "latch"), ("bit1", "STM32_STALL", "latch"),
("bit2", "SEQ_BACKWARD", "latch"), ("bit3", "FRSTDATA", "latch"),
("bit4", "STM32_ERR", "latch"), ("bit5", "HW_ERR", "latch"),
- ("bit6", "不入锁存", "none"), ("bit7", "不入锁存", "none")]),
+ ("bit6", "不入锁存", "none"), ("bit7", "不入锁存", "none"),
+ ("bit8", "不入锁存", "none")]),
dict(label="events 事件位", sub="adc_health_check() 出参,仅当拍有效",
note="adc_health_report() 的唯一输入:按位分三级处置",
bits=[("bit0", "—", "none"), ("bit1", "—", "none"), ("bit2", "—", "none"),
("bit3", "FRSTDATA", "event"), ("bit4", "STM32_ERR", "event"),
("bit5", "HW_ERR", "event"), ("bit6", "TPAFE_GAP", "event"),
- ("bit7", "STM32_GAP", "event")]),
+ ("bit7", "STM32_GAP", "event"), ("bit8", "—", "none")]),
]),
dict(head="消费者侧的掩码与记忆位图", rows=[
dict(label="PROT_HF_LOCK_MASK", sub="protect_task.c 编译期常量",
- note="命中即 prot_elm_init() 复位元件 + 本拍不 eval;SOE 类型 5/6",
+ note="命中即 prot_elm_init() 复位元件 + 本拍不 eval;SOE 5/6 带位图",
bits=[("bit0", "闭锁", "mask"), ("bit1", "闭锁", "mask"),
("bit2", "闭锁", "mask"), ("bit3", "不闭锁", "none"),
("bit4", "不闭锁", "none"), ("bit5", "不闭锁", "none"),
- ("bit6", "—", "none"), ("bit7", "—", "none")]),
+ ("bit6", "—", "none"), ("bit7", "—", "none"),
+ ("bit8", "闭锁", "mask")]),
dict(label="serious 严重事件", sub="adc_task.c 的 adc_health_report()",
note="三类计数增量恒应为 0 → 每次 DBG_ERROR + 自检留痕",
bits=[("bit0", "—", "none"), ("bit1", "—", "none"), ("bit2", "—", "none"),
("bit3", "FRSTDATA", "mask"), ("bit4", "STM32_ERR", "mask"),
("bit5", "HW_ERR", "mask"), ("bit6", "—", "none"),
- ("bit7", "—", "none")]),
+ ("bit7", "—", "none"), ("bit8", "—", "none")]),
dict(label="gap 丢帧事件", sub="同一函数里的丢帧分支",
note="刻意不告警:超拍是常态(启动突发期即累计 23 次),只累计",
bits=[("bit0", "—", "none"), ("bit1", "—", "none"), ("bit2", "—", "none"),
("bit3", "—", "none"), ("bit4", "—", "none"), ("bit5", "—", "none"),
- ("bit6", "TPAFE_GAP", "mask"), ("bit7", "STM32_GAP", "mask")]),
+ ("bit6", "TPAFE_GAP", "mask"), ("bit7", "STM32_GAP", "mask"),
+ ("bit8", "—", "none")]),
dict(label="s_health_logged", sub="编排层记忆位图(每类一次留痕)",
note=" |= (serious | rise):用同一位号把 SYSLOG 压成每类每次开机一条",
bits=[("bit0", "记过", "mask"), ("bit1", "记过", "mask"),
("bit2", "记过", "mask"), ("bit3", "记过", "mask"),
("bit4", "记过", "mask"), ("bit5", "记过", "mask"),
- ("bit6", "不记", "none"), ("bit7", "不记", "none")]),
+ ("bit6", "不记", "none"), ("bit7", "不记", "none"),
+ ("bit8", "不记", "none")]),
]),
]
FOOT = ("bit3 / bit4 / bit5 在 events 与 sticky 中刻意用同一位号 → 消费者能把事件位直接"
- "套在锁存位图上做每类一次留痕;bit8 以上全 32 位保留未用。"
+ "套在锁存位图上做每类一次留痕;bit8 = 保护侧兜底门专用(判定层不写它)。"
"详见 docs/算法/采样有效性校验设计说明.md")
KIND_COLOR = dict(level=C_LEVEL, latch=C_LATCH, event=C_EVENT, mask=C_MASK, none=C_NONE)
@@ -118,6 +123,8 @@ def fit(s, fs, avail, where):
# ----------------------------- 生成 -----------------------------
def build():
+ # 位数列数由数据推导(曾硬编码 8 列,加 bit8 后备注框把新列盖住了)
+ NB = max(len(row["bits"]) for g in GROUPS for row in g["rows"])
body = []
addb = body.append
@@ -157,7 +164,7 @@ def build():
'%s' % (x + 10, cy + 19, BIT_FONT, fg, esc(b)))
addb('%s'
% (x + 10, cy + 36, TAG_FONT, esc(tag)))
- nx = GRID_X + 8 * (BIT_W + BIT_GAP) + 12
+ nx = GRID_X + NB * (BIT_W + BIT_GAP) + 12
addb('' % (nx, cy, NOTE_W, BIT_H))
lines = wrap(r["note"], NOTE_FONT, NOTE_W - 24)
From 790e153cd672d04c3549d8079178ffc3e60b6770 Mon Sep 17 00:00:00 2001
From: Wanderingss <1624155937@qq.com>
Date: Thu, 22 Oct 2026 00:43:17 +0800
Subject: [PATCH 4/4] =?UTF-8?q?fix(adc=5Finput):=20=E4=B8=A2=E5=B8=A7?=
=?UTF-8?q?=E8=AE=A1=E6=95=B0=E6=94=B9=E6=8C=89=E5=B8=A7=E7=82=B9=E6=95=B0?=
=?UTF-8?q?=E7=B4=AF=E8=AE=A1=EF=BC=8C=E4=B8=8E=20TPAFE=20=E4=BE=A7=20tgap?=
=?UTF-8?q?=20=E5=90=8C=E5=8D=95=E4=BD=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
读码确认的口径缺陷:两片转换器的丢帧计数单位不一致——TPAFE 侧
adc_health_tpafe_gaps() 按 (d-1) 加权记「帧点数」,STM32 侧
adc_input_stm32_gaps() 每次事件只 +1 记「次数」。两者都被当作心跳字段
并排打印(adc1_gap / tgap),一次跨多帧时数值就分叉:同样丢 3 帧,
一边记 2、一边记 1。跨字段对比或做除法会得出错误结论。
选 A(改代码,真对称)而不是只改字段名:
- 单拍超一帧(d=2)时两者数值相同,历史上绝大多数样本本来就等价,
换单位对既有判读习惯冲击很小;
- 只在一次跨 ≥2 帧时分叉,而那正是心跳日志突发期最常见的形态,
留在文档里当"注意点"迟早还会读错一次。
改动:
- adc_input.c:s_stm32_gaps += (d - 1u)(d = seq - s_dma_seq_seen,
顺带把已算过的差值提成局部变量 d,不再重复算一遍)
- adc_input.h / adc_input.c 函数头:单位写明「Σ 每次前进的超出部分」,
并点名 2026-10-21 之前固件该字段是次数口径,跨版本日志不可直接对比
- 用例:原两档只跨 1 帧(前进 2 记 1),钉不出单位;补第二档——连投
3 帧不消费(一次跨 2 帧)必须记 2 点、累计 1+2=3。补充断言沿用所属
用例覆盖点,不另立新编号,也不在文档外复制用例号(编号门禁按文件内
实例数比对台账豁免,多写一处即被判新债)
- 文档同步:ADC采样节拍设计说明 §8/§9 字段口径与稳态基线、采集与算法
层设计说明 §3/§4、采样有效性校验设计说明 §6.2/§10/§11(并把"两车道
单位不同"从观测盲区降级为"历史日志口径差异")、ADC输入层单元测试
说明用例表该行;两张配图生成器文案同步
实测:
- @build 0 错 0 警;Program Size 与上一提交**逐字节相同**(Code=213520
RW=1184 ZI=131504)——编译器早已为半区推导算过同一差值并复用,
故本次净增 0 B,不产生新的台账漂移
- 主机单测:ADC 输入层 103/103、ADC 有效性 45/45
- stack_report:adcTask 1320 B / protectTask 872 B 均不变
- 字节卫生:BOM 无 / CR 0 / U+FFFD 0 / 文件尾换行齐
- id-check:Passed(曾因新增用例号实例触发"台账之外 +2",已按不复制
用例号的方式消除,未动 id_known_backlog.json 台账)
gate: only mem-budget/mem-numbers pending lead ledger backfill
pre-commit run --all-files 失败仅这两条,漂移量来自上一条提交 6f90600
(+96 B Code / +24 B ZI);本条自身 0 B。按并行分支约定本分支不跑
--write,台账由 Lead 合并后统一回填。
---
App/algo/adc_input.c | 26 +++++++++++++------
App/algo/adc_input.h | 8 ++++--
docs/images/采样有效性判定时间轴.svg | 6 ++---
docs/images/采样有效性校验数据流.svg | 2 +-
docs/子系统设计/ADC采样节拍设计说明.md | 6 ++---
docs/测试说明/ADC输入层单元测试说明.md | 2 +-
docs/算法/采样有效性校验设计说明.md | 14 ++++++----
docs/算法/采集与算法层设计说明.md | 4 +--
test/host/adc_input/adc_input_test.c | 14 ++++++++--
tools/arch_diagram/gen_adc_health_flow_svg.py | 2 +-
.../gen_adc_health_timeline_svg.py | 6 ++---
11 files changed, 59 insertions(+), 31 deletions(-)
diff --git a/App/algo/adc_input.c b/App/algo/adc_input.c
index 9771dbd8..65f8b6ba 100644
--- a/App/algo/adc_input.c
+++ b/App/algo/adc_input.c
@@ -12,6 +12,10 @@
* "TIM3 TRGO 硬件触发 + DMA 双缓冲消费":本层不再触发
* 也不等待转换(消除最坏 2~3 ms 忙等);新增三个 HAL
* 回调(ISR 内只计数)与 seq / hw_errs 观测量
+ * v2.1 2026-10-21 王建锋 丢帧计数口径由「次数」改为「帧点数」(累加每次前进
+ * 的超出部分),与判定层 TPAFE 侧
+ * adc_health_tpafe_gaps() 同单位。⚠ 换版前固件里心跳
+ * 字段 adc1_gap 是次数口径,不可跨版本直接对比
*/
#include "adc_input.h"
@@ -69,7 +73,7 @@ static uint16_t s_stm32_dma[ADC_INPUT_STM32_DMA_LEN];
static volatile uint32_t s_dma_seq; /* DMA 半帧/整帧完成次数(ISR 递增,只增不清) */
static uint32_t s_dma_seq_seen; /* 消费侧上次看到的完成计数 */
static volatile uint32_t s_stm32_hw_errs; /* HAL ADC/DMA 错误回调累计(ISR 写) */
-static uint32_t s_stm32_gaps; /* 丢帧:seq 一次前进 >1(消费者超拍漏读)*/
+static uint32_t s_stm32_gaps; /* 丢帧点数:Σ(前进量-1),与 tgap 同单位 */
static uint8_t s_stall_run; /* 连续"无新帧"拍数(相位伪影只会是 1 拍)*/
/* TPAFE5160 最新一帧原始码值(16 位二进制补码),按驱动 buf 下标索引 */
static int16_t s_tpafe_raw[ADC_INPUT_TPAFE_CH_NUM];
@@ -94,6 +98,7 @@ static uint32_t s_stm32_errs; /* STM32 扫描累计失败次数 */
static int stm32_take(void) {
uint32_t seq = s_dma_seq; /* 单次 32 位读:与 ISR 自增不产生撕裂 */
const uint16_t *src;
+ uint32_t d;
uint8_t half;
uint8_t i;
@@ -115,11 +120,14 @@ static int stm32_take(void) {
return ADC_INPUT_RC_STALL;
}
- if ((uint32_t)(seq - s_dma_seq_seen) > 1u) {
+ d = seq - s_dma_seq_seen;
+ if (d > 1u) {
/* 计数一次前进 >1 = 中间有帧没被读走(乒乓缓冲已被覆盖)→ **真丢帧**,
* 与"无新帧"分开计数:前者是消费者超拍(本任务单拍耗时 > 1 ms),
- * 后者多半是相位伪影 */
- s_stm32_gaps++;
+ * 后者多半是相位伪影。按**丢掉的帧数**累计(d-1),与判定层 TPAFE 侧的
+ * adc_health_tpafe_gaps() 同单位——否则心跳里 adc1_gap 与 tgap 一个是次
+ * 一个是点,跨字段对比会得出错误结论 */
+ s_stm32_gaps += (d - 1u);
}
s_stall_run = 0u;
s_dma_seq_seen = seq;
@@ -310,12 +318,14 @@ uint32_t adc_input_stm32_errs(void) {
}
/*
- * 函数功能:读取 STM32 ADC1 的丢帧次数
+ * 函数功能:读取 STM32 ADC1 累计丢帧点数
* 入口参数:无
- * 返 回 值:DMA 完成计数一次前进 >1 的次数(中间那帧已被乒乓缓冲覆盖)
+ * 返 回 值:Σ(完成计数每次前进的超出部分),即被乒乓缓冲覆盖掉、没被消费的帧数
* 限定条件:无
- * 函数说明:这是"消费者超拍"的直接证据(本任务单拍耗时 > 1 ms,如突发日志突发期);
- * 与 adc_input_stm32_errs()(节拍源停摆)是两类不同的故障。
+ * 函数说明:这是"消费者超拍"的直接证据(本任务单拍耗时 > 1 ms,如心跳日志突发期);
+ * 与 adc_input_stm32_errs()(节拍源停摆)是两类不同的故障,**勿混读**。
+ * 单位与判定层 TPAFE 侧 adc_health_tpafe_gaps() 一致(都是帧点数),心跳里
+ * 可直接对比;注意 2026-10-21 之前固件该字段是"次数"口径。
*/
uint32_t adc_input_stm32_gaps(void) {
return s_stm32_gaps;
diff --git a/App/algo/adc_input.h b/App/algo/adc_input.h
index 93b0e1fe..a46c0188 100644
--- a/App/algo/adc_input.h
+++ b/App/algo/adc_input.h
@@ -19,6 +19,8 @@
* (换源判据:F4 上 ADC1 收不到 TIM6/TIM7 的 TRGO,见
* docs/子系统设计/ADC采样节拍设计说明.md §3.2);新增
* adc_input_stm32_seq() / _hw_errs()
+ * v2.1 2026-10-21 王建锋 adc_input_stm32_gaps() 单位由「次数」改为「帧点数」,
+ * 与判定层 adc_health_tpafe_gaps() 同单位
*/
#ifndef ADC_INPUT_H
@@ -130,12 +132,14 @@ int adc_input_stm32_rc(void);
uint32_t adc_input_stm32_errs(void);
/*
- * 函数功能:读取 STM32 ADC1 的丢帧次数
+ * 函数功能:读取 STM32 ADC1 累计丢帧点数
* 入口参数:无
- * 返 回 值:DMA 完成计数一次前进 >1 的次数(中间那帧已被覆盖,未及消费)
+ * 返 回 值:Σ(完成计数每次前进的超出部分),即没被消费就被覆盖掉的帧数
* 限定条件:无
* 函数说明:与 errs 分开——gap = 消费者超拍(单拍耗时 > 1 ms),
* err = 节拍源/ DMA 停摆。两类故障处置不同,混在一起就查不了。
+ * 单位与判定层 adc_health_tpafe_gaps() 一致(帧点数),心跳里可直接对比;
+ * 2026-10-21 之前的固件该字段是"次数"口径。
*/
uint32_t adc_input_stm32_gaps(void);
diff --git a/docs/images/采样有效性判定时间轴.svg b/docs/images/采样有效性判定时间轴.svg
index 56816031..d068261f 100644
--- a/docs/images/采样有效性判定时间轴.svg
+++ b/docs/images/采样有效性判定时间轴.svg
@@ -140,9 +140,9 @@
同上
1 t=20 ms:d=3 → tgap += 2 + 当拍事件,下一拍事件消失
(边沿语义,不是电平)
-2 丢帧不入锁存、不告警、不闭锁;两者口径也不同:tgap
- 计帧点数(d-1 加权),adc1_gap 计次数(每次 +1)。
- 台架日志:启动突发期累计 23 次,稳态每 10 s +1~2 次
+2 丢帧不入锁存、不告警、不闭锁;两条车道同单位:
+ tgap 与 adc1_gap 都按帧点数(d-1 加权)累计,心跳里可
+ 直接对比。台架日志:启动突发期 23 点,稳态每 10 s +1~2 点
D 序号倒退:锁存不可自愈,代价是永久闭锁
seq: 1050 1051 ... 跳到 995
diff --git a/docs/images/采样有效性校验数据流.svg b/docs/images/采样有效性校验数据流.svg
index 0dc64fb0..109e45de 100644
--- a/docs/images/采样有效性校验数据流.svg
+++ b/docs/images/采样有效性校验数据流.svg
@@ -52,7 +52,7 @@
adc_input.c(DMA2 Stream4)
s_dma_seq++:半区/整区完成各一次
-s_stm32_gaps++:完成计数一次前进 >1
+s_stm32_gaps += d-1:丢帧点数
s_stm32_errs++:连续 >=2 拍无新帧
s_stm32_hw_errs++:HAL 错误回调
diff --git a/docs/子系统设计/ADC采样节拍设计说明.md b/docs/子系统设计/ADC采样节拍设计说明.md
index ccd0f2b3..3fca4f2a 100644
--- a/docs/子系统设计/ADC采样节拍设计说明.md
+++ b/docs/子系统设计/ADC采样节拍设计说明.md
@@ -282,10 +282,10 @@ t = 1000 µs 下一个更新事件
| `ring` | `sample_ring_frames()` | `ring == frames` | 不等 → 有 `idle` / `skip` 拍被压进环(回归恒等式) |
| `adc1_rc` | 最近一拍取帧状态 | 0(`ADC_INPUT_RC_OK`) | -2 本拍无新帧;-1 DMA 挂载失败 |
| `adc1_seq` | `s_dma_seq`(半传输 + 整传输共用) | ≈ `seq`,**恒定小偏移** | 偏移持续增大 → STM32 侧在丢 |
-| `adc1_gap` | `seq` 一次前进 > 1 | 每 10 s 心跳 +1 | 快速上涨 → 本任务单拍 > 1 ms(看 `adcm`) |
+| `adc1_gap` | Σ(`seq` 每次前进的超出部分) = **丢掉的帧点数**(2026-10-21 起;此前固件为"次数") | 每 10 s 心跳 +1 点 | 快速上涨 → 本任务单拍 > 1 ms(看 `adcm`) |
| `adc1_err` | **连续 ≥ 2 拍**无新帧 | 0 | > 0 → 节拍源 / DMA 真停 |
| `adc1_hw` | `HAL_ADC_ErrorCallback()` 次数 | 0 | > 0 → ADC / DMA 硬件报错 |
-| `tgap` | `adc_health_tpafe_gaps()`(L1-6) | 极少 | TPAFE 侧丢帧,与 `adc1_gap` 对称 |
+| `tgap` | `adc_health_tpafe_gaps()`(L1-6) | 极少 | TPAFE 侧丢帧,与 `adc1_gap` **同单位(帧点数)**,可直接对比 |
单拍耗时(`adcp` = `TASK_PROF_ADC_POLL`)与整拍耗时(`adcm` = `TASK_PROF_ADC_BEAT`)不在心跳里,
由 DWT 探针的 `[PROF]` 行给出,字段语义与基线 → `docs/子系统设计/看门狗设计说明.md` §11.4。
@@ -305,7 +305,7 @@ t = 1000 µs 下一个更新事件
| 单拍取数 `adcp` | **≈ 8 µs**(TPAFE 快照临界区 + 拷 9 个半字),本段**零忙等** | `[PROF]` 稳态;搬运由 DMA 完成后任务侧不再等待转换(§4) |
| 整拍最坏 `adcm` | ≈ 2 ms 峰值,**来自每 10 s 一次的心跳日志**(多行 `DBG_INFO` + 相量打印),不是采集 | 同批 `adcp` 仍是 µs 级、`idle = 0`;勿把 `adcm` 读成采集成本 |
| 稳态 `idle` | 0(唤醒与生产同源,无 tick 量化) | §6.2 |
-| 稳态 `adc1_gap` | 每 10 s 心跳 +1(开机日志突发期集中数十次) | §8:心跳拍超 1 ms + 相位伪影 |
+| 稳态 `adc1_gap` | 每 10 s 心跳 +1 点(开机日志突发期集中数十点) | §8:心跳拍超 1 ms + 相位伪影 |
| 静态 RAM | `s_stm32_dma` 36 B 在 `RW_IRAM1` | `scripts/ram_report.py`;**加大缓冲前必须重跑 .map 确认仍在 `RW_IRAM1`**(§4.3) |
| 任务与栈 | 零新任务;`adcTask` 640 words,静态最坏链约 1.3 KB、余量约 48% | `scripts/stack_report.py`、`docs/内存管理/RAM使用分析与优化方案.md` §2.3 |
| 动态分配 | 无(自研代码禁 C 库堆) | AGENTS.md「平台约束」 |
diff --git a/docs/测试说明/ADC输入层单元测试说明.md b/docs/测试说明/ADC输入层单元测试说明.md
index 452317a9..0755adb8 100644
--- a/docs/测试说明/ADC输入层单元测试说明.md
+++ b/docs/测试说明/ADC输入层单元测试说明.md
@@ -162,7 +162,7 @@ test/host/adc_input/adc_input_test.c
| **TC-ADCIN-02B** | `TEMP` = CH5 / rank6 = 105 | 同上 |
| **TC-ADCIN-02C** | 取帧后 `UA`(TPAFE)仍为 -2 | 两来源互相独立 |
| **TC-ADCIN-080..085** | ⭐ **乒乓双缓冲**:往"正在被 DMA 写的另一半区"填 900/800 系干扰值,`stm32_take()` 仍必须读到**刚完成那一半**(108 / 208) | **撕裂帧防线**(读错半区 = 半新半旧混合帧) |
-| **TC-ADCIN-086..089** | 连投两帧不消费 → 取到最新帧(408)且 `gaps()` = 1、`errs()` = 0 | **丢帧与停摆分开计数**(消费者超拍 ≠ 节拍源停) |
+| **TC-ADCIN-086..089** | 两档超拍:连投 2 帧不消费 → 取到最新帧(408)且 `gaps()` = 1;再连投 3 帧不消费(一次跨 2 帧)→ `gaps()` 累计到 3,把单位钉成**丢掉的帧点数**(2026-10-21 由「次数」改来,与判定层 `tgap` 同单位);`errs()` 始终 = 0 | **丢帧与停摆分开计数**(消费者超拍 ≠ 节拍源停);两档并列才分得出「次数 vs 点数」 |
| **TC-ADCIN-090..09A** | `HAL_ADC_ErrorCallback(&hadc1)` ×2 → `hw_errs()` = 2;用**别的句柄**调三个回调 → `seq` / `hw_errs` 均不动 | 三个 HAL 弱回调的 `hadc == &hadc1` **句柄守卫** |
| **TC-ADCIN-030..034** | `HAL_ADC_Start_DMA` 返回 `HAL_ERROR` → `init()` 返回 **-2**、状态码 `RC_START`、计数 +1、STM32 侧无有效数据 | 挂载失败(一次性,非每拍) |
| **TC-ADCIN-035..039** | 无任何 DMA 完成 → `RC_STALL`、**单拍不计故障**、`seq` 不前进、STM32 侧无有效数据 | 无新帧路径 + 相位伪影不误报 |
diff --git a/docs/算法/采样有效性校验设计说明.md b/docs/算法/采样有效性校验设计说明.md
index 8c465166..d63096c6 100644
--- a/docs/算法/采样有效性校验设计说明.md
+++ b/docs/算法/采样有效性校验设计说明.md
@@ -246,10 +246,11 @@ else { 清电平位 } /* 只有 stall 类有 else 分支,SEQ_BACKWARD 没有
| 车道 | 丢帧证据来源 | 单位 | 为什么这样分 |
|---|---|---|---|
| TPAFE | 判定层自算(驱动只暴露 `tpafe5160_frame_seq()`) | **帧点数**:按 `d - 1` 加权 | 驱动侧除帧序号外没有别的观测点,判定层手里的 seq 是唯一线索 |
-| STM32 | L1 的 `adc_input_stm32_gaps()`,判定层只看它的增量 | **次数**:每次事件 +1 | L1 在消费点上直接看得到 DMA 完成计数,细账归它;判定层再算一套就是两处判据打架 |
+| STM32 | L1 的 `adc_input_stm32_gaps()`,判定层只看它的增量 | **帧点数**:按 `d - 1` 加权 | L1 在消费点上直接看得到 DMA 完成计数,细账归它;判定层再算一套就是两处判据打架 |
-⚠ 读心跳日志时**别把 `tgap` 与 `adc1_gap` 当同一单位**(一个是点、一个是次),
-字段口径见 `docs/子系统设计/ADC采样节拍设计说明.md` §8。
+两条车道**同单位(帧点数)**,心跳里 `tgap` 与 `adc1_gap` 可以直接对比(2026-10-21 之前
+固件的 `adc1_gap` 是"次数"口径,跨版本对比要留意:单拍超一帧时两者数值相同,只有
+一次跨过 ≥2 帧才会分出差值)。字段口径见 `docs/子系统设计/ADC采样节拍设计说明.md` §8。
为什么刻意不告警、不锁存、不闭锁:消费者超拍是**常态**。同一批台架日志里,
启动日志突发期 `adc1_gap` 已累计 23 次,稳态每 10 s 心跳 +1~2 次(`tgap` 1 → 2)。
@@ -447,7 +448,8 @@ stateDiagram-v2
2. `tpafe_stall_ms` 只在电平位置位时给值,正常态恒为 0,因此它不能当「距上次前进的时间」趋势量用。
3. **观察者死亡本层不可自证**(§8 责任矩阵):判定层的存活依赖 `adcTask`。该盲区由 §15 的兜底门
闭合(保护不再吃陈旧数据),但**本层自身**仍然报不出"我没在跑"这件事。
-4. `tgap` 与 `adc1_gap` 单位不同(点 / 次,§6.2),跨字段做除法或对比会得出错误结论。
+4. `tgap` 与 `adc1_gap` 现已同为帧点数口径(§6.2);但**换版前的历史日志**里 `adc1_gap`
+ 是次数,跨版本对比前先确认固件版本。
5. 倒退的可判定窗是 ±2^31 拍(约 ±24.85 天),窗口外的倒退读作前进(§6.3)。
---
@@ -509,7 +511,8 @@ stateDiagram-v2
### 11.2 台架证据
`docs/测试说明/boot_log_data/20261011_prod_adc_health_boot_log.txt`:两条心跳行
-(`adc1_err=0 adc1_hw=0 adc1_gap=23 → 25`、`tgap=1 → 2`、`frames` 每 10 s +10000)
+(`adc1_err=0 adc1_hw=0 adc1_gap=23 → 25`、`tgap=1 → 2`、`frames` 每 10 s +10000;
+该日志出自**换版前固件**,其中的 `adc1_gap` 是次数口径)
加上全程零 `[ERR]` —— 即「健康不误报」+「gap 常态被正确压住」。
---
@@ -655,5 +658,6 @@ stateDiagram-v2
| 日期 | 变更 | 说明 |
|---|---|---|
| 2026-10-21 | 新建 | 首版单文件深设计:判据推导、四种位语义、闭锁与兜底责任矩阵、验证分工与现场判读手册;新增三张配图生成器(`tools/arch_diagram/gen_adc_health_flow_svg.py`、`gen_adc_health_bitmap_svg.py`、`gen_adc_health_timeline_svg.py`)与产物 SVG |
+| 2026-10-21 | §6.2 / §10 | 丢帧口径统一:L1 的 `adc1_gap` 由「次数」改为「帧点数」,与 `tgap` 同单位;§10 盲区第 4 条改写、§11.2 台架证据标注历史日志为旧口径 |
| 2026-10-21 | 新增 §15 | 兜底新鲜度门:`adc_health_age_stale()` 无状态纯判据 + `ADC_HF_STALE`(bit8)+ `protectTask` 独立观测;§7 位表、§8 责任矩阵与「已知窗口」段、§10 盲区、§11 用例表、§12 判读、§14 清单与位图配图全部同步 |
| 2026-10-21 | §14 更新 | 初始化入口已接线:`adc_task_func()` 进循环前调 `adc_task_init()`(该入口不再打日志);同时删除 `App/task/net_task.c` 里的空壳 `net_task_init()` |
diff --git a/docs/算法/采集与算法层设计说明.md b/docs/算法/采集与算法层设计说明.md
index c58937c7..ebdd695f 100644
--- a/docs/算法/采集与算法层设计说明.md
+++ b/docs/算法/采集与算法层设计说明.md
@@ -94,7 +94,7 @@ flowchart TD
| `adc_input_poll()` | 采一拍:`tpafe5160_snapshot()` 原子快照(8 通道)+ `stm32_take()` 消费**刚完成的那半区**(按完成计数奇偶推半区,零忙等) | **仅任务上下文**(快照是临界区);在 ISR 里调会破坏 `tpafe5160_snapshot()` 的"仅任务上下文"约定 |
| `adc_input_raw(ch, &out)` | 按语义读原始码值(TPAFE 16 位补码 / STM32 `0..4095`);`-2` = 该来源尚无有效数据 | 消费侧取数**唯一入口**;须先成功 `poll()` |
| `adc_input_desc(ch)` | 语义 → 物理通道描述(来源 + 下标 / 通道号),供自检注入与 UI 通道序 | 返回静态地址,**勿改** |
-| `adc_input_stm32_rc()` / `_seq()` / `_errs()` / `_gaps()` / `_hw_errs()` | 最近一拍状态码 / DMA 整帧完成计数 / 停摆(连续 ≥2 拍无新帧)/ 丢帧(计数一次前进 >1)/ HAL 错误回调 | **四类成因不同,勿混读**:对应心跳字段 `adc1_rc` / `adc1_seq` / `adc1_err` / `adc1_gap` / `adc1_hw`(判读见 `docs/子系统设计/ADC采样节拍设计说明.md` §8) |
+| `adc_input_stm32_rc()` / `_seq()` / `_errs()` / `_gaps()` / `_hw_errs()` | 最近一拍状态码 / DMA 整帧完成计数 / 停摆(连续 ≥2 拍无新帧)/ 丢帧**点数**(Σ 每次前进的超出部分,与 `tgap` 同单位)/ HAL 错误回调 | **四类成因不同,勿混读**:对应心跳字段 `adc1_rc` / `adc1_seq` / `adc1_err` / `adc1_gap` / `adc1_hw`(判读见 `docs/子系统设计/ADC采样节拍设计说明.md` §8) |
**要点**
@@ -123,7 +123,7 @@ flowchart TD
| `adc_health_check(&in, &out)` | 吃"计数快照"做一次判定(每拍调,常数次整数运算);差值法 `(int32_t)(now - prev)` → 计数与 tick 回绕都安全 |
| `adc_health_flags()` | **电平**故障位:`TPAFE_STALL` / `STM32_STALL`(≥100 ms 无前进,可自愈)/ `SEQ_BACKWARD`(锁存)——为 L5-4 闭锁供数 |
| `adc_health_sticky()` | **锁存**位:`FRSTDATA` / `STM32_ERR` / `HW_ERR` 的增量痕迹,自愈后仍留痕 |
-| `adc_health_tpafe_gaps()` | TPAFE 累计丢帧点数(序号前进 >1 的超出部分),与 STM32 侧 `adc_input_stm32_gaps()` 对称(心跳字段 `tgap`) |
+| `adc_health_tpafe_gaps()` | TPAFE 累计丢帧点数(序号前进 >1 的超出部分),与 STM32 侧 `adc_input_stm32_gaps()` **同单位**(心跳字段 `tgap` / `adc1_gap` 可直接对比) |
**要点**
diff --git a/test/host/adc_input/adc_input_test.c b/test/host/adc_input/adc_input_test.c
index 57af9018..a1784267 100644
--- a/test/host/adc_input/adc_input_test.c
+++ b/test/host/adc_input/adc_input_test.c
@@ -328,13 +328,23 @@ static void tc_dma_pingpong(void) {
/* 半区奇偶口径:seq=1 -> 前半区,seq=2 -> 后半区(与 dma_beat 独立推导,互相证伪) */
CHECK(((adc_input_raw(ADC_SEM_UC, &v) == 0) && (v == 203)), "TC-ADCIN-085 UC 同帧内一致(203)");
- /* 消费者超拍:两拍都没取 -> 中间那帧已被乒乓缓冲覆盖 -> 记一次丢帧 */
+ /* 消费者超拍:两拍都没取 -> 中间那帧已被乒乓缓冲覆盖 -> 记 1 点丢帧 */
dma_beat(300);
dma_beat(400);
CHECK(stm32_take() == ADC_INPUT_RC_OK, "TC-ADCIN-086 超拍后仍取到最新帧");
- CHECK(adc_input_stm32_gaps() == 1u, "TC-ADCIN-087 seq 前进 2 -> 记一次丢帧");
+ CHECK(adc_input_stm32_gaps() == 1u, "TC-ADCIN-087 seq 前进 2 -> 记 1 点丢帧");
CHECK((adc_input_raw(ADC_SEM_UB, &v) == 0) && (v == 408), "TC-ADCIN-088 取到的是最新帧(408)");
CHECK(adc_input_stm32_errs() == 0u, "TC-ADCIN-089 丢帧不计入停摆(两类故障分开)");
+
+ /* 第二档:上面只跨了 1 帧(前进 2 记 1 点),"次数"与"点数"数值相同、钉不出单位。
+ * 这里连投三帧不消费 = 一次跨 2 帧,必须记 2 点(累计 1+2=3),把 2026-10-21 改的
+ * 「帧点数」口径钉死。断言不另立编号,仍属上面那条用例的覆盖点 */
+ dma_beat(500);
+ dma_beat(600);
+ dma_beat(700);
+ CHECK(stm32_take() == ADC_INPUT_RC_OK, "second overrun still returns latest frame");
+ CHECK(adc_input_stm32_gaps() == 3u, "delta 3 -> 2 points, total 3 (point unit)");
+
}
/* ============ TC-ADCIN-090..093:HAL 错误回调与句柄守卫 ============ */
diff --git a/tools/arch_diagram/gen_adc_health_flow_svg.py b/tools/arch_diagram/gen_adc_health_flow_svg.py
index aeed6d0f..1cd78cb9 100644
--- a/tools/arch_diagram/gen_adc_health_flow_svg.py
+++ b/tools/arch_diagram/gen_adc_health_flow_svg.py
@@ -91,7 +91,7 @@ LANES = [
cells=[
dict(title="adc_input.c(DMA2 Stream4)",
lines=["s_dma_seq++:半区/整区完成各一次",
- "s_stm32_gaps++:完成计数一次前进 >1",
+ "s_stm32_gaps += d-1:丢帧点数",
"s_stm32_errs++:连续 >=2 拍无新帧",
"s_stm32_hw_errs++:HAL 错误回调"] ),
dict(title="同一拍的四个 STM32 计数",
diff --git a/tools/arch_diagram/gen_adc_health_timeline_svg.py b/tools/arch_diagram/gen_adc_health_timeline_svg.py
index 54c1ca72..6d971899 100644
--- a/tools/arch_diagram/gen_adc_health_timeline_svg.py
+++ b/tools/arch_diagram/gen_adc_health_timeline_svg.py
@@ -90,9 +90,9 @@ PANELS = [
marks=[(20, 9, "1"), (60, 9, "2")],
notes=["1 t=20 ms:d=3 → tgap += 2 + 当拍事件,下一拍事件消失",
" (边沿语义,不是电平)",
- "2 丢帧不入锁存、不告警、不闭锁;两者口径也不同:tgap",
- " 计帧点数(d-1 加权),adc1_gap 计次数(每次 +1)。",
- " 台架日志:启动突发期累计 23 次,稳态每 10 s +1~2 次"],
+ "2 丢帧不入锁存、不告警、不闭锁;两条车道同单位:",
+ " tgap 与 adc1_gap 都按帧点数(d-1 加权)累计,心跳里可",
+ " 直接对比。台架日志:启动突发期 23 点,稳态每 10 s +1~2 点"],
),
dict(
title="D 序号倒退:锁存不可自愈,代价是永久闭锁",