# ADC 采样节拍设计说明(TIM3 1 kHz 同源硬件节拍)
> **模块名称**:ADC 采样节拍与消费链——TIM3 节拍源(`Src/tim.c`)、更新事件回调(`Src/main.c`)、
> 语义取数与有效性层(`App/algo/adc_input.c`)、消费者 `adcTask`(`App/task/adc_task.c`)。
> **文档定位**:**现状设计说明**——这条链当前由谁产生节拍、同一个硬件事件如何驱动两片转换器、
> STM32 侧怎么搬运、消费者怎么取数、为什么这样配置、边界与代价在哪里。**历史演进、方案对比、
> 门禁与台架复测记录、修订日志一律不写进本文**(需要时查 git 历史)。
> **状态**:已落地并经真机验证;下游 = 全周傅氏相量 / 测频 → 保护判据(`docs/算法/采集与算法层设计说明.md`)。
> **相关文档**:
> `docs/硬件平台/ADC通道映射说明.md`(网名 ↔ 板级 `ADCn` ↔ 转换器通道号,唯一权威)
> `docs/算法/继电保护功能复现规划.md` §3.1「周期 CONVST 重触发」
> `Drivers/BSP/TPAFE5160/README.md`(驱动契约:`BUSY` / `FRSTDATA` / 原子快照)
> `docs/子系统设计/看门狗设计说明.md` §11.4(`[PROF]` 字段语义与耗时基线)
>
> ⚠ **网名 `AINn` ≠ 板级 `ADCn` ≠ 转换器通道号**(17 路里 13 路置换)→ 取数必须查通道映射文档。
## 目录
- [1. 节拍链路总览](#1-节拍链路总览)
- [2. 实现落点](#2-实现落点)
- [3. 节拍源:TIM3 配置与启动](#3-节拍源tim3-配置与启动)
- [4. STM32 侧:ADC1 触发与 DMA 乒乓双缓冲](#4-stm32-侧adc1-触发与-dma-乒乓双缓冲)
- [5. TPAFE 侧:CONVST 脉冲与 BUSY 读帧](#5-tpafe-侧convst-脉冲与-busy-读帧)
- [6. 消费者 adcTask:事件驱动唤醒](#6-消费者-adctask事件驱动唤醒)
- [7. 单周期时序与中断交互](#7-单周期时序与中断交互)
- [8. 可观测性:心跳计数与判读](#8-可观测性心跳计数与判读)
- [9. 性能与资源特征](#9-性能与资源特征)
- [10. 设计边界与已知代价](#10-设计边界与已知代价)
- [11. 设计铁律(改这条链前必读)](#11-设计铁律改这条链前必读)
---
## 1. 节拍链路总览
一句话链:**TIM3 每 1 ms 产生一个更新事件,同一个事件同时驱动两片转换器,并唤醒唯一的消费者任务**。
```mermaid
flowchart TD
T["TIM3 更新事件(1 kHz)"]
T --> ISR["TIM3 更新中断(抢占优先级 6)"]
T --> TRGO["TRGO_UPDATE → ADC1 外部触发(上升沿)"]
ISR --> CONV["PC9 CONVST 脉冲"]
ISR --> WAKE["adc_tick_irq_notify()
就绪门 + 置 ADC_WAKE_FLAG"]
CONV --> BUSY["TPAFE 转换 → BUSY 拉低"]
BUSY --> EXTI["PC7 EXTI(优先级 6)读 8 通道
帧缓冲 + seq++"]
TRGO --> SCAN["ADC1 一轮 9 通道"]
SCAN --> DMA["DMA2_Stream4 循环搬运
乒乓双缓冲 2×9 半字"]
WAKE --> TASK["adcTask(AboveNormal)被唤醒即抢占"]
EXTI --> POLL["adc_input_poll():TPAFE 原子快照"]
DMA --> TAKE["stm32_take():取上一拍完成的半区"]
TASK --> POLL
TASK --> TAKE
POLL --> 全周傅氏相量["全周傅氏相量 / 测频 → 采样环
仅确有新帧时压环"]
TAKE --> 全周傅氏相量
全周傅氏相量 --> OUT["下游:保护判据(protectTask)"]
全周傅氏相量 --> HB["task_wd_beat(TASK_WD_ADC) + 10 s 心跳"]
```
**关键性质**:
- **一个更新事件驱动两片转换器**:TPAFE 的 CONVST 由 ISR 内 GPIO 脉冲给出,STM32 ADC1 由同一事件
的 TRGO 硬件触发 → 两者的相对相位固定,与软件调度无关。
- **节拍精度是硬件级**:84 MHz ÷ (PSC+1) ÷ (ARR+1),与 FreeRTOS tick(TIM7)无关;硬件自动重载,
任务被长时抢占**不漏拍**。
- **节拍与唤醒同源**:消费者由同一个更新事件的 ISR 唤醒 → 改采样率时消费节拍自动跟随(§10)。
- **消费者是纯消费者**:不触发转换、不自旋等转换、不做保护运算;只做"取一拍 → 压环 → 算相量 → 心跳"。
- **零新任务、零动态分配**;这条链新增的静态 RAM 只有 `s_stm32_dma` 36 B(§9)。
⚠ **同一事件驱动 ≠ 8 路电气量同刻采样**:两片转换器之间没有共同采样触发点(§10 第一条)。
---
## 2. 实现落点
| 位置 | 当前职责 | 改动边界 |
|---|---|---|
| `MDK-ARM/STM32F407-Demo.ioc` | TIM3 使能与参数(PSC / ARR / TRGO / NVIC 6)、ADC1 触发源与 DMA 绑定、DMA2_Stream4 | **改这里**,重生成才不丢 |
| `Src/tim.c` / `Inc/tim.h` | `htim3`、`MX_TIM3_Init()`、`HAL_TIM_Base_MspInit()`(`TIM3_IRQn` 优先级 6) | CubeMX 生成,勿手改 |
| `Src/stm32f4xx_it.c` | `TIM3_IRQHandler()` → `HAL_TIM_IRQHandler(&htim3)` | 生成 |
| `Src/main.c` 的 `HAL_TIM_PeriodElapsedCallback()` | TIM3 分支:CONVST 脉冲 + `adc_tick_irq_notify()` | **只写 `USER CODE` 区**(§3.5) |
| `App/app/app_main.c`(步骤 6) | 唯一的节拍启动点 `HAL_TIM_Base_Start_IT(&htim3)` | 手写;顺序约束见 §3.4 |
| `Src/adc.c` | ADC1 触发与 DMA 参数 + `adc1_calibrate()` | 参数在生成区;自校准在 `USER CODE` 区(§4.2) |
| `Src/dma.c` | DMA2_Stream4 参数与 NVIC 优先级 | 生成区;**改流后必回本页核对**(§4.3) |
| `App/algo/adc_input.c` | 语义取数与有效性:`adc_input_poll()` / `adc_input_raw()`、半区推导、DMA 完成回调、四类故障计数 | 手写 |
| `App/task/adc_task.c` / `adc_task.h` | 消费者主循环、`adc_tick_irq_notify()` 与就绪门、10 s 心跳统计 | 手写 |
| `Src/freertos.c` | `adcTask` 栈 640 words、优先级 `osPriorityAboveNormal` | 手写于 `USER CODE` 区(不在 `.ioc`);改栈前后各跑一次 `stack_report.py` |
| `App/task/protect_task.c` | 下游消费者:20 ms 判据节拍取 全周傅氏相量快照 | → `docs/算法/采集与算法层设计说明.md` §9 |
---
## 3. 节拍源:TIM3 配置与启动
### 3.1 当前配置
| 项 | 值 | 依据 / 后果 |
|---|---|---|
| 计数时钟 | 84 MHz(APB1 定时器时钟 = 42 MHz × 2) | SYSCLK 168 MHz / HCLK 168 MHz / APB1 ÷4 |
| Prescaler (PSC) | **83** | → 1 MHz 计数,**1 µs 分辨率** |
| Counter Period (ARR) | **999** | → **1.000 kHz**(1000 µs 周期) |
| Counter Mode | Up | — |
| Clock Division | No Division | 与采样无关 |
| auto-reload preload | **Enable** | 运行期写 ARR 在下一个更新事件生效 → 改频无毛刺(§10) |
| Trigger Event Selection | **Update**(TRGO = 更新事件) | 硬件触发 ADC1(§4.1) |
| Master-Slave Mode | MSM = ENABLE(从模式 `SMS` = 0 内部时钟) | 软件 `CEN` 启动不受影响(已核对 RM0090 与生成代码) |
| NVIC | `TIM3_IRQn` 抢占 **6** / 子优先级 0 | 理由见 §3.3 |
| ADC1 触发 | `ADC_EXTERNALTRIGCONV_T3_TRGO`,边沿 RISING | §4.1 |
`MX_TIM3_Init()` **只配置不启动**;`HAL_TIM_Base_Start_IT(&htim3)` 是唯一启动点(§3.4)。
### 3.2 为什么是 TIM3(不是 TIM6 / TIM7)
F4 上 **ADC1 的外部触发清单里没有 TIM6 / TIM7 的 TRGO**——它们的 TRGO 只接 DAC。ADC1 的可用外部触发
(RM0090 的 `EXTSEL` 表、本仓 `stm32f4xx_hal_adc.h`、CubeMX 器件库 `db/mcu/IP/ADC-STM32F417_Modes.xml`):
`T1_CC1/2/3、T2_CC2/3/4、T2_TRGO、T3_CC1、T3_TRGO、T4_CC4、T5_CC1/2/3、T8_CC1、T8_TRGO、EXTI15`
想让**同一个硬件事件**同时驱动两片转换器,TRGO 型候选只剩 TIM2 / TIM3 / TIM4 / TIM5 / TIM8;
TIM7 已被 HAL 时基占用,TIM3 空闲且同为 APB1 通用定时器 → **用 TIM3**。PSC / ARR 与 HAL 时基 TIM7
同构(84 MHz / 1 MHz / 1000 计数),对照排查时两边可直接互参。
**换节拍源前先核三条**:① 该定时器的 TRGO 在 F4 上确实能触发 ADC1;② 定时器空闲(TIM7 = HAL 时基);
③ 是否仍在 APB1 时钟域(否则 PSC / ARR 要按新时钟重算)。
### 3.3 优先级为什么是 6
- 与 TPAFE `BUSY` 的 EXTI(`EXTI9_5_IRQn`)**同抢占优先级 → 互不抢占**:读帧的 8 次 RD 翻转不会
被打断,避免"读到一半被新转换刷新数据总线"。
- ≥ `configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY`(= 5)→ ISR 内允许调内核 API(`osThreadFlagsSet()`
实为 `xTaskNotifyFromISR()` + `portYIELD_FROM_ISR()`,会立即触发上下文切换)。
- 5 是 USART、7 是 SPI / DMA 等 → 被更高优先级 ISR 挡住的时长以 µs 计(§7)。
- `DMA2_Stream4_IRQn` 同为 6(`Src/dma.c`)。
### 3.4 启动位置与顺序
1. `MX_TIM3_Init()` 由 `main()` 在 `app_main_init()` 之前调用(生成代码,**只配置不计数**)。
2. **`HAL_TIM_Base_Start_IT(&htim3)` 必须排在 `tpafe5160_init()` 成功之后**(`app_main.c` 步骤 6):
否则 ISR 会在驱动首帧有效性自检期间乱发 CONVST,使自检结果不可信。
3. 节拍启动仍**早于内核**:`osKernelInitialize()` / `MX_FREERTOS_Init()` / `osKernelStart()` 都在其后
→ "节拍已在跑"与"消费者已存在"之间有约 100 ms 窗口,处置见 §6.1。
### 3.5 共用回调与生成区落点
`HAL_TIM_PeriodElapsedCallback()` 被 **TIM7 与 TIM3 共用**:TIM7 分支是 HAL 时基(`HAL_IncTick()`)与
看门狗时基(`task_wd_tick_probe()`),TIM3 分支是采样节拍。三条分支**并列**,不得合并、不得删改
TIM7 那两条(改坏不报编译错,只在时钟与看门狗上出事)。TIM3 分支写在 `USER CODE` 保护区;
重生成存活方法论与审计脚本 `scripts/check_cubemx_survival.py` → `docs/子系统设计/看门狗设计说明.md` §10。
---
## 4. STM32 侧:ADC1 触发与 DMA 乒乓双缓冲
### 4.1 ADC1 当前配置
| 项 | 值 | 后果 |
|---|---|---|
| `ScanConvMode` | ENABLE | 一轮 9 通道 |
| `ContinuousConvMode` | DISABLE | 每轮由外部触发启动(§7) |
| `ExternalTrigConv` | `T3_TRGO`,`ExternalTrigConvEdge = RISING` | 与 CONVST 同一个更新事件 |
| `NbrOfConversion` | 9 | 通道清单与语义 → `docs/硬件平台/ADC通道映射说明.md` |
| `DMAContinuousRequests` | **ENABLE**(DDS 位) | ⚠ 关掉 = 一轮 9 通道只发**一次** DMA 请求 → **静默丢 8 路,不报任何错** |
### 4.2 上电自校准
F4 的 ADC **上电必须自校准**:缺它不报任何错,只是读数带失调。CubeMX 不生成这一步,且本仓库裁剪版
HAL 没有 `HAL_ADCEx_Calibration_Start()`、裁剪版 CMSIS 头没有 `ADC_CR2_CAL`(位值 3 在源内自定义)
→ `adc1_calibrate()` 手写于 `Src/adc.c` 的 `USER CODE` 区,在 `MX_ADC1_Init()` 末尾调用。
时序按 RM0090 §13.3.6:清 `ADON` → 等硬件清零 → 置 `ADON` 上电待稳定 → 置 `CAL` → 等硬件**自动**清
`CAL`(上限 10 ms)。存活由审计项 `adc1-calibration-call` / `adc1-calibration-impl` 兜底。
### 4.3 DMA 选型与四条硬约束
| 项 | 当前值 | 为什么 / 违反的代价 |
|---|---|---|
| 流 | **DMA2_Stream4** | ADC1 在 DMA2 **只挂 Stream0 或 Stream4**(Stream1 = ADC3、Stream2/3 = ADC2)。取 Stream4 是为了不碰 SPI1_RX / USART1_RX 所在的流(NAND 与调试串口的 DMA)——让 CubeMX 自由分配会优先抢 Stream0,把这两路连锁搬家 |
| NVIC 优先级 | **6** | ⚠ **换流会把该流的 NVIC 优先级重置为 0**,0 抢占 FreeRTOS 临界区(上限 5)→ 任何改流之后必须回 `Src/dma.c` 核对优先级 |
| `DMAContinuousRequests` | ENABLE | 见 §4.1:违反时**不报错**,只是静默丢通道 |
| 缓冲地址 | `s_stm32_dma[18]`(`uint16_t`)落在 **`RW_IRAM1`** | ⚠ ADC1 走 **DMA2**,而 F4 的 DMA2 **不可达 CCM**(`0x10000000`)→ 放错不报编译错也不报运行错,数据永远搬不回来 |
模式:`DMA_CIRCULAR` + `DMA_PRIORITY_LOW` + halfword→halfword,长度 `2 × 9 = 18`(两个 9 通道半区)。
### 4.4 半区推导(无竞态写法)
半传输与整传输**都**代表"一整帧就绪",两个回调共用同一个自增计数(`HAL_ADC_ConvHalfCpltCallback()` /
`HAL_ADC_ConvCpltCallback()`,均带 `hadc == &hadc1` 句柄守卫);消费侧 `stm32_take()` 只读一个计数:
```c
uint32_t seq = s_dma_seq; /* 单次 32 位读:与 ISR 自增不产生撕裂 */
uint8_t half = (uint8_t)(1u - (seq & 1u)); /* 奇 = 刚完成前半区,偶 = 刚完成后半区 */
const uint16_t *src = &s_stm32_dma[half * ADC_INPUT_STM32_CH_NUM];
```
- **判据只读一个 32 位量**:不另存"当前半区"变量——否则会出现"读到新 `half`、读到旧 `seq`"的
组合窗口。
- **代价**:STM32 侧数据固定滞后一拍(1 ms)。两片转换器本就无同刻性(§10)→ 不新增误差。
- **失败拍不沿用旧值**:`seq` 未前进(本拍无新帧)或 `HAL_ADC_Start_DMA()` 挂载失败 → 清
`s_stm32_valid` → 本拍 STM32 侧判为无效,消费者**整帧跳过**(`skip` 计数,§8),下一拍成功自动恢复。
- 取帧状态码:`ADC_INPUT_RC_OK` = 0 / `ADC_INPUT_RC_START` = -1 挂载失败 /
`ADC_INPUT_RC_STALL` = -2 本拍无新帧 / `ADC_INPUT_RC_NOT_RUN` = -3 尚未取过帧。
### 4.5 使能时机与 DMA 缓冲相位(+2 格旋转,2026-10-27 实测)
**这条是本节最反直觉的一条:DMA 缓冲会整体错开 2 格,而寄存器配置全对、拷贝循环也全对。**
**现象。** 台架把某个端子接地,读数却出现在**另一个通道**上——例如接地 `AIN2`(`UB` / PC4 / CH14),
`UB` 读出 784 码(浮空值),而 `IB` 读出 0。表象与"映射表写错"完全一致,但
`SQR1/SQR2/SQR3` 实测为 CH0..CH7 + CH14、`L=8`,`k_stm32_rank_ch[]` 与拷贝循环也逐条核过,全对。
**证据。** 同一次开机内,先用无 DMA 单通道方式(逐路关扫描、软件触发、直接读 `DR`)取真值,
再与 DMA 路径的同一批输入逐路对照,得到 9/9 完美置换:
| 槽(rank) | 通道 | DMA 值 | 实际装的是 | 真值 | 差 |
|---|---|---|---|---|---|
| 1 | CH0 | 850 | CH2 | 850 | 0 |
| 2 | CH1 | 932 | CH3 | 931 | 1 |
| 3 | CH2 | 853 | CH4 | 852 | 1 |
| 4 | CH3 | 819 | CH5 | 817 | 2 |
| 5 | CH4 | 946 | CH6 | 945 | 1 |
| 6 | CH5 | 954 | CH7 | 953 | 1 |
| 7 | CH6 | 0 | CH14 | 0 | 0 |
| 8 | CH7 | 690 | CH0 | 691 | 1 |
| 9 | CH14 | 790 | CH1 | 785 | 5 |
即**槽 i 装的是 rank ((i+1) mod 9)+1 —— 整个缓冲从 rank 3 开始搬,固定错开 2 格**。
差值全在 5 码以内(两种读法的噪声),不是随机抖动。
**判据上的陷阱。** `s_stm32_raw[k_stm32_rank_ch[i]] = src[i]` 的恒等性**不能**用来证明映射正确:
DMA 从序列中途开始搬时,缓冲整体旋转,拷贝照样是恒等的。判映射只能靠
**按通道号索引的独立真值**(无 DMA 单通道读),或按 §4.5 的修法消除旋转后再比对。
**根因。** 触发条件是「ADC 被使能之后**闲置了数秒**才挂 DMA」。本工程 `MX_ADC1_Init()` 在 `main()`
里调用,而挂 DMA 在 `app_main_init()` 步骤 4b,中间隔着 GD5F 全片 BBT 扫描。台架对照:
"使能后闲置数秒才挂" → 7/7 次都是 +2 旋转;"使能与挂 DMA 在同一时刻" → 6/6 次旋转为 0
(探针固件 5/5 + 同一次开机内重新挂 DMA 1/1)。
**微架构层面的确切原因(为何恰好丢 2 拍)尚未证出**,但工程结论是确定的:使能与搬运不该有可观测间隔。
**修法。** `app_main_init()` 步骤 4b-pre 调 `adc1_disable()`(实现见 `Src/adc.c` 的 `USER CODE` 区)
先把 ADC 关掉,使能动作交给 `HAL_ADC_Start_DMA()` 内部完成——它自己 `__HAL_ADC_ENABLE` +
等 Tstab(约 20 µs),然后紧接着开 `CR2.DMA` 并启流。台架验证:在 `MX_ADC1_Init()` 末尾直接关闭
4/4 次、在步骤 4b-pre 调本 API 3/3 次,均 `UB=0`,同开机对照差值 ≤4 码。
⚠ **顺序不可调换**:`adc1_disable()` 必须严格早于 `adc_input_init()`;若 ADC 在挂 DMA 时仍处于使能态,
旋转会重新出现。改动被删时会由审计项 `adc1-calibration-impl` 的基线漂移拦下
(它整段守护 `Src/adc.c` 的 `USER CODE BEGIN 1`)。
踩坑经过、判读陷阱与回归判据另见 `docs/问题记录/ADC_Trap_Records.md` 的 `TRAP-ADC-01`。
---
## 5. TPAFE 侧:CONVST 脉冲与 BUSY 读帧
- **PC9 CONVST** 是 `GPIO_Output`(**不是**定时器复用引脚),脉冲由 TIM3 ISR 内两次 GPIO 写产生
→ 引脚配置、驱动 `tpafe5160_init()` 与首帧自检都不受影响。
- 转换约 23 µs → **PC7 `BUSY`** 拉低 → `EXTI9_5_IRQn`(优先级 6)内读 8 通道(≈0.5 µs)→ 写帧缓冲、
`seq++`。转换与读帧时序的契约在 `Drivers/BSP/TPAFE5160/README.md`。
- **取数只用原子快照**:`tpafe5160_snapshot()`(临界区,**仅任务上下文**)。旧的 `get_buf()` /
`data_ready()` / `clear_ready()` 已从驱动**删除**——两步之间被抢占会拷回两帧混合数据。
- 消费者一律经 `adc_input_poll()` / `adc_input_raw()` **按语义**取数,**不得写死缓冲区下标**
(`buf[2]=IA` 一类映射只写在 `docs/硬件平台/ADC通道映射说明.md`)。
- `frst_err`(`tpafe5160_frstdata_err()`)是"`FRSTDATA` 无跳变"的帧计数——它是"每个周期 `BUSY`
EXTI 都真的在读帧"的直接观测面。
---
## 6. 消费者 adcTask:事件驱动唤醒
### 6.1 就绪门(ISR 侧的唯一入口)
`TIM3` 的节拍远早于调度器(§3.4):这约 100 ms 窗口里 `adcTaskHandle` 还是 `NULL`、调度器也还没起。
- ISR **只调** `adc_tick_irq_notify()`,**不得**在回调里直接 `osThreadFlagsSet()`。前者内含就绪门
`s_consumer_ready`——由 `adcTask` 自己在进入等待前置 1(**单写者 = 任务,ISR 只读**)。
- 就绪门把时序约束写成**显式代码**,不依赖 FreeRTOS 对 `NULL` 句柄的内部宽容——"句柄已赋值、
调度器未启动"这一段仍会让 ISR 版内核 API 跑在调度器之外(内核启动前不得调用内核 API 是平台级
约束,见 AGENTS.md「RTOS 与内存」)。
- 置位与等待之间不漏唤醒:线程标志**粘滞**,开闸(`s_consumer_ready = 1u`)放在进入循环之前。
- **把"定时轮询"换成"事件驱动"时,必须单独核对节拍源启动时刻与消费者创建时刻**——否则第一次上电
就会踩到"ISR 对还不存在的消费者动手"。同类约束 → `docs/子系统设计/看门狗设计说明.md` §11.5。
### 6.2 等待语义与优先级
| 项 | 当前实现 | 说明 |
|---|---|---|
| 等待 | `osThreadFlagsWait(ADC_WAKE_FLAG, osFlagsWaitAny, osWaitForever)` | 无超时 = 刻意的 fail-safe(§6.3)。`ADC_WAKE_FLAG` 是**命令位**,等待即消费,**不加** `osFlagsNoClear`(那是就绪屏障位的规则) |
| 优先级 | `osPriorityAboveNormal` | 唤醒后**立即抢占**其余 Normal / BelowNormal 任务 → "唤醒 → 开始处理"的延迟固定在 µs 级,无 tick 量化、无累积漂移;代价见 §9、§10 |
| 超拍合并 | 多个 TIM3 脉冲合并成一次唤醒 | 下一轮 `adc_input_poll()` 返 `r == 0` → 记 `idle`,**不重复压环**(只有 `r == 1u` 才压环)→ 20 点 DFT 窗不会被上一拍的重复值污染 |
| 看门狗 | 每拍 `task_wd_beat(TASK_WD_ADC)`;`required_mask` 与心跳位集合一致 | 提优先级不改掩码 |
| 本任务不做 | 不触发转换、不 `osDelay` 轮询、不做保护运算 | 判据在 `App/algo/prot_elm.c`,消费者是 `protectTask`(20 ms 节拍) |
### 6.3 节拍缺失是刻意的 fail-safe
TIM3 未启动或停摆 → ADC1 不再被触发、`adcTask` 再无唤醒 → 任务永久阻塞 → `TASK_WD_ADC` 缺席 →
IWDG 复位。**设计如此**:节拍源缺失不允许被静默放过,不得为了"少一次复位"给等待加超时。
---
## 7. 单周期时序与中断交互
```text
t = 0 TIM3 更新事件 → ISR:PC9 CONVST 上升沿脉冲 + adc_tick_irq_notify()
同一更新事件经 TRGO 启动 ADC1 一轮 9 通道(DMA 逐通道搬运)
t ≈ 0+ε adcTask 被唤醒(AboveNormal → 立即抢占)→ adc_input_poll()
t ≈ 23 µs BUSY 拉低 → EXTI 读 8 通道(≈0.5 µs)→ 帧缓冲、seq++
t ≈ 67 µs 第 9 个通道搬完 → 半传输 / 整传输回调 s_dma_seq++
t = 1000 µs 下一个更新事件
```
| 交互 | 现状 | 判据 |
|---|---|---|
| CONVST ↔ 读帧 | 读窗口与下一次 CONVST 相隔约 977 µs,互不重叠 | 转换只占约 23 µs |
| TIM3 ↔ `BUSY` EXTI | 同抢占优先级 6 → **互不抢占** | 读 RD 时序不被打断(§3.3) |
| TIM3 ↔ 更高优先级 ISR | USART(优先级 5)可挡住节拍 ISR,时长 µs 级 | 触发抖动占 1000 µs 的 < 0.5% |
| ISR 本体 | 两次 GPIO 写 + 一次线程标志置位 | ISR 内不做任何采集与算法计算(§11 第一条) |
| 数据就绪时刻 | TPAFE 侧本拍 ISR 后约 23 µs;STM32 侧取**上一拍已完成**的半区 | §4.4;因此 `adc1_seq` 与 `seq` 之间是一个**恒定小偏移**(§8) |
---
## 8. 可观测性:心跳计数与判读
每 10 s 一条 `[ADC_TASK]` 心跳(`ADC_TASK_REPORT_MS` = 10000):
| 字段 | 来源 | 正常态 | 异常判读 |
|---|---|---|---|
| `frames` | `adc_input_poll() == 1` 的拍数 | 与 `seq` 同速 | 增速低于 `seq` → 消费者被挡或超拍 |
| `idle` | 无新帧拍数(`r == 0`) | 稳态不增长 | 持续增长 → `BUSY` EXTI 未在读帧,或节拍源停摆 |
| `skip` | 无有效帧整帧跳过(STM32 侧失败拍,§4.4) | 极少 | 持续涨 → 看 `adc1_err` / `adc1_hw` |
| `seq` | `tpafe5160_frame_seq()` | 每 10 s +10000(= 1000.0 Hz) | 不涨 → TIM3 未启动或转换未触发 |
| `frst_err` | `tpafe5160_frstdata_err()` | 0 | > 0 → 读帧与转换未对齐 |
| `ring` | `sample_ring_frames()` | `ring == frames` | 不等 → 有 `idle` / `skip` 拍被压进环(回归恒等式) |
| `adc1_rc` | 最近一拍取帧状态 | 0(`ADC_INPUT_RC_OK`) | -2 本拍无新帧;-1 DMA 挂载失败 |
| `adc1_seq` | `s_dma_seq`(半传输 + 整传输共用) | ≈ `seq`,**恒定小偏移** | 偏移持续增大 → STM32 侧在丢 |
| `adc1_gap` | Σ(`seq` 每次前进的超出部分) = **丢掉的帧点数**(2026-10-21 起;此前固件为"次数") | 每 10 s 心跳 +1 点 | 快速上涨 → 本任务单拍 > 1 ms(看 `adcm`) |
| `adc1_err` | **连续 ≥ 2 拍**无新帧 | 0 | > 0 → 节拍源 / DMA 真停 |
| `adc1_hw` | `HAL_ADC_ErrorCallback()` 次数 | 0 | > 0 → ADC / DMA 硬件报错 |
| `tgap` | `adc_health_tpafe_gaps()`(「采样有效性校验」) | 极少 | TPAFE 侧丢帧,与 `adc1_gap` **同单位(帧点数)**,可直接对比 |
单拍耗时(`adcp` = `TASK_PROF_ADC_POLL`)与整拍耗时(`adcm` = `TASK_PROF_ADC_BEAT`)不在心跳里,
由 DWT 探针的 `[PROF]` 行给出,字段语义与基线 → `docs/子系统设计/看门狗设计说明.md` §11.4。
**为什么"连续 ≥ 2 拍无新帧"才计 `adc1_err`(`ADC_INPUT_STALL_RUN_MAX = 2`)**:任务在 TIM3 触发瞬间
被唤醒,而该拍的 DMA 完成约落在 67 µs 之后;若上一拍被心跳日志或抢占拖过这 67 µs,本拍就会看到
"计数没前进"(那个完成已被上一拍消费掉)。这种**相位伪影只会连续 1 拍**——DMA 若真停,第 2 拍仍然
不会前进。故单拍无新帧**不计故障**,只记 `ADC_INPUT_RC_STALL`。
---
## 9. 性能与资源特征
| 指标 | 现状 | 来源 / 条件 |
|---|---|---|
| 节拍频率 | 1.000 kHz,硬件自动重载 | 84 MHz / 84 / 1000;心跳 `seq` 每 10 s +10000 |
| 单拍取数 `adcp` | **≈ 8 µs**(TPAFE 快照临界区 + 拷 9 个半字),本段**零忙等** | `[PROF]` 稳态;搬运由 DMA 完成后任务侧不再等待转换(§4) |
| 整拍最坏 `adcm` | ≈ 2 ms 峰值,**来自每 10 s 一次的心跳日志**(多行 `DBG_INFO` + 相量打印),不是采集 | 同批 `adcp` 仍是 µs 级、`idle = 0`;勿把 `adcm` 读成采集成本 |
| 稳态 `idle` | 0(唤醒与生产同源,无 tick 量化) | §6.2 |
| 稳态 `adc1_gap` | 每 10 s 心跳 +1 点(开机日志突发期集中数十点) | §8:心跳拍超 1 ms + 相位伪影 |
| 静态 RAM | `s_stm32_dma` 36 B 在 `RW_IRAM1` | `scripts/ram_report.py`;**加大缓冲前必须重跑 .map 确认仍在 `RW_IRAM1`**(§4.3) |
| 任务与栈 | 零新任务;`adcTask` 640 words,静态最坏链约 1.3 KB、余量约 48% | `scripts/stack_report.py`、`docs/内存管理/RAM使用分析与优化方案.md` §2.3 |
| 动态分配 | 无(自研代码禁 C 库堆) | AGENTS.md「平台约束」 |
| ISR 负担 | 1 kHz 一次,两次 GPIO 写 + 一次标志置位 | §7 |
**心跳拍的连带效应**:`adc1_gap` 的根治手段是**缩短心跳**(每 10 s 一次的多行日志),不是提高 DMA 能力。
日志成本模型 → AGENTS.md「日志与工具链」。
---
## 10. 设计边界与已知代价
| 项 | 现状 | 说明 / 出口 |
|---|---|---|
| **8 路电气量分属两片转换器** | 跨转换器**没有共同采样触发点** → 无同一时刻性、相位不确定 | **V0.1 硬件缺陷**(登记簿条目「双转换器无共同采样触发」):V0.1 上**禁止**跨转换器做相位 / 功率 / 功率因数 / 矢量运算;根治要改板(8 路全接 TPAFE) |
| STM32 侧数据滞后 | 固定 1 拍(1 ms) | 乒乓半区的结构代价;因上一条,不新增误差(§4.4) |
| TPAFE 侧仍是"脉冲 + 中断读" | ISR 内 GPIO 脉冲、`BUSY` EXTI 逐通道读 8 次,**未 DMA 化** | 单点成本约 0.5 µs,非当前瓶颈;DMA / FMC 化的**前提未定**:接口类型与 `DRDY`、读时序须先用逻辑分析仪确认 |
| 等周波调频 | **硬件侧已具备**:1 µs 分辨率 + ARR 预装载 + 唤醒与生产同源 → 运行期 `__HAL_TIM_SET_AUTORELOAD(&htim3, 周期µs - 1)` 即可无毛刺改频,**消费节拍自动跟随** | 采样率随动到相量窗与定值不在本层;测频见 `App/algo/freq.c`,上位需求见 `docs/算法/继电保护功能复现规划.md` §3.1 |
| 通道有效性 | V0.1 只有部分通道测量有效 | 唯一权威 → `docs/硬件平台/ADC通道映射说明.md` |
| AboveNormal 的挤占面 | 单拍超过 1 ms 期间抢占 net / ftp / default / timeSync / ui | 观测面 = `adcm` 与 `adc1_gap`;诊断方法与基线 → `docs/子系统设计/看门狗设计说明.md` §11 |
---
## 11. 设计铁律(改这条链前必读)
1. **ISR 只发唤醒,不做计算**。相量、压环、保护判据全部留在任务上下文:忙等与算法不可入 ISR、
任务级共享数据不能在 ISR 里改写、`HAL_TIM_PeriodElapsedCallback()` 是生成区回调承载的业务代码。
2. **ISR 侧唤醒只能走 `adc_tick_irq_notify()`**(就绪门,§6.1);直接 `osThreadFlagsSet()` 会在内核
启动前把调度器外代码跑进 ISR。
3. **一个更新事件驱动两片转换器**。另起定时器分别触发 = 重新引入跨转换器的相位不确定性(§10)。
4. **取数只用 `adc_input_poll()` / `adc_input_raw()` / `tpafe5160_snapshot()`**,按语义不按下标;
不得自行拼"两步取数"的等价物(§5)。
5. **改节拍源、改 DMA 流、动 `DMAContinuousRequests` 之前先读 §3.2 与 §4.3**——这几类改动的共同点是
**违反时不报任何错**,只在数据上出事(静默丢通道、搬不回来、优先级被重置为 0)。
6. **节拍源停摆必须导致复位**(§6.3):不得给等待加超时来"绕过"看门狗。
7. **动栈或加深调用链前后各跑一次 `scripts/stack_report.py`**,新增缓冲前跑 `scripts/ram_report.py`(§9)。
8. **挂 DMA 之前必须让 ADC 处于关闭态**(§4.5):`app_main_init()` 步骤 4b-pre 的 `adc1_disable()`
不可删除、不可后移。违反时**不报任何错**,只是 9 路通道按 rank 序整体错开 2 格——
表象与"映射表写错"或"注入点标签不对"完全一样,历史上曾据此误判过三轮。