问题:ADC1 在 MX_ADC1_Init() 里使能,而 DMA 在 app_main_init() 步骤 4b 才挂载,中间隔着 GD5F 全片 BBT 扫描(数秒)。台架同一次开机内用 无 DMA 单通道逐路取真值、与 DMA 路径对照,得 9/9 完美置换:乒乓缓冲 从 rank 3 开始搬,槽 i 装的是 rank ((i+1) mod 9)+1,整体错开 2 格。 表象是"接地 AIN2、UB 却读到别的通道的值",极易误判为映射表写错或 注入点标签不对。 修法:新增 adc1_disable()(Src/adc.c 的 USER CODE BEGIN 1),在 app_main_init() 步骤 4b-pre 调用,把 ADC 置为关闭态,使能动作交给 HAL_ADC_Start_DMA() 内部完成(它自己 __HAL_ADC_ENABLE + 等 Tstab 约 20 us,紧接着开 CR2.DMA 并启流),使能与搬运之间不留可观测间隔。 顺序不可调换:本调用必须严格早于 adc_input_init()。 台架验证:在 MX_ADC1_Init() 末尾关闭 4/4 次、在 4b-pre 调本 API 3/3 次,均 UB=0;同开机对照差值 <=4 码(修前 7/7 次 UB=783~786)。 文档:ADC采样节拍设计说明.md 新增 4.5 节(9 行证据表、判据陷阱、 根因与修法)与设计铁律第 8 条。 台账:Flash 261,764 -> 261,928 B(+164,新增函数),RAM 无变化; CubeMX 存活审计基线重建(adc1-calibration-impl / adc1-dma-link 两项漂移均属本次有意改动,锚点已逐条复核在位)。
27 KiB
ADC 采样节拍设计说明(TIM3 1 kHz 同源硬件节拍)
模块名称:ADC 采样节拍与消费链——TIM3 节拍源(
Src/tim.c)、更新事件回调(Src/main.c)、 L1 采集层(App/algo/adc_input.c)、消费者adcTask(App/task/adc_task.c)。 文档定位:现状设计说明——这条链当前由谁产生节拍、同一个硬件事件如何驱动两片转换器、 STM32 侧怎么搬运、消费者怎么取数、为什么这样配置、边界与代价在哪里。历史演进、方案对比、 门禁与台架复测记录、修订日志一律不写进本文(需要时查 git 历史)。 状态:已落地并经真机验证;下游 = L2 相量 / 测频 → L3 保护判据(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. 节拍链路总览
- 2. 实现落点
- 3. 节拍源:TIM3 配置与启动
- 4. STM32 侧:ADC1 触发与 DMA 乒乓双缓冲
- 5. TPAFE 侧:CONVST 脉冲与 BUSY 读帧
- 6. 消费者 adcTask:事件驱动唤醒
- 7. 单周期时序与中断交互
- 8. 可观测性:心跳计数与判读
- 9. 性能与资源特征
- 10. 设计边界与已知代价
- 11. 设计铁律(改这条链前必读)
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 --> 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_dma36 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 |
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 相量快照 | → 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 启动位置与顺序
MX_TIM3_Init()由main()在app_main_init()之前调用(生成代码,只配置不计数)。HAL_TIM_Base_Start_IT(&htim3)必须排在tpafe5160_init()成功之后(app_main.c步骤 6): 否则 ISR 会在驱动首帧有效性自检期间乱发 CONVST,使自检结果不可信。- 节拍启动仍早于内核:
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)。
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无跳变"的帧计数——它是"每个周期BUSYEXTI 都真的在读帧"的直接观测面。
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. 设计铁律(改这条链前必读)
- ISR 只发唤醒,不做计算。相量、压环、保护判据全部留在任务上下文:忙等与算法不可入 ISR、
任务级共享数据不能在 ISR 里改写、
HAL_TIM_PeriodElapsedCallback()是生成区回调承载的业务代码。 - ISR 侧唤醒只能走
adc_tick_irq_notify()(就绪门,§6.1);直接osThreadFlagsSet()会在内核 启动前把调度器外代码跑进 ISR。 - 一个更新事件驱动两片转换器。另起定时器分别触发 = 重新引入跨转换器的相位不确定性(§10)。
- 取数只用
adc_input_poll()/adc_input_raw()/tpafe5160_snapshot(),按语义不按下标; 不得自行拼"两步取数"的等价物(§5)。 - 改节拍源、改 DMA 流、动
DMAContinuousRequests之前先读 §3.2 与 §4.3——这几类改动的共同点是 违反时不报任何错,只在数据上出事(静默丢通道、搬不回来、优先级被重置为 0)。 - 节拍源停摆必须导致复位(§6.3):不得给等待加超时来"绕过"看门狗。
- 动栈或加深调用链前后各跑一次
scripts/stack_report.py,新增缓冲前跑scripts/ram_report.py(§9)。 - 挂 DMA 之前必须让 ADC 处于关闭态(§4.5):
app_main_init()步骤 4b-pre 的adc1_disable()不可删除、不可后移。违反时不报任何错,只是 9 路通道按 rank 序整体错开 2 格—— 表象与"映射表写错"或"注入点标签不对"完全一样,历史上曾据此误判过三轮。