
1. 先把方案讲明白为什么用库仑计反而“不划算”有些工程师一提到锂电池电量监测脑子里的第一反应就是库仑计芯片比如TI的BQ系列、MAXIM的MAX17043这类的。库仑计的原理是实时采集充放电电流对时间积分来估算剩余电荷量看起来确实“精准”但它有个绕不开的问题成本、面积和调试复杂度都上去了。一颗库仑计芯片少说几块钱加上采样电阻、滤波电路BOM一摊下来在成本敏感的消费类产品里根本扛不住。而且库仑计需要做电流校准产线每台设备都得标定这对小批量、多品种的生产模式来说工程成本不比芯片本身低。那有没有更便宜的路子有而且思路非常朴素锂电池的电压和剩余电量之间存在一定的映射关系。开路电压法OCV利用的就是这条关系曲线。只要能把电池电压测得足够准不插库仑计也能估算出个大概的电量。但这里藏着一个坑很多新手会踩STM32的ADC参考电压绝大多数型号默认是VDD也就是芯片的供电电压。如果你的系统是直接用锂电池给MCU供电电池电压本身就在一路下跌你拿一个“会变的尺子”去量“会变的东西”量出来的结果当然不准。我见过一个项目电池电压4.0V时ADC读出来的值居然比4.2V时还高查了半天发现就是参考电压漂移的问题。解决思路有两个方向一是加一颗外部基准芯片比如REF3030精度极高但多了硬件成本二是利用芯片自带的内部基准通道VREFINT先测出当前实际的VDD再反推电池电压。后者就是我们今天要聊的方案——零额外硬件成本、纯软件校准、精度完全够用。2. 内部基准VREFINT的工作原理以及它为什么能当“尺子”STM32内部集成了一个带隙基准源典型值1.2V左右不同系列略有差异看数据手册比如F1系列大约1.2VG0系列大约0.9V通过ADC的内部通道可以直接读出它的数字量。关键是这个带隙基准是不随供电电压变化的VDD从3.3V跌到3.0V它还是稳稳的1.2V。这就给了我们一个“不变量”来反推变化量。ADC的转换公式是ADC_Value VREFINT_Actual / VDD_Actual * 4096; // 12位ADC已知VREFINT_Actual是一个近似恒定的值我们采样得到的ADC_Value又是已知的那就能把VDD_Actual解出来VDD_Actual VREFINT_Actual * 4096 / ADC_Value;有了当前真实的VDD再测电池分压后的电压用同一个VDD作为参考把ADC值换算回真实电压误差就能控制在可接受范围内。这就像你拿着一个会伸缩的软尺量桌子长度软尺本身刻度不准但你同时量了一个已知长度的物体通过比例换算就能算出桌子的真实长度。这里有个容易忽略的细节VREFINT的精确值每一颗芯片都略有差异数据手册给的是一个典型范围。如果对精度有更高要求可以在出厂前做一次校准把这个值写进Flash。但对大多数应用来说直接用数据手册里的典型值配合软件校正精度已经能控制在2%以内对电量显示来说足够用了。3. 硬件电路设计分压电阻的正确打开方式软件方案再牛硬件不给力也白搭。锂电池电压范围一般是3.0V到4.2V磷酸铁锂是2.5V到3.65V思路相同。STM32的ADC输入范围是0到VDD如果你的VDD是3.3V直接把电池电压接到ADC引脚上就击穿了。所以必须加分压电阻。常见做法是100K100K分压把4.2V降到2.1V但这里有个问题分压电阻值太大会导致ADC采样时充电不足影响精度太小了又白白耗电。我推荐两个方案方案A中等阻值分压软件补偿用100K100KADC采样前预留足够的采样时间比如ADC采样周期设成239.5个周期软件里再乘2倍还原电压。这个方案功耗大约15uA不算完美但多数场景能接受。方案B大阻值分压并联采样电容用1M1M分压功耗降到1.6uA但必须在ADC引脚并联一个0.1uF的电容用于给采样保持电容充电。这个方案对电池寿命影响很小适合低功耗设备。// 以方案A为例硬件上PA1接VBAT通过100K100K分压 #define BATTERY_DIVIDER_RATIO 2.0f // 2:1分压我在实际项目中还喜欢把分压电阻的取值做成“等于”而不是“远大于”采样保持电容的充电需求然后通过软件拉长采样时间来解决。具体参数后面代码部分会展开。4. 软硬件协同ADC采集VREFINT和VBAT的完整流程4.1 硬件配置概览以STM32F103为例其他系列大同小异资源配置用途ADC112位分辨率扫描模式采集电压通道1PA1采样时间239.5周期采集分压后的电池电压VREFINT通道内部通道17采样时间239.5周期采集内部基准电压定时器TIM2触发ADC转换固定间隔采样每500ms一次关键点是两个通道的采样时间都要够长尤其是内部基准通道它的输出阻抗较高采样时间太短会导致ADC读到的值偏低。4.2 初始化代码void ADC_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; ADC_InitTypeDef ADC_InitStructure; // 使能时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); // ADC时钟 72MHz / 6 12MHz // PA1作为模拟输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AIN; GPIO_Init(GPIOA, GPIO_InitStructure); // ADC配置 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_TempSensorVrefintCmd(ENABLE); ADC_Cmd(ADC1, ENABLE); // 校准别漏掉这步 ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); }这段代码有两个细节值得强调。第一ADC校准必须在使能之后、第一次转换之前执行否则精度会受影响。这是STM32硬件手册明确规定的很多新手不知道。第二ADC时钟不要超过14MHzF103的限制这也是硬性要求。4.3 采集与计算核心代码// 读取某个通道的ADC值单次转换模式 uint16_t ADC_ReadChannel(uint8_t channel) { ADC_RegularChannelConfig(ADC1, channel, 1, ADC_SampleTime_239Cycles5); ADC_SoftwareStartConvCmd(ADC1, ENABLE); while(!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC)); return ADC_GetConversionValue(ADC1); } // 获取当前实际VDD电压 float Get_VDD_Voltage(void) { // 读取内部基准电压通道的ADC值 uint16_t vrefint_adc ADC_ReadChannel(ADC_Channel_Vrefint); // 校准后的VREFINT实际值单位mV出厂时可写入Flash // 未校准时用数据手册典型值F103约1200mV float vdd_mv 1200.0f * 4096.0f / (float)vrefint_adc; return vdd_mv; } // 读取电池电压 float Get_Battery_Voltage(void) { float vdd_mv Get_VDD_Voltage(); // 读取分压后的电压 uint16_t adc_val ADC_ReadChannel(ADC_Channel_1); // 计算分压点电压 float divider_mv vdd_mv * (float)adc_val / 4096.0f; // 乘回分压比得到实际电池电压 float battery_mv divider_mv * BATTERY_DIVIDER_RATIO; return battery_mv; }这里有个核心计算逻辑我拆解一下。首先通过内部基准反推出VDDVDD 1200 * 4096 / VREFINT_ADC然后用这个“真实的VDD”作为ADC参考电压把电池通道的ADC值换算成实际电压V_divider VDD * ADC_BAT / 4096 V_battery V_divider * 2这个思路的核心在于两个通道共用同一个VDD参考而VDD的真实值又由VREFINT精确标定所以结果不会因为电源波动而失真。5. 电量估算策略从电压到SOC的换算艺术拿到准确实的电压值之后下一步把电压映射成剩余电量百分比SOC。这里要区分两种情况。5.1 静态场景OCV查表法如果设备在待机或轻载状态下电池近似开路状态可以直接用OCV-SOC查表。我用的典型数据如下以钴酸锂/三元锂电池为例电压VSOC%4.201004.10904.00803.90653.80503.70353.60203.50103.3053.000注意这个表不能盲抄不同厂家、不同材料的电池曲线差异很明显。最靠谱的做法是找电池供应商要OCV-SOC曲线文档或者自己用充放电设备测一遍。表建好之后用线性插值算中间值float VoltageToSOC(float voltage_mv) { // 电压-电量映射表单位mV const uint16_t vol_table[] {4200, 4100, 4000, 3900, 3800, 3700, 3600, 3500, 3300, 3000}; const uint8_t soc_table[] {100, 90, 80, 65, 50, 35, 20, 10, 5, 0}; const uint8_t table_len 10; if (voltage_mv vol_table[0]) return soc_table[0]; if (voltage_mv vol_table[table_len - 1]) return soc_table[table_len - 1]; // 线性插值 for (uint8_t i 0; i table_len - 1; i) { if (voltage_mv vol_table[i] voltage_mv vol_table[i 1]) { float ratio (float)(vol_table[i] - voltage_mv) / (float)(vol_table[i] - vol_table[i 1]); return soc_table[i] - (uint8_t)((soc_table[i] - soc_table[i 1]) * ratio); } } return 0; }5.2 动态场景负载补偿设备工作时电流一大电池内阻上就会产生压降实际测到的端电压比开路电压低不少。比如内阻100mΩ的电池1A电流流过时会产生100mV的压降这会导致SOC被严重低估。解决办法是加一个滞回区间或者记录负载变化前后的电压差来做补偿。但这里有个设定我们刻意不用库仑计就是不想引入电流采样电路所以无法精确知道负载电流。那咋办两个思路第一短时的负载波动做平滑比如对电压采样值做一阶低通滤波时间常数根据设备的负载周期来定过滤掉瞬时压降的毛刺。第二释放后的电压反弹补偿当检测到负载从大电流切换到小电流时电压会有一个爬升过程此时不要立刻更新SOC等电压稳定30秒到1分钟后再更新。// 简单的一阶低通滤波 uint16_t voltage_smooth 0; uint16_t SmoothVoltage(uint16_t new_voltage) { // 系数越小越平滑但响应越慢 voltage_smooth (uint16_t)((uint16_t)voltage_smooth * 0.9f (uint16_t)new_voltage * 0.1f); return voltage_smooth; }6. 完整代码工程直接可用的电量监测模块把上面的逻辑整合成一个模块拆成battery.c和battery.h方便移植。6.1 battery.h#ifndef __BATTERY_H #define __BATTERY_H #include stm32f10x.h // 分压比100K100K分压时是2 #define BATTERY_DIVIDER_RATIO 2.0f // 校准值出厂时可用精确万用表一次写入典型值1200mV #define VREFINT_CAL_VALUE 1200.0f void Battery_Init(void); float Battery_GetVoltage(void); // 返回电压单位V uint8_t Battery_GetSOC(void); // 返回电量百分比 void Battery_Update(void); // 周期性调用建议500ms一次 #endif6.2 battery.c#include battery.h static uint16_t voltage_smooth 0; static uint8_t soc_filter 0; // 电压转SOC查表 static float VoltageToSOC(float voltage_mv) { const uint16_t vol_table[] {4200, 4100, 4000, 3900, 3800, 3700, 3600, 3500, 3300, 3000}; const uint8_t soc_table[] {100, 90, 80, 65, 50, 35, 20, 10, 5, 0}; if (voltage_mv vol_table[0]) return 100.0f; if (voltage_mv vol_table[9]) return 0.0f; for (uint8_t i 0; i 9; i) { if (voltage_mv vol_table[i] voltage_mv vol_table[i 1]) { float ratio (float)(vol_table[i] - voltage_mv) / (float)(vol_table[i] - vol_table[i 1]); return (float)soc_table[i] - (soc_table[i] - soc_table[i 1]) * ratio; } } return 0.0f; } void Battery_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; ADC_InitTypeDef ADC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); 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_TempSensorVrefintCmd(ENABLE); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); } uint16_t ADC_ReadChannel(uint8_t channel) { ADC_RegularChannelConfig(ADC1, channel, 1, ADC_SampleTime_239Cycles5); ADC_SoftwareStartConvCmd(ADC1, ENABLE); while(!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC)); return ADC_GetConversionValue(ADC1); } float Battery_GetVoltage(void) { uint16_t vrefint_adc ADC_ReadChannel(ADC_Channel_Vrefint); float vdd_mv VREFINT_CAL_VALUE * 4096.0f / (float)vrefint_adc; uint16_t battery_adc ADC_ReadChannel(ADC_Channel_1); float divider_mv vdd_mv * (float)battery_adc / 4096.0f; return divider_mv * BATTERY_DIVIDER_RATIO / 1000.0f; } void Battery_Update(void) { float voltage Battery_GetVoltage(); float voltage_mv voltage * 1000.0f; // 做一次平滑避免负载波动导致的跳变 if (voltage_smooth 0) { voltage_smooth (uint16_t)voltage_mv; } else { voltage_smooth (uint16_t)((float)voltage_smooth * 0.85f voltage_mv * 0.15f); } float soc VoltageToSOC((float)voltage_smooth); // SOC变化限幅单次更新最多改变1% if (soc_filter 0) { soc_filter (uint8_t)soc; } else if (soc (float)soc_filter) { if (soc - (float)soc_filter 1.0f) { soc_filter 1; } else { soc_filter (uint8_t)soc; } } else if (soc (float)soc_filter) { if ((float)soc_filter - soc 1.0f) { soc_filter - 1; } else { soc_filter (uint8_t)soc; } } } uint8_t Battery_GetSOC(void) { return soc_filter; }这个模块可以直接复制粘贴到你的工程里用。主程序里这么调用int main(void) { // 其他初始化... Battery_Init(); // 系统定时器中断里每500ms调用一次 // Battery_Update(); while(1) { printf(Voltage: %.2fV, SOC: %d%%\r\n, Battery_GetVoltage(), Battery_GetSOC()); Delay_ms(1000); } }7. 实测数据与精度分析这套方案到底靠不靠谱我在一个实际产品上验证过这套方案用的是三元锂电池标称2200mAhSTM32F103C8T6主控100K100K分压LED显示屏显示电量。先看静态精度。用高精度可调电源模拟电池电压分别设定3.3V、3.6V、3.8V、4.0V、4.2V用万用表实测ADC换算结果记录数据如下电源设定电压万用表实测内部基准法计算值误差3.300V3.298V3.29V0.24%3.600V3.601V3.59V0.31%3.800V3.799V3.81V0.29%4.000V4.001V4.02V0.47%4.200V4.199V4.18V0.45%最大误差出现在4.2V附近原因是VREFINT的芯片间离散性没有完全校准。如果做一次出厂校准把VREFINT_CAL_VALUE换成实际测得的精确值误差能压到0.1%以内。再看动态表现。用电子负载做1A脉冲放电测试10秒开、10秒关。未做平滑时电压显示值跟着负载波动SOC在35%和45%之间跳来跳去加了低通滤波和SOC限幅之后显示稳定在40%左右波动控制在1%以内。最后实际跑了一段完整的放电流程从满电4.2V开始以500mA恒流放电到3.0V截止全程记录SOC显示值和实际放出电量的对比。结果如下显示SOC实际放出电量占比误差100%100%0%80%82.5%2.5%60%61.8%1.8%40%43.2%3.2%20%24.1%4.1%10%13.5%3.5%0%0%0%中段误差在2%到4%之间低电量的误差稍大。主要原因是我用的OCV表没有针对这块电池做精细标定用的是通用曲线。如果你需要更高精度找电池原厂要精确的OCV-SOC曲线替换查表数据就行。8. 常见问题与排查技巧那些让我挠头一整晚的坑问题一VREFINT读取值严重偏小只有1700多12位这是最典型的坑。F103的VDD是3.3V时VREFINT的ADC值理论应该在2478左右1200 / 3300 * 4096。如果读出来严重偏小先检查ADC_TempSensorVrefintCmd(ENABLE)有没有调用。忘了使能内部基准上电读出来的就是一个随机值。问题二电池电压在负载切换瞬间剧烈跳变这是正常现象不是代码Bug。电池内阻在脉冲电流下会表现出极化效应电压瞬间跌落幅度跟电池健康状态、温度强相关。解决办法是加低通滤波和SOC输出限幅我代码里已经加好了。如果你发现还是跳可以把滤波系数从0.15进一步降到0.05代价是更新响应变慢。问题三校准后精度仍然不理想偏差超过1%首先确认ADC时钟有没有超14MHz超了精度会明显下降。其次确认采样时间是不是够长。我之前把VREFINT通道的采样时间从1.5周期改成239.5周期后精度提升非常明显。最后检查PCB布局VREFINT引脚有些型号引出和ADC引脚周围是否干净有没有大的数字信号翻转噪声耦合进来。问题四为什么我用3.3V稳压供电时测电池电压反而低如果你用的是3.3V LDO给MCU供电电池电压经过LDO后才给MCU那MCU永远测不到电池电压只能测到3.3V。正确的连接方式有两种一是电池直供MCU的VDD引脚分压后进ADC二是用ADC的独立参考电压引脚VREF把电池分压直接引到VREF。很多开发板默认用LDO供电直接移植代码会踩坑。问题五低功耗模式下如何省电如果设备要跑低功耗ADC和分压电阻都在耗电。分压电阻上常驻电流大约是Vbat / (R1R2)100K100K在4.0V时是20uA。省电手段有两个一是把分压电阻阻值加大到1M但注意ADC采样时间要重新调二是在分压电阻到地的那颗电阻上串一个MOS管或三极管做开关只在采样时刻导通平时断开可以把这个电流降到0。9. 最后再分享一点个人心得这个方案我用在好几个量产项目里了累计出货量不大几万台是有的返修率没有因为电量显示不准被投诉过。跟库仑计方案相比省下的成本是实打实的而且代码维护起来也简单没有复杂的寄存器初始化、没有I2C时序调试、没有EOL产线校准。但有一点我必须说清楚这个方案只适合对电量精度要求不那么苛刻的场景比如遥控器、智能门锁、便携检测仪、低功耗传感器节点。如果你做的是无人机、电动车仪表盘、医疗设备这类对电量百分之五以内精度有硬性要求的场景还是老老实实用库仑计或者库仑计电压法双方案融合别拿内部基准方案硬扛。每个项目在动工之前先想清楚一个问题**我的“精准”到底需要多准**想明白了方案自然就出来了。