最近在整理一个用 ESP32-S3 做的桌面环境监测小项目,项目代号就叫 Madeira。名字是随手取的,没有特别含义,但这套板子的硬件选型、固件结构和联调方法,做完之后基本沉淀成了我手边一个可复用的物联网节点模板。所以这篇文章打算把 Madeira 从需求拆解、硬件设计、代码实现到问题排查完整梳理一遍。
它本质上是一个基于 ESP32-S3 的 Wi-Fi/BLE 物联网开发节点,可以用一个 3.7V 锂电池供电,采集温湿度、光照强度,把数据通过 MQTT 上报到本地服务器或云平台,同时用一块 0.96 寸 OLED 做本地显示,预留了 GPIO 做外设控制。如果你正准备做一个环境监测、智能家居中控、传感器网关之类的小玩意,或者想找一套能直接抄作业的 ESP32-S3 工程骨架,这篇文章应该能帮到你。
1. 项目背景与整体设计思路拆解
1.1 为什么选择“Madeira”这个项目代号
我习惯给每个手头在做的硬件项目起一个内部代号,方便在整个开发周期里用一个短词指代它。Madeira 这个词最早出现在芯片厂商的参考设计文档里,我借过来用,中文可以理解为一个偏欧洲风的地名代称,并没有具体含义。
但这背后有一个习惯值得保留:给项目起代号不是纯粹图方便,它能帮你快速归类日志、分支、硬件版本甚至采购清单。比如我的固件仓库分支叫 feature/madeira-oled,打样的 PCB 文件夹叫 Madeira-V1.2,GitHub 上的工程说明第一行就是“Madeira: a ESP32-S3 based ambient monitoring node”。一旦代码和文档多了,这种命名一致性会省掉很多找文件的麻烦。
1.2 需求拆解:一个“能用”的制作需要哪些组件
如果只是点亮一颗 LED,那不算项目。Madeira 的目标很明确:放在桌面上,能实时显示环境数据,能远程查看历史数据,还要在断电后重新上电时自动恢复工作。我把需求拆成了四条:
- 环境采集:温度、湿度、光照强度三项基础数据,采集周期默认 10 秒一次。
- 本地显示:0.96 寸 OLED 直接显示当前数据,不需要打开手机也能看到。
- 远程上报:通过 Wi-Fi 连接路由器,用 MQTT 协议把 JSON 格式的数据推送到服务器。
- 低功耗待机:如果选择电池供电模式,设备要能在无人查看时进入浅睡眠,定时唤醒采集,再自动睡回去。
这个拆解过程看起来简单,但真正决定项目成败的是“不做什么”。我最初还想加上音频播放、人脸识别、语音控制,后来全部砍掉了。一个稳定可靠的小节点,比一个功能堆砌但总出问题的原型有价值得多。所以整个设计始终围绕“低复杂度、高可靠性、易复现”三个关键词展开。
1.3 开发板选型与采购避坑
Madeira 的核心模块我选了 ESP32-S3-WROOM-1,N8R8 版本,也就是 8MB Flash 加 8MB PSRAM 的模组。选它有四个原因:
- 双核 LX7 处理器跑 240MHz,做数据采集和 MQTT 上报绰绰有余。
- 原生 Wi-Fi 加 BLE 5.0,不需要外挂无线芯片。
- 支持向量指令和 SIMD,以后如果接麦克风做语音关键词识别,还能继续用。
- 板载 USB 烧录调试功能,省去外接 USB-TTL。
如果自己画板,采购模组时最需要留意的是 Flash 容量的字样。市面上有 N4(4MB)、N8(8MB)、N16(16MB)等型号,N8 后面还有 R8 表示带 PSRAM。如果你的代码里用到了 PSRAM 扩展内存,或者 OTA 差分包,空间规划就很重要。我自己在 V1.0 版本吃过亏:买成了 N4 版本,编完固件还剩不到 500KB,升级一个差分包很容易失败,后来全部换成 N8R8 才安心。
2. 核心硬件细节解析:传感器、屏幕和供电方案
2.1 I2C 挂设备的原则与 GPIO 分配
Madeira 的传感器和屏幕都走 I2C 总线,这样只占两个 GPIO,电路布线也简单。具体组合如下:
| 外设 | 型号 | I2C 地址 | 功能说明 |
|---|---|---|---|
| 温湿度传感器 | SHT30 | 0x44 | 温度精度 ±0.2℃,湿度精度 ±2%RH |
| 光照传感器 | BH1750 | 0x23 | 量程 1-65535 lux,可配高分辨率模式 |
| OLED 显示屏 | SSD1306 | 0x3C | 128x64 像素,白字显示 |
GPIO 分配上,我用了 GPIO8 作为 SCL,GPIO9 作为 SDA。这两个引脚在 ESP32-S3 的很多模组上都已经引出来,而且不占用默认的 Flash 引脚。需要注意,不要把 SCL/SDA 放在 GPIO26 和 GPIO27,因为这两个引脚在某些核心板上直接连到了板载 Flash,轻则信号干扰,重则导致启动失败。
I2C 挂多个设备时,要注意总线上拉电阻的选择。ESP32-S3 内部有上拉电阻,但默认阻值偏大,高速通信时波形不够好。我在板子上外挂了两个 4.7kΩ 上拉电阻,实测 400kHz 速率下波形干净,没有出现 CRC 错误。如果是纯手焊的飞线板,建议加上拉电阻后把速率降到 100kHz 或 200kHz,稳定性会明显好很多。
2.2 电源树与低功耗门槛
这部分的坑最多。ESP32-S3 本身可以在 Deep Sleep 下做到几十微安,但如果你把 OLED 和传感器的电源直接接在 3.3V 上,一切努力都是白费。因为 SSD1306 待机也要一两毫安,SHT30 和 BH1750 常开再各加零点几毫安,整个系统就永远降不到微安级别。
Madeira 的电源方案分两条路:
- 系统主供电:3.7V 锂电池经过 XC6206P332MR 或类似低压差 LDO,输出稳定的 3.3V,给 ESP32-S3 供电。
- 外设供电:OLED、SHT30、BH1750 的 VCC 不直接接 3.3V,而是接在一颗 MOS 管开关后面,开关的控制脚接到 ESP32-S3 的 GPIO21。需要测量时打开电源,测完立刻关掉。
这个细节让 Madeira 的休眠功耗从 3.2mA 降到了约 90μA。对于一块 400mAh 的电池来说,如果每 30 秒唤醒一次,每次唤醒工作 2 秒,理论上可以跑很多天。当然,实际还要算上电池自放电和 Wi-Fi 重连瞬间的电流尖峰。
另一个电源细节是:ESP32-S3 在 Wi-Fi 发射瞬间电流会拉到 350mA 左右,电源走线太细或者 LDO 压差太大,会导致射频输出功率下降、握手失败。所以我在 PCB 上把 3.3V 主供电走线加宽到 1mm,并在模组电源引脚旁边放了 10μF 和 0.1μF 两级电容。
2.3 天线布局与 PCB 注意事项
如果你用的是官方模组而不是自己画 RF 部分,天线区域还是不能马虎。ESP32-S3-WROOM-1 的天线在 PCB 板边,我在布局时保证天线下方和周围 10mm 范围内没有任何金属件、螺丝孔、底线或传感器排线。曾经有一版我把蜂鸣器放在天线旁边,结果实测 Wi-Fi RSSI 掉了 8dBm 左右,重新挪开位置后才恢复正常。
还有一点,PCB 铺地不要大面积挖空。很多新手为了“绝缘”把天线区域背面挖空,其实模组参考设计里天线正下方是有铺铜的,并且通过过孔连接到主地。挖空反而破坏了参考地平面,驻波比变差。我的经验是:直接照抄模组 datasheet 里的 Layout Guide,别自己发明。
3. 开发环境搭建与固件实现
3.1 工具链选择:ESP-IDF 还是 Arduino
做这种原生 Wi-Fi 加 MQTT 的节点,我强烈建议直接用乐鑫官方 ESP-IDF,而不是 Arduino。理由很简单:ESP-IDF 对电源管理、Wi-Fi 事件处理、MQTT 客户端和 OTA 的封装更完整,调试信息也更规范。你用 Arduino 写 30 行能跑通联网,但后续加低功耗、加 OTA、加错误重连机制,往往要到处找库补丁,反而更折腾。
我用的版本是 ESP-IDF v5.2,安装方式很简单:在 Ubuntu 或 macOS 上克隆 esp-idf 仓库,运行 install.sh,然后 source export.sh。Windows 上可以直接用乐鑫官方 IDE,或者用 MSYS2 终端。开发时配 VS Code 加 ESP-IDF 插件,能用图形界面管理和切换 SDK 版本,对新手更友好。
为了兼顾老项目,我验证过同样的代码结构在 ESP-IDF v4.4 上也能编译,只是注意几个函数名的兼容性,比如 esp_netif_create_default_wifi_sta 在 v4.4 已经存在,但 v4.4 的 Wi-Fi 事件循环需要额外注册事件基。建议新项目直接用 v5.x,少踩兼容坑。
3.2 工程结构和关键代码实现
Madeira 的固件工程遵循 ESP-IDF 的标准结构,核心源码放在 main 目录下:
madeira/ ├── CMakeLists.txt ├── sdkconfig.defaults ├── main/ │ ├── CMakeLists.txt │ ├── main.c │ ├── app_wifi.c │ ├── app_sensor.c │ ├── app_mqtt.c │ └── app_display.cmain.c 负责初始化所有模块,并创建三个任务:传感器采集任务、显示刷新任务、网络状态监控任务。这里我没有用 FreeRTOS 的复杂队列,只用一个全局结构体保存最新传感器数据,任务之间通过互斥锁或原子写来避免资源竞争。数据量小、更新频率低,这个方案足够简单可靠。
下面这段是传感器读取的核心逻辑。SHT30 需要先发送测量命令,再等待 50ms 读取 6 个字节;BH1750 则需要在每次读取前发送一次连续高分辨率模式指令,等 180ms 后读取 2 个字节。注意读取完成后要关闭外设电源,为后面的休眠做准备。
// app_sensor.c 关键代码 #define SHT30_ADDR 0x44 #define BH1750_ADDR 0x23 #define POWER_PIN 21 typedef struct { float temperature; float humidity; float lux; } madeira_sensor_data_t; static madeira_sensor_data_t s_sensor_data; static SemaphoreHandle_t s_sensor_mutex; void app_sensor_init(void) { gpio_set_direction(POWER_PIN, GPIO_MODE_OUTPUT); gpio_set_level(POWER_PIN, 0); s_sensor_mutex = xSemaphoreCreateMutex(); } bool app_sensor_read(madeira_sensor_data_t *out) { if (!out) return false; gpio_set_level(POWER_PIN, 1); // 打开外设电源 vTaskDelay(pdMS_TO_TICKS(50)); // 等待传感器上电稳定 esp_err_t ret = ESP_OK; uint8_t temp_sensor_data[6] = {0}; i2c_cmd_handle_t cmd = i2c_cmd_link_create(); i2c_master_start(cmd); i2c_master_write_byte(cmd, (SHT30_ADDR << 1) | I2C_MASTER_WRITE, true); i2c_master_write_byte(cmd, 0x2C, true); i2c_master_write_byte(cmd, 0x06, true); // 重复测量,高重复性 i2c_master_stop(cmd); ret |= i2c_master_cmd_begin(I2C_NUM_0, cmd, pdMS_TO_TICKS(100)); i2c_cmd_link_delete(cmd); vTaskDelay(pdMS_TO_TICKS(50)); cmd = i2c_cmd_link_create(); i2c_master_start(cmd); i2c_master_write_byte(cmd, (SHT30_ADDR << 1) | I2C_MASTER_READ, true); i2c_master_read(cmd, temp_sensor_data, 6, I2C_MASTER_LAST_NACK); i2c_master_stop(cmd); ret |= i2c_master_cmd_begin(I2C_NUM_0, cmd, pdMS_TO_TICKS(100)); i2c_cmd_link_delete(cmd); // BH1750 读取 uint8_t light_data[2] = {0}; cmd = i2c_cmd_link_create(); i2c_master_start(cmd); i2c_master_write_byte(cmd, (BH1750_ADDR << 1) | I2C_MASTER_WRITE, true); i2c_master_write_byte(cmd, 0x10, true); // 连续高分辨率模式 i2c_master_stop(cmd); i2c_master_cmd_begin(I2C_NUM_0, cmd, pdMS_TO_TICKS(100)); i2c_cmd_link_delete(cmd); vTaskDelay(pdMS_TO_TICKS(180)); cmd = i2c_cmd_link_create(); i2c_master_start(cmd); i2c_master_write_byte(cmd, (BH1750_ADDR << 1) | I2C_MASTER_READ, true); i2c_master_read(cmd, light_data, 2, I2C_MASTER_LAST_NACK); i2c_master_stop(cmd); ret |= i2c_master_cmd_begin(I2C_NUM_0, cmd, pdMS_TO_TICKS(100)); i2c_cmd_link_delete(cmd); gpio_set_level(POWER_PIN, 0); // 关闭外设电源,省电 if (ret != ESP_OK) return false; out->temperature = -45 + 175 * ((temp_sensor_data[0] << 8 | temp_sensor_data[1]) / 65535.0f); out->humidity = 100 * ((temp_sensor_data[3] << 8 | temp_sensor_data[4]) / 65535.0f); out->lux = (light_data[0] << 8 | light_data[1]) / 1.2f; xSemaphoreTake(s_sensor_mutex, portMAX_DELAY); s_sensor_data = *out; xSemaphoreGive(s_sensor_mutex); return true; }Wi-Fi 连接部分,我建议使用事件驱动的 STA 模式,而不是阻塞式连接。代码如下,注意事件的注册时机要在 esp_wifi_start 之前完成,否则容易漏掉事件。
// app_wifi.c 关键代码 static void wifi_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data) { if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { esp_wifi_connect(); } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { // 断线后重连,注意加退避时间,避免频繁重试 vTaskDelay(pdMS_TO_TICKS(3000)); esp_wifi_connect(); } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { // 记录获得的 IP,后续 MQTT 连接可以放在这里 ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data; ESP_LOGI("WIFI", "got ip: " IPSTR, IP2STR(&event->ip_info.ip)); } } void app_wifi_init(void) { esp_netif_init(); esp_event_loop_create_default(); esp_netif_create_default_wifi_sta(); wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); esp_wifi_init(&cfg); esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_event_handler, NULL); esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, wifi_event_handler, NULL); wifi_config_t wifi_config = { .sta = { .ssid = CONFIG_MADEIRA_WIFI_SSID, .password = CONFIG_MADEIRA_WIFI_PASSWORD, }, }; esp_wifi_set_mode(WIFI_MODE_STA); esp_wifi_set_config(WIFI_IF_STA, &wifi_config); esp_wifi_start(); }MQTT 上报我用的组件是 esp-mqtt,在 menuconfig 里开启 MQTT 之后,调用 mqtt_client 接口。上报的数据格式特意做成了 JSON 对象,方便服务器端解析:
{"device":"madeira","temp":25.3,"humidity":48.2,"lux":386.5,"rssi":-52}实际发布时把温度值保留一位小数,湿度一位小数,光照取整。订阅端收到后可以直接判断是否需要立刻处理,比如温度过高就推送告警。
OLED 显示部分,我用的是 U8g2 图形库。初始化时注意设置 I2C 通信速率,SSD1306 在 800kHz 下也能跑,但为了省电流我把速率降到 400kHz。显示内容采用两屏轮换:第一屏显示温度和湿度,第二屏显示光照强度和 Wi-Fi 信号强度。每 10 秒切换一次,避免长期烧屏。
3.3 编译烧录与日志调试
编译过程在 ESP-IDF 里已经非常流畅,先设置目标芯片:
idf.py set-target esp32s3 idf.py menuconfig idf.py build idf.py -p /dev/ttyACM0 flash monitor需要注意的是,ESP32-S3 的板载 USB 口有两种角色:一种是 USB-OTG,一种是 USB-Serial-JTAG。很多核心板默认用 USB-Serial-JTAG 枚举出一个串口,大部分系统会显示为 ttyACM0。如果你用 USB 口连接还是无法烧录,或者串口列表里看不到设备,先按住板子上的 BOOT 键再插入 USB,让芯片进入下载模式。
调试日志我开了三级:INFO 打印关键事件,WARN 打印重连情况,ERROR 打印传感器读取失败。实测下来,ESP-IDF 自带的日志系统足够用。不要过度 DEBUG,否则大量日志会拖慢 Wi-Fi 的实时行为,偶尔还会触发任务看门狗。
4. 功能联调与参数调优
4.1 无线稳定性与功耗实测
联调阶段,我最关心的两个指标是 Wi-Fi 重连稳定性和待机电流。经过多轮测试,我得出了几组参考数据:
- 冷启动到 MQTT 首次上线时间:约 1.8 秒,最快 1.5 秒。
- Wi-Fi 断线重连时间:3 到 5 秒。因为我在断线事件里加了 3 秒延时,防止模块频繁发起无效连接。
- 正常工作电流:约 80mA 到 120mA 之间,取决于 MQTT 发布频率。
- Deep Sleep + 外设断电:实测约 95μA,其中 ESP32-S3 的 Deep Sleep 电流 80μA 左右,LDO 静态功耗 10μA 左右,还有几微安的漏电流。
如果想进一步压低休眠电流,可以把系统主供电也改成 MOS 管控制,但那样需要一颗纽扣电池或者法拉电容维持 RTC 时钟,复杂度会上升。我的结论是:95μA 对于家庭桌面节点已经可以接受,再往下抠成本不太划算。
4.2 数据上报格式与云端解析方案
MQTT 主题我定义为 madeira/sensor/data,QoS 设成 0,因为环境数据允许偶尔丢包。发布频率默认 10 秒一次,服务器端按时间序列存储,不需要实时响应,QoS 1 反而会因为重传导致数据堆积。
在云端解析时,我用 Node-RED 订阅这个主题,把 JSON 拆出来写入时序数据库,比如 InfluxDB。再用 Grafana 画一个简单的仪表盘,显示温度和湿度曲线。如果你想用现成平台,也可以直接接入 Home Assistant 的 MQTT 传感器,配置几行 YAML 就能显示。
这里分享一个细节:上报数据时建议带上 RSSI 字段。它不仅能反映设备信号强度,还能用来做位置判断。比如某天 RSSI 突然稳定在 -70dBm 以下,说明设备可能被挪到了角落,或者天线附近被金属遮挡。这个信息对家庭场景很有用。
4.3 本地显示与升级扩展
OLED 显示部分的代码很简单,但有一个容易忽略的点:SSD1306 是全屏刷新模式,更新整个画面需要 1KB 的显存。如果你的板子没有 PSRAM,全部用内部 SRAM 会有点紧。我用了 U8g2 的全缓冲模式,在 8MB PSRAM 的板子上毫无压力,在无 PSRAM 的板子上建议改用半缓冲或页模式。
扩展性方面,Madeira 预留了 OTA 升级能力。我实现了最基本的 HTTP OTA:设备定期请求一个固定 URL,比较服务端的版本号和当前运行固件版本,不一致就下载升级。其实 HTTP OTA 已经很成熟,乐鑫官方也提供了 native OTA 示例。要特别注意分区表要预留 ota_0 和 ota_1 两个分区,否则无法拥有 A/B 备份回滚能力。
5. 常见问题与排查技巧实录
5.1 烧录失败与枚举问题
遇到最多的烧录失败,十有八九是芯片没有进入下载模式。ESP32-S3 用原生 USB 口烧录时,需要按住 BOOT(GPIO0)插线,等串口出现后再松开。有些人用 USB-OTG 口想直接烧录,但没注意到板子默认的 USB 工作模式是 USB-Serial-JTAG,两者枚举的串口名不一样,容易选错端口。
排查步骤我建议按顺序来:
- 插上 USB,执行 dmesg 或 ls /dev/tty*,确认系统是否识别到设备。
- 如果识别到设备但烧录一直失败,按住 BOOT 复位一次。
- 如果系统完全没识别,检查 USB 线是不是纯充电线。这个坑很多人中招,换一根数据线立刻解决。
- 如果用的是自定义板卡,确认 GPIO0 没有外接强上拉或强下拉,否则芯片启动模式会被锁定。
5.2 Wi-Fi 频繁掉线
这个问题的软件原因通常有两个。一个是电源输入瞬时纹波太大,射频前端在高功率发射时供电被拉低,导致模块自己复位或掉线。解决办法是在 LDO 输出端并联大容量电容,并在模组电源引脚附近放 100nF 高频去耦电容。
另一个原因是断线重连没有做退避。如果路由器重启,模块连续 100ms 内发起十几次连接请求,很容易触发路由器的连接限速,导致一直连不上。我在代码里加了 3 秒固定延时,如果需要更平滑,可以使用指数退避策略,比如第 1 次 2 秒,第 2 次 4 秒,第 3 次 8 秒,最大不超过 60 秒。
5.3 内存不足和任务看门狗
如果你在 sdkconfig 里开了一堆功能,编译后发现内存不足,先检查是不是把 WiFi 和 MQTT 缓冲同时开到了最大值。默认情况下,ESP32-S3 的 Wi-Fi 协议栈会占用约 100KB 动态内存,MQTT 发送缓冲默认 1KB,接收缓冲 1KB,这些都很小。真正吃内存的是 TLS 连接。如果没有启用服务器证书校验和 ALPN,连接一次大概会多占用 40KB 以上。非加密场景就没必要给 TLS 留太多缓冲。
任务看门狗报错则通常是因为某个任务在较长循环里调用了阻塞 API,比如在 I2C 读取时等待 180ms 的场景。解决办法是把这个等待放到一个专门的任务里,并调用 vTaskDelay 让出 CPU,或者临时提高看门狗超时阈值。
5.4 传感器读数异常与 OLED 黑屏
传感器读数为 0 或者错误,先检查 I2C 地址是否正确。BH1750 的地址取决于 ADDR 引脚电平,接地是 0x23,接 VCC 是 0x5C。SHT30 同样有 0x44 和 0x45 两个版本。很多情况下说明书写的是默认地址,但实际模块上已经接了不同的电阻,最好用 i2cscanner 扫描一遍所有地址,避免死磕。
OLED 黑屏则先看电源是否已经打开。因为我把外设电源接到了 GPIO21 上,如果初始化代码没把它拉高,屏幕自然不亮。其次看 I2C 地址,SSD1306 的 7 位地址通常是 0x3C,但也有 0x3D 的模块,扫描确认就行。最后再看初始化顺序,要在 U8g2 初始化之前先调用 app_sensor_init 打开电源开关,否则屏幕上电时序不对,初始化就失败。
6. 我个人对这套设计的一点体会
做 Madeira 的过程中,最深的体会是:硬件项目的坑大多不是出在单一功能上,而是出在各个子系统交互的边界。传感器单独测没问题,Wi-Fi 单独连没问题,可一旦传感器任务和 Wi-Fi 重连同时跑,偶发卡顿就会出现。所以后来我在设计中特别强调“每个外设都要能独立控制电源”,以及“不同任务之间尽量通过简单信号量通信,不做复杂队列”。
如果再让我重新做 V1.0,我会在一开始就把 GPIO 和电源分配表写在 README 里,而不是靠记忆。这份文档虽然只花十分钟,却能在后续调车式排查里省几个小时。最后再分享一个小技巧:给传感器外设统一做一个硬件电源开关,哪怕你现在不做低功耗,也值得加。因为调试时每次断电重启传感器,比软件复位靠谱得多。这就是我在这套板子上的经验,希望对你做自己的 Madeira 项目有一点参考价值。