STM32 ADC单通道采集实战:从原理到滤波与DMA应用
2026/9/12 15:00:23 网站建设 项目流程

1. 项目缘起:为什么单ADC模数转换是嵌入式开发的必修课

在嵌入式开发领域,尤其是基于STM32这类主流MCU的项目中,模数转换器(ADC)的使用频率几乎和GPIO、定时器一样高。无论是读取电位器的旋钮位置、监测电池电压,还是采集传感器(如温度、光照、压力)的模拟信号,最终都需要通过ADC将连续的物理世界信号,转换为MCU能够理解和处理的离散数字值。很多新手朋友拿到开发板,跑通流水灯和串口打印后,第一个想动手实战的往往就是ADC。因为它直接连接了数字的“0”和“1”与真实的“电压”和“电流”,这种将模拟量数字化的过程,充满了工程师的掌控感。

然而,看似简单的“读取一个电压值”,在实际项目中却可能遇到各种意想不到的问题:读数跳变剧烈、数值不准、采样时机难以控制,或者代码结构混乱导致主循环被阻塞。这些问题背后,往往是对ADC工作机制、STM32的HAL库(或标准库)使用方式、以及嵌入式系统中数据流处理的理解不够深入所致。今天,我们就以STM32最基础的“单ADC模数转换”为切入点,抛开那些复杂的多ADC同步、双ADC交替采样等高级模式,深入聊聊如何稳健、高效地实现一个单通道的ADC数据采集。我会结合自己这些年调试过的几个真实项目案例,把从原理到配置,再到代码实现和避坑的经验,一次性讲透。

2. 核心原理拆解:STM32的ADC到底是怎么工作的?

在动手写代码之前,我们必须先搞清楚STM32内部的ADC模块是如何运作的。这就像开车前得知道油门、刹车和方向盘是干嘛的,而不是只记住“踩右边是走,踩左边是停”。

2.1 ADC的基本工作流程:从引脚电压到寄存器数值

STM32的ADC本质是一个逐次逼近型(SAR)模数转换器。你可以把它想象成一个非常精密的“猜数字”游戏。假设输入电压范围是0-3.3V,对应的数字输出范围是0-4095(对于12位分辨率)。当输入一个2.0V的电压时,ADC内部会先猜“是不是一半,即2048(对应1.65V)?”,比较器发现实际电压(2.0V)比猜的(1.65V)高,于是它就知道答案在2048到4095之间。接着它再猜这个区间的中间值,如此反复,经过12次(对于12位ADC)比较,最终锁定最接近2.0V的那个数字值,比如2470(假设值)。

这个过程由ADC模块内部的电路自动完成,我们的程序只需要触发一次转换,然后等待转换完成,最后去读取结果寄存器(DR)里的数字即可。这个“触发”是关键,它决定了ADC何时开始这次“猜数字”游戏。

2.2 关键概念:分辨率、采样时间与参考电压

  • 分辨率:常见的有12位、10位、8位。12位分辨率意味着输出范围是0-4095(2^12 - 1)。这个值决定了ADC的“刻度精细度”。4096个刻度去度量0-3.3V,每个刻度(LSB)代表的电压是 3.3V / 4095 ≈ 0.806mV。分辨率越高,能区分的电压变化就越细微。
  • 采样时间:这是最容易忽略也最容易出问题的地方。ADC前端有一个采样保持电路,它内部有一个小电容。当开始转换时,它需要先闭合开关,让外部引脚上的电压给这个小电容充电,直到电容电压和外部电压基本一致,这个阶段就是“采样”。采样时间(Sampling Time)就是开关闭合的时长,通常以ADC时钟周期为单位。如果采样时间太短,电容还没充到目标电压,转换就开始了,结果必然不准。对于高阻抗的信号源(比如用一个很大的电阻分压),充电慢,就需要更长的采样时间。
  • 参考电压(VREF+和VREF-):这是ADC度量的“标尺”。通常VREF-接GND(0V),VREF+接3.3V(AVDD)。这意味着ADC认为0V对应数字0,3.3V对应数字4095。参考电压的稳定性直接决定了ADC的绝对精度。如果系统电源纹波大,AVDD在3.2V到3.4V之间波动,那么你读到的数字值即使很稳,换算成的电压值也是飘的。在一些高精度场合,会使用独立、高精度的基准电压芯片(如REF3033)来提供VREF+。

