TIM2+DMA+ADC组合:STM32高频连续采样的零CPU负担实现

发布时间:2026/10/14 19:04:12
TIM2+DMA+ADC组合:STM32高频连续采样的零CPU负担实现
简介面向STM32嵌入式开发者这份资源提供一个基于HAL库的STM32F411CEU6完整工程核心演示TIM2定时器、DMA与ADC三者的协同配置实现每毫秒轮询三个ADC通道的实时采样方案适用于环境监测、传感器数据采集等需低延迟多通道模拟信号读取的场景。工程共1148个文件以C源码、H头文件、汇编启动文件、链接脚本及编译输出.o、.axf、.hex、.map为主另含IAR/Keil工程配置、CubeMX初始化文件等压缩包约20.23MB目录结构完整可直接导入开发环境对照学习。已有998人学习下载适合需要掌握定时器触发ADC、DMA搬运数据及HAL库中断回调机制的开发者。资源中可看到完整的初始化代码、中断服务函数、DMA传输配置、外设时钟与采样序列设置并附有编译链接产物便于逐段验证时序与采样结果是理解多外设联动和高效数据采集的实用范例。1. 一个能把 50kHz 连续采样压到 CPU 零负担的组合STM32F411CEU6 里的 TIM2DMAADC第一次看到这个压缩包的人多半会把名字里的 TIME2 当成笔误确实STM32 里只有 TIM2没有 TIME2。这个工程的含金量就一句话用定时器 2 产生稳定的采样节拍ADC 在节拍到来时自动开始转换转换完成后 DMA 把结果搬进内存数组全程不用 CPU 干预。我在没有用这个方案前用软件触发做 50kHz 采样时主循环基本被拖死中断一抖数据就错位换成 TIM2DMAADC 之后CPU 只在缓冲区半满时介入一次占用率直接掉到个位数。适合已经会点灯、会串口打印、想把连续等间隔采样做成真功夫的从业者。如果你只是偶尔读一次传感器软件触发就够了没必要看这篇文章。但如果你需要 10kHz 以上连续采样还希望主循环有空跑算法、跑协议、刷显示那就是这个工程解决的问题把 CPU 从“等待转换完成”里解放出来。下面我从链路原理讲到具体参数把这条链路上最容易被忽略的几个坑一次说清。2. TIM2DMAADC 三角色链路为什么这个组合在周期采样里比中断方案稳2.1 三个外设怎么分工谁触发谁、谁搬数据先从数据流的角度理解这三位。TIM2 是一个 32 位通用定时器它的计数器从 0 数到 ARR数满溢出时产生更新事件并把更新事件通过 TRGO 引线送出去ADC 配置为外部触发模式后收到这个脉冲就开始一次转换转换结束后数据寄存器 DR 里出现一个 12 位结果同时 EOC 标志置位。如果你把 DMA 请求使能打开DMA 控制器会在这个时刻自动把 DR 的值搬运到内存数组。CPU 从头到尾没有参与搬运它要做的只是在缓冲区半满或全满的时候处理一次数据。这就是这个组合省 CPU 的本质。要特别强调一点这种触发模式下的 ADC 不应该配置成连续转换。很多人第一次配的时候习惯性把 Continuous Conversion 打开结果 ADC 自己不停转DMA 跟不上缓冲区里全是乱序数据。在 CubeMX 里Continuous 要关掉External Trigger 要选中 TIM2 的触发输出转换模式用单次转换配合外部触发就行。三个角色和触发关系可以用一张表看明白外设职责关键输出TIM2产生采样节拍更新事件 - TRGOADC模拟到数字转换EOC、数据寄存器 DRDMA搬运 DR 到内存半传输/传输完成事件其中 TIM2 的更新事件触发 ADCADC 的 EOC 触发 DMA 搬运DMA 搬运完成后触发中断三个事件串成一条链环环相扣。如果换成传统做法——定时器中断里启动 ADC再在 ADC 中断里读结果CPU 会被打断两次。当系统里还有串口、协议栈或电机控制任务时中断服务程序的执行时间会直接挤占主循环采样间隔也会因为中断排队而抖动。TIM2DMAADC 把两次打断砍掉一次DMA 搬运不打断 CPU只有半满或全满时才产生一次中断而且每个缓冲区块的处理时间可以远大于单个采样间隔实时性压力小得多。这也是这个组合在电机电流采样和振动监测里成为常客的原因。2.2 触发源选型TRGO 更新事件为什么比 PWM 边沿触发更省心F411 的 ADC 触发源有很多个软件触发、定时器 TRGO、定时器通道比较输出、外部引脚都能拿来用。选哪种取决于你的采样节奏从哪来。如果节奏来自内部固定频率定时器 TRGO 是最稳的如果节奏要跟外部事件同步用外部引脚软件触发只适合低频和不要求等间隔的场合。触发方式采样间隔是否等长CPU 开销常见坑软件触发受指令调度影响易抖动高中断优先级高时会阻塞主循环定时器 TRGO更新事件严格等长硬件生成无需注意 APB1 分频导致的定时器时钟倍数定时器 PWM 边沿严格等长但一周期两个沿无采样率变两倍外部引脚触发取决于外部信号无外部引脚抖动会误触发我一般会优先选 TRGO 更新事件。它每个计数周期只产生一次且 CNT 溢出时相位天然对齐到 ARR不会像 PWM 边沿那样在一个周期里出现两次触发。触发极性那边保持上升沿触发即可下降沿会带来一拍延迟在同步采集多个信号时这种相位差很麻烦。顺带说一个计算采样率的细节F411 的 TIM2 挂在 APB1 上当 APB1 预分频系数不等于 1 时定时器时钟是 APB1 的两倍。比如你把 HCLK 配到 84MHzAPB1 分频为 2那么 PCLK1 是 42MHzTIM2 的工作时钟却是 84MHz。很多人算采样率直接用 PCLK1 的 42MHz 去算结果实际频率是预期两倍这是这个方案里最容易翻车的位置之一。2.3 链路能跑多快先看转换时间再看定时器分频链路能跑多快取决于最短的板子。ADC 一次完整转换需要采样周期数加 12 个转换周期例如采样时间选 1.5 周期则总周期数是 13.5再除以当前 ADC 时钟频率就得到单次转换时间。TIM2 的触发通常不会成为瓶颈因为 32 位定时器分频后从 1Hz 到几 MHz 都能出。DMA 搬运一个半字16 位数据几乎不花时间在定时器触发频率到几百 kHz 之前带宽都不是瓶颈。还有一个细节TIM2 是 32 位定时器ARR 可以设到几十万级别这在低频采样场景里很有用。如果你要 1Hz 的采样率用 16 位定时器需要把预分频拉到很大预分频器本身有上限而 TIM2 通过大 ARR 就能轻松实现。不过大 ARR 意味着计数器溢出的间隔长更新事件频率低调试时如果你想立刻就触发一次采样可以调用一次强制更新事件。这个在后面的代码里会给出。另外一个常见误区是以为 DMA 缓冲越大越好。缓冲大确实能减少中断频率但半满中断到来时你要处理的数据块也大处理时间变长缓冲区溢出或丢数据的风险反而可能上升。缓冲大小要按你的平均处理速率来定而不是一味求大。这个权衡我在第四章的参数表里再展开。3. 把工程搭起来解压、核对时钟、用 CubeMX 把外设串成一条链3.1 拿到压缩包后先核对四类文件别急着编译拿到这个压缩包后先别急着打开 IDE 编译先看里面有没有工程文件、.ioc 配置、启动文件和 HAL 库源码这四类东西。一个典型的 CubeMX 工程会包含以下可见文件类别文件类别常见后缀需要确认什么CubeMX 配置工程.ioc芯片型号是否为 F411CEU6、外设是否打开编译器工程.uvprojx / .ewp / .cproject是否定义了 __FPU_PRESENT1启动文件路径是否正确启动文件startup_stm32f411xe.s中断向量与堆栈大小是否对应 F411CEU6链接脚本.ld / .icf / .sctFlash 和 RAM 地址是否匹配 F411CEU6F411CEU6 是一颗带 FPU 的 M4 芯片启动文件必须对应 stm32f411xe 系列如果工程里混入 f103 或 f401 的启动文件烧进去的表现就是莫名其妙死机或外设不响应。无论压缩包里是什么建议都对照一下这几个地方再做下一步。如果解压后没有源码只有已经编译好的镜像直接烧录看现象是最快的但后面要改采样率还是会回到源码所以建议还是从工程源码过一遍。3.2 CubeMX 关键配置时钟树、TIM2、ADC、DMA 四个面板一次设对在 CubeMX 里把这四个面板按顺序过一遍基本不会漏。第一是时钟树System Clock 设到 84MHz 或 100MHzAPB1 Prescaler 设 2APB2 Prescaler 设 1 或 2然后看 CubeMX 时钟树里 TIM2 的时钟频率把它记下来后面算采样率要用。第二是 TIM2Counter Settings 里设 Prescaler、Counter Mode 选 Up、Counter Period 就是 ARRauto-reload preload 打开然后在 Trigger Output (TRGO) 选项里选 Update Event。第三是 ADC1Resolution 选 12 位右对齐Continuous Conversion 一定是 DisableExternal Trigger Source 选 Timer Trigger Out event再在 DMA Settings 里添加 ADC1 的 DMA 请求。第四是 DMA确认 Mode 是 Circular数据宽度外设和内存都选 Half Word内存地址递增打开。面板配置项建议值SYSDebugSerial WireRCCHSE外部晶振或旁路按板子选择TIM2Prescaler / Counter Period按目标采样率算见第四章公式TIM2Trigger Output (TRGO)Update EventADC1Continuous ConversionDisableADC1External Trigger SourceTimer 2 Trigger Out eventDMAModeCircularDMAData WidthHalf Word / Half Word / 地址递增这里有两个容易配错的地方ADC 的 External Trigger Polarity 默认 Rising Edge 就行但一定要确认选的是上升沿下降沿触发会有一拍延迟另一个是如果板子上没有外部晶振时钟源只能选 HSI但 HSI 的频率精度比晶振差不少做 FFT 这类应用时谱峰位置会偏移这个坑在第五章单独说。3.3 生成代码后的最小验证串口打印第一个 ADC 值CubeMX 生成代码后把 ADC1、TIM2 初始化函数和 DMA 初始化函数按顺序调用然后先启动 DMA 再启动定时器。最小验证代码大概是这个样子#define ADC_BUF_LEN 512 uint16_t adc_buf[ADC_BUF_LEN]; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_ADC1_Init(); MX_TIM2_Init(); // 先启动 ADC 的 DMA再启动定时器 HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buf, ADC_BUF_LEN); HAL_TIM_Base_Start(htim2); while (1) { // 在调试器 Live Watch 里观察 adc_buf[0] 是否在变化 } }启动顺序不要反过来。先让 DMA 挂上 ADC再启动定时器否则第一次触发事件到来时 DMA 通道可能还没就绪丢点只会在启动瞬间发生但会让人排查半天。验证时最直接的办法是打开调试器的 Live Watch看 adc_buf[0] 是否在按节拍跳变也可以用串口每秒打印一次 adc_buf[0] 的值用时间差判断整秒别用HAL_GetTick() % 1000 0这种写法主循环一忙就会跳帧。4. 参数与代码采样率公式、启动顺序、双缓冲回调4.1 采样率公式与四组可直接抄的定时器配置采样率计算公式只有一个无论 CubeMX 里 TIM2 时钟显示多少都按这个算f_sample f_TIM2CLK / ((PSC 1) * (ARR 1))f_TIM2CLK 是 TIM2 的工作时钟也就是第三章记下来的那个值。PSC 是预分频器ARR 是自动重载值。我把 84MHz 下的四组常用参数列出来直接抄就行目标采样率PSCARR实际采样率100kHz0839100kHz50kHz0167950kHz10kHz0839910kHz1kHz839991kHz如果你的 TIM2 时钟是 100MHz把 84 换成 100 重算一次PSC 和 ARR 相应调整。比如 100MHz 下想得到 50kHzPSC0ARR1999 即可。注意PSC 和 ARR 修改后不会立刻生效要等一次更新事件。如果你在运行中动态改采样率改完最好强制触发一次更新事件避免参数写了没生效这种玄学问题。我一般会专门写一个 set_sample_freq 函数里面同时改 PSC、ARR 并触发一次更新事件。预分频器要等到更新事件后才会重新计数只改半边容易造成一段时间的采样率漂移。4.2 先启动 ADC 的 DMA 再启动 TIM2完整主循环代码把第三章的最小验证扩展成完整结构加上半满和全满标志主循环里只处理数据块#define ADC_BUF_LEN 1024 uint16_t adc_buf[ADC_BUF_LEN]; volatile uint8_t half_flag 0; volatile uint8_t full_flag 0; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_ADC1_Init(); MX_TIM2_Init(); // 先启动 ADC 的 DMA再启动定时器 HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buf, ADC_BUF_LEN); HAL_TIM_Base_Start(htim2); while (1) { if (half_flag) { half_flag 0; ProcessBuffer(adc_buf[0], ADC_BUF_LEN / 2); } if (full_flag) { full_flag 0; ProcessBuffer(adc_buf[ADC_BUF_LEN / 2], ADC_BUF_LEN / 2); } } }adc_buf 必须放在 RAM 里并且建议按 32 位字节对齐编译器通常会自动对齐但如果你自己声明局部大数组要注意栈空间。HAL_ADC_Start_DMA 的第三个参数是数据个数不是字节数这里传 1024 表示搬 1024 个半字。ProcessBuffer 里不要做浮点或复杂运算只做搬数据、算均值、置标志真正算法放主循环。半满和全满回调是 HAL 库提供的两个弱函数在 DMA 模式下自动被调用void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { half_flag 1; // 前半块数据就绪 } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { full_flag 1; // 后半块数据就绪 } }HAL 库在 DMA 模式下每搬到一半和全部完成都会触发一次回调Circular 模式下它们交替触发。注意这两个回调运行在中断上下文只置标志位最保险。如果直接在回调里调 ProcessBuffer 且里面跑浮点一旦采样率高中断嵌套会影响其他外设。4.3 半满/全满回调做双缓冲以及动态切通道的注意点半满和全满回调天然形成双缓冲前半块处理的同时 DMA 正在向后半块写数据后半块处理时 DMA 回过来写前半块。只要 ProcessBuffer 的执行时间远小于半块的时间就不会互相干扰。这是一个很多人没意识到的省内存技巧。缓冲区长度怎么定采样率 50kHz每块 1024 个点全块时间约 20.48ms半块约 10.24ms只要 ProcessBuffer 在 10ms 内完成就安全。如果你的算法需要 20ms就把缓冲区扩大到每块 2048 点半块时间拉长到 20ms。处理不过来时优先加大缓冲而不是优化算法。动态切通道时我一般会这样写void SetAdcChannel(uint32_t channel) { ADC_ChannelConfTypeDef sConfig {0}; sConfig.Channel channel; sConfig.Rank ADC_REGULAR_RANK_1; sConfig.SamplingTime ADC_SAMPLETIME_15CYCLES; HAL_ADC_ConfigChannel(hadc1, sConfig); }动态切通道的一个陷阱是DMA 正在循环时调用 HAL_ADC_ConfigChannel有可能下一次触发在配置完成之前到来导致这一轮采到旧通道或无效值。我一般会先 HAL_ADC_Stop_DMA切完再重新启动。多通道扫描时DMA 缓冲长度必须是通道数的整数倍比如两个通道就设 1024、2048 这种偶数半满中断切开的两个半块也都对齐到通道组否则数据处理时会把两个通道的数据当同一通道算。5. 避坑从全零数据到采样率翻倍的 5 个典型现象这五条是按出现频率排的前两条新人几乎必踩我自己也全部遇过。每一条按现象、原因、解决的结构写排错时直接对照。5.1 数据全零DMA 的 NDTR 寄存器动都没动现象信号源接到 ADC 引脚电压怎么调adc_buf 全是 0。不管采样率改成多少数据都不变。原因DMA 没有进入搬运。常见是 HAL_ADC_Start_DMA 没调用或者初始化顺序错误另一个常见原因是 ADC 的 DMA 请求没有开启只在 DMA 设置面板建了通道ADC 外设本身的 DMA 开关却是 Disable。解决看两处。第一用 IDE 的外设寄存器窗口打开 ADC1-CR2 的 DMA 位看是否为 1。第二看 DMA 通道寄存器的 NDTR启动后如果 NDTR 一直等于初值不递减说明通道没有收到请求。寄存器窗口里看到 NDTR 在变化数据就一定在搬。还有一个容易漏的原因你开了 DMA 但启动时用了普通的 HAL_ADC_Start 而不是 HAL_ADC_Start_DMA这个 API 不会开启 DMA 通道表面上采集函数返回正常实际没有搬运。5.2 采样率变成预想的两倍你用了 PWM 边沿触发现象设定的采样率是 50kHz示波器看波形或 FFT 峰值落在 100kHz 上。原因ADC 的触发源选了 TIM2 的捕获比较输出OC/CC而不是 TRGO 更新事件。PWM 在一个计时周期里会有两个边沿一个在 CCR 匹配处一个在 ARR 更新处每个边沿都可能触发 ADC所以采样率翻倍。有些配置里你把 CCR 设成接近 ARR两个沿间隔很近数据看起来像成对重复FFT 上就多了频率混叠。解决ADC 的外部触发源选 Timer Trigger Out event也就是 TRGO然后在 TIM2 的主从设置里把 Trigger Output 设为 Update Event。别用 Capture Compare Channel。改了之后如果发现触发频率还是不对再检查触发极性是不是在下降沿更新事件本身是边沿信号上升沿触发是常规选择。5.3 数据周期性错位传输宽度与缓冲区地址没对齐现象输入一个固定电压数据大体稳定但每隔几个点跳一次或和相邻通道串位。看着不像噪声像规律性的错位。原因DMA 传输单位配置不一致。F411 的 ADC 数据寄存器 DR 是 32 位但低 16 位有效如果你在外设侧设了 Half Word 而内存侧设了 Word每次搬运一个 32 位结果被塞进了高 16 位的位置反过来则只搬了 16 位高 16 位残留上次数据。多通道扫描模式下内存地址递增如果按 Byte 单位数据点会互相挤位。解决外设和内存的数据宽度统一为 Half Word或者统一为 Word 后读低 16 位。别在 DMA 配置里一个选半字一个选字节。调试时看 adc_buf 里值的分布规律如果高位总是 0xFFFF 或乱跳基本就是宽度不匹配。5.4 停止采集后 DMA 还停不下来顺序错了现象调用 HAL_TIM_Base_Stop 后DMA 好像还在搬串口还在出数据或者重新启动后第一帧数据是上一轮残留。原因停止顺序反了。定时器停止只禁止了未来的触发但 DMA 的循环模式并没有解除ADC 的 DMA 请求还挂着另外在停止瞬间 TRGO 可能正好来了一下ADC 完成转换后 DMA 又执行一次传输。解决固定顺序先 HAL_ADC_Stop_DMA再 HAL_TIM_Base_Stop。启动顺序反过来先 HAL_ADC_Start_DMA再 HAL_TIM_Base_Start。如果你还改了采样率顺手调用一次更新事件强制同步__HAL_TIM_SET_AUTORELOAD(htim2, new_arr); __HAL_TIM_SET_PRESCALER(htim2, new_psc); HAL_TIM_GenerateEvent(htim2, TIM_EVENTSOURCE_UPDATE);5.5 FFT 底噪高、谱峰偏移时钟源和采样时间的锅现象输入一个干净的正弦波FFT 峰值不在设定频率上或者谱线两边有较宽的裙边。原因三个常见来源。第一用了 HSI 内部时钟内部 RC 时钟约百分之几的漂移50kHz 采样率会漂到 49kHz 到 51kHzFFT 峰值跟着偏。第二ADC 采样时间太短源阻抗高导致采样电容电荷补充不足波形带失真。第三VDDA 和 VREF 上有电源噪声尤其板子用了廉价 LDO 和劣质去耦电容时。这三个问题往往同时存在。解决要频率准确就改用外部晶振并通过 PLL 把 HCLK 锁定然后把 APB1 分频关系固定下来。高阻信号源比如大电阻分压、传感器直连把采样时间从 1.5 周期提到 7.5 周期甚至 15 周期代价是最大转换率下降。供电再给 ADC 的 VDDA、VREF 和地之间加 100nF 加 10uF 去耦实测毛刺能小一半。如果你做的是电机电流采样这种毛刺最骗人看起来像谐波其实是电源噪声。6. 进阶验证用 FFT 给整条链路的采样率体检6.1 串口导出原始数据Python 侧做频谱分析把一段已知频率的正弦波接到 ADC 引脚用 DMA 采一帧数据通过串口导成 adc.bin然后跑下面的 Python 脚本import numpy as np raw np.fromfile(adc.bin, dtypenp.uint16) N len(raw) fs 50000.0 # 改成你的实际目标采样率 win np.hanning(N) spec np.fft.rfft(raw.astype(np.float64) * win) freq np.fft.rfftfreq(N, 1.0 / fs) idx np.argmax(np.abs(spec[1:])) 1 print(峰值频率: %.2f Hz % freq[idx])rfftfreq 的第二个参数是采样间隔即 1/fs不需要手动构造横轴argmax 里从索引 1 开始是为了避开 0Hz 直流分量。峰值频率和输入信号频率对上说明定时器分频、触发链路、DMA 搬运全部正常如果峰值偏了 2% 左右基本可以断定时钟源用的是 HSI 或 APB 分频关系算错如果峰值周围出现明显旁瓣优先查采样时间和电源去耦。6.2 另一个硬件习惯把定时器脉冲引出来数频率我现在的习惯是调试期把 TIM2 的更新事件用中断翻转一个 GPIO 引出到示波器注意只用中断来测频率会有一点抖动但够数整数值先确认触发频率真的等于你的目标采样率再相信 FFT 的结果。每次改完 PSC 或 ARR先跑一遍这套验证再写业务代码别让错误采样率的数据进了算法后期排查成本高得多。这条 TIM2DMAADC 链路一旦配顺后面换通道、改采样率、加 DMA 都很顺手。希望帮到你。本文还有配套的精品资源点击获取