ESP32 进阶教程:让项目从 Demo 变成像样的设备

本文由 AI 协助整理,代码和验证步骤适用于学习记录。不同开发板、库版本、分区表和部署环境可能导致细节差异,请以实际硬件现象为准。

ESP32 项目工程化层次

为什么 Demo 能跑还不够

很多 ESP32 项目第一次跑起来时都很快乐:串口有输出,网页能打开,传感器有数据,LED 也很配合。问题是,Demo 的快乐通常发生在开发者眼皮底下,旁边有电脑、有串口、有重启按钮,还有一只随时准备插拔 USB 的手。真正的设备没有这么舒服。它可能被放在柜子上、弱电箱里、窗台边,网络会断,供电会抖,配置会丢,固件会升级,传感器会偶尔读失败。

所以工程化不是把代码写得更复杂,而是让项目面对现实世界时更有章法。它要回答几个朴素的问题:设备启动后知道自己是谁吗?配置从哪里来?网络失败后怎么办?升级失败后还能回来吗?出了问题以后,我们能不能从日志里看出一点线索?这些问题听起来不像点灯那样立刻有画面,但它们决定了项目能不能从“桌面演示”走向“长期运行”。

这篇文章不追求一次做成工业级方案,而是整理一个适合学习者的工程化清单。我们把 ESP32 项目分成业务功能、配置层、通信层、运维层和保护层。每一层都不必一开始做到满分,但至少要知道它存在。否则项目越写越大,最后会变成一团能运行但不敢碰的代码。那种代码像桌上的耳机线,看起来只是有点乱,真要解开就开始怀疑人生。

第一步:给设备一个身份

设备身份包括设备名、固件版本、硬件版本、编译时间和唯一 ID。学习阶段可以先在启动日志里打印:

#include <esp_system.h>

const char* FW_VERSION = "0.1.0";
const char* DEVICE_NAME = "esp32-env-node";

void printBootInfo() {
  Serial.printf("device=%s\n", DEVICE_NAME);
  Serial.printf("fw=%s\n", FW_VERSION);
  Serial.printf("chip=%08X\n", (uint32_t)ESP.getEfuseMac());
  Serial.printf("reset_reason=%d\n", esp_reset_reason());
  Serial.printf("free_heap=%u\n", ESP.getFreeHeap());
}

这些信息有什么用?当你手里只有一块板子时,用处似乎不大;当你有三块板子、两个版本、一个莫名其妙的重启问题时,它们就会变得非常亲切。尤其是固件版本,千万不要靠记忆。人类记忆在调试面前并不总是可靠,特别是凌晨一点以后。

设备名也不要长期写成 esp32-test。可以使用场景命名,比如 bedroom-env-01、desk-clock-01。名字不是仪式感,是后续日志、MQTT 主题、配置文件和后台展示的基础。

第二步:配置不要全写死

最开始写项目时,把 Wi-Fi 名称、MQTT 地址和采样周期写在代码里很方便。但只要换一个网络、换一台服务器、换一个房间,就要重新编译烧录,这就有点笨重了。ESP32 常用的配置保存方式有两类:NVS 和 LittleFS。

NVS 适合保存少量键值,比如设备名、采样周期、是否完成配网。LittleFS 更适合保存结构化配置文件、网页资源和简单日志。可以这样理解:NVS 像贴在冰箱上的便签,LittleFS 像一个小抽屉。便签适合写“采样周期 30 秒”,小抽屉适合放一份 JSON 配置。

一个简单的 NVS 示例:

#include <Preferences.h>

Preferences pref;

struct AppConfig {
  uint32_t sampleIntervalSec;
  String deviceName;
};

AppConfig loadConfig() {
  pref.begin("app", true);
  AppConfig cfg;
  cfg.sampleIntervalSec = pref.getUInt("interval", 30);
  cfg.deviceName = pref.getString("name", "esp32-node");
  pref.end();
  return cfg;
}

void saveInterval(uint32_t seconds) {
  pref.begin("app", false);
  pref.putUInt("interval", seconds);
  pref.end();
}