2.3 单次、连续与扫描模式

  • 单次转换模式:触发一次,只对一个选定的通道进行一次完整的“采样-转换”流程,然后ADC就停止并等待下一次触发。这是最省电、最基础的模式。
  • 连续转换模式:一旦启动,ADC就会马不停蹄地、一个接一个地对选定通道进行转换。完成一次后立即开始下一次,不需要外部重复触发。适合需要持续监控信号的场景。
  • 扫描模式:当使能了多个通道时,ADC会按照预设的顺序(通常是通道号从小到大),自动依次对每个通道进行转换。扫描可以配合单次或连续模式。在单次模式下,一次触发会完成一轮对所有使能通道的扫描;在连续模式下,则会不停地循环扫描所有通道。

对于我们今天的主题“单ADC模数转换”,通常指的是使用一个ADC模块,对单个通道进行数据采集。模式上,单次和连续都可以,取决于应用需求。

3. 实战配置:从CubeMX到代码生成

理论聊完,我们进入实战。我强烈推荐使用STM32CubeMX进行初始化配置,它能图形化地设置所有参数,并生成高度可移植的HAL库代码,极大避免了底层寄存器配置的错误。这里我们以STM32F103C8T6(蓝色药丸核心板)的ADC1通道0(PA0)为例。

3.1 CubeMX图形化配置步骤

  1. 选择ADC接口:在Pinout & Configuration标签页,左侧找到Analog下的ADC1。在IN0对应的引脚PA0上,选择IN0 Single-ended。这表示我们将ADC1的通道0配置为单端输入模式(最常用)。
  2. 参数配置:点击ADC1,进入参数设置。
    • Clock Prescaler: ADC时钟分频。ADC时钟(ADCCLK)由APB2时钟分频而来,不能超过芯片手册规定的最大值(对于F1是14MHz)。如果系统时钟72MHz,这里选PCLK2 divided by 6,得到12MHz的ADCCLK,是安全的。
    • Resolution: 选择12-bit。这是我们最常用的分辨率。
    • Data Alignment: 选择Right alignment(右对齐)。这样读取到的16位数据寄存器的低12位就是有效值,高4位为0,处理起来最直观。
    • Scan Conversion Mode: 因为我们只用一个通道,所以选择Disabled。如果使能,即使只配一个通道,ADC也会以扫描模式工作,可能会有些许不同。
    • Continuous Conversion Mode: 选择Disabled(单次模式)或Enabled(连续模式)。我们先从单次开始。
    • Discontinuous Conversion Mode: 禁用。
    • DMA Continuous Requests: 单通道单次采集,暂时不用DMA,禁用。
    • End Of Conversion Selection: 保持默认。
  3. 通道配置:在下方Rank表格中,会看到Channel 0
    • Sampling Time: 这是重中之重!对于连接在PA0上的信号,如果信号源阻抗低(比如直接来自运放输出),1.5 Cycles可能就够了。但如果来自电位器分压(阻抗可能在10kΩ量级),就需要更长时间。一个保守且通用的选择是239.5 Cycles原则是:在ADC时钟允许和转换速度要求内,尽可能选长一点的采样时间,可以显著提高稳定性,减少噪声影响。
  4. 触发配置:在Regular Conversion Trigger中,Trigger Source选择Software trigger。这意味着我们将通过软件调用HAL_ADC_Start()HAL_ADC_Start_IT()来启动转换。
  5. 生成代码:配置好系统时钟、工程名、IDE等选项后,点击GENERATE CODE

3.2 生成的代码结构与关键函数解析

CubeMX会生成adc.cadc.h。在adc.cMX_ADC1_Init函数里,包含了我们刚才的所有配置。我们需要在用户代码区调用几个关键HAL函数。

