ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32H743定时器触发ADC采样:TIM+DMA双缓冲实现高可靠采集

STM32H743定时器触发ADC采样:TIM+DMA双缓冲实现高可靠采集

简介:面向STM32H743嵌入式开发者的定时器触发ADC采样工程包,对应STM32CubeMX配置教程第十二章,解决固定频率下借助DMA搬运ADC数据的实现问题。压缩包内包含完整CubeMX工程配置文件、HAL库驱动源码、编译链接生成文件及调试信息,共163个文件,大小8.56MB。文件构成以C源文件、头文件、编译依赖文件及目标文件为主,涵盖定时器、ADC、DMA、RCC、电源管理等外设初始化代码,另有uvprojx/uvoptx等Keil工程配置与hex/axf烧录调试产物,便于直接查看整体工程组织方式并验证固件构建流程。目前已有1558人浏览学习,是STM32定时器触发采样场景中较受欢迎的参考资源。通过学习该包,可对照CubeMX生成逻辑与HAL驱动写法,理清定时器频率设定、ADC规则组转换及DMA循环传输的配合关系,尤其适合需要快速完成定时器与ADC/DMA联调的中高级嵌入式开发者,减少从零配置的试错成本。

1. 先讲结论:定时器触发采样,比在主循环里读 ADC 可靠得多

把 STM32H743_TIM_ADC_DMA 这套链路跑通,说穿了就是让 TIM3 定频触发 ADC1,ADC 转换完由 DMA1 自动把结果搬进内存,CPU 全程不参与搬运。做电机电流采样、电源控制、振动监测这类应用时,如果选择在 while(1) 里读 ADC,很容易被中断和任务调度打断采样节拍,波形上全是毛刺。这个工程解决的就是这个痛点:用硬件触发把采样时刻锁死,数据到了再通知 CPU。工程里最关键的设计是 DMA 循环模式配合半传输/全传输中断,相当于开了个双缓冲,前半段数据在处理时,后半段还在继续采。适合已经在嵌入式采集上踩过坑、想直接跑通一套可靠采样链路的开发者。

2. 架构设计:三个外设怎么分工,数据从触发到落地经历了什么

2.1 TIM、ADC、DMA 的职责划分与工作模式

整个工程的核心链路是 TIM3 产生触发事件,ADC1 收到触发后开始转换,转换完成后 DMA1 把结果从 ADC 数据寄存器搬到内存缓冲区。三个外设各干各的,互不打断,这是这套方案比中断采样稳定的根本原因。

外设工作模式职责
TIM3PWM Generation / Trigger Output定频产生触发事件,决定采样率
ADC1规则通道 + 外部触发收到触发后完成一次模数转换
DMA1Circular Mode把 ADC 转换结果自动搬运到内存缓冲

工程里 TIM3 用的是输出比较模式,通过 TRGO 事件把触发信号送给 ADC1。ADC 的触发源有很多种,定时器触发是采样节拍最稳的一种。ADC1 工作在规则通道组,单通道连续采样不需要连续转换模式,外部触发来了才转一次。DMA 用循环模式,搬满一半触发 Half 中断,搬满全部触发 Full 中断,这样两个缓冲区交替使用,CPU 在处理完一半数据时,后半段采样仍在持续运行。

2.2 解压后看到的工程结构

这个压缩包解压出来是标准的 CubeMX 生成工程,目录分层很清楚。Core 文件夹放的是 main.c、tim.c、adc.c、dma.c 这些用户代码,Drivers 是 HAL 库源码,MDK-ARM 下是 Keil 工程。

路径内容
Core/Src/main.c系统初始化、外设启动、主循环
Core/Src/tim.cTIM3 初始化与触发配置
Core/Src/adc.cADC1 通道、采样时间、外部触发配置
Core/Src/dma.cDMA1 流配置与中断使能
MDK-ARM/Keil 工程文件与链接脚本

这种结构的优势在于外设初始化代码按模块拆开,改采样率不用去翻整个 main 函数。实际二次开发时,大部分改动集中在 tim.c 的定时器周期和 adc.c 的通道配置上,dma.c 基本不用动。CubeMX 生成的代码虽然啰嗦,但胜在结构一致,排查问题的时候顺着模块走就行。

2.3 为什么这套工程选 H743 而不是 F1/F4

