@@ -1,468 +1,344 @@
# ADC 采样节拍设计说明(1 kHz 硬件定时器触发 )
# ADC 采样节拍设计说明(TIM3 1 kHz 同源硬件节拍 )
> **状态**:**已实现并通过台架验收(2026-10-10) **——TIM6 更新中断(1 kHz)触发已落地
> ( `Src/main.c` 回调 + `app_main.c` 启动 + `adc_task.c` 退化为纯消费者),`@build` 0 错 0 警;
> 台架 `@test` **PASS**( `frame_seq` 每 10 s 精确 +10000 = 1000.0 Hz、`frst_err=0`、回读一致;
> 实测见 §7.1)。
> **事件驱动消费改造方案 a 已落地(Task #32 P1-5, 2026-10-10) **:用户决策
> "ISR 只发唤醒 + 计算留任务 + 提优先级"(方案 a),拒绝"ISR 内计算"。消费端已从
> 1 ms `osDelayUntil` 轮询改为 **TIM6 ISR 置位 `ADC_WAKE_FLAG` 唤醒**, `adcTask`
> 优先级 `Normal` → **`AboveNormal`**。台架实测 **`idle = 0`**( 20000 拍全零;改前 1~649)、
> `frames` 严格 1000.0 Hz、UI 侧 `uip/uiw/uis` 无退化。详见 §10。
> **上位需求**:`docs/算法/继电保护功能复现规划.md` §3.2 L1-1(周期 CONVST 重触发 1000 Hz,
> 与保护节拍同源)。**驱动契约**: `Drivers/BSP/TPAFE5160/README.md`。
> **背景**: B1( 2026-10-10)已把 `adc_task` 由空桩改为真实消费者,但采样节拍是
> **任务周期软触发**( `osDelayUntil`)——CONVST 边沿 = 任务被调度到的时刻,抖动大、
> 且与 FreeRTOS tick 同源。本项把节拍源换成**硬件定时器**。
> **模块名称**:ADC 采样节拍与消费链——TIM3 节拍源(`Src/tim.c`)、更新事件回调(`Src/main.c`)、
> L1 采集层(`App/algo/adc_input.c`)、消费者 `adcTask`( `App/task/adc_task.c`)。
> **文档定位**:**现状设计说明**——这条链当前由谁产生节拍、同一个硬件事件如何驱动两片转换器、
> STM32 侧怎么搬运、消费者怎么取数、为什么这样配置、边界与代价在哪里。**历史演进、方案对比、
> 门禁与台架复测记录、修订日志一律不写进本文**(需要时查 git 历史)。
> **状态**:已落地并经真机验证;下游 = L2 相量 / 测频 → L3 保护判据(`App/algo/README.md`)。
> **相关文档**:
> `docs/硬件平台/ADC通道映射说明.md`(网名 ↔ 板级 `ADCn` ↔ 转换器通道号,唯一权威)
> `docs/算法/继电保护功能复现规划.md` §3.1 L1-1(上位需求: 1000 Hz 周期 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. 目标与现状
## 1. 节拍链路总览
| 项 | 改造前(B1 软触发) | 改造后(TIM6 硬件定时器,2026-10-10 已落地) |
一句话链:**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()<br/>就绪门 + 置 ADC_WAKE_FLAG"]
CONV --> BUSY["TPAFE 转换 → BUSY 拉低"]
BUSY --> EXTI["PC7 EXTI(优先级 6)读 8 通道<br/>帧缓冲 + seq++"]
TRGO --> SCAN["ADC1 一轮 9 通道"]
SCAN --> DMA["DMA2_Stream4 循环搬运<br/>乒乓双缓冲 2× 9 半字"]
WAKE --> TASK["adcTask( AboveNormal)被唤醒即抢占"]
EXTI --> POLL["adc_input_poll(): TPAFE 原子快照"]
DMA --> TAKE["stm32_take():取上一拍完成的半区"]
TASK --> POLL
TASK --> TAKE
POLL --> L2["L2 相量 / 测频 → 采样环<br/>仅确有新帧时压环"]
TAKE --> L2
L2 --> OUT["下游:L3 保护判据(protectTask) "]
L2 --> 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. 实现落点
| 位置 | 当前职责 | 改动边界 |
|---|---|---|
| CONVST 边沿时刻 | 任务被调度时刻(抖动 = 调度延迟,可达数十 µs~ms) | TIM 更新事件 + ISR 入口延迟(亚 µs~数 µs) |
| 周期精度 | 依赖 FreeRTOS tick( TIM7) | 定时器硬件分频,**晶振级** |
| 丢拍 | 任务被长时抢占会漏拍 | 硬件自动重载,不漏拍 |
| 等周波调频 | 需改 tick 周期(不可行) | 运行期写 ARR 即可(§8 ) |
| `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` | L1: `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 判据节拍取 L2 相量快照 | → `App/algo/README.md` |
**判据(L1-1) ** :周期 CONVST 重触发 1000 Hz;示波器见 U3 `BUSY` 连续脉冲;
`tpafe5160_frame_seq()` 持续递增(每秒 +1000)。
---
## 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。
---
## 2 . 方案选型
## 4 . STM32 侧:ADC1 触发与 DMA 乒乓双缓冲
### 2 .1 方案 A(**推荐**):TIM6 更新中断 → ISR 内脉冲 CONVST
### 4 .1 ADC1 当前配置
- TIM6 配成 1 kHz 更新中断;ISR 内调 `tpafe5160_start_conv_irq()` (现成的 GPIO 脉冲)。
- **零引脚改动**: PC9 保持 `GPIO_Output` ,驱动与 `tpafe5160_init()` 首帧自检**完全不动**。
- BUSY( PC7) EXTI 读帧路径**完全不动**。
- 周期精度 = 定时器硬件(晶振级);CONVST 边沿抖动 = TIM6 ISR 入口延迟(见 §5)。
- 运行期改 ARR 即改变采样率 → 天然支持等周波调频。
| 项 | 值 | 后果 |
|---|---|---|
| `ScanConvMode` | ENABLE | 一轮 9 通道 |
| `ContinuousConvMode` | DISABLE | 每轮由外部触发启动(§7) |
| `ExternalTrigConv` | `T3_TRGO` , `ExternalTrigConvEdge = RISING` | 与 CONVST 同一个更新事件 |
| `NbrOfConversion` | 9 | 通道清单与语义 → `docs/硬件平台/ADC通道映射说明.md` |
| `DMAContinuousRequests` | **ENABLE ** (DDS 位) | ⚠ 关掉 = 一轮 9 通道只发**一次** DMA 请求 → **静默丢 8 路,不报任何错 ** |
### 2 .2 方案 B(备选,暂不采用):TIM3_CH4/TIM8_CH4 硬件 PWM 直驱 PC9
### 4 .2 上电自校准
- PC9 复用为定时器通道,硬件直接产生 CONVST 方波,**零软件抖动**。
- **代价**:① 需把 PC9 由 `GPIO_Output` 改为 AF( `.ioc` 引脚改动);
② 驱动的 `TPAFE5160_CONVST_*` 宏(GPIO 写)全部失效 → `tpafe5160_i nit()` 的
"确保 CONVST 低"与首帧自检(E-44)都要重写;③ 上电初值/空闲电平语义变化。
- **结论**:收益(把抖动从"数 µs"降到"0")不足以抵消改动面与回归风险 。
**仅当 §5 实测抖动超过算法容限时再考虑 ** 。
F4 的 ADC **上电必须自校准 ** :缺它不报任何错,只是读数带失调。CubeMX 不生成这一步,且本仓库裁剪版
HAL 没有 `HAL_ADCEx_Calibration_Start()` 、裁剪版 CMSIS 头没有 `ADC_CR2_CAL` (位值 3 在源内自定义)
→ `adc1_calibrate()` 手写于 `Src/adc.c` 的 `USER CODE` 区,在 `MX_ADC1_I nit()` 末尾调用。
时序按 RM0090 §13.3.6:清 `ADON` → 等硬件清零 → 置 `ADON` 上电待稳定 → 置 `CAL` → 等硬件**自动**清
`CAL` (上限 10 ms)。存活由审计项 `adc1-calibration-call` / `adc1-calibration-impl` 兜底 。
> 下文只展开**方案 A**。
### 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 尚未取过帧。
---
## 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 都真的在读帧"的直接观测面。
---
## 3 . CubeMX 配置规格(照此配置)
## 6 . 消费者 adcTask:事件驱动唤醒
**前提 ** : SYSCLK = 168 MHz / HCLK = 168 MHz / APB1 = 42 MHz(÷4)→ **APB1 定时器时钟 = 84 MHz ** ;
HAL 时基 = TIM7(已占用);**TIM1~TIM6、TIM8~TIM14 当前全部空闲**。
### 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):
| 字段 | 来源 | 正常态 | 异常判读 |
|---|---|---|---|
| 1 | Timers → **TIM6 ** → * Activated * | ✅ 勾选 | 基础定时器,**无输出引脚**(不占用任何 IO) |
| 2 | Prescaler (PSC) | **83 ** | 84 MHz ÷ (83+1) = **1 MHz ** 计数时钟(1 µs 分辨率) |
| 3 | Counter Period (ARR) | **999 ** | 1 MHz ÷ (999+1) = **1.000 kHz ** ( = 1000 µs 周期) |
| 4 | Counter Mode | Up | 向上计数 |
| 5 | **auto-reload preload ** | **Enable ** | 运行期写 ARR 在下一个更新事件生效,不产生毛刺(§8 等周波调频要用) |
| 6 | Clock Division | No Division | 与采样无关 |
| 7 | Trigger Event Selection | Reset(默认) | 不用 TRGO |
| 8 | NVIC Settings → **TIM6 global interrupt ** | ✅ 勾选;**Preemption Priority = 6** | 与 BUSY EXTI( `EXTI9_5_IRQn` = 6)**同级**:两者互不抢占,避免"读 RD 时序被 CONVST 脉冲打断"(§5);且 6 ≥ `configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY` (=5),将来 ISR 里发任务通知也安全 |
| `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` 一次前进 > 1 | 每 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()` ( L1-6) | 极少 | TPAFE 侧丢帧,与 `adc1_gap` 对称 |
**生成后 ** : CubeMX 会新建 `Src/tim.c ` + `Inc/tim.h` (含 `htim6 ` 与 `MX_TIM6_Init()` )、
在 `Src/stm32f4xx_it.c` 生成 `TIM6_IRQHandler()` 、在 `Src/main.c` 的 `main()` 里插入 `MX_TIM6_Init()` 。
⚠ * * `MX_TIM6_Init()` 只做配置,不启动计数**——启动由用户代码负责(§4 第 4 条)。
单拍耗时( `adcp ` = `TASK_PROF_ADC_POLL` )与整拍耗时( `adcm ` = `TASK_PROF_ADC_BEAT` )不在心跳里,
由 DWT 探针的 `[PROF]` 行给出,字段语义与基线 → `docs/子系统设计/看门狗设计说明.md` §11.4 。
> **为何不用 TIM7 那种"1 MHz 计数 + ARR=999"以外的心跳**:本配置与 HAL 时基 TIM7
> 的构造完全一致(同为 84 MHz / 1 MHz / 1000 计数),便于对照排查;PSC 取 83 使 ARR 与
> "微秒"一一对应,等周波调频时可直接 `ARR = 周期µs - 1`。
**为什么"连续 ≥ 2 拍无新帧"才计 `adc1_err`( `ADC_INPUT_STALL_RUN_MAX = 2`) ** :任务在 TIM3 触发瞬间
被唤醒,而该拍的 DMA 完成约落在 67 µs 之后;若上一拍被心跳日志或抢占拖过这 67 µs,本拍就会看到
"计数没前进"(那个完成已被上一拍消费掉)。这种**相位伪影只会连续 1 拍**——DMA 若真停,第 2 拍仍然
不会前进。故单拍无新帧**不计故障**,只记 `ADC_INPUT_RC_STALL` 。
---
## 4 . 代码改造清单
## 9 . 性能与资源特征
> 约定:**生成区不改**(CubeMX 重生成会覆盖);所有手工代码落 `USER CODE` 保护区(L2)。
### 4.1 `Src/main.c`( CubeMX 生成;改 `USER CODE` 区)
- `USER CODE BEGIN Includes` 增加 `#include "tpafe5160.h"` 。
- `HAL_TIM_PeriodElapsedCallback()` (**注意:该回调被 TIM7 时基共用**)增加 TIM6 分支:
``` c
void HAL_TIM_PeriodElapsedCallback ( TIM_HandleTypeDef * htim )
{
if ( htim - > Instance = = TIM7 ) { HAL_IncTick ( ) ; } /* 既有:HAL 时基 */
/* USER CODE BEGIN Callback 0 / 1 —— 加在既有 TIM7 分支之后 */
if ( htim - > Instance = = TIM6 )
{
tpafe5160_start_conv_irq ( ) ; /* 1 kHz 采样节拍:ISR 内脉冲 CONVST(仅 GPIO 写,亚 µs) */
}
}
```
⚠ **不得**破坏既有 `HAL_IncTick()` / `task_wd_tick_probe()` 两条 TIM7 分支。
### 4.2 `Src/tim.c` / `Inc/tim.h` / `Src/stm32f4xx_it.c`
- **CubeMX 自动生成,勿手改**( `TIM6_IRQHandler` → `HAL_TIM_IRQHandler(&htim6)` )。
### 4.3 `App/app/app_main.c`
- 增加 `#include "tim.h"` 。
- 第 213 行的**单次** `tpafe5160_start_conv_irq();` 替换为**启动周期触发**:
``` c
/* 6) 启动 1 kHz 采样节拍(TIM6 更新中断 → ISR 内触发 CONVST) */
( void ) HAL_TIM_Base_Start_IT ( & htim6 ) ;
```
- ⚠ **必须在 `tpafe5160_init()` 成功之后**再启动 TIM6,否则 ISR 会在驱动自检
(首帧有效性探针,E-44)期间乱发 CONVST。
### 4.4 `App/task/adc_task.c`
- **删除**主循环里的 `tpafe5160_start_conv_irq();` (触发已交给 TIM6)。
- 本任务退化为**纯消费者**: `tpafe5160_snapshot()` + 帧统计 + 10 s 心跳 + 看门狗心跳。
1 ms 节拍(`osDelayUntil` )保留,仅用于"消费节奏"。
- 更新文件头与函数注释(说明节拍由 TIM6 提供,本任务不再触发)。
- **看门狗不变**: `TASK_WD_ADC` 心跳仍在循环内每拍调用。
### 4.5 文档同步(2026-10-10 已执行)
- `docs/算法/继电保护功能复现规划.md` :§3.2 L1-1 由 🔶 改为 ✅(TIM6 硬件触发)、§1.2 采样软件行 + 变更记录同步。
- `docs/任务规划/基线与待确认.md` §1:TPAFE 自动扫描模式待确认项标注"已不影响"(TIM6 方案不依赖它)。
- `AGENTS.md` 架构要点 `adcTask` 条 + `app_main_init` 序列:改为"TIM6 硬件触发 + 纯消费者"。
- `Drivers/BSP/TPAFE5160/README.md` :周期重触发改为"由 TIM6 更新中断提供"。
---
## 5. 时序与优先级分析
**单周期时序(1 kHz,周期 1000 µs) ** :
```
t=0 TIM6 更新事件 → ISR → CONVST 脉冲(LOW→HIGH 上升沿)
t≈0+ε TPAFE5160 开始转换(BUSY 拉高)
t≈23 µs BUSY 拉低 → PC7 EXTI(优先级 6) → 读 8 通道(≈0.5 µs)→ seq++
t=1000 µs 下一个 TIM6 更新事件
```
- **读窗口与下一次 CONVST 相隔 ~977 µs**(转换仅 ~23 µs),互不重叠。
- **TIM6 与 BUSY EXTI 同级(6)→ 互不抢占**:读 RD 时序(8 次 RD 翻转)不会被打断,
避免"读到一半被新转换刷新数据总线"。这是选 6 而非 5 的原因。
- **触发抖动**:TIM6 是最高优先级之一(5 为 USART,7 为 SPI/DMA);被更高优先级 ISR
(USART,~数 µs)挡住的概率与时长都有限 → 最坏抖动约数 µs,占 1000 µs 的 <0.5%。
- **ISR 时长**: `tpafe5160_start_conv_irq()` 只做两次 `HAL_GPIO_WritePin` (寄存器写)
→ 亚 µs,不拖累其它中断。
---
## 6. 风险与注意事项
1. **共用回调 ** : `HAL_TIM_PeriodElapsedCallback` 同时服务 TIM7( HAL 时基)与 TIM6 →
分支必须并列、不得互相干扰;改动后必须确认开机日志时间戳正常(时基未坏)。
2. **启动顺序 ** : TIM6 必须在 `tpafe5160_init()` 之后启动(§4.3)。
3. **不新增任务/栈 ** :本项**零新任务、零新栈、零动态分配** → 不动 `stack_report.py` 预算。
4. **CubeMX 生存 ** :新增生成文件 `Src/tim.c` / `Inc/tim.h` ;改后跑一次
`python scripts/check_cubemx_survival.py` ,若有新增生成文件被登记,按提示重建基线。
5. **Keil 工程 ** :从 CubeMX 重生成工程(含 MDK-ARM)以把 `tim.c` 纳入编译;随后
`@build` 必须 0 错 0 警。
6. * * `mem-numbers` **:本项改 Flash/RAM 极小但非零 → 若 `check_mem_numbers.py` FAIL,
跑 `python scripts/check_mem_numbers.py --write` 刷新快照。
7. **消费端与生产端同频 ** : TIM6( 84 MHz 派生)与 FreeRTOS tick TIM7(同源)都恰好 1 kHz,
相位固定 → 消费端每拍恰好取到 1 帧。**一旦进入等周波调频(采样率≠1 kHz),
固定周期轮询会与生产端失配 → 消费端须改为"节拍事件驱动"**(见 §8)。
✅ **该前置已满足(2026-10-10,§10) ** :消费唤醒改由 TIM6 ISR 置位,与采样率天然同步。
---
## 7. 验收判据
| # | 判据 | 手段 |
| 指标 | 现状 | 来源 / 条件 |
|---|---|---|
| 1 | `@build` 0 错 0 警,且镜像无源文件新于 hex | `keil-build` skill( `keil_build.py` ) + `find -newermt` |
| 2 | PC9 `CONVST` 周期 = 1.000 kHz(±0.1%) | 示波器 / 逻辑分析仪 |
| 3 | PC7 `BUSY` 每周期一个脉冲,与 CONVST 相位固定 | 示波器双通道 |
| 4 | `frame_seq()` 每秒 +1000(每 10 s +10000),严格递增 | `@test` 串口 `[ADC_TASK] frames=... seq=...` |
| 5 | `frst_err=0` 、 `idle` 基本为 0 | 同上 |
| 6 | 开机日志时间戳正常(TIM7 时基未坏) | 串口日志 |
| 7 | 无 HardFault / `[ERR]` / 看门狗复位 | `@test` Forbidden 判定 |
| 节拍频率 | 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 |
### 7.1 台架实测结果(2026-10-10, `@test` PASS)
证据:`docs/测试说明/boot_log_data/20261010_prod_adc_tim6_boot_log.txt` ( `fw build: Oct 10 2026 15:21:20` )。
| # | 判据 | 实测 |
|---|---|---|
| 1 | `@build` 0 错 0 警 + 镜像时效 | ✅ 0 错 0 警;无源文件新于 hex |
| 2 | CONVST 1.000 kHz | ✅ 以 `frame_seq` 速率替代(无示波器):`seq` 10005→20005→30005, **每 10 s 精确 +10000 = 1000.0 Hz** |
| 3 | BUSY 每周期一脉冲 | ✅ 以 `frst_err=0` 替代:全程无"FRSTDATA 无跳变"帧(BUSY EXTI 每周期都在读帧) |
| 4 | `frame_seq` 严格递增 | ✅ 见 #2 ,严格单调 |
| 5 | `frst_err=0` 、`idle` 基本为 0 | ✅ `frst_err=0` ;⚠ `idle` 每 10 s +5(≈0.05%,见下) |
| 6 | 开机时间戳正常 | ✅ 日志带墙钟时间戳,`net init: OK` 在 +530 ms |
| 7 | 无 HardFault / `[ERR]` / 看门狗复位 | ✅ 零 `[ERR]` /HardFault; `reset cause:` 仅 1 次(调试器软复位) |
**关于 `idle`(改前:轮询消费的固有抖动) ** :生产端(TIM6)与消费端(`adcTask` 1 ms 轮询)
同为 1000 Hz( TIM6 与 FreeRTOS tick 源 TIM7 参数一致,均 84 MHz/84/1000),但二者无显式同步 →
偶发 ±1 帧的相位抖动:每 10 s 约 5 次"两次轮询落在同一生产间隔"(`idle` +5)配 5 次"一次轮询跨两个
生产间隔"( `frames` 少 5,即 9995/10000 ≈ 99.95% 取数)。
✅ **现状(§10 已落地,2026-10-10) ** :消费端改由 **TIM6 ISR 置位 `ADC_WAKE_FLAG` ** 唤醒。
落点与当初设想的"BUSY EXTI 读帧完成时通知"不同——选 TIM6 是为了让唤醒与**采样节拍**严格同源,
且不必把内核 API 引入 BUSY EXTI(它与 TIM6 同抢占优先级,见 §5);效果一致:实测 * * `idle = 0` **
(6 次上板、每次 20000 拍全零)→ 判据 5 由"基本为 0"收紧为**恒 0**。
**心跳拍的连带效应 ** : `adc1_gap` 的根治手段是**缩短心跳**(每 10 s 一次的多行日志),不是提高 DMA 能力。
日志成本模型 → AGENTS.md「日志与工具链」。
---
## 8. 后续:等周波调频(不在本项范围)
## 10. 设计边界与已知代价
参考系统按 **20 点/周波**采样:电网频率漂移时需让采样率跟随(50.0 Hz → 1000 Hz;
49.5 Hz → 990 Hz),以保持傅氏全波窗对齐一个工频周期。
- 本配置已为此预留:**1 µs 分辨率** + **ARR 预装载使能 ** → 运行期
`__HAL_TIM_SET_AUTORELOAD(&htim6, 周期µs - 1)` 即可无毛刺改频。
- 需要配套:① 测频源(过零/DFT 相位);② 把当前采样率同步给保护算法(相量/定值);
③ **消费端改事件驱动 ** (§10)——否则固定周期轮询在非 1 kHz 采样率下会丢帧。
✅ **已落地(2026-10-10,§10) ** :消费唤醒与 TIM6 同源 → 调频时只改 ARR,消费节拍自动跟随。
| 项 | 现状 | 说明 / 出口 |
|---|---|---|
| **8 路电气量分属两片转换器 ** | 跨转换器**没有共同采样触发点** → 无同一时刻性、相位不确定 | **V0.1 硬件缺陷 ** (登记簿 N-18):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 |
---
## 9. 变更记录
## 11. 设计铁律(改这条链前必读)
| 日期 | 变更 | 说明 |
|---|---|---|
| 2026-10-10 | 建立本文档 | B1( ADC 采集链路)遗留项"精确 1 kHz 硬件定时器触发"的规划:方案选型(A/B)、CubeMX 配置规格、代码改造清单、时序与优先级分析、风险与验收判据、等周波调频预留 |
| 2026-10-10 | **实现(方案 A) ** | CubeMX 加 TIM6( PSC=83 / ARR=999 / ARR 预装载 / NVIC 抢占优先级 6);`Src/main.c` 回调加 TIM6 分支触发 CONVST; `app_main.c` 用 `HAL_TIM_Base_Start_IT(&htim6)` 启动节拍;`adc_task.c` 退化为纯消费者。`@build` 0 错 0 警;台架 `@test` 验收待做 |
| 2026-10-10 | **台架 `@test` 验收 PASS ** | 真机闭环:`seq` 10005→20005→30005(每 10 s +10000 = 1000.0 Hz)、`frst_err=0` 、`idle` 每 10 s +5(0.05%,轮询抖动)、回读 `No difference found` 、零 `[ERR]` /HardFault、`reset cause` 仅 1 次。证据 `boot_log_data/20261010_prod_adc_tim6_boot_log.txt` |
| 2026-10-10 | **事件驱动消费方案 a 规划入档(Task #32 P1-5) ** | 记录用户决策与改造清单(TIM6 ISR 置位 `ADC_WAKE_FLAG` 唤醒 `adcTask` + `adcTask` 提至 `osPriorityAboveNormal` ),并附下一版硬件 DMA 双缓冲建议(§10.5)。代码待实施,消费端仍为 1 ms 轮询 |
| 2026-10-10 | **事件驱动消费方案 a 已实施(Task #32 P1-5) ** | 四处改动落地:`Src/main.c` TIM6 分支调 `adc_tick_irq_notify()` ; `adc_task.c` 循环尾改 `osThreadFlagsWait(ADC_WAKE_FLAG, osFlagsWaitAny, osWaitForever)` 并删 `ADC_TASK_BEAT_MS` /`next` ; `Src/freertos.c` 优先级 `Normal` →**`AboveNormal` **; `App/task/adc_task.h` 加 `ADC_WAKE_FLAG` 与 ISR 入口。实施时补**就绪门**(§10.6,规划未预见:TIM6 比调度器早起 ≈100 ms)。`@build` 0 错 0 警(Code 256368→256728);`@test` PASS: **`idle = 0` /19999 拍**(改前 1~649)、`frames` 1000.0 Hz、`seq− frames = 7` 正是就绪窗口、UI 侧 `uip/uiw/uis` 无退化 |
| 2026-10-10 | * * §10.1/§10.3/§10.4 复核修正(无功能改动)** | ① 2 ms 忙等的单拍上界由"9 通道 × 2 = 18 ms"更正为**一次超时 ≈2~3 ms**( `stm32_scan()` 在首个超时处即 `Stop` +`return` );② 6 次上板对照 `adcp` = **122 µs ** (超时 0 次的 5 轮)/ **2209 µs ** (超时 1 次的那轮)→ 2 ms 不是常态开销;③ `adcm` 2.1 ms 峰值**归因改判**为每 10 s 一次的心跳日志(`idle=0` /20000 反证无每拍 2 ms);④ 新增超时的**正确性**代价(超时拍 STM32 侧静默沿用上一拍值,`skip` 抓不到)与硬化排序 |
| 2026-10-10 | **补 ADC1 上电自校准(§10.4) ** | 发现 F4 必需的自校准在本项目**完全缺失**(CubeMX 不生成 + 裁剪 HAL 无 `HAL_ADCEx_Calibration_Start` + 裁剪 CMSIS 头无 `ADC_CR2_CAL` )→ 在 `Src/adc.c` 的 `USER CODE` 区实现 `adc1_calibrate()` ( RM0090 §13.3.6 时序),`MX_ADC1_Init()` 末尾调用。新增审计项 `adc1-calibration-call` /`-impl` 。`@build` 0 错 0 警(Code 256728→256868, +140 B);`@test` PASS, `adcp` 仍 122 µs、`adc1_err=0` ;实测 STM32 侧 6 路同向偏移 +10~+63 LSB、TPAFE 侧不变 |
| 2026-10-10 | **修"失败拍沿用旧值"(§10.4 硬化 ①) ** | `stm32_scan()` 两条失败支路都清 `s_stm32_valid` → 失败拍走既有 `skip` 整帧跳过,不再产出"半新半旧"混合帧污染相量窗;下一拍成功自愈。主机单测 70 → **79 ** 用例(新增 TC-AI-070..078),并做反向验证(改回旧行为 3 条立即 FAIL)。`@build` 0 错 0 警(Code 256868→256872)、`@test` PASS。硬化 ②(DMA 消忙等)仍未做,并更正原表述:**F4 的 ADC EOC 没有 EXTI 通道**,只有 `ADC1_2_IRQn` |
| 2026-10-10 | **v2.8:节拍源 TIM6→TIM3 + STM32 侧 DMA 乒乓双缓冲(§11) ** | 用户选定最彻底方案(DMA + TRGO 硬件触发 + 消费上一拍半区)。更正一处硬事实:**F4 上 ADC1 无 TIM6/TIM7 TRGO**(只接 DAC)→ 节拍源改 **TIM3 ** ( PSC 83/ARR 999, `TIM_TRGO_UPDATE` ),TIM6 从 `.ioc` 整体移除;ADC1 挂 **DMA2_Stream4 ** ( CubeMX 默认抢 Stream0 会连锁搬走 SPI1_RX/USART1_RX,且换流把 NVIC 优先级重置为 **0 ** ,违反"外设中断 ≥5")、`DMAContinuousRequests=ENABLE` 、循环模式长度 2× 9 乒乓,半区由 `seq` 奇偶推出(只读一个 32 位量 → 无竞态)。`stm32_scan()` → `stm32_take()` ,逐通道 `PollForConversion` 全删;故障计数拆成 `adc1_seq` /`adc1_gap` /`adc1_err` /`adc1_hw` 四类(单拍无新帧 = 相位伪影,不计故障)。实测:`adcp` **122 µs → 8 µs ** 、`adc1_err=0` 、`adc1_hw=0` 、`adc1_gap=27` /20002 拍、`adc1_seq` 与 `seq` 恒定偏移 2;主机单测 79 → **101 ** (四组反向验证均咬);`@build` 0 错 0 警 Code 256872→**257660**; `@test` PASS; adcTask 静态链 832 B / 余 59% 不变 |
---
## 10. 事件驱动消费改造(Task #32 P1-5, 2026-10-10:用户决策方案 a, **已落地**)
> 上位背景:Task #32 暴露"UI 任务看门狗停摆"候选占用者之一是 `adcTask` 的 1 ms 轮询——
> 原 `osDelayUntil(1ms)` 把消费唤醒**钉在 FreeRTOS tick( 1 ms)边界**,带来 0~1 ms 随机
> 相位抖动 + 累积漂移风险,且 `adcTask` 与 `uiTask` 同为可被长时抢占的对象。用户决策:
> **方案 a(ISR 只发唤醒 + 计算仍在任务 + 提优先级)**,并拒绝"ISR 内计算"方案。
> **落地状态(2026-10-10)**:四处改动全部实施(清单见 §10.2),`@build` 0 错 0 警、
> 台架 `@test` PASS,实测见 §10.3;实施时补了一条规划未预见的**就绪门**(§10.6)。
### 10.1 决策:为什么选 a,不选"ISR 内计算"
- **拒绝"ISR 内计算"**:把相量/压环搬进 TIM6 ISR 是反模式——
① `adc_input_poll()` 内含 `HAL_ADC_PollForConversion` 的 **2 ms 忙等 ** ( `ADC_INPUT_POLL_TIMEOUT_MS=2U` ),
绝不可入 ISR(TIM6 抢占优先级 6,自旋期间低优先级中断与全部任务都被压住;单拍最坏 =
**一次**超时 ≈2~3 ms——`stm32_scan()` 在首个超时处即 `HAL_ADC_Stop()` +`return`
( `App/algo/adc_input.c:79-84` ),不会累加成 9 次);
② 相量/采样环是任务级共享数据,ISR 内改写需额外临界区,破坏 `tpafe5160_snapshot()` 的
"仅任务上下文"铁律;③ `HAL_TIM_PeriodElapsedCallback` 是 CubeMX 生成区回调,承载 L2 计算
违反"生成区不改"约定,重生成即丢。
- **选定方案 a**: TIM6 ISR **只置位唤醒标志 ** ( `osThreadFlagsSet` ),所有计算留在 `adcTask` ;
并把 `adcTask` 优先级由 `osPriorityNormal` 提到 `osPriorityAboveNormal` ,使唤醒后**立即抢占**
其余 Normal 任务,固定 µs 级延迟。计算量、数据共享、生成区约定全部不变。
### 10.2 改造内容(已实施)
| 文件 | 实际改动 |
|---|---|
| `Src/main.c` | `HAL_TIM_PeriodElapsedCallback` 的 TIM6 分支(`USER CODE` 区,L2):`tpafe5160_start_conv_irq()` 后加 * * `adc_tick_irq_notify();` **——**不是**直接 `osThreadFlagsSet` (理由见 §10.6);并在 `USER CODE BEGIN Includes` 加 `#include "adc_task.h"` |
| `App/task/adc_task.c` | 主循环尾 `osDelayUntil(next)` → * * `(void)osThreadFlagsWait(ADC_WAKE_FLAG, osFlagsWaitAny, osWaitForever);` **;删除 `ADC_TASK_BEAT_MS` 与 `next` (P0-2 的重同步随之不再需要);新增 `adc_tick_irq_notify()` 与就绪门 `s_consumer_ready` |
| `Src/freertos.c` | `adcTask_attributes.priority` : `osPriorityNormal` → * * `osPriorityAboveNormal` **( =32 > 其余 Normal=24; `configMAX_PRIORITIES=56` 有裕量) |
| `App/task/adc_task.h` | 新增 `#define ADC_WAKE_FLAG 0x0001U` 与 `void adc_tick_irq_notify(void);` 。**未**把 `extern osThreadId_t adcTaskHandle;` 放进头文件——句柄只在 `adc_task.c` 内引用,`main.c` 不碰任何内核符号(与 `task_wd_tick_probe()` 同构;规划里的这一条按此收敛) |
- **ISR 安全性(设计校验)**:TIM6 抢占优先级 = 6 ≥ `configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY` (=5) →
`osThreadFlagsSet` 在 ISR 内走 `xTaskNotifyFromISR` + `portYIELD_FROM_ISR` ( CMSIS-RTOS V2 的
`osThreadFlagsSet` 实现),合法且会立即触发上下文切换。`configUSE_OS2_THREAD_FLAGS=1` 已开,符号已编入。
- **看门狗不变**: `TASK_WD_ADC` 心跳仍在循环内每拍调用,`required_mask` 与心跳位集合一致,
优先级调整不影响掩码。
- **验证前置**:实施后须 `@build` 0 错 0 警 + 台架 `@test` 复核 `idle` 是否降至 ≈0(§7.1)。
### 10.3 收益与语义(含落地实测)
1. **唤醒延迟固定 µs 级 ** :原 `osDelayUntil(1ms)` 把唤醒量化到 tick 边界 → 0~1 ms 随机相位抖动 +
一旦超拍即"永久落后/空转"风险(即 P0-2 的成因);事件驱动走 TIM6 硬件节拍,唤醒 = 生产时刻 +
ISR/调度延迟(亚 µs~数 µs),**无 tick 量化、无累积漂移**。
2. **idle 归零(实测已达成) ** :原轮询与生产的 ±1 帧相位抖动消失——唤醒与生产同源锁定。
3. **粘滞标志的健壮性 ** :若单拍偶超 1 ms,TIM6 多发脉冲合并为一次唤醒,`adc_input_poll()` 返回
`r==0` (无新帧)记 `idle` ,**不重复处理同帧**,与 P0 修复(仅 `r==1` 压环)口径一致,无 DFT 窗污染。
**落地实测(2026-10-10, `@test` PASS,开机 29 s,台架无客户端) ** :
| 指标 | 改前(1 ms 轮询 + Normal) | **改后(事件驱动 + AboveNormal) ** | 判读 |
|---|---|---|---|
| `idle` 无新帧拍数 | 649/20001; P0-1 后仍 **1/20000 ** | **0 / 19999 ** (两次心跳全零) | 相位抖动**彻底消失**,唤醒与生产同源 |
| `frames` 速率 | 1000.0/s | **1000.0/s ** ( t=21155→29227 ms 恰 +8072 拍) | 节拍未丢、未重复消费 |
| `ring` | == `frames` | == `frames` ( 19999) | P0-1 口径保持 |
| `adcm` 单拍最坏 | ≈2.1 ms | **2.12~2.25 ms ** | ⚠ 归因已复核:峰值来自**每 10 s 一次的心跳日志**(`DBG_INFO` + `log_phasors` ),不是采集——同批 `adcp` 仅 122 µs,且 `idle=0` 反证没有"每拍 2 ms"(否则粘滞标志会合并唤醒、`idle` 必 >0) |
| `uip` / `uiw` / `uis` | 61.3 / 11.4 ms / 1 µs | **61.3 / 11.4 ms / 1 µs ** | 提优先级**未**伤到 UI(LVGL 地板不变) |
| `defd` 控制台排空 | 46 ms | 46 ms | 未被 AboveNormal 挤动 |
| `seq` − `frames` | — | 20006 − 19999 = **7 ** | 正是"TIM6 已在跑、消费者尚未就绪"的 ≈100 ms 窗口(§10.6),与设计一致 |
### 10.4 残留风险与后续硬化(落地后仍开放)
- **Task #32 §4.4 的 2 ms 忙等仍在 L1 层,且如今跑在 AboveNormal 上**: `adc_input_poll()` 内的
`HAL_ADC_PollForConversion` ( `ADC_INPUT_POLL_TIMEOUT_MS=2U` )是任务级忙等(`HAL_GetTick()`
1 ms 粒度、判据 `> Timeout` → 实际 **2~3 ms ** )。提优先级后,STM32 ADC 一旦偶发超时,这 2~3 ms
会在**高于 net/ftp/default/timeSync** 的优先级上压住它们——比改前更敏感。
**6 次上板对照(2026-10-10) ** :超时 1 次的那轮 `adcp = 2209 µs` ;超时 0 次的 5 轮
`adcp = 122 µs` ( = 9 通道 × 7.4 µs + TPAFE 快照)→ **2 ms 只在超时拍出现,不是常态开销 ** 。
⚠ **超时的第二个代价是正确性,不只是耗时 ** :超时拍 `s_stm32_valid` 仍是上次成功留下的 1,
而 `adc_input_poll()` 的返回值只由 TPAFE 决定 → 该拍照样 `r == 1` 、照样压环,STM32 侧几路
( UB/UC/IB/IC/IN/温度)**静默沿用上一拍的值**(`adc_input.c:209-212` ),`s_frames_skip`
只统计"从未成功过"、抓不到它。
**硬化排序 ** :① ✅ **已实施(2026-10-10) ** ——`stm32_scan()` 两条失败支路(启动失败 / 轮询
超时)都清 `s_stm32_valid` → 本拍 STM32 侧判为无有效数据,走既有 `skip` 整帧跳过路径,
下一拍成功自动恢复;主机侧新增 **TC-ADCIN-070..078 ** 钉死并做反向验证(改回旧行为 3 条立即
FAIL)。② ✅ **已实施(2026-10-10) = DMA ** :见 * * §11**——TIM3 更新事件经 TRGO 硬件触发
ADC1 一轮 9 通道,`HAL_ADC_Start_DMA(&hadc1, buf, 18)` (乒乓双缓冲)由 DMA 自动搬运,
任务侧 `HAL_ADC_PollForConversion` **全部删除 ** ,实测单拍 `adcp` **122 µs → 8 µs ** ;
"超时拍沿用旧值"这条支路在结构上消失(不再有超时概念,改由"本拍无新帧"走同一条 `skip`
防线)。⚠ **F4 的 ADC EOC 没有 EXTI 通道 ** ,只有 `ADC1_2_IRQn` ——"EOC 改 EXTI"在 F4 上
不成立,别照 F3/G4 的做法抄。
③ 双缓冲已随 ② 同一批落地;**等周波触发**属 V0.2 硬件项(§10.5 / §8)。
- **已做的一项(2026-10-10):补 F4 ADC 上电自校准**。CubeMX **不生成**该步骤,且本仓库裁剪版
HAL **没有 ** `HAL_ADCEx_Calibration_Start` → 由 `Src/adc.c` 的 `adc1_calibrate()` 实现
( `USER CODE` 区 = L2,审计项 `adc1-calibration-call` / `adc1-calibration-impl` 兜底)。
实测校准前后同一拍 STM32 侧 6 路**同向**偏移 +10~+63 LSB、TPAFE 侧一字不变 → 此前 ADC 确实
带失调在跑。它**不消** 2 ms 忙等,但把"未校准"这条可疑路径从本节的候选根因里划掉。
- **启动 fail-safe(设计如此,已按此实现)**:TIM6 未启动 / 停摆 → `adcTask` 永久阻塞于
`osThreadFlagsWait(osWaitForever)` → `TASK_WD_ADC` 缺席 → IWDG 复位。属安全 fail-safe,
非缺陷(确保"节拍源缺失"不被静默放过)。
### 10.5 下一版硬件建议:DMA + 双缓冲(方案 b,用户要求记录)
> **2026-10-10 更新**:本节里 **STM32 侧那一半已经落地**(§11,DMA + 乒乓双缓冲 + 定时器
> TRGO 同源触发)。原写"STM32 ADC1 已支持 **TIM6**-TRGO → ADC1 + DMA"**是错的**——
> F4 上 ADC1 的外部触发清单里没有 TIM6/TIM7 TRGO(只接 DAC),实际用的是 **TIM3_TRGO**;
> 已按 §11 更正。下面剩下的部分是 **TPAFE5160 侧**的下一版硬件建议,仍未办。
瓶颈在 **TPAFE5160 接口 ** ,须先以逻辑分析仪确认其接口类型再定型:
- **若 TPAFE 为 SPI + DRDY**(推荐先验):
- 单 1 kHz 主时钟:TIM6 `TRGO` 同时驱动 ADC1 扫描(`ADC_ExternalTrigConv` =TIM6_TRGO)与 TPAFE `CONVST` ;
- TPAFE 数据由 **SPI-RX-DMA ** 搬运;`DRDY` 下降沿 → DMA **HT/TC 中断 ** 实现**乒乓双缓冲**;
- 任务只在"半满"时做后处理(压环 + 相量),CPU 完全脱离逐点搬运。
- **若 TPAFE 为并行接口**:用 **FMC ** 把 TPAFE 数据总线映射为异步 SRAM,DMA 从 FMC 读 → 内存;
须确认 FSMC/FMC 地址/时序兼容 TPAFE 读时序(开销大于 SPI 方案)。
- **统一收益**:CPU 脱离轮询/搬运 → 采样率可上探(如 4 k / 8 k 点/周波)、相量窗更密、腾出
AboveNormal 算力给保护算法;与 §8 等周波调频天然契合(ARR 改频 + DMA 自动续传)。
- **验收前置**:先以逻辑分析仪确认 TPAFE 接口类型与 `DRDY` 时序,**再**定型下一版硬件 DMA 方案,
避免返工。
### 10.6 实施时补的一条:消费者就绪门(规划未预见,必须保留)
规划写的是"ISR 里 `osThreadFlagsSet(adcTaskHandle, ...)` "。实施时发现**时序上不能直接这么写**:
- TIM6 在 `app_main_init()` ( `Src/main.c:140` )里就 `HAL_TIM_Base_Start_IT(&htim6)` ,而
`osKernelInitialize()` / `MX_FREERTOS_Init()` / `osKernelStart()` 在 `:144/145/148`
→ * * "节拍源已在跑"与"消费者已存在"之间有约 100 ms 窗口**,其间 `adcTaskHandle` 还是 NULL、
调度器也还没起。
- `osThreadFlagsSet()` 对 NULL 句柄本身安全(`cmsis_os2.c` 的入参检查返回 `osErrorParameter` ),
但"句柄已赋值、调度器未启动"这段(建任务 → `osKernelStart` )仍会让 ISR 版内核 API 在调度器外跑。
本仓库对这类坑有实测记录:`Src/main.c` 的 **TIM7 分支注释 ** ( 2026-10-02, port.c 优先级断言
把系统卡死在 ISR 内)。
- **处置**: ISR 只调 `adc_tick_irq_notify()` ,其内含就绪门 `s_consumer_ready` ——由 `adcTask`
自己在进入等待前置 1(单写者 = 任务,ISR 只读)。把时序约束变成**显式代码**,而不是依赖
FreeRTOS 的内部实现细节。
- **实测印证**: `seq − frames = 20006 − 19999 = 7` ——正是这 ≈100 ms 窗口里 TIM6 发出的 7 个脉冲
被丢弃(当时无消费者),之后一帧不差。
**教训(与 `docs/子系统设计/看门狗设计说明.md` §11.5 的三条"实施时才暴露"同类) ** :把"定时轮询"换成
"事件驱动"时,必须单独核对**节拍源启动时刻 vs 消费者创建时刻**,否则第一次上电就会踩到
"ISR 对还不存在的消费者动手"。
---
## 11. 节拍源改 TIM3 + STM32 侧 DMA 乒乓双缓冲(v2.8, 2026-10-10, **已落地并上板闭环**)
> 触发背景:§10.4 硬化排序里的 ②(消忙等)与 ③(双缓冲)。用户明确选择最彻底的方案
> ("我认为最彻底的就是 B2"):**DMA + 定时器 TRGO 硬件触发 + 消费上一拍已完成的半区**。
### 11.1 为什么节拍源从 TIM6 换成 TIM3
原规划(§2.1 / §10.5)假设"TIM6 TRGO 可触发 ADC1"——**在 F4 上不成立**。查 CubeMX 器件库
( `db/mcu/IP/ADC-STM32F417_Modes.xml` )与 `stm32f4xx_hal_adc.h` , ADC1 的外部触发只有:
```
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
```
TIM6/TIM7 的 TRGO 在 F4 上**只喂 DAC**( `T6_TRGO` 仅出现在 DB 里 `Expression="STM32F7"` 的块中)。
要在**同一个硬件事件**上同时驱动 TPAFE( ISR 脉冲 CONVST)与 STM32 ADC1( TRGO),
可选的通用定时器 TRGO 只有 TIM2 / TIM3 / TIM4 / TIM5 / TIM8; TIM3 空闲且为 APB1 通用定时器 →
**改用 TIM3 ** ( PSC 83 / ARR 999 = 1 kHz, `TIM_MasterOutputTrigger=TIM_TRGO_UPDATE` ,
`TIM_MasterSlaveMode=ENABLE` )。TIM6 从 `.ioc` 中整体移除(`Mcu.IP` / `Mcu.Pin` / `VP_*` /
`TIM6_DAC_IRQn` 全部清掉),`htim6` / `MX_TIM6_Init` / `TIM6_DAC_IRQHandler` 一并消失。
⚠ `MSM=ENABLE` 而从模式 `SMS=0` (内部时钟)→ 软件 `CEN` 启动不受影响(已核对 RM0090 与生成代码)。
### 11.2 DMA 选型与三个静默陷阱
| 项 | 结论 | 踩坑代价 |
|---|---|---|
| 流 | **DMA2_Stream4 ** ( ADC1 只挂 Stream0 或 Stream4; Stream1=ADC3、Stream2/3=ADC2) | CubeMX 默认抢 Stream0 → 连锁把 **SPI1_RX→Stream2、USART1_RX→Stream5 ** (NAND 与调试串口的 DMA 全被搬家) |
| 优先级 | `DMA2_Stream4=6` 、`DMA2_Stream0=7` 、`DMA2_Stream2=9` | **换流会把该流的 NVIC 优先级重置为 0 ** → 违反"外设中断 ≥5", 0 抢占 FreeRTOS 临界区(`configMAX_SYSCALL_INTERRUPT_PRIORITY=5` ) |
| `DMAContinuousRequests` | **ENABLE ** (DDS 位) | 关掉 = 一轮 9 通道只发**一次** DMA 请求,静默丢 8 路,不报任何错 |
| 缓冲地址 | 必须在 **SRAM ** (实测 `s_stm32_dma` @ `0x20003448` ,落在 `RW_IRAM1` ) | ADC1 走 **DMA2 ** ,而 F4 的 DMA2 **不可达 CCM( 0x10000000) ** → 放错不报编译错、不报运行错,数据永远搬不回来 |
模式:`DMA_CIRCULAR` + `DMA_PRIORITY_LOW` + halfword→halfword,长度 `2 × 9 = 18` 。
### 11.3 乒乓半区怎么选才没有竞态
DMA 每完成 9 个半字触发一次 **半传输/传输完成 ** 回调,两个回调都代表"一整帧就绪",
各自 `s_dma_seq++` ( `HAL_ADC_ConvHalfCpltCallback` / `HAL_ADC_ConvCpltCallback` ,
均带 `if (hadc == &hadc1)` 句柄守卫)。消费侧:
``` c
half = ( uint8_t ) ( 1u - ( seq & 1u ) ) ; /* seq 奇 = 刚完成前半区,偶 = 刚完成后半区 */
src = & s_stm32_dma [ half * ADC_INPUT_STM32_CH_NUM ] ;
```
**只读一个 32 位 `seq` ** (原子读)→ 半区下标由它推导,不再引入第二个 ISR 写、任务读的变量
(若另存一个 `s_dma_half` 就有"读到新 half、读到旧 seq"的窗口)。
代价:STM32 侧数据**固定滞后 1 拍(1 ms)**——两片转换器本来就没有同刻性(N-18),无新增误差。
### 11.4 可观测性:四类故障分开计数
| 计数 | 含义 | 判读 |
|---|---|---|
| `adc1_seq` | DMA 整帧完成计数 | 应 ≈ TPAFE `seq` (实测恒定偏移 2 = TIM3 启动到 DMA 挂载之间的窗口) |
| `adc1_gap` | `seq` 一次前进 >1 → **丢帧 ** | 消费者超拍(单拍 > 1 ms,如心跳日志突发) |
| `adc1_err` | **连续 ≥2 拍**无新帧 → 停摆 | 节拍源/DMA 真停;单拍无新帧是**相位伪影 ** (见下),不计 |
| `adc1_hw` | `HAL_ADC_ErrorCallback` | ADC/DMA 硬件报错 |
**相位伪影 ** (实测发现,故设 `STALL_RUN_MAX=2` ):任务在 TIM3 触发瞬间被唤醒,而该拍的 DMA
完成落在 ≈67 µs 之后;若上一拍被日志/抢占拖过这 67 µs,本拍就会看到"计数没前进"(那个完成
已被上一拍消费掉)。这种伪影**只会连续 1 拍**——DMA 若真停,第 2 拍仍不会前进。
### 11.5 实测(`@test` PASS, 2026-10-10)
| 指标 | DMA 前 | DMA 后 |
|---|---|---|
| `adcp` (单拍消费耗时) | **122 µs ** (超时拍 2209 µs) | **8 µs ** |
| `adcm` (心跳拍峰值) | 2.1 ms | 2.0 ms(仍是每 10 s 心跳日志本身) |
| `adc1_err` | — | **0 ** |
| `adc1_hw` | — | **0 ** |
| `adc1_gap` / `skip` | — | 27 / 20002 拍(0.13%,集中在开机日志突发期;稳态每 10 s 心跳 +1) |
| `adc1_seq` vs `seq` | — | 20004 vs 20006(恒定偏移 2,每拍 +1) |
| `idle` | 0 | 0 |
| 主机单测 | 79 | **101 ** (新增乒乓半区、HAL 回调守卫、gap/stall 分开计数三组) |
| `@build` | Code 256872 | **257660 ** ( +788 B);ZI +4 B( 36 B 乒乓缓冲 − 旧变量) |
| adcTask 静态最坏链 | 832 B / 余 59% | **832 B / 余 59% ** (未变,`HAL_ADC_PollForConversion` 出链) |
反向验证(改错实现看用例是否咬):半区奇偶取反 → 11 条 FAIL;停摆拍置 `valid=1` → 4 条 FAIL;
gap 阈值改 `>2` → 1 条 FAIL; `STALL_RUN_MAX` 改 1 → 6 条 FAIL。
### 11.6 遗留与注意
- **启动 fail-safe 语义不变但更硬**: TIM3 未启动 → ADC1 不再被触发 → `adcTask` 永久阻塞 →
`TASK_WD_ADC` 缺席 → IWDG 复位(设计如此)。
- `adc1_gap` 的根治手段是**缩短心跳拍**(每 10 s 一次的多行日志),不是提高 DMA 能力。
- 加大缓冲(如 4 路乒乓)前**必须**重跑 `.map` 确认地址仍在 `RW_IRAM1` 。
- 每次改 DMA 流之后必须回 NVIC 页核对优先级(§11.2 第三行)。
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)。