☰
STM32内置VREFINT基准实现锂电池电量监测:免库仑计的精准SOC方案
2026/9/28 1:20:15 网站建设 项目流程

做电池供电的设备,我最烦的一件事就是“显示电量还有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量程,必须用电阻分压。很多设计图随手选两个电阻,比如100k+100k,看似没问题,但电阻的精度等级很关键。如果你用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_CAL
STM32F4070x1FFF7AAAVREFINT_CAL
STM32F0系列0x1FFFF7BAVREFINT_CAL
STM32L0系列0x1FF8007A 附近,双bank可选VREFINT_CAL

在STM32F1和F4上,读取方式很简单,直接把这个地址强制转换成指针即可:

uint16_t vref_cal = *((uint16_t*)0x1FFFF7BA);

注意,这个校准值每个芯片都不同,是出厂的时候烧进去的,不能从别人工程里复制一个常数来用,否则就失去校准的意义了。

2.3 校准公式:两次ADC读数算出真实电压

当我们需要测量某一路输入电压时,先得到两个ADC读数:一个是目标通道的ADC值ADC_CH,另一个是VREFINT通道的ADC值VREFINT_DATA。

已知出厂条件:当VDDA=3.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,实际用万用表测得VDDA=3.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.16
904.07
803.98
703.92
603.86
503.82
403.78
303.74
203.68
103.58
53.40
03.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°C,OCV下降约4mV correctedVoltage = voltage + offset; }

注意修正的方向:低温下想要得到SOC,需要把实测电压“抬高”后再查表,因为低温压降更大。

4. 完整代码实现:从校准到SOC输出一次走通

4.1 硬件准备与引脚说明

我用STM32F103C8T6最小系统板做的验证。电池电压经过两个100kΩ和100kΩ电阻分压,分压点接到PA1(ADC1_IN1),VREFINT直接使用内部通道,不需要外部引脚。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); // 校准ADC(F1系列特有的自校准步骤) 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μA(4.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) #endif

6.4 不建议省略ADC自校准

STM32F1系列ADC有一个上电自校准步骤,通过写入ADC_CR2的CAL位触发,校准后内部自动修正电容匹配误差。这个步骤不影响VREFINT校准,但是能改善ADC整体线性度。我在代码里已经包含了,建议大家在移植的时候保留。

6.5 一个最终的提醒

内部基准法本质上是一个“零元购”版本的电压法电量监测,它能做到“够用”的精准,但不要去跟库仑计比累计误差。在样板阶段,如果你发现电压数据已经校准得非常好,SOC还是不太准,问题大概率出在你的电池OCV-SOC表不对,而不是代码问题。买几颗同型号电池,用电子负载做一遍0.2C放电实验,把实际数据填进表里,这才是提升电量精度最有效的一步。

我最初做这个方案时,也怀疑过不花一分钱硬件成本能不能达到“精准”两个字。实际验证下来,VREFINT校准消除系统性误差后,配合查表和软件防抖,手机壳大小的设备上做到显示电量误差±5%以内是完全可以落地的。这个方案的所有核心代码加起来不过一百多行,没有任何魔法,只是把芯片自带的资源用到位了。希望这篇整理能帮你少走几步我之前走过的弯路。

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

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

立即咨询