H743 在这条链路上的优势不是一点半点。主频 480MHz 是 F103 的四倍多,ADC 有 3 个独立内核,单核采样率能到 5Msps,而 F103 只有 1Msps 左右。做三相电流采样时,3 个 ADC 可以同时各采一相,这在 F1/F4 上是做不到的。

DMA 方面 H743 也更适合这种场景。它支持双缓冲模式和比较大的内存到内存传输,配合 TIM 触发可以做无 CPU 干预的持续采样。如果只是在 F103 上做单通道低速采集,这套架构有点浪费,但一旦采样率要推到每通道几百 K 甚至上 Msps,H743 的硬件资源就刚刚好。

3. 核心代码拆解:从初始化到 DMA 回调的完整链路

3.1 CubeMX 里的关键配置项

用 CubeMX 生成工程时,几个关键配置决定后面代码长什么样。TIM3 配置成 PWM Generation,选择输出比较通道,预分频和计数周期算出需要的触发频率。ADC1 打开一个规则通道,采样时间尽量选长一点,外部触发源选 TIM3 的 Trigger Out。DMA 请求里打开 ADC1 的数据搬运,模式选 Circular,数据宽度设成 Half Word,因为 ADC 结果是 16 位寄存器。

中断这块要单独注意。DMA1 对应流上要勾选 Half Transfer 和 Full Transfer 两个中断,NVIC 优先级在 CubeMX 里设好。很多工程只开了 Full 中断,结果采样率高的时候前半段数据处理不过来。还有一点,ADC 的 EOC 中断在 DMA 模式下可以不开,转换完成由 DMA 搬运即可,省掉一次不必要的中断开销。

3.2 ADC1 初始化代码与参数说明

打开 adc.c 能看到 CubeMX 生成的初始化函数,核心结构如下。

