STM32 ADC多通道DMA定时器触发实测:TRGO触发链、采样率精确控制与乒乓缓冲落地

文章目录

    • 一、前言
    • 二、核心原理:把采样节拍交给定时器
      • [2.1 触发链全景](#2.1 触发链全景)
      • [2.2 方案级决策:三种采样驱动方式对比](#2.2 方案级决策:三种采样驱动方式对比)
      • [2.3 配置级决策:DMA 通道为什么没得选](#2.3 配置级决策:DMA 通道为什么没得选)
    • [三、CubeMX 配置清单(寄存器级)](#三、CubeMX 配置清单(寄存器级))
      • [3.1 TIM3:产生精确触发节拍](#3.1 TIM3:产生精确触发节拍)
      • [3.2 ADC1:扫描序列与外部触发](#3.2 ADC1:扫描序列与外部触发)
      • [3.3 DMA1_Channel1 与 NVIC](#3.3 DMA1_Channel1 与 NVIC)
    • 四、容易遗漏的三个步骤
    • 五、核心代码:乒乓缓冲落地
      • [5.1 缓冲区与标志设计](#5.1 缓冲区与标志设计)
      • [5.2 处理函数与时间预算](#5.2 处理函数与时间预算)
      • [5.3 主循环与撕裂观测](#5.3 主循环与撕裂观测)
    • 六、测试验证
      • [6.1 触发周期与采样率精度:理论 vs 实测(对照一)](#6.1 触发周期与采样率精度:理论 vs 实测(对照一))
      • [6.2 采样时间参数扫描:触发率上限从哪来](#6.2 采样时间参数扫描:触发率上限从哪来)
      • [6.3 触发频率极限扫描:理论 vs 实测(对照二)](#6.3 触发频率极限扫描:理论 vs 实测(对照二))
      • [6.4 CPU 占用对比](#6.4 CPU 占用对比)
    • [七、故障排查实录(6 类)](#七、故障排查实录(6 类))
      • [7.1 DMA 计数始终为 0:TRGO 触发链断了](#7.1 DMA 计数始终为 0:TRGO 触发链断了)
      • [7.2 触发率超极限:丢点与通道错位](#7.2 触发率超极限:丢点与通道错位)
      • [7.3 全通道固定偏移约 10 LSB:校准遗漏](#7.3 全通道固定偏移约 10 LSB:校准遗漏)
      • [7.4 乒乓缓冲数据撕裂:printf 挤爆预算](#7.4 乒乓缓冲数据撕裂:printf 挤爆预算)
      • [7.5 首半缓冲数据异常:启动顺序与首轮丢弃](#7.5 首半缓冲数据异常:启动顺序与首轮丢弃)
      • [7.6 两路读数互换:引脚与通道号对不上](#7.6 两路读数互换:引脚与通道号对不上)
    • 八、总结
    • 参考资料

摘要:STM32 多通道 ADC 采集最常用的是连续转换模式,但该模式下采样率由 ADC 时钟决定,节拍不可控,无法满足等间隔采样、抗混叠和多路数据对齐分析的需求。本文基于 STM32F103C8T6,把 ADC 的触发源从"软件/连续"切换为 TIM3 的 TRGO 事件,配合 DMA 循环模式与乒乓双缓冲,实现 4 通道、触发率从 1kHz 到 35kHz 可精确设定的采集链路。实测:1kHz 触发周期偏差约 0.1μs,非整除档位(3.3kHz)采样率偏差 +0.78%,触发频率极限实测 35~40kHz 与理论值 35.7kHz 吻合,10kHz 四通道采集 CPU 占用从轮询方案的 28.3% 降到 0.4%。文中给出寄存器级配置清单、两段完整排查链和采样时间参数扫描数据。

一、前言

多路模拟量采集是单片机项目里最常见的需求之一:电池管理要同时看几节电池电压,工装夹具要同时监测多路应变桥,电机驱动要同步看母线电压和相电流。这类场景有一个共同要求------各路数据必须是"同一节拍"采出来的,后续做差、做功率计算、做数字滤波才有意义。

我上一篇文章《STM32 多通道 ADC + DMA 采集实战:数据错位、读数跳动的根因与滤波选型》解决的是"连续转换模式下数据为什么会错位、读数为什么跳动",那篇的前提是 ADC 用 Continuous 模式自由跑,节拍由 ADC 时钟决定。但实践中很快会遇到它的边界:采样率不可设。ADC 时钟 12MHz、单通道 14 个转换周期时,采样率固定在 857kSPS 这个量级,想要"每 100μs 精确采一轮"是做不到的,只能在软件里对数据抽稀,浪费了带宽还引入不确定性。

本文的目标是把采样节拍的控制权交给定时器:TIM3 定时产生 TRGO 事件,每次触发 ADC 扫描一轮 4 个通道,DMA 把结果搬进缓冲区,半满/全满中断里用乒乓方式处理数据。读完你将得到:一条寄存器级配置清单、一套可直接移植的乒乓缓冲代码、一组实测的触发频率极限数据,以及两个完整的故障排查过程。

前置条件:读者应具备 STM32 HAL 库基础开发经验,了解 CubeMX 基本操作;需要 STM32F103C8T6 最小系统板、ST-Link、逻辑分析仪(或示波器)、信号源(函数发生器或电位器分压组)。完整工程代码可在 CSDN 下载频道 获取(VIP 免费)。

二、核心原理:把采样节拍交给定时器

2.1 触发链全景

整条链路可以拆成三段:定时器产生节拍 → ADC 按节拍扫描 → DMA 循环搬运。TIM3 每 1ms 产生一次更新事件,通过 MMS 位把更新事件送到 TRGO 输出;ADC1 的规则组配置为 TIM3_TRGO 外部触发,收到一个脉冲就按 rank 顺序扫描一遍 4 个通道;每转换完成一个通道,DMA1_Channel1 立即把 DR 寄存器的半字搬到内存,写满 128 个半字后自动回绕。
#mermaid-svg-l32mmuNijxeSaeRj{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-l32mmuNijxeSaeRj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-l32mmuNijxeSaeRj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-l32mmuNijxeSaeRj .error-icon{fill:#552222;}#mermaid-svg-l32mmuNijxeSaeRj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-l32mmuNijxeSaeRj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-l32mmuNijxeSaeRj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-l32mmuNijxeSaeRj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-l32mmuNijxeSaeRj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-l32mmuNijxeSaeRj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-l32mmuNijxeSaeRj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-l32mmuNijxeSaeRj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-l32mmuNijxeSaeRj .marker.cross{stroke:#333333;}#mermaid-svg-l32mmuNijxeSaeRj svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-l32mmuNijxeSaeRj p{margin:0;}#mermaid-svg-l32mmuNijxeSaeRj .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-l32mmuNijxeSaeRj .cluster-label text{fill:#333;}#mermaid-svg-l32mmuNijxeSaeRj .cluster-label span{color:#333;}#mermaid-svg-l32mmuNijxeSaeRj .cluster-label span p{background-color:transparent;}#mermaid-svg-l32mmuNijxeSaeRj .label text,#mermaid-svg-l32mmuNijxeSaeRj span{fill:#333;color:#333;}#mermaid-svg-l32mmuNijxeSaeRj .node rect,#mermaid-svg-l32mmuNijxeSaeRj .node circle,#mermaid-svg-l32mmuNijxeSaeRj .node ellipse,#mermaid-svg-l32mmuNijxeSaeRj .node polygon,#mermaid-svg-l32mmuNijxeSaeRj .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-l32mmuNijxeSaeRj .rough-node .label text,#mermaid-svg-l32mmuNijxeSaeRj .node .label text,#mermaid-svg-l32mmuNijxeSaeRj .image-shape .label,#mermaid-svg-l32mmuNijxeSaeRj .icon-shape .label{text-anchor:middle;}#mermaid-svg-l32mmuNijxeSaeRj .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-l32mmuNijxeSaeRj .rough-node .label,#mermaid-svg-l32mmuNijxeSaeRj .node .label,#mermaid-svg-l32mmuNijxeSaeRj .image-shape .label,#mermaid-svg-l32mmuNijxeSaeRj .icon-shape .label{text-align:center;}#mermaid-svg-l32mmuNijxeSaeRj .node.clickable{cursor:pointer;}#mermaid-svg-l32mmuNijxeSaeRj .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-l32mmuNijxeSaeRj .arrowheadPath{fill:#333333;}#mermaid-svg-l32mmuNijxeSaeRj .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-l32mmuNijxeSaeRj .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-l32mmuNijxeSaeRj .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-l32mmuNijxeSaeRj .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-l32mmuNijxeSaeRj .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-l32mmuNijxeSaeRj .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-l32mmuNijxeSaeRj .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-l32mmuNijxeSaeRj .cluster text{fill:#333;}#mermaid-svg-l32mmuNijxeSaeRj .cluster span{color:#333;}#mermaid-svg-l32mmuNijxeSaeRj div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-l32mmuNijxeSaeRj .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-l32mmuNijxeSaeRj rect.text{fill:none;stroke-width:0;}#mermaid-svg-l32mmuNijxeSaeRj .icon-shape,#mermaid-svg-l32mmuNijxeSaeRj .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-l32mmuNijxeSaeRj .icon-shape p,#mermaid-svg-l32mmuNijxeSaeRj .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-l32mmuNijxeSaeRj .icon-shape .label rect,#mermaid-svg-l32mmuNijxeSaeRj .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-l32mmuNijxeSaeRj .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-l32mmuNijxeSaeRj .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-l32mmuNijxeSaeRj :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} TRGO 脉冲

1kHz
每通道 EOC
写满前 64
写满后 64
TIM3

PSC=71 ARR=999

MMS=010 (Update)
ADC1 规则组

SCAN=1 EXTSEL=100

CH0→CH1→CH4→CH5
DMA1_Channel1

Circular 128×halfword
HT 中断

处理前半缓冲
TC 中断

处理后半缓冲
乒乓处理

均值/打包/上传

关键点在于 ADC 的 CONT 位要清零:外部触发模式下,一个 TRGO 脉冲只扫一轮,扫完自动停下等下一个脉冲。这和连续模式"转起来不停"的逻辑完全不同,也是很多人从连续模式迁移到触发模式后数据异常的第一个根源。

2.2 方案级决策:三种采样驱动方式对比

确定"要精确节拍"这个目标后,有三个候选方案,我实际都跑过一轮:

对比维度 轮询读取 DR EOC 中断逐通道收数 定时器 TRGO + DMA
采样率精确性 取决于主循环抖动 取决于中断响应抖动 定时器硬件级,误差仅时钟精度
CPU 占用(10kHz×4通道) 28.3% 8.6% 0.4%
通道间时间偏差 存在且不确定 存在且不确定 固定 = 3×单通道转换时长
实现复杂度 最低 中 中偏高(需管缓冲)
数据完整风险 主循环阻塞即丢 中断嵌套即丢 处理超预算才丢

轮询方案在 10kHz 下 CPU 有近三成时间耗在等 ADC,主循环里稍微加一段串口打印就会丢样;EOC 中断方案中断频率高达 40k 次/秒,中断进出开销本身就把 CPU 吃掉了 8 个多点。TRGO + DMA 方案里,CPU 只在半满/全满时被叫一次(128 半字缓冲下每秒仅 625 次),处理时间窗口也清晰可算。采样节拍要精确、CPU 要解放,这两个诉求直接决定了选型,代价是要自己管理缓冲与预算,这正是第五节要解决的问题。

2.3 配置级决策:DMA 通道为什么没得选

第二个层次是配置级的"没得选":STM32F103 的 DMA1 五个通道与外设是硬连线映射,ADC1 的请求固定接在 DMA1_Channel1 上,软件无法改道。参考手册 RM0008 的 Table 78 列出了完整映射(节选本项目相关部分):

DMA1 通道 固定映射的外设请求 本项目使用
Channel 1 ADC1 / TIM2_CH3 / TIM4_CH1 是(ADC1)
Channel 2 SPI1_RX / USART3_RX / TIM1_CH1 否
Channel 3 SPI1_TX / USART3_TX / TIM1_CH2 否
Channel 4 SPI2_RX / USART1_RX / TIM1_CH4 否(留给 USART1_RX)
Channel 5 SPI2_TX / USART1_TX / TIM1_TRGO 否(留给 USART1_TX)

同样,触发源 EXTSEL 的可选值也是硬件定死的。RM0008 Table 66 给出 ADC1/ADC2 规则组的外部触发选择:EXTSEL[2:0]=100 对应 TIM3_TRGO。我选 TIM3 而不是 TIM1_CC1,是因为 TIM1 是高级定时器,板子上还留着做 PWM 输出;TIM3 是通用定时器,TRGO 只需要设一个 MMS 位,不占用任何比较通道。如果项目里 TIM3 已被编码器模式占用,换 TIM4_TRGO(EXTSEL=101 的相邻选项,对应 TIM4_CC4)或 TIM2_CC2(011)都是现成的退路。

三、CubeMX 配置清单(寄存器级)

3.1 TIM3:产生精确触发节拍

先算分频。目标触发率 1kHz:定时器时钟 72MHz(APB1 预分频不为 1 时定时器时钟翻倍),取 PSC=71、ARR=999,则溢出频率 = 72MHz ÷ 72 ÷ 1000 = 1000Hz,整除无取整误差。CubeMX 里三步配完,每步对应的寄存器位写在括号里:

  1. Prescaler = 71(TIM3_PSC,实际分频系数 = PSC+1);
  2. Counter Period = 999(TIM3_ARR,向上计数从 0 到 ARR 后产生更新事件);
  3. Trigger Event Selection = Update Event(TIM3_CR2 的 MMS2:0 = 010,把更新事件作为 TRGO 输出)。

这里最容易漏的就是第三步。CubeMX 里 TRGO 默认是 Reset(MMS=000),而 Reset 事件只在计数器被复位时产生一次,定时器自由跑起来之后 TRGO 上一根脉冲都没有------这个坑我在 7.1 节花了整整一段排查链来讲。

3.2 ADC1:扫描序列与外部触发

ADC 时钟取 PCLK2 的 6 分频,即 12MHz(RCC_CFGR 的 ADCPRE1:0=10,不超过 F1 的 14MHz 上限)。ADC1 主参数逐项列出:

配置项 取值 对应寄存器位
Scan Conversion Mode Enabled ADC_CR1 的 SCAN=1,按 SQ 序列自动逐通道转换
Continuous Conversion Mode Disabled ADC_CR2 的 CONT=0,触发模式必须关掉连续转换
External Trigger Conversion Source Timer 3 Trigger Out event ADC_CR2 的 EXTSEL2:0=100
External Trigger Conversion Edge Rising edge ADC_CR2 的 EXTTRIG=1,选上升沿
Data Alignment Right ADC_CR2 的 ALIGN=0
Number Of Conversion 4 ADC_SQR1 的 L3:0=0011(N-1 编码)

通道序列在 Rank 管理里按顺序添加:Rank1 = IN0(PA0)、Rank2 = IN1(PA1)、Rank3 = IN4(PA4)、Rank4 = IN5(PA5),采样时间统一先取 71.5 Cycles(ADC_SMPR1 的 SMP 位段,后面 6.2 节会做参数扫描看它对触发率上限的影响)。rank 顺序决定了 DMA 缓冲区里的数据排布,改了顺序就等于换了数组下标,这一点和前作讲的"错位"问题一脉相承------排布错了,四路数据会整体轮转。

GPIO 这边不用做任何事,CubeMX 会把 PA0/PA1/PA4/PA5 设为 Analog 模式(GPIOx_CRL 的 MODE=0000、CNF=0000,输入浮空都不行,必须模拟输入,否则采样电容充不上电,读数偏低且抖)。

3.3 DMA1_Channel1 与 NVIC

DMA Settings 里点 Add,选 ADC1,参数如下:

配置项 取值 对应寄存器位
Mode Circular DMA_CCR 的 CIRC=1,计数回绕自动续传
Data Width(外设/内存) Half Word / Half Word DMA_CCR 的 PSIZE=01、MSIZE=01
Increment(外设/内存) 不递增 / 递增 DMA_CCR 的 PINC=0、MINC=1
Number Of Data 128(运行时由 HAL 传入) DMA_CNDTR

NVIC 里只开 DMA1 channel1 global interrupt(DMA_CCR 的 TCIE=1、HTIE=1 由 HAL 在 Start_DMA 时置位),HAL 库会把传输半满/全满分别映射到 HAL_ADC_ConvHalfCpltCallback 和 HAL_ADC_ConvCpltCallback 两个回调。ADC1 自身的中断不用开------EOC 中断在这个方案里根本不用。

四、容易遗漏的三个步骤

这三个步骤 CubeMX 都不会替你生成,遗漏后的症状各不相同,单独列出来提醒。

第一步,ADC 校准。 F1 系列的 ADC 上电后必须执行一次校准(调用 HAL_ADCEx_Calibration_Start),它内部会复位校准寄存器、等待 STSET 稳定、再置 ADC_CR2 的 CAL 位跑一遍校准周期。这个函数是 F1 HAL 特有的,F4 上根本没有------所以从 F4 教程抄来的初始化代码经常把它漏掉。遗漏后实测表现为全通道读数整体偏移 8~12 LSB(约 7~10mV),且不同芯片个体偏差不同,没法用软件常量修正一劳永逸。

第二步,启动顺序。 必须先 HAL_ADC_Start_DMA 把 DMA 通道使能,再 HAL_TIM_Base_Start 启动 TIM3。反过来先开定时器,第一次 TRGO 触发时 DMA 还没就绪,首个样本直接丢失,缓冲区从中间某个位置开始才是有效数据。HAL 的启动函数本身不校验这个顺序,只能靠约定,我在代码里用注释把它钉死。

第三步,共享标志加 volatile。 g_buf_ready、撕裂计数器这些在中断和主循环之间传递的变量,编译器在 O2 优化下会把主循环里的判断折叠成寄存器读取,不加 volatile 时症状是"打断点单步运行一切正常,全速跑就卡死在 while 里等不到标志"。这类"优化依赖型 bug"不连调试器根本复现不了,吃过一次亏之后我的规矩是:凡是 ISR 写主循环读的变量,声明处一律 volatile。

五、核心代码:乒乓缓冲落地

5.1 缓冲区与标志设计

乒乓缓冲的核心思想:把 DMA 循环缓冲对半分,前半写满(HT 中断)时 CPU 处理前半,DMA 同时往后半写;后半写满(TC 中断)时反过来。两边永远不碰同一块内存,处理耗时就有了一个明确的预算上限------半缓冲时长。

c 复制代码
/* adc_dma_pingpong.h ------ 乒乓缓冲参数集中定义 */
#define ADC_CH_NUM      4u                            /* 规则组通道数 */
#define ADC_SCAN_NUM    16u                           /* 每半缓冲容纳的扫描轮数 */
#define ADC_BUF_HALF    (ADC_CH_NUM * ADC_SCAN_NUM)   /* 64  个半字 */
#define ADC_BUF_TOTAL   (ADC_BUF_HALF * 2u)           /* 128 个半字 */

缓冲区用 volatile 修饰。虽然 DMA 硬件写内存不理会 volatile,但 CPU 侧读它时需要阻止编译器把重复读取优化掉;而中断与主循环的握手靠下面这个单字节标志完成:

c 复制代码
/* adc_dma_pingpong.c */
#include "adc_dma_pingpong.h"

static volatile uint16_t g_adc_buf[ADC_BUF_TOTAL];  /* DMA 循环缓冲 */
static volatile uint8_t  g_buf_ready = 0u;          /* 0:无新数据 1:前半就绪 2:后半就绪 */
static volatile uint32_t g_tear_cnt  = 0u;          /* 撕裂计数:预算超限时递增 */

/* 启动采集:顺序必须是 先 DMA 后 TIM(见第四节) */
HAL_StatusTypeDef ADC_Capture_Start(ADC_HandleTypeDef *hadc, TIM_HandleTypeDef *htim)
{
    HAL_StatusTypeDef ret;

    ret = HAL_ADCEx_Calibration_Start(hadc);        /* F1 专属校准,遗漏全通道偏移 */
    if (ret != HAL_OK) return ret;

    /* 必须先启动 DMA,再启动 TIM3,否则首个 TRGO 触发时 DMA 未就绪,首样本丢失 */
    ret = HAL_ADC_Start_DMA(hadc, (uint32_t *)(uintptr_t)g_adc_buf, ADC_BUF_TOTAL);
    if (ret != HAL_OK) return ret;

    ret = HAL_TIM_Base_Start(htim);                 /* TRGO 由硬件产生,无需 TIM 中断 */
    return ret;
}

回调里只做一件事:握手置标志。处理动作全部留给主循环,避免在 ISR 里做耗时操作------这是预算模型成立的前提。

c 复制代码
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1) {
        if (g_buf_ready != 0u) { g_tear_cnt++; }    /* 上一半还没被处理:撕裂 */
        g_buf_ready = 1u;                           /* 前半缓冲就绪,DMA 正写后半 */
    }
}

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1) {
        if (g_buf_ready != 0u) { g_tear_cnt++; }
        g_buf_ready = 2u;                           /* 后半缓冲就绪,DMA 回绕写前半 */
    }
}

5.2 处理函数与时间预算

主循环侧的处理函数从标志拿到"哪一半是新数据",对 4 个通道各做 16 点滑动均值,打包成帧丢给串口。关键是开头那段预算检查:半缓冲时长 = 16 轮 ÷ 触发频率,1kHz 时是 16ms,很宽裕;10kHz 时只剩 1.6ms,处理函数稍有拖延就会撞上下一次握手。

c 复制代码
void ADC_Process(void)
{
    uint8_t r = g_buf_ready;
    if (r == 0u) return;
    g_buf_ready = 0u;                               /* 先清标志,迟到的新握手计入撕裂 */

    const uint16_t *p = (r == 1u) ? &g_adc_buf[0] : &g_adc_buf[ADC_BUF_HALF];

    uint32_t sum[ADC_CH_NUM] = {0};
    for (uint32_t i = 0; i < ADC_SCAN_NUM; i++) {
        for (uint32_t ch = 0; ch < ADC_CH_NUM; ch++) {
            sum[ch] += p[i * ADC_CH_NUM + ch];      /* 按 rank 排布逐轮累加 */
        }
    }

    uint16_t mean[ADC_CH_NUM];
    for (uint32_t ch = 0; ch < ADC_CH_NUM; ch++) {
        mean[ch] = (uint16_t)(sum[ch] / ADC_SCAN_NUM);
    }
    App_Frame_Send(mean);                           /* 打包上送,内部不做阻塞打印 */
}

数据在缓冲区里的排布值得画出来看一眼,它是所有"错位"问题的物证:
CPU 主循环 DMA1_Ch1 ADC1 扫描 TIM3 TRGO CPU 主循环 DMA1_Ch1 ADC1 扫描 TIM3 TRGO #mermaid-svg-Idt65t8L2fzzsg1V{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Idt65t8L2fzzsg1V .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Idt65t8L2fzzsg1V .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Idt65t8L2fzzsg1V .error-icon{fill:#552222;}#mermaid-svg-Idt65t8L2fzzsg1V .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Idt65t8L2fzzsg1V .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Idt65t8L2fzzsg1V .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Idt65t8L2fzzsg1V .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Idt65t8L2fzzsg1V .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Idt65t8L2fzzsg1V .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Idt65t8L2fzzsg1V .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Idt65t8L2fzzsg1V .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Idt65t8L2fzzsg1V .marker.cross{stroke:#333333;}#mermaid-svg-Idt65t8L2fzzsg1V svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Idt65t8L2fzzsg1V p{margin:0;}#mermaid-svg-Idt65t8L2fzzsg1V .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Idt65t8L2fzzsg1V text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-Idt65t8L2fzzsg1V .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Idt65t8L2fzzsg1V .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-Idt65t8L2fzzsg1V .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-Idt65t8L2fzzsg1V .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-Idt65t8L2fzzsg1V #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-Idt65t8L2fzzsg1V .sequenceNumber{fill:white;}#mermaid-svg-Idt65t8L2fzzsg1V #sequencenumber{fill:#333;}#mermaid-svg-Idt65t8L2fzzsg1V #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-Idt65t8L2fzzsg1V .messageText{fill:#333;stroke:none;}#mermaid-svg-Idt65t8L2fzzsg1V .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Idt65t8L2fzzsg1V .labelText,#mermaid-svg-Idt65t8L2fzzsg1V .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-Idt65t8L2fzzsg1V .loopText,#mermaid-svg-Idt65t8L2fzzsg1V .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-Idt65t8L2fzzsg1V .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Idt65t8L2fzzsg1V .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Idt65t8L2fzzsg1V .noteText,#mermaid-svg-Idt65t8L2fzzsg1V .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-Idt65t8L2fzzsg1V .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Idt65t8L2fzzsg1V .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Idt65t8L2fzzsg1V .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Idt65t8L2fzzsg1V .actorPopupMenu{position:absolute;}#mermaid-svg-Idt65t8L2fzzsg1V .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-Idt65t8L2fzzsg1V .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Idt65t8L2fzzsg1V .actor-man circle,#mermaid-svg-Idt65t8L2fzzsg1V line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-Idt65t8L2fzzsg1V :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ......共16轮写满前半 buf0..63 第1轮触发 CH0→CH1→CH4→CH5 依次 EOC 写 buf0..3 第2轮触发 写 buf4..7 HT 中断(前半就绪) 处理 buf0..63(预算1.6ms@10kHz) 第17~32轮触发 DMA 写后半 buf64..127 TC 中断(后半就绪) 处理 buf64..127,DMA 回绕写前半

也就是说 buf[0] 是第 1 轮的 CH0,buf[1] 是第 1 轮的 CH1,buf[4] 是第 2 轮的 CH0------通道内下标步长是 4 不是 1。我第一次写均值循环时按 p[i] 连续取数,四路数据在示波器上看起来"各自都有波形但互相错开",后来对着上表逐个下标核对才改对。

5.3 主循环与撕裂观测

主循环就是"初始化 + 死循环处理"两件事,撕裂计数器周期性上送,作为预算是否超限的观测窗口:

c 复制代码
int main(void)
{
    HAL_Init();
    SystemClock_Config();       /* 72MHz:HSE 8MHz ×9,PCLK1=36MHz,PCLK2=72MHz */
    MX_GPIO_Init();
    MX_DMA_Init();              /* 必须先初始化 DMA 时钟,再初始化 ADC */
    MX_ADC1_Init();
    MX_TIM3_Init();
    MX_USART1_UART_Init();

    if (ADC_Capture_Start(&hadc1, &htim3) != HAL_OK) {
        Error_Handler();
    }

    uint32_t tick_report = HAL_GetTick();
    while (1) {
        ADC_Process();

        if (HAL_GetTick() - tick_report >= 1000u) {     /* 每秒上报一次健康状态 */
            tick_report = HAL_GetTick();
            printf("ready=%u tear=%lu\r\n", g_buf_ready, g_tear_cnt);
        }
    }
}

正常状态下每秒应看到 tear=0。它一旦开始增长,说明处理耗时逼近甚至超过半缓冲时长------第六节的失败路径二就是拿这个计数器抓到的元凶。

六、测试验证

6.1 触发周期与采样率精度:理论 vs 实测(对照一)

测量方法:在 TC 回调里翻转 PA8,逻辑分析仪测相邻翻转间隔再除以 16(每半缓冲 16 轮、每两轮一次 HT/TC,实际用 HT 与 TC 的间隔 ×16 最稳)。两个触发档位的对照如下:

参数 设定/理论值 实测值 偏差 原因分析
触发周期(PSC=71, ARR=999) 1000.0μs 1000.1μs ≈0.01% 72MHz 整除分频,无取整误差
触发频率(PSC=21, ARR=983,目标 3.3kHz) 3300Hz 3325.9Hz +0.78% 72MHz÷22÷984=3325.9Hz,整数取整不可避免
单通道转换时长(71.5+12.5 周期) 7.00μs 7.03μs +0.4% 8MHz 外部晶振自身偏差 ±30ppm 量级 + ADC 时钟分频取整
4 通道扫描周期 28.0μs 28.2μs +0.7% 同上,逐通道累积

3.3kHz 档这行值得多说一句:任何"目标频率"都要先过一遍 72MHz÷(PSC+1)÷(ARR+1) 能否整除的验算,除不尽就一定会带来 +0.x% 的固定偏差。对控制环路这类应用,固定偏差可以在上位机标定掉,但如果你不知道它存在,做频谱分析时就会在谱线上看到一根来历不明的泄漏。

6.2 采样时间参数扫描:触发率上限从哪来

固定触发 20kHz、4 通道扫描,把采样时间从 1.5 周期扫到 239.5 周期,看数据完整率(判据:HT+TC 中断计数 ÷ 理论值,同时检查 PA0 固定 1.65V 分压下的读数是否稳定在 2048±2):

采样时间(cycles) 单通道时长 4 通道扫描周期 理论最高触发率 20kHz 下完整率
1.5 1.17μs 4.67μs 214kHz 100%
7.5 1.67μs 6.67μs 150kHz 100%
13.5 2.17μs 8.67μs 115kHz 100%
28.5 3.42μs 13.7μs 73kHz 100%
71.5 7.00μs 28.0μs 35.7kHz 100%
239.5 21.0μs 84.0μs 11.9kHz 78.4%(错乱)

结论清晰:"单次触发扫描一轮"的总时长必须小于触发周期,否则上一轮还没扫完,下一个 TRGO 就来了,DR 里的旧值被覆盖(F1 没有 OVR 标志中断这个保护,数据无声丢失),DMA 少搬一次,缓冲区从丢失点开始整体错位。239.5 档扫描周期 84μs 远超 50μs 的触发周期,完整率掉到 78.4% 且读数错乱,与模型完全一致。反过来,采样时间也不能一味压短------它是给采样电容充电的窗口,高阻信号源在 1.5 周期下会充不满,这个权衡在前作里已经量化过,这里不再展开。

6.3 触发频率极限扫描:理论 vs 实测(对照二)

固定 71.5 周期采样时间(本项目的基准配置),从低到高扫触发频率,理论极限 = 1 ÷ 28.0μs = 35.7kHz:

触发频率设定 HT+TC 中断实测(次/秒,理论值×2) 数据完整率 现象
1kHz 2000 100% 正常
10kHz 20000 100% 正常
30kHz 60000 100% 正常
35kHz 70000 100% 逼近临界
40kHz 72640(理论 80000) 90.8% 丢点 + 通道整体错位

实测临界落在 35~40kHz 之间,与理论 35.7kHz 吻合。35kHz 档中断率已经 70k 次/秒,主循环里多做一件事都会挤占中断响应,这个档位其实已经接近 F103 的实用边界------再往上应该考虑 ADC 双规则组并行或换 F4/H7。顺带把精度也交代了:12 位理论 LSB = 0.806mV(VREF=3.30V),实测固定直流输入下 ENOB 约 10.3 位,瓶颈是板载 LDO 纹波和采样保持噪声;叠加 16 点均值后有效位数约 11.2 位,与"平均 N 点提升约 ½log₂N 位"的经典估计一致。

6.4 CPU 占用对比

DWT->CYCCNT 统计法:记录单位时间内处理代码占用的周期数除以总周期数。10kHz × 4 通道、同样输出均值的前提下:

驱动方式 中断/介入次数(次/秒) 实测 CPU 占用
轮询 DR(触发后忙等) 10000 轮忙等 28.3%
EOC 中断逐通道收数 40000 8.6%
TRGO + DMA 乒乓(128 半字) 625 0.4%

轮询方案的 28.3% 看起来还能接受,但它有个隐藏成本:忙等期间主循环完全停摆,任何实时任务都会被拖累。DMA 方案的 0.4% 里还包括均值计算,把缓冲加大到每半 64 轮还能再降一个量级,代价是处理延迟变大------缓冲深度的选择本质是"中断频率 vs 数据新鲜度"的取舍,按控制周期需求定即可。

七、故障排查实录(6 类)

7.1 DMA 计数始终为 0:TRGO 触发链断了

症状:HAL_ADC_Start_DMA 返回 HAL_OK,但每秒打印的撕裂计数和 HT/TC 中断计数全是 0,缓冲区 128 个半字保持初始值,ADC 像根本没被触发过。

排查链:最初怀疑 TIM3 没有运行,于是临时把 PA6 复用为 TIM3_CH1 输出一路 PWM(用逻辑分析仪探针测量),方波正常出现,排除定时器本体问题。然后怀疑 触发源选错,翻参考手册 RM0008 Table 66 核对,ADC1 规则组外部触发里 TIM3_TRGO 对应 EXTSEL=100,CubeMX 里也确实选了 Timer 3 Trigger Out event,看起来没错。再往下查 才发现 EXTSEL 只回答了"收到脉冲算触发",没有回答"TIM3 到底发不发脉冲"------读 TIM3_CR2,MMS2:0 是默认的 000(Reset),而 TRGO 需要的是 010(Update)。Reset 事件只在计数器复位那一瞬间产生一次,定时器自由运行期间 TRGO 一根脉冲都不发。根因 :CubeMX 的 Trigger Event Selection 默认值没有随"被选为 ADC 触发源"联动修改。修复 :MMS 改 010。验证:HT/TC 中断计数立刻恢复到每秒 2000(1kHz 触发 ×2),数据完整率 100%。

这是本文我认为最值得记住的一链:触发链要一段一段验------定时器在跑(看输出引脚)→ TRGO 有脉冲(MMS 位)→ 触发源匹配(EXTSEL 位)→ DMA 在搬(看缓冲区)。任何一环断掉,症状都是"全 0",很容易误判成硬件坏了。

7.2 触发率超极限:丢点与通道错位

症状:40kHz 档下 PA0 固定电压的读数周期性跳变,均值后仍出现四路数据"整体轮转"。

排查链:先用 6.3 的中断计数法确认丢点(72640/80000 = 90.8%),排除接触不良;把采样时间从 71.5 改到 13.5 周期后完整率回到 100%,锁定"扫描周期 > 触发周期"。F1 的 ADC 在 DR 被覆盖时不会置任何可查询的错误标志(F4 系列有 OVR 位并会停住 DMA,行为不同),所以只能靠计数间接发现。根因 即 6.2 的扫描周期模型。解决 :降触发率,或缩短采样时间,或把 4 个通道拆到 ADC1/ADC2 双规则组并行(扩展方向)。验证:改回 30kHz + 71.5 周期,tear 与丢点均为 0。

7.3 全通道固定偏移约 10 LSB:校准遗漏

症状:上电后四路读数全部偏高 8~12 LSB,约 7~10mV,换板子偏差值不同。排查时先怀疑分压电阻精度,换精密基准源依旧偏;逻辑分析仪抓 DMA 数据发现是"整体平移"不是"波动",指向系统性偏差而非噪声。根因是初始化代码从一篇 F4 教程抄来,没有 HAL_ADCEx_Calibration_Start。补上后偏差回到 ±1 LSB 以内。验证方法:同一引脚接同一基准,校准前后各读 1000 点取均值对比。

7.4 乒乓缓冲数据撕裂:printf 挤爆预算

症状:1kHz 档一切正常,切到 10kHz 档后 tear 计数每秒增长约 600,串口输出偶发出现新旧数据混帧。

排查链:tear 递增说明两次 HT/TC 之间上一半还没处理完,即处理耗时超过 1.6ms 的半缓冲预算。逐段用 DWT 停表测量:均值计算 0.11ms,帧打包 0.06ms,都很小;唯独 App_Frame_Send 里每帧附带打印 64 行原始值,115200bps 下约 167ms------超预算两个数量级。根因 :把调试打印留在了处理临界区里。修复 :临界区只打包均值,原始数据先攒进另一个缓冲、由低优先级慢速上传,或干脆只发均值。验证:tear 回零,DWT 实测处理总耗时 0.38ms,占预算 24%。

7.5 首半缓冲数据异常:启动顺序与首轮丢弃

症状:每次复位后的第一个 HT 数据明显不对,像是半旧半新。查到第二个根因:初始化时先调用了 HAL_TIM_Base_Start 再调 HAL_ADC_Start_DMA,首个 TRGO 触发时 DMA 尚未挂上,样本丢失导致后续整体错位一轮。修正顺序(第四节第二步)后,仍建议保留"首轮丢弃":校准刚结束时前几个样本受内部状态影响,工程上简单丢弃第一个 HT 再开始使用数据最稳妥。

7.6 两路读数互换:引脚与通道号对不上

症状:PA4 和 PA5 两路数据互换了。STM32F1 的通道号与引脚映射是 IN0~IN7 对应 PA0~PA7、IN8=PB0、IN9=PB1,而 CubeMX 的 rank 列表只显示通道号。把 IN4、IN5 的物理接线误接到了 PB0/PB1(对应 IN8、IN9),rank 序列里排的是 IN4/IN5,读到的自然是"空"通道附近的串扰。对照数据手册的引脚-通道映射表重新接线后恢复。排查这类问题最快的办法:给两个通道分别接固定电压(如 1.0V 和 2.5V),看缓冲区里两个下标的值分别是谁,一测便知。

相关阅读:《STM32 ADC 实战指南:从基础配置到多通道 DMA 采集优化》 --- 配置层面的避坑清单,可作本文的入门前置。

八、总结

核心要点回顾:定时器触发模式把采样节拍的控制权从 ADC 时钟手里收回来,一个 TRGO 脉冲扫一轮,CONT 位必须清零;F1 的 DMA 通道与触发源都是硬连线映射(DMA1_Channel1 ↔ ADC1、EXTSEL=100 ↔ TIM3_TRGO),配置前先查 RM0008 的映射表;乒乓缓冲把"处理不得与 DMA 抢内存"转化为明确的半缓冲时长预算,撕裂计数器是预算超限的自动报警;触发率上限由"采样+转换总时长"决定,71.5 周期采样时实测临界 35~40kHz,与理论 35.7kHz 吻合。

适用边界 :本文方案适用于 F1 系列、触发率 1kHz35kHz、48 通道的精确节拍采集;低于 1kHz 时直接用连续模式+抽稀更省事,高于 35kHz 应考虑 ADC 双规则组并行或换平台。F4/F7/H7 的 DMA 结构(Stream/Channel)、OVR 错误处理、通道选择机制都不同,代码需要重新映射,但乒乓缓冲的设计思想完全通用。

局限与已知问题:F1 没有溢出标志中断,丢点只能靠计数间接检测;采样时间参数扫描只覆盖了 71.5 附近的高阻信号场景,1.5 周期档对高阻源会充不满电,使用前请核对信号源阻抗;均值滤波引入 16 轮的群延迟,做反馈控制时要计入相位裕量。

扩展方向 :一是把触发链升级为 ADC 双规则组(ADC1+ADC2 同步注入模式),突破单 ADC 扫描周期对触发率的限制;二是把乒乓换成三缓冲环形队列,对接 FreeRTOS 的消息队列做跨任务分发;三是结合 DMA 硬件给出的精确节拍做等效时间采样(ETS),用低速 ADC 采高频周期信号。如果想往信号质量方向深入,可以继续读 《ADC 采样精度与电路设计全解析》,把前端电路这一环也补齐。如需获取本文完整代码和更多实战项目,可开通 CSDN 技术会员。

版本备注

  • 硬件平台:STM32F103C8T6 最小系统板(HSE 8MHz)+ ST-Link V2 + 24MHz 8 通道逻辑分析仪 + 函数发生器/精密分压组
  • 软件版本:STM32CubeIDE 1.17.0 + STM32CubeMX 6.12.1 + STM32CubeF1 HAL 库 1.8.5
  • 兼容说明:F1 系列(F103/F102)可直接移植;F4 及以上需改用 DMA2 Stream0/Stream4(Channel 0)并重写 OVR 错误处理;触发源若改用 TIM2/TIM4,需同步修改 EXTSEL 与 MMS 配置
  • API 变更风险:HAL_ADCEx_Calibration_Start 为 F1 HAL 专有,F4 HAL 中不存在;HAL F1 1.8.x 的 ADC DMA 回调函数签名与 1.6.x 一致,但 CubeMX 5.x 生成工程时 DMA 句柄命名(hdma_adc1)与 6.x 生成工程的文件组织有差异,移植时以 CubeMX 重新生成为准

参考资料

  1. ST, RM0008 Reference Manual: STM32F101xx, STM32F102xx, STM32F103xx --- Table 66(规则组外部触发选择)、Table 78(DMA1 请求映射)、ADC 章节、TIM 章节
  2. ST, AN3116 STM32's ADC modes and their applications --- 触发模式与双 ADC 同步的官方说明
  3. ST, STM32F103xx Datasheet --- ADC 12 位特性、ADC 时钟上限、ENOB 相关电气参数
  4. 相关阅读:《STM32 多通道 ADC + DMA 采集实战:数据错位、读数跳动的根因与滤波选型》 --- 本文前作,连续转换模式下的错位与滤波问题
  5. 相关阅读:《STM32 多通道 ADC 采集实战:DMA 模式高效数据搬运》 --- DMA 搬运机制的基础讲解
  6. 相关阅读:《STM32 多通道 ADC 扫描与 DMA 协同采集原理与实践》 --- 规则组扫描机制的原理视角
相关推荐
iCxhust1 小时前
SEGGER 的 J-Scope
单片机·嵌入式硬件
路上有只喵3 小时前
认识元器件 · 单片机(MCU/MPU/SOC) · 实战篇2
单片机·嵌入式硬件
单片机仿真设计3 小时前
Proteus仿真51单片机的智能温控风扇调速系统设计(仿真图+程序)
单片机·嵌入式硬件·proteus
youcans_5 小时前
【嵌入式软件AI编程】22. STM32 基础实验:GPIO外部中断
stm32·单片机·嵌入式硬件·ai编程·claude
未来_攻城狮5 小时前
STM32F407VGT6 芯片详解:高性能 Cortex-M4 MCU 入门指南
stm32·单片机·嵌入式硬件
xcLeigh5 小时前
FreeRTOS实战:用STM32+SEGGER打造工业级实时嵌入式系统
人工智能·stm32·单片机·嵌入式硬件·ai
硅基造物主5 小时前
STM32 从 0 到 1 做智能家居(十二):USART2 分时复用 —— ESP-12F + MQTT
网络·stm32·mqtt·智能家居·esp-12f
路上有只喵6 小时前
认识元器件 · 单片机(MCU/MPU/SOC) · 实战篇1
单片机·嵌入式硬件
芯岭技术郦6 小时前
XL4460 FSK 无线透传模组
stm32·单片机·嵌入式硬件