Files
STM32F4-Base/docs/算法/采样有效性校验设计说明.md
T
wandering1 4c74cfa825 refactor(编号·批次二): 文档侧 194 处保护功能号改成功能名,定义表 7 张首格改语义标识
批次二文档半(道 gate-docs 执行)。先纠三个数的口径账,别再拿任何旧数当准:**234 处是准数** —— 卡片写
52(Lead 用了门禁 Checker 的引用集合,它**刻意排除属主文档内部的定义位**)、道里先报 265(裸正则把
`SDL2-2.32.10` 里的 `L2-2` 当成功能号,误报 10 处)。最终边界口径 = 带左边界 + 跳过反引号代码段。

· 19 只文件 HEAD 共 234 处 → 现 40 处,**40 处 100% 落在变更记录行**(冻结历史,按 Q3 一条没动),
  非变更记录残留 **0**;改写 163 行 / 16 只文档。站点最多:规划文档 110→37、相量层单测说明 25→1、
  采集与数据处理短板评测 21→0、App/algo/README 26→0。
  (`App/algo/README.md` 由上一笔代码提交带上车,路径归属已在该笔正文注明,内容是本批改写)
· **定义表首列由编号改成语义标识**(编号规范 §3.3「表首格就是短语」的完全形态):首列原为编号的表
  共 **7 张**(不是 5 张),删掉与之重复的「语义名」列;表头标签按域词区分——三张 L3 保护**元件**表
  保留 `| 元件 |`,四张 L1/L2 表用 `| 功能名 |`(把「元件」统一洗成功能名会丢掉领域信息)。
  七张表逐张核:表头竖线数 == 分隔行 == 首条数据行 == 列数+1;与 HEAD 逐块对拍**净新增不一致块 0**,
  并且正是这个对拍抓到第一版「三张表数据行已删列、表头没删」的破表。
· 7 处删号留下的残词/重复解释括号人工收口(如「L1-5(通道标定)」→「通道标定(一次系数 + 零漂)」),
  复扫待收口站点 0;措辞逐字取自映射表,无第二种说法。
· tools/arch_diagram 三只生成器 4 处(3 处 docstring + 1 处图形文案)与产物同批:重跑后**只有位图那张
  变 2 个 text 节点的换行位置**,画布 1580x740 与总行数不变,另两张逐字节未变;改前先干跑对照证明三张
  svg 原本与生成器一致(不在 stale 基线里)——`check_generated_fresh --only docs-svg` PASS。

本笔另含 **F-* 半批的文档面**(同一轮 `git add docs tools/arch_diagram AGENTS.md` 一并收进
,非误伤):BSD
函数调用关系里 9 处反引号紧邻指针、属主文档 §7 的停发句与导读行(**12 条定义行原样保留、不删登记族**)、
以及 `gen_net_instability_svg.py` 与它的产物 `网络不稳根因总览.svg`(「唯一读者红线」那条的框宽自适应
526 → 551,右缘 1411 未越界,画布 1560x1468 不变)——该半批的**代码注释面**在下一笔。

⚠ 因 `docs/任务规划/登记簿.md` 与 `docs/任务规划/UI补齐.md` 正被另一张卡(UI 叶数复核)改动,本笔**不含
那两只文件**,批次二的 §9 存量收尾与它们同批提交。
2026-10-27 09:05:24 +08:00

