简介:STM32H743定时器触发固定频率ADC采样(DMA方式)的完整工程,配套STM32CubeMX配置教程,适合使用HAL库的初中级嵌入式开发者。工程内含CubeMX生成的ioc配置、Keil项目及定时器/ADC/DMA相关HAL驱动源码,清晰演示定时器触发ADC转换、DMA自动搬运结果至内存的完整链路,有效解决高频采样时CPU占用率过高的问题。资源共163个文件,以c/h源码为主,兼有hex/axf可执行文件与Keil工程配置,整包仅8.56MB。目前已有1558人浏览学习。下载后可直接编译烧录验证;对照CubeMX配置步骤,可深入理解定时器触发采样、DMA中断与初始化时序的设计思路,为后续高速数据采集项目提供坚实参考。
1. 一个 RAR 背后的三件事:先搞清 TIM、ADC 与 DMA 是怎么串起来的
做某控制项目时,我用 STM32H743 采集电流传感器的输出,最初是定时器中断里启动 ADC,转换完再把数据读出来。采样率一提到 100kHz,CPU 就被打断得毫无脾气,波形上还时不时冒出毛刺,FFT 底噪怎么压都压不下去。后来改成“定时器产生触发,ADC 自己转换,DMA 自动搬运”,CPU 彻底腾出来了,数据也干净了。这个 RAR 干的就是这件事:在 H743 上搭一条从 TIM 到 ADC 再到 DMA 的完整采样链路,适合电机控制、振动分析、音频采集这类对采样时刻敏感的场景。想快速起步的开发者,能直接从这个包里看到寄存器配置、CubeMX 设置以及启动代码的顺序,少踩好几轮坑。
2. 为什么必须用定时器触发:主从模式和硬件实时链路
2.1 定时器 TRGO 是怎么把事件送到 ADC 的
H743 的定时器内部有一个触发输出叫 TRGO,它可以把定时器产生的更新事件、比较事件等映射到芯片内部的触发网络。ADC 的外部触发源里就包含 TIM 的 TRGO,配置好主从模式后,定时器每溢出一次,ADC 就自动开始一次转换。这个过程中 CPU 完全不参与,所以采样点之间的时间间隔由定时器的硬件计数器决定,而不是由中断响应延迟决定。
从代码上看,CubeMX 生成的主从模式配置是这几行:
sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_ENABLE; sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE; htim1.Init.Period = 999; /* ARR = 999 */ htim1.Init.Prescaler = 0; /* PSC = 0 */这里把MasterSlaveMode使能,MasterOutputTrigger设置为TIM_TRGO_UPDATE,意思是定时器计数到 ARR 后产生更新事件,并把这个事件作为触发信号送出去。Period这个参数很直观:定时器从 0 数到 999,正好 1000 个计数周期触发一次。触发频率算起来也不复杂。
2.2 触发频率和 ADC 转换时间怎么匹配
定时器触发频率的公式是:
Fs = TIM_CLK / ((PSC + 1) * (ARR + 1))比如 H743 的定时器时钟是 480MHz,PSC = 0,ARR = 999,那么 Fs = 480000000 / 1000 = 480kHz。这个频率不能随便写,ADC 必须能在两次触发之间把上一次转换完成。H7 系列的 ADC 时钟上限约 36MHz,单次 12 位转换大概需要十几个 ADC 时钟周期,折算下来单通道采样率上限在 2MSPS 到 3MSPS 之间,具体看采样时间配置。
如果定时器触发频率设得太高,ADC 还没转换完,下一次触发就来了,结果就是上一组数据被覆盖,或者转换结果错位,采样率再快也没意义。比较稳妥的做法是把触发频率压在单通道采样率上限的三分之一以内,因为还要给 DMA 搬运和系统其他中断留余量。跑电机控制和音频采集这种场景,100kHz 到 500kHz 是比较舒服的区间。
选型上,为什么用 TIM 而不是用另一个定时器直接开中断再触发 ADC?原因在于中断响应存在不可预知的延迟,Cache 命中、中断优先级、任务切换都会让两个采样点之间的间隔抖动,这种抖动反映到频谱里就是底噪抬高。用硬件触发链路,抖动被限制在硬件计数器精度级别,后续做 FFT 分析才有意义。
3. CubeMX 配置到代码补丁:从工程生成到跑出第一组数据
3.1 时钟树和 ADC 时钟:先卡住 36MHz 上限
在 CubeMX 里配置 H743 时,最容易忽略的就是 ADC 时钟。时钟树界面里,ADC12 的输入时钟可以接 PLL2、PLL3 或某个 HCLK 分频,我一般直接选一个确定小于等于 36MHz 的时钟源,然后在 ADC 配置里再确认一次实际频率。
以常见的配置为例,ADC 参数可以这样设:
hadc1.Instance = ADC1; hadc1.Init.ClockPrescaler = ADC_CLOCK_ASYNC_DIV1; hadc1.Init.Resolution = ADC_RESOLUTION_12B; hadc1.Init.ScanConvMode = DISABLE; hadc1.Init.ContinuousConvMode = DISABLE; hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T1_TRGO; hadc1.Init.ExternalTrigConvEdge = ADC_EXTERNALTRIGCONVEDGE_RISINGEDGE; hadc1.Init.DMAContinuousRequests = ENABLE;ContinuousConvMode必须关掉,因为我们走的是外部触发;ExternalTrigConvEdge设为上升沿,定时器更新事件就是上升沿脉冲,比较直观。DMAContinuousRequests要打开,这样 ADC 每转换完一次就会自动发起一次 DMA 请求。
定时器这边,按照前面说的公式配好 ARR 和 PSC,比如要 100kHz:
htim1.Init.Period = 4799; /* 480000000 / 4800 = 100kHz */ htim1.Init.Prescaler = 0;3.2 生成代码后必须补的三处
CubeMX 生成的代码不会替你考虑启动顺序,也不会自动做 ADC 校准,更不会帮你把 DMA 半满中断和转换完成中断接好。这三处需要手动补。
第一处,ADC 校准必须在 ADC 使能前完成。H7 的 ADC 有硬件校准功能,不校准的话,前几次转换结果可能整体偏小或固定跳变。校准函数是这个:
HAL_ADCEx_Calibration_Start(&hadc1, ADC_CALIB_OFFSET, ADC_SINGLE_ENDED);ADC_CALIB_OFFSET表示做偏移校准,单端输入场景就用它;如果用的是差分输入,第二参数要换成ADC_CALIB_OFFSET_LINEARITY或对应模式,别照抄。
第二处,启动顺序是“先校准,再启动 ADC DMA,最后启动定时器”。代码顺序如下:
HAL_ADCEx_Calibration_Start(&hadc1, ADC_CALIB_OFFSET, ADC_SINGLE_ENDED); HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buffer, ADC_BUF_SIZE); HAL_TIM_Base_Start(&htim1);为什么不让定时器先跑?因为定时器一旦启动,第一个触发脉冲可能立刻到达,此时 ADC 和 DMA 如果还没完成初始化,那个采样点就丢了,更糟的是 DMA 缓冲区错位,后面所有数据看起来都像多了一个“鬼影”。我习惯在启动完成后加几十微秒延时,再开定时器,给触发链路留出稳定时间。
第三处,DMA 中断回调。HAL 库会回调两个函数,分别对应缓冲半满和缓冲满:
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { process_buffer(&adc_buffer[0], ADC_BUF_SIZE / 2); } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { process_buffer(&adc_buffer[ADC_BUF_SIZE / 2], ADC_BUF_SIZE / 2); } }这两个回调其实是乒乓缓冲的核心承接点,下一章展开说。需要注意的是,DMA 在循环模式下并不会从头开始,只有把内存地址复位逻辑交给硬件,让它自动回卷,半满中断和完成中断才会像是被“预先切好的两半”反复触发。
4. DMA 数据的处理与验证:从裸缓冲区到稳定波形
4.1 乒乓缓冲:让 CPU 和 DMA 错开时间用内存
循环 DMA 模式把一段缓冲区变成一个无限循环的搬运轨道。以ADC_BUF_SIZE = 2048为例,DMA 先把 ADC 结果搬到数组的 0 到 1023 号位置,然后触发半满中断,继续搬后面 1024 个;搬完整个数组后从头再开始。这中间有并行处理空间:CPU 在半满中断里处理前一半数据时,DMA 还在往后一半写数据,两边各用各的内存,不冲突。
这就是乒乓缓冲的核心思想,只不过 H7 的 DMA 半导体帮你完成了“翻面”动作。用表格看更清楚:
| 采样率 Fs | 缓冲区点数 | 半段点数 | 单段数据可用时间 |
|---|---|---|---|
| 100kHz | 2048 | 1024 | 10.24ms |
| 500kHz | 2048 | 1024 | 2.048ms |
| 1MHz | 2048 | 1024 | 1.024ms |
单段数据可用时间就是半满中断触发后,DMA 写满后半段所需的时间。如果process_buffer在这个时间内干不完,下次半满中断来时数据还没处理完,就会丢。处理函数里尽量只做数据搬移、简单换算和标志位,真正复杂的滤波或 FFT 放到主循环里。
半满回调里有个常见误区:有人把hadc->Instance判断写成ADC1,但实际上这个回调的hadc参数可能是 DMA 或模拟外设传递过来的句柄,最好先用if (hadc->Instance == ADC1)保住逻辑清晰,再在芯片支持多个 ADC 时分别处理,不然多路 ADC 工程里会串数据。
4.2 用示波器和 FFT 双重验证采样链跑没跑对
判断采样链是否正常的第一个动作,是把回调里只留一行 GPIO 翻转代码:
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_0); } }用示波器量这个引脚。数据每满半段翻转一次,所以翻转频率等于 Fs 除以半段点数的一半,具体点说,如果半满和完成回调里都翻转,那么每个缓冲周期翻转两次,波形频率 = Fs / ADC_BUF_SIZE。比如 Fs = 100kHz,缓冲区 2048 点,翻转频率大约是 48.8Hz;如果你测出来是 24.4Hz,说明 DMA 触发逻辑少了一半,多半是只进了完成回调,没进半满回调。
验证数据内容的方法更直接,把缓冲区数据导出来做 FFT。下面这段 Python 代码可以在主机上分析录下来的原始 uint16 数组:
import numpy as np data = np.fromfile("adc_capture.bin", dtype=np.uint16) voltage = data * 3.3 / 4096.0 spectrum = np.fft.rfft(voltage - voltage.mean()) db = 20 * np.log10(np.abs(spectrum) / np.abs(spectrum).max() + 1e-12) peak = np.argmax(np.abs(spectrum)) print("当前信号主峰频率 bin:", peak)喂一个干净的 1kHz 正弦波进去,如果主峰周围出现明显的边带或底噪抬高,就说明采样时刻的抖动很厉害。硬件触发链路正常时,底噪应该能压到 -70dB 以下;如果底噪在 -50dB 附近晃,优先检查 ADC 时钟超没超频,再检查定时器触发边沿是否稳定。
5. 避坑记录:H743 ADC 链路最常翻车的五个点
5.1 现象:ADC 时钟超限,采集数据乱跳
某次采集正弦波,顶部波形出现削波样的平顶,数值像随机数一样跳变。原因不是输入信号太大,而是 ADC 时钟配置超过了 36MHz。H743 的 ADC 精度指标建立在时钟上限内,超频后转换结果不可靠。解决方法是回到 CubeMX 的时钟树,把 ADC12 输入时钟降下来,我当时从 48MHz 改到 36MHz,乱跳立刻消失。
这个坑特别隐蔽,因为 CubeMX 界面对超频有时只给黄色警告,不一定报错。工程编译、下载都正常,数据就是脏。
5.2 现象:DMA 请求映射错误,缓冲区一直不更新
手动把 DMA 中断打开后,发现内存里始终只有前两次转换的数值,后面的数据一动不动。原因是在 H7 上,DMA1/DMA2 的请求映射比 F1/F4 系列复杂,ADC1 的请求并不自动绑到固定的 Stream 和 Channel,一旦选错,DMA 根本收不到 ADC 的请求。解决方法是查对应参考手册里 DMA 请求映射表,或者干脆把 CubeMX 生成的 DMA 配置代码原样搬过来,不手改 Mapping 字段。从那以后我再手动配 DMA 都会先对一遍映射表。
5.3 现象:实际采样率只有设定值的一半
定时器算出来明明是 100kHz,FFT 结果里信号频率却减半。原因通常出在 ARR 的计算上:Period是“计到多少才算一个周期”,要让定时器数 1000 个周期才溢出,Period要写 999,而不是 1000。还有人会在 PSC 上同样犯这个错,导致总分频系数多乘了 1。解决方法是把实际频率用公式回算一遍,并在 HAL_TIM_Base_Start 后用某个 GPIO 输出定时器溢出事件,用示波器实测核对。
5.4 现象:上电后第一帧数据全为 0 或固定值
校准没做,或者校准函数放错了位置。H7 的 ADC 校准必须在 ADC 还没有真正开始转换时执行,ADEN置位前调用HAL_ADCEx_Calibration_Start。如果放到HAL_ADC_Start_DMA之后,校准结果会受当前转换状态影响,首帧仍是坏的。解决方法是把校准放在HAL_TIM_Base_Start之前,并在启动后加一点延时。常见做法是上电后先初始化时钟,校准,延时 5ms,再启动 DMA 和定时器。
5.5 现象:中断里做了耗时操作,数据周期性地丢段
半满回调里加了printf和一次 SD 卡写入,采样率 500kHz,缓冲区 2048 点,单段处理时间只有 2ms,打印一次日志就超时。现象很规律:波形每段都缺一小截,或者缓冲区尾部数据被覆盖。解决方法是中断里只置一个标志位并把缓冲区指针存下来,在 while 主循环里统一处理。如果一定要在中断里搬数据,就开三缓冲,两段被 CPU 使用时,第三段还能继续接收 DMA 写入。
6. 移植到多通道和更高采样率:三个必改点与一次快速自检
想把这条链路从单通道 100kHz 改成四通道同步采样,或者把采样率拉高到 1MHz,只需要动三个地方。第一是定时器触发频率,改 ARR 和 PSC,但先确认 ADC 时钟下每个通道的转换总时长是否放得下。第二是 DMC 缓冲区的长度和 DMA 宽度,多通道扫描模式下缓冲区里是“通道 0 的第 1 次、通道 1 的第 1 次、通道 0 的第 2 次、通道 1 的第 2 次”这样交错排列,处理时按步长 2 或者步长 4 取数。第三是中断回调里的处理逻辑,半满中断对应的不再是单通道的 N/2 个点,而是 N/2 个采样序列,每个序列里包含全部通道的数据。
快速自检技巧是故意让一个 GPIO 跟着 DMA 完成中断翻转,用示波器量翻转周期。翻转周期如果跟着设定的 Fs 等比例变化,说明整条链路没有丢步。从那以后我每次搭 H743 采样工程,都强制走一遍“时钟超限检查、触发频率回算、校准启动顺序、GPIO 翻转验证、FFT 底噪查看”,一套下来基本不会再翻车。希望帮到你。
本文还有配套的精品资源,点击获取