STM32 ADC从入门到精通:单通道电压测量与滤波实战

📅 2026/8/12 19:49:31
STM32 ADC从入门到精通:单通道电压测量与滤波实战
1. 从零开始为什么STM32的ADC是嵌入式开发的必修课如果你刚开始玩STM32尤其是像STM32F103C8T6这种经典的“蓝核”系列那么学会使用ADC模数转换器几乎是绕不开的一道坎。这玩意儿就像是单片机的“感官”能把外部世界连续变化的模拟信号比如温度、压力、光照强度转换成我们程序里能处理的数字量。我见过不少新手点个灯、调个串口通信玩得挺溜但一遇到要读取传感器电压就卡壳要么读出来的值乱跳要么根本读不准最后只能对着原理图干瞪眼。就拿STM32F103C8来说它内置了2个12位的ADC最高采样率能达到1MHz对付大多数中等速度的模拟信号采集绰绰有余。但硬件有了软件上怎么把它用起来里面门道可不少。从最基本的单通道单次转换到多通道扫描、DMA传输再到软件滤波处理每一步都有需要注意的细节。网上的教程很多但往往只给个代码片段缺了最关键的“为什么这么做”的逻辑导致你照抄能跑但换个需求或者一出问题就完全不知道怎么排查。这篇文章我就以最常用的STM32F103C8T6核心板为例抛开那些复杂的库函数封装从寄存器配置的底层逻辑讲起手把手带你走通ADC单通道电压测量的全流程。我会重点解释每个配置项背后的意义分享几个我实际调试中总结出来的、能让ADC读数更稳定的滤波函数和小技巧。目标很简单让你不仅能“抄作业”把代码跑起来更能真正理解ADC的工作原理以后遇到任何相关的需求或问题都能自己分析解决。2. 核心原理拆解STM32的ADC到底是怎么工作的在动手写代码之前我们得先搞清楚STM32F103的ADC是怎么运转的。很多人配置失败根源就在于对ADC的工作模式、时钟和时序关系一知半解。这里我们不深入半导体物理层只关注作为开发者必须掌握的“行为逻辑”。2.1 ADC的“心脏”时钟与采样时间STM32F103的ADC不能自己产生时钟它的时钟ADCCLK来自APB2总线时钟PCLK2经过一个可编程的分频器。APB2的时钟最高是72MHz但ADC模块的最高工作频率是14MHz。所以第一步就是正确配置分频系数。通常我们会选择6分频即ADCCLK PCLK2 / 6 72MHz / 6 12MHz这样既满足速度要求又留有余量保证稳定性。比时钟更重要的是采样时间。这是ADC模块一个非常关键但常被忽略的参数。ADC转换一个点分为两个阶段采样保持和逐次逼近转换。采样阶段内部电容需要时间充电到外部输入引脚的电平转换阶段才是真正的模数转换过程。STM32允许我们为每个通道单独设置采样周期单位是ADCCLK周期。对于STM32F103采样周期可以设置为1.5到239.5个周期。如果采样时间太短内部电容还没充到稳定的输入电压值就开始转换结果必然不准如果太长虽然精度高但转换速度就慢了。这是一个需要权衡的取舍。对于测量变化不快的直流电压比如电位器分压通常设置较长的采样时间如55.5或239.5个周期来获得更高的精度。官方数据手册里会提供一个表格指导你在不同的信号源阻抗下选择多大的采样时间。简单来说信号源内阻越大比如通过一个大电阻分压充电就越慢需要的采样时间就越长。2.2 转换模式与数据对齐STM32F103的ADC支持多种转换模式单次转换触发一次转换一个指定的通道然后停止。适合低速、非连续的测量。连续转换开启后ADC会不停地对选定通道进行转换。适合需要持续监控的场景。扫描模式配合多通道使用ADC会按顺序自动转换一组预先设置好的通道。我们第一个实验从最简单的单次转换开始。触发方式可以选择软件触发直接写寄存器启动或外部触发如定时器、外部中断。转换完成后那个12位的数字结果0-4095会存放在数据寄存器ADC_DR里。这里又涉及一个细节数据对齐。STM32支持左对齐和右对齐。我们通常使用右对齐这样读取到的16位数据寄存器的低12位就是转换结果直观对应0-4095。如果选择左对齐结果存放在高12位需要右移4位才能得到我们习惯的数值常用于需要快速进行某些判断的场景比如只比较高低。理解这些原理后我们配置寄存器时就不是盲目填值了。比如当你设置SMPx位采样时间选择时你心里清楚这是在给电容充电留足时间当你选择单次模式时你知道每次测量都需要重新触发。这种“知其所以然”的状态是调试和解决问题的底气。3. 硬件连接与软件环境准备理论清楚了我们开始动手。先确保你的硬件和软件环境是OK的。3.1 硬件电路连接我们用一个最简单的电路来验证测量一个电位器的分压电压。MCUSTM32F103C8T6核心板。测量点找一个10kΩ的多圈电位器。将它的两端分别接到核心板的3.3V和GND。中间的可调抽头就是我们待测的模拟电压输出端。ADC输入引脚将电位器抽头连接到MCU的某个ADC通道引脚上。例如PA0对应的是ADC1的通道0ADC1_IN0PA1对应通道1以此类推。我们选择PA0。参考电压STM32F103的ADC参考电压默认取自VREF和VREF-引脚。在最小系统板上VREF通常直接连接到VDDA模拟电源必须接3.3VVREF-连接到VSSA模拟地必须接GND。务必确保你的板子VDDA和VSSA已正确连接否则ADC根本无法正常工作或精度极差。很多廉价核心板可能省略了模拟电源的滤波电容有条件最好自己加上一个10uF钽电容并联一个0.1uF陶瓷电容到地靠近MCU引脚放置。注意ADC输入引脚不能直接承受高于VREF通常为3.3V或低于VREF-通常为0V的电压否则可能损坏芯片。如果测量外部更高电压必须使用电阻分压电路进行衰减。3.2 软件工程配置我强烈建议初学者在初始学习时尝试不用CubeMX等图形化工具而是直接基于标准外设库StdPeriph Lib或者甚至直接操作寄存器来编写。这能让你更清晰地看到每个配置步骤。这里以标准外设库为例。创建工程在你的IDE如Keil MDK中创建一个新工程选择正确的器件型号STM32F103C8。添加库文件将STM32F10x标准外设库的相关文件添加到工程中至少需要CMSIS核心文件。STM32F10x_StdPeriph_Driver下的misc.c,stm32f10x_rcc.c,stm32f10x_gpio.c,stm32f10x_adc.c。对应的头文件路径。系统时钟配置在main函数开始首先要初始化系统时钟。STM32F103C8最高可运行在72MHz。调用RCC_Configuration()函数需自己实现将SYSCLK设置为72MHzAPB2时钟PCLK2也设为72MHz。这是ADC时钟的源头。准备工作就绪接下来进入核心的ADC配置环节。4. 步步为营ADC单次转换的配置与实现现在我们来一步步编写ADC初始化和读取值的代码。我会把每一步的代码和对应的解释放在一起。4.1 GPIO与ADC时钟使能任何外设使用前必须先打开它的时钟门控这是STM32低功耗设计的要求。// 1. 开启GPIOA和ADC1的时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE);PA0是GPIOA的引脚ADC1是一个独立的外设模块所以需要同时开启两者的时钟。4.2 配置ADC输入引脚为模拟输入模式GPIO引脚有多种模式推挽输出、开漏输出、浮空输入、上拉输入等。用于ADC时必须设置为模拟输入模式。在这种模式下IO口内部的施密特触发器、上下拉电阻都被断开信号直接进入ADC的模拟部分避免数字电路干扰。GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; // PA0 GPIO_InitStructure.GPIO_Mode GPIO_Mode_AIN; // 模拟输入模式 GPIO_Init(GPIOA, GPIO_InitStructure);4.3 初始化ADC参数结构体这是配置的核心部分。我们使用库函数提供的结构体ADC_InitTypeDef来设置ADC的工作参数。ADC_InitTypeDef ADC_InitStructure; // 先给结构体一个默认值这是个好习惯 ADC_StructInit(ADC_InitStructure); // 然后修改为我们需要的参数 ADC_InitStructure.ADC_Mode ADC_Mode_Independent; // 独立模式ADC1单独工作 ADC_InitStructure.ADC_ScanConvMode DISABLE; // 失能扫描模式因为我们只用一个通道 ADC_InitStructure.ADC_ContinuousConvMode DISABLE; // 失能连续转换模式单次转换 ADC_InitStructure.ADC_ExternalTrigConv ADC_ExternalTrigConv_None; // 软件触发不使用外部触发 ADC_InitStructure.ADC_DataAlign ADC_DataAlign_Right; // 数据右对齐 ADC_InitStructure.ADC_NbrOfChannel 1; // 转换的通道数量为1 ADC_Init(ADC1, ADC_InitStructure); // 将配置写入ADC1的寄存器关键点解析ADC_Mode_Independent对于只有一个ADC的应用或者多个ADC各自独立工作就选这个。DISABLE扫描和连续转换这就是我们想要的单次、单通道模式。ADC_ExternalTrigConv_None选择由软件控制转换开始而不是等待某个外部事件。ADC_DataAlign_Right数据右对齐结果在低12位。4.4 配置通道的采样时间前面原理提到采样时间很重要。我们需要为通道0对应PA0单独设置。// 设置指定ADC的通道及其采样时间 // ADC1, 通道0, 采样时间为55.5个周期 ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5);函数ADC_RegularChannelConfig的第三个参数Rank在扫描模式下表示通道的转换顺序1代表第一个转换。在单通道模式下这个参数设为1即可。ADC_SampleTime_55Cycles5是一个折中的选择在12MHz的ADCCLK下55.5个周期约合4.6微秒对于大多数信号源阻抗几千欧姆以内都能保证充分采样。4.5 使能ADC并校准配置写完就可以开启ADC了。但开启后不能立刻用必须进行校准。校准的目的是消除ADC模块内部的偏移误差。ADC_Cmd(ADC1, ENABLE); // 使能ADC1 // 校准过程 ADC_ResetCalibration(ADC1); // 复位校准寄存器 while(ADC_GetResetCalibrationStatus(ADC1)); // 等待复位完成 ADC_StartCalibration(ADC1); // 开始校准 while(ADC_GetCalibrationStatus(ADC1)); // 等待校准完成校准只需要在ADC初始化后执行一次。如果中途关闭了ADCADC_Cmd(DISABLE)再次开启时需要重新校准。4.6 编写电压读取函数一切准备就绪我们可以写一个函数来触发一次转换并读取结果。uint16_t Read_ADC_Value(uint8_t channel) { // 重新配置通道如果通道可能变化的话。单通道固定可省略但养成好习惯 ADC_RegularChannelConfig(ADC1, channel, 1, ADC_SampleTime_55Cycles5); ADC_SoftwareStartConvCmd(ADC1, ENABLE); // 软件触发转换开始 while(!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC)); // 等待转换结束标志位 return ADC_GetConversionValue(ADC1); // 读取转换结果 }在主函数的循环里你可以这样调用它并计算实际电压while(1) { adc_value Read_ADC_Value(ADC_Channel_0); // 读取原始值范围0-4095 voltage (float)adc_value / 4095 * 3.3f; // 计算电压值假设Vref3.3V // 通过串口打印 voltage 的值或者用OLED显示 printf(ADC: %d, Voltage: %.2f V\r\n, adc_value, voltage); Delay_ms(500); // 延时500ms再测一次 }至此一个最基本的ADC电压测量程序就完成了。编译下载到板子上旋转电位器你应该能看到串口输出的电压值在0-3.3V之间变化。5. 从“能用”到“好用”滤波与精度提升实战如果你按照上面的步骤做大概率已经能读出电压了。但很快你会发现两个问题1. 数值总是在跳变不够稳定2. 当电压接近0V或3.3V时读数可能不是完美的0或4095。这是正常的因为存在噪声和误差。接下来我们解决这两个问题。5.1 软件滤波让读数更稳定ADC读数的跳动主要来源于电源纹波、数字电路干扰、以及信号本身的噪声。硬件上可以通过优化布局布线、添加滤波电容来改善软件上则可以通过滤波算法来平滑数据。1. 算术平均滤波这是最简单有效的方法。连续采样N次然后取平均值。#define SAMPLE_TIMES 64 // 采样次数可取32, 64, 128等2的幂次方方便计算 uint16_t ADC_AverageFilter(uint8_t channel) { uint32_t sum 0; for(int i0; iSAMPLE_TIMES; i) { sum Read_ADC_Value(channel); } return (uint16_t)(sum / SAMPLE_TIMES); }这种方法能显著抑制随机噪声但会降低响应速度因为需要多次采样。SAMPLE_TIMES越大曲线越平滑但反应越慢。对于缓慢变化的信号如温度、电位器取64或128次平均效果很好。2. 滑动平均滤波有时我们既想平滑又想保持一定的实时性。滑动平均维护一个固定长度的队列每次新采样值进入最老的采样值退出始终计算队列的平均值。#define FILTER_LEN 10 uint16_t adc_buf[FILTER_LEN] {0}; uint8_t buf_index 0; uint16_t ADC_MovingAverageFilter(uint8_t channel) { uint32_t sum 0; static uint8_t init_flag 0; // 用新值替换旧值 adc_buf[buf_index] Read_ADC_Value(channel); buf_index (buf_index 1) % FILTER_LEN; // 如果缓冲区还没填满不进行平均 if(!init_flag buf_index 0) { init_flag 1; } if(init_flag) { for(int i0; iFILTER_LEN; i) { sum adc_buf[i]; } return (uint16_t)(sum / FILTER_LEN); } else { // 缓冲区未满时返回当前值或0 return adc_buf[(buf_index FILTER_LEN - 1) % FILTER_LEN]; } }滑动平均的响应速度比算术平均快因为它每次只更新一个数据点。FILTER_LEN决定了平滑程度和响应速度的平衡。3. 一阶低通滤波惯性滤波这是一种更“智能”的滤波它认为当前值应该是上次输出值和本次采样值的一个加权组合。float alpha 0.1; // 滤波系数介于0~1之间。越小越平滑但滞后越严重。 float filtered_value 0; uint16_t ADC_LowPassFilter(uint8_t channel) { uint16_t raw Read_ADC_Value(channel); filtered_value alpha * raw (1 - alpha) * filtered_value; return (uint16_t)filtered_value; }alpha非常关键。如果信号变化很慢噪声大alpha可以取小一点如0.05如果希望跟踪快速变化alpha要取大一点如0.3。一阶低通滤波计算量小效果不错是嵌入式系统里非常常用的软件滤波方法。提示在实际项目中我通常会先使用硬件RC滤波在ADC输入引脚对地接一个0.1uF的电容做初步处理再在软件上结合滑动平均或一阶低通这样能以较小的计算开销获得非常稳定的读数。5.2 校准与补偿提高绝对精度滤波解决了读数“稳不稳”的问题但“准不准”是另一个维度。ADC存在偏移误差零点误差和增益误差满量程误差。虽然芯片出厂时有校准但受温度、电源电压影响仍会有偏差。1. 两点校准法这是最实用的方法。你需要两个已知且精确的参考电压点通常选择0V或接近0V和3.3V或ADC的参考电压。将输入接地或接一个已知的接近0V的电压V_low读取此时的ADC平均值ADClow。将输入接一个精确的3.300V参考源V_high读取此时的ADC平均值ADChigh。理论上ADC值x和实际电压V的关系是V k * x b。我们可以通过两个点求出斜率k和截距bfloat k, b; // 假设已知V_low 0.001V, ADClow 5; V_high 3.300V, ADChigh 4080 k (V_high - V_low) / (ADChigh - ADClow); // 计算斜率 b V_low - k * ADClow; // 计算截距 // 后续测量中使用校准后的公式计算电压 uint16_t adc_raw ADC_AverageFilter(ADC_Channel_0); float voltage_calibrated k * adc_raw b;经过两点校准可以很大程度上消除系统的线性误差。这个校准系数可以保存在Flash中每次上电后加载。2. 参考电压监测STM32F103有一个内置的内部参考电压VREFINT它是一个出厂时校准过的、随温度变化相对稳定的电压约1.2V。ADC的通道17专门用于测量这个电压。我们可以利用它来反推实际的VREF电压即我们的VDDA因为VREFINT的ADC读数与VREF成反比关系。// 1. 读取内部参考电压通道的ADC值 ADC_RegularChannelConfig(ADC1, ADC_Channel_Vrefint, 1, ADC_SampleTime_239Cycles5); uint16_t vrefint_raw ADC_AverageFilter(ADC_Channel_Vrefint); // 2. 从芯片的独有校准值中读取出厂时测得的VREFINT在3.3V VREF下的ADC值 // 这个值存储在系统存储区的一个固定地址对于F103通常在0x1FFFF7BA uint16_t vrefint_cal *(__IO uint16_t *)(0x1FFFF7BA); // 3. 计算实际的VDDA电压 // 公式: VREFINT_CAL / VREFINT_RAW 1.2V / VDDA // 所以 VDDA 1.2V * VREFINT_RAW / VREFINT_CAL float vdda_measured 1.2f * vrefint_raw / vrefint_cal; // 4. 用计算出的VDDA作为参考电压去计算其他通道的电压 uint16_t adc_raw ADC_AverageFilter(ADC_Channel_0); float voltage_true (float)adc_raw / 4095 * vdda_measured;这种方法特别有用因为即使你的板载3.3V电源有波动比如电池供电电压下降你也能准确测出外部电压的真实值相当于做了一个实时的参考电压补偿。6. 进阶与避坑多通道、DMA与常见问题排查掌握了单通道测量你的需求可能会升级如何同时测量多个电压如何让ADC在后台自动采样不占用CPU下面我们聊聊进阶应用和那些容易踩的坑。6.1 多通道扫描与间断模式假设你需要轮流测量PA0,PA1,PA2三个电位器的电压。如果还用单次模式轮流切换通道并启动转换效率很低。这时应该使用扫描模式。配置扫描模式的关键点ADC_InitStructure.ADC_ScanConvMode ENABLE;使用ADC_RegularChannelConfig函数按顺序配置多个通道及其采样时间Rank参数依次为1, 2, 3...设置ADC_InitStructure.ADC_NbrOfChannel 3;转换模式依然可以是单次ADC_ContinuousConvMode DISABLE或连续ENABLE。在扫描模式下无论是单次触发还是连续转换ADC都会按照Rank的顺序自动完成所有设定通道的一次转换。转换完成后会产生一个EOC转换结束标志。但这里有个大坑数据寄存器ADC_DR只会保存最后一个转换通道的数据前两个通道的数据会被覆盖。所以扫描模式必须配合DMA直接存储器访问使用让DMA在每次转换完成后自动把数据从ADC_DR搬运到我们指定的内存数组中。6.2 使用DMA实现自动搬运DMA是解放CPU的神器。配置ADC使用DMA的步骤初始化DMA通道ADC1对应DMA1的通道1。配置DMA从外设地址ADC1-DR搬运到内存地址自定义数组数据宽度为半字16位循环模式。使能ADC的DMA请求调用ADC_DMACmd(ADC1, ENABLE);。启动DMA。启动ADC转换软件或外部触发。一旦启动ADC每转换完一个通道DMA就会自动把数据存起来完全不需要CPU干预。你可以去处理其他任务等需要时直接读取内存数组里的数据即可。这是实现多通道高速采样的标准做法。6.3 调试中常见的坑与排查思路即使按照教程一步步来你可能还是会遇到问题。下面是我总结的几个常见坑点问题1ADC读数始终为0或4095。排查GPIO模式首先确认ADC输入引脚是否配置为模拟输入AIN。配置成浮空输入或其他模式可能读到一个固定的上拉/下拉电平。排查硬件连接用万用表测量PA0引脚对地的实际电压确认电位器分压是否正常。检查VDDA和VSSA是否确实接到了3.3V和GND。排查参考电压如果VDDA没接ADC可能无法工作读数会固定为0或满量程。问题2读数不稳定跳动范围很大远超过LSB。检查采样时间这是最常见的原因。尝试将采样时间增加到ADC_SampleTime_239Cycles5看看跳动是否减小。如果减小了说明原采样时间不足。检查电源和地线模拟部分电源VDDA最好通过磁珠或0欧电阻与数字电源VDD隔离并搭配足够的滤波电容10uF 0.1uF。检查信号源电位器本身噪声可能就很大。尝试直接接一个稳定的电压源如稳压芯片的输出测试。软件滤波如前所述必须加软件滤波。问题3读数有固定偏差线性度不好。进行两点校准使用精确的电压源进行校准可以修正大部分线性误差。检查PCB布局模拟信号走线应远离高频数字信号线如时钟、PWM最好用地线包围隔离。考虑内部参考电压补偿如果对绝对精度要求高务必使用内部VREFINT通道来实时计算VDDA消除电源波动带来的误差。问题4使用多通道扫描模式数据错乱。确认DMA配置扫描模式必须开DMA并确认DMA的内存数组足够大能存下所有通道的数据。确认通道顺序检查ADC_RegularChannelConfig中设置的Rank顺序是否与DMA搬运到的数组下标预期一致。注意EOC标志在扫描模式下单个通道转换完成会产生EOC标志但整个序列转换完成会产生EOS序列结束标志。根据需要等待正确的标志位。调试ADC问题逻辑分析仪或示波器是很好的帮手。你可以用示波器观察ADC输入引脚的实际波形看是否平滑是否有毛刺。同时可以测量一下VDDA的纹波过大如超过50mV的纹波会直接影响ADC精度。最后分享一个我个人的习惯在项目初期我会专门写一个ADC测试函数循环测量所有通道并打印原始值。当输入接地时读数应该在一个很小的范围内比如0-10当输入接VDDA时读数应该接近4095。这个快速测试能帮你迅速判断ADC模块和基本配置是否正常工作。把基础打牢后续的复杂应用才能顺利进行。