1. 为什么我要放弃库仑计,改用VREFINT
搞嵌入式电池供电项目的人,迟早会撞上一个绕不开的问题:怎么知道电池还剩多少电。我最早做手持设备的时候,第一反应就是上库仑计,比如TI的BQ系列或者MAXIM的燃料计芯片。贵,是真的贵,一颗芯片比主控还烧钱,而且外围还得配采样电阻,PCB面积也上去了。后来换了个思路,用STM32自带的ADC去测电池分压,结果发现读数飘得厉害,满电和快没电的时候电压差只有零点几伏,ADC参考电压本身又跟着VDD一起抖,测出来的值根本没法看。
直到我把STM32参考手册翻到VREFINT那一章,才意识到芯片内部藏了一个现成的“标尺”。VREFINT是内部参考电压,出厂时校准过,典型值1.20V左右,关键是它不随VDD变化。用它反推VDD,再用VDD去修正电池ADC读数,整个测量链路就稳了。这套方案不需要任何额外芯片,成本几乎为零,精度做下来在手持设备上完全够用。
这篇文章适合谁看?如果你正在做STM32的电池供电项目,手头没有库仑计,又想让电量显示别那么“玄学”,那这套方法可以直接抄。我会把原理、代码、踩过的坑全部摊开讲,代码基于STM32F1系列,但F0、F3、F4、G0、G4这些带VREFINT的型号思路完全一样,改改寄存器名就能跑。
注意:VREFINT不是所有STM32型号都有,拿不准的话先去参考手册搜“VREFINT”或者“Internal reference voltage”,确认你的芯片支持再往下看。
2. 核心原理拆解:VREFINT到底怎么当尺子用
2.1 先搞懂ADC的参考电压从哪来
STM32的ADC是逐次逼近型,它的输出公式很简单:
ADC_Value = (Vin / Vref) * Full_ScaleFull_Scale对于12位ADC就是4095。问题就出在Vref上。大部分低成本方案里,Vref直接接VDD,而VDD来自电池或者LDO。电池从4.2V掉到3.3V,Vref就跟着掉,同一个Vin测出来的ADC_Value完全不一样。这就是为什么直接用VDD当参考去测电池,读数会“越用越高”——因为尺子本身在缩短。
VREFINT的作用就是提供一把不缩水的尺子。它内部是一个带隙基准源,输出电压固定在1.20V左右(具体值看芯片数据手册,F1典型值1.20V,F4是1.21V)。这个电压不随VDD和温度大幅变化,芯片出厂时还会在特定条件下测一次,把校准值存到系统存储区里。
2.2 用VREFINT反推VDD的数学过程
思路很直接:既然VREFINT的电压是已知的,那我用ADC去测VREFINT,得到一个ADC读数,就能反推出当前VDD是多少。
设VREFINT的实际电压为Vrefint,ADC测VREFINT得到的读数为ADC_vrefint,那么:
VDD = Vrefint * 4095 / ADC_vrefint这里有个关键点:Vrefint不能直接用数据手册的典型值1.20V,因为每颗芯片都有个体差异,典型值只是统计中心。STM32在出厂时会把这个芯片的实际Vrefint校准值存在特定地址,F1系列存在0x1FFFF7BA(16位,单位mV),F4系列存在0x1FFF7A2A。读出来代入公式,精度能提高一大截。
拿到VDD之后,再去测电池分压:
Vbat = ADC_bat * VDD / 4095 * (R1 + R2) / R2R1和R2是分压电阻。这样整个链路里,VDD的波动被VREFINT修正掉了,电池电压的测量就只取决于分压电阻的精度和ADC本身的噪声。
2.3 为什么不用内部温度传感器或者别的基准
有人会问,STM32不是还有内部温度传感器吗,能不能用它来修正?可以,但温度传感器的绝对精度很差,F1的数据手册标的是±1.5°C,换算成电压误差很大,而且它本身也受VDD影响。VREFINT是专门为“提供稳定参考”设计的,温漂和电压调整率都比温度传感器好一个数量级。至于外部基准芯片,比如REF3030,精度确实高,但要多一颗料、多一路电、多一块PCB面积,对于成本敏感的项目来说不划算。
2.4 精度到底能做到多少
我实测过一批STM32F103C8T6,用出厂校准值修正后,VDD的测量误差在±20mV以内,电池电压经过分压还原后误差在±30mV左右。对于判断“满电、中等、低电、关机”这四个档位,这个精度绰绰有余。如果你要做电量百分比显示,建议做分段映射,不要直接线性换算,因为锂电池的放电曲线不是直线。
3. 硬件设计与参数计算:分压电阻怎么选
3.1 分压电阻的取值逻辑
电池电压最高4.2V,STM32的ADC输入不能超过VDD,通常VDD是3.3V。所以必须分压。分压比怎么定?假设R1是上臂,R2是下臂,电池接R1,R2接地,ADC接中间。
分压后的电压:
Vadc = Vbat * R2 / (R1 + R2)要求Vadc最大不超过3.3V,留点余量取3.0V。电池最高4.2V,那么:
R2 / (R1 + R2) = 3.0 / 4.2 ≈ 0.714取R1 = 10kΩ,R2 = 24kΩ,分压比0.706,4.2V时分压后2.96V,安全。但这里有个坑:电阻太大,ADC采样保持电容充电慢,读数会偏低;电阻太小,静态功耗大,电池还没用就没电了。
3.2 静态功耗与采样时间的平衡
STM32的ADC采样保持电容大概8pF,采样时间可配置。如果分压电阻是10k和24k并联,等效源阻抗约7kΩ。F1的ADC手册建议源阻抗不超过10kΩ,7kΩ勉强可以,但采样时间要拉长。我一般把采样时间设成239.5个ADC周期,这样充电充分,读数稳定。
静态功耗方面,分压电阻一直在耗电:
I = Vbat / (R1 + R2) = 4.2 / 34k ≈ 123μA123μA对于大容量电池不算什么,但对于小容量电池或者长期待机设备,这个电流不能忽略。我的做法是在分压电阻下臂串一个NMOS,平时关断,要测量的时候再打开,测完立刻关掉。这样平均功耗可以降到几微安。
3.3 滤波电容和PCB布局
ADC输入端并一个0.1μF电容到地,可以滤掉高频噪声。这个电容要尽量靠近MCU的ADC引脚,走线短而粗。分压电阻的接地点要和MCU的模拟地单点连接,不要和数字地混在一起。如果PCB空间允许,在电池输入端再并一个10μF的电容,应对电池内阻带来的瞬态压降。
提示:分压电阻选1%精度的金属膜电阻,温漂选50ppm/°C以内的。普通碳膜电阻温漂大,夏天和冬天测出来的电量能差一档。
4. 软件实现:从校准值读取到滤波输出
4.1 读取出厂校准值
F1系列的VREFINT校准值存在0x1FFFF7BA,是一个16位值,单位是mV。读的时候要注意,这个地址是系统存储区,直接指针访问即可:
#define VREFINT_CAL_ADDR ((uint16_t*)0x1FFFF7BA) uint16_t vrefint_cal = *VREFINT_CAL_ADDR; // 单位mV,典型值1200左右F4系列地址不同,是0x1FFF7A2A,而且F4的校准值是在VDD=3.3V、温度25°C条件下测的,单位也是mV。G0、G4系列有的型号校准值存在OTP区域,具体地址查参考手册的“Device electronic signature”章节。
4.2 ADC多通道配置与DMA搬运
我习惯用DMA来搬ADC数据,这样不占CPU,采样也稳定。配置两个通道:一个测VREFINT,一个测电池分压。用规则组扫描模式,DMA循环搬运到一个数组里。
// 伪代码,基于标准外设库 ADC_InitStructure.ADC_ScanConvMode = ENABLE; ADC_InitStructure.ADC_ContinuousConvMode = ENABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 2; ADC_Init(ADC1, &ADC_InitStructure); // 通道0测电池,通道17测VREFINT ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_239Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_17, 2, ADC_SampleTime_239Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE);VREFINT的通道号各系列不同,F1是17,F4是17,G0是13,具体查数据手册的“ADC channel selection”表。
4.3 计算VDD和电池电压
DMA搬回来的数组里,第一个是电池通道,第二个是VREFINT通道。计算顺序很重要:先用VREFINT算VDD,再用VDD算电池。
uint16_t adc_bat = adc_dma_buf[0]; uint16_t adc_vref = adc_dma_buf[1]; // 用校准值算VDD,单位mV uint32_t vdd_mv = (uint32_t)vrefint_cal * 4095 / adc_vref; // 算电池电压,单位mV,分压比R2/(R1+R2) uint32_t vbat_mv = (uint32_t)adc_bat * vdd_mv / 4095 * (R1 + R2) / R2;这里有个细节:vrefint_cal是mV,adc_vref是ADC读数,算出来的vdd_mv也是mV。整个计算用32位整数,避免溢出。4095是12位满量程,如果你开了过采样或者用了16位ADC,这里要改。
4.4 滑动平均滤波与异常值剔除
ADC读数一定有噪声,直接算出来的电压会跳。我一般做两级处理:先剔除明显异常的值,再做滑动平均。
#define FILTER_LEN 8 static uint32_t vbat_buf[FILTER_LEN]; static uint8_t buf_idx = 0; uint32_t vbat_filter(uint32_t new_val) { // 如果新值和当前平均值差太多,直接丢弃 uint32_t sum = 0; for (int i = 0; i < FILTER_LEN; i++) sum += vbat_buf[i]; uint32_t avg = sum / FILTER_LEN; if (new_val > avg + 200 || new_val < avg - 200) { return avg; // 丢弃异常值 } vbat_buf[buf_idx] = new_val; buf_idx = (buf_idx + 1) % FILTER_LEN; sum = 0; for (int i = 0; i < FILTER_LEN; i++) sum += vbat_buf[i]; return sum / FILTER_LEN; }200mV的阈值是根据我的分压比和电池特性调的,你可以根据实际情况改。滑动平均长度8,对应8次采样,如果采样率是10Hz,响应时间0.8秒,够快了。
4.5 电量分段映射
锂电池的放电曲线不是线性的,4.2V到3.7V之间掉得慢,3.7V到3.3V掉得快。直接线性映射百分比,会出现“前80%用很久,后20%瞬间没电”的体验。我一般做分段映射:
| 电池电压(mV) | 显示电量 | 说明 |
|---|---|---|
| ≥4100 | 100% | 满电 |
| 3900~4100 | 80% | 正常使用 |
| 3700~3900 | 50% | 中等 |
| 3500~3700 | 20% | 低电提醒 |
| 3300~3500 | 5% | 即将关机 |
| <3300 | 0% | 关机保护 |
这个表是根据我用的某款锂聚合物电池的放电曲线实测出来的,不同电池会有差异,建议自己用电子负载测一遍。
5. 实操中踩过的坑与排查技巧
5.1 VREFINT读数跳动大怎么办
第一次调的时候,VREFINT的ADC读数在±30个LSB之间跳,算出来的VDD也跟着跳。排查下来有几个原因:一是采样时间太短,源阻抗高,采样保持电容没充满;二是ADC时钟太快,F1的ADC时钟不能超过14MHz,我一开始设了72MHz分频6,正好12MHz,没问题,但后来换了个板子分频设错,ADC直接跑飞;三是VREFINT通道使能后需要稳定时间,数据手册建议第一次转换后丢弃,我一般连续采8次,丢掉前2次,取后6次平均。
5.2 电池电压测出来偏高或偏低
如果测出来的电压和万用表差很多,先检查分压电阻的实际阻值。我买过一批标称10k的电阻,实际测出来9.7k,分压比就偏了。另外,ADC输入引脚如果有漏电流,也会导致读数偏差。STM32的ADC输入漏电流在nA级别,一般不影响,但如果引脚上接了其他东西,比如LED或者上拉电阻,就会分流。测量引脚要专用,不要复用。
5.3 低电量时读数突然掉到0
电池快没电的时候,内阻变大,ADC采样瞬间的电流会导致电压瞬间跌落,采到一个很低的値。我的处理是在ADC采样前先打开分压电阻的NMOS,延时1ms再采样,采完立刻关断。这样分压电阻只在采样瞬间耗电,电池内阻的影响也小了。另外,软件上做异常值剔除,连续3次低于关机阈值才真正关机,避免误判。
5.4 不同批次的芯片校准值差异
我做过一批100台的小设备,发现不同芯片的VREFINT校准值从1190mV到1215mV都有,跨度25mV。如果不读校准值,直接用1.20V算,VDD误差能到2%,电池电压误差更大。所以校准值必须读,这是精度的关键。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| VREFINT读数一直是0 | 通道号配错 | 查数据手册确认VREFINT通道 |
| VDD算出来是0 | 校准值地址读错 | 确认芯片型号对应的校准地址 |
| 电池电压跳动大 | 采样时间短/无滤波 | 加长采样时间,加滑动平均 |
| 低电量误关机 | 电池内阻导致瞬态跌落 | 采样前延时,连续多次确认 |
| 满电显示不满 | 分压电阻误差 | 实测电阻值,修正分压比 |
| 温度变化后电量不准 | 电阻温漂大 | 换低温漂电阻,或做温度补偿 |
注意:VREFINT的校准值是在特定温度下测的,虽然带隙基准温漂小,但在极端温度下还是会有偏差。如果产品要在-40°C到85°C工作,建议做温度补偿,用内部温度传感器读温度,查表修正。
6. 进阶优化:让电量监测更稳更准
6.1 用过采样提升有效位数
STM32的ADC是12位,但通过过采样可以提高到14位甚至16位。原理是连续采16次,求和后右移2位,得到14位结果。这样VREFINT和电池电压的分辨率都提高了,小信号变化也能捕捉到。代价是采样时间变长,16次采样如果每次239.5周期,在12MHz ADC时钟下大概要3.2ms,对于电量监测来说完全可以接受。
// 过采样16次,右移2位得14位结果 uint32_t adc_oversample(uint8_t channel) { uint32_t sum = 0; for (int i = 0; i < 16; i++) { ADC_RegularChannelConfig(ADC1, channel, 1, ADC_SampleTime_239Cycles5); ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC)); sum += ADC_GetConversionValue(ADC1); } return sum >> 2; // 14位结果 }6.2 动态调整采样率省电
电量变化很慢,没必要一直高频采样。我的做法是正常工作时每10秒测一次,低电量时每2秒测一次,关机前每0.5秒测一次。这样平均功耗可以压到很低。用RTC唤醒或者定时器中断来触发采样,采完就进低功耗模式。
6.3 结合电池内阻估算健康状态
电池用久了内阻会变大,同样的电压下,带载能力下降。我试过在采样瞬间打开一个已知负载(比如一个100Ω电阻),测带载前后的电压差,反推内阻。内阻超过初始值2倍时,提示电池老化。这个方法不需要额外硬件,但负载电阻要选好,太小了耗电,太大了压差测不准。
6.4 代码移植到其他STM32系列
F1到F4的移植主要是改校准值地址和VREFINT通道号。F4的ADC是12位,但参考电压计算方式一样。G0、G4系列有的型号VREFINT通道是13,校准值在OTP里,读法不同。H7系列ADC精度更高,但VREFINT原理一样。移植的时候先查参考手册的“Internal voltage reference”章节,确认校准值地址和通道号,再改宏定义就行。
我在实际项目里用这套方案做了三款产品,最长的已经跑了两年多,电量显示一直很稳。唯一一次出问题是因为分压电阻用了便宜的碳膜电阻,夏天高温时阻值漂移,导致电量显示偏高,换了金属膜电阻就好了。所以电阻别省那几分钱,选1%精度的,温漂50ppm以内的,整个方案的成本也就多一两毛。
最后分享一个小技巧:如果你觉得读校准值麻烦,可以在产线上用高精度电源给每台设备校准一次,把实际的VREFINT电压写进Flash,这样精度比出厂校准值还高。批量生产的话,这个校准步骤也就几秒钟的事。