单次阻塞模式采集流程:这是最简单直接的方式,但会阻塞CPU直到转换完成。

// 在main.c或其他用户文件中 #include “adc.h” uint32_t adc_value = 0; float voltage = 0.0f; // 1. 启动ADC转换(单次模式) HAL_ADC_Start(&hadc1); // 2. 等待转换完成,超时时间设为10ms if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK) { // 3. 读取转换结果 adc_value = HAL_ADC_GetValue(&hadc1); // 4. 停止ADC(对于单次模式,转换完成自动停止,但显式停止是好习惯) HAL_ADC_Stop(&hadc1); } // 5. 将数字值转换为电压值(假设VREF=3.3V) voltage = (float)adc_value * 3.3f / 4095.0f; printf(“ADC Value: %lu, Voltage: %.3f V\r\n”, adc_value, voltage);

单次中断模式采集流程:非阻塞方式,转换完成后触发中断,适合在复杂系统中不占用主循环时间。

// 首先,在CubeMX的NVIC设置中使能ADC全局中断。 // 然后在用户代码中: // 启动ADC转换(中断模式) HAL_ADC_Start_IT(&hadc1); // 转换完成后,会自动进入ADC转换完成中断回调函数 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { if (hadc->Instance == ADC1) { uint32_t adc_value = HAL_ADC_GetValue(&hadc1); // 在这里处理adc_value,例如存入缓冲区、设置标志位等 // 注意:回调函数中不宜进行耗时操作 g_adc_ready_flag = 1; g_adc_raw_value = adc_value; } // 如果需要单次采集,在这里停止ADC。如果需要连续采集,则不需要。 // HAL_ADC_Stop_IT(&hadc1); } // 在主循环中检查标志位 while (1) { if (g_adc_ready_flag) { g_adc_ready_flag = 0; voltage = (float)g_adc_raw_value * 3.3f / 4095.0f; // ... 进行后续处理 } // ... 其他任务 }

注意HAL_ADC_ConvCpltCallback是一个弱定义函数,你需要在自己的代码中(如main.c)重新实现它,否则中断发生后什么也不会发生。

4. 精度提升与数据滤波:让ADC读数“稳如老狗”

直接读取的ADC值往往存在噪声和跳动,尤其是在电源质量一般、布线有干扰或者信号源本身不稳定的情况下。如何得到一个稳定、可信的数值,是ADC应用的核心。

4.1 硬件层面的“治本”措施

在软件滤波之前,硬件设计是基础:

  1. 电源去耦:在MCU的AVDD和AGND引脚附近,务必放置一个0.1uF和一个10uF的电容,且尽可能靠近引脚。这是抑制电源噪声的第一道防线。
  2. 参考电压滤波:如果VREF+直接使用AVDD,那么AVDD的滤波就至关重要。可以考虑使用LC(电感电容)滤波电路为模拟部分提供更干净的电源。
  3. 信号调理:对于高阻抗或微弱的模拟信号,先经过一个电压跟随器(运放构成)进行缓冲,降低输出阻抗,再送入ADC。这能有效解决因采样时间不足导致的误差。
  4. PCB布局:模拟信号走线尽量短,远离数字信号线(特别是时钟、PWM等),避免平行走线。在模拟信号线周围铺地铜进行屏蔽。

4.2 软件滤波算法:从简单到实用

当硬件定型后,软件滤波就是主要手段。

1. 算术平均滤波最简单有效。连续采样N次,求和后取平均。

#define SAMPLE_TIMES 64 uint32_t adc_sum = 0; for(int i=0; i<SAMPLE_TIMES; i++) { HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 10); adc_sum += HAL_ADC_GetValue(&hadc1); HAL_ADC_Stop(&hadc1); // 可以加一个小延时,避免两次采样间隔太近 HAL_Delay(1); } uint32_t adc_average = adc_sum / SAMPLE_TIMES;

优点:简单,能有效抑制随机噪声。缺点:速度慢,占用CPU,对于有规律干扰(如工频50Hz)效果有限。SAMPLE_TIMES取2的幂次方(如32,64)可以用移位代替除法优化速度。

