# 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 格—— 表象与"映射表写错"或"注入点标签不对"完全一样,历史上曾据此误判过三轮。