适用人群:STM32/ESP32 基础学完了,FreeRTOS 会创建任务、会用队列,Deep Sleep 和 OTA 也各自跑过例程,但从来没把它们拼成一个完整产品的同学。没看过本专栏前面几篇也能读,关键结论我都会在用到的地方重述一遍。
读完你能得到:① 一个真能上电跑、能远程升级、平时几乎不耗电的完整节点(硬件选型 + 电源树 + 全部代码);② 一张从"每个阶段吃多少电"算到"电池能撑多少天"的能量预算表,知道该优化哪一段;③ ESP-IDF v5.x 的 i2c_master / Deep Sleep / esp_https_ota 三套 API 的正确用法(含错误处理和超时);④ 我在这个项目上真踩过的坑:栈溢出、唤醒后变量没了、I²C 偶发 NAK、OTA 到底会不会变砖;⑤ 一个面试时能讲 10 分钟、每句都有数字支撑的项目。
一、为什么做这个:一个能写进简历的项目
先说我做这个东西的真实动机,一点都不高大上。
我简历上曾经写着这么一行:“熟悉 STM32/ESP32 开发,掌握 FreeRTOS、OTA 升级、低功耗设计。” 看着挺唬人。然后我自己模拟面试的时候,把这句话念出来,紧接着的问题就很自然:
“那你这些是在哪个项目里用到的?说说你那个设备最后功耗做到多少?”
我卡住了。因为我的"掌握"全都是孤立的例程:Deep Sleep 是一个只会打印开机次数的工程,OTA 是官方 system/ota 例程改了个 URL,FreeRTOS 是两个任务互相点灯。它们各自都跑通了,但它们从来没在同一块板子上同时活着。没有实习经历的我,如果连一个"完整的东西"都拿不出来,那这行字就是空的。
所以有了这个项目。目标定得很具体,每一条都能被面试官追问:
- 电池供电:一节 18650,中途不许充电,目标撑半年以上;
- 定时上报:每 10 分钟醒一次,读温湿度,发到服务器;
- 能远程升级:我改完代码,把新固件往服务器一放,设备下次醒来自己升级,升坏了还能自动回滚;
- 平时几乎不耗电:99.7% 的时间在 Deep Sleep,整机十几个 µA。
它把前面几个专栏学的东西强行串到了一起,这正是它的价值:
| 低功耗(08 专栏) | Deep Sleep 主循环、rtc_gpio_isolate()、电源树选型、用 GPIO 给示波器打标记 |
| OTA(07 专栏) | 双 OTA 分区、esp_https_ota 流程、版本号比较防降级、未提交自动回滚 |
| FreeRTOS(02 专栏) | 采样任务 / 联网任务拆分、队列传数据、事件组同步、栈大小估算 |
| 调试排错(11 专栏) | 栈高水位、栈溢出 watchpoint、I²C NAK 的排查顺序 |
| C 语言(03 专栏) | 字节拼 16 位时的整型提升、结构体按值进队列、CRC 的位运算 |
一句话概括这篇要干的事:把四个"我会",变成一个"我做过"。
💡 先说清楚我不会吹的部分:这是学生在宿舍桌上做出来的东西,不是量产设备。它没有 EMC 测试、没有跑过高低温、没有十万台的运营数据。文里所有"量产要怎样"的说法,我都会标明这是从手册和官方文档推出来的结论,而不是我经历过的。
二、硬件选型与电源树
2.1 先看要买些什么(BOM)
| 主控 | ESP32-WROOM-32(4 MB Flash 版本) | Wi-Fi + 原生 OTA + Deep Sleep 全都是现成的;4 MB Flash 是硬门槛——两个 app 分区各约 1.5 MB,加起来就 3 MB 了(见 6.2) |
| 温湿度 | SHT30(SHT3x-DIS 系列) | I²C 接口好写;单次测量模式下 idle 电流典型只有 0.2 µA(手册 Table 3),几乎等于不耗电 |
| 稳压 | 微功耗 LDO:Iq 几 µA 级、dropout < 200 mV、输出能力 ≥ 500 mA | 电池设备的生死线,见 2.3 |
| 电池 | 1×18650 锂电(2600 mAh)+ 保护板 | 见 2.2 的对比表 |
| 去耦/缓冲 | 主轨 220~470 µF + 10 µF + 0.1 µF | Wi-Fi 发射瞬间是几百 mA 的脉冲,靠电容顶 |
| I²C 上拉 | 4.7 kΩ × 2(SDA/SCL 各一个) | SHT30 模块板上通常已经带了,别重复并 |
关于"输出能力 ≥ 500 mA"这条,不是我拍脑袋定的。乐鑫《ESP32-WROOM-32 Datasheet》v3.6 的 Table 13 Recommended Operating Conditions 写得很明确:
VDD33 Power supply voltage Min 3.0 Typ 3.3 Max 3.6 V
IVDD Current delivered by external power supply Min 0.5 — — A
外部电源最小供电能力 0.5 A——这是"最小",不是"典型"。如果你手上只有 300 mA 的 LDO 想硬上,那就得自己算电容能不能顶住瞬时缺口:
LDO 供不上的那部分电流 I_deficit,在 t 时间内由电容放电顶上,电压会掉:
ΔV = I_deficit × t / C
例:峰值 400 mA,LDO 只给 300 mA,缺 100 mA,突发持续 1 ms
C = 220 µF → ΔV = 0.1 A × 0.001 s / 220e-6 F ≈ 0.45 V
3.3 V 掉到 2.85 V < ESP32 最低 3.0 V → 复位
要压到 0.1 V 以内,需要 C ≥ 0.1 × 0.001 / 0.1 = 1000 µF
算完你就明白:与其堆一大坨电容,不如老老实实选一颗够 500 mA 的 LDO。 这个公式本身比结论更值钱,选型时随时能用。
2.2 电池怎么选:别一上来就 2 节 AA
很多教程一提电池节点就是"2 节 AA",我一开始也这么想,直到把四种方案摆在一起算:
| 2×AA 碱性 | 3.2 V(新)→ 1.8 V(耗尽)/ ~2000 mAh | ❌ 只有前一小段能用 | 掉到 3.0 V 以下就欠压;而且碱性电池内阻大(新电池几百 mΩ,后期到 Ω 级),Wi-Fi 一发射几百 mA,电压当场塌陷复位。想用必须加 Boost 升压,而 Boost 自己的 Iq 又要重新盯 |
| 3×AA 碱性 | 4.8 V → 2.7 V / ~2000 mAh | ❌ 要 LDO 降压 | 可行但笨重;4.5 V → 3.3 V 压差大,重载效率约 73%(低功耗_03 第 4.2 节的表) |
| 1×18650 锂电 + 保护板 | 4.2 V → 3.0 V / 2600 mAh | ❌ 满电 4.2 V 超上限,必须过 LDO | ✅ 首选:3.7 V → 3.3 V 是 LDO 的甜点区(效率 ~89%),内阻只有几十 mΩ,扛得住脉冲;保护板负责过放保护 |
| CR2032 | 3.0 V / 235 mAh | 电压刚好但没意义 | 内阻几十~几百 Ω,拉不动 Wi-Fi 的百 mA 脉冲,电压瞬间塌陷(这就是低功耗_03 练习 4 的结论),直接出局 |
我最后用的是 18650 + 微功耗 LDO。注意一个新手常见误解:LDO 掉出压差(dropout)不等于立刻挂掉。当输入降到 3.4 V、dropout 是 150 mV 时,输出大约是 3.25 V,还在 ESP32 的 3.0 V 之上,系统照样跑——真正的下限是"输入 − dropout < 3.0 V"那一刻。所以 dropout 越小,电池尾巴上那段电量你才吃得到。
2.3 电源树:画出来,每一级都问一句"它睡着时吃多少"
【气象节点电源树(睡眠态电流标在每一级右边)】
┌──────────────┐ ┌───────────────┐ ┌────────────────────┐
│ 18650 锂电 │ │ 保护板 PCM │ │ 微功耗 LDO │
│ 3.0 ~ 4.2 V │──▶│ 过充/过放/过流 │──▶│ 4.2/3.7 V → 3.3 V │──┬──▶ 3.3 V 主轨
│ 2600 mAh │ │ ~2 µA 自耗 │ │ Iq ≈ 1~3 µA │ │
└──────────────┘ └───────────────┘ │ Iout ≥ 500 mA │ │
│ └────────────────────┘ │
│ │
│ ⚠️ 想加"电池电量检测"分压? ├──▶ ESP32-WROOM-32
│ 100k + 100k 直接挂电池 = 4.2/200k ≈ 21 µA │ 睡眠 10 µA(手册值)
└─────────────────────────────────────────────┐ │ 发射峰值 ~300 mA
它一个人就比 ESP32 睡眠还费电! │ │
修法:串一个 MOSFET,只在采样时接通 │ ├──▶ SHT30
或阻值加到 MΩ 级 │ │ idle 0.2 µA (typ)
│ │ 测量 600 µA × 12.5 ms
│ │
│ ├──▶ 4.7k×2 I²C 上拉
│ │ 睡眠时两端都是高 → 0 A
│ │
│ └──▶ ❌ 电源指示 LED
│ 1.5 mA,直接不焊!
▼
(量产板才做,学生板先不加)
睡眠态总账:10 µA(ESP32) + 0.2 µA(SHT30) + 2 µA(LDO Iq) + 2 µA(保护板) ≈ 15 µA
—— 前提是:没有指示灯、没有 USB 转串口芯片、没有常通分压。
这张图有三个必须记住的点,全部来自低功耗_03 的实测结论:
2.4 先算账,再写代码:全年能量预算表
这一步很多人跳过,然后花两周优化了一个完全不重要的地方。先把一个周期拆成几段,每段"电流 × 时间"算电荷,加起来除以周期,就是平均电流(低功耗_03 第 2.4 节的面积法)。
按"每 10 分钟醒一次"来排(下面的电流是我这块板子上的量级,你的板子必须自己测,方法见低功耗_03):
| ① 唤醒 + bootloader + 外设初始化(射频未开) | 40 mA | 0.30 s | 12.0 mA·s |
| ② 读 SHT30(与 ③ 并行,不单独计时) | +0.6 mA | 0.02 s | ≈ 0 |
| ③ Wi-Fi 连接 + DHCP | 120 mA | 1.20 s | 144.0 mA·s |
| ④ HTTPS 上报 + 查 OTA 版本 | 150 mA | 0.50 s | 75.0 mA·s |
| ⑤ Deep Sleep(整机 15 µA) | 0.015 mA | 598.0 s | 9.0 mA·s |
| 合计 | — | 600 s | 240 mA·s |
I_avg = 240 mA·s ÷ 600 s = 0.40 mA = 400 µA
18650(2600 mAh)理论寿命 = 2600 mAh ÷ 0.40 mA = 6500 h ≈ 271 天 ≈ 8.9 个月
再打个现实折扣(自放电、低温掉容量、保护板提前截止、末段电压吃不到)
按 70% 估:≈ 190 天 ≈ 6 个多月 ← 标题里"大半年"就是这么来的
最值钱的不是那个天数,是占比:
| ③+④ 联网那 1.7 秒 | 91.3% |
| ① 唤醒初始化 | 5.0% |
| ⑤ 睡了 598 秒 | 3.7% |
所以:
- 你辛辛苦苦把睡眠电流从 15 µA 抠到 8 µA(几乎是极限了),平均电流 400 µA → 393 µA,只多撑 4 天。
- 你把 Wi-Fi 连接时间从 1.2 s 压到 0.3 s(把 BSSID 和信道存进 RTC 内存快连 + 静态 IP 免 DHCP),平均电流直接掉到 220 µA,理论寿命 492 天,翻了将近一倍。
- 你把上报间隔从 10 分钟改成 30 分钟(很多气象场景完全够用),平均电流 143 µA,理论寿命 756 天。
💡 这就是"先算账"的意义:同样是一天的工作量,改上报间隔和改快连收益是几百天,抠睡眠电流收益是 4 天。面试时你能把这张占比表讲出来,比背十条省电技巧有用得多。
顺便回答一个很多人担心的问题:OTA 会不会很费电? 算一下就知道——一个 1.2 MB 的固件,HTTPS 下载按 10 秒、150 mA 算:
一次 OTA 的电荷 = 150 mA × 10 s = 1500 mA·s = 0.417 mAh
一年升级 6 次 = 2.5 mAh,占 2600 mAh 的 0.1%
OTA 本身便宜到可以忽略;贵的是"每次醒来都要联网"这件事本身。 所以设计上就一条原则:每次醒来只花约 100 ms 问服务器一句"有没有新版本"(跟上报数据合并成同一次连接),有才下载,没有就立刻回去睡。
三、总体架构与 FreeRTOS 任务划分
3.1 先说清楚:这个项目的"主循环"不在代码里
裸机思维下你会写 while(1) { 采样; 上报; delay(10min); }。但 ESP32 的 Deep Sleep 醒来等同复位——app_main() 从头再跑一遍,while(1) 根本转不到第二圈。
所以这个项目的主循环长这样:
上电 ──▶ app_main ──▶ 干完活 ──▶ esp_deep_sleep_start()
│
(芯片几乎断电 598 秒)
│
定时器唤醒
│
等同一次复位
│
└──▶ app_main(第 2 圈)
循环是靠"睡眠 + 复位"串起来的,不是靠 while。 想跨圈保留的东西(开机次数、上次失败计数、缓存的 Wi-Fi 信道)只能放 RTC_DATA_ATTR,普通全局变量每圈都会被重新初始化。
3.2 为什么还要拆任务?(一个诚实的回答)
先泼盆冷水:这个项目小到你完全可以顺序写完,一个 app_main 从头写到尾也能跑。有些教程会说"拆任务能并行、能省时间",我们算一下这个"省"有多少:
【顺序写】 读 SHT30 (15 ms) ─▶ 连 Wi-Fi (1200 ms) ─▶ 上报 (500 ms) 共 1715 ms
【拆任务】 连 Wi-Fi (1200 ms) ──────────────┐
读 SHT30 (15 ms,同时进行) └─▶ 上报 (500 ms) 共 1700 ms
省了 15 ms,占总时间 0.9%。—— 这不能算理由。
所以我拆任务的真实理由是另外三条:
💡 什么时候并行才是真理由?当传感器需要长时间预热的时候。 比如 MH-Z19 这类 CO₂ 模块、SDS011 这种带风扇的 PM2.5 传感器,预热要几十秒。这时候"一边预热一边连 Wi-Fi"省下的就是几十秒 × 上百 mA 的真金白银。SHT30 只要 15 ms,所以老实承认:这里拆任务是为了结构和健壮性,不是为了性能。
3.3 任务、队列、事件组的连线图
【气象节点任务架构(每次唤醒都重新搭一遍)】
定时器唤醒(= 复位)
│
▼
┌────────────────────┐
│ app_main(主任务) │ s_boot_count++(RTC_DATA_ATTR)
│ 建队列 + 建事件组 │ NVS 初始化
└─────────┬──────────┘
xTaskCreate ├──────────────────────────┐
▼ ▼
┌─────────────────────┐ ┌──────────────────────────┐
│ sensor_task │ │ net_ota_task │
│ 优先级 5 / 栈 3072 B │ │ 优先级 5 / 栈 8192 B │
├─────────────────────┤ ├──────────────────────────┤
│ 1. i2c 初始化 │ │ 1. Wi-Fi 连接(带超时) │
│ 2. 发 0x2400 命令 │ │ 2. 从队列取样本(带超时) │
│ 3. 等 ≥15 ms │ │ 3. HTTPS 上报 │
│ 4. 读 6 字节 + CRC │ │ 4. 查版本,有新的就 OTA │
│ 5. 换算温湿度 │ │ 5. 打印栈高水位 │
└──────────┬──────────┘ └───────────┬──────────────┘
│ xQueueSend(sample_t) │
└──────────────────────────▶│(按值拷贝 16 字节)
│ │
置 BIT_SENSOR_DONE 置 BIT_NET_DONE
└──────────┬────────────────┘
▼
app_main: xEventGroupWaitBits(两位都到 或 20 s 超时)
▼
enter_deep_sleep(600 s)
(关 Wi-Fi → 隔离 GPIO → 睡)
3.4 数据结构与主流程代码
先把共用的东西放一个头文件里,两个任务和主流程都 include 它:
/* ===== node_common.h ===== */
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/event_groups.h"
/* 队列里传的样本。注意三件事:
1) 队列是【按值拷贝】的,整个结构体会被 memcpy 一份进队列内部缓冲,
所以里面【绝对不能放指向任务栈的指针】——任务一退出就是野指针;
2) 只放 POD 类型,不放位域,避免不同编译器/平台布局不一致;
3) sizeof(sample_t) 这里是 16 字节(两个 float + uint32 + bool + 3 字节填充),
结构体对齐的细节见 C语言_02。 */
typedef struct {
float temp_c; /* 温度,°C */
float rh; /* 相对湿度,%RH */
uint32_t boot_count; /* 第几次唤醒 */
bool valid; /* 采样是否成功;失败也要上报,服务器才知道设备还活着 */
} sample_t;
#define BIT_SENSOR_DONE (1u << 0)
#define BIT_NET_DONE (1u << 1)
#define ALL_DONE_BITS (BIT_SENSOR_DONE | BIT_NET_DONE)
extern QueueHandle_t g_sample_q;
extern EventGroupHandle_t g_done_eg;
extern uint32_t g_boot_count; /* 定义在 main.c,带 RTC_DATA_ATTR */
void sensor_task(void *arg); /* 第四节 */
void net_ota_task(void *arg); /* 第六节 */
void mark_init(void); /* 第五节:示波器打点脚 */
void mark_set(int level);
void enter_deep_sleep(uint64_t sleep_us); /* 第五节,不返回 */
主流程本体:
/* ===== main.c —— ESP-IDF v5.x ===== */
#include <inttypes.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_err.h"
#include "esp_sleep.h"
#include "nvs_flash.h"
#include "node_common.h"
static const char *TAG = "main";
#define WAKE_INTERVAL_US (600ULL * 1000 * 1000) /* 10 min,单位微秒 */
#define WORK_TIMEOUT_MS 20000 /* 一次唤醒最多允许干 20 s */
/* 放进 RTC 慢速内存,Deep Sleep 也不丢。只有真正的上电/硬复位才会清零 */
RTC_DATA_ATTR uint32_t g_boot_count;
QueueHandle_t g_sample_q;
EventGroupHandle_t g_done_eg;
void app_main(void)
{
g_boot_count++;
ESP_LOGI(TAG, "==== wake #%" PRIu32 ", cause = %d ====",
g_boot_count, (int)esp_sleep_get_wakeup_cause());
mark_init(); /* 示波器打点脚,见 5.3 */
mark_set(1); /* ── 标记:本次唤醒开始 */
/* Wi-Fi 要用 NVS 存参数,必须先初始化。这段是官方例程的标准写法 */
esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
err = nvs_flash_init();
}
ESP_ERROR_CHECK(err);
g_sample_q = xQueueCreate(2, sizeof(sample_t));
g_done_eg = xEventGroupCreate();
if (g_sample_q == NULL || g_done_eg == NULL) {
ESP_LOGE(TAG, "队列/事件组创建失败(内存不够),直接回去睡");
enter_deep_sleep(WAKE_INTERVAL_US); /* 不返回 */
}
/* ⚠️ ESP-IDF 里 xTaskCreate 的栈参数单位是【字节】,不是字!
(原版 FreeRTOS 的 Cortex-M port 是字,这个坑见 调试排错_03) */
if (xTaskCreate(sensor_task, "sensor", 3072, NULL, 5, NULL) != pdPASS ||
xTaskCreate(net_ota_task, "net_ota", 8192, NULL, 5, NULL) != pdPASS) {
ESP_LOGE(TAG, "任务创建失败,回去睡");
enter_deep_sleep(WAKE_INTERVAL_US);
}
/* 等两件事都干完。超时也必须走——绝不能让主任务无限等下去,
挂在 120 mA 上过一夜,第二天电池就空了 */
EventBits_t bits = xEventGroupWaitBits(g_done_eg,
ALL_DONE_BITS,
pdFALSE, /* 退出时不清标志位 */
pdTRUE, /* 两位都到齐才算 */
pdMS_TO_TICKS(WORK_TIMEOUT_MS));
if ((bits & ALL_DONE_BITS) != ALL_DONE_BITS) {
ESP_LOGW(TAG, "本周期超时,bits = 0x%02x,放弃并回去睡", (unsigned)bits);
}
ESP_LOGI(TAG, "main task stack remain %u bytes",
(unsigned)uxTaskGetStackHighWaterMark(NULL));
enter_deep_sleep(WAKE_INTERVAL_US); /* 此行之后不再返回 */
}
3.5 栈给多大:估一个,然后用高水位往下调
栈大小别猜也别抄,用"先给宽裕值 → 跑够久 → 看高水位 → 往下收"的流程(调试排错_03 第三节):
| sensor | 3072 B | I²C 调用本身很轻,但 ESP_LOGI 打印 %.2f 会走 vfprintf,浮点格式化一次就要一两千字节 |
| net_ota | 8192 B | Wi-Fi + HTTPS(mbedTLS 握手)+ OTA 写 Flash 全在这条链上;乐鑫官方 advanced_https_ota 例程给的也是 8192。给 4096 会在 TLS 握手时当场爆栈,我第一版就是这么死的(见 7.1) |
| app_main | 默认(CONFIG_ESP_MAIN_TASK_STACK_SIZE,默认 3584 B) | 主流程只创建任务 + 等事件,够用 |
在每个任务快结束时把高水位打出来:
/* ⚠️ 平台差异:ESP-IDF(Xtensa/RISC-V port)上 StackType_t 是 uint8_t,
所以这个返回值的单位是【字节】;STM32 + 原版 FreeRTOS 上返回的是【字】,要 ×4 */
ESP_LOGI(TAG, "%s stack remain %u bytes", pcTaskGetName(NULL),
(unsigned)uxTaskGetStackHighWaterMark(NULL));
怎么读这个数(同样来自调试排错_03):它是从任务创建到现在的历史最小剩余,所以必须跑到所有分支(尤其是 OTA 那条最吃栈的路径)才有参考价值;剩余长期在 2 KB 以上说明给多了可以收,接近 0 就是随时会崩。
四、传感器驱动:用 I²C 读 SHT30
4.1 先把手册上那几行读懂
写驱动之前,Sensirion《Datasheet SHT3x-DIS》上只有四条信息是你真正需要的:
| 设备地址是多少 | ADDR 引脚接低 → 0x44(默认);接高 → 0x45。ADDR 脚不允许悬空 | Table 8 |
| 怎么让它测一次 | 单次测量模式,16 位命令。高重复性 + 不用时钟拉伸 = 0x2400;要时钟拉伸版本是 0x2C06 | Table 9 |
| 测一次要多久 | 高重复性:典型 12.5 ms,最长 15 ms | Table 4 |
| 数据怎么变成温湿度 | 每次读回 6 字节:温度高字节、低字节、CRC,湿度高字节、低字节、CRC | 4.6 / 4.12 / 4.13 节 |
换算公式(手册 4.13 节,注意分母是 2^16 − 1 = 65535,不是 65536):
T[°C] = -45 + 175 × S_T / (2^16 – 1)
RH[%] = 100 × S_RH / (2^16 – 1)
CRC 参数(手册 Table 20):CRC-8,多项式 0x31(x⁸+x⁵+x⁴+1),初值 0xFF,输入输出都不反转,最后不异或。手册还很贴心地给了一组自测向量:CRC(0xBEEF) = 0x92——写完 CRC 函数第一件事就是拿它验一下,别等到读不出数据才怀疑人生。
⚠️ 为什么我选"不用时钟拉伸"的 0x2400 而不是 0x2C06? 时钟拉伸是让从机在没测完时把 SCL 拽住不放,主机就得干等。ESP32 的 I²C 主机对 SCL 被拉低有超时限制(i2c_device_config_t 里的 scl_wait_us),15 ms 的拉伸远超默认值,配不好就是一个 ESP_ERR_TIMEOUT。用 0x2400 + 自己 vTaskDelay 等,逻辑更直白,出错也更好查。
4.2 ESP-IDF v5.x 的新 I²C 驱动
ESP-IDF v5.x 起,官方推荐的是 driver/i2c_master.h 这套总线句柄 + 设备句柄的新 API;老的 driver/i2c.h(i2c_param_config / i2c_master_cmd_begin 那一套)在文档里已经被标为 legacy 驱动,新工程别再用了。新 API 的心智模型很简单:
i2c_new_master_bus() → 拿到一条总线 bus_handle(配 SDA/SCL/时钟源)
│
i2c_master_bus_add_device() → 在这条总线上挂一个设备 dev_handle(配地址/速率)
│
i2c_master_transmit() → 往这个设备写
i2c_master_receive() → 从这个设备读 每个都带 xfer_timeout_ms
i2c_master_transmit_receive() → 写完立刻重复起始条件再读(寄存器型器件常用)
│
i2c_master_bus_rm_device() / i2c_del_master_bus() → 用完拆掉
⚠️ 这三个传输函数的最后一个参数是 xfer_timeout_ms,传 -1 表示永远等下去。电池设备里千万别写 -1:I²C 总线只要被拉死(比如从机没上电),你的任务就永久阻塞在那儿,主任务超时后设备照样能睡,但这条任务会一直占着内存和一个"没结束"的状态。老实给个 100 ms。
4.3 完整的采样任务代码
/* ===== sensor.c —— SHT30 单次测量(ESP-IDF v5.x,driver/i2c_master.h) ===== */
#include <stddef.h>
#include <stdint.h>
#include "driver/i2c_master.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_err.h"
#include "node_common.h"
static const char *TAG = "sensor";
#define I2C_SDA_IO GPIO_NUM_21
#define I2C_SCL_IO GPIO_NUM_22
#define SHT30_ADDR 0x44 /* ADDR 接地:手册 Table 8 的默认地址 */
#define I2C_FREQ_HZ 100000 /* 100 kHz 标准模式,杜邦线连接时别上 400 kHz */
#define I2C_TIMEOUT_MS 100 /* 绝不写 -1(无限等) */
/* 手册 Table 20:CRC-8 / 多项式 0x31 / 初值 0xFF / 不反转 / 不异或
自测向量:sht3x_crc8((uint8_t[]){0xBE, 0xEF}, 2) == 0x92 */
static uint8_t sht3x_crc8(const uint8_t *data, size_t len)
{
uint8_t crc = 0xFF;
for (size_t i = 0; i < len; i++) {
crc ^= data[i];
for (int bit = 0; bit < 8; bit++) {
/* crc << 1 会先整型提升成 int,再强转回 uint8_t 截断,
这一步不写强转虽然结果也对,但显式写出来更不容易看错(C语言_07) */
crc = (crc & 0x80) ? (uint8_t)((crc << 1) ^ 0x31)
: (uint8_t)(crc << 1);
}
}
return crc;
}
static esp_err_t sht30_measure(i2c_master_dev_handle_t dev, float *temp_c, float *rh)
{
const uint8_t cmd[2] = { 0x24, 0x00 }; /* 单次测量 / 高重复性 / 不用时钟拉伸 */
uint8_t rx[6] = { 0 };
esp_err_t err = i2c_master_transmit(dev, cmd, sizeof(cmd), I2C_TIMEOUT_MS);
if (err != ESP_OK) {
ESP_LOGE(TAG, "发测量命令失败: %s", esp_err_to_name(err));
return err;
}
/* 手册 Table 4:高重复性最长 15 ms 才测完。
⚠️ 别写 pdMS_TO_TICKS(20)!ESP-IDF 默认 tick 是 100 Hz(10 ms 一跳),
pdMS_TO_TICKS(20) = 2 tick,而 vTaskDelay(2) 的实际时长在 1~2 个 tick 之间,
最短可能只有 10 ms —— 传感器还没测完你就去读,得到的就是 NACK。
给 30 ms(3 tick,最短也有 20 ms)才安全;或者把 CONFIG_FREERTOS_HZ 提到 1000。 */
vTaskDelay(pdMS_TO_TICKS(30));
err = i2c_master_receive(dev, rx, sizeof(rx), I2C_TIMEOUT_MS);
if (err != ESP_OK) {
ESP_LOGE(TAG, "读结果失败: %s", esp_err_to_name(err));
return err;
}
/* 6 字节 = 温度MSB, 温度LSB, CRC, 湿度MSB, 湿度LSB, CRC */
if (sht3x_crc8(&rx[0], 2) != rx[2] || sht3x_crc8(&rx[3], 2) != rx[5]) {
ESP_LOGE(TAG, "CRC 不匹配,这一包数据不要了");
return ESP_ERR_INVALID_CRC;
}
uint16_t raw_t = (uint16_t)(((uint16_t)rx[0] << 8) | rx[1]);
uint16_t raw_h = (uint16_t)(((uint16_t)rx[3] << 8) | rx[4]);
*temp_c = –45.0f + 175.0f * ((float)raw_t / 65535.0f); /* 手册 4.13 */
*rh = 100.0f * ((float)raw_h / 65535.0f);
return ESP_OK;
}
void sensor_task(void *arg)
{
(void)arg;
sample_t s = { .temp_c = 0.0f, .rh = 0.0f, .boot_count = g_boot_count, .valid = false };
i2c_master_bus_handle_t bus = NULL;
i2c_master_dev_handle_t dev = NULL;
i2c_master_bus_config_t bus_cfg = {
.i2c_port = I2C_NUM_0,
.sda_io_num = I2C_SDA_IO,
.scl_io_num = I2C_SCL_IO,
.clk_source = I2C_CLK_SRC_DEFAULT,
.glitch_ignore_cnt = 7, /* 官方文档给的典型值:短于 7 个模块时钟的毛刺被滤掉 */
.flags.enable_internal_pullup = true, /* 只能应急,理由见下面的提示框 */
};
i2c_device_config_t dev_cfg = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = SHT30_ADDR, /* 填 7 位原始地址,不用自己左移一位 */
.scl_speed_hz = I2C_FREQ_HZ,
};
esp_err_t err = i2c_new_master_bus(&bus_cfg, &bus);
if (err != ESP_OK) {
ESP_LOGE(TAG, "I2C 总线创建失败: %s", esp_err_to_name(err));
goto finish;
}
err = i2c_master_bus_add_device(bus, &dev_cfg, &dev);
if (err != ESP_OK) {
ESP_LOGE(TAG, "挂设备失败: %s", esp_err_to_name(err));
goto finish;
}
/* 调试期非常有用:先探一探这个地址上到底有没有人应答 */
if (i2c_master_probe(bus, SHT30_ADDR, I2C_TIMEOUT_MS) != ESP_OK) {
ESP_LOGW(TAG, "地址 0x%02X 无应答,检查接线 / 上拉 / ADDR 脚是否悬空", SHT30_ADDR);
}
if (sht30_measure(dev, &s.temp_c, &s.rh) == ESP_OK) {
s.valid = true;
ESP_LOGI(TAG, "T = %.2f °C, RH = %.2f %%", s.temp_c, s.rh);
}
finish:
/* ⚠️ 采样失败也要把样本发出去(valid = false)。
让服务器知道"设备活着,但传感器读不出来",比什么都不发有用一百倍——
否则你只会看到设备"失联",根本分不清是没电了还是传感器掉了。 */
if (xQueueSend(g_sample_q, &s, pdMS_TO_TICKS(100)) != pdTRUE) {
ESP_LOGW(TAG, "队列满,样本丢弃");
}
/* 拆掉设备和总线,释放引脚(睡前让 I²C 脚回到高阻,也少一条漏电路径) */
if (dev != NULL) { i2c_master_bus_rm_device(dev); }
if (bus != NULL) { i2c_del_master_bus(bus); }
ESP_LOGI(TAG, "stack remain %u bytes", (unsigned)uxTaskGetStackHighWaterMark(NULL));
xEventGroupSetBits(g_done_eg, BIT_SENSOR_DONE);
vTaskDelete(NULL); /* ⚠️ FreeRTOS 任务函数不允许 return!必须自己删掉自己 */
}
几个容易被跳过但很关键的点:
- device_address 填的是 7 位原始地址 0x44,新驱动会自己拼读写位。老驱动时代那种"要不要左移一位"的纠结,在这套 API 里不存在了。
- .flags.enable_internal_pullup = true 只能救急。ESP32 内部上拉典型 45 kΩ(WROOM-32 手册 Table 14 的 RPU),对 I²C 来说太弱了——上升沿会变得很软,杜邦线一长就误码。正式板一定要焊 4.7 kΩ 外部上拉,官方文档也在这条配置项旁边明确写了"recommend proper external pull-up if possible"。
- goto finish 不是坏味道。这里它保证了不管哪一步失败,样本一定会被送进队列、事件位一定会被置起来。如果这里提前 return,net_ota_task 会一直等队列等到超时,主任务再等 20 秒——一次失败白白多耗 20 秒的 40 mA。
- vTaskDelete(NULL) 不能省。任务函数 return 掉在 FreeRTOS 里是未定义行为,ESP-IDF 上会直接 abort() 重启。
五、低功耗主循环:Deep Sleep + RTC GPIO 隔离
5.1 为什么是 Deep Sleep,而不是"裸机自己数秒"
把"一年前的我"拉回来想一件事:这个节点一天只该醒来几回,醒来就该拼命干完活然后彻底躺平。ESP32 的 Deep Sleep(呼应 低功耗_01)能让电流掉到 ~10 µA(实测值,低功耗_03 用电流枪量过),这时候 CPU、Wi-Fi、大部分外设全断电,只剩下 RTC 那一小块在数时间。
但代价你得记牢,这是新手第一坑:
Deep Sleep 醒来是"复位",不是"从哪睡的从哪醒"。 所有没特殊保护的变量、外设状态全没了。寄存器得重配,FreeRTOS 得重建,你的全局 boot_count 也得重新来——除非你把它放进 RTC 慢速内存。
这也正好解释了第三节那个 RTC_DATA_ATTR 的 g_boot_count:它是极少数能"跨睡眠"活下来的变量,靠的就是 RTC_DATA_ATTR 把它塞进了断电也不丢的 RTC 内存区。
5.2 用 GPIO 打标记,给示波器看"电流在哪一刻跳起来"
低功耗_03 反复强调一句话:不量不知道,量了吓一跳。你以为自己睡了 10 µA,示波器一看其实 300 µA——多半是某只 GPIO 在偷偷漏电。为了让"哪一刻在干活"和"电流曲线"对得上,我习惯挑一个空闲脚,在关键节点拉高拉低,示波器同时看电流和这个脚,时间轴就对齐了。
/* power.c —— 所有"睡前收尾"动作都归到这里 */
#include "driver/gpio.h"
#include "esp_sleep.h"
#include "esp_wifi.h"
#include "node_common.h"
#include "driver/rtc_io.h" /* rtc_gpio_isolate 在这里声明 */
static const char *TAG = "power";
#define MARK_IO GPIO_NUM_13 /* 随便挑个空闲脚,接示波器通道 1 */
void mark_init(void)
{
gpio_reset_pin(MARK_IO);
gpio_set_direction(MARK_IO, GPIO_MODE_OUTPUT);
gpio_set_level(MARK_IO, 0);
}
/* level: 1 拉高 / 0 拉低。采样完、联网前、睡前各打一下,波形上一目了然 */
void mark_set(int level)
{
gpio_set_level(MARK_IO, level ? 1 : 0);
}
5.3 睡前该关的都关掉:enter_deep_sleep()
static void enter_deep_sleep(uint64_t sleep_us)
{
ESP_LOGI(TAG, "准备进 Deep Sleep,睡 %llu s", sleep_us / 1000 / 1000);
mark_set(1); /* 给示波器一个"马上睡"的脉冲 */
/* ⚠️ 如果本唤醒周期启动过 Wi-Fi,睡前必须显式停掉。
射频那块不会自己断电,不 stop 就睡,漏电能把你 10 µA 的美梦吃掉大半。 */
esp_wifi_stop();
mark_set(0);
/* 呼应 低功耗_03 的坑:GPIO12 在 ESP32 上跟 Strapping / 内部电路有纠葛,
睡觉前把它孤立(高阻)出来,避免它顺着外部电路悄悄漏电。 */
rtc_gpio_isolate(GPIO_NUM_12);
/* 如果你全程没用 RTC_DATA_ATTR 保存任何变量,可以把 RTC 慢速内存也断电,
再抠几十 µA。我们因为留了 g_boot_count 所以必须保留,写出来让你知道有这选项:*/
/* esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_SLOW_MEM, ESP_PD_OPTION_OFF); */
esp_sleep_enable_timer_wakeup(sleep_us); /* 参数 sleep_us 是微秒 */
esp_deep_sleep_start(); /* 这句之后 CPU 就停了,醒来=复位,下面代码永远跑不到 */
}
三个点强调一下:
- esp_sleep_enable_timer_wakeup() 的单位是微秒,不是毫秒。我第一次就写成 SLEEP_S * 1000 然后困惑"怎么 600 毫秒就醒了"。
- esp_deep_sleep_start() 不会返回。它后面写什么都执行不到。所以这个函数通常放在 app_main 的最后一句。
- rtc_gpio_isolate(GPIO_NUM_12) 不是对所有脚都必要,但 GPIO12 是公认的雷区(ESP32 上它连着 Strapping,浮空时可能走内部路径漏电)。低功耗_03 实测过:不隔离,睡眠电流会莫名多一截。
六、空中升级 OTA:用原生 esp_https_ota
6.1 OTA 在节点上只占"醒来那一小会儿"
回顾一下第三节的架构:节点每 600 秒醒来一次,干完采样和上传就睡。OTA 检查不值得单独醒来——我们就把它塞进这次醒来的窗口里顺便做掉:连上服务器,先问"有没有新版本",有才下载,没有就直接睡。平时这一下"问版本+上报"只花 ~1.5 秒(呼应 2.4:真正下载固件约 10 秒,但一年才几次,占比 0.1%,可忽略)。
6.2 分区表:没有 ota_data,OTA 就白谈
呼应 OTA_01 的结论,ESP32 的 OTA 是 A/B 双分区玩法:
# partitions.csv(节选,4MB Flash)
nvs, data, nvs, 0x9000, 0x5000
otadata, data, ota, 0xe000, 0x2000 # ⚠️ 必须有!记录"下次启动跑哪个槽"
ota_0, app, ota_0, , 0x180000
ota_1, app, ota_1, , 0x180000
要点(也是 OTA_01 强调过的):
- otadata 这个分区必须有,它记着"下次该启动哪个槽"。没有它,升级完不知道从哪启动。
- 本节点只有 4 MB Flash,放不下「factory + 两个 1.5 MB OTA」(算一下:0x10000 + 3×0x180000 早就超过 0x400000 了)。所以这里不预留 factory,第一次上电 ESP-IDF 会直接启动 ota_0(呼应 OTA_01:没有 factory 就启 ota_0)。ota_0 本身当"基准版",要兜底就让它保持一个稳定版本即可。
- 两个 OTA 槽大小要一致(这里都是 0x180000 ≈ 1.5 MB),否则小固件塞不进大槽、大固件溢出小槽都会炸。1.5 MB 对带 HTTPS(mbedTLS)的固件刚好够,别为了多留 spiffs 把槽压到 1 MB——那样链接阶段就会报 app partition too small。
6.3 版本防回滚:先读当前版本号,再决定要不要升
呼应 OTA_05:升级最大的坑是"升到一个跑不起来的版本,然后设备变砖"。防回滚的标准做法——新固件启动后,先跑一遍自检,通过了才标记自己有效;通不过就什么都不做,bootloader 下次自动回滚到老版本。
而"要不要升",先拿当前版本号跟服务器比:
#include "esp_ota_ops.h"
#include "esp_app_format.h" /* esp_app_desc_t 在这里 */
static const char *TAG = "ota";
/* 读当前正在跑的分区描述,拿到版本号字符串 */
static void log_current_version(void)
{
const esp_partition_t *running = esp_ota_get_running_partition();
esp_app_desc_t desc = { 0 };
if (esp_ota_get_partition_description(running, &desc) == ESP_OK) {
ESP_LOGI(TAG, "当前固件版本: %s (分区 %s)", desc.version, running->label);
}
/* 版本号来自构建时写进固件的 app version:
在 version 组件里放一个 version 文件(写 1.0.0),
或 menuconfig 里设 CONFIG_APP_PROJECT_VER。 */
}
esp_ota_get_partition_description() 把烧在固件里的 esp_app_desc_t 读出来,里面 version[32] 就是版本字符串。实战里你会把它和 HTTP 响应头/JSON 里的服务器版本比对,只有服务器更新才往下走——这能避免“每次醒来都重下同一个包”的浪费,也避免“回滚到旧版本刷掉新版本”的事故。
6.4 真正升级:esp_https_ota 一句话搞定
#include "esp_https_ota.h"
#include "esp_http_client.h"
static const char *TAG = "ota";
static esp_err_t do_ota(const char *url)
{
esp_http_client_config_t http_cfg = {
.url = url,
/* ⚠️ HTTPS 必须带证书才校验服务端身份。实验室偷懒可改成 http://,
并打开 CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP;量产一定要 HTTPS + 正经证书。 */
/* .cert_pem = (char *)server_cert_pem_start, */
};
esp_https_ota_config_t ota_cfg = {
.http_config = &http_cfg,
};
esp_err_t err = esp_https_ota(&ota_cfg); /* 下载 + 写另一槽 + 更新 otadata,一条龙 */
if (err == ESP_OK) {
ESP_LOGI(TAG, "升级成功,下次启动跑新固件");
} else {
ESP_LOGE(TAG, "升级失败: %s", esp_err_to_name(err));
}
return err;
}
说明:在新版 ESP-IDF(v5.x)里 esp_https_ota() 被标记为 deprecated,官方更推荐 esp_https_ota_begin() / _perform() / _finish() 三段式(方便做进度条、断点续传,呼应 OTA_05)。但对入门来说,这一行最直观,原理完全一样——你理解了这个,去看三段式只是把"一条龙"拆开而已。
6.5 升级后必须"打卡有效":否则下次启动会回滚
这是 OTA_05 最强调的保命动作。升级成功后,新固件下次启动,得先证明自己能跑,再去"取消回滚保护":
/* 放在 app_main 里、自检通过之后调用一次 */
static const char *TAG = "ota";
static void confirm_if_ota(void)
{
const esp_partition_t *running = esp_ota_get_running_partition();
esp_app_desc_t desc = { 0 };
esp_ota_get_partition_description(running, &desc);
/* 只有"刚 OTA 来的新固件"才需要确认;已确认的分区(比如 ota_0)再调它也没副作用。 */
esp_err_t err = esp_ota_mark_app_valid_cancel_rollback();
if (err == ESP_OK) {
ESP_LOGI(TAG, "已确认本固件有效: %s", desc.version);
}
}
逻辑闭环是这样:
所以把"传感器能读出来"当成自检项,刚好把第四节的驱动和 OTA 安全绑在了一起——这也是我刻意这么设计的原因。
6.6 把上面几段拼回 net_ota_task
前面 6.3–6.5 那几个函数,最终都是在 main.c 里 xTaskCreate(net_ota_task, …) 拉起来的这个任务里被调用的。它长这样(删掉日志和错误处理就是骨架):
void net_ota_task(void *arg)
{
(void)arg;
log_current_version(); /* 6.3:先看看自己现在几版 */
/* 1) 连 Wi-Fi(课前作业:先 esp_netif_init + esp_event_loop_create_default
+ esp_wifi_set_mode/storage_config/start,这里略,乐鑫 example 都有) */
/* 2) 问服务器"最新版本号是多少",比当前新才继续;否则直接跳到 step 4 */
do_ota("https://your-server/firmware.bin"); /* 6.4:下载+写另一槽 */
/* 3) 自检:能读到 SHT30、能连上服务器,就算"我这个新固件能跑" */
bool self_check_ok = sensor_self_check(); /* 你来实现,返回 true/false */
/* 4) 自检过才确认有效;不过就不调用,靠 6.5 的回滚兜底 */
if (self_check_ok) {
confirm_if_ota();
}
/* 5) 上报样本、打标记,通知主任务"我干完了" */
xEventGroupSetBits(g_done_eg, BIT_NET_DONE);
vTaskDelete(NULL);
}
注意 sensor_self_check() 不是 SDK 给的,是你自己写的一个"轻量自检"(比如读一次 SHT30,能读到非 0 值就算过)。把它和 confirm_if_ota() 串起来,整套"升级安全网"才真正闭环。
七、实战踩坑 & 调试(呼应 调试排错_03)
把这几个坑单独拎出来,是因为我每一个都真踩过,而且踩的时候都"明明逻辑没问题啊"。
7.1 栈溢出:先定位到"是哪个任务"
呼应 调试排错_03:ESP-IDF 上 uxTaskGetStackHighWaterMark() 返回的是剩余字节数(不是字数!)。我在第四节 sensor_task 末尾就打了这一句。规则:剩余值 < 200 字节就要警惕,< 50 字节随时炸。
想更狠一点,在 sdkconfig 里打开 CONFIG_FREERTOS_CHECK_FOR_STACK_OVERFLOW = 2,并实现 vApplicationStackOverflowHook()——栈一溢就进钩子,你直接 printf 出任务名,比猜快十倍。ESP-IDF 还可以在 menuconfig 里开 CONFIG_FREERTOS_WATCHPOINT_END_OF_STACK,溢出瞬间触发 watchpoint 断下来,连钩子都不用等。
7.2 睡眠醒来变量没了:不是 bug,是 Deep Sleep 的脾气
第一次写 Deep Sleep,最懵的就是"我明明设了 g_temp,睡一觉怎么变 0 了"。别查代码了——Deep Sleep 醒来是复位,普通全局变量全重置。只有 RTC_DATA_ATTR 标记的变量活得下来。教训:凡是"跨睡眠要记住"的(重启次数、累加均值、上一次采样值),一律 RTC_DATA_ATTR。
7.3 I²C 一直 NACK:按这个顺序查
第四节的 i2c_master_probe() 就是为这准备的。NACK 排查顺序:
7.4 OTA 中途断电:靠回滚兜底,别靠运气
升级下到一半突然没电(电池节点太常见了),最坏情况是"新固件只写了一半,otadata 却已经指向它"。这时候不能靠运气——正是 6.5 那套回滚机制在兜底:bootloader 发现指向的分区是"待确认"状态,直接回滚到老固件。所以量产铁律:永远先 mark valid 再放心,升级过程断了就当没发生。配合 OTA_05 说的"先小批量灰度,确认稳定再全量推",基本告别变砖。
八、新手踩坑速查表(建议收藏到笔记里)
| 1 | 睡眠电流 300 µA 而不是 ~10 µA | GPIO12 没隔离;或外部上拉顺着电路漏电 | rtc_gpio_isolate(GPIO_NUM_12);用电流枪实测(低功耗_03) | 五、7.3 |
| 2 | 醒来后全局变量全变 0 | Deep Sleep 醒来是复位,普通变量不保活 | 跨睡眠要记住的变量加 RTC_DATA_ATTR | 五、7.2 |
| 3 | I²C 一直 NACK 读不到数 | 地址错 / 没焊外部上拉 / 测量等待时间不够 | i2c_master_probe 探地址;焊 4.7 kΩ;等待 ≥30 ms | 四、7.3 |
| 4 | 跑着跑着 HardFault 重启 | 任务栈太小溢出 | 看 uxTaskGetStackHighWaterMark(字节);开 CHECK_FOR_STACK_OVERFLOW | 三、7.1 |
| 5 | OTA 后变砖、起不来 | 新固件没 mark valid;或新固件自检挂了 | 启动后自检通过再 esp_ota_mark_app_valid_cancel_rollback(),否则靠回滚兜底 | 六、7.4 |
| 6 | 升级完发现还是旧版本 | 没设 app version;或服务器版本不比当前新 | version 组件里写版本号;升级前比对版本号 | 六 |
| 7 | esp_sleep_enable_timer_wakeup 时间不对 | 单位是微秒不是毫秒 | 唤醒时长按微秒传,如 main.c 的 WAKE_INTERVAL_US(600 s 折算的微秒) | 五 |
| 8 | 任务函数 return 后整板重启 | FreeRTOS 任务函数不允许 return | 末尾 vTaskDelete(NULL) 自己删自己 | 四 |
九、动手练(故意搞坏才学得会)
光看记不牢,下面几个"故意做错"的小实验,建议你挨个跑一遍,对照现象看日志——踩过的坑才是自己的。
小结
这一篇我们从简历项目出发,把一块 ESP32-WROOM-32 + SHT30 的气象节点从硬件电源树、FreeRTOS 任务拆分、I²C 驱动、Deep Sleep 低功耗、到原生 OTA 升级,串成了一条完整的实战线。几个我最想让你带走的点:
- 低功耗不是"调个函数",是"醒来就拼命干完、然后彻底躺平",而且醒来是复位,跨睡眠变量得 RTC_DATA_ATTR。
- 驱动要逐个查手册:SHT30 的 0x44 / 0x2400 / 15 ms / CRC-8,都是 datasheet 上白纸黑字,照着写才不会猜。
- OTA 的命门是"回滚":升级后自检通过再 mark valid,否则让 bootloader 帮你兜底——这是量产不掉线的底线。
下一篇:项目实战_02_智能家居环境节点ESP32做BLE网关与本地联动