2. 滑动平均滤波(Moving Average)维护一个固定长度的队列(数组),每次新采样值入队,最老的出队,然后计算队列中所有值的平均值。

#define MOVING_AVG_SIZE 16 uint32_t adc_buffer[MOVING_AVG_SIZE] = {0}; uint8_t buffer_index = 0; uint32_t buffer_sum = 0; // 每次采样后调用 uint32_t get_moving_average(uint32_t new_sample) { buffer_sum = buffer_sum - adc_buffer[buffer_index] + new_sample; // 减去最老的,加上最新的 adc_buffer[buffer_index] = new_sample; buffer_index = (buffer_index + 1) % MOVING_AVG_SIZE; return buffer_sum / MOVING_AVG_SIZE; }

优点:实时性好,每次计算开销小,能反映信号变化趋势。缺点:对突发性尖峰脉冲干扰的抑制能力不如算术平均,会引入一定的滞后(相位延迟)。

3. 中位值平均滤波(防脉冲干扰平均滤波法)结合了中值滤波和平均滤波的优点。连续采样N次,去掉一个最大值和一个最小值,然后对剩下的N-2个数据求算术平均。

#define SAMPLE_N 10 // 建议取10左右 uint32_t adc_buf[SAMPLE_N]; uint32_t adc_filtered_value = 0; void adc_filter_task(void) { // 1. 采集N个样本 for(int i=0; i<SAMPLE_N; i++) { // ... 启动ADC并读取值到adc_buf[i] HAL_Delay(1); // 间隔采样 } // 2. 排序(这里用简单的冒泡排序示例,实际可用更高效的算法) for(int i=0; i<SAMPLE_N-1; i++) for(int j=i+1; j<SAMPLE_N; j++) if(adc_buf[i] > adc_buf[j]) { uint32_t temp=adc_buf[i]; adc_buf[i]=adc_buf[j]; adc_buf[j]=temp;} // 3. 去掉头尾,计算中间值的和 uint32_t sum_mid = 0; for(int i=1; i<SAMPLE_N-1; i++) // 去掉下标0(最小)和下标SAMPLE_N-1(最大) { sum_mid += adc_buf[i]; } // 4. 求平均 adc_filtered_value = sum_mid / (SAMPLE_N - 2); }

优点:能有效滤除因偶然因素引起的脉冲性干扰,精度高。缺点:计算量相对较大,速度慢。

我的经验选择:在大多数需要显示或进行粗略控制的场合(如电池电压检测、电位器调参),滑动平均滤波是性价比最高的选择,MOVING_AVG_SIZE取8或16即可。在对实时性和抗干扰要求都高的控制场合(如闭环控制中的反馈采样),可以选用中位值平均滤波,采样次数SAMPLE_N取6或8,在精度和速度间取得平衡。

5. 进阶话题:DMA传输与定时器触发

当采样速率要求较高,或者需要同时采集多路信号时,阻塞等待或中断方式都会消耗大量CPU资源,此时必须请出DMA(直接存储器访问)这个“外设数据搬运工”。

5.1 ADC与DMA的搭配使用

DMA可以在ADC转换完成后,自动将数据寄存器(DR)中的值搬运到我们指定的内存数组(缓冲区)中,整个过程无需CPU干预。

CubeMX配置关键点:

  1. ADC1DMA Settings中,点击Add,选择ADC1
  2. 将DMA的Mode设置为Circular(循环模式)。这样当DMA搬运完一轮数据后,会自动从头开始,实现连续不断的自动搬运,形成一个数据流。
  3. Data Width选择Word(对应ADC的32位数据寄存器,但实际有效数据是低16位或12位)。

代码实现:

