news 2026/9/7 12:12:17

STM32 ADC多通道采集:DMA配置与CubeMX实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 ADC多通道采集:DMA配置与CubeMX实战指南

简介:STM32结合ADC与DMA实现多通道数据采集,是一份面向嵌入式初学者与开发者的完整工程资源。该方案利用STM32内置ADC完成多路模拟信号采样,再通过DMA直接传输至内存,减少CPU干预,适用于环境监控、工业设备、电源管理等高频率实时采集场景。压缩包共219个文件,约8.49MB,主要包含HAL库源码(.c/.h)、工程配置(.ioc/.uvprojx)、编译产物(.o/.axf/.hex)以及链接映射(.map)等,便于直接打开工程查看初始化与中断处理逻辑。已有1680人学习下载。通过阅读代码可掌握ADC通道序列配置、DMA循环传输、转换完成中断处理等关键环节,同时还能学习如何用CubeMX生成基础工程并基于HAL库编写驱动,适合希望快速上手STM32多通道采集的开发者作为参考模板。 做单片机开发的朋友多半碰过这种场景:一个板子上同时要采好几路模拟量,三相电流、两路电池电压、一路温度,用STM32的ADC去读本来不难,但通道一多、响应一快,主循环里就会被各种等待转换的代码塞满。我这次把STM32+ADC+DMA多通道采集的完整操作讲一遍,包括原理、配置、参数计算、代码和调试时踩过的坑,给你一份能直接照抄的作业。这里面既有新手容易卡住的CubeMX选项解释,也有老手偶尔翻车的对齐和优先级问题,做数据采集、仪器仪表、电机控制的朋友都能用得着。

1. 多通道采集为什么绕不开DMA

1.1 一次ADC多通道转换,数据是怎么跑起来的

先弄明白ADC本身的工作方式。STM32的ADC模块是逐次逼近型结构,内部有个采样保持电容,外部模拟电压先通过采样开关给电容充电,再靠内部的比较器一位一位逼近出数字结果。多通道采集时,我们要把若干个通道放进"规则组",开启扫描模式后,ADC会按照Rank序号挨个把每个通道转换一遍。比如Rank1配的是通道0,Rank2配的是通道1,一轮扫描结束后,四个通道的结果就按顺序依次出现在同一个数据寄存器ADC_DR里。

这里有一个非常关键的细节:每转换完一个通道,ADC_DR里只保留最新结果。如果你没在通道0的结果被通道1覆盖之前把它读走,这个数就没了。也就是说,没有DMA时,CPU必须在"转换完成"这件事上随叫随到。读取速度跟不上,数据就会丢。DMA的作用就是替CPU盯住这个时间点,转换完成信号一来,DMA控制器直接把ADC_DR的值搬到内存数组,全程不用CPU干预。

1.2 查询、中断、DMA三种方式的真实成本和取舍

查询方式代码最简单,但代价也最直观。你永远不知道下一次转换什么时候完成,只能用while循环死等,等一个通道、读一个通道,CPU基本就焊死在ADC这件事上了。只采一路慢速温度信号还能忍,四路八路还要跑显示、通信、控制算法,查询方式直接拖垮主流程。

中断方式比查询好一些,每个通道转换完成后触发一次中断,在中断里搬运16位数据。但中断频率等于通道数乘以每秒轮数,四通道跑10kHz刷新,一秒就是四万次中断,CPU的时间大量花在进出栈上,其他实时任务的响应就会变得不稳定。DMA方式下,转换信号直接触发DMA搬运,搬完一整轮才产生一次中断(开了半传输中断就是半轮一次),CPU负载瞬间降下来。

我最推荐的是"连续转换+DMA循环模式"这个组合:ADC启动后不停扫描四个通道,DMA也不停把数据写进数组,像一条自动流水线在后台跑。主循环想用数据时,直接读数组对应元素就行。CPU只是偶尔来取货,不用站在传送带旁边干等。

2. 动手之前:引脚、时钟和CubeMX工程规划

2.1 引脚选择和信号源内阻,直接决定采集准不准

