1. 项目缘起与整体设计思路1.1 为什么要做智能电池盒手里攒了一堆18650和21700锂电池充电器是那种几十块钱的傻充插上去就一个红灯充满了也不告诉你全靠估摸着时间拔。更麻烦的是我经常把两节电池串起来给一些小设备供电但这两节电池电量到底还剩多少、电压差有多大心里完全没数。市面上带电量显示的电池盒要么贵得离谱要么精度堪忧索性自己动手做一个。这个项目的核心目标很明确做一个能实时检测电池电压、估算剩余电量、并且把数据推到手机上看的小盒子。主控选ESP32-C3原因很简单——它自带Wi-Fi和蓝牙价格便宜ADC虽然有点小毛病但做电池电量检测绰绰有余。整个系统的工作流是这样的电池电压经过分压电阻降到ESP32-C3的ADC量程范围内ADC采样后经过滤波和换算得到实际电压再通过电池放电曲线估算剩余电量百分比最后用BLE推送到手机端显示。适合谁来参考这个项目如果你有基本的焊接能力会用Arduino IDE或者ESP-IDF烧录程序对电阻分压和ADC有概念性的了解那这篇内容基本可以照着抄作业。完全零基础也能看懂原理部分但实操环节建议先补一下基础电子知识。1.2 核心方案选型与取舍选ESP32-C3而不是ESP32-S3或者ESP32-C6主要考虑三点。第一是功耗C3在light-sleep模式下能做到几十微安级别对于电池盒这种需要长期待机的设备来说很关键。第二是价格C3模组现在零售价也就十来块钱做几个都不心疼。第三是RISC-V架构虽然生态还不如Xtensa成熟但Arduino和ESP-IDF对C3的支持已经很完善了常用的库都能跑。ADC方面ESP32-C3有两个12位SAR ADC但实际有效位数大概只有9到10位这是它被吐槽最多的地方。不过测电池电压不需要多高的精度12位分辨率下3.3V量程对应约0.8mV每LSB就算有效位数打折扣做到10mV级别的精度也够用了。真正需要注意的是ADC的线性度和温漂后面会详细讲怎么处理。电量显示方案我选了BLE而不是Wi-Fi原因是BLE连接手机更方便不需要配网功耗也更低。手机端用现成的BLE调试助手就能看数据不用专门写App。如果你想要更漂亮的界面可以用MIT App Inventor自己拖一个但那就超出这篇的范围了。分压电阻的计算是这个项目里最容易翻车的地方。很多人随便拿两个电阻就焊上去了结果要么量程不够烧了ADC引脚要么分压比不对导致读数完全不准。我在下面会给出完整的计算方法和一个实用的计算器思路保证你一次算对。2. 硬件设计与分压电阻计算2.1 分压电路原理与参数选择分压电路的本质就是一个串联电阻网络。电池正极接R1的一端R1的另一端接R2的一端和ADC输入引脚R2的另一端接地。根据基尔霍夫电压定律ADC引脚上的电压V_adc等于电池电压V_bat乘以R2除以R1加R2的和。公式写出来就是V_adc V_bat × R2 / (R1 R2)ESP32-C3的ADC参考电压内部是1.1V左右但实际使用时通常用3.3V作为满量程参考因为芯片内部有衰减器。默认情况下ADC引脚能承受的最大电压是3.3V但为了安全起见建议把最大输入电压控制在3.0V以内。假设我们要测的电池最高电压是4.2V单节锂电满电那么分压比就是3.0除以4.2约等于0.714。也就是说R2除以R1加R2要等于0.714反过来R1除以R2就是0.4。选电阻的时候有两个原则一是总阻值不能太小否则电池会通过分压电阻持续放电白白耗电二是总阻值也不能太大否则ADC输入阻抗会影响精度。ESP32-C3的ADC输入阻抗大概在100kΩ左右所以分压电阻的总阻值最好控制在100kΩ以内。我一般选R1等于100kΩR2等于68kΩ这样分压比是68除以168约等于0.405对应4.2V输入时ADC端电压是1.7V留足了余量。总阻值168kΩ电池通过分压电阻的漏电流是4.2V除以168kΩ约等于25微安对电池容量来说可以忽略不计。如果你要测两节串联的电池最高电压8.4V那分压比就要重新算。假设还是用3.0V作为ADC最大输入分压比是3.0除以8.4约等于0.357。选R1等于200kΩR2等于110kΩ总阻值310kΩ漏电流约27微安同样可以接受。这里要注意电阻的精度直接影响测量精度建议用1%精度的金属膜电阻不要用5%的碳膜电阻。2.2 分压电阻计算器的实现思路每次换电池类型都要重新算电阻太麻烦了。我干脆写了一个简单的计算器输入电池最高电压和ADC最大输入电压自动算出最接近的标准电阻组合。核心逻辑是这样的先根据分压比算出R1和R2的比值然后从E24系列标准电阻值中遍历找到误差最小的组合。E24系列电阻值包括1.0、1.1、1.2、1.3、1.5、1.6、1.8、2.0、2.2、2.4、2.7、3.0、3.3、3.6、3.9、4.3、4.7、5.1、5.6、6.2、6.8、7.5、8.2、9.1以及它们的十倍、百倍、千倍。遍历的时候对于每个R1值计算理想的R2值然后在E24系列里找最接近的计算实际分压比和理想分压比的误差记录误差最小的组合。代码用Python写核心部分大概二十行import numpy as np E24 [1.0, 1.1, 1.2, 1.3, 1.5, 1.6, 1.8, 2.0, 2.2, 2.4, 2.7, 3.0, 3.3, 3.6, 3.9, 4.3, 4.7, 5.1, 5.6, 6.2, 6.8, 7.5, 8.2, 9.1] def find_divider(vbat_max, vadc_max, r_total_target150000): ratio vadc_max / vbat_max best None for r1_base in E24: for r1_mult in [1000, 10000, 100000]: r1 r1_base * r1_mult r2_ideal r1 * ratio / (1 - ratio) for r2_base in E24: for r2_mult in [1000, 10000, 100000]: r2 r2_base * r2_mult r_total r1 r2 if r_total 50000 or r_total 500000: continue actual_ratio r2 / r_total error abs(actual_ratio - ratio) / ratio if best is None or error best[error]: best {r1: r1, r2: r2, error: error, ratio: actual_ratio, total: r_total} return best这个计算器跑出来的结果对于4.2V到3.0V的场景最优组合是R1等于130kΩR2等于91kΩ误差只有0.3%。实际焊接的时候如果你手头没有这两个阻值用100kΩ加68kΩ的组合误差也只有1.2%完全够用。2.3 硬件连接与注意事项接线本身不复杂但有几个坑我踩过这里直接说清楚。第一ADC输入引脚前面一定要加一个0.1微法的电容到地作用是滤掉高频噪声。ESP32-C3的ADC对噪声很敏感不加电容的话读数能跳几十毫伏。第二分压电阻的接地点要和ESP32-C3的GND接在一起不要各接各的否则会形成地环路引入干扰。第三如果你测的是正在充电的电池充电电流会在导线上产生压降导致测量值偏高最好在充电器断开后再测。还有一个容易被忽略的点ESP32-C3的ADC引脚在上电瞬间会有短暂的冲击电流如果分压电阻太小这个电流可能损坏引脚。虽然概率不高但加一个100欧姆的限流电阻在ADC引脚前面会更稳妥。这个电阻和ADC输入阻抗串联对精度的影响很小因为ADC输入阻抗远大于100欧姆。电池盒的物理结构我用3D打印了一个外壳内部用弹簧片做电池触点分压电路和ESP32-C3模组焊在一块小PCB上。如果你不想打板用洞洞板也能凑合但要注意走线尽量短尤其是ADC那根线越短越好。3. ADC采样与数据处理3.1 ESP32-C3 ADC的脾气与应对ESP32-C3的ADC有两个通道组ADC1有5个通道ADC2有1个通道。但ADC2和Wi-Fi有冲突用Wi-Fi的时候ADC2基本废了。所以做电池检测一定要用ADC1的通道比如GPIO0到GPIO4。我一般用GPIO3因为它离电源引脚远一点噪声相对小。ADC的参考电压内部是1.1V但通过设置衰减器可以扩展到大约2.5V满量程。Arduino环境下用analogRead函数默认返回0到4095的12位值。但实际测试下来输入1.0V的时候读数大概在1240左右而不是理论上的1240说明线性度还可以但增益误差有百分之几。所以不能直接用理论公式换算必须做校准。校准的方法很简单用万用表量一个准确的电压比如1.000V接到ADC引脚上读出一串数值取平均算出实际的比例系数。然后在代码里用这个系数去换算。我实测下来ESP32-C3在0.1V到2.5V范围内的线性度还不错最大偏差大概在正负10mV对于电池电量显示来说完全够用。还有一个问题是ADC的温漂。芯片发热的时候ADC读数会慢慢偏移。我试过用热风枪吹芯片读数能偏20mV。所以代码里要加温度补偿或者至少不要用芯片内部温度传感器去补偿因为那个传感器本身也不准。最简单的办法是让设备在室温下校准一次然后接受温漂带来的误差反正电池电量显示不需要毫伏级精度。3.2 采样滤波与去直流归一化ADC采样最怕的就是噪声。我试过直接读一次就用结果数值在正负30mV之间跳根本没法看。后来加了滑动平均滤波读16次取平均跳动降到正负5mV以内。但滑动平均有个缺点响应慢电池电压突然变化的时候要过好几秒才能跟上。折中方案是用中值滤波加滑动平均先连续读9次去掉最大和最小的各2个剩下5个取平均。这样既能滤掉脉冲噪声响应速度也还能接受。代码实现大概是这样#define SAMPLE_COUNT 9 #define DISCARD 2 int read_adc_filtered() { int samples[SAMPLE_COUNT]; for (int i 0; i SAMPLE_COUNT; i) { samples[i] analogRead(ADC_PIN); delayMicroseconds(100); } // 冒泡排序 for (int i 0; i SAMPLE_COUNT - 1; i) { for (int j 0; j SAMPLE_COUNT - 1 - i; j) { if (samples[j] samples[j 1]) { int temp samples[j]; samples[j] samples[j 1]; samples[j 1] temp; } } } int sum 0; for (int i DISCARD; i SAMPLE_COUNT - DISCARD; i) { sum samples[i]; } return sum / (SAMPLE_COUNT - 2 * DISCARD); }去直流和归一化是信号处理里的概念在这个项目里其实很简单去直流就是减去零点偏移归一化就是把ADC读数映射到0到1之间。零点偏移怎么测把ADC引脚接地读出来的值就是零点。我实测下来ESP32-C3的零点大概在5到10之间不是0。归一化就是拿滤波后的值减去零点再除以满量程值减去零点。满量程值用校准电压来定比如输入1.000V时读数是1240零点读数是8那满量程就是1240减8等于1232对应1.000V。3.3 电压换算与电量估算有了校准系数之后电压换算就是一道算术题。假设分压比是0.405ADC端电压是V_adc电池电压就是V_adc除以0.405。但这里有个细节分压电阻本身有误差1%精度的电阻带来的分压比误差也是1%左右。如果你想要更准可以实测分压比用可调电源给一个已知电压比如4.000V读出ADC换算出来的电压然后反推实际分压比。电量估算比电压换算复杂得多。锂电池的放电曲线不是线性的满电4.2V放到3.7V的时候大概还剩50%电量放到3.5V的时候只剩10%了。所以不能简单地用电压除以4.2来算百分比。我用的方法是一段线性插值把放电曲线分成几段每段用线性函数近似。比如4.2V对应100%4.0V对应80%3.8V对应50%3.6V对应20%3.4V对应5%3.2V对应0%。代码里用查表加插值实现。float estimate_soc(float voltage) { const float v_table[] {4.20, 4.00, 3.80, 3.60, 3.40, 3.20}; const float soc_table[] {100.0, 80.0, 50.0, 20.0, 5.0, 0.0}; int n 6; if (voltage v_table[0]) return 100.0; if (voltage v_table[n - 1]) return 0.0; for (int i 0; i n - 1; i) { if (voltage v_table[i] voltage v_table[i 1]) { float ratio (voltage - v_table[i 1]) / (v_table[i] - v_table[i 1]); return soc_table[i 1] ratio * (soc_table[i] - soc_table[i 1]); } } return 0.0; }这个表是我根据几节不同品牌的18650电池实测出来的平均值不同电池的曲线会有差异你可以根据自己的电池型号微调。另外电池内阻会导致放电时电压偏低所以最好在电池静置几分钟后再测或者用带负载时的电压加上内阻补偿来估算。4. 手机端电量显示与BLE通信4.1 BLE服务与特征值设计ESP32-C3的BLE功能用Arduino的BLEDevice库就能搞定不需要额外的协议栈知识。我定义了一个自定义服务UUID用随机生成的避免和标准服务冲突。服务下面挂两个特征值一个用来传电压值一个用来传电量百分比。电压值用float类型四个字节电量百分比用uint8一个字节。特征值的属性设置为可读和可通知这样手机端可以主动读也可以等设备推送。代码框架大概是这样#include BLEDevice.h #include BLEServer.h #include BLEUtils.h #include BLE2902.h #define SERVICE_UUID 6E400001-B5A3-F393-E0A9-E50E24DCCA9E #define CHARACTERISTIC_UUID_VOLTAGE 6E400002-B5A3-F393-E0A9-E50E24DCCA9E #define CHARACTERISTIC_UUID_SOC 6E400003-B5A3-F393-E0A9-E50E24DCCA9E BLECharacteristic *pVoltageChar; BLECharacteristic *pSocChar; void setup_ble() { BLEDevice::init(SmartBatteryBox); BLEServer *pServer BLEDevice::createServer(); BLEService *pService pServer-createService(SERVICE_UUID); pVoltageChar pService-createCharacteristic( CHARACTERISTIC_UUID_VOLTAGE, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY ); pVoltageChar-addDescriptor(new BLE2902()); pSocChar pService-createCharacteristic( CHARACTERISTIC_UUID_SOC, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY ); pSocChar-addDescriptor(new BLE2902()); pService-start(); BLEAdvertising *pAdvertising BLEDevice::getAdvertising(); pAdvertising-addServiceUUID(SERVICE_UUID); pAdvertising-start(); }主循环里每隔一秒更新一次特征值然后调用notify通知手机。手机端用nRF Connect或者LightBlue这类通用BLE调试工具就能看到数据。如果你想要更友好的界面可以用MIT App Inventor拖一个简单的App把接收到的电压和电量显示在标签上。4.2 手机端显示方案对比手机端显示有三种方案我分别试过各有优劣。第一种是通用BLE调试助手优点是零开发成本下载就能用缺点是界面丑数据刷新率低而且每次都要手动连接。第二种是MIT App Inventor自己拖界面优点是界面可以做得很好看数据可以存历史记录缺点是需要花一两个小时学习App Inventor的用法而且生成的App体积比较大。第三种是用微信小程序优点是分享方便不用安装缺点是需要注册小程序账号而且BLE接口在小程序里限制比较多。如果你只是自己用我推荐第一种方案省事。如果你想要一个能拿得出手的成品花点时间用App Inventor做一个。我最后是用App Inventor做了一个简单的界面上面一个大大的电量百分比下面一行小字显示电压背景色根据电量变化低于20%变红高于80%变绿。整个App的块代码大概五十个积木块不算复杂。4.3 功耗优化与续航实测ESP32-C3的功耗是很多人关心的点。我实测下来BLE广播状态下电流大概在80mA左右连接状态下大概在40mAlight-sleep模式下能降到0.5mA以下。对于电池盒来说如果一直开着BLE广播一节2000mAh的18650电池大概能撑一天多显然不够。我的优化策略是间歇工作每30秒唤醒一次采样ADC更新BLE特征值然后立刻进入light-sleep。手机端连接的时候设备保持连接状态但把BLE连接间隔调大从默认的30ms调到500ms这样平均电流能降到5mA左右。如果手机断开连接设备就进入深度睡眠只靠定时器唤醒平均电流降到1mA以下。这样一节2000mAh电池能撑两三个月。light-sleep的代码实现esp_sleep_enable_timer_wakeup(30 * 1000000); // 30秒 esp_light_sleep_start();注意light-sleep会暂停BLE广播所以如果你需要持续广播就不能用light-sleep只能用modem-sleep。modem-sleep下BLE保持连接但CPU降频电流大概在15mA左右。具体选哪种模式看你的使用场景。5. 常见问题与排查技巧实录5.1 烧录失败与ADC读数异常ESP32-C3烧录失败是新手遇到最多的问题。我总结下来主要有三个原因第一USB线质量差只能充电不能传数据换一根好线就能解决。第二驱动没装好Windows下需要装CH343或者CP210x驱动设备管理器里看到串口才能烧录。第三烧录时没有按住BOOT键ESP32-C3需要在上电瞬间拉低GPIO9进入下载模式有些开发板有自动下载电路有些没有没有的话就要手动按。ADC读数异常的表现是数值乱跳或者始终为0或4095。如果始终为0先检查分压电阻有没有焊错用万用表量一下ADC引脚对地的电压。如果始终为4095说明输入电压超过了量程检查分压比是不是算错了。如果数值乱跳先加滤波电容再检查地线有没有接好。我遇到过一次读数乱跳查了半天发现是ADC引脚旁边的GPIO被配置成了输出导致串扰把那个GPIO改成输入就好了。还有一个坑是ADC引脚复用。ESP32-C3的GPIO0到GPIO4默认是ADC功能但如果你在代码里把某个引脚配置成了其他功能ADC就读不到了。检查方法是看引脚定义有没有冲突。5.2 电量显示跳变与校准技巧电量显示跳变通常是因为电压采样噪声大或者电量估算曲线太陡。解决办法有两个一是加大滤波力度把采样次数从9次增加到17次二是对电量百分比做迟滞处理比如电压变化小于20mV的时候不更新显示。迟滞的代码很简单float last_voltage 0; float last_soc 0; void update_display(float voltage) { if (fabs(voltage - last_voltage) 0.02) { last_voltage voltage; last_soc estimate_soc(voltage); // 更新BLE特征值 } }校准技巧方面我建议用可调电源做三点校准0.5V、1.5V、2.5V各测一次用最小二乘法拟合出增益和偏移。如果嫌麻烦至少做一点校准输入1.000V读出ADC值算出比例系数。校准后的精度能到正负5mV对于电量显示来说绰绰有余。5.3 常见问题速查表问题现象可能原因排查方法解决方案烧录失败USB线只有充电功能换线测试换数据线烧录失败驱动未安装设备管理器查看串口安装CH343或CP210x驱动烧录失败未进入下载模式按住BOOT键再上电手动进入下载模式ADC读数始终为0分压电阻焊错万用表量ADC引脚电压重新焊接电阻ADC读数始终为4095输入超量程检查分压比重新计算电阻ADC读数乱跳缺少滤波电容示波器看波形加0.1uF电容ADC读数乱跳地线干扰检查接地单点接地电量显示跳变采样噪声大增加采样次数17次中值滤波电量显示不准未校准对比万用表三点校准BLE连不上服务UUID冲突用调试助手扫描更换UUID功耗过高未进入睡眠测量电流启用light-sleep这个表是我踩坑踩出来的基本上覆盖了九成以上的问题。如果你遇到表里没有的情况先检查硬件连接再检查代码配置最后用示波器看波形三步之内基本能定位。5.4 几个容易被忽略的细节第一个细节是电池反接保护。虽然电池盒的弹簧片设计一般不会接反但万一接反了分压电阻和ADC引脚可能烧掉。加一个肖特基二极管在电池正极和分压电路之间压降只有0.3V对测量影响很小但能救命。第二个细节是ADC引脚的ESD保护。人体静电打一下ADC引脚芯片可能就挂了。加一个TVS二极管到地几毛钱的事能省几十块的芯片钱。第三个细节是固件升级。ESP32-C3支持OTA升级但BLE OTA比较麻烦建议用Wi-Fi OTA。不过Wi-Fi和BLE同时开功耗会上去所以我的做法是平时只开BLE需要升级的时候通过BLE发一个命令切换到Wi-Fi模式升级完再切回来。第四个细节是电池温度。锂电池在低温下内阻增大电压会偏低导致电量显示不准。如果你在户外用最好加一个NTC热敏电阻做温度补偿或者至少在低温环境下把电量估算曲线整体下移。6. 个人实操体会与扩展思路这个项目我从画原理图到焊板子到写代码前前后后花了大概两个周末。最大的感受是ESP32-C3的ADC确实不如STM32的ADC稳但通过校准和滤波做到10mV级别的精度是没问题的。分压电阻的计算看起来简单但实际选型的时候要考虑标准电阻值、功耗、输入阻抗匹配还是有点讲究的。如果你想把功能扩展一下有几个方向可以考虑。一是加一个OLED小屏幕不连手机也能看电量。二是加一个按键按一下唤醒BLE广播平时深度睡眠进一步降低功耗。三是把数据存到Flash里记录电池的充放电历史分析电池健康度。四是换成ESP32-C6支持Thread和Zigbee可以接入智能家居系统。最后分享一个小技巧如果你手头没有可调电源做校准可以用一节全新的碱性电池它的开路电压大概是1.58V到1.62V比较稳定可以当参考电压用。虽然精度不如可调电源但比不校准强多了。