# 采样有效性校验设计说明 > **本文回答的问题**:`App/algo/adc_health.c` 这 200 行为什么长成现在这样——判据是怎么定下来的、 > 每个设计选择换到了什么、代价与盲区在哪里、改它之前必须一起动什么。 > **阅读前提**:采样节拍与乒乓双缓冲 → `docs/子系统设计/ADC采样节拍设计说明.md`; > 8 路电气量为何分属两片转换器 → `docs/硬件平台/ADC通道映射说明.md`。 > **不在本文范围**:接口签名与全层约定速览在 `docs/算法/采集与算法层设计说明.md`,本文不重复其接口表; > 需求与验收口径登记在 `docs/算法/继电保护功能复现规划.md` §3.1(功能项「采样有效性校验」)与同文档 §3.5(动作留痕自检与闭锁)。 > **配图**:三张 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`,无堆、无栈上缓冲 | | 依赖 | 只有 ``:不碰 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 报错、语义取数与有效性停摆) | 无(驱动只累计不上报) | 事件 + 锁存,供事后归因(§6.4) | 最关键的是第一行。**节拍停而任务照跑时,上层拿到的是旧数据而不是空数据**:`App/task/adc_task.c` 只在 `adc_input_poll()` 返回 1 时才压环重算,于是 全周傅氏相量**保持最后一帧**;`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` | | 语义取数与有效性采集 | `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**:语义取数与有效性已有细账 `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 | 语义取数与有效性的 `adc_input_stm32_gaps()`,判定层只看它的增量 | **帧点数**:按 `d - 1` 加权 | 语义取数与有效性在消费点上直接看得到 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` | 语义取数与有效性消费侧 | 0 | 连续 >=2 拍无新帧,或 DMA 挂载失败 | `ADC_HE_STM32_ERR` | `ADC_HF_STM32_ERR` | 否 | | `stm32_gaps` | 语义取数与有效性消费侧 | 少量 | 超拍丢帧(§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`,语义取数与有效性的 `stm32_errs` 增量同时给出事件 | 100 ms | 闭锁 + 留痕 | | HAL 报错 | 语义取数与有效性计数 → 本层 `HE_HW_ERR` | 下一拍 | 事件 + 锁存,不闭锁 | | `adcTask` 超拍 | 本层 `HE_TPAFE_GAP` + 语义取数与有效性 `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 : 序号重新前进
电平位清,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. 怎么验证 **验证分工先说清**:台架 `/serial-boot-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` 行 | 那不是本层的判据(本层只看序号) | 属 语义取数与有效性相位伪影或通道首次未就绪,见 `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 —— 语义取数与有效性功能项与验收口径(本层 = 「采样有效性校验」行) - `docs/算法/继电保护功能复现规划.md` §3.5 —— 动作留痕自检与闭锁(本层供数的「自检失败闭锁」在此登记) - `docs/内存管理/RAM使用分析与优化方案.md` §2.3 与 §4.0.6 —— `adcTask` 栈配置、最坏链与余量台账 - `App/algo/README.md` —— 目录级文件清单与构建测试入口 - `docs/子系统设计/ADC采样节拍设计说明.md` §3.4 —— TIM3 启动位置与顺序(兜底门开机不误锁的前提)