664 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 采样有效性校验设计说明
> **本文回答的问题**:`App/algo/adc_health.c` 这 200 行为什么长成现在这样——判据是怎么定下来的、
> 每个设计选择换到了什么、代价与盲区在哪里、改它之前必须一起动什么。
> **阅读前提**:采样节拍与乒乓双缓冲 → `docs/子系统设计/ADC采样节拍设计说明.md`;
> 8 路电气量为何分属两片转换器 → `docs/硬件平台/ADC通道映射说明.md`。
> **不在本文范围**:接口签名与全层约定速览在 `docs/算法/采集与算法层设计说明.md`,本文不重复其接口表;
> 需求与验收口径登记在 `docs/算法/继电保护功能复现规划.md` §3.1(功能项「采样有效性校验」)与同文档 §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-兜底新鲜度门保护侧独立通道)
- [16. 相关文档](#16-相关文档)
- [变更记录](#变更记录)
---
## 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`,无堆、无栈上缓冲 |
| 依赖 | 只有 `<stdint.h>`:不碰 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. 在采集链路里的位置
![采样有效性校验数据流](../images/采样有效性校验数据流.svg)
| 层 | 落点 | 在本机制里只负责什么 |
|---|---|---|
| 驱动 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()`,命中掩码即复位元件并停判据(「自检失败闭锁」) |
两条边界是刻意的,破坏它们的代价写在右边:
1. **ISR 侧只加计数,不在中断里打日志**——FRSTDATA 异常只累计,帧序号只自增。
中断里做重活会直接推后下一拍采样,见 `docs/子系统设计/ADC采样节拍设计说明.md` §5。
2. **判定层不许出现 HAL / 日志 / 取数**——`test/host/adc_health/adc_health_test.c` 用 `#include`
原样纳入生产源文件、**零桩**跑全套。判定层里多一个 `DBG_ERROR`,主机侧就要为它写一份桩,
「离线穷举故障」这条唯一的验证路径随之失效(§11)。
---
## 4. 四条设计思想
### 4.1 判「前进量」,不判「数据合理性」
| 候选判据 | 为什么不用 |
|---|---|
| 码值越界 / 超量程 | 「通道标定(一次系数 + 零漂)」通道标定未实现(`docs/算法/继电保护功能复现规划.md` §3.1),码值还没有工程量含义;且两片转换器增益不同(TPAFE 16 位双极性 / STM32 12 位单极性),不存在统一阈值 |
| 波形合理性(方差、过零、单调) | 这是「信号对不对」,属「采样通道自检」的另一半;空载与零值都是合法工况,判据只能靠猜阈值 |
| **序号是否前进** | 唯一与信号内容无关的新鲜度证据:不管输入是正弦、零值还是断线,正常态就是每拍 +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. 四类判据逐个说透
![采样有效性判定时间轴](../images/采样有效性判定时间轴.svg)
四块面板分别是: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()`,判定层只看它的增量 | **帧点数**:按 `d - 1` 加权 | L1 在消费点上直接看得到 DMA 完成计数,细账归它;判定层再算一套就是两处判据打架 |
两条车道**同单位(帧点数)**,心跳里 `tgap` 与 `adc1_gap` 可以直接对比(2026-10-21 之前
固件的 `adc1_gap` 是"次数"口径,跨版本对比要留意:单拍超一帧时两者数值相同,只有
一次跨过 ≥2 帧才会分出差值)。字段口径见 `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 无跳变) | `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. 位图、掩码与告警编排
![采样有效性位图与掩码](../images/采样有效性位图与掩码.svg)
| 位 | 名称 | 类别 | 置位条件 | 清除条件 |
|---|---|---|---|---|
| 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 | `ADC_HF_STALE` | 电平(**由 protectTask 置位**,判定层不写) | 帧年龄 >= 120 ms(§15) | 序号恢复前进 |
| 9..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` 永久阻塞,没人跑判定)→ 由 §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` | 下一拍 | 事件 + 锁存,不闭锁 |
| `adcTask` 超拍 | 本层 `HE_TPAFE_GAP` + L1 `adc1_gap` | 当拍 | 只计数 |
| 帧序号倒退 | 本层 `SEQ_BACKWARD` | 当拍 | 闭锁且不自愈,直到复位 |
一句话概括本层的**能力边界**:它判的是「生产者相对消费者是否前进」,因此**判不了消费者自己**
是否还活着。过去只能靠 IWDG 兜命——它判的是"任务还在不在",不是"数据新不新";现在另有一条与本层
完全解耦的兜底门专门管这件事(§15)。三者不可互相替代,也别指望把本层门限调小来顶班。
**这条盲区已闭合(原已知窗口)**: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`。
---
## 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 : 序号重新前进<br/>电平位清,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` §2.3(配置与实测峰值)与 §4.0.6(静态最坏链与判定) |
| Flash | 主要花在编排层的日志调用点,判定层本身很小 | 每条 `DBG_*` 约 90 B Flash,故新增位**不要**顺手加日志 |
**观测盲区**(如实登记,别在别处再假设它们存在):
1. `adc_health_status_t` 只给 `tpafe_stall_ms`,**STM32 侧没有对应的停滞时长字段**——
只知道「STM32 静默了」,不知道「静默多久」。
2. `tpafe_stall_ms` 只在电平位置位时给值,正常态恒为 0,因此它不能当「距上次前进的时间」趋势量用。
3. **观察者死亡本层不可自证**(§8 责任矩阵):判定层的存活依赖 `adcTask`。该盲区由 §15 的兜底门
闭合(保护不再吃陈旧数据),但**本层自身**仍然报不出"我没在跑"这件事。
4. `tgap` 与 `adc1_gap` 现已同为帧点数口径(§6.2);但**换版前的历史日志**里 `adc1_gap`
是次数,跨版本对比前先确认固件版本。
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()` 全清,且重新建基线干净 |
| 帧年龄:首拍只建基线 | `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(宁可不锁) |
合计 **17 组用例 / 45 条断言**(用例清单与被测源文件同一处:
`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;
该日志出自**换版前固件**,其中的 `adc1_gap` 是次数口径)
加上全程零 `[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` | 闭锁解除 | 与上一条配对看时长 |
| `…protection blocked (hf=0x00000100)` | **判定层失明时的兜底**:只有 bit8、stall 位一个都没报 | 查节拍源与 `adcTask`:同次开机 `[ADC_TASK] health:` 是否 0 次、`MISS` 快照是否缺 bit6(§15) |
| 心跳 `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 | 先回答「主机单测怎么免桩跑」;答不上来就别改 |
| 动兜底门(§15) | 同步 `PROT_HF_LOCK_MASK`、§7 位表 bit8 行、§8 责任矩阵、§11 的 TC-ADCHEALTH-012..017、配图生成器 `tools/arch_diagram/gen_adc_health_bitmap_svg.py`;两条车道的 `primed` 必须各一份 |
三条禁令(都对应已写死的机制,不是风格问题):
1. 判定层**不取数、不打日志、不碰寄存器**——否则 §11 的整条验证路径作废。
2. 判定层**不用浮点、不做除法**——它在 1 kHz 路径上,且主机侧要能逐拍精确复现每个分支。
3. 位图**不复用已冻结位号**:bit0..bit7 已分配,新位从 bit8 起;`events` 与 `sticky` 若要共享
语义就必须共享位号(§7 的去重写法依赖这点)。
**启动路径事实**:`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` §4.0.6)。
---
## 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` 所在行)
- `docs/子系统设计/看门狗设计说明.md` §4.3 —— 长耗时窗口的三类处置(瞬态静默的候选来源)
- `docs/子系统设计/看门狗设计说明.md` §11.4 —— `[PROF]` 探针字段与耗时基线(判「超拍」要看它)
- `docs/硬件平台/ADC通道映射说明.md` —— 语义映射唯一权威口径(与「哪一侧静默」直接相关)
- `docs/算法/继电保护功能复现规划.md` §3.1 —— L1 功能项与验收口径(本层 = 「采样有效性校验」行)
- `docs/算法/继电保护功能复现规划.md` §3.5 —— L5 自检与闭锁(本层供数的「自检失败闭锁」在此登记)
- `docs/内存管理/RAM使用分析与优化方案.md` §2.3 与 §4.0.6 —— `adcTask` 栈配置、最坏链与余量台账
- `App/algo/README.md` —— 目录级文件清单与构建测试入口
- `docs/子系统设计/ADC采样节拍设计说明.md` §3.4 —— TIM3 启动位置与顺序(兜底门开机不误锁的前提)
---
## 变更记录
| 日期 | 变更 | 说明 |
|---|---|---|
| 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()` |