Files
STM32F4-Base/docs/算法/采样有效性校验设计说明.md
wandering1 4a4ad6bc5f chore(全仓): @build/@test 旧文档别名清退为技能调用名
现状口径:/keil-build 与 /serial-boot-test 是 DSH 会话的技能调用名,
不是 shell 命令;「名 + 命令行参数」的命令形态改为可执行体
run_boot_test.py 的真实调用。

- AGENTS.md:别名说明重写为「技能调用名不是 shell 命令」,两条目改
  「编译(/keil-build)」「串口回归(/serial-boot-test)」
- keil-build / serial-boot-test 两 skill 调用名与示例同步;task-worktree
  方案文档去掉 @build/@test 惯例从句;@task 保留(不在点名范围)
- 123 文件现状陈述全改;台架捕获、变更记录/时间线、rename 映射输入
  等 41 行历史证据保留旧名
- gen_memory_svg.py 6 处措辞随改,3 张 SVG 产物同批重跑
- 顺手补并行方案同行漏网计数 51→52 钩子

验证:提交期快钩子对 123 文件实跑全 Passed;check_doc_links --all
rc=0(3038 处、无法判定 0);check_mermaid --all rc=0;YAML 解析 52
钩子不变;skill-size rc=0;py_compile 全过;字节卫生零漂移。
2026-10-30 13:03:12 +08:00

46 KiB
Raw Permalink Blame History

采样有效性校验设计说明

本文回答的问题: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. 三分钟建立心智模型

一句话:本模块不看数据、不看寄存器,只回答一个问题——两片转换器还在不在往前吐帧。

维度 内容
输入 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 报错、语义取数与有效性停摆) 无(驱动只累计不上报) 事件 + 锁存,供事后归因(§6.4)

最关键的是第一行。节拍停而任务照跑时,上层拿到的是旧数据而不是空数据:App/task/adc_task.c 只在 adc_input_poll() 返回 1 时才压环重算,于是 全周傅氏相量保持最后一帧;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
语义取数与有效性采集 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 计数,判定只看差值:

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 纯逻辑换取免桩单测

入参是「值」而不是「指针 + 接口」,因此测试可以把时间与计数任意摆:

in.now_ms    = 0xFFFFFF00u;   /* 直接站在 tick 回绕边界上 */
in.tpafe_seq = 995u;          /* 人为制造倒退 */

这些工况在台架上要靠「拔芯片 / 改驱动」才造得出来,而且造出来也说不清落在哪一拍、 是否恰好跨过门限。故障注入的可复现性 + 边界的可精确性是这一层被单独拆成纯逻辑文件的 直接动因(完整理由见 test/host/adc_health/adc_health_test.c 文件头)。


5. 判定主干走读

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. 四类判据逐个说透

采样有效性判定时间轴

四块面板分别是:A 健康对照、B 静默检出与自愈、C 消费者超拍、D 序号倒退。红线 = ADC_HEALTH_STALL_MS = 100 ms;下方两条条带 = 位为 1 的存续区间(上 = flags, 下 = sticky / events)。

6.1 静默 stall:门限 100 ms 是怎么定的

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. 位图、掩码与告警编排

采样有效性位图与掩码

位 名称 类别 置位条件 清除条件
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 里编号相同,编排层因此可以 拿事件位直接去操作「已留痕位图」,一行搞定每类每次开机只留痕一次:

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 读一次电平位:

#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 探针,验完即撤):

[+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. 状态机视角:静默位的生命周期

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. 怎么验证

验证分工先说清:台架 /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 文件头)。跑法:

# 直接跑(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 启动位置与顺序(兜底门开机不误锁的前提)