这里最容易忽略的是默认值。配置不存在不是异常,而是设备第一次启动时的正常状态。程序应该能在没有配置时进入默认模式,或者进入配网页,而不是直接崩掉。另一个问题是写入频率。Flash 有擦写寿命,不要每秒保存一次配置。运行数据和配置数据要分开,别把每一次温度变化都塞进 NVS。

第三步:日志要能帮你定位问题

日志不是越多越好。好的日志应该能回答三个问题:发生了什么?发生在什么时候?当时设备处于什么状态?

学习阶段可以先按级别打印:

#define LOGI(fmt, ...) Serial.printf("[I] " fmt "\n", ##__VA_ARGS__)
#define LOGW(fmt, ...) Serial.printf("[W] " fmt "\n", ##__VA_ARGS__)
#define LOGE(fmt, ...) Serial.printf("[E] " fmt "\n", ##__VA_ARGS__)

void connectWifi() {
  LOGI("wifi connecting");
  WiFi.begin("ssid", "password");

  unsigned long start = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - start < 10000) {
    delay(300);
  }

  if (WiFi.status() == WL_CONNECTED) {
    LOGI("wifi ok ip=%s", WiFi.localIP().toString().c_str());
  } else {
    LOGW("wifi timeout");
  }
}

注意不要在日志里打印密码、Token、家庭 Wi-Fi 名称等敏感信息。文章截图尤其要小心,很多泄露不是因为代码托管平台多么危险,而是因为截图里顺手露出了一串不该露的东西。

日志还要节制。循环里每毫秒打印一次,看起来像设备很努力,实际会影响时序,也会把真正有用的信息冲走。周期日志可以每 5 秒或 30 秒输出一次,错误日志可以立即输出。让日志像记录员,不要像话痨。

第四步:网络要有超时和重连

网络代码最忌讳“无限等待”。下面这种写法在入门教程里常见:

while (WiFi.status() != WL_CONNECTED) {
  delay(500);
}

它的优点是简单,缺点是过于深情。只要网络不回来,程序就一直等。更稳妥的做法是设置超时,失败后进入降级模式。例如温湿度节点断网后仍然显示本地数据,只是暂停 MQTT 上报。

bool connectWifiWithTimeout(uint32_t timeoutMs) {
  WiFi.mode(WIFI_STA);
  WiFi.begin("ssid", "password");

  unsigned long start = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - start < timeoutMs) {
    delay(200);
  }
  return WiFi.status() == WL_CONNECTED;
}

MQTT 也是一样。重连要有间隔,客户端 ID 要唯一,失败原因要记录。对于小项目来说,先做到“不会因为断网卡死”就已经很有价值。等项目稳定后,再考虑离线缓存、QoS、遗嘱消息和鉴权。

第五步:OTA 不是魔法,是风险管理

OTA 的诱惑很大:不用插线,不用拆外壳,远程点一下就升级。听起来很潇洒,但升级这件事本质上是在替换设备的大脑,不能只靠心情。一个稍微靠谱的 OTA 流程至少要考虑版本号、固件校验、升级日志、失败回滚和升级中的供电稳定。

ESP32 OTA 与异常恢复流程

学习阶段可以先用 ArduinoOTA 在局域网里测试,重点不是立刻远程部署,而是理解事件回调:

#include <ArduinoOTA.h>

void setupOta() {
  ArduinoOTA.setHostname("esp32-env-node");

  ArduinoOTA.onStart([]() {
    Serial.println("[I] ota start");
  });

  ArduinoOTA.onEnd([]() {
    Serial.println("[I] ota end");
  });

  ArduinoOTA.onError([](ota_error_t error) {
    Serial.printf("[E] ota error=%u\n", error);
  });

  ArduinoOTA.begin();
}

void loop() {
  ArduinoOTA.handle();
}

如果项目要更进一步,就需要关注分区表和回滚机制。简单理解,设备最好不要把唯一能运行的固件一把梭覆盖掉。升级失败时,应能退回旧版本。就算暂时不实现完整回滚,也要在文章里把风险讲明白:OTA 能提升维护效率,但它不是免费的午餐。

第六步:异常恢复要提前设计

