STM32F103 ADC电压采集:从原理到高精度实现的实战指南
1. 从零开始为什么STM32F103的ADC电压采集值得深究如果你刚开始接触STM32或者已经用它点过灯、调过串口那么ADC模数转换器功能大概率是你遇到的第一个“模拟世界”接口。而STM32F103作为经典的“蓝桥杯”神器和无数电子爱好者的入门首选其内置的12位ADC更是项目中的常客。表面上看配置ADC、读取一个电压值似乎很简单网上例程一抓一大把。但当你真正把它用在一个要求稍高的项目里比如做一个简易的万用表、电池管理系统BMS的电压监控或者需要稳定读取传感器信号时各种问题就会接踵而至读数跳得厉害、采集的值和万用表对不上、多通道切换时数据错乱……这时候你才会发现ADC这潭水远比想象中深。我最初用STM32F103做一个小型无人机的电池电压监控时就踩过不少坑。明明电路分压算得好好的代码也是从官方例程改的但采集到的电压值总是不稳定偶尔还会出现一个离谱的跳变导致系统误判电池电量差点酿成事故。后来经过一番折腾从基准电压、采样时间、PCB布局到软件滤波层层排查才让采集结果稳定可靠。这个过程让我意识到ADC采集不是一个配置完寄存器就一劳永逸的功能它涉及硬件设计、时钟配置、采样时序、数据处理等一系列环环相扣的细节。今天我就以STM32F103C8T6这款最普遍的芯片为例抛开那些简单的“三步读取电压”教程深入聊聊如何实现一个高精度、高稳定性的ADC电压采集系统。我们会从最核心的ADC原理与STM32的硬件特性出发一步步拆解CubeMX配置背后的门道然后深入到多通道、DMA等高级应用最后重点攻克软件滤波与校准这些决定最终精度的关键环节。无论你是想做一个精密的测量设备还是仅仅想让你的产品读数更准一些这里面的经验都值得你仔细琢磨。2. 核心原理与硬件设计别让第一步就埋下隐患在动手写代码之前我们必须先搞清楚两件事STM32F103的ADC到底是怎么工作的以及我们的硬件电路应该如何设计才能给它提供最好的“食材”很多采集不准的问题根子都出在这里。2.1 STM32F103 ADC的“家底”与工作模式STM32F103系列大多数型号内置了1到3个12位逐次逼近型SARADC。以常见的STM32F103C8T6为例它拥有2个ADCADC1和ADC2每个ADC最多有16个外部模拟输入通道ADC1_IN0~ADC1_IN15其中一些与ADC2复用。此外还有两个内部通道一个连接温度传感器一个连接内部参考电压VREFINT。这个12位的分辨率意味着它可以将模拟输入电压例如0-3.3V量化为0到4095之间的一个数字值ADC_Value。理想情况下电压 (ADC_Value / 4095) * Vref其中Vref是ADC的参考电压。这里就引出了第一个关键点参考电压Vref。对于STM32F103Vref默认连接到VDDA模拟电源。VDDA必须和VSSA模拟地一起从一个干净、稳定的电源引入通常与数字电源VDD3.3V来自同一LDO但需要通过磁珠或电感进行隔离并配合去耦电容滤波。如果VDDA本身纹波很大或者因为数字电路噪声而波动那么你采集到的所有电压值都会跟着“跳舞”。所以一个稳定的VDDA是ADC精度的生命线。另一个核心概念是采样时间。ADC不是瞬间完成测量的。它内部有一个采样保持电容需要时间采样时间来充电到与被测电压相同的电平。STM32F103允许你为每个通道单独配置采样时间单位是ADC时钟周期。采样时间太短电容充电不足测量值就会偏低且不稳定采样时间太长虽然更稳定但会降低整体采样速率在多通道扫描时尤其明显。对于高阻抗的信号源比如经过一个大电阻分压后的信号必须设置足够长的采样时间。官方数据手册会给出不同源阻抗下的建议采样时间这是一个必须查阅的黄金参数。ADC的触发方式也决定了它的工作模式。你可以选择软件触发调用HAL_ADC_Start()或者用定时器、外部引脚等硬件触发。对于连续采集的场景比如波形显示硬件触发配合DMA是唯一的选择。2.2 分压电路与输入保护给ADC穿上“防护服”绝大多数时候我们测量的电压都会超过ADC的输入范围0-Vref通常0-3.3V。这时就需要分压电路。计算分压电阻看起来是小学生数学但选型不当就是灾难。假设要测量0-12V的电池电压采用经典的R1上拉、R2下拉分压目标是将12V分压到3.0V左右留一点余量。你可能会随手选两个10kΩ电阻。但这里有个陷阱ADC的输入引脚不是完全开路的它有一个等效的输入阻抗和采样开关的漏电流。如果下拉电阻R2的阻值太大比如100kΩ以上ADC采样时微小的漏电流就会在R2上产生一个不可忽视的误差电压。一个实用的经验法则是分压电路的下拉电阻R2建议在10kΩ以下常见取2kΩ~10kΩ。这样ADC输入阻抗的影响可以忽略不计。确定了R2再根据分压比计算R1。注意分压电阻的精度直接影响测量精度。如果要求1%的测量精度那么电阻至少要用1%精度的。温漂系数也需要根据应用环境考虑。接下来是输入保护。ADC引脚非常脆弱过压、静电、负电压都可能损坏它。即使你测量的是电池这种看似安全的电源也可能会因为插拔、负载突变产生电压尖峰。一个简单有效的保护电路是在ADC输入引脚前串联一个100-500Ω的电阻Rs并并联一个钳位二极管到VDDA和VSSA比如BAT54S这样的肖特基二极管。这个串联电阻Rs和ADC输入电容会形成一个低通滤波器有助于抑制高频噪声。但Rs不能太大否则会和ADC的采样电容影响充电时间可能需要进一步增加采样时间。关于热词中提到的“ADC采样保护电路能不能用BAV99”BAV99是一个双二极管常用于信号钳位。它可以用作简单的钳位保护但其正向压降约0.7V比肖特基二极管约0.3V大这意味着你的输入信号在超过VDDA0.7V或低于VSSA-0.7V时才会被钳位保护“门槛”较高。对于3.3V系统如果输入信号可能超过4.0VBAV99仍能提供保护。但如果你追求更精细的过压保护比如紧贴3.6V或者信号电压本身就很接近3.3V那么低Vf的肖特基二极管是更好的选择。此外BAV99的结电容相对较大对高速信号可能有影响但对于直流或低速电压采集完全够用。3. CubeMX配置详解每一个选项背后的意义硬件电路准备妥当后我们进入软件配置环节。使用STM32CubeMX可以极大简化初始化过程但如果不理解每个选项的含义就等于在闭着眼睛开车。3.1 时钟树与ADC时钟速度与精度的平衡打开CubeMX在Pinout Configuration标签页中找到ADC1。首先需要配置的是ADC时钟。STM32F103的ADC时钟源自APB2总线时钟PCLK2并经过一个可编程的分频器。ADC有一个最大时钟频率限制对于STM32F103通常是14MHz具体请查对应型号的数据手册。假设你的PCLK2是72MHz系统常用配置那么分频系数至少需要设为672/612MHz才能保证ADC正常工作。核心原则在不超过最大额定频率的前提下ADC时钟越慢通常对抗噪声的性能越好但转换速度会下降。对于追求精度的直流或低速测量我习惯将ADC时钟设置在12-14MHz的下限附近比如设置分频为6得到12MHz的ADC时钟。接下来在Parameter Settings选项卡中我们会遇到一系列关键设置Resolution分辨率选择12位。这是硬件支持的最高精度。Scan Conversion Mode扫描转换模式如果你需要循环采集多个通道必须启用这个模式。启用后ADC会按照Rank的顺序自动扫描所有使能的通道。Continuous Conversion Mode连续转换模式如果启用ADC在完成一次或一轮扫描转换后会自动开始下一次适合连续监控。如果禁用则需要每次触发软件或硬件一次转换。通常单次采集用非连续模式配合DMA的连续采集用连续模式。Discontinuous Conversion Mode间断转换模式这是一个高级功能可以将一组通道分成若干子组来触发。在初期可以保持禁用。DMA Continuous RequestsDMA连续请求如果你使用了DMA这个选项至关重要当启用DMA且ADC为连续转换模式时必须勾选此项以确保DMA能持续不断地将转换数据搬运到内存否则DMA只搬运一次就停止了。End Of Conversion Selection转换结束选择选择EOC转换结束标志在每次单个通道转换完成后置位还是在所有序列通道转换完成后置位。配合DMA时通常选择每个通道转换后产生EOC。3.2 通道配置与采样时间精度调优的关键在Configuration标签页点击ADC1进入详细配置。这里最重要的就是Channel Configuration。假设我们配置了三个通道通道0PA0测量外部电压通道16温度传感器和通道17内部参考电压VREFINT。我们需要为每个通道分配一个Rank顺序并设置其Sampling Time采样时间。采样时间的选择是一个权衡。时钟周期数越多采样时间越长精度越高尤其是对高阻抗源。STM32F103的采样时间可以从1.5个周期到239.5个周期。如何选择对于低阻抗源如运放输出、分压后的信号源阻抗10kΩ1.5到7.5个周期通常足够。对于中等阻抗源可能需要28.5或41.5个周期。对于高阻抗源或要求高精度建议使用最长的239.5个周期。一个实操技巧你可以先设置一个较长的采样时间如239.5周期确保读数稳定然后逐步减小采样时间同时观察ADC读数的波动情况直到找到在可接受噪声水平下的最短采样时间这样可以优化采样速率。对于内部通道温度传感器和VREFINT必须使用更长的采样时间因为它们的输出阻抗较高。数据手册明确要求采样时间不低于17.1μs。在12MHz ADC时钟下一个周期约83.3ns你需要至少设置17.1μs / 83.3ns ≈ 205个周期。所以设置为239.5个周期是安全且常见的做法。最后在DMA Settings标签页添加DMA。对于ADC1通常选择DMA1 Channel1。模式设为Circular循环模式这样DMA会周而复始地将数据搬运到指定数组无需软件干预。数据宽度都选择Word32位因为HAL库的ADC值是通过一个32位变量返回的。4. 代码实现与DMA传输构建高效的数据流水线配置生成代码后我们进入编程环节。HAL库封装得很好但只有理解其流程才能用得顺手并处理异常。4.1 基础单次采集与轮询对于最简单的单通道单次采集流程如下// 1. 启动ADC HAL_ADC_Start(hadc1); // 2. 等待转换完成超时时间根据采样时间设置通常10-50ms足够 if (HAL_ADC_PollForConversion(hadc1, 50) HAL_OK) { // 3. 读取转换值 uint32_t adc_value HAL_ADC_GetValue(hadc1); // 4. 转换为电压 (假设Vref3.3V) float voltage (adc_value * 3.3f) / 4095.0f; } // 5. 停止ADC对于单次模式HAL_ADC_GetValue后会自动停止但显式停止是好习惯 HAL_ADC_Stop(hadc1);这种方式简单但效率低下因为CPU大部分时间在等待PollForConversion。它只适用于对实时性要求极低、偶尔读取一下的场景。4.2 中断方式与多通道扫描中断方式可以让CPU在ADC转换期间去处理其他任务。// 启动ADC中断转换 HAL_ADC_Start_IT(hadc1); // 转换完成中断回调函数 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { if (hadc-Instance ADC1) { uint32_t adc_value HAL_ADC_GetValue(hadc); // 处理数据... } }对于多通道扫描你需要在Rank中配置好顺序然后ADC会按顺序转换每转换完一个通道就会进入一次中断如果EOC选择是每通道。这样你可以在中断里读取值并存入数组。但频繁的中断会消耗大量CPU资源当通道多、采样快时系统可能就被中断“淹没了”。4.3 DMA传输解放CPU的终极方案对于稳定、连续的电压采集DMA是必选项。它能在ADC转换完成后自动将数据搬运到你指定的内存数组中完全不需要CPU参与。初始化后你只需要启动一次// 定义一个数组用于存放DMA搬运的数据 #define ADC_BUFF_SIZE 100 uint32_t adc_buffer[ADC_BUFF_SIZE]; // 启动ADC并开启DMA传输到指定数组长度设为缓冲区大小模式为循环 HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_buffer, ADC_BUFF_SIZE);启动后ADC就会按照配置连续转换、扫描模式不停地工作DMA则循环地将转换结果填入adc_buffer。对于多通道扫描情况稍微复杂DMA搬运的数据是交替的通道数据流。假设你配置了3个通道Rank1, Rank2, Rank3那么adc_buffer里的数据排列将是[Ch1, Ch2, Ch3, Ch1, Ch2, Ch3, ...]。你需要根据这个规律来解析数据。// 假设有三个通道DMA循环填充了100个数据 for(int i 0; i ADC_BUFF_SIZE; i3) { float voltage_ch1 (adc_buffer[i] * 3.3f) / 4095.0f; float voltage_ch2 (adc_buffer[i1] * 3.3f) / 4095.0f; float voltage_ch3 (adc_buffer[i2] * 3.3f) / 4095.0f; // 进行后续处理如滤波、存储等 }这里有一个极易出错的地方DMA缓冲区的大小和溢出。在循环模式下DMA会不停地覆盖缓冲区中旧的数据。如果你的主程序处理数据的速度跟不上ADC采集的速度还没等处理完一轮数据新数据就把旧数据覆盖了就会导致数据丢失。解决方法是使用双缓冲区Double Buffer或者确保处理逻辑足够快。HAL库提供了HAL_ADC_ConvHalfCpltCallback半传输完成中断和HAL_ADC_ConvCpltCallback传输完成中断回调函数可以利用它们实现“乒乓操作”安全地处理数据。5. 软件滤波与校准从“有读数”到“读得准”原始的ADC值通常是充满噪声的。直接使用它你的电压读数可能会在小范围内不断跳动。此外由于基准电压Vref的微小偏差、ADC本身的增益和偏移误差计算出的电压值与真实值之间可能存在系统误差。这就需要软件滤波和校准。5.1 数字滤波算法平滑数据的艺术滤波的目的是在保持信号真实变化趋势的前提下抑制随机噪声。没有一种滤波算法是万能的需要根据信号特性选择。均值滤波最简单有效。连续采样N次取算术平均值。它能有效抑制白噪声但会引入滞后且对脉冲噪声偶发的跳变无能为力。#define SAMPLE_TIMES 10 uint32_t adc_sum 0; for(int i0; iSAMPLE_TIMES; i) { adc_sum HAL_ADC_GetValue(hadc1); // 假设单次采样 HAL_Delay(1); // 适当间隔 } uint32_t adc_average adc_sum / SAMPLE_TIMES;滑动平均滤波维护一个长度为N的队列每次新采样值入队最旧值出队然后计算队列均值。实时性比一次性均值滤波好但同样有滞后。#define FILTER_LEN 20 uint32_t adc_buf[FILTER_LEN] {0}; uint8_t buf_index 0; uint32_t adc_sum 0; // 每次获取新数据时调用 uint32_t get_filtered_value(uint32_t new_val) { adc_sum - adc_buf[buf_index]; // 减去即将被覆盖的旧值 adc_sum new_val; // 加上新值 adc_buf[buf_index] new_val; // 更新缓冲区 buf_index (buf_index 1) % FILTER_LEN; // 索引循环 return adc_sum / FILTER_LEN; }中值滤波连续采样N次N为奇数将这N个值排序取中间值作为输出。对脉冲噪声有奇效。比如你的ADC偶尔会受到干扰产生一个极大或极小的值均值滤波会被这个坏值严重影响而中值滤波能直接把它剔除。可以结合均值滤波使用先中值滤波去脉冲再均值滤波平滑。一阶滞后滤波低通滤波Y(n) α * X(n) (1-α) * Y(n-1)。其中α是滤波系数0α1X(n)是新采样值Y(n-1)是上次滤波输出。α越接近1新采样值权重越大响应快但噪声大α越接近0滤波效果平滑但滞后严重。这种滤波计算量小非常适合在MCU上实现。float alpha 0.1f; // 滤波系数 float filtered_val 0.0f; float first_order_lpf(float new_sample) { filtered_val alpha * new_sample (1 - alpha) * filtered_val; return filtered_val; }我的经验是对于变化缓慢的直流电压如电池电压采用“中值滤波滑动平均”的组合拳效果非常好。先采集5-7个值做中值滤波剔除野值再将这个中值送入一个长度为10-20的滑动平均窗口可以得到极其稳定平滑的结果。5.2 内部参考电压校准消除芯片个体差异这是提升绝对精度的最重要步骤但也是最容易被忽略的。公式电压 (ADC_Value / 4095) * Vref假设Vref是精确的3.3V且ADC的12位刻度是完美的。现实并非如此。VDDA可能实际是3.28V或3.32VADC本身也存在零点偏移和增益误差。STM32提供了一个工厂预校准的内部参考电压VREFINT。它在芯片出厂时被测量并存储在系统存储区通常地址为0x1FFFF7BA和0x1FFFF7C0对于F103具体地址需查参考手册。这个电压非常稳定约1.2V不随外部VDDA变化。我们可以利用它来反向推算出实际的VDDA。校准步骤如下在ADC中启用VREFINT通道并设置足够长的采样时间。采集VREFINT通道的ADC原始值记为ADC_RAW_VREFINT。从系统存储区读取工厂校准值记为VREFINT_CAL这是一个在VDDA3.3V、30°C环境下测量VREFINT通道得到的12位ADC值。计算实际VDDAVDD_ACTUAL (3.3V * VREFINT_CAL) / ADC_RAW_VREFINT。后续计算任何外部通道电压时使用这个计算出的VDD_ACTUAL作为参考电压而不是理想的3.3V。// 1. 读取校准值 (地址请根据具体型号查Reference Manual) #define VREFINT_CAL_ADDR ((uint16_t*)0x1FFFF7BA) uint16_t vrefint_cal *VREFINT_CAL_ADDR; // 2. 假设你已经通过DMA或中断获得了VREFINT通道的原始值 adc_raw_vrefint float vdda_actual (3.3f * vrefint_cal) / adc_raw_vrefint; // 3. 计算外部通道电压 float external_voltage (adc_raw_external * vdda_actual) / 4095.0f;通过这个方法即使你的VDDA因为电源波动变成了3.25V你也能准确计算出外部电压因为它所用的参考基准vdda_actual是实时测算出来的。这相当于给ADC做了一次“在线标定”。5.3 两点校准法追求极致精度如果内部参考电压校准后你发现测量值与高精度万用表读数之间还存在固定的线性误差比如整体偏大1%这可能是因为ADC的增益误差。此时可以进行两点校准。你需要两个已知的、稳定的精确电压源最好一个在量程低端如0.5V一个在高端如3.0V。分别用你的ADC系统测量它们得到原始值ADC_RAW_LOW和ADC_RAW_HIGH。设理想情况下电压V和ADC原始值RAW的关系是V k * RAW b。 已知V_LOW对应RAW_LOWV_HIGH对应RAW_HIGH可以解出k (V_HIGH - V_LOW) / (RAW_HIGH - RAW_LOW)b V_LOW - k * RAW_LOW将k和b存入Flash以后所有采集的原始值都通过这个公式V k * RAW b来计算电压。这可以同时补偿偏移误差和增益误差将系统精度提升到硬件极限。6. 高级话题与实战排坑掌握了基础配置、DMA和校准你的ADC采集系统已经相当可靠了。但在一些复杂场景下还会遇到更深层次的问题。6.1 多ADC交替采样与高采样率热词中提到了“双ADC交错采集实现4M采样率”。STM32F103的ADC支持双模式Dual Mode即ADC1和ADC2可以协同工作。在“交替采样”模式下两个ADC可以交替对同一通道进行采样理论上可以将采样率翻倍。但请注意STM32F103的ADC转换时间采样时间12.5个周期的固定转换时间是有限的。在12MHz ADC时钟、239.5周期采样时间下一次转换就需要(239.512.5)/12e6 ≈ 21μs即单通道采样率不到50ksps。即使使用最短采样时间1.5周期单通道极限采样率也在1/((1.512.5)/12e6) ≈ 857ksps左右。要实现更高的采样率必须使用多ADC并行、降低分辨率如降到8位或使用更快的ADC时钟需确保不超过芯片极限。对于STM32F103追求Msps级别的采样率是比较困难的需要仔细评估时序。6.2 PCB布局与噪声抑制所有精心的软件滤波都可能被糟糕的硬件布局毁掉。ADC精度是“毫伏”甚至“微伏”级别的较量PCB上的噪声很容易淹没信号。电源隔离VDDA和VSSA必须通过磁珠或0Ω电阻从VDD和VSS隔离并在靠近ADC引脚处放置一个10μF的钽电容和一个0.1μF的陶瓷电容进行去耦。地平面保持完整的地平面模拟部分和数字部分的地在一点连接通常在磁珠或0Ω电阻处单点接地。信号走线模拟信号线ADC输入线应远离高频数字信号线如时钟线、PWM线。如果必须交叉尽量垂直交叉。可以在模拟信号线两侧布置地线进行屏蔽。元件摆放分压电阻、滤波电容应尽可能靠近ADC输入引脚。6.3 典型问题排查清单当你的ADC采集出现问题时可以按以下清单排查读数完全不对或为0/4095检查引脚配置是否正确模拟输入模式。检查VDDA和VSSA是否已正确连接电源和地。用万用表测量ADC引脚实际电压确认是否在0-VDDA范围内。如果超过可能已损坏。读数不稳定跳动大首先检查采样时间是否足够尤其是对高阻抗信号源。检查电源VDDA的纹波。可以用示波器交流耦合档观察。检查PCB布局是否存在数字噪声干扰。在ADC输入引脚对地加一个0.1μF~1μF的电容注意这会增加源阻抗可能需要更长的采样时间。启用并检查软件滤波。多通道采集数据错乱确认扫描模式Scan Conversion Mode已启用。确认DMA配置正确且缓冲区大小和通道数匹配。检查数据解析逻辑是否正确通道顺序。如果是中断方式检查是否在中断中及时读取了数据或者中断是否被更高优先级中断打断导致数据丢失。测量值有固定偏差执行内部参考电压校准。检查分压电阻的精度。考虑进行两点校准。最后关于热词中“电池包采集电芯电压和实际测量电压不一致”的问题除了上述ADC本身的误差还可能源于采集回路中的接触电阻、采样线缆的压降、多路复用开关如果用了模拟开关的导通电阻、以及BMS采集芯片与MCU ADC之间的电平转换或隔离电路带来的误差。这需要从整个信号链的每一个环节去分析和校准。ADC采集是一个从硬件到软件都需要精心设计的系统工程。它不像点灯那样立刻就有反馈每一个环节的疏忽都会在最终结果上放大。耐心地理解原理细致地配置参数严谨地处理数据你就能让STM32F103这颗经典的芯片发挥出令人满意的测量性能。