多通道采集第一步不是写代码,而是确认引脚。以F103为例,PA0到PA7都能复用为ADC1的输入通道,对应通道0到通道7。你要确保这些引脚没有被其他外设占用,比如PA9、PA10是USART1,和串口冲突的话排线就要乱套。还需要注意,同时采多路模拟量时优先用ADC1的通道,因为F103的ADC1搭配DMA最顺畅,ADC2、ADC3也能用,但DMA映射在部分型号上有限制,新手阶段先把ADC1玩明白再说。

很多人忽略信号源内阻的问题。STM32的ADC内部采样电容不是理想器件,采样时间有限,如果外部信号源阻抗很高,比如几kΩ以上直接用个电位器分压接进来,采样瞬间电容来不及充到稳定电压,读出来的值就会偏低而且跳动大。实际项目里我习惯在ADC引脚前加一级RC低通,R取1kΩ左右,C取100nF,既滤高频噪声又能保证充电速度。如果你的信号源确实很高阻,比如光敏电阻直接分压,那就加一个运放跟随器把阻抗降下来,精度立刻改善。

2.2 CubeMX里的关键选项:扫描模式、连续转换和DMA

用CubeMX生成工程时,ADC1的Parameter Settings里几个选项要格外注意。ScanConvMode必须设为Enabled,这是多通道采集的前提,否则ADC只会反复转换你指定的那一个通道。ContinuousConvMode建议打开,转换完一轮自动开启下一轮,配合DMA循环模式实现后台持续采集。DiscontinuousConvMode保持Disabled,它是给特殊触发场景用的,普通多通道采集用不上。NumberOfConversion填通道数,比如4。ExternalTrigConv选Software Start,代码里软件启动,不依赖定时器。

DMA Settings里,把ADC1的DMA请求加上,Mode选Circular循环模式,Data Width选Half Word。F103上ADC1的DMA请求是固定连接到DMA1的Channel1,CubeMX会自动配对,不用手动选。优先级建议给High,避免高负载时ADC的数据搬运被其他DMA请求挤掉。

2.3 数组大小、通道数量和Rank顺序怎么对齐

DMA搬运的目标是内存数组,启动时指定的传输次数就是通道数。四个通道就定义uint16_t adc_buf[4],启动调用HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf, 4)。数组下标和通道的对应关系完全由你在ADC里设定的Rank顺序决定,Rank1的结果固定存入buf[0],Rank2存入buf[1],依次类推。所以你配Rank顺序时,脑子里要清楚"buf[0]究竟对应哪个物理通道"。

这里埋了一个常见坑:如果你中途改了CubeMX里Rank顺序,但程序里读数据的代码没改,数据和引脚对不上是必然的。建议代码里别用裸数字0、1、2、3,而是定义成ADC_CH_CURRENTADC_CH_VOLTAGE这样的宏,哪天调换顺序只动一个地方。

3. 核心参数计算:采样周期、转换时间与DMA配置

3.1 用公式算清楚一轮多通道采集到底要多久

ADC单次转换时间由两部分组成:采样阶段加转换阶段。12位分辨率下,转换阶段固定占12.5个ADC时钟周期,采样阶段由SamplingTime决定。F103的ADC时钟来自APB2,经过预分频,为了不超规格,我把APB2设为72MHz、分频6,得到12MHz的ADC时钟。公式是:单通道转换时间=(采样周期+12.5)/ADC时钟。采样时间设为55.5周期时,单通道就是(55.5+12.5)/12MHz=5.67us,四通道一轮约22.7us,每秒大概能刷新四万多轮。

这个数值已经很充裕了。要采高阻慢变信号时,采样时间拉大点,让采样电容充得更满,数值更稳;要采几百kHz的快速信号时,就得用短采样时间同时降低源阻抗。下面这个对照表可以直接参考:

采样时间(周期)单通道转换时间(12MHz ADC时钟)适合场景
1.51.17us极低阻信号源,追求高刷新率
7.51.67us一般低阻传感器
13.52.17us普通运放输出
28.53.42us稍高阻信号,希望稳一点
55.55.67us高阻或慢变信号,优先保证准确
71.57.00us很在意抗干扰时
239.521.00us极慢信号,速度无所谓