异常恢复包括几类:配置损坏、网络长期不可用、传感器读取失败、任务卡死、升级失败。每一类都可以设计一个合理动作。

配置损坏时,恢复默认值或进入配网模式;网络长期不可用时,保留本地功能并降低重连频率;传感器读取失败时,标记状态而不是发布假数据;任务卡死时,可以使用看门狗机制让系统重启;升级失败时,尽量回滚到旧固件。

看门狗这个词听起来凶,其实它的作用很朴素:如果程序长时间没有按预期喂它,就认为系统可能卡住了,然后触发恢复动作。学习阶段不必一上来就把它配置得很复杂,但要理解它不是用来掩盖 bug 的。它只能帮你从卡死里回来,不能替你解释为什么卡死。解释问题仍然要靠日志、状态和复现步骤。

一个建议的项目目录

如果项目开始变大,可以把代码拆成几个文件。Arduino IDE 也能拆 .h 和 .cpp,PlatformIO 会更舒服一些。

esp32-env-node/
  src/
    main.cpp
    config.cpp
    config.h
    network.cpp
    network.h
    sensor.cpp
    sensor.h
    display.cpp
    display.h
  data/
    config.example.json
  README.md

main.cpp 负责调度,不要塞满所有细节。config 管配置,network 管 Wi-Fi 和 MQTT,sensor 管传感器,display 管屏幕。拆文件的目的不是仪式感,而是让每个模块的错误更容易定位。项目一大,最怕所有代码都住在一个几千行文件里,像一间没有标签的仓库。

验证清单

工程化文章最好配一份验证清单,因为它能体现“我不是只把代码写完,我还想过它怎么坏”。

场景 预期行为
第一次启动,没有配置 使用默认值,或进入配网模式
Wi-Fi 密码错误 超时后提示失败,继续本地功能
MQTT Broker 关闭 周期重连,不阻塞采样和显示
传感器断开 不发布无效数据,屏幕显示异常
断电重启 打印版本和重启原因,恢复配置
OTA 失败 记录错误,尽量保留旧版本
配置被清空 自动回到默认配置

这张表看起来有点像测试用例,但博客读者会喜欢这种内容。它比单纯贴代码更有启发性,因为它展示的是思考方式:设备不是只在理想条件下工作,而是在不理想条件下也要尽量体面。

写文章时可以怎么表达

如果把这篇写成博客,我会避免一上来就堆术语。可以先讲一个实际场景:温湿度节点在桌面跑得好好的,第二天换到卧室就连不上网了;或者 OTA 升级后设备再也不响应,只能拆下来重新烧录。这样的开头更容易让读者进入问题。

正文再分层展开:身份、配置、日志、网络、OTA、异常恢复。每一节都给一个小代码和一个可观察现象。最后给验证清单和后续计划。这样文章既有故事感,又有结构,不会变成“我用了某某库,然后成功了”的流水账。

语气上可以稍微诙谐一点,但不要为了好笑牺牲准确性。嵌入式文章最可贵的是可信:哪里已经验证,哪里只是建议,哪里需要读者根据自己的板子调整,都要说清楚。这样文章读起来像一个认真做过实验的人在分享,而不是一个把示例代码换了标题的人在赶稿。

本篇小结

ESP32 项目工程化的核心,不是把项目写得又大又复杂,而是让它更可解释、更可恢复、更可维护。一个像样的设备应该知道自己是谁,知道配置从哪里来,知道网络失败时怎么办,也能在升级和异常后留下线索。

Demo 追求“这东西能跑起来”,工程化追求“这东西跑不顺时我也知道怎么办”。两者都重要,但阶段不同。前者带来成就感,后者带来安全感。等你把配置、日志、网络、OTA 和恢复机制补上,再回头看最初那个只会点灯的 ESP32,会有一种老父亲看孩子长大的感觉:它还是那块板子,但已经不太一样了。

打赏

如果这篇学习记录对你有帮助,欢迎扫码支持后续整理。

打赏码

打赏支持:王庆岩

posted on 2026-09-15 10:35  潇洒石头  阅读(16)  评论(0)    收藏  举报

导航