欢迎光临
我们一直在努力

项目实战 01:从 0 做一个带 OTA 的低功耗气象节点(ESP32 + SHT30,一节 18650 跑大半年)

适用人群: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",我一开始也这么想,直到把四种方案摆在一起算:

方案电压范围 / 容量能直接喂 ESP32 吗(要求 3.0~3.6 V)真实问题
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 的实测结论:

  • 轻载看 Iq,不看效率曲线。 一颗 AMS1117 的静态电流手册标称 5 mA(typ),它一个人就能让"MCU 睡到 10 µA"变成笑话——2600 mAh ÷ 5 mA ≈ 21 天没电,跟你写不写 Deep Sleep 毫无关系。
  • I²C 上拉电阻在睡眠时到底漏不漏电? 只要 SDA/SCL 两端都是高电平(ESP32 深睡时这两个脚是高阻,SHT30 idle 也不拉低),上拉电阻两端等电位,电流是 0。它变成漏电点只有一种情况:你给 SHT30 断了电,而上拉还挂在 3.3 V 上——这时电流会顺着上拉电阻灌进未上电芯片的 ESD 二极管。Sensirion 手册在讲硬复位那节专门提醒过这件事:切断传感器供电时,SDA/SCL/ADDR 也要一起处理,否则会通过 ESD 二极管给传感器倒灌供电。
  • 所以 SHT30 这颗传感器,我没给它加 load switch。 它 idle 典型 0.2 µA、最坏 2.0 µA,而加一颗 MOSFET 开关本身也有漏电、还要多一个 GPIO、还引入上面那条倒灌问题。低功耗_03 说"传感器别常供电"是对的,但前提是那颗传感器的待机电流值得你去断它——先查手册再决定,别背口诀。
  • 2.4 先算账,再写代码:全年能量预算表

    这一步很多人跳过,然后花两周优化了一个完全不重要的地方。先把一个周期拆成几段,每段"电流 × 时间"算电荷,加起来除以周期,就是平均电流(低功耗_03 第 2.4 节的面积法)。

    按"每 10 分钟醒一次"来排(下面的电流是我这块板子上的量级,你的板子必须自己测,方法见低功耗_03):

    阶段电流时长电荷 = I × t
    ① 唤醒 + 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%。—— 这不能算理由。

    所以我拆任务的真实理由是另外三条:

  • 超时兜底更干净。 主任务用事件组等"采样完成 + 上报完成",带一个 20 秒的总超时。哪个环节卡住了(AP 消失、服务器不回包),主任务照样能把设备踹回 Deep Sleep。顺序写法里,只要有一个阻塞调用忘了设超时,设备就会挂在 120 mA 上过一整夜——早上起来电池就没了。这是电池设备最真实的死法。
  • 扩展时不用动主流程。 以后加个光照传感器、加个 BLE 广播,就是再起一个任务 + 多等一个事件位,主流程一行不用改。
  • 你本来就在用 RTOS。 ESP-IDF 里 Wi-Fi、lwIP、事件循环、esp_timer 全都是 FreeRTOS 任务,app_main 自己也跑在一个任务里。所以问题从来不是"要不要上 RTOS",而是"你要不要自觉地用它"。
  • 💡 什么时候并行才是真理由?当传感器需要长时间预热的时候。 比如 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 把新固件写进 ota_1,otadata 标记"下次启 ota_1",但给它打个 “待确认/暂无效” 标签。
  • 重启后跑 ota_1,先跑自检(比如能读到 SHT30、能连上服务器)。
  • 自检过 → 调 esp_ota_mark_app_valid_cancel_rollback(),标签变"有效",稳了。
  • 自检不过(比如新固件把 I²C 引脚配错了,传感器死活读不出)→ 不调用,bootloader 下次启动发现"还是待确认",自动回滚到 ota_0 的老固件。
  • 所以把"传感器能读出来"当成自检项,刚好把第四节的驱动和 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 排查顺序:

  • 地址对不对:SHT30 默认 0x44,ADDR 脚拉高变 0x45。先 probe 一下确认有应答。
  • 上拉焊了没:45 kΩ 内部上拉太弱,I²C 必焊 4.7 kΩ 外部上拉,否则长线误码。
  • 时序等够没:高重复性测量最长 15 ms(手册 Table 4)。把等待写短了(比如 vTaskDelay(pdMS_TO_TICKS(10))),传感器还没测完你就去读,必然 NACK。第四节我专门写了 30 ms 的坑。
  • 电平对不对:SHT30 支持 2.15–5.5 V,和 ESP32 的 3.3 V 直连没问题;别手滑把一个 5 V 传感器直接挂上来。
  • 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) 自己删自己

    九、动手练(故意搞坏才学得会)

    光看记不牢,下面几个"故意做错"的小实验,建议你挨个跑一遍,对照现象看日志——踩过的坑才是自己的。

  • 不隔离 GPIO12,看漏电翻倍:把 enter_deep_sleep() 里 rtc_gpio_isolate(GPIO_NUM_12) 注释掉,示波器/电流枪看睡眠电流。再取消注释对比,你能直观看到低功耗_03 说的"几十 µA 的差"。
  • 把 sensor_task 栈改到 512 字节:原本 3072 够用,改小后喂一组真实采样,等着看 HardFault 或 vApplicationStackOverflowHook 被触发。再调回来看 high-water 余数,理解"栈不是越大越好,但千万别不够"。
  • 把 I²C 等待改成 10 ms:把第四节的 vTaskDelay(pdMS_TO_TICKS(30)) 改成 10,大概率收到 NACK。用 i2c_master_probe 确认"地址有人",再用示波器看 SCL/SDA,你会看到"测量还没完就被读"的时序。改回 30 或提高 CONFIG_FREERTOS_HZ 到 1000 解决。
  • 故意不 mark valid,烧个坏固件看回滚:编一个"故意把 SHT30 引脚配错、永远读不出传感器"的固件,OTA 上去后不调用 confirm_if_ota()。重启后观察设备自动回滚到老固件——这一下把你从"变砖恐惧症"里解放出来。
  • 验证 RTC_DATA_ATTR:在 app_main 里打印 g_boot_count,连续唤醒几次,确认它在跨睡眠后一直在累加,而不是每次都从 0 开始。

  • 小结

    这一篇我们从简历项目出发,把一块 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网关与本地联动

    赞(0)
    未经允许不得转载:171主机测评 » 项目实战 01:从 0 做一个带 OTA 的低功耗气象节点(ESP32 + SHT30,一节 18650 跑大半年)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址