
简介基于 STM32F411CEU6 的 TIM2DMAADC 多通道采集项目适合使用 HAL 库进行嵌入式开发的工程师与学习者重点解决每毫秒轮询三路模拟信号的同步采样需求。压缩包共收录 1148 个文件以 c/h 源文件驱动代码为主同时包含 uvprojx/uvoptx 工程配置、icf 链接脚本、axf/hex 编译产物、lib 数学库及 o/crf 中间文件便于直接导入 Keil 或 IAR 工程核对定时器、DMA 与 ADC 的完整初始化参数压缩包大小约20.23MB。目前已有 998 人学习下载项目内提供了 TIM2 周期中断触发 ADC 采样、DMA 搬运转换结果到内存的具体实现涵盖中断服务程序、HAL 库 API 调用以及采样时间、分辨率、转换序列和 DMA 通道选择等关键配置。通过研读该项目读者能够掌握 1ms 定时轮询多通道 ADC 的工程化写法理解定时器、DMA 与 ADC 三者协同配合的同步机制并可将配置模板复用于环境监测、传感器数据采集等实时信号处理场景。1. 一个典型的 STM32 HAL 库调试现场TIM2、DMA、ADC 三外设如何拧成一条链路一个典型的 STM32 HAL 库调试现场往往是这样开局的多路模拟信号进来先用主循环轮询读 ADC 寄存器单通道转换等待就把循环拖出几微秒的抖动换中断每次转换进一次 ISR采样率到 100kHz 时中断已然侵占主线任务。F411CEU6 上真正能扛住等间隔采样的方案是把 TIM2、DMA、ADC 三件事拆给三个硬件定时器按固定节拍发出触发信号ADC 完成转换后由 DMA 直接搬运进内存数组CPU 只在一整批数据“写完了”时收到回调通知。这套组合能解决波形采集、传感器数据同步、多通道周期性采样里“CPU 要不要一直盯着 ADC”这个核心矛盾。适合正在做数据采集、需要稳定时基而不想让主循环被拖垮的开发者也适合刚接触 HAL 库、想一次把触发源和 DMA 配置理顺的入门者。2. 给 TIM2、DMA 和 ADC 分工三种采样模型对比与选型边界这是整个例程包的地基为什么 F411 上用定时器触发 ADC、再由 DMA 搬数据而不是老老实实轮询或中断2.1 轮询、中断与 DMA三种采样模型的 CPU 占用差异大多数第一次接触 ADC 的入门者会先尝试轮询主循环里不停检查HAL_ADC_PollForConversion转换完成就拿数据。这种方式代码最简单但 CPU 在等待期间不能干别的。即使单通道转换只需要十几微秒三个通道轮着来主循环里任何时间敏感逻辑都会出现采样周期波动。中断模式是把“转换完成”这件事变成异步事件每次转换完成触发一次HAL_ADC_ConvCpltCallback。1kHz 采样时中断频率不高但把采样率提到 100kHz每 10 微秒就要进出一次中断现场保存寄存器、执行回调、恢复现场这些固定开销开始挤压任务运行时间。更关键的是如果回调和主程序同时操作同一个数据变量还要考虑临界区保护。DMA 模式把数据搬运工作交给 DMA 控制器ADC 转换结果写入ADCx_DR后DMA 自动把它拷贝到内存数组整个过程不经过 CPU。CPU 只在 DMA 搬完半批或整批缓冲数据时收到一次中断。以 256 个 16 位数据为一个缓冲周期1kHz 触发下 CPU 每 128ms 才被通知一次开销和中断模式差了三个数量级。三种模型的对比如下采样模型触发者数据搬运者CPU 介入时机适用采样率轮询软件CPU 每次读每次转换等待低速、非实时ADC 中断ADC 硬件CPU 进 ISR每次转换完成中低速、无 DSPTIM2DMA定时器 TRGODMA 控制器半缓冲/全缓冲完成中高速、等间隔这个对比解释了项目标题为什么把 TIM2 放在最前面。轮询和中断都是“CPU 跟着 ADC 跑”而 TIM2DMA 是“CPU 按自己的节奏处理数据”。2.2 TIM2 的选型理由32 位计数器与触发源精度F411CEU6 的 TIM2 和 TIM5 是 32 位定时器TIM3、TIM4 是 16 位。做 ADC 触发时32 位计数器的价值体现在低频场景主频 100MHz 下16 位定时器 ARR 最大 65535最低输出频率约 1.5kHz而 TIM2 的 ARR 可以写到几十万甚至几百万想生成 1Hz 触发脉冲只需要预分频和自动重装载值配合好不必再外接分频电路。触发频率计算公式是所有参数调试里最应牢记的F_TRGO TIM_CLK / (PSC 1) / (ARR 1)以 F411 的 TIM2 接在 APB1 定时器时钟100MHz为例要得到 1kHz 触发频率可以设PSC 999ARR 99这样经过两个分频级得到 1000Hz。CubeMX 里可以直接在 TIM2 配置页将“Trigger Output (TRGO)”设为 Update Event这样每次计数器溢出更新时输出一个触发脉冲给 ADC。触发精度方面定时器由硬件计数边沿触发 ADC 的时机不受主循环任务调度影响所以多个通道之间的采样间隔能保持一致。这是后面做 FFT、滤波等算法时最重要的前提——数据在时间轴上均匀分布。2.3 从触发到数据落进内存整条链路的数据流与中断流把这条链路拆成四个环节调试时才能定位到底哪一环断了。第一环TIM2 计数器达到 ARR产生更新事件TRGO 输出上升沿。第二环ADC 检测到外部触发沿开始对规则通道组进行采样和转换转换完成后结果锁存进ADC1_DR寄存器。第三环ADC 产生 DMA 请求DMA 把ADC1_DR里的 16 位数据搬运到 SRAM 数组每搬完一个数据源地址自动递增。第四环DMA 搬完半缓冲区和全缓冲区时分别触发中断HAL 库调用HAL_ADC_ConvHalfCpltCallback和HAL_ADC_ConvCpltCallback。专业习惯是把这个流程用一句话记下来TIM2 的 TRGO 触发 ADC 规则通道转换转换结果经 DMA 写入内存数组DMA 半满与全满中断是 CPU 与外部世界的两个握手点。这个结构里唯一的软件参与点就在握手回调其他环节全是硬件自动行为。理解这一点后续配置 CubeMX 时就知道哪些参数必须严格哪些参数可以松动。3. 让链路在 HAL 库工程里跑起来CubeMX 参数与最小启动代码原理讲清楚之后最重要的就是让工程真的转起来。这里给出这套例程包里我调试通过的配置顺序照着操作可以少踩一半坑。3.1 CubeMX 里的关键参数触发源、DMA 模式与内存宽度新建 STM32F411CEU6 工程后时钟树可以按常规方式配置HCLK 设为 100MHzAPB1 分频为 50MHzAPB2 为 100MHz。F411 上 ADC 时钟由 APB2 分频得到需要注意 ADC 最大时钟限制一般不能超过 36MHz所以 ADC Clock Prescaler 用 4 分频得到 25MHz 比较稳妥给采样时间留出余量。ADC1 页里把“Continuous Conversion”关掉因为我们用 TIM2 外部触发不需要 ADC 自己连续转换。分辨率是 12 位采样时间可以先选 15 Cycles如果信号源阻抗较大再提高到 84 Cycles——采样时间越长内部采样电容充电越充分读数越稳但亏的是最高触发频率。External Trigger Source 选“Timer 2 Trigger Out event”这就把触发源绑定到了 TIM2。DMA 设置页添加 ADC1 的 DMA 请求方向是 Peripheral To Memory模式必须选 Circular。Circular 模式下 DMA 搬完一整批数据后自动回到起始地址继续搬形成环形缓冲不用每次搬完都重新启动这是连续采集的关键。外设和内存数据宽度都设 Half Word因为 12 位 ADC 结果放在 16 位容器里刚好用 Byte 会截断用 Word 会浪费内存和带宽。DMA 模式选 Normal 的情况只适合做“采集一次就停止”的场景比如单次电池电压检测连续波形采样必须 Circular否则 DMA 计数器归零后链路停止所有后续触发都会丢失。3.2 最小启动代码校准、启动 DMA、启动定时器CubeMX 生成工程后实际要补的代码并不多。核心就三个 HAL 函数调用但顺序有一个细节必须先校准 ADC再启动 DMA 传输最后启动 TIM2。#define ADC_BUF_SIZE 256 static uint16_t adc_buf[ADC_BUF_SIZE] __attribute__((aligned(4))); static volatile uint8_t transfer_full_done 0; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_ADC1_Init(); MX_TIM2_Init(); HAL_Delay(10); /* 1. 校准 ADC上电后做一次校准值由硬件自动计算 */ HAL_ADCEx_Calibration_Start(hadc1); /* 2. 启动 DMA 搬运第三个参数是样本个数不是字节数 */ HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buf, ADC_BUF_SIZE); /* 3. 启动定时器TIM2 开始计数后TRGO 才会持续触发 ADC */ HAL_TIM_Base_Start(htim2); while (1) { if (transfer_full_done) { transfer_full_done 0; /* 在这里处理 adc_buf 中已经搬完的数据 */ } } } void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { /* 前半段数据已就绪但通常不在这里做重活 */ } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { /* 后半段数据已就绪置标志通知主循环 */ transfer_full_done 1; } }这段代码的逻辑顺序是例程包的关键差异点校准放在第一位是因为 F4 系列 ADC 上电后需要执行校准校准值存在芯片内部不校准的后果是整体读数偏移后面避坑章会展开。HAL_ADC_Start_DMA的第三个参数是数组元素个数也就是 256不是字节数CubeMX 生成的 DMA 配置里外设和内存数据宽度都是 Half WordHAL 库正是依据这个配置来决定 DMA 计数器和内存寻址方式。TIM2 必须最后启动不然定时器立刻开始发触发脉冲而 ADC 的 DMA 通道还未就绪第一批触发会白丢。回调函数名是 HAL 库定义好的弱函数HAL_ADC_ConvHalfCpltCallback在 DMA 搬完半批时触发HAL_ADC_ConvCpltCallback在整批搬完时触发。这两个回调里不建议放滤波器、FFT 等耗时逻辑正确做法是置标志位把数据处理的活留在主循环。3.3 用 GPIO 和调试器验证链路是否真的在跑程序写完后先别急着看数据用一个最简单的方法确认硬件链路是通的在HAL_ADC_ConvCpltCallback里翻转一个 GPIO。void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { HAL_GPIO_TogglePin(DBG_GPIO_Port, DBG_Pin); transfer_full_done 1; } }TIM2 触发 1kHz、缓冲区 256 个样本的情况下全缓冲完成频率是 1000 / 256约 3.9 次每秒GPIO 翻转周期约 256ms。用示波器或逻辑分析仪测量这个翻转频率如果频率和理论值一致说明 TIM2 触发、ADC 转换、DMA 搬运三个环节全部正常工作。这个方法比打开调试器看内存数组更快也更适合现场排查。确认链路通之后再用调试器把adc_buf加入 Watch 窗口观察其数值是否随输入电压变化。任何一步不对都可以借助“先确认 GPIO 频率、再检查数组数值”这个顺序定位到具体是定时器没跑、还是 ADC 没转换、还是 DMA 没搬运。4. 缓冲区的数据组织多通道顺序、双缓冲切块与内存对齐链路通了之后第二个问题接踵而至数据在内存里怎么排列多个通道如何区分半满和全满回调各自动含义是什么这一章把缓冲区管理讲透照例程包里的代码操作即可。4.1 多通道规则转换与内存数组的对应关系F411 的 ADC1 支持规则通道组CubeMX 里配置“Number of Conversion”为 3然后在 Rank 1、Rank 2、Rank 3 里分别指定通道号就定义了规则转换顺序。DMA 搬运时严格按照这个顺序执行先转 Rank 1 的通道结果放在数组第 0 个元素再转 Rank 2结果放在第 1 个元素最后转 Rank 3结果放在第 2 个元素。然后进入下一轮扫描Rank 1 的结果继续放在第 3 个元素以此类推。#define NUM_CHANNELS 3 #define ADC_BUF_SIZE 300 static uint16_t adc_buf[ADC_BUF_SIZE]; /* 取最近一次扫描的三个通道值 */ uint16_t ch0_latest adc_buf[ADC_BUF_SIZE - NUM_CHANNELS 0]; uint16_t ch1_latest adc_buf[ADC_BUF_SIZE - NUM_CHANNELS 1]; uint16_t ch2_latest adc_buf[ADC_BUF_SIZE - NUM_CHANNELS 2];这段代码从数组尾部取出最新一轮扫描的三个通道值。因为 DMA 按照扫描顺序持续搬运数组末尾恰好是最近完成的规则组数据。这样读取不会打乱正在进行的数据搬运也省去维护“当前最新值在哪个下标”的状态变量。实际项目里更推荐直接维护三个latest变量在回调里更新主循环只读变量这样多线程和中断之间的竞争更少。这里有一个容易混淆的点ADC 的“扫描模式”和“DMA 搬运”是两个独立机制。扫描模式决定规则组按顺序转换DMA 搬运决定转换结果如何进内存。扫描顺序变了内存排列随顺序变但 DMA 配置本身不用动。所以调试多通道数据错位时先检查 Rank 顺序不要先去翻 DMA 参数。4.2 半传输与全传输回调双缓冲的正确切块方式DMA 的 Circular 模式天然支持双缓冲策略但很多开发者对两个回调的理解是反的导致处理数据时读到半新半旧的数据。DMA 配置为 256 个半字缓冲时搬运过程是这样的DMA 从起始地址写到第 128 个元素触发半传输中断此时前半段数据完整可读DMA 正在写后半段。继续搬运到第 256 个元素触发全传输中断此时后半段数据完整可读DMA 立刻重新回到起始地址开始覆盖前半段。所以两个回调的正确分工是#define HALF_INDEX (ADC_BUF_SIZE / 2) void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { /* 前半段已写完且尚未被覆盖可以安全读取 [0, HALF_INDEX) */ process_buffer(adc_buf[0], HALF_INDEX); } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { /* 后半段已写完DMA 正准备覆盖前半段读取 [HALF_INDEX, BUF_SIZE) 安全 */ process_buffer(adc_buf[HALF_INDEX], HALF_INDEX); } }双缓冲的价值在于CPU 处理一个半区数据的时间DMA 在写另一个半区两者并行。只要process_buffer的执行时间小于半个缓冲区的写入时间数据就不丢。整批数据处理完再切到另一半区始终保持“处理一边、采集另一边”的状态。很多网上代码会在完成回调里整个数组一起处理这实际上会读到一半新一半旧的数据瞬时波形出现毛刺还找不到原因。如果缓冲区只有 64 或 128 个元素半区处理时间更紧可以把回调里的活儿进一步拆细回调只复制指针或打标志主循环判断标志后再处理彻底隔离中断上下文和应用上下文。4.3 缓冲区的内存对齐与 volatile 使用边界F4 的 DMA 支持非对齐访问但实践上推荐把 ADC 缓冲区做 4 字节对齐。原因有两点一是对齐后的内存访问在 AHB 总线上是单次事务非对齐访问可能被拆成多次高采样率下带宽边际变紧二是有些调试器在对齐变量上断点观察更方便。#if defined(__CC_ARM) __align(4) static uint16_t adc_buf[ADC_BUF_SIZE]; #elif defined(__GNUC__) static uint16_t adc_buf[ADC_BUF_SIZE] __attribute__((aligned(4))); #endif__align(4)与aligned(4)分别适配 ARMCC 和 GCC 工具链。嵌入式工程常在这两种编译器之间切换条件编译可以避免换 IDE 后回滚内存布局。缓冲区是数组首地址对齐元素本身是 16 位半字所以后续每个元素地址自然会落在偶数地址上。另一个高频踩坑点是 volatile。很多开发者把整个缓冲区声明成volatile uint16_t adc_buf[...]期望 CPU 每次访问都从 SRAM 重新读取。但实际上缓冲区只有一个写者DMA和一个读者CPUDMA 写完后 CPU 再读不存在需要 volatile 维持一致性的场景。真正需要 volatile 的是那个跨中断和主循环传递的标志变量static volatile uint8_t transfer_full_done 0;如果transfer_full_done不带 volatile编译器可能把while(1) { if (transfer_full_done) ... }优化成第一次判断后直接缓存旧值导致主循环永远等不到标志更新。这个问题在 O2 优化下尤其明显Release 构建经常比 Debug 更容易卡死。所以原则是数据缓冲区不用 volatile控制标志必须 volatile。5. 常见问题排查从数据静止到波形失真的四个真实坑这一章是例程包调试阶段的血泪记录四条踩坑记录按照“现象→原因→解决”的方式整理每条都对应工程里真实出现过的故障。5.1 现象采样数据一动不动数组全为 0 或停在初始值调试器里 Watch 窗口看adc_buf内容自始自终不变。检查HAL_ADC_Start_DMA和HAL_TIM_Base_Start均已调用程序也不卡死。原因是 TIM2 计数跳转正常但 TRGO 没有连接到 ADC 触发源常见于 CubeMX 里选了“Timer 2 Trigger Out event”后又改了 TIM2 的 TRGO 模式或者 ADC 的 External Trigger 设置被重置成了 Software Trigger。还有少部分情况是把HAL_ADC_Start_DMA的第三个参数误传成缓冲区字节数DMA 计数器直接溢出失效。解决方法是先查 TIM2 的 CNT 寄存器是否在计数再确认hadc1.Init.ExternalTrigConv的值为ADC_EXTERNALTRIGCONV_T2_TRGO。如果外部触发无误在HAL_ADC_Start_DMA之前用一次软件触发验证 DMA 通路临时把触发源改成 Software Trigger调用HAL_ADC_Start再HAL_ADC_PollForConversion如果 DMA 数据开始变化说明搬运链路正常问题只在触发源。验证完后再改回 TIM2 配置。5.2 现象多通道采集时数据错位通道与数组索引对不上配置了 3 个通道输入 2V 信号的通道读数却出现在另一个通道的数组位置或数据整体向后偏移一个转换周期。原因是规则通道顺序配置与 DMA 搬运顺序不一致重点检查 CubeMX 里 Rank 1/2/3 的通道号是否与实际接线一一对应。另一种情况是 ADC 过程中设了EOCS位导致每次单通道转换结束就触发一次 DMADMA 搬运频率变成原设计的 N 倍数据被穿插进额外的转换结果。解决方法是给每个通道接一个可辨识电压比如通道 0 接 0.5V、通道 1 接 1.0V、通道 2 接 1.5V然后观察数组每 3 个元素一组的序列是否按顺序对应。再用代码把各通道的数组元素分别抽出打印前 9 个值错位位置一目了然。确认顺序后检查 CubeMX 配置页里“Number of Conversion”是否仍为 3EOCS 是否被意外打开。5.3 现象零点偏大且非线性接地读数不是 0 而是几十 LSB输入接地采集数组里稳定显示 50~300 LSB 不等的底噪放大后动态范围明显受压。原因是漏掉了 ADC 校准。F4 系列 ADC 上电后内部电容和比较器存在失调电压HAL 库提供HAL_ADCEx_Calibration_Start在运行时自动校准但很多示例代码不会在普通工程里主动调用。如果校准函数根本没有执行ADC 输出自然不是零偏。解决是在 ADC 初始化完成后延时一小段时间再调用一次校准即可。这里有一个细节校准必须在 ADC 时钟稳定之后做所以HAL_Delay(10)放在MX_ADC1_Init和校准调用之间是合理的。如果程序后续改动分辨率或参考电压校准值会失效需要重新校准。批量产品里建议每次上电都做校准而不是只在调试阶段做避免芯片个体差异带来的读数散差。5.4 现象回调里加了数据处理后波形开始丢点或频率变慢把滤波器、FFT 或串口打印直接放进HAL_ADC_ConvCpltCallback后采样率下降、波形出现间歇性毛刺甚至 DMA 中断丢失。原因是回调处理耗时长于 DDM 搬运一整批数据的时间DMA 在写下一批数据时发现上一批还没处理完中断标志被覆盖或数据被新数据覆盖。串口打印尤其危险默认的printf重定向到 UART 是阻塞发送一个字符在低速波特率下就要几微秒整包打印会把回调卡死几十毫秒。FFT 也是常见凶手工程里第一个版本把 128 点 FFT 直接放进回调采样率掉到预期的三分之一。解决方法是回调里只置标志数据处理放在主循环或更高优先级任务里执行串口打印改用 DMA 发送或只在主循环低频率输出高频采集场景建议每采集完一批就只处理该批内抽样的几个点不要全量运算。另一个稳妥做法是把缓冲区扩大一倍两个半区加长CPU 有更多时间处理回调负载自然降下来。这套改动之后同样 500kHz 触发率回调负载降到原来的十分之一波形完整性明显好转。6. 进阶验证与边界测试采样频率标定、波形重建与触发上限链路跑通、数据不丢之后要做一件容易被忽略但很值得做的事标定采样频率。不要假设 TIM2 配置的 1kHz 就是实际 1kHz因为时钟树配置、PLL 分频系数、ADC 采样时间的组合效应实际触发频率可能偏了百分之几。对波形分析场景这个偏差对低频信号影响不大但对音频或振动分析帧内的点数偏差会导致频谱偏移。我的做法是用信号发生器给通道输入一个已知频率的方波比如 1kHz 方波让 TIM2 触发 ADC 采集中等长度的缓冲区然后在数据里数上升沿的数量反过来推算实际采样率。/* 伪代码统计缓冲区中电压跨越阈值 VTH 的次数 */ uint16_t count_edges(uint16_t *buf, uint16_t len, uint16_t vth) { uint16_t edges 0; for (uint16_t i 1; i len; i) { if (buf[i] vth buf[i - 1] vth) { edges; } } return edges; }假设配置的是 50kHz 触发缓冲区 4096 个样本数出 49 个上升沿则实际采样率约等于 50kHz 乘以 49 除以实际方波周期对应的点数。更简单的方式是直接在HAL_ADC_ConvCpltCallback里翻转 GPIO用示波器量翻转周期倒推完整缓冲区耗时。两种方法配合起来既能确认时基也能确认 DMA 中断没有被反复触发。边界测试关注的是 ADC 转换时间上限。12 位 ADC 单次转换总周期 采样时间 转换时间转换时间固定为 12 个 ADC 时钟周期。如果 ADC 时钟是 25MHz采样时间选 15 个周期单次转换需要 27 个时钟周期最高理论触发率大约是 25MHz / 27约 926kSPS。实际工程建议把触发率压到理论值的一半以下留出 DMA 竞争和中断延迟的余量稳定跑 300kHz 左右比较踏实。ADC 采样时间单次转换总周期25MHz 下理论上限建议触发上限15 Cycles27 Cycles926kSPS300kSPS84 Cycles96 Cycles260kSPS100kSPS480 Cycles492 Cycles50.8kSPS20kSPS这个例程包把时序参数、缓冲管理和回调结构都整理成了可以直接编译的最小工程解压后对照本章节的验证流程跑一遍基本能把链路边界摸清楚。从那以后我每次拿到新的 F4 例程包都强制走一遍这套标定流程先点亮 GPIO 测批量时间再送已知频率信号数上升沿确认采样率没有偏移最后才谈滤波和算法。希望帮到你。本文还有配套的精品资源点击获取