用户裁定:文档只写现状,历史流水一律删掉,过程查 git log。 - 43 篇「变更记录 / 修订记录」整节删除:继电保护规划 12.6 KB、产品 需求 11.8 KB、项目部署环境 5.5 KB、BSD Socket 指南 5.3 KB、架构 总览 5.0 KB、实机测试实施指南 3.8 KB 等 - 23 篇别处仍有指向:只留节名 + 一行「本文件不再记流水账,过程查 git log」,节号与锚点不位移(doc-links 不因此断链) - 5 篇整表保留(被当唯一证据 / 活台账引用):看门狗设计说明、配置 文件设计说明、RAM预算台账、内容页架构、本地工具路径环境变量方案 - 规范同步:文档规范 §5.1 改新裁定;测试文档模板规范删示例节与 登记句;测试开发标准流程 / UI 两篇 / 实机测试实施指南 / 四篇单测 说明 / 并行任务工作树方案 / 编号规范 的「登记变更记录」改「写进 正文」 - 技能同步:stale-doc-count-sweep / ui-verify / id-family 三个 SKILL 正文不再要求追加流水行(顺带消掉 SKILL.md:121「产品需求 §12」 失效引用) - 修 5 处悬空指针:采样有效性校验目录锚点、BSD 指南「文末变更 记录」、docs/README 过期描述、网络自愈架构两处指向面 72 篇文档净减 600 余行;check_doc_links --all --sections-strict 可判 定比例 61% → 78%、失效引用 0;静态门禁 passed=50 failed=0;gitlint 10 条历史提交信息 0 failed。 未提交他人在飞改动:.dsh/skills/keil-build/*(%SystemDrive% 守卫)。
46 KiB
采样有效性校验设计说明
本文回答的问题:
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. 三分钟建立心智模型
- 2. 它要解决的问题
- 3. 在采集链路里的位置
- 4. 四条设计思想
- 5. 判定主干走读
- 6. 四类判据逐个说透
- 7. 位图、掩码与告警编排
- 8. 闭锁链路与兜底责任划分
- 9. 状态机视角:静默位的生命周期
- 10. 代价与观测盲区
- 11. 怎么验证
- 12. 现场判读手册
- 13. 自测问题(读完能不能真的懂)
- 14. 改它之前必读
- 15. 兜底新鲜度门(保护侧独立通道)
- 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 报错、语义取数与有效性停摆) | 无(驱动只累计不上报) | 事件 + 锁存,供事后归因(§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(),命中掩码即复位元件并停判据(「自检失败闭锁」) |
两条边界是刻意的,破坏它们的代价写在右边:
- ISR 侧只加计数,不在中断里打日志——FRSTDATA 异常只累计,帧序号只自增。
中断里做重活会直接推后下一拍采样,见
docs/子系统设计/ADC采样节拍设计说明.md§5。 - 判定层不许出现 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 |
三条容易被漏掉的行为:
in == NULL提前返回,out保持原值(不是写全 0)——调用方复用同一个结构体时看不到被抹平的历史。out == NULL仍照常更新内部状态——支持「只喂状态、不取结果」的调用法(TC-ADCHEALTH-010钉住)。- 首拍也会写
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,故新增位不要顺手加日志 |
观测盲区(如实登记,别在别处再假设它们存在):
adc_health_status_t只给tpafe_stall_ms,STM32 侧没有对应的停滞时长字段—— 只知道「STM32 静默了」,不知道「静默多久」。tpafe_stall_ms只在电平位置位时给值,正常态恒为 0,因此它不能当「距上次前进的时间」趋势量用。- 观察者死亡本层不可自证(§8 责任矩阵):判定层的存活依赖
adcTask。该盲区由 §15 的兜底门 闭合(保护不再吃陈旧数据),但本层自身仍然报不出"我没在跑"这件事。 tgap与adc1_gap现已同为帧点数口径(§6.2);但换版前的历史日志里adc1_gap是次数,跨版本对比前先确认固件版本。- 倒退的可判定窗是 ±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 文件头)。跑法:
# 直接跑(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 必须各一份 |
三条禁令(都对应已写死的机制,不是风格问题):
- 判定层不取数、不打日志、不碰寄存器——否则 §11 的整条验证路径作废。
- 判定层不用浮点、不做除法——它在 1 kHz 路径上,且主机侧要能逐拍精确复现每个分支。
- 位图不复用已冻结位号: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 启动位置与顺序(兜底门开机不误锁的前提)