简介:面向使用STM32CubeMX与HAL库的嵌入式开发者,这份RAR压缩包给出STM32H743定时器触发固定频率ADC采样、并通过DMA自动搬运数据的完整工程参考。示例对应《STM32CUBEMX配置教程(十二)》,工程内可清楚看到TIM触发源选择、ADC采样通道配置、DMA循环传输模式等关键初始化代码,以及HAL_TIM、HAL_ADC、HAL_DMA等外设驱动模块的调用关系,并包含RCC、PWR等底层时钟与电源支持代码。包内共163个文件,以C源文件、头文件与编译目标文件为主,同时包含IOC/MXPROJECT的CubeMX配置、UVPROJX/UVOPTX的Keil工程文件,以及AXF/HEX等可直接验证的映像,整体仅8.56MB。已有1558人学习下载,适合刚接触H7系列定时器与DMA协同采样,或希望对照HAL库源码理解自动生成代码结构的嵌入式开发者进行学习与验证。
1. 定时器触发 ADC:一个工程让 1MSPS 采样不再吃 CPU
把 STM32H743_TIM_ADC_DMA 这个工程完整跑通之后,我对 32 位 MCU 做数据采集的印象刷新了一次:原来 1MSPS 的连续采样可以完全不让 CPU 参与。定时器产生触发脉冲,ADC 按固定间隔转换,DMA 把结果自动搬进内存,主循环甚至可以去做别的活。这个压缩包里的工程干的正是这件事,它绕开了「查询标志位 + 中断读寄存器」这种老办法,把 H743 上三个外设串成了一条完整流水线。
它适合两类人。一类是做信号采集、振动监测、电力参数测量的开发者,需要稳定且高速的采样,又不想让采样占用太多 CPU 资源;另一类是刚接触 H7 系列,想弄明白外设之间怎么协同工作的同学。工程体积不大,但触发、转换、搬运、回调这一整条链路非常完整,拿来当模板改比自己从零写省太多时间。
2. 工程链路拆解:从 TRGO 脉冲到 DMA 落内存的完整通路
2.1 为什么是 TIM1 + ADC1 + DMA1 这个组合
在 H743 上做高速采集,外设组合的选择基本决定了工程的上限。定时器选 TIM1,因为它挂在高级定时器这一路,触发输出 TRGO 可以直接连到 ADC 的外部触发引脚,而且支持更新事件、比较事件多种触发源,采样率可以算得很准。ADC 选 ADC1,H743 有三个独立 ADC,ADC1 的 DMA 请求通道在 DMAMUX 里映射最灵活,触发源和 DMA 请求互不干扰。DMA 选 DMA1 的 Stream 0,原因比较简单,这是 CubeMX 默认生成的位置,请求映射由 DMAMUX 决定,你把它换到 DMA2 上效果一样。
这个组合能成为经典,核心原因是分工清晰:定时器只管「什么时刻触发」,ADC 只管「触发后转一次」,DMA 只管「转换完把数据搬走」。CPU 在中间的每个环节都不需要停下手头的事去等结果。相比 F1 时代用定时器中断里去启动转换的写法,这套组合把采样抖动从微秒级降到了纳秒级,因为触发脉冲是硬件产生的,不经过中断响应。实际项目里采样率的稳定性往往比转换速度更值钱,用 H743 做这件事,算是最稳妥的起步方案。
2.2 工程目录与三个关键文件
用 CubeMX 生成的标准目录长这样:
STM32H743_TIM_ADC_DMA ├── Core │ ├── Inc │ │ ├── main.h │ │ ├── adc.h │ │ ├── tim.h │ │ ├── dma.h │ │ └── gpio.h │ └── Src │ ├── main.c │ ├── adc.c │ ├── tim.c │ ├── dma.c │ └── gpio.c ├── Drivers │ ├── CMSIS │ └── HAL 驱动库目录 └── MDK-ARM ├── 工程文件.uvprojx └── 启动文件与链接脚本这个结构是 CubeMX 的默认产物,绝大多数人拿到的工程都是这个骨架。真正需要动的地方其实只有三个:main.c 里的主循环逻辑、adc.c 里的采样参数、tim.c 里的触发频率。dma.c 和 gpio.c 生成后基本不用改。如果你打开工程发现文件结构有明显差异,那大概率是用了老版本的固件包,不影响理解,但建议先核对 HAL 版本再编译,避免某些 API 名称不一样。
有个习惯建议从一开始就养成:修改代码只写在由/* USER CODE BEGIN */和/* USER CODE END */包裹的区域里。CubeMX 重新生成项目时会保留这些区间的代码,写到区间外面,一次重新生成就把你的改动抹掉了。不少初级开发者在这上面吃过亏,改了一大段代码,回 CubeMX 调一下引脚,回来发现代码全没了。
2.3 一次触发到一次搬移的完整时序
把这一整条链路拆成步骤看,其实每一步都有明确的标志位:
- TIM1 自由运行,计数器从 0 数到 ARR 产生更新事件;
- 更新事件经过 TRGO 路由,在 TRGO 引脚或内部连线上输出一个触发脉冲;
- ADC1 收到外部触发,启动一次规则组转换;
- 转换完成,ADC 数据寄存器写入结果,EOC 置位;
- ADC 的 DMA 请求信号拉高,DMA1 Stream 0 响应请求;
- DMA 把数据寄存器里的半字搬到内存缓冲区,内存地址自增;
- 当 DMA 搬完设定的数据量,触发传输完成中断,进入 HAL 的回调函数。
这个链路里最容易踩坑的是第 2 步到第 3 步的衔接。ADC 的外部触发源有个触发边沿选择,如果配成下降沿触发,但定时器输出的是上升沿脉冲,ADC 永远不会启动。CubeMX 里 ADC 的 External Trigger Edge 选项有 Rising Edge、Falling Edge、Both Edges 三种,实际工程里绝大多数配置都选 Rising Edge,这一点在改触发源时特别容易忽略。
第 5 步也需要注意。ADC 的 DMA 请求不是所有模式下都会持续产生的,如果把 ADC 配置成单次转换,DMA 搬完设定的次数后就不再出发了。要想实现「一直采,不停搬」,ADC 侧要开启 DMA Continuous Requests,DMA 侧要选择 Circular 循环模式,两个开关缺一个,数据流都会中断。
3. CubeMX 初始化实战:时钟树、触发源与 DMA 通道的三个关键配置
3.1 时钟树:先弄清楚定时器和 ADC 各吃哪路时钟
H743 的时钟树比 F103 复杂得多,第一次用容易直接愣住。它的 CPU、总线、外设各自有独立的时钟源,不是像老芯片那样一根总线挂所有。ADC 的时钟来自 ADCCLK,定时器的时钟来自 TIMx 所挂总线的定时器倍频器,两者的分频配置在 CubeMX 的 Clock Configuration 页面里是完全独立的两块。如果上来就先调 PLL 让主频跑到 480MHz,ADC 和定时器的时钟未必是你以为的值。
我的做法是先在 CubeMX 的 Clock Configuration 页面里把目光聚焦到两个角落:左上角的 ADC 分频器,以及 TIM1 所挂总线的定时器时钟。H743 的 ADC 时钟一般给到几十 MHz 级别,分频值从 2、4、8、16 里选,具体看外设频率目标;TIM1 的计数时钟通常能达到频率很高的总线定时器时钟。这个数值影响后面对定时器分频和重载值的计算,所以先把它记下来。
时钟配置做完之后会自动生成 SystemClock_Config 函数,里面是一串 HAL_RCC_xxx_Config 调用,这段一般不需要手动改,但要看一眼 ADC 和定时器时钟的最终频率。如果后面发现采样率不对,回到这里核对是最快的排查方式。
3.2 定时器配置:PSC、ARR 与 TRGO 的计算
定时器在本工程里的角色不是产生中断,而是产生触发脉冲,所以配置上只需要关心三件事:计数时钟、更新频率、触发输出。CubeMX 里 TIM1 的 Clock Source 选择 Internal Clock,Prescaler 和 Counter Period 按目标采样率计算,Trigger Output Trigger 选择 Update Event,这样每次计数器溢出都会在 TRGO 引脚产生一个事件。
采样率的计算公式是:
采样率 = 定时器计数时钟 / (PSC + 1) / (ARR + 1)假设你在时钟树里看到 TIM1 的计数时钟是 240MHz,想要 1MSPS 的采样率,常见做法是 PSC 设为 0,ARR 设为 239,算出来正好是 240MHz / 240 = 1MHz。如果你的目标采样率和时钟频率除不净,先调整 PSC 让计数时钟能被整除,再算 ARR,这样采样间隔不会出现周期性的抖动。
定时器的初始化代码一般是这样的,CubeMX 生成后基本不需要动:
static void MX_TIM1_Init(void) { TIM_MasterConfigTypeDef sMasterConfig = {0}; htim1.Instance = TIM1; htim1.Init.Prescaler = 0; htim1.Init.CounterMode = TIM_COUNTERMODE_UP; htim1.Init.Period = 239; // 1MSPS 对应的重载值 htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim1.Init.RepetitionCounter = 0; htim1.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_Base_Init(&htim1); sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE; sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(&htim1, &sMasterConfig); }代码逻辑说明:Prescaler 是预分频值,实际分频系数是 PSC 加 1;Period 是自动重载寄存器 ARR,计数器数到它就会溢出并产生更新事件。TIM_TRGO_UPDATE把更新事件路由到 TRGO,这是整个触发链路的源头。AutoReloadPreload开启预装载,表示修改 ARR 后要等当前计数周期结束才生效,避免运行中改采样率时出现半个长周期半个短周期的情况。
3.3 ADC 与 DMA:触发边沿、半字宽度与启动顺序
ADC 的配置里,和定时器联动的关键参数都在 CubeMX 的 ADC1 设置页面上。Resolution 选 12 bit,Scan Mode 保持 Disable,Continuous Conversion 必须关掉,因为采样节奏由定时器管,ADC 自己连续转换会打乱节奏。External Trigger Source 选择 TIM1 的 TRGO 事件,External Trigger Edge 选 Rising Edge,采样时间按你的信号源阻抗来定,阻抗大就选长一点,一般从 16.5 cycles 起步。
DMA 部分,ADC1 的 DMA 请求在 CubeMX 里添加到 DMA Settings 后,默认会生成到 DMA1 Stream 0。Direction 为 Peripheral To Memory,Mode 选 Circular,外设数据宽度和内存数据宽度都设成 Half Word,因为 12 位 ADC 的结果用一个 16 位无符号整数就能装。这里宽度设置不一致,搬出来的数据就会错位,后面避坑章节还会提到。
启动代码是这套工程里最关键的一段:
// ADC 校准,H7 系列建议上电后做一次 HAL_ADCEx_Calibration_Start(&hadc1); // 以 DMA 方式启动 ADC1 转换 HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buffer, ADC_BUFFER_SIZE); // 启动 TIM1 基本定时器,开始产生触发脉冲 HAL_TIM_Base_Start(&htim1);逻辑说明:第一步做 ADC 校准,H7 的 ADC 内置线性校准逻辑,不做的话转换结果整体偏一个值;第二步把 ADC 和 DMA 接起来,最后一个参数是转换次数,也就是 DMA 一次传输的元素个数,不是字节数;第三步启动定时器,触发链路开始跑。注意第二和第三个函数的调用顺序不能反,先启动定时器再启动 DMA 会导致前面的触发事件丢失。HAL 库对这种顺序的错误没有报错机制,表现出来就是什么事情都没发生,很难查。
4. 核心代码逐段拆解:双缓冲回调与数据换算的实现细节
4.1 缓冲区与标志定义
工程代码最顶部的定义区决定了整个数据搬运的骨架。见过不少工程直接只用一个数组,DMA 满就中断处理,处理完再覆盖,这样数据一忙就丢。这个工程的做法是把缓冲区拆成前半段和后半段,配合 DMA 传输完成中断交替处理。
#define ADC_BUFFER_SIZE 1024 // 每次 DMA 传输的转换次数 static uint16_t adc_buffer[ADC_BUFFER_SIZE]; static uint16_t adc_half_buffer[ADC_BUFFER_SIZE / 2]; volatile uint8_t adc_full_flag = 0; // 后半段已填满 volatile uint8_t adc_half_flag = 0; // 前半段已填满参数说明:adc_buffer 是 DMA 搬运的目标地址,DMA 会从缓冲区的开头往里写,写到尾部再从开头重新写;adc_half_buffer 是给应用层取数据的暂存区,分成两半是为了让处理逻辑和数据采集错开。两个 flag 是主循环和中断回调之间的通信渠道,用 volatile 修饰,避免编译器优化导致主循环读不到最新值。
4.2 主循环里只管启动,后面交给硬件
主函数的逻辑简单到不像一个高速采集工程:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_ADC1_Init(); MX_TIM1_Init(); HAL_ADCEx_Calibration_Start(&hadc1); HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buffer, ADC_BUFFER_SIZE); HAL_TIM_Base_Start(&htim1); while (1) { if (adc_half_flag) { adc_half_flag = 0; process_samples(adc_half_buffer, ADC_BUFFER_SIZE / 2); } if (adc_full_flag) { adc_full_flag = 0; process_samples(adc_half_buffer, ADC_BUFFER_SIZE / 2); } } }这段代码最核心的设计是初始化顺序:DMA 先于 ADC,ADC 先于定时器。MX_DMA_Init 必须在 MX_ADC1_Init 前面,因为 HAL_ADC_Init 内部需要把 DMA 句柄关联到 ADC 的 DMA 请求上,如果此时 DMA 还没初始化,句柄就是 NULL,链路就断了。主循环里没有采样相关的代码,只检查两个标志位,这样的好处是采样频率完全由硬件决定,主循环里哪怕加了几行耗时的浮点计算,也不会影响采样的时间轴。
4.3 中断回调:半满与全满的交替处理
DMA 在循环模式下每搬完半段缓冲区就会触发一次中断,HAL 库把这些中断统一转成两个回调函数,代码里只需要重写它们:
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { adc_half_flag = 1; } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { adc_full_flag = 1; } }逻辑说明:DMA 传输半满时触发 ConvHalfCplt 回调,5 传输全满时触发 ConvCplt 回调。因为缓冲区有 1024 个元素,半满是 512,全满是 1024,真实含义是:前半段写完了,或者后半段写完了。主循环拿到 flag 后要尽快处理对应的半段数据,因为 DMA 还在以循环模式继续写缓冲区,处理慢了,新写入的数据就会覆盖还没算完的数据。这里有个细节要注意:回调里只置标志位,不做数据处理。中断回调里做乘除运算会拖长中断时间,如果 DMA 传输在一段时间内没有被及时处理,数据就会被覆盖,表现为波形出现周期性的毛刺。
4.4 数据换算:从原始值到电压和有效值
ADC 转换出来的原始值是 0 到 4095 之间的整数,要变成有物理意义的电压,需要一个简单的换算:
float adc_raw_to_voltage(uint16_t raw) { return raw * 3.3f / 4095.0f; } float calc_rms(const uint16_t *buf, uint32_t len) { uint64_t sum = 0; for (uint32_t i = 0; i < len; i++) { int32_t v = buf[i] - 2048; // 去掉直流分量再平方 sum += (uint64_t)v * v; } return sqrtf((float)sum / len) * 3.3f / 4095.0f; }逻辑说明:raw_to_voltage 假设参考电压是 3.3V 的典型接法,如果你的板载参考电压不是 3.3V,把 3.3 换成实际值即可;calc_rms 先把每个采样点减去 2048,还原成交流信号相对零点的偏移,再平方求和取平均开根号,就是标准的均方根算法。这里用了 uint64_t 累加,防止 1024 个点的平方和超过 32 位表达范围。如果你做的是 DMA 多通道采样,raw 数组里会交替出现不同通道的数据,就不能用这个函数了,需要先按通道把数据拆开,再逐通道做换算。
5. 避坑记录:采样率上不去、数据错位和抖动噪声的三个真实案例
5.1 采样率上不去,改了 ARR 也没用
现象:定时器周期怎么调,实际采样率都停在某个值,用逻辑分析仪看触发波形,频率和设定值对不上。我刚开始也以为是 ARR 算错了,反复把计算公式套了好几遍,依然对不上。
原因:定时器的计数时钟不是我以为的 240MHz。H743 的 APB 总线有预分频,定时器输入时钟往往是 APB 时钟的两倍,但前提是总线分了频。如果我拿到 CubeMX 默认配置直接改 ARR,没看 TIM1 实际挂的时钟频率,算出来当然不准。另外一个隐藏限制是 ADC 转换时间,单次转换需要的总周期是采样时间加上固定周期,如果定时器触发的频率高于 ADC 的转换速度,ADC 根本没转完就收到下一个触发,采样率就被 ADC 的转换时间卡住了。
解决:先回到 CubeMX 的 Clock Configuration 页面记下 TIM1 的实际时钟,再重新算 PSC 和 ARR。同时把 ADC 的采样时间调短,比如从 64.5 cycles 改到 16.5 cycles,再用公式验证一遍触发频率是否小于 ADC 最大转换频率。这两个都确认了,采样率才能上去。
5.2 DMA 搬来的数据错位,波形像被切碎了
现象:把缓冲区数据打印出来,波形每隔一段就出现一次跳变,像是把两个通道的数据混在了一起,单通道采集也这样。
原因:DMA 数据宽度配置和外设寄存器不匹配。ADC 数据寄存器是 16 位,DMA 外设端如果配成了 Word 宽度,它每次会读走 32 位,把相邻的两个转换结果拉在一起拼成一个乱七八糟的半字。更隐蔽的一个原因是 DMAMUX 的请求映射被 CubeMX 重新生成时改掉了,ADC1 的请求被映射到了别的 DMA Stream,地址对不上,搬出来的数据自然不对。
解决:检查 DMA 配置,外设端和内存端数据宽度都选 Half Word。然后核对 DMAMUX 里 ADC1 对应的请求映射是 ADC1 的通道,别的 ADC 的请求映射到同一个 Stream 上,结果互相覆盖,也会出现类似错位。H743 的外设多了,这种配置错误在 CubeMX 里不仔细看很难发现,我一般会同时在调试器里看 DMA 的 Current Data Counter,和缓冲区长度对不上说明配置被改过。
5.3 转换值噪声大,信号里叠了一层毛刺
现象:采集 50Hz 正弦波,波形外围能看到一层粗颗粒,理想情况下应该是一条平滑的曲线。FFT 之后高频部分多了很多乱七八糟的谱线。
原因:这里有两种可能。第一是 ADC 采样时间设太短,内部采样电容没有完全充满到输入电压,转换结果里带上了电容充放电的误差;第二是模拟参考电压不稳,VREF 引脚或者 VDDA 上的纹波直接耦合进了转换结果,这种情况下无论怎么改采样参数都没有用。
解决:先把采样时间从几 cycle 改到 32.5 或者 64.5 cycles,差值减下来再说。如果还不行,用万用表看 VDDA 引脚电压是不是稳定,纹波大就在 VDDA 和 VSSA 之间加一颗高质量的磁珠和一两个去耦电容,参考电压要求更高的话可以外接独立的基准源。做信号采集的板子,模拟电源和数字电源的隔离问题,迟早要面对。
5.4 定时器正常触发,但 DMA 中断一次都不进
现象:定时器在跑,ADC 也在转换,理论上 DMA 应该持续搬运,但断点跟踪发现 HAL_ADC_ConvCpltCallback 从未被调用,缓冲区里一直是 0。
原因:这是初始化顺序和中断优先级双重翻车造成的。如果 MX_DMA_Init 在 MX_ADC1_Init 之后执行,ADC 初始化时拿到的 DMA 句柄是空的,HAL_ADC_Start_DMA 实际上没有启动任何搬运。另外,DMA 的传输完成中断虽然默认使能,但如果 NVIC 里对应 Stream 的中断没有打开,或者优先级配得和同中断组里的其他中断冲突,回调函数就不会被调用。
解决:强制在 main 函数里把 MX_DMA_Init 提到 MX_ADC1_Init 之前,顺序错了,其他全对也白搭。然后在 NVIC 设置里打开 DMA1_Stream0_IRQn,优先级可以给一个较高的等级,但不能和 SysTick 同级,否则 tick 中断和 DMA 中断互相抢占,系统会出现诡异卡顿。这两个检查完,中断基本就能进到了。
6. 二次开发思路:把固定 1MSPS 采样改成可变速多通道方案
6.1 采样率改成运行时可调
固定 1MSPS 在调试阶段够用,真做产品往往需要在运行中切换采样率,比如启动时用低采样率判断信号类型,判断完切到高速采集。实现方式是在运行中修改定时器的 ARR 寄存器,因为开启预装载后,新值会在下一轮溢出时生效,不会破坏正在进行的采样周期。
void set_sample_rate(uint32_t sample_rate) { uint32_t timer_clock = TIM1_CLOCK_HZ; // 在时钟树里确认后的值 __HAL_TIM_SET_AUTORELOAD(&htim1, timer_clock / sample_rate - 1); }注意 sample_rate 不能超过 ADC 的最大转换频率,切换后最好重新读取一次实际 ARR,确认寄存器写入生效。
6.2 多通道与注入组
需要同时采多路信号时,把 ADC 的 Scan Mode 打开,在 CubeMX 里配置通道数量和每个通道的采样时间。DMA 缓冲区长度改成 通道数乘以每通道点数,数据在缓冲区里按通道顺序交替存放,换算前先按通道拆开。如果各路信号的重要性不一样,可以用注入组实现「规则组在采,注入组插队」的效果,注入组的触发源独立配置,转换完成也走独立的回调,互不打扰。
6.3 用内部参考做简单校准
H743 内置了 VREFINT 参考电压通道和校准数据,上电时读取 TS_CAL1 这类出厂校准值,可以修正 ADC 的增益误差。做法是在初始化阶段把内部参考通道配置成 ADC 的一个输入,采集一段数据求出实际增益系数,之后所有通道的换算都乘上这个系数。校准的数据放在芯片的 OTP 区,读出来直接用就行,不需要自己标定。
这个工程我前后复现过几次,每次换一块新的 H743 板子,都强制要求自己先从时钟树看起,再顺一遍 DMA 初始化顺序,最后才敢往上接信号源。这套流程能省下大把排查问题的时间,也让我少踩了好几次「配置没问题但就是不出数据」的坑。希望帮你少走这些弯路。
本文还有配套的精品资源,点击获取