Files
STM32F4-Base/docs/子系统设计/ADC采样节拍设计说明.md
wandering1 593c5d0c84 refactor(编号): 采集链分层 L1/L1.5/L2/L2.5/L3/L4/L5 → 语义名(批次 1)
用户裁定(2026-10-28):L1/L1.5/L2/L2.5/L3 这套层号「谁能看懂」,改用语义名。
映射(同一批定稿,含 L4/L5):
  L1   → 语义取数与有效性
  L1.5 → 标定折算
  L2   → 全周傅氏相量
  L2.5 → 测频与遥测快照
  L3   → 保护判据
  L4   → 出口与记录
  L5   → 动作留痕

实测规模:384 站点 / 62 文件(App/algo、App/task、test/host 六个套件、docs/算法、
产品需求、架构总览、启动顺序、ADC 采样节拍、IEC104 上行、采集短板评测、内存管理、
AGENTS.md、README.md、docs/README.md、.pre-commit-config.yaml 与 scripts 里的注释)。

改法(不是全局替换,逐模式有上下文规则):
- 先吃同位语再落裸词:L1 有效性校验 → 语义取数与有效性校验、L2 相量层 → 全周傅氏
  相量层、L3 保护 → 保护判据、L2.5 遥测快照 → 测频与遥测快照,避免「保护判据保护判据」
  这类重复(第一版就是这么坏掉的,已整体回滚重做)。
- .c/.h 只改注释:先把字符串字面量切出去(引号数为奇数的跨行注释按整行处理),
  C 字符串里的 L2-in ring 之类原样保留——AGENTS 铁律「字符串字面量必须纯 ASCII」。
- 层内条目号(L1-3 / L2-3 这类)**本批不动**,属批次 2;另外三族(UI 菜单层级
  L0~L3、链路延迟分段 L1~L8、CubeMX 防丢失三层)也不动,属批次 3。
- 顺手修的措辞:范围写法 L1~L3 → 「分层(语义取数与有效性 → 保护判据)」、
  CI 段「不含 L1/L2/L3」→「不含采集链分层」、目录地图里同一处并列表述。

验证:@build 0 错 0 警(Code=236456,与改前逐字节同尺寸——纯注释改动);
内存布局配图已按新脚本重生成(自检通过);doc-links --all --sections-strict 通过
(195 文档);mojibake / bom-guard / coding-standard / 新编号门禁自查 全 rc=0;
git diff --check 干净。
2026-10-28 13:04:02 +08:00

28 KiB
Raw Permalink Blame History

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. 节拍链路总览

一句话链:TIM3 每 1 ms 产生一个更新事件,同一个事件同时驱动两片转换器,并唤醒唯一的消费者任务。

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 --> 全周傅氏相量["全周傅氏相量 / 测频 → 采样环<br/>仅确有新帧时压环"]
    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() 只读一个计数:

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. 单周期时序与中断交互

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