适用人群: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 µFWi-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Ω,扛得住脉冲;保护板负责过放保护
CR20323.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 的实测结论:

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

2.4 先算账,再写代码:全年能量预算表

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

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

阶段电流时长电荷 = I × t
① 唤醒 + bootloader + 外设初始化(射频未开)40 mA0.30 s12.0 mA·s
② 读 SHT30(与 ③ 并行,不单独计时)+0.6 mA0.02 s≈ 0
③ Wi-Fi 连接 + DHCP120 mA1.20 s144.0 mA·s
④ HTTPS 上报 + 查 OTA 版本150 mA0.50 s75.0 mA·s
⑤ Deep Sleep(整机 15 µA)0.015 mA598.0 s9.0 mA·s
合计600 s240 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%。—— 这不能算理由。

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

  1. 超时兜底更干净。 主任务用事件组等"采样完成 + 上报完成",带一个 20 秒的总超时。哪个环节卡住了(AP 消失、服务器不回包),主任务照样能把设备踹回 Deep Sleep。顺序写法里,只要有一个阻塞调用忘了设超时,设备就会挂在 120 mA 上过一整夜——早上起来电池就没了。这是电池设备最真实的死法。
  2. 扩展时不用动主流程。 以后加个光照传感器、加个 BLE 广播,就是再起一个任务 + 多等一个事件位,主流程一行不用改。
  3. 你本来就在用 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 第三节):

任务我给的初值依据
sensor3072 BI²C 调用本身很轻,但 ESP_LOGI 打印 %.2f 会走 vfprintf,浮点格式化一次就要一两千字节
net_ota8192 BWi-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;要时钟拉伸版本是 0x2C06Table 9
测一次要多久高重复性:典型 12.5 ms,最长 15 msTable 4
数据怎么变成温湿度每次读回 6 字节:温度高字节、低字节、CRC,湿度高字节、低字节、CRC4.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.hi2c_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 不是坏味道。这里它保证了不管哪一步失败,样本一定会被送进队列、事件位一定会被置起来。如果这里提前 returnnet_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_ATTRg_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);
    }
}

逻辑闭环是这样:

  1. OTA 把新固件写进 ota_1,otadata 标记"下次启 ota_1",但给它打个 “待确认/暂无效” 标签。
  2. 重启后跑 ota_1,先跑自检(比如能读到 SHT30、能连上服务器)。
  3. 自检过 → 调 esp_ota_mark_app_valid_cancel_rollback(),标签变"有效",稳了。
  4. 自检不过(比如新固件把 I²C 引脚配错了,传感器死活读不出)→ 不调用,bootloader 下次启动发现"还是待确认",自动回滚到 ota_0 的老固件。

所以把"传感器能读出来"当成自检项,刚好把第四节的驱动和 OTA 安全绑在了一起——这也是我刻意这么设计的原因。

6.6 把上面几段拼回 net_ota_task

前面 6.3–6.5 那几个函数,最终都是在 main.cxTaskCreate(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 排查顺序:

  1. 地址对不对:SHT30 默认 0x44,ADDR 脚拉高变 0x45。先 probe 一下确认有应答。
  2. 上拉焊了没:45 kΩ 内部上拉太弱,I²C 必焊 4.7 kΩ 外部上拉,否则长线误码。
  3. 时序等够没:高重复性测量最长 15 ms(手册 Table 4)。把等待写短了(比如 vTaskDelay(pdMS_TO_TICKS(10))),传感器还没测完你就去读,必然 NACK。第四节我专门写了 30 ms 的坑。
  4. 电平对不对: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 µAGPIO12 没隔离;或外部上拉顺着电路漏电rtc_gpio_isolate(GPIO_NUM_12);用电流枪实测(低功耗_03)五、7.3
2醒来后全局变量全变 0Deep Sleep 醒来是复位,普通变量不保活跨睡眠要记住的变量加 RTC_DATA_ATTR五、7.2
3I²C 一直 NACK 读不到数地址错 / 没焊外部上拉 / 测量等待时间不够i2c_master_probe 探地址;焊 4.7 kΩ;等待 ≥30 ms四、7.3
4跑着跑着 HardFault 重启任务栈太小溢出uxTaskGetStackHighWaterMark(字节);开 CHECK_FOR_STACK_OVERFLOW三、7.1
5OTA 后变砖、起不来新固件没 mark valid;或新固件自检挂了启动后自检通过再 esp_ota_mark_app_valid_cancel_rollback(),否则靠回滚兜底六、7.4
6升级完发现还是旧版本没设 app version;或服务器版本不比当前新version 组件里写版本号;升级前比对版本号
7esp_sleep_enable_timer_wakeup 时间不对单位是微秒不是毫秒唤醒时长按微秒传,如 main.c 的 WAKE_INTERVAL_US(600 s 折算的微秒)
8任务函数 return 后整板重启FreeRTOS 任务函数不允许 return末尾 vTaskDelete(NULL) 自己删自己

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

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

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

更多推荐