只靠一块 3W 太阳能板和一节 18650,让 XIAO ESP32-C3 长期在户外运行:供电链路设计、电量检测、功耗估算,以及根据电量和昼夜动态切换持续运行与深度睡眠的策略。文中以土壤湿度传感器作为示例负载。

想让一块 ESP32 放在户外长期跑着:不用换电池、不用拉电线,上面还能跑点自己的任务。方案是太阳能板 + 18650 锂电池 + ESP32-C3。这篇记录一下供电电路怎么设计、为什么这么接,以及怎么让它只靠一块小太阳能板活下去。
文中用一个土壤湿度传感器当作示例负载,换成温湿度、光照或者别的外设,思路都一样。
| 器件 | 规格 | 作用 |
| Seeed XIAO ESP32-C3 | — | 主控,Wi-Fi 上报 |
| 太阳能板 | 6V 3W | 发电 |
| CN3065 充电板 | 线性锂电充电 | 太阳能给电池充电 |
| 18650 锂电池 | 1500mAh,带电池仓 | 储能 |
| TPS63802 模块 | 升降压,输出 3.3V | 把 3.0–4.2V 的电池电压稳成 3.3V |
| 2N7000 | N 沟道 MOSFET | 给外设断电省电 |
| 电容式土壤湿度传感器 | v1.2 | 示例负载,可换成其他外设 |
| 电阻、电容 | 1M×2、1k、100k、100nF | 分压、限流、下拉、滤波 |
| 洞洞板 | — | 焊接 |
核心是两块:供电链路和电池电压检测。2N7000 加土壤湿度传感器只是挂在上面的一个示例负载。
太阳能板 → CN3065 → 18650 → TPS63802 → XIAO 的 3V3 引脚。
图里的地符号都代表同一个公共地,也就是电池负极。CN3065 内部 IN− 和 BAT− 是通的,TPS63802 的两个 GND 在模块内部也是同一块铺铜。
那 TPS63802 为什么要引出两个 GND?主要是为了让电源线和地线成对出现:输入侧一对(VIN/GND),输出侧一对(VOUT/GND),接线不用绕。同时两边的开关电流各走各的回路,回路面积小、噪声小,两个焊盘并联载流量也更大。实际焊的时候可以直接把两个 GND 短接,再用一根短而粗的线接到电池负极——输入侧的地是开关电流最大的地方,越短越好。
R1、R2 都是 1MΩ,从电池正极分压后送到 GPIO4(A2)。锂电池最高 4.2V,分压后约 2.1V,在 ESP32-C3 ADC 的量程内。代码里读到的值乘 2 就是电池电压。用 1MΩ 这么大的电阻,是因为这一路一直挂在电池上,只漏约 2µA。
C1(100nF)是干嘛的? 1M/1M 分压的等效内阻有约 500kΩ,而 ADC 每次采样时内部的采样电容要瞬间吸走一点电荷,这么“弱”的信号源补不上,读数会偏低、跳动。C1 相当于一个小蓄水池,采样时直接从它取电荷,读数就稳了;它和 500kΩ 还组成一个时间常数约 50ms 的低通,顺便滤掉 TPS63802 的开关噪声。焊的时候尽量靠近 GPIO4。
外设在 ESP32 睡眠时也一直耗电,就把整个系统的睡眠电流抬上去了。所以用一个 2N7000 切断外设的地,需要时再打开。以电容式土壤湿度传感器为例,它工作时大约 5mA:
读数流程:GPIO5 拉高 → 等 200ms → 读 ADC → GPIO5 拉低。换成别的外设,只要电流不超过一两百毫安,都可以用同样的接法。
MOSFET 可以理解成一个用电压控制的开关:D(漏极)和 S(源极)是开关的两个触点,G(栅极)是按钮。它只看 G 相对 S 的电压 Vgs:低于约 0.8V 一定关断,高于约 3V 完全导通,中间是半开的区域(典型开启电压约 2V,每颗管子不一样)。
关键是栅极是绝缘的。它和里面的导电沟道之间隔着一层很薄的氧化层,等效成一个约 20pF 的小电容。数据手册里栅极漏电最大 10nA(Vgs = 15V 时),换算下来 G-S 之间的直流电阻至少 1.5GΩ,实际通常更大。所以:
这也是为什么把它接在传感器和地之间(低边开关):S 接地,GPIO 的 3.3V 就完整地加在 G、S 之间。如果放在 3V3 一侧,S 的电位会跟着升高,Vgs 就不够了。
GPIO 在很多时候会进入高阻态,相当于松手不管:deep sleep(ESP32-C3 睡眠时引脚默认不保持输出)、上电和复位的瞬间、烧录、程序崩溃。这时如果没有 R4,栅极经 R3 连到一个悬空的引脚上,等于栅极也悬空了。悬空的栅极电压由几个偶然因素决定:
结果是管子可能关、可能半开、也可能全开。对长期靠电池运行的设备,“不确定”本身就是问题:哪天赶上一次,传感器就在睡眠中耗一整晚的电,还很难排查。
R4 给栅极一条通往地的确定路径,把它钉在 0V。放电时间常数 τ = 100kΩ × 20pF = 2µs,残留电荷微秒级就放光了。
那为什么不直接把栅极接地?因为那样 GPIO 就再也拉不上去了。R4 是一个弱下拉:没人管时它说了算,GPIO 一出手(内阻只有几十欧)就能轻松压过它,代价只是导通时多流 3.3V ÷ 101kΩ ≈ 33µA。
R4 取值是两头的平衡:
| R4 | 结果 |
| 0Ω(直接接地) | GPIO 拉不上去,MOSFET 打不开 |
| 1kΩ | 和 R3 分压后栅极只剩 1.65V,打不开 |
| 10k–1MΩ | 常用范围 |
| 100MΩ 以上 | 拉不住:10nA 的漏电流就能在上面产生 1V,接近开启电压 |
注意 R4 太大的问题不是放电慢——就算 100MΩ,τ 也只有 2ms——而是抗干扰能力差。100kΩ 是比较稳妥的中间值。
软件上也可以用 gpio_hold_en() 让引脚在 deep sleep 时保持低电平,但上电、复位、烧录这些时候软件管不到,R4 这个硬件兜底还是要留。
既然栅极是个电容,充满之后就不再进电流,稳定时流过 R3 的只有流向 R4 的那 33µA,R3 上的压降只有 0.033V,Vgs ≈ 3.27V,几乎不受影响。R3 只在 GPIO 翻转的那一瞬间起作用:
2N7000 的栅极电容只有约 20pF,所以在这个电路里 R3 属于保险,省掉也能用;换成栅极电容大几十倍的大功率 MOSFET 就很有必要了。R3 要比 R4 小两个数量级,否则会把 Vgs 分掉一块。
另外 R4 要接在 R3 靠栅极的一侧:这样就算 R3 虚焊断开,栅极仍然被下拉,不会悬空。
一句话总结:R4 决定默认关,必须有;R3 让开关更温和,是好习惯。
XIAO 的 D0 是 GPIO2,它是 ESP32-C3 的 strapping 引脚,上电时如果被外部电路拉低可能启动失败。分压电阻在电池电压低时、传感器断电时的输出都可能把它拉低,所以干脆不用,改用 GPIO3 / GPIO4 / GPIO5。另外 ESP32-C3 的 ADC2 不可靠,模拟量都放在 ADC1 的 GPIO3、GPIO4 上。
我本来还想在 ESP32 上跑点别的东西,所以先算了一下一直开着 Wi-Fi 行不行(都是粗估):
| 项目 | 估算 |
| ESP32-C3 连着 Wi-Fi 的平均电流 | 约 80mA @3.3V |
| 加上转换损耗,电池端功耗 | 约 0.3W,一天约 7Wh |
| 电池容量 | 1500mAh × 3.7V ≈ 5.5Wh |
| 3W 板子晴天实际能充进去 | 约 4–6Wh/天 |
结论是不行:晴天收进来的电都不够一天用,阴天发电只有晴天的一两成,电池会一天比一天亏。CN3065 是线性充电、没有 MPPT,也吃掉了不少板子的功率。
反过来,如果每 15 分钟醒一次、干 5 秒活就 deep sleep,一天只要约 15mAh,不晒太阳理论上能撑三个月。
最后的方案是:电量充足时持续运行(可以跑别的任务),电量低了就 deep sleep,睡多久根据电量和是不是白天来定。有几个坑:
1. 阈值要有回差。 如果只用一个 25% 的线,带载跑到 25% 去睡,负载一撤电压回弹,醒来一测又高于 25%,马上又跑,很快又睡,反复启停。所以设两条线:运行中低于约 25% 去睡,醒来回到约 60% 才恢复持续运行,中间只做一次测量上报。
2. 电压测量时机。 刚醒、Wi-Fi 还没开时测的最接近静置电压,运行中测到的是带载电压,会低 0.05–0.1V。锂电池电压和电量也不是线性的,大致是 4.2V≈100%、3.9V≈60%、3.7V≈25%、3.5V≈5%,具体电芯要自己实测。
3. 怎么判断白天。 两个依据:一是 NTP 对时(deep sleep 期间系统时间会继续走),二是这次醒来的静置电压比上次高,说明正在被太阳充电。后者在阴天时也能判断对。
| 情况 | 动作 |
| 电量 ≥60% | 持续运行 |
| 25%–60%,白天 | 上报后睡 20 分钟 |
| 25%–60%,夜里 | 上报后睡 2 小时 |
| 低于 5%,白天 | 不开 Wi-Fi,睡 1 小时 |
| 低于 5%,夜里 | 不开 Wi-Fi,睡到早上 10 点 |
另外一个小现象:边充电边运行时,CN3065 可能一直判断不出充满,充满灯不亮,这是正常的,它会把电池稳定在 4.2V。
不会。deep sleep 时整套电路约 60µA,5% 的电量也够睡五十多天,正常情况下不会被睡干。真耗光了,太阳出来后 CN3065 会先小电流预充,电池电压升到 TPS63802 能启动时,ESP32 冷启动,从 setup() 重新开始。
真正要防的是开机-掉电循环:阳光很弱时刚充上一点电,一开 Wi-Fi 几百毫安的电流把电压拉垮,重启,再开机……永远充不起来。所以程序开机第一件事就是测电压,太低就不开 Wi-Fi 直接睡。
硬件上建议用带保护板的 18650,或者外加一块 DW01 保护板,防止软件出 bug 时把电池过放到 2.5V 以下。
你的其他任务写在 loop() 开头的位置,阈值和睡眠时长都在文件开头可以改。
// XIAO ESP32-C3 太阳能 + 18650 供电 —— 按电量 + 白天/夜晚动态调度
// 电量充足时持续运行(可放你的其他任务);电量低于阈值进入 deep sleep,按情况决定睡多久。
//
// 接线:GPIO4(A2) 电池分压 1M/1M;GPIO3(A1) 传感器 AOUT;GPIO5(D3) -> 2N7000 栅极
#include <WiFi.h>
#include <time.h>
#include "esp_sleep.h"
const char* WIFI_SSID = "your_ssid";
const char* WIFI_PASS = "your_password";
#define PIN_BAT 4
#define PIN_SOIL 3
#define PIN_SENS_EN 5
// ---------- 电压阈值(mV),按你的电芯实测后微调 ----------
const int V_RUN_ENTER = 3900; // 静置约 60%:达到这个才恢复持续运行(回差,避免反复启停)
const int V_RUN_EXIT = 3650; // 运行中(带负载)约 25%:低于这个就去睡
const int V_CRITICAL = 3500; // 静置约 5%:只睡觉,连 Wi-Fi 都不开
// ---------- 白天时段(本地时间) ----------
const int DAY_START = 8;
const int DAY_END = 17;
// ---------- 各种情况的睡眠时长(分钟) ----------
const uint32_t SLEEP_DAY_LOW = 20; // 白天、电量不足:勤醒,看看充上来没有
const uint32_t SLEEP_NIGHT_LOW = 120; // 夜里、电量不足:低频上报
const uint32_t SLEEP_DAY_CRITICAL = 60; // 白天、电量极低:不联网,等太阳
// 夜里、电量极低:直接睡到 DAY_START + 2 小时
const uint32_t RUN_CHECK_MS = 60 * 1000; // 持续运行时每分钟检查一次电量并上报
RTC_DATA_ATTR int lastRestMv = 0; // 上次唤醒时的静置电压,deep sleep 后保留
// ===================== 测量 =====================
int readBatMv() {
uint32_t sum = 0;
for (int i = 0; i < 16; i++) { sum += analogReadMilliVolts(PIN_BAT); delay(2); }
return (sum / 16) * 2; // 1M/1M 分压,乘 2 还原
}
int readSoilMv() {
digitalWrite(PIN_SENS_EN, HIGH); // 打开 2N7000,给传感器通电
delay(200); // 等传感器稳定
uint32_t sum = 0;
for (int i = 0; i < 16; i++) { sum += analogReadMilliVolts(PIN_SOIL); delay(2); }
digitalWrite(PIN_SENS_EN, LOW);
return sum / 16; // 越干电压越高,需自行标定干/湿两点
}
// ===================== 时间 / 白天判断 =====================
int currentHour() { // 没对过时返回 -1
struct tm t;
if (!getLocalTime(&t, 10)) return -1;
return t.tm_hour;
}
uint32_t minutesUntilHour(int hour) {
struct tm t;
if (!getLocalTime(&t, 10)) return 60; // 断电重启后不知道时间:保守地 1 小时后再看
int now = t.tm_hour * 60 + t.tm_min;
int d = hour * 60 - now;
if (d <= 0) d += 24 * 60;
return d;
}
// 白天 = 在白天时段内,或者电压比上次高(说明正在被太阳充电)
bool isDaytime(int restMv) {
bool charging = (lastRestMv > 0) && (restMv > lastRestMv + 20);
int h = currentHour();
if (h < 0) return charging;
return (h >= DAY_START && h < DAY_END) || charging;
}
// ===================== 网络 =====================
bool connectWiFi(uint32_t timeoutMs = 10000) {
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
uint32_t t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < timeoutMs) delay(100);
if (WiFi.status() != WL_CONNECTED) return false;
configTzTime("CST-8", "ntp.aliyun.com", "pool.ntp.org"); // 北京时间
struct tm t;
getLocalTime(&t, 3000);
return true;
}
void report(int soilMv, int batMv, const char* mode) {
// TODO: 换成你的 MQTT / HTTP 上报
Serial.printf("[%s] soil=%dmV bat=%dmV\n", mode, soilMv, batMv);
}
// ===================== 睡眠 =====================
void goSleep(uint32_t minutes) {
digitalWrite(PIN_SENS_EN, LOW);
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
Serial.printf("sleep %u min\n", minutes);
Serial.flush();
esp_sleep_enable_timer_wakeup((uint64_t)minutes * 60ULL * 1000000ULL);
esp_deep_sleep_start();
}
uint32_t decideSleep(int restMv, bool day) {
if (restMv < V_CRITICAL) return day ? SLEEP_DAY_CRITICAL : minutesUntilHour(DAY_START + 2);
return day ? SLEEP_DAY_LOW : SLEEP_NIGHT_LOW;
}
// ===================== 主流程 =====================
void setup() {
pinMode(PIN_SENS_EN, OUTPUT);
digitalWrite(PIN_SENS_EN, LOW);
Serial.begin(115200);
setCpuFrequencyMhz(80); // 降频省电,Wi-Fi 照常可用
int restMv = readBatMv(); // 刚醒、还没开 Wi-Fi,最接近静置电压
bool day = isDaytime(restMv);
// 1) 电量极低:不联网,直接睡
if (restMv < V_CRITICAL) {
lastRestMv = restMv;
goSleep(decideSleep(restMv, day));
}
// 2) 联网、对时、上报一次
connectWiFi();
day = isDaytime(restMv); // 对时后再判断一次
report(readSoilMv(), restMv, "wake");
// 3) 电量没回到恢复线:继续睡(回差)
if (restMv < V_RUN_ENTER) {
lastRestMv = restMv;
goSleep(decideSleep(restMv, day));
}
// 4) 电量充足:进入 loop() 持续运行
lastRestMv = 0; // 运行中的带载电压不能和静置电压比较
}
uint32_t lastCheck = 0;
void loop() {
// ========== 在这里放你的其他任务(不要长时间阻塞) ==========
// ========== 定时检查电量 ==========
if (millis() - lastCheck >= RUN_CHECK_MS) {
lastCheck = millis();
if (WiFi.status() != WL_CONNECTED) connectWiFi(5000);
int loadMv = readBatMv(); // 带负载电压,会比静置低 0.05–0.1V
report(readSoilMv(), loadMv, "run");
if (loadMv < V_RUN_EXIT) {
goSleep(decideSleep(loadMv, isDaytime(loadMv)));
}
}
}洞洞板焊接,实测待机电流
用自己的电芯标定电压和电量的对应关系
接上 MQTT,做一个电量和运行状态的监控面板