3.2 DMA方向、数据宽度、循环模式怎么选

DMA方向没有悬念,外设到内存DMA_PERIPH_TO_MEMORY。数据宽度里,外设侧选Half Word,内存侧也选Half Word,因为ADC结果就是16位。这里特别注意,外设地址增量PeriphInc保持Disable,内存地址增量MemInc必须Enable,否则每次搬运都写到同一个地址,四路数据全堆在buf[0]里,后面的数据全被覆盖。

Mode选Circular后,DMA搬完一轮会自动把内存地址指针指回数组头,不需要CPU干预。连续转换模式下,数组里的数据永远是最新的一轮结果。启动后只要不调用HAL_ADC_Stop_DMA,这套流水线就会一直跑,数据始终保持更新状态。

3.3 容易翻车的半字对齐和连续请求选项

半字对齐这个坑我实际遇到不止一次。有人把外设端设为HalfWord、内存端设为Word,看起来只是"放宽"了宽度,结果DMA每次从ADC_DR取16位数据,却按32位写进内存,buf[1]直接落到数组第三个位置,数据全部错位。反过来外设端Word、内存端HalfWord也乱。最稳的做法就是两边一致,都用HalfWord。

另外,F3、F4的部分系列在DMA配置里会有Continuous Requests这类选项,作用是让DMA通道持续请求总线,不再依赖外设事件。听起来效率高,但实际会一直占用总线带宽,多通道采集场景没必要开,开了之后其他外设的DMA传输反而会被拖慢,属于典型的"看着厉害,用着受罪"。

4. 完整代码与实测数据验证

4.1 F103上的HAL库初始化代码

贴一段我实际用过的初始化代码,四通道电池电压采集的简化版,关键行都加了注释。CubeMX生成工程后,你只需要确认这几个函数,然后在main里调用启动。

static void MX_ADC1_Init(void) { ADC_ChannelConfTypeDef sConfig = {0}; hadc1.Instance = ADC1; hadc1.Init.ScanConvMode = ADC_SCAN_ENABLE; // 多通道扫描 hadc1.Init.ContinuousConvMode = ENABLE; // 连续转换 hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START; // 软件触发 hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; // 12位右对齐 hadc1.Init.NbrOfConversion = 4; // 4个通道 HAL_ADC_Init(&hadc1); sConfig.SamplingTime = ADC_SAMPLETIME_55CYCLES_5; // 采样时间长一点,稳 sConfig.Channel = ADC_CHANNEL_0; sConfig.Rank = 1; HAL_ADC_ConfigChannel(&hadc1, &sConfig); sConfig.Channel = ADC_CHANNEL_1; sConfig.Rank = 2; HAL_ADC_ConfigChannel(&hadc1, &sConfig); sConfig.Channel = ADC_CHANNEL_2; sConfig.Rank = 3; HAL_ADC_ConfigChannel(&hadc1, &sConfig); sConfig.Channel = ADC_CHANNEL_3; sConfig.Rank = 4; HAL_ADC_ConfigChannel(&hadc1, &sConfig); }

DMA的初始化一般由CubeMX生成在单独文件里,核心内容如下。注意__HAL_LINKDMA这行,它就是把ADC句柄和DMA句柄绑定在一起的关键,少了它HAL_ADC_Start_DMA根本跑不起来。

hdma_adc1.Instance = DMA1_Channel1; hdma_adc1.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_adc1.Init.PeriphInc = DMA_PINC_DISABLE; hdma_adc1.Init.MemInc = DMA_MINC_ENABLE; hdma_adc1.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; hdma_adc1.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; hdma_adc1.Init.Mode = DMA_CIRCULAR; hdma_adc1.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_adc1); __HAL_LINKDMA(&hadc1, DMA_Handle, hdma_adc1);

启动采集就一行,放在main初始化之后,之后它就一直跑:

HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 4);

