1. 为什么你的ESP32项目需要一个靠谱的电压检测?
玩ESP32的朋友,尤其是做那些需要电池供电的小玩意儿,比如便携式气象站、智能门锁、或者一个可以揣兜里的小游戏机,肯定都遇到过一个问题:这玩意儿还剩多少电?它啥时候会突然罢工?我试过好几次,项目调试得好好的,结果一拔USB线,设备跑一会儿就黑屏了,原因就是电池电压掉得太低,ESP32直接“睡过去”了。所以,给你的ESP32项目加上一个电池电压检测功能,就像给你的手机装了个电量显示,绝对是刚需,它能让你心里有底,还能实现低电量预警、自动休眠等高级功能,让项目更智能、更可靠。
你可能觉得,ESP32本身不是有ADC(模数转换器)引脚吗?直接用线把电池接上去读不就行了?我一开始也是这么想的,结果踩了个大坑。ESP32的ADC引脚输入电压范围是0到3.3V,而我们的锂电池,比如常见的18650或者锂聚合物电池,满电电压能到4.2V,甚至更高。你要是直接把4.2V的电池正极怼到ADC引脚上,轻则读数不准,重则可能损坏芯片内部的ADC模块。所以,我们需要一个“中间人”,把电池的高电压安全地、按比例地“缩小”到ESP32能安全读取的范围,这个“中间人”就是电阻分压电路。
这个实战教程,就是要手把手带你搞定这件事。从最基础的硬件电路怎么搭,电阻怎么选才不会“拖后腿”,到软件里如何把ADC读到的数字变成真实的电压值,最后怎么校准得跟万用表一样准,甚至把电压换算成直观的电量百分比。我会把我自己调试过程中遇到的坑和解决办法都分享出来,保证你跟着做一遍,就能给你手头的任何ESP32项目加上一个精准的“电量表”。
2. 硬件连接:搭建安全的电压“采样走廊”
硬件是基础,这一步要是没弄对,后面的软件写得再漂亮也是白搭。我们的核心目标就一个:把电池的电压(比如0V到4.2V)线性地、安全地转换到0到3.3V以内。
2.1 电阻分压电路:原理与选型
这里用到的就是经典的电阻分压电路,由两个电阻串联而成。电池电压从整个串联电路两端输入,而我们只从中间两个电阻的连接点(也就是分压点)取电压送给ESP32的ADC引脚。这个点的电压计算公式很简单:V_adc = V_bat * (R2 / (R1 + R2))。我们的任务就是选择合适的R1和R2,让电池最高电压时,V_adc刚好小于或等于3.3V。
我常用的组合是R1=100kΩ, R2=100kΩ。为什么选这个?首先,阻值比较大,意味着整个分压电路从电池抽取的电流非常小(根据欧姆定律,I = V / (R1+R2)),在4.2V下电流只有约21微安,几乎可以忽略不计,对电池续航的影响微乎其微。其次,100kΩ是常用阻值,很容易买到。最后,计算一下:当V_bat=4.2V时,V_adc = 4.2 * (100k / (100k+100k)) = 2.1V,远低于3.3V,非常安全。
但这里有个细节:电阻的精度。你买到的贴片电阻,上面印着“104”(代表100k),但它实际的阻值可能是在99k到101k之间波动,这就是精度,常见的有1%(精密电阻)和5%(普通电阻)两种。这个误差会直接导致你的分压比例不准。所以,如果你追求高精度,建议选用1%精度的金属膜电阻。我在实际项目中,如果对电量显示精度要求不是特别苛刻(比如只显示个大概),用5%的电阻也完全没问题,因为后面我们可以通过软件校准来弥补这个硬件误差。
硬件连接实物图(文字描述版):
- 准备你的ESP32开发板(以常见的ESP32 DevKit V1为例)、一块万用表、两个100kΩ电阻、杜邦线和电池。
- 将R1和R2串联起来。你可以用面包板,或者直接焊接。
- 串联电路的一端(假设是R1的自由端)连接到电池的正极。
- 串联电路的另一端(R2的自由端)连接到电池的负极(也就是GND)。
- 找到ESP32的一个ADC引脚,比如GPIO36(也叫VP),这是我最常用的,因为它即使在深度睡眠时也能工作。
- 将R1和R2的连接点(分压点)用杜邦线连接到GPIO36。
- 最后,确保电池的负极(GND)和ESP32的GND引脚连接在一起,让它们有共同的参考地。
注意:在通电测试前,务必用万用表先测量一下分压点的电压,确认它在你电池电压下是否超过3.3V。这是保护你ESP32ADC的最后一道防线。
2.2 滤波与稳定性:让读数不再“跳舞”
如果你按照上面的步骤连接好,马上用程序去读ADC值,可能会发现一个现象:读出来的数值像心跳一样,一直在小范围跳动,即使电池电压很稳定。这不是程序写错了,而是因为电路中存在各种噪声干扰,比如电源的纹波、ESP32内部数字电路的干扰等。
为了让读数更稳定,我们需要在分压点对地之间,并联一个电容,通常叫它“滤波电容”。它的作用就像一个微型水库,当电压有微小波动时,它能快速地进行充放电来平滑这个波动。我一般会加一个0.1uF(104)的陶瓷电容。电容的接法很简单:一端接到分压点(也就是接ADC引脚的那根线),另一端接到GND。
加了这个小电容之后,你再读ADC值,会发现数字稳定多了,跳动范围大大缩小。这对于我们后续计算精确电压非常有帮助。当然,你也可以根据实际情况调整电容值,比如增加到1uF,滤波效果会更强,但响应速度会稍微变慢,对于缓慢变化的电池电压来说,这完全不是问题。
3. 软件实现:从原始数据到精准电压
硬件搭好了,现在我们来写代码,让ESP32“看懂”它采集到的信号。这个过程就像翻译,把ADC的“数字语言”翻译成我们熟悉的“电压语言”。
3.1 读取ADC原始值
ESP32的ADC分辨率是12位,这意味着它能把0到3.3V的模拟电压,分成2的12次方,也就是4096个数字等级。所以,当ADC引脚电压为0V时,读数是0;为3.3V时,读数是4095(有些库可能是4096,但最大值通常是4095)。
在Arduino框架下,读取ADC非常简单。我们以GPIO36为例:
// 在setup()中,其实不需要特别设置引脚模式为INPUT,因为ADC引脚默认就是模拟输入。 // 但为了代码清晰,你也可以加上: // pinMode(36, INPUT); // GPIO36 对应 ADC1_CH0 void loop() { int raw_adc_value = analogRead(36); // 读取GPIO36的ADC原始值 Serial.print("Raw ADC: "); Serial.println(raw_adc_value); delay(1000); }上传这段代码,打开串口监视器,你就能看到不断刷新的ADC原始值。用万用表测量你电池当前的真实电压,然后根据分压公式V_adc = V_bat * (R2/(R1+R2))算出理论上的V_adc。比如电池4.0V,分压比0.5,理论V_adc是2.0V。此时,你读到的raw_adc_value应该接近2.0 / 3.3 * 4095 ≈ 2482。如果相差很大,别急,我们一步步来校准。
3.2 计算引脚电压与电池电压
拿到原始ADC值后,第一步是把它换算成ADC引脚上实际感受到的电压(V_adc)。
float V_adc = raw_adc_value * (3.3 / 4095.0); // 将ADC值转换为电压(假设参考电压是3.3V)这里隐含了一个重要假设:ESP32的ADC参考电压(Vref)是精确的3.3V。但实际上,这个3.3V是由板载的LDO稳压芯片提供的,它本身就有误差,可能是在3.25V到3.35V之间。这是我们需要校准的第一个原因。
知道了V_adc,再根据分压公式反推,就能得到电池电压(V_bat)。公式变形一下:V_bat = V_adc / (R2 / (R1 + R2))。
float R1 = 100000.0; // 你的R1阻值 float R2 = 100000.0; // 你的R2阻值 float V_bat = V_adc / (R2 / (R1 + R2)); // 计算电池电压 Serial.print("Battery Voltage: "); Serial.println(V_bat, 3); // 打印3位小数现在,你串口打印出来的V_bat,应该和你用万用表测得的电池电压在数值上比较接近了,但可能还存在几十到上百毫伏的误差。
4. 校准的艺术:让精度媲美万用表
校准是区分“能用”和“好用”的关键。我们的校准目标很简单:让程序计算出的V_bat,无限接近万用表测出的真实值。误差主要来自两方面:ADC参考电压不准,以及分压电阻的实际阻值与标称值不符。
4.1 单点线性校准法
这是最简单有效的办法,我称之为“实战派校准法”。你不需要深究误差具体来自哪里,只需要一个已知的、稳定的真实电压作为基准。
操作步骤:
- 准备好你的电路,确保电池电压相对稳定(或者使用一个可调稳压电源更好)。
- 用一块你认为靠谱的万用表,精确测量当前电池的真实电压,记为
V_bat_real。比如,测得4.123V。 - 让ESP32运行上面的程序,读出它计算出的
V_bat_calc。比如,算出4.056V。 - 计算一个校准系数:
calibration_factor = V_bat_real / V_bat_calc。本例中,4.123 / 4.056 ≈ 1.0165。 - 在程序中,将最后计算出的V_bat乘以这个系数:
float calibration_factor = 1.0165; // 这就是你测得的系数 V_bat = V_bat * calibration_factor; // 校准后的电池电压 - 再次比较校准后的V_bat和万用表读数,应该就非常接近了。
这个方法的原理是,它把ADC参考电压误差、电阻比例误差、甚至PCB走线带来的微小阻抗,统统打包成了一个综合性的线性误差,并用一个乘法系数来修正。我在大多数项目中都用这个方法,简单粗暴效果好。
4.2 两点校准与ADC特性补偿
如果你对精度有极致追求,或者发现单点校准后,在不同电压段误差不一致(比如低电压时准,高电压时不准),可以考虑两点校准。ESP32的ADC在某些电压区间可能存在非线性的问题。
你需要测量两个已知电压点下的ADC读数。通常选一个低点(如3.0V)和一个高点(如4.2V)。假设:
- 在真实电压
V_low_real时,ADC计算出的电压是V_low_calc。 - 在真实电压
V_high_real时,ADC计算出的电压是V_high_calc。
你可以拟合一条直线y = k * x + b来修正,其中y是真实电压,x是计算电压。
// 假设测得: // V_low_real = 3.000, V_low_calc = 2.950 // V_high_real = 4.200, V_high_calc = 4.130 float k = (V_high_real - V_low_real) / (V_high_calc - V_low_calc); // 斜率 float b = V_low_real - k * V_low_calc; // 截距 // 使用时: float V_bat_calc = ...; // 程序计算出的未校准电压 float V_bat_calibrated = k * V_bat_calc + b; // 两点校准后的电压这种方法能更好地补偿整个量程范围内的误差,但操作稍复杂。对于电池电量监测来说,单点校准的精度通常已经足够满足需求。
5. 进阶应用:从电压到电量百分比
知道电压了,我们更想知道的是:“还剩多少电?” 这就需要把电压映射到电池的荷电状态(SOC)。但要注意,锂电池的放电曲线不是线性的,电压和剩余电量之间的关系很复杂,受温度、放电电流、电池老化程度影响很大。
5.1 简单的线性映射
对于要求不高的场景,我们可以用一个非常简化的模型:假设电池电压从“空电电压”到“满电电压”是线性下降的。你需要定义两个阈值:
VOLTAGE_EMPTY:你认为电池没电了的电压。比如对于很多3.7V锂电池,保护板会在3.0V左右切断,我们可以设为3.1V或3.2V作为软件警告点。VOLTAGE_FULL:电池充满电的电压。通常是4.2V。
然后使用Arduino的map()函数进行映射,但要注意map()只处理整数,所以我们可以把电压乘以100来操作:
#define VOLTAGE_EMPTY 3.1 #define VOLTAGE_FULL 4.2 float V_bat = ...; // 你的校准后电压 // 将电压映射到0-100的百分比,并约束在0-100之间 int percentage = map((int)(V_bat * 100), (int)(VOLTAGE_EMPTY * 100), (int)(VOLTAGE_FULL * 100), 0, 100); percentage = constrain(percentage, 0, 100); // 确保不超过范围 Serial.print("Battery: "); Serial.print(percentage); Serial.println("%");这个百分比在电池电压中间段(比如3.7V-4.0V)可能比较有参考性,但在接近满电和接近没电时,会非常不准确,因为锂电池在这两端的电压变化很缓慢。
5.2 基于查表法的粗略估算
为了更准确一点,我们可以根据特定电池的放电曲线,建立一个电压-电量对应表。比如,通过实验测量你的电池在特定负载下,电量每下降10%时的电压是多少。
// 一个示例查表,数据需要你根据自己的电池实测 struct BatteryProfile { float voltage; int percentage; }; BatteryProfile profile[] = { {4.20, 100}, {4.10, 90}, {4.00, 80}, {3.90, 60}, // 注意中间可能非线性 {3.80, 40}, {3.70, 20}, {3.60, 10}, {3.50, 5}, {3.30, 0} }; int profileSize = sizeof(profile) / sizeof(profile[0]); int getBatteryPercentage(float voltage) { if (voltage >= profile[0].voltage) return 100; if (voltage <= profile[profileSize-1].voltage) return 0; for (int i = 0; i < profileSize - 1; i++) { if (voltage <= profile[i].voltage && voltage >= profile[i+1].voltage) { // 在两个已知点之间线性插值 return map((int)(voltage * 1000), (int)(profile[i+1].voltage * 1000), (int)(profile[i].voltage * 1000), profile[i+1].percentage, profile[i].percentage); } } return 50; // 默认值,理论上不会走到这里 }这种方法比纯线性映射要靠谱得多,但前提是你得花时间为你用的电池建立这个 profile。对于消费级产品,厂家通常会做大量的测试来拟合更复杂的算法,但对于我们DIY玩家,查表法已经是一个巨大的进步。
6. 实战代码整合与OLED显示
让我们把前面所有的知识点整合到一个完整的、带OLED显示的示例中。我假设你使用了一个I2C接口的SSD1306 OLED屏幕(128x64)。
#include <Wire.h> #include <U8g2lib.h> // 强大的OLED图形库 // 硬件参数 #define ADC_PIN 36 #define R1 100000.0 #define R2 100000.0 // 校准系数(通过单点校准得到) #define CALIBRATION_FACTOR 1.0165 // 电池电量映射参数 #define VOLTAGE_EMPTY 3.1 #define VOLTAGE_FULL 4.2 // 初始化OLED (根据你的屏幕型号修改构造函数) U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, /* reset=*/ U8X8_PIN_NONE); void setup() { Serial.begin(115200); // 初始化I2C,ESP32默认I2C引脚是21(SDA), 22(SCL),如果不同请在此指定 Wire.begin(); u8g2.begin(); u8g2.enableUTF8Print(); // 启用UTF8支持,方便显示中文 u8g2.setFont(u8g2_font_wqy12_t_gb2312b); // 设置中文字体 pinMode(ADC_PIN, INPUT); // 注意:对于ESP32,某些ADC引脚可能有衰减器设置,如果需要测量0-3.3V全范围,通常不需要特别配置。 // 但如果测量小电压,可能需要设置衰减。这里我们默认。 // analogSetAttenuation(ADC_11db); // 可选:设置衰减器为11dB,量程约0-3.6V } void loop() { // 1. 读取并计算电压 int raw = analogRead(ADC_PIN); float v_adc = raw * (3.3 / 4095.0); float v_bat = v_adc / (R2 / (R1 + R2)); v_bat = v_bat * CALIBRATION_FACTOR; // 应用校准 // 2. 计算电量百分比(简化线性映射) int percentage = map((int)(v_bat * 100), (int)(VOLTAGE_EMPTY * 100), (int)(VOLTAGE_FULL * 100), 0, 100); percentage = constrain(percentage, 0, 100); // 3. 串口输出,用于调试 Serial.printf("Raw: %d, V_adc: %.3fV, V_bat: %.3fV, SOC: %d%%\n", raw, v_adc, v_bat, percentage); // 4. OLED显示 u8g2.clearBuffer(); u8g2.setCursor(0, 15); u8g2.print("电压: "); u8g2.print(v_bat, 2); u8g2.print(" V"); u8g2.setCursor(0, 35); u8g2.print("电量: "); u8g2.print(percentage); u8g2.print(" %"); // 绘制一个简单的电池图标和电量条 u8g2.drawRFrame(90, 10, 30, 15, 2); // 电池外壳 u8g2.drawBox(118, 13, 3, 9); // 电池正极凸起 int barWidth = map(percentage, 0, 100, 0, 26); // 电量条宽度 u8g2.drawBox(92, 12, barWidth, 11); // 填充的电量 u8g2.sendBuffer(); delay(2000); // 每2秒更新一次,避免过于频繁 }这段代码就是一个完整的、可用的电池监测系统。上传到你的ESP32,接好电池和OLED,就能看到实时的电压和电量显示了。你可以根据屏幕的实际显示效果,调整字体、位置和电池图标的绘制。
7. 低功耗优化与深度睡眠唤醒
对于真正的电池供电设备,功耗是命脉。让ESP32一直全速运行来检测电压是非常浪费的。更聪明的做法是让它大部分时间在深度睡眠(Deep Sleep)中,定期醒来测量一下电压,如果电量充足就继续睡,如果电量低了就报警或者安全关机。
ESP32的深度睡眠功耗可以低到10微安左右,而运行时是几十毫安,相差几千倍。关键是,我们之前用的ADC引脚GPIO36,在深度睡眠时仍然可以被用来唤醒芯片!
实现思路:
- 使用定时器唤醒(Timer Wakeup):让ESP32每隔一段时间(比如1小时)自动醒来。
- 醒来后,执行一次电压测量。
- 如果电压低于设定的安全阈值(如3.3V),则不再进入睡眠,而是执行报警流程(比如闪烁LED,通过Wi-Fi发送报警信息等)。
- 如果电压正常,则打印或记录一下当前电压,然后再次进入深度睡眠。
这里有一个关键点:在深度睡眠下,所有变量都会丢失。如果你想记录历史电量,需要将数据保存到RTC内存(RTC slow memory)或者外部的EEPROM/Flash中。RTC内存在深度睡眠期间是保持的,但空间很小(约8KB)。
// 定义存储在RTC内存中的变量 RTC_DATA_ATTR int bootCount = 0; // 记录唤醒次数 RTC_DATA_ATTR float lastMeasuredVoltage = 0; #define VOLTAGE_ALARM_THRESHOLD 3.3 void setup() { Serial.begin(115200); delay(1000); // 给串口一点启动时间 bootCount++; Serial.printf("这是第 %d 次唤醒\n", bootCount); // 测量电压(复用之前的测量函数) float v_bat = measureBatteryVoltage(); lastMeasuredVoltage = v_bat; Serial.printf("当前电池电压: %.3f V\n", v_bat); if (v_bat < VOLTAGE_ALARM_THRESHOLD) { Serial.println("电量过低!执行报警,不再睡眠。"); // 这里可以添加你的报警代码,比如点亮红色LED,或者尝试发送一条网络消息 while(1) { // 停在这里 delay(1000); } } else { Serial.println("电量正常,继续睡眠..."); // 配置深度睡眠时间(单位:微秒) // esp_sleep_enable_timer_wakeup(3600 * 1000000ULL); // 睡眠1小时 esp_sleep_enable_timer_wakeup(10 * 1000000ULL); // 测试用:睡眠10秒 Serial.flush(); // 确保串口数据发送完毕 esp_deep_sleep_start(); // 进入深度睡眠 } } void loop() { // 因为setup执行完就睡眠或报警,所以loop永远不会被执行到。 }通过这种方式,你的设备可以持续工作数月甚至数年,具体取决于电池容量和唤醒间隔。这真正发挥了ESP32在低功耗物联网设备中的威力。我有个小环境传感器就是这么干的,用一节18650电池,每半小时测量一次并上传数据,已经稳定运行了快一年。