static void MX_ADC1_Init(void) { ADC_ChannelConfTypeDef sConfig = {0}; // 通道配置结构体 hadc1.Instance = ADC1; // 选择 ADC1 外设 hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV4; // ADC 时钟分频 hadc1.Init.Resolution = ADC_RESOLUTION_12B; // 12 位分辨率 hadc1.Init.ScanConvMode = DISABLE; // 单通道模式,不扫描 hadc1.Init.ContinuousConvMode = DISABLE; // 关闭连续转换,外部触发一次转一次 hadc1.Init.DiscontinuousConvMode = DISABLE; // 不分组转换 hadc1.Init.ExternalTrigConv = ADC_EXTERNAL_TRIGCONV_T3_TRGO; // 定时器 3 触发 hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; // 数据右对齐 hadc1.Init.NbrOfConversion = 1; // 规则通道组数量 HAL_ADC_Init(&hadc1); sConfig.Channel = ADC_CHANNEL_0; // 采集 PA0 引脚 sConfig.Rank = ADC_REGULAR_RANK_1; // 规则组第一个通道 sConfig.SamplingTime = ADC_SAMPLETIME_1CYCLE_5; // 采样时间设置 HAL_ADC_ConfigChannel(&hadc1, &sConfig); }

关键参数里,ClockPrescaler 决定了 ADC 的转换时钟,分频比越大转换越慢但越稳。ContinuousConvMode 必须设成 DISABLE,配合外部触发使用;如果开着连续转换,TIM3 的触发信号会被忽略,采样率就完全失控了。SamplingTime 这项翻车最多,用 ADC_SAMPLETIME_1CYCLE_5 在高阻抗信号源上读数会偏低,后文排查章再展开。

3.3 DMA 搬运与半传输/全传输回调

dma.c 里初始化完 DMA 流后,main 函数里调用 HAL_ADC_Start_DMA 启动整个链路。数据传输完成后会触发 DMA 中断,HAL 库再回调到用户代码。

#define ADC_BUF_SIZE 1024 uint16_t adc_buf[ADC_BUF_SIZE]; // DMA 搬运的目标缓冲区 uint16_t adc_buf_half[ADC_BUF_SIZE / 2]; // 用于半传输段的读数缓存 void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { memcpy(adc_buf_half, adc_buf, ADC_BUF_SIZE / 2 * sizeof(uint16_t)); } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { uint32_t sum = 0; if (hadc->Instance == ADC1) { // 对完整缓冲区做轻量处理,求和而不是逐点分发 for (int i = 0; i < ADC_BUF_SIZE; i++) { sum += adc_buf[i]; } adc_average = sum / ADC_BUF_SIZE; // 一次搬运的均值结果 } }

两个回调的触发时机正好错开半拍,Half 回调触发时 DMA 正在搬运后半段数据,前半段数据可以安全读取。这种设计让采样的同时还能做数据处理,不会出现漏采。回调里不该做的事是调用 printf、跑浮点运算、做 FFT,这些都会拉长中断时间,导致 DMA 还没来得及搬完就被下一次转换覆盖。我的习惯是回调里只拷贝数据或算个累加值,复杂计算放主循环。

3.4 main 函数里的启动时序

启动顺序在工程里看起来不起眼,实际上是稳定性的关键。先初始化所有外设,然后校准 ADC,再启动 DMA,最后使能定时器触发。这个顺序不能乱,乱了会出现第一次采样数据无效的问题。

int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_ADC1_Init(); MX_TIM3_Init(); HAL_ADCEx_Calibration_Start(&hadc1); // 先校准 ADC HAL_ADC_Start_DMA(&hadc1, adc_buf, ADC_BUF_SIZE); // 再启动 DMA 搬运 HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); // 最后开启定时器触发 while (1) { // 主循环里消费 adc_average,做显示或控制 } }

HAL_ADCEx_Calibration_Start 做的是 ADC 内部失调校准,跳过这一步第一次触发采样出来的值会偏差不少。HAL_ADC_Start_DMA 第三个参数是缓冲区大小,这里填的是数组元素个数,不是字节数,写成 sizeof(adc_buf) 会直接越界。HAL_TIM_PWM_Start 放到最后,确保触发信号产生时整个链路已经就绪。

4. 把工程跑起来:编译、烧录与数据验证

4.1 打开工程的第一个检查点

拿到压缩包先解压,看里面有没有 .uvprojx 文件。有就直接用 Keil 打开,没有就把 Core 和 Drivers 目录拖进一个新建工程。打开工程后三件事先确认:芯片型号选的是 STM32H743xx,C/C++ 宏定义里有 USE_HAL_DRIVER 和 STM32H743xx,Debug 设置里的烧录算法对应的是 H7 系列 1MB 或 2MB Flash 版本。这三处不对,编译下载都会出问题。

调试器推荐用 ST-Link 或者 J-Link 的 SWD 四线方式,H743 的 SWD 接口在板子上一般引出 VCC、SWDIO、SWCLK、GND 四个引脚。连接不上时先查复位引脚有没有拉高,再查目标板供电,这两个原因占了八成以上的连接失败。

4.2 烧录后怎么判断链路已经通了

程序下载进去复位运行,先别急着看波形。打开调试器内存窗口,查看 adc_buf 数组里的值,确认是规律的数值而不是全 0 或全 0xFFFF。如果全 0,大概率是采样引脚没接信号源,或者 ADC 时钟没起来。如果全 0xFFFF,大概率是通道配置错了,采到了空悬引脚。

再把 PA0 接到板子上的 VCC 或者 GND,adc_buf 里的值应该分别接近 4095 和 0。接一个可调电阻的分压电路,调节电压时观察 adc_buf 数值线性变化。到这一步就说明 TIM 触发、ADC 转换、DMA 搬运整条链路已经打通,可以往下做实际采集了。

4.3 用串口把采样数据打出来验证

工程验证阶段最直接的办法是把 ADC 均值通过串口打印出来。printf 重定向到 UART 比在调试器里看内存数组方便得多。

// 重定向 fputc 到 USART1,注意要用微库 MicroLib int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 100); return ch; } // 在主循环里周期性打印采样均值 while (1) { printf("adc: %d\n", adc_average); HAL_Delay(100); }

打印出来的数值转换成实际电压公式是电压等于 adc_average 乘以 3.3 再除以 4096。如果引脚前级有分压电阻,还要把分压比例乘回去。H743 的 ADC 参考电压默认是 VDDA 电源,如果板子供电是 3.3V,换算出来应该落在合理范围。打印值一直不变或者跳变幅度异常,优先查参考电压和引脚配置。

5. 避坑实录:数据错位、回调超时、首次启动无数据

5.1 DMA 缓冲区数据对不上通道顺序

现象:配置了多通道扫描,缓冲区里的数据顺序和通道顺序不一致,读出来的数值张冠李戴。原因:扫描模式下 DMA 搬运的数据按通道组顺序排列,如果配置了 3 个通道,缓冲区前 3 个元素分别对应通道 1、2、3。很多人没注意 CubeMX 里的 Rank 排序,通道顺序和引脚编号对不上。解决:先查 adc.c 里 sConfig.Rank 配置顺序,确保和期望的采集顺序一致;再用一个固定电压接在某个通道上,打印缓冲区确认对应位置,顺序对了再继续往下做。

5.2 回调里做太多事,采样率一高就丢数据

现象:低速采样一切正常,采样率提到几百 K 之后数据出现大片空洞或者 DMA 错误标志置位。原因:中断回调里做了耗时的浮点运算或者串口打印,中断没退出前下一次 DMA 搬运已经完成,缓冲区被覆盖。解决:回调里只做拷贝和标志置位,浮点滤波和 FFT 全部丢到主循环。如果必须在中断里算,用定点整数运算代替浮点,减少处理时间。还可以把缓冲区加大一倍,给 CPU 更多处理余量。

5.3 第一次触发采样没数据,第二次才正常

现象:程序刚下载完运行时没有数据,按一下复位或者重新上电就好了。原因:ADC 校准还没完成就开始接收外部触发,第一次转换拿到的数据是无效的。解决:启动顺序强制校准、启动 DMA、再开定时器。HAL_ADCEx_Calibration_Start 完成之后,要给它一个时钟周期的稳定时间再开始采样。工程里如果复位后第一次触发数据异常,优先检查三个初始化函数的调用顺序。

5.4 采样时间太短,信号源阻抗不匹配导致读数偏低

现象:用信号发生器接传感器,读数总是比实际值低 10% 以上,调整分压比也没用。原因:ADC 内部采样电容需要时间充满,信号源输出阻抗越高,需要的采样时间越长,采样时间配置成 1.5 个周期太短。解决:把 SamplingTime 加大到 ADC_SAMPLETIME_32CYCLES 或 ADC_SAMPLETIME_64CYCLES。这个参数影响的是单次转换时间,对触发频率的影响很小,但直接决定读数准确性。

5.5 外部触发改到其他定时器后中断进不去

现象:从 TIM3 改成 TIM2 触发,寄存器配置改了但 DMA 中断一直没有触发。原因:定时器在 CubeMX 里生成的代码不完整,定时器没有正确使能输出比较通道,或者 NVIC 中断优先级配置没有重新生成。解决:检查定时器初始化里是否调用了 HAL_TIM_OC_ConfigChannel 或对应的 PWM 启动函数,再确认触发源映射关系,H7 系列不同定时器的 TRGO 映射到 ADC 的触发源编号不同。改动后重新走一遍 CubeMX 生成流程,比手改寄存器更稳。

6. 二次开发方向:从单通道采样扩展成真正能用的采集系统

拿到这个工程后,最值得动手的改动方向有三个。第一个是多通道规则扫描,把工程从单通道扩展成 4 路或 8 路采集。配置上在 adc.c 里加多个 sConfig 配置项,Rank 依次递增,DMA 缓冲区大小按通道数翻倍,回调里用通道数取模读取对应数据。第二个是接入 FreeRTOS 任务,把 DMA 回调里置位的标志消费到采样任务中,任务里做滤波和均值计算,避免阻塞中断。第三个是数据后处理,把 adc_buf 里的原始值改造成 RMS 或者简易 FFT,输出真正有物理意义的指标。

// 多通道扫描时,缓冲区排列顺序为 ch1, ch2, ch3, ch1, ch2, ch3... for (int i = 0; i < ADC_BUF_SIZE; i++) { switch (i % 3) { case 0: sum_ch1 += adc_buf[i]; break; case 1: sum_ch2 += adc_buf[i]; break; case 2: sum_ch3 += adc_buf[i]; break; } }

改成多通道后要回去检查采样时间配置,通道越多单轮转换时间越长,需要的触发周期也要相应拉长,不然 DMA 还没有搬完上一轮数据,下一轮触发又来了。我见过不少人在这步栽跟头,把三通道的触发周期和单通道设成一样,结果数据全乱了。

做采样类工程的这几年来,我养成一个习惯:每次拿到新板子,先把原始 ADC 值通过串口打出来看一遍,确认数据趋势和量级对了再往上叠滤波和控制算法,绝不在 DMA 缓冲数据上直接做闭环控制。这套 TIM 加 ADC 加 DMA 的组合,配置看着简单,真正调稳还是得靠逐层验证。希望这篇拆解能帮你少走几步弯路,把采样链路一次跑通。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进