做电池供电的设备我最烦的一件事就是“显示电量还有30%下一秒直接关机”。早些年我也跟风上过库仑计芯片测SOC确实准但一颗专用IC少说十几块钱打样阶段改一版PCB就废一版料肉疼得很。后来我换了个思路STM32内部基准电压源VREFINT其实是一个被严重低估的“标尺”配合采样校准和电压-电量映射完全可以实现锂电池电量的精准监测而且不加一分钱硬件成本。这篇文章就把我的完整做法、校准公式、避坑经验以及可以直接抄走的代码模块全部整理出来给正在做低功耗仪表、手持设备或者小家电电量显示的朋友做个参考。1. 先弄明白一件事为什么直接用ADC采电压“不准”1.1 ADC的标尺——VDDA本身就不是标准的3.3V很多人写单片机ADC读电压的程序就一句话voltage adc_value * 3.3f / 4096.0f;这个3.3V从哪里来的数据手册写的参考电压。但实际板上VDDA引脚处的电压真的恰好是3.300V吗大概率不是。稳压芯片有精度误差普通LDO的典型精度在±2%左右也就是说VDDA实测可能是3.23V也可能是3.37V。用固定的3.3V去反推输入电压等于你的“尺子”本身就不标准量出来的数据自然带着系统误差。这个误差有多大举个实际例子假设VDDA真实值是3.25V你按3.3V算误差约1.5%。在3.7V附近这个误差相当于55mV。而锂电池在3.7V~3.9V平台段电压每变化0.1V对应的SOC变化可能有20%~30%算下来电量误差能到10%以上。所以电压读得“看起来挺准”一旦换算成SOC误差全部暴露出来。1.2 锂电池内阻带来的动态偏差锂电池不是理想电压源它内部有一个等效串联电阻交流内阻通常在几十毫欧到一两百毫欧之间。当负载电流流过时电池端电压会被拉低压降等于电流乘以内阻。举个例子一个内阻80mΩ的电池在2A脉冲负载下端电压瞬间掉160mV。如果这时候你拿ADC读到的端电压去查SOC表原本还有60%电量会被误判成40%。更麻烦的是负载撤掉之后电压又会反弹如果不做软件处理电量数字就会来回跳用户体验很差。这也是很多新手困惑的地方“我电压测得很准怎么看电量还是不准”因为你测的是端电压不是电池的开路电压OCV而SOC-OCV曲线或者叫开路电压查表法只有在开路状态下才有意义。1.3 分压网络误差被放大锂电池电压最高4.2V超过STM32 ADC的3.3V量程必须用电阻分压。很多设计图随手选两个电阻比如100k100k看似没问题但电阻的精度等级很关键。如果你用5%精度的贴片电阻两个电阻分压比的理论误差就有5%对应到4.2V就是210mV这误差直接把前面优化的功夫全吃掉了。所以在这个方案里分压电阻必须用1%精度最好是0603或0805封装的金属膜电阻。这三个问题叠加起来就是“直接ADC读电压做电量检测不靠谱”的根本原因。好消息是这三个问题分别可以用“内部基准校准”、“软件补偿算法”、“合理选型”来解决并不需要额外花大价钱上库仑计。2. VREFINT内部基准把出厂校准值变成你的“定盘星”2.1 VREFINT是什么STM32芯片内部除了CPU和Flash还有一个带隙基准电压源Bandgap它输出的电压大约是1.2V这个电压非常稳定不受VDDA波动影响。芯片出厂时厂家在VDDA等于某个标准电压F1系列是3.0V、温度约30°C的条件下测量了这个基准对应的ADC转换值并把它固化在芯片系统存储器区域这个值就是VREFINT_CAL。运行时我们可以读取VREFINT通道的ADC值在F103上对应ADC1_IN16记作VREFINT_DATA。由于ADC是比例型转换器它把输入电压与VDDA的比值量化成数字量所以VREFINT_DATA实际上是“内部基准电压占VDDA的比例”。如果VDDA偏低了VREFINT_DATA会偏高VDDA偏高时VREFINT_DATA会偏低。这就是我们反推真实VDDA的依据。2.2 校准值存在哪里不同芯片不一样关键问题是校准值放在哪个地址不同系列不一样必须按型号查数据手册。我把常用的几个系列给大家列出来系列校准值存储器地址手册中的参数名称STM32F1030x1FFFF7BAVREFINT_CALSTM32F4070x1FFF7AAAVREFINT_CALSTM32F0系列0x1FFFF7BAVREFINT_CALSTM32L0系列0x1FF8007A 附近双bank可选VREFINT_CAL在STM32F1和F4上读取方式很简单直接把这个地址强制转换成指针即可uint16_t vref_cal *((uint16_t*)0x1FFFF7BA);注意这个校准值每个芯片都不同是出厂的时候烧进去的不能从别人工程里复制一个常数来用否则就失去校准的意义了。2.3 校准公式两次ADC读数算出真实电压当我们需要测量某一路输入电压时先得到两个ADC读数一个是目标通道的ADC值ADC_CH另一个是VREFINT通道的ADC值VREFINT_DATA。已知出厂条件当VDDA3.0V时VREFINT_CAL对应的比例是固定的。那么实际VDDA为VDDA_ACTUAL 3.0V × VREFINT_CAL / VREFINT_DATA而目标通道的真实电压为V_CH ADC_CH × VDDA_ACTUAL / 4096合并公式VREFINT_DATA在分子分母中配合消掉一部分最终得到V_CH 3.0V × VREFINT_CAL × ADC_CH / (4096 × VREFINT_DATA)这个公式才是真正绕开“3.3V固定值”错误的正确写法。实测下来同样的硬件、同样的分压网络校准前后同一个电池电压的读数差异可能达到50mV以上在4.2V满电附近尤其明显。下面是核心代码模块可以直接放进工程里用// VREFINT通道ADC采集以STM32F1标准库为例 uint16_t GetVREFINTData(void) { uint16_t sum 0; for (uint8_t i 0; i 8; i) { ADC_RegularChannelConfig(ADC1, ADC_Channel_16, 1, ADC_SampleTime_239Cycles5); ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC)); sum ADC_GetConversionValue(ADC1); } return sum / 8; }这里特别强调一下采样时间为什么要选239.5个周期而不是最快的1.5周期。ADC的采样电容需要在采样时间内充电到接近输入电压VREFINT通道内部输入阻抗较高采样时间太短会导致转换值偏小。我用1.5周期和239.5周期对比过结果差了不少这个坑必须避开。2.4 校准精度实测我在一块STM32F103C8T6的小板子上做了验证LDO输出标称3.3V实际用万用表测得VDDA3.253V。直接用3.3V换算3.7V电压读出来是3.645V差55mV。用VREFINT校准后读出来是3.702V差2mV以内。55mV在SOC估算里可能就是10%~15%的误差校准后基本把底层系统误差消除了。3. 从“测准电压”到“估准电量”构建锂电池SOC估算模型电压测准了这只是第一步。要鲁棒地估计电量百分比还需要一套映射模型。3.1 为什么不能直接拿电压对SOC锂电池放电曲线有一个特点在30%~80%这个区间电压变化非常平缓平台段很平。如果直接拿瞬时电压查表比如3.85V对应50%3.82V对应40%那中间差0.03V只要ADC有10mV的波动SOC就会上下跳十几个百分点完全没法用。而且前面讲了负载电流会造成端电压下跌这个下跌量和放电深度无关纯粹是内阻压降。所以正确的做法不是“测电压-查表”两步走而是“测电压-修正-查表”三步走。3.2 分段线性查表简单、好用、代码量小我采用的方案是分段线性查表把电池的开路电压-OCV曲线按照电量区间离散成若干关键点存成一张表落在两点之间的电压用线性插值。表不用太密10%一档完全够用。下面是一张基于三元锂电池、0.2C放电、25°C环境下的典型OCV-SOC表仅作示例实际项目需要用你手头电池实测标定SOC(%)OCV(V)1004.16904.07803.98703.92603.86503.82403.78303.74203.68103.5853.4003.00查表代码写起来很直接typedef struct { float volt; uint8_t soc; } OCVPoint; const OCVPoint ocvTable[] { {4.16f, 100}, {4.07f, 90}, {3.98f, 80}, {3.92f, 70}, {3.86f, 60}, {3.82f, 50}, {3.78f, 40}, {3.74f, 30}, {3.68f, 20}, {3.58f, 10}, {3.40f, 5}, {3.00f, 0}, }; uint8_t LookupSOC(float voltage) { if (voltage ocvTable[0].volt) return 100; if (voltage ocvTable[11].volt) return 0; for (uint8_t i 0; i 11; i) { if (voltage ocvTable[i].volt voltage ocvTable[i 1].volt) { float ratio (ocvTable[i].volt - voltage) / (ocvTable[i].volt - ocvTable[i 1].volt); float soc ocvTable[i].soc - ratio * (ocvTable[i].soc - ocvTable[i 1].soc); return (uint8_t)(soc 0.5f); } } return 0; }这里有几个细节要注意表必须是降序排列从高电压到低电压插值用线性就够没必要做拟合成多项式因为电池自身离散性远大于拟合误差SOC返回的是整数但在显示时建议保留一位小数后续做平滑滤波更容易。3.3 负载补偿没有电流采样怎么修正端电压有些设备有电流采样电阻但更多轻量级应用没有电流反馈。没有电流怎么办我用了一个务实的策略只在“准静态”条件下更新SOC显示。具体做法是系统每次开机时先延时一段时间再采样让电池从静置转向带载的过渡期结束主循环里以较长周期比如每隔10秒采样一次每次连续采多组取平均值如果发现连续几次采集的电压变化超过50mV认为负载剧烈变化暂停更新等稳定后再更新。这种策略牺牲了一点实时性换来了显示稳定性。实际体验下来用户看电量表不是看瞬时值而是看趋势所以宁慢勿跳。如果你的系统有I2C电流传感器或低边采样电阻可以在电压上补偿一个deltaV I × Rinternal精度会进一步提升但成本也会上去这属于可选增强项。3.4 温度影响的处理思路锂电池在低温下内阻增大、可用容量变小同一电量下开路电压会略微下降。如果你不做温度修正冬天户外设备的电量会显示偏低。简单做法是通过NTC采集电池温度查表或按系数修正SOC。经验值是这样的0°C时25°C的OCV-SOC曲线整体下移约0.1V-10°C下移约0.15V。如果你是在室内设备或常温环境这部分可以忽略但做户外产品建议至少做一个线性修正if (temperature 25) { float offset (25 - temperature) * 0.004f; // 每降低1°COCV下降约4mV correctedVoltage voltage offset; }注意修正的方向低温下想要得到SOC需要把实测电压“抬高”后再查表因为低温压降更大。4. 完整代码实现从校准到SOC输出一次走通4.1 硬件准备与引脚说明我用STM32F103C8T6最小系统板做的验证。电池电压经过两个100kΩ和100kΩ电阻分压分压点接到PA1ADC1_IN1VREFINT直接使用内部通道不需要外部引脚。VDDA直接接3.3V注意VDDA和VSSA之间要放一个1μF陶瓷电容并且尽量靠近MCU引脚这个细节对ADC稳定性影响很大。有一点要特别说明如果用电池直接给系统供电电池电压范围是3.0V~4.2V超过了STM32的工作电压范围必须经过稳压到3.3V再给MCU供电。我在验证板上用的是AMS1117-3.3它的压差约1V意味着输入电压至少要4.3V跟满电4.2V非常接近实际会早早就进入欠压状态所以真正做产品要用低压差LDO比如XC6206-3.3或RT9013压差只有100mV级别。4.2 ADC初始化代码void ADC1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; ADC_InitTypeDef ADC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); // ADC时钟 72MHz/6 12MHz GPIO_InitStructure.GPIO_Pin GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AIN; GPIO_Init(GPIOA, GPIO_InitStructure); ADC_InitStructure.ADC_Mode ADC_Mode_Independent; 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; ADC_Init(ADC1, ADC_InitStructure); ADC_Cmd(ADC1, ENABLE); // 校准ADCF1系列特有的自校准步骤 ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); }4.3 电压采集与VREFINT校准函数// 读取指定通道ADC值过采样去极值平均 uint16_t GetADCValue(uint8_t channel) { uint16_t samples[8]; uint16_t temp; uint16_t sum 0; // 冒泡排序去掉最大最小值 for (uint8_t i 0; i 8; i) { ADC_RegularChannelConfig(ADC1, channel, 1, ADC_SampleTime_239Cycles5); ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC)); samples[i] ADC_GetConversionValue(ADC1); } for (uint8_t i 0; i 7; i) { for (uint8_t j 0; j 7 - i; j) { if (samples[j] samples[j 1]) { temp samples[j]; samples[j] samples[j 1]; samples[j 1] temp; } } } for (uint8_t i 1; i 7; i) sum samples[i]; return sum / 6; } // 计算实际电池电压单位V float GetBatteryVoltage(void) { uint16_t vref_cal *((uint16_t*)0x1FFFF7BA); uint16_t vref_data GetADCValue(ADC_Channel_16); // 内部基准通道 uint16_t vbat_data GetADCValue(ADC_Channel_1); // 电池分压通道 // 分压采样点电压 float v_sense 3.0f * (float)vref_cal * (float)vbat_data / (4096.0f * (float)vref_data); // 两枚100k电阻分压恢复真实电池电压注意不同分压比要改这里 return v_sense * 2.0f; }这里我再解释一下公式的物理意义vref_cal/vref_data 这个比值补偿了VDDA偏离3.0V的误差vbat_data/4096是采样点相对于VDDA的比例两者相乘以3.0V校准基准得到的就是真实电压和VDDA具体是多少已经没关系了。4.4 主循环的完整调用逻辑int main(void) { uint8_t soc 0; float battery_volt 0.0f; SystemInit(); Delay_Init(); ADC1_Init(); UART1_Init(115200); // 用于调试输出 // 开机等待电压稳定 Delay_Ms(3000); while (1) { battery_volt GetBatteryVoltage(); soc LookupSOC(battery_volt); printf(Vbat: %.3f V, SOC: %d%%\n, battery_volt, soc); Delay_Ms(1000); } }4.5 常见的坑STOP模式下的采样如果你的产品会进入低功耗模式STOP模式下ADC和外设时钟都停了唤醒后不能马上采样。唤醒瞬间系统电源会有一个短暂的恢复过程VDDA可能还没稳定。我的做法是唤醒后先延时10~20ms让LDO稳压输出和VDDA的滤波电容都稳定下来再做采样否则读到的电压普遍偏低SOC会被低估。5. 工程化调优把“能跑”变成“好用”的核心细节5.1 分压电阻怎么选分压电阻的阻值要在“自身功耗”和“ADC采样误差”之间做平衡。阻值太小电池的静态漏电变大待机功耗做不下去阻值太大ADC采样电容充电时间不足读数偏低。我常选的组合是上分压电阻100kΩ下分压电阻100kΩ然后在下电阻上并联一个0.1μF电容构成低通滤波采样时间用239.5周期。这个组合的静态漏电只有约20μA4.2V/200k对几百毫安时的电池来说可以接受同时100k源阻抗配0.1μF电容时间常数只有10ms通过延长采样时间完全可以稳定。如果想进一步降低静态功耗可以串联一个MOSFET把分压网络在休眠时断开唤醒后再打开。这个功能在做超低功耗产品时很关键STM32L4系列内部已经有这种灵活的分压开关思路。5.2 电量显示的平滑与防跳变查表SOC天然会抖动尤其是电压落在平台段时电压微小波动就对应SOC波动。我采用的策略是软件防抖维护一个全局的SOC显示值每次算出的新SOC和显示值比较差值小于2%就不更新差值在2%~10%时按1%/s的速度缓慢逼近差值大于10%时直接更新。这种策略的代码很简单void SocDisplayUpdate(uint8_t new_soc) { static uint8_t display_soc 255; int16_t diff; if (display_soc 255) { display_soc new_soc; return; } diff (int16_t)new_soc - (int16_t)display_soc; if (diff 10 || diff -10) { display_soc new_soc; // 突变情况如换电池 } else if (diff 2) { display_soc; } else if (diff -2) { display_soc--; } }5.3 充电状态下的特殊处理充电时电池电压不是随电量单调上升那么简单还叠加了充电电流在内阻上产生的压降。如果用开路电压法查表会严重高估电量。我通常的做法是先判断充电状态通过充电IC的CHRG引脚或者检测外接电源充电模式下不再用OCV查表而是用一个简单的“电压达到4.05V判定为90%左右达到4.15V且充电电流很小判定为100%”的充电SOC表充满后进入满电状态再退出充电时重新校准。另外注意充电刚结束时电池电压从4.2V缓慢回落到4.1V这个回落后电压更接近真实OCV要做一次SOC重估否则满电拔电后电量显示会掉到95%用户不理解其实就是没做回落校准。5.4 和库仑计方案的取舍聊这么多我把两种方案做个硬核对比方便你做选型维度STM32内部基准方案库仑计芯片方案额外硬件成本几乎为零10~30元精度长期累计一般依赖标定高电流积分实时动态响应需软件补偿天然适应复杂度低中低功耗能力极低功耗库仑计本身也消耗电流适合场景电量显示、低电告警航模、BMS、高精度计量我的判断是如果产品只是“显示剩余电量百分比低压报警”内部基准方案完全够用系统越简单越好如果是电池包本身或者电动工具这种大电流、高动态、需要追溯安时数的场合库仑计还是绕不开。两种方案不是替代关系而是看项目定位。6. 我踩过的坑和一点实用建议6.1 万用表校准分压比的坑分压电阻即使买1%精度的实际比值也可能有1%的误差这个误差在校准公式里补偿不掉因为它是物理分压网络的误差不是ADC参考电压的误差。解决方法是产品量产前在产测环节用高精度电压源给一个3.8V标准电压读取换算后的结果然后把这个偏差值存储到EEPROM或Flash里运行时做补偿。没有产测条件的话至少要在代码里留一个校准系数宏手工改一次也能解决。6.2 VREFINT读数波动偏大的处理VREFINT通道本身是内部低压信号很容易被数字噪声干扰。我第一次实测时VREFINT数据在1550~1600之间跳导致换算电压忽高忽低。后来发现是两个问题一是采样时间太短二是VREFINT通道没有做多次平均。解决方法是采样时间拉满到239.5周期连续采8次去掉最大最小取平均波动基本压到正负2个LSB以内。6.3 “校准地址”千万别照抄不同封装、不同系列的校准地址可能完全不同比如F103的0x1FFFF7BA在F407上是0x1FFF7AAA。如果在F407上用了F103的地址读出来的值要么是0xFFFF要么是乱码电压计算会完全崩溃。所以代码里面最好按芯片型号用条件编译区分#if defined(STM32F103xx) #define VREFINT_CAL_ADDR ((uint16_t*)0x1FFFF7BA) #elif defined(STM32F407xx) #define VREFINT_CAL_ADDR ((uint16_t*)0x1FFF7AAA) #endif6.4 不建议省略ADC自校准STM32F1系列ADC有一个上电自校准步骤通过写入ADC_CR2的CAL位触发校准后内部自动修正电容匹配误差。这个步骤不影响VREFINT校准但是能改善ADC整体线性度。我在代码里已经包含了建议大家在移植的时候保留。6.5 一个最终的提醒内部基准法本质上是一个“零元购”版本的电压法电量监测它能做到“够用”的精准但不要去跟库仑计比累计误差。在样板阶段如果你发现电压数据已经校准得非常好SOC还是不太准问题大概率出在你的电池OCV-SOC表不对而不是代码问题。买几颗同型号电池用电子负载做一遍0.2C放电实验把实际数据填进表里这才是提升电量精度最有效的一步。我最初做这个方案时也怀疑过不花一分钱硬件成本能不能达到“精准”两个字。实际验证下来VREFINT校准消除系统性误差后配合查表和软件防抖手机壳大小的设备上做到显示电量误差±5%以内是完全可以落地的。这个方案的所有核心代码加起来不过一百多行没有任何魔法只是把芯片自带的资源用到位了。希望这篇整理能帮你少走几步我之前走过的弯路。