// 在adc.c的USER CODE BEGIN PV区域定义缓冲区 #define ADC_BUFF_SIZE 100 uint32_t adc_dma_buffer[ADC_BUFF_SIZE]; // 在初始化后,启动ADC和DMA HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_dma_buffer, ADC_BUFF_SIZE); // 此后,adc_dma_buffer数组就会被DMA自动填充最新的ADC值。 // 你可以通过DMA的半传输完成中断(HT)和传输完成中断(TC)来知道缓冲区何时半满或全满,然后处理数据。 void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) { // 处理 adc_dma_buffer[0] 到 adc_dma_buffer[ADC_BUFF_SIZE/2 -1] 的数据 } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { // 处理 adc_dma_buffer[ADC_BUFF_SIZE/2] 到 adc_dma_buffer[ADC_BUFF_SIZE -1] 的数据 }

这种方式将CPU彻底解放出来,只需要在数据准备好(半满或全满)时去处理即可,效率极高。

5.2 定时器触发ADC采样

在很多应用里,我们需要以固定、精确的时间间隔进行采样,比如音频采集(44.1kHz)、电力监测(每周波采样256点)等。这时就需要用定时器来触发ADC。

工作原理:配置一个定时器(如TIM2)以特定频率(比如1kHz)产生更新事件(UEV)。将这个定时器的触发输出(TRGO)连接到ADC的触发源。这样,定时器每“滴答”一次,就自动触发一次ADC转换。

CubeMX配置步骤:

  1. 配置一个定时器,比如TIM2。在Parameter Settings中,设置PrescalerCounter Period,使得更新频率等于你想要的采样率。例如,如果APB1时钟是72MHz,预分频器设为71,计数器周期设为999,则更新频率 = 72MHz / (71+1) / (999+1) = 1000Hz。
  2. Trigger Output中选择Update Event作为TRGO
  3. 回到ADC1配置,在Regular Conversion TriggerTrigger Source中,选择Timer 2 Trigger Out event
  4. 将ADC的Continuous Conversion Mode禁用。因为现在是由外部定时器触发,每次触发完成一次转换。
  5. 强烈建议结合DMA。这样,每次定时器触发ADC转换,转换结果就通过DMA自动存入数组,形成一个自动化的、等时间间隔的数据流采集系统。

这种“定时器触发 + ADC转换 + DMA搬运”的组合,是STM32 ADC应用中的黄金搭档,能实现高效、精准、不占用CPU的数据采集,是产品级应用的标配。

6. 避坑指南与调试心得

纸上得来终觉浅,绝知此事要躬行。下面分享几个我实际调试中踩过的坑和总结的经验。

坑1:ADC读数跳变剧烈,像在“跳舞”

  • 可能原因1:采样时间不足。这是最常见的原因。尤其是当你用杜邦线连接电位器到开发板时,导线本身就有电感电容,构成了一个高阻抗源。将采样时间从1.5 Cycles改为239.5 Cycles,效果立竿见影。
  • 可能原因2:电源噪声。用示波器测量一下AVDD引脚,看看纹波是否过大。确保去耦电容已焊接且靠近MCU引脚。可以尝试用电池给开发板供电,如果读数立刻变稳,那就是电源问题。
  • 可能原因3:数字信号干扰。检查ADC输入引脚附近是否有高速翻转的数字信号线(如PWM输出、串口TX)。在PCB布局上应让模拟和数字区域分开。
  • 排查方法:将ADC输入引脚直接短接到一个干净的参考电压(如通过电阻分压得到的1.65V),如果读数依然不稳,基本可以确定是MCU侧配置或硬件问题;如果读数很稳,那问题就在外部信号源或信号链路上。

坑2:ADC转换结果永远为0或4095

  • 结果为0:检查输入引脚是否真的连接了信号,或者是否被意外配置成了输出模式。用万用表量一下引脚电压。也可能是参考电压VREF-有问题。
  • 结果为4095(或最大值):输入电压超过了VREF+(通常是3.3V)。检查信号电平。也可能是采样时间极短,电容根本没充电,比较器一直认为输入电压高于猜测值,导致输出满量程。