这里提醒一下初始化顺序。CubeMX生成的执行顺序一般是GPIO、DMA、ADC,这个顺序不要乱调,因为ADC初始化时要用到已经注册好的DMA句柄。如果你把MX_DMA_Init放到MX_ADC1_Init后面,HAL库在启动时找不到DMA句柄,直接跑飞或者报错,排查起来还不太容易想到是这个原因。

4.2 在主循环里稳定读取多通道数据

直接读adc_buf[0]没有问题,但有个隐患:DMA每时每刻都在写数组,如果你在主循环里连续读完四个元素,读一半DMA又覆盖了buf[0],这轮数据就是新旧混合的。简单做法是先把四路数据一次性复制到本地数组再处理,复制过程虽然也可能被DMA打断,但一次复制的时间非常短,实际影响可以忽略。

要求再高一点,可以用DMA传输完成中断加标志位,在回调里只置标志,真正数据处理放主循环:

volatile uint8_t adc_ready = 0; void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { adc_ready = 1; } }

主循环里判断到adc_ready为1时,才读数组、清标志。中断回调里尽量不做事,只置标志,避免在中断上下文里耗时过长。

4.3 软件滤波和电压换算:从原始码值到有意义的结果

ADC出来的原始值是码值,12位分辨率下范围0到4095。换算电压很简单:电压=码值×Vref/4095。F103的Vref内部接到VDDA,大部分板子上就是3.3V,所以3.3V对应4095。

滤波这一步直接影响使用体验。我常用的两段式:先中值滤波剔毛刺,再滑动平均平滑波形。中值滤波的代码很经典,九个值排序后取中间那个,对脉冲噪声特别有效:

uint16_t adc_median_filter(uint16_t *arr, uint8_t len) { uint8_t i, j; uint16_t tmp; for (i = 0; i < len - 1; i++) { for (j = 0; j < len - i - 1; j++) { if (arr[j] > arr[j + 1]) { tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; } } } return arr[len / 2]; }

然后对中值结果再做几次滑动平均,毛刺基本就被压住了。如果你的信号本身就是缓慢变化,多采几轮再平均,波动可以控制在±2个LSB以内。这里有一个容易忽略的细节:ADC参考电压VDDA必须稳定,最好单独加滤波电容,VDDA上的一点纹波,最后都会变成采样结果里的一串噪声,这属于硬件层面的事,但往往比软件滤波更能解决问题。

4.4 实测数据长这样,才算调通了

调试时我习惯在四个通道上分别接固定电压:通道0接3.3V,通道1接地,通道2接一个精密电阻分压得到的1.65V,通道3接电位器。实测读数应该是:通道0稳定在4095附近,通道1在0附近,通道2在2045到2048之间摆动,通道3随电位器拧动线性变化。抖动不超过±3个LSB,说明整条链路是通的。

如果发现数值差得离谱,比如接地通道读出来还有几百,先怀疑接线和电源参考,而不是怀疑DMA。用串口打印数据观察最直观,如果你的串口调试助手数据不稳定,先看波特率,再看USB转串口驱动。设备管理器里虚拟串口带黄色感叹号,通常是驱动没装好,把对应厂商的驱动重装一遍就能解决。这类问题和数据采集本身没关系,但排查时最容易卡住人。

5. 常见问题排查实录

5.1 通道数据错位,DMA搬回来的值和引脚对不上

最典型的症状:明明PA0接的3.3V,读取的buf[0]却不是4095,反而是buf[2]对得上。原因基本是Rank顺序和代码读取顺序不一致,或者DMA的内存地址增量没开。解决办法很简单,回到CubeMX确认Rank1对应通道0、Rank2对应通道1,再检查启动DMA的传输次数是不是4,数组元素个数是不是4。还有一个隐蔽原因:如果你在CubeMX里改过引脚复用,但工程没有重新生成,代码里的GPIO配置和目标引脚对不上,也会出现这种"看起来像DMA错了,其实引脚错了"的情况。遇到错位先用万用表量引脚电压,再对照串口打印的数据,能很快缩小范围。

5.2 首轮采集值偏小或全为0

