欢迎光临
我们一直在努力

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

文章目录

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

TIM3PSC=71 ARR=999MMS=010 (Update)

ADC1 规则组SCAN=1 EXTSEL=100CH0→CH1→CH4→CH5

DMA1_Channel1Circular 128×halfword

HT 中断处理前半缓冲

TC 中断处理后半缓冲

乒乓处理均值/打包/上传

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

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

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

对比维度轮询读取 DREOC 中断逐通道收数定时器 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 里三步配完,每步对应的寄存器位写在括号里:

  • Prescaler = 71(TIM3_PSC,实际分频系数 = PSC+1);
  • Counter Period = 999(TIM3_ARR,向上计数从 0 到 ARR 后产生更新事件);
  • Trigger Event Selection = Update Event(TIM3_CR2 的 MMS[2:0] = 010,把更新事件作为 TRGO 输出)。
  • 这里最容易漏的就是第三步。CubeMX 里 TRGO 默认是 Reset(MMS=000),而 Reset 事件只在计数器被复位时产生一次,定时器自由跑起来之后 TRGO 上一根脉冲都没有——这个坑我在 7.1 节花了整整一段排查链来讲。

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

    ADC 时钟取 PCLK2 的 6 分频,即 12MHz(RCC_CFGR 的 ADCPRE[1: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 的 EXTSEL[2: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 的 L[3: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 中断)时反过来。两边永远不碰同一块内存,处理耗时就有了一个明确的预算上限——半缓冲时长。

    /* 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 侧读它时需要阻止编译器把重复读取优化掉;而中断与主循环的握手靠下面这个单字节标志完成:

    /* 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 里做耗时操作——这是预算模型成立的前提。

    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,处理函数稍有拖延就会撞上下一次握手。

    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轮写满前半 buf[0..63]

    第1轮触发

    CH0→CH1→CH4→CH5 依次 EOC

    写 buf[0..3]

    第2轮触发

    写 buf[4..7]

    HT 中断(前半就绪)

    处理 buf[0..63](预算1.6ms@10kHz)

    第17~32轮触发

    DMA 写后半 buf[64..127]

    TC 中断(后半就绪)

    处理 buf[64..127],DMA 回绕写前半

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

    5.3 主循环与撕裂观测

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

    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,MMS[2: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 重新生成为准

    参考资料

  • ST, RM0008 Reference Manual: STM32F101xx, STM32F102xx, STM32F103xx — Table 66(规则组外部触发选择)、Table 78(DMA1 请求映射)、ADC 章节、TIM 章节
  • ST, AN3116 STM32’s ADC modes and their applications — 触发模式与双 ADC 同步的官方说明
  • ST, STM32F103xx Datasheet — ADC 12 位特性、ADC 时钟上限、ENOB 相关电气参数
  • 相关阅读:《STM32 多通道 ADC + DMA 采集实战:数据错位、读数跳动的根因与滤波选型》 — 本文前作,连续转换模式下的错位与滤波问题
  • 相关阅读:《STM32 多通道 ADC 采集实战:DMA 模式高效数据搬运》 — DMA 搬运机制的基础讲解
  • 相关阅读:《STM32 多通道 ADC 扫描与 DMA 协同采集原理与实践》 — 规则组扫描机制的原理视角
  • 赞(0)
    未经允许不得转载:171主机测评 » STM32 ADC多通道DMA定时器触发实测:TRGO触发链、采样率精确控制与乒乓缓冲落地
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址