坑3:使用DMA时,数据缓冲区里的值不更新

  • 检查DMA是否配置为循环模式。单次模式(Normal)下,DMA传输完指定长度后就会停止。
  • 检查ADC是否已启动HAL_ADC_Start_DMA必须在所有外设初始化完成后调用。
  • 检查DMA和ADC的中断优先级。如果被更高优先级的中断长时间阻塞,可能导致数据丢失。确保DMA中断有合适的优先级。
  • 使用调试器查看:在HAL_ADC_ConvHalfCpltCallbackHAL_ADC_ConvCpltCallback中设置断点,看是否能进入。如果能进入但数据不对,检查DMA的目标内存地址是否正确。

坑4:多通道扫描时,数据顺序错乱或通道间串扰

  • 确保Rank顺序:在CubeMX的ADC配置Rank表中,Channel列是你选择的通道,Rank列是转换顺序。Rank 1最先转换。
  • 增加通道间延迟:在扫描多个通道时,可以在ADC_CFGR1寄存器中设置Delay between channels(部分型号支持),或者在每个通道转换后插入短暂的软件延时(不优雅),以降低通道切换带来的串扰。更根本的办法是硬件上确保信号源阻抗足够低。

一个实用的调试技巧:打印原始值与电压值在调试初期,不要只打印换算后的电压值。一定要把原始的ADC数字值(0-4095)也打印出来。这能帮你快速判断问题是出在ADC转换环节(数字值不稳),还是出在后续的换算或处理环节。如果原始值很稳,但电压值计算不对,那就要检查你的参考电压(3.3f)这个常数是否准确,或者是否存在浮点数计算问题。

7. 从单次采集到小型数据采集系统:一个综合案例

最后,我们整合以上所有知识点,设计一个简单但完整的小型数据采集系统案例。

需求:以1kHz的频率,持续采集STM32 PA0引脚上的模拟电压(例如来自一个温度传感器),并对数据进行滑动平均滤波,最后通过串口每秒发送一次平均值。

方案设计

  1. 触发与转换:使用TIM2产生1kHz的触发信号,驱动ADC1进行规则组转换。
  2. 数据搬运:启用DMA1,工作在循环模式,将ADC转换结果自动搬运到一个大小为200的环形缓冲区adc_raw_buf中。
  3. 数据处理:在主循环中,定期(比如每10ms)检查DMA的搬运进度(通过计算当前DMA写入指针与上次处理指针的差值),取出未处理的新数据,进行滑动平均滤波。
  4. 数据输出:设置一个1秒的定时器(如TIM3),每秒钟将滤波后的最终平均值通过串口发送出去。

关键代码片段(思路):

// 全局变量 uint32_t adc_raw_buf[ADC_BUF_SIZE]; volatile uint32_t dma_last_index = 0; // 上次处理到的缓冲区位置 float moving_avg = 0.0f; #define MOVING_AVG_LEN 16 float mavg_buf[MOVING_AVG_LEN] = {0}; uint8_t mavg_idx = 0; // 在主循环中,每10ms调用一次 void adc_data_process_task(void) { uint32_t current_dma_index = ADC_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_adc1); uint32_t new_data_cnt = (current_dma_index - dma_last_index + ADC_BUF_SIZE) % ADC_BUF_SIZE; if(new_data_cnt > 0) { for(uint32_t i=0; i<new_data_cnt; i++) { uint32_t idx = (dma_last_index + i) % ADC_BUF_SIZE; uint32_t raw_val = adc_raw_buf[idx]; float voltage = raw_val * 3.3f / 4095.0f; // 滑动平均滤波 moving_avg = moving_avg - mavg_buf[mavg_idx]/MOVING_AVG_LEN + voltage/MOVING_AVG_LEN; mavg_buf[mavg_idx] = voltage; mavg_idx = (mavg_idx + 1) % MOVING_AVG_LEN; } dma_last_index = current_dma_index; } } // 在TIM3的1秒中断中 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if(htim->Instance == TIM3) { printf(“Avg Voltage: %.3f V\r\n”, moving_avg); } }

这个案例涵盖了定时器触发、ADC转换、DMA传输、软件滤波和串口通信,是一个典型的嵌入式数据采集子系统。通过这个框架,你可以轻松地扩展为多通道采集(修改ADC的Rank序列),或者提高采样率(调整定时器参数),从而满足更复杂的项目需求。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询