ADC模块上电后不是立刻稳定的,内部参考和采样电容需要一点建立时间。启动后立刻读DMA数组,第一轮或者前几轮数据经常偏小,甚至读出来一串0。我的习惯是启动DMA后延时个几毫秒,再把启动阶段的数据清掉,从后面几轮开始用实际数据:

HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 4); HAL_Delay(2); // 给ADC一点建立时间 memset(adc_buf, 0, sizeof(adc_buf)); // 丢弃启动阶段垃圾数据

后面连续采集模式下,数据稳定后再去"消费"就行。另外,引脚悬空时读数本来就是随机的,别拿悬空通道校验数据,也不要因为悬空通道跳得厉害就怀疑DMA配置。

5.3 下载时报No STM32 Target Found,程序却已经跑了

这个报错遇到过太多次,包括我第一次也卡过。它通常不是代码逻辑问题,而是调试器连接问题。检查顺序大概是:SWD两根线SWDIO、SWCLK是否接对,地线是否共地;芯片供电是否正常,VDD和VDDA都要量一下;调试器驱动是否装好,设备管理器里有没有识别到设备;BOOT0是不是被意外拉高,拉高的话芯片会进入ISP模式,调试器自然连不上;程序里如果之前开了低功耗模式,也会让内核睡死,导致调试器无法连接。

老手常用的土办法是:按住复位键,点下载后立刻松开。很多时候连不上只是时序没赶上,这一招能救急。如果是较新的芯片提示和Debug Authentication相关,多半是读保护等级被提高或者调试认证配置问题,需要用官方工具解除读保护再操作。

5.4 ADC和串口DMA同时用时,怎么避免互相干扰

工程里ADC用DMA,串口发送也用DMA,是很常见的组合。F103的DMA1有多个Channel,ADC1占用Channel1,串口可以用其他Channel,硬件上不冲突,但总线访问会有竞争。ADC的DMA优先级配得比串口高,高频采集时串口发送才不会频繁抢占总线导致ADC数据偶发丢失。串口DMA发送还有一个细节:必须等上一轮发送完成才能启动下一轮,否则数据会错乱或者卡死。最简单的方式是发送前检查状态:

while (huart1.gState != HAL_UART_STATE_READY); HAL_UART_Transmit_DMA(&huart1, tx_buf, len);

实际工程里这个while可以加超时机制,避免在异常状态下死等。这个"等上一轮发完再发下一轮"的思路,和前面DMA循环采集其实是一回事:DMA只管搬运,任务的节奏始终要在应用层控制好。

我做过多路数据采集的小项目,从四路电池电压采样到八路温度采集,底层基本都是这套STM32+ADC+DMA的组合。这套方案本身已经很成熟,难点反而不在初始化代码,而在你对数据链路的理解:ADC怎么采、DMA怎么搬、应用层怎么消费。把这三点想清楚,多通道采集就真的只是配置问题。最后分享一个小技巧:如果你用STM32CubeIDE调试,可以把adc_buf数组加到Live Expressions窗口,实时观察四路数据的值,比串口打印直观得多,排查数据错位这类问题能省下不少时间。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 12:11:45

Linux内核context_switch深度解析:地址空间与内核栈的切换奥秘

1. context_switch到底在交接什么东西1.1 __schedule最后一步&#xff1a;把CPU从prev交到next手里如果你把__schedule比作一次交接仪式&#xff0c;那context_switch就是真正把接力棒递出去的那一下。前面pick_next_task选好了next&#xff0c;更新了各种统计和调度类回调&…

作者头像 李华
网站建设 2026/9/7 12:11:28

树莓派5无外设安装Ubuntu:从烧录到SSH远程登录的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 12:10:27

ComfyUI整合包2026旗舰版:零基础部署Stable Diffusion工作流

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 12:05:45

SpringBoot+Vue社区志愿者管理系统:从部署到二次开发全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 12:03:34

元器件采购平台怎么选?按预算分档的实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 12:03:19

硬件工程师面试20问:从运放到信号完整性的高频考点与实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华