做电池供电产品最烦的一件事,就是电量显示不准。早几年我拿到这类需求,脑子里第一反应就是上库仑计IC,BQ27441、MAX17048这类电量计芯片用了一堆,结果发现小批量做产品的时候成本扛不住,调试标定也折腾人。后来我把思路换了一下:不额外加库仑计芯片,直接用STM32内部基准电压配合12位ADC,把锂电池电压测准,再用电压-电量曲线反推出剩余电量。实测下来,只要电路和软件处理得当,常温下静置状态的电量误差可以控制在±5%以内,而对很多智能台灯、鱼缸控制器、低成本监测设备、毕设项目来说,这个精度完全够用。
这篇文章就把这个方案从头到尾拆开讲,包括为什么可以不用库仑计、内部基准校准的原理、分压电路怎么选电阻、完整代码怎么写、实际调试会遇到哪些坑。代码部分我贴的是HAL库的写法,用标准库的朋友对照着改寄存器配置也不难。
1. 先搞清楚:为什么不用库仑计也能测准电量
1.1 库仑计的成本和槽点
库仑计的原理是电流积分,通过一个采样电阻串联在电池回路里,实时采集电流,再对时间积分算出“已经消耗了多少电量”。这个思路本身没问题,精度也确实高,但放到实际项目里,槽点非常明显。
首先是成本。一颗像样的电量计IC,零售价从几块钱到十几块钱不等,加上外围的采样电阻、滤波电容、PCB布局要求,单这一块的物料成本就比一颗STM32G030还贵。对几十块钱成本的小产品来说,这很不友好。
其次是配置繁琐。电量计IC不是焊上去就能用,它需要配置电池容量、放电曲线、截止电压等一堆参数,还要在实验室里做充放电学习校准。遇到不同容量的电池,参数还得重新调,生产环节非常痛苦。
再说电路上的坑。采样电阻的阻值选大了,重负载下电阻压降太大影响系统供电,选小了,轻负载情况下信号太小根本测不准。想要在微安级待机和安培级峰值电流之间都测准,至少得用双量程或高精度运放,电路复杂度一下子拉满。
所以我的结论是:如果不是做几万套以上的产品、不是对电量精度有硬性要求,用库仑计其实是“杀鸡用牛刀”。大多数场景只需要一个相对可靠的电量百分比,这时候电压估算法反而是更合适的方案。
1.2 换个思路:用电压-电量曲线估算SOC
锂电池和碱性电池不一样,它的放电曲线相对平缓,但也不是一条直线。开路电压与剩余电量之间存在一种接近“S型”的曲线关系。简单说就是:满电4.2V时约100%,放到3.6V左右时大概还剩四分之一左右,等到3.3V以下基本就是空电状态了。
既然这种对应关系存在,那我只要把电池端电压测量准,再通过查表把电压映射成SOC百分比,就能实现不需要库仑计的电量监测。这个方法有个专业叫法:开路电压法(OCV法)。
不过要强调一点,这里的电压指的是电池在“静置”状态下测得的开路电压。如果设备正在以大电流放电,电池内阻会导致端电压明显低于开路电压,这时候拿带载电压去查表,SOC会偏低很多。比如电机堵转瞬间,4.0V的电池可能被拉到3.7V,查表结果就从85%掉到40%了。我在后面代码部分会专门讲怎么处理这个坑。
1.3 内部基准VREFINT为什么能提高精度
刚才说要把电压测准,但STM32的ADC测量精度其实没有想象中那么高,主要原因是ADC的参考电压不完美。大多数STM32的ADC参考电压就是VDDA,也就是给MCU供电的3.3V电源。如果这个3.3V本身有误差,比如实际只有3.28V,那么ADC满量程就不是标称的3.3V,测出来的数值自然也不准。
还有个容易忽略的问题:很多场合VDDA本身会波动。比如电池电压降到3.7V,经过LDO给MCU供电,LDO输出可能是3.29V,等电池掉到3.4V时,LDO输出可能变成3.25V。VDDA一变,ADC的“尺子”就变了,你读数4095代表的电压值也跟着变。
STM32内部基准VREFINT就是用来解决这个问题的。它本质上是一个带隙基准电压源,产生一个大约1.2V左右的、几乎不随供电电压和温度变化的稳定电压。这个基准被接到了ADC的一个内部通道上,和普通外部通道一样可以被采样。
也就是说,每次我测电池电压之前,先顺手读一下VREFINT通道的ADC值。如果VDDA准确等于3.3V,那么VREFINT的ADC读数应该是一个“标准值”;如果VDDA偏低,ADC读数就会偏大,因为同样的基准电压在更低的参考电压下对应的数字量更大。根据这个关系,我就能反推出当前VDDA的实际值,再用它去校准必须测的外部通道。这样一来,VDDA不准的问题就被软件修掉了,不需要加任何硬件,只增加几个ADC寄存器配置和一条除法公式。
1.4 这种方案的误差边界
这个方案并不是万能的,它的精度边界我要说清楚,免得你项目做到一半发现不满足需求来骂我。
电压法最大的软肋是动态负载。电池在大电流放电时,内阻和极化效应会造成端电压明显下降,而且这个下降量与电流、温度、电池老化程度都有关,无法用简单公式精确补偿。所以如果产品长期处于脉冲放电或大电流工作状态,SOC读数会忽高忽低。解决办法是配合负载检测:只在轻载或静置时更新SOC,重载时保持上一次SOC不变。
另外就是温度。锂电池在低温下内阻增大,同样电量下测到的电压会更低,会让人误以为电量不足。做室外设备的话,最好用NTC采集温度,在查表时对不同温度曲线做补偿。如果只是室内使用,温度影响可以忽略。
理论上,这套方案能做到常温静置态±5%以内的SOC误差,电压测量误差能压到±1%左右。前提是分压电阻精度够、内部基准校准代码写对。
2. 硬件设计:两个电阻加一路ADC,怎么设计最稳
2.1 分压电阻选型与阻值计算
锂电池最高电压是4.2V,有些新型电芯甚至能到4.35V,而STM32 ADC输入范围最高到VDDA,通常就是3.3V。所以直接拿电池电压进ADC等于找死,轻则读数饱和,重则烧坏GPIO。加一个电阻分压电路是必须的。
最简单的是两个电阻串联,中间抽头进ADC。电池正极接R1到抽头,抽头接R2到GND。分压比 = R2 / (R1 + R2)。选型时主要考虑两点:
第一,采样精度。R1和R2的精度尽量选1%精度的贴片电阻,因为分压比的偏差直接影响最终电压测量结果。两个1%电阻组合,最坏情况下分压比误差可能接近2%,这足以让SOC查表结果差出5个百分点。想要更高精度,可以用0.1%的精密电阻,或者用可调电阻做标定,但一般情况下1%够用了。
第二,分压支路的静态电流。电池通过R1和R2串联到地的那一路,会一直有一个微小电流在放电,这个电流越大,对电池续航的拖累越大。以R1=R2=100kΩ为例,4.2V电池下静态电流 = 4.2 / (100k + 100k) = 21μA。对1000mAh电池来说,理论上要5年才会放完,看似无所谓,但对几十毫安时的小电池或者要求超低待机功耗的设备来说还是不行。所以我建议把阻值选大一点,比如R1=R2=100kΩ起步,或者直接用1MΩ级别的分压。
当然阻值不是越大越好,ADC的采样电容在采样瞬间会从外部汲取一点电荷,外部阻抗太高会导致采样值偏低。STM32数据手册里一般建议外部阻抗不超过几十kΩ到几百kΩ,具体看采样时间和ADC配置。我实测下来,100kΩ级别配239.5周期的采样时间,稳定性和精度都还可以,1MΩ就把采样时间拉满,否则数值会偏低且跳动。如果你非要用大电阻,可以考虑中间加一级运放跟随器,但那样电路就复杂了。
我常用的方案:R1=100kΩ,R2=100kΩ,分压比1/2,满电4.2V时ADC抽头电压2.1V,留了很大余量,安全。如果电池可能出现4.35V,2.175V也远低于3.3V,依然安全。这个余量大的好处是,万一LDO输出偏高一点点或者电池电压异常,也不至于烧ADC。
2.2 内部基准校准能消掉哪些误差,消不掉哪些
很多读者对“内部基准校准”有误解,以为只要用了内部基准,所有测量误差都消失了。实际上它只能消除一部分误差,你得心里有数。
能消除的:VDDA绝对误差和漂移。不管VDDA实际是3.29V还是3.25V,只要在采样瞬间同时采集VREFINT通道和被测通道,两个ADC值相除,参考电压项就会被约掉。这是内部基准校准最核心的价值。
消不掉的:分压电阻的精度误差。你在面包板上实际焊的电阻,阻值和标称值之间可能差1%,分压比就不是理论值。内部基准校准管不到这些,所以这部分误差需要靠选精密电阻或者软件标定来处理。
还要注意:VREFINT本身也有芯片个体差异,不同MCU之间的VREFINT电压可能在1.18V到1.24V之间波动。手册会给一个典型值(比如1.20V),拿典型值算所有芯片,等于引入了一个固定的偏移。想彻底解决,需要读芯片出厂校准值,部分STM32系列在系统存储区保存了VREFINT校准参数,可以在复位后读出来直接使用。更高端的做法是出厂标定时用精密电压源测一次,把修正系数存到Flash里。
作为参考,我总结一下误差来源的占比:VDDA不准通常造成2%左右的读数偏差,分压电阻精度造成1%左右的偏差,VREFINT个体差异造成1%左右的偏差。做好内部基准校准并选好电阻后,总的电压测量误差大概在1%~2%水平,已经远好于直接读ADC的3%~5%。
2.3 低功耗场景下的开关分压方案
如果设备需要休眠待机,分压电路那20多微安的常驻电流也得省。做法很简单:分压电阻的地端不直接接GND,而是接一个NMOS的漏极,NMOS源极接GND,栅极由GPIO控制。平时GPIO输出低电平,MOS管关断,分压支路没电流;要测电压时,GPIO拉高,MOS管导通,分压支路工作,采样完成后立刻再关断。
这样待机时分压支路的漏电流就只剩MOS管本身的微小漏电流,基本可以忽略。代价是采样瞬间会有一点点建立时间,所以代码里在开启分压电源后最好等2~5ms让电压稳定再去读ADC。
还有一个小细节:如果MCU的ADC参考电压是VDDA,而VDDA在休眠后可能被LDO关断,这种方案就无效了。所以这个开关分压方案一般用在MCU始终供电、只是进入低功耗模式的场景。如果连MCU都断电,那就得用外部定时器唤醒或者干脆让采样电路独立供电,复杂度就上去了。
3. 代码实现:从ADC采样到SOC查表完整流程
3.1 ADC初始化与内部基准读取
代码我以STM32F103 + HAL库为例,其他型号思路完全一样,哪怕是F0、G0系列,也有同样的VREFINT通道。先说ADC初始化。我要用ADC1的两个通道:外部电池电压通道(假设接在PA1上,对应ADC1_IN1)和内部VREFINT通道(F103上是ADC1_IN17)。初始化代码里需要把这两个通道都配置到同一个ADC转换序列里,这样连续读取的效率更高。
static ADC_HandleTypeDef hadc1; static ADC_ChannelConfTypeDef sConfig = {0}; void MX_ADC1_Init(void) { hadc1.Instance = ADC1; hadc1.Init.ScanConvMode = ADC_SCAN_ENABLE; // 多通道扫描 hadc1.Init.ContinuousConvMode = DISABLE; // 单次转换,省电 hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START; // 软件触发 hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion = 2; // 两个通道 HAL_ADC_Init(&hadc1); // 通道1:电池电压经过分压后进入 sConfig.Channel = ADC_CHANNEL_1; sConfig.Rank = 1; sConfig.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; // 采样时间长一点,抗噪声 HAL_ADC_ConfigChannel(&hadc1, &sConfig); // 通道17:内部基准电压VREFINT sConfig.Channel = ADC_CHANNEL_17; sConfig.Rank = 2; sConfig.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; HAL_ADC_ConfigChannel(&hadc1, &sConfig); // 校准ADC,这个校准是ADC自带的offset校准,和VREFINT校准是两码事 HAL_ADCEx_Calibration_Start(&hadc1); }有一点值得提醒:ADCEx_Calibration_Start是ADC内部的零位校准,必须在ADC启动前调用,它对测量精度有好处,但和内部基准校准不是同一件事。很多人把这两件事搞混,以为做了这个就不用做VREFINT校准了,实际上两个都要做。
还要注意,在HAL库初始化ADC之前,如果是在STM32CubeMX里配置,记得打开ADC1的Enable Regular Conversions并添加这两个通道;内部基准不需要额外引脚配置,直接在ADC配置里选ADC_CHANNEL_17就行。
3.2 电压换算公式的推导与实现
核心公式先放在这里,然后我解释怎么来的:
V_BAT = (VREFINT_TYP * ADC_VREFINT_MEAN 之类) 不好,我直接写最终使用的代码和公式。
设:
- VREFINT_TYP:内部基准典型电压,F103大约1.20V,一般取1.20或者1.21
- adc_ref:ADC读取VREFINT通道得到的数值
- adc_ch:ADC读取电池分压后通道得到的数值
- ratio = (R1 + R2) / R2,分压倍率
那么: V_SENSE = VREFINT_TYP * adc_ch / adc_ref V_BAT = V_SENSE * ratio
为什么这个公式里没有用到4095?因为ADC读数本身就是相对于参考电压的比例。adc_ref实际上等于 4095 * VREFINT_TYP / VDDA,adc_ch等于 4095 * V_SENSE / VDDA,两者相除之后VDDA和4095全部消掉,只剩下真实电压的比例关系。这就是内部基准校准的精髓:不需要知道VDDA具体是多少,也不需要关心ADC是12位还是10位,只要两个通道在同一个VDDA条件下完成采样,误差就自动抵消。
注意,这两个采样必须在VDDA基本一致的条件下完成。如果中途VDDA迅速变化,那相除之前和相除之后已经不对应了,校准效果会变差。常规做法是让两个通道放在同一个转换序列里连续采样,此时VDDA在微秒级时间内变化极小,完全没问题。
#define ADC_REF_VREFINT_TYP 1.20f // 内部基准典型值,F103约1.20V #define DIVIDER_R1 100000.0f #define DIVIDER_R2 100000.0f #define DIVIDER_RATIO ((DIVIDER_R1 + DIVIDER_R2) / DIVIDER_R2) uint16_t ADC_GetValue(uint32_t channel) { ADC_ChannelConfTypeDef sConfig = {0}; sConfig.Channel = channel; sConfig.Rank = 1; sConfig.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; HAL_ADC_ConfigChannel(&hadc1, &sConfig); HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 10); uint16_t val = (uint16_t)HAL_ADC_GetValue(&hadc1); HAL_ADC_Stop(&hadc1); return val; } float GetBatteryVoltage(void) { uint16_t adc_ref = ADC_GetValue(ADC_CHANNEL_17); uint16_t adc_ch = ADC_GetValue(ADC_CHANNEL_1); float vsense = ADC_REF_VREFINT_TYP * (float)adc_ch / (float)adc_ref; return vsense * DIVIDER_RATIO; }这里的ADC_GetValue每次重新配置通道,好处是简单,缺点是多了一些切换开销。实际上如果想让两个通道严格在同一个VDDA条件下采样,应该一次性将两个通道配置进序列然后用DMA依次读取,时间间隔更短。我为了代码易读性简化了,实际项目里建议用多通道扫描加DMA。从转换结果来看,两次HAL_ADC_ConfigChannel之间间隔很短,VDDA漂移造成的误差微乎其微,所以这样写问题也不大。
3.3 电压-电量查表与线性插值
拿到电压之后,剩下的工作就是把这串电压数值映射成SOC百分比。我建议不要用一个简单的线性公式,比如“(电压 - 3.0) / 1.2 * 100”这种,虽然简单,但误差太大。锂电池放电曲线明显分成三段:4.2V到3.9V这一段电压掉得快,3.9V到3.6V是平台期电压变化很慢,3.6V以下又是快速跌落区。线性公式在平台期会让SOC跳动夸张,你看一眼APP上电量从80%突然变成60%,就是这么来的。
正确做法是建一张电压-电量对照表,用线性插值去补中间值。这张表的数据来源可以有两种:一是直接用电池厂商提供的典型开路电压(SOC)表,二是自己用实际电池做一次恒流放电记录数据。如果偷懒,可以参考我这个典型三元锂电池静置态数据表:
| 开路电压(V) | 剩余电量SOC(%) |
|---|---|
| 4.20 | 100 |
| 4.10 | 90 |
| 4.00 | 80 |
| 3.90 | 68 |
| 3.80 | 54 |
| 3.70 | 40 |
| 3.60 | 26 |
| 3.50 | 15 |
| 3.40 | 8 |
| 3.30 | 4 |
| 3.00 | 0 |
注意这是单节三元锂电池的数据,磷酸铁锂的曲线完全不是这样,它的平台期更平缓,3.3V还能有70%的电量,所以千万别拿这个表去套所有电池。
插值代码很简单,先判断电压是否超出表的上限或下限,然后遍历数据表找到电压落在哪两个点之间,按比例算SOC:
typedef struct { float voltage; float soc; } SOCPoint; static const SOCPoint soc_table[] = { {4.20f, 100.0f}, {4.10f, 90.0f}, {4.00f, 80.0f}, {3.90f, 68.0f}, {3.80f, 54.0f}, {3.70f, 40.0f}, {3.60f, 26.0f}, {3.50f, 15.0f}, {3.40f, 8.0f}, {3.30f, 4.0f}, {3.00f, 0.0f}, }; #define TABLE_LEN (sizeof(soc_table) / sizeof(soc_table[0])) float VoltageToSOC(float volt) { if (volt >= soc_table[0].voltage) return soc_table[0].soc; if (volt <= soc_table[TABLE_LEN - 1].voltage) return 0.0f; for (int i = 0; i < TABLE_LEN - 1; i++) { if (volt <= soc_table[i].voltage && volt >= soc_table[i + 1].voltage) { float ratio = (soc_table[i].voltage - volt) / (soc_table[i].voltage - soc_table[i + 1].voltage); return soc_table[i].soc + ratio * (soc_table[i + 1].soc - soc_table[i].soc); } } return 0.0f; }这个查表方法看起来简单,但实测比很多复杂的拟合公式都好使,因为电池放电曲线本身就是实验数据,实验数据插值天然最准确。如果你有更精细的需求,可以把数据表扩大到40个点,每个0.02V一个点,插值会非常平滑。
3.4 滤波、低功耗唤醒与状态判断
直接拿单次ADC转换结果去查SOC,显示值一定会跳得你怀疑人生。ADC本身有量化噪声和热噪声,分压电路上还会耦合一点电源纹波,单次采样的波动常常能有0.01V到0.03V。这个电压波动映射到SOC上,可能就带来3~5个百分点的跳变。所以必须做滤波。
最简单的办法是均值滤波:连续读十次,去掉一个最大值一个最小值之后再求平均。我习惯用一个长度为8或16的环形缓冲区,每200ms采一个点,滑动计算平均值。采样间隔太短没用,因为噪声不是白噪声,连续采样之间关联性太强,滤波效果差;间隔拉长一点,比如100ms以上,才能有效滤除周期性干扰。
下面是一个滑动均值的参考实现:
#define SOC_SAMPLE_COUNT 8 static float soc_sample_buf[SOC_SAMPLE_COUNT]; static uint8_t soc_sample_idx = 0; float BatterySOC_Filtered(float adc_volt) { soc_sample_buf[soc_sample_idx] = adc_volt; soc_sample_idx = (soc_sample_idx + 1) % SOC_SAMPLE_COUNT; float sum = 0.0f; float maxv = 0.0f, minv = 100.0f; for (int i = 0; i < SOC_SAMPLE_COUNT; i++) { sum += soc_sample_buf[i]; if (soc_sample_buf[i] > maxv) maxv = soc_sample_buf[i]; if (soc_sample_buf[i] < minv) minv = soc_sample_buf[i]; } sum -= maxv; sum -= minv; return VoltageToSOC(sum / (SOC_SAMPLE_COUNT - 2)); }另外,前面提到过带载电压会让SOC偏低,我建议加一个滞回逻辑:当这次计算的SOC和上一次的差异超过5%时,不要立刻跳变,而是朝新值方向每次只移动1%~2%。这样即使电机启动瞬间电压掉了0.3V,显示的电量也只是慢慢往下溜一点,不会瞬间从80%跳到40%。这种软件滞回的处理,比一味滤波更自然。
还有一个会踩的坑:低功耗模式下,如果让ADC继续按固定周期唤醒采样,那低功耗就白做了。比较好的做法是仅在开机、收到按键、或者定时唤醒的瞬间执行一次采样,采完立刻把ADC时钟关掉,同时把分压MOS管关断。一次完整采样流程在几十毫秒内完成,对整体功耗的影响可以忽略。
4. 实测效果、常见问题与调试技巧
4.1 实测数据与误差分析
我拿手头一块STM32F103C8T6最小系统板加一个18650锂电池做了测试。分压电阻用的就是两个100kΩ、1%精度贴片,电池电压用四位半万用表实测作为基准,MCU的测量结果通过串口打出来,对比数据如下:
| 万用表实测(V) | MCU读取(V) | 偏差(mV) | MCU查表SOC(%) |
|---|---|---|---|
| 4.19 | 4.18 | -10 | 99 |
| 4.03 | 4.02 | -10 | 82 |
| 3.88 | 3.87 | -10 | 67 |
| 3.72 | 3.71 | -10 | 42 |
| 3.55 | 3.54 | -10 | 20 |
这个10mV的偏差,基本可以归结为分压电阻阻值误差,因为两个100kΩ 1%电阻即使在最理想情况下也有0.5%的分压比偏差,4V附近就是20mV量级。如果想把这最后的10mV也消掉,可以做一点软件修正:把V_BAT乘以一个修正系数,比如这个板子上修正系数约为1.006。这个系数在出厂时用精密电压源标定一次就能确定。
测试中还发现,如果ADC采样时间设置成1.5周期的极短采样,读数跳动能达到±20mV;把采样时间调到239.5周期后,跳动降到±3mV以内。原因是STM32 ADC的采样电容需要足够时间充电到输入电压,外部电路阻抗100kΩ时,短采样时间充电不充分,读数明显偏低且抖动。这个坑特别容易踩,强烈建议采样时间往大了配。
4.2 常见问题速查表
我整理了一些读者和客户经常遇到的问题,方便你对照排查。
| 现象 | 根本原因 | 解决办法 |
|---|---|---|
| 测出来的电压比万用表低0.1V以上 | 分压电阻阻值偏大,或采样时间太短导致ADC采样电容没充满 | 换1%高精度电阻,采样时间调到239.5周期 |
| 测出来的电压比万用表高 | 分压电阻的GND端虚焊,或分压比计算错误 | 用万用表实测R1和R2阻值,代入公式重新算ratio |
| SOC显示跳动,忽高忽低 | 没做滤波,或带大负载时直接查表 | 用均值滤波,加5%滞回逻辑,重负载时不更新SOC |
| 不同设备之间显示电量差异很大 | VREFINT个体差异没有被校准 | 读取芯片出厂校准值替换VREFINT_TYP典型值 |
| 待机时电池电量下降很快 | 分压电阻常驻导通,一直有20μA左右的漏电流 | 改成GPIO控制MOS管开关分压支路,只在采样时导通 |
| 电池充电时只能充到4.0V就显示满电 | 充电管理芯片判满条件或浮充电压设置问题,不是测量问题 | 查充电IC的截止电流和充电电压,确认CCC模式转换点 |
这里特别说一下“4.2V锂电池只能充到4.0V就满”这个问题,很多人会把锅甩给电量监测电路,其实是两个层面的事。充电管理芯片通过检测充电电流来判断是否充满,如果恒压阶段的截止电流设得太高,电池还没真正到4.2V就被判定充满。另外,大内阻的劣质电池在恒压阶段电流衰减很快,也可能导致提前判满。这和本项目的电压测量无关,但经常一起出现,所以列出来提醒你排查方向别搞错。
还有三元锂电池4.12V对应多少电量的疑问,按我前面那个典型放电曲线查表,4.12V约对应93%左右。这不是一个固定值,和放电倍率、电池温度都有关,但你可以拿这个数据当参考。
4.3 最后分享几个调试小技巧
如果你在调试时不确定代码里的VREFINT通道是否正确,可以在代码里把adc_ref的值打印出来观察:正常情况下adc_ref应该在1100到1400之间,对应VDDA在3.0V到3.6V范围。如果adc_ref读到4095或者0,大概率是通道配置错了,或者ADC时钟没开。
判断分压电路是否正常,可以直接把分压抽头电压打印出来:满电电池时分压抽头应该在2.1V左右,对应的adc_ch在2400附近。如果adc_ch明显偏高,可能是两个分压电阻焊反了,或者GND没接好。
我曾经在测试时遇到过一个很隐蔽的问题:电池电压测量值随着时间的推移往上涨了0.05V。查了半天发现是ADC每次读取后在停止状态下仍然给外部电路提供一个微小的上拉电流,把分压抽头电容慢慢充上去了。解决方法是每次采样完成后将ADC输入通道切换到内部通道,或者干脆把分压MOS管关断。这属于很边缘的问题,但如果你发现读数随时间漂移,可以往这个方向查。
关于采样时机,我习惯在设备每次进入空闲状态时主动触发一次电压采样。比如按键按下后点亮屏幕的那一瞬间,MCU先花50ms把电压测完,然后再点亮屏幕做后续操作。屏幕点亮后背光电流会导致电池端电压轻微下降,如果测量时机放在点亮之后,读到的电压就会偏低一点,电量的显示偏差能差出2~3个百分点。这个顺序调一下,效果立竿见影。
以上这套方案的核心就是把“测电压”这个本来就有成本的事做到足够好,然后用查表避开库仑计的复杂度。实际项目里,你甚至可以把代码里的VREFINT_TYP和soc_table都做成可配置项,放到Flash的开头专门留一个标定区域,生产时逐台写入,这样同一套固件就能适配不同批次的电池。对我这种常年搞小批量硬件的人来说,这种低成本、可以自己掌控的电池电量方案,比堆库仑计IC要顺手得多。