1. 从一块裸板到桌面摆件:Status Deck 的技术选型逻辑
Status Deck 这个项目,说白了就是一块能实时显示各种状态信息的小屏幕摆件。第一篇文章里我把需求拆了个底朝天——要能显示服务器在线状态、消息通知、天气、日程,甚至还能当个简易的番茄钟用。需求列完,接下来最头疼的就是选型:用什么主控、走什么通信协议、数据怎么组织、代码怎么分层。这几个问题不解决,后面写代码就是给自己挖坑。
我见过太多人做这类小项目,上来就买最贵的开发板,结果发现功耗压不住、蓝牙配对老掉线、JSON 解析把内存吃光。所以这一篇我不打算直接甩代码,而是先把选型背后的逻辑讲透。你跟着我的思路走一遍,后面实操的时候就知道每一步为什么这么做,出了问题也知道往哪个方向排查。
1.1 为什么是 ESP32-S3 而不是其他主控
选主控这件事,核心看三个维度:无线能力、算力余量、生态成熟度。Status Deck 需要同时跑 BLE 和 WiFi,还要驱动一块彩色屏幕,偶尔解析一下 JSON 数据,这对主控的要求其实不低。
我对比过几个常见方案。ESP32 经典款便宜是便宜,但蓝牙和 WiFi 共用天线,同时工作时互相干扰比较明显,而且单核跑起来驱动屏幕加网络协议栈,帧率会掉得厉害。树莓派 Pico W 的 WiFi 还行,但蓝牙支持一直是个短板,社区里做 BLE 外设的案例少得可怜。至于那些 Linux 开发板,性能是够了,但功耗和启动速度完全不适合一个常年插电但要求秒级响应的桌面摆件。
ESP32-S3 就不一样了。它是双核 Xtensa LX7,主频拉到 240MHz,一个核专门跑应用逻辑,另一个核处理无线协议栈,互不打架。蓝牙方面支持 BLE 5.0,广播包能塞更多数据,配对流程也比老款顺畅。最关键的是,乐鑫的 ESP-IDF 和 Arduino 核心对 S3 的支持已经非常成熟,社区里关于 BLE GATT 的示例代码一抓一大把,踩坑成本低。
注意:买 ESP32-S3 开发板的时候一定要看清型号。有些便宜板子用的是 ESP32-S3-WROOM-1 模组,但天线是 PCB 板载的,信号强度比外置天线版本差不少。如果你打算把 Status Deck 放在金属显示器旁边,强烈建议选带 IPEX 外置天线接口的版本。
1.2 BLE 与 WiFi 的分工策略
Status Deck 的通信需求分两块:一块是跟手机或电脑交互,比如配网、推送通知、修改显示内容;另一块是主动去互联网拉数据,比如天气、服务器状态。这两块需求特性完全不同,所以我把它们拆开处理。
BLE 负责近场交互。手机 App 通过 GATT 协议连接 Status Deck,写入 WiFi 账号密码、推送自定义消息、调整屏幕亮度。BLE 的优势是配对快、功耗低,手机靠近就能连,不需要 Status Deck 自己开热点。而且 GATT 的数据模型很清晰,一个 Service 下面挂几个 Characteristic,每个 Characteristic 负责一类数据,读写权限分开控制,逻辑上不容易乱。
WiFi 负责后台拉取。Status Deck 连上路由器之后,定时去请求天气 API、检查服务器 HTTP 状态码、拉取 RSS 订阅。这些操作数据量大、频率低,用 WiFi 最合适。而且 WiFi 连接建立之后,ESP32-S3 可以进入轻量级省电模式,不用一直保持高功耗接收。
这里有个细节值得展开说。很多人担心 BLE 和 WiFi 同时开会不会互相干扰。实测下来,只要不是 BLE 正在传输大块数据的同时 WiFi 满速下载,基本感觉不到影响。我的做法是让 BLE 和 WiFi 分时复用:BLE 只在手机主动连接时工作,平时处于广播状态;WiFi 则按需连接,拉完数据就断开,这样两者冲突的概率极低。
1.3 JSON 作为数据交换格式的取舍
Status Deck 需要跟手机 App、云端 API、本地配置文件三方面交换数据。格式选型上我考虑过三种:自定义二进制协议、MessagePack、JSON。
自定义二进制协议效率最高,但调试成本太大。你没法用串口助手直接看内容,每次改字段都要同步改两端代码,开发阶段的时间全耗在编解码上了。MessagePack 比 JSON 紧凑,但 ESP32-S3 上可用的库不多,而且手机端处理起来也没 JSON 方便。
最终选 JSON 的理由很实在:可读性强、库成熟、两端都好处理。ESP32-S3 上用 ArduinoJson 库,手机端用系统自带的 JSON 解析,云端 API 返回的也是 JSON,整条链路统一。虽然 JSON 比二进制占空间,但 Status Deck 传输的数据量很小,一个状态更新包也就几百字节,ESP32-S3 的 512KB SRAM 完全扛得住。
实操心得:ArduinoJson 在解析时一定要用
DynamicJsonDocument并提前算好容量。我一开始图省事用了 8192 字节的文档,结果解析一个嵌套三层的配置就爆了。后来改成按需计算,用JSON_OBJECT_SIZE宏精确分配,内存占用直接降了六成。
2. 项目骨架搭建:从工程结构到依赖管理
选型定下来之后,下一步就是把工程骨架搭起来。这一步看着简单,但结构没设计好,后面加功能就是灾难。我见过有人把所有代码塞进一个.ino文件,写到两千行的时候连自己都找不到函数在哪。Status Deck 的代码量预计在三千行左右,必须提前分好层。
2.1 工程目录的划分原则
我的划分逻辑是按职责分层,按功能分模块。顶层目录下分src、lib、data、docs四个文件夹。src放主程序入口和任务调度,lib放自己写的功能模块,data放网页配置界面和静态资源,docs放接线图和 API 文档。
lib里面再按功能拆成若干子文件夹,每个子文件夹是一个独立的模块,包含.h和.cpp两个文件。比如ble_service负责 BLE GATT 服务,wifi_manager负责 WiFi 连接和重连,json_handler负责 JSON 的序列化和反序列化,display_driver负责屏幕刷新。模块之间通过头文件暴露的接口通信,不直接访问对方的内部变量。
这样拆的好处是,改 BLE 逻辑的时候不会碰到显示代码,调屏幕驱动的时候也不用担心把 WiFi 配置搞乱。而且每个模块可以单独写单元测试,虽然嵌入式环境跑测试麻烦点,但至少逻辑上是隔离的。
2.2 PlatformIO 环境配置要点
开发环境我用的是 PlatformIO,配合 VS Code。相比 Arduino IDE,PlatformIO 的依赖管理和库版本控制强太多。platformio.ini文件里我把配置分成三块:通用配置、ESP32-S3 专属配置、调试配置。
通用配置里指定平台和框架,platform = espressif32,framework = arduino。ESP32-S3 专属配置里设置board = esp32-s3-devkitc-1,然后打开 PSRAM 支持,因为屏幕缓冲和 JSON 文档都比较吃内存。调试配置里把串口波特率设成 921600,日志级别开到 verbose,方便排查问题。
[env:esp32-s3-devkitc-1] platform = espressif32 board = esp32-s3-devkitc-1 framework = arduino monitor_speed = 921600 build_flags = -DBOARD_HAS_PSRAM -DCORE_DEBUG_LEVEL=3 -DARDUINO_USB_MODE=1 -DARDUINO_USB_CDC_ON_BOOT=1 lib_deps = bblanchon/ArduinoJson@^7.0.0 adafruit/Adafruit NeoPixel@^1.12.0 olikraus/U8g2@^2.35.0这里有个坑要提醒。ESP32-S3 的 USB 接口有两种模式:硬件 CDC 和软件 CDC。如果你用 Arduino 框架,默认是硬件 CDC,但有些开发板的 USB 转串口芯片不一样,需要手动指定ARDUINO_USB_MODE。我手头这块板子如果不设这个标志,串口监视器就收不到任何输出,排查了半天才发现是模式选错了。
2.3 依赖库的版本锁定策略
嵌入式开发最怕的就是库版本升级导致编译不过。我吃过一次亏,ArduinoJson 从 6.x 升到 7.x 之后,StaticJsonDocument被废弃了,所有相关代码都要改。所以现在我在platformio.ini里严格锁定版本号,用@^7.0.0这种写法,允许小版本更新但禁止大版本跳跃。
对于屏幕驱动库,我选的是 U8g2。这个库支持的单色屏幕型号非常多,SSD1306、SH1106、UC1701 都能驱动,而且自带多种字体,显示中文也不用额外折腾。虽然它主要面向单色屏,但 Status Deck 第一版用的就是 128x64 的 OLED,够用了。后面如果换彩色屏,再考虑 TFT_eSPI 或者 LVGL。
提示:U8g2 的字体文件很占 Flash 空间。如果你只需要显示英文和数字,在
U8g2的构造参数里把不需要的字体关掉,能省下几十 KB。我一开始全开了,编译出来固件 1.8MB,关掉多余字体后降到 1.2MB,OTA 升级都快了不少。
3. BLE GATT 服务设计与 JSON 数据通道
BLE 是 Status Deck 跟外界交互的主要通道,设计好坏直接影响使用体验。我的目标很明确:手机连上之后,能读状态、能写配置、能收通知,而且整个过程不需要装专门的 App,用通用的 BLE 调试工具就能操作。
3.1 GATT 服务与特征值的规划
GATT 的数据模型是 Service 套 Characteristic。一个设备可以暴露多个 Service,每个 Service 下面挂若干 Characteristic。每个 Characteristic 有 UUID、权限(读/写/通知)、以及一个值。手机通过读写这些值来跟设备交互。
Status Deck 我设计了两个 Service。第一个是设备信息服务,UUID 用标准的0x180A,里面放设备名称、固件版本、制造商信息,这些是只读的,手机连上就能看到。第二个是自定义控制服务,UUID 自己生成一个 128 位的,里面放四个 Characteristic。
| Characteristic 名称 | UUID 后缀 | 权限 | 用途 |
|---|---|---|---|
| WiFi 配置 | ...0001 | 读写 | 写入 SSID 和密码,读取当前连接状态 |
| 显示内容 | ...0002 | 读写 | 写入要显示的文字或图标编号 |
| 状态通知 | ...0003 | 通知 | 设备主动推送状态变化 |
| 命令通道 | ...0004 | 写 | 发送控制命令,如重启、清屏、切换模式 |
UUID 的生成有个小技巧。不要随便填一串数字,最好遵循蓝牙规范的建议格式:XXXXXXXX-0000-1000-8000-00805F9B34FB。前面八位自己定义,后面固定。这样既保证唯一性,又符合标准,某些手机系统对非标准 UUID 的兼容性会差一些。
3.2 数据序列化与反序列化实操
Characteristic 的值本质上是一串字节。要传结构化数据,就得约定编解码方式。我选的是 JSON 字符串转 UTF-8 字节数组。手机端把 JSON 转成字符串,再转成字节写入;ESP32-S3 收到字节后转回字符串,用 ArduinoJson 解析。
写 WiFi 配置的 JSON 长这样:
{ "ssid": "MyHomeWiFi", "password": "12345678", "timeout": 15000 }ESP32-S3 端的解析代码:
#include <ArduinoJson.h> void handleWifiConfig(uint8_t* data, size_t len) { DynamicJsonDocument doc(256); DeserializationError err = deserializeJson(doc, data, len); if (err) { Serial.printf("JSON parse failed: %s\n", err.c_str()); return; } const char* ssid = doc["ssid"]; const char* password = doc["password"]; int timeout = doc["timeout"] | 10000; // 后续连接逻辑 }这里DynamicJsonDocument的容量我给了 256 字节。怎么算出来的?ssid最长 32 字节,password最长 64 字节,加上 JSON 的键名、括号、引号,总共不超过 200 字节。留点余量给 256 足够了。如果你不确定,可以用 ArduinoJson 官网的 Assistant 工具,输入 JSON 样例它会告诉你需要多大容量。
注意:BLE 单次传输的数据长度有限制。默认 MTU 是 23 字节,去掉协议头只剩 20 字节可用。传大 JSON 必须分包。我的做法是在 JSON 前面加一个长度头,手机端先发总长度,再分片发送,ESP32-S3 收齐后再解析。或者更简单点,协商更大的 MTU,ESP32-S3 支持到 517 字节,一般手机也能协商到 200 多,够用了。
3.3 通知机制与实时状态推送
状态通知这个 Characteristic 我开了 Notify 权限。ESP32-S3 可以主动往这个特征值写数据,手机端订阅之后就能收到推送。用来做什么呢?比如 WiFi 连接成功了、服务器状态从在线变成离线了、番茄钟倒计时结束了,这些事件都可以推给手机。
实现上,ESP32-S3 端保存一个连接句柄,当有手机订阅通知时,把句柄存下来。状态变化时调用esp_ble_gatts_send_indicate发送数据。手机端收到通知后解析 JSON,更新界面。
void sendStatusNotification(const char* status) { if (notifyConnId == 0xFFFF) return; // 没有订阅者 uint8_t payload[128]; size_t len = snprintf((char*)payload, sizeof(payload), "{\"status\":\"%s\",\"ts\":%lu}", status, millis()); esp_ble_gatts_send_indicate(gattsIf, notifyConnId, statusCharHandle, len, payload, false); }这里false表示不需要确认,发出去就不管了。如果需要可靠送达,改成true,但会增加延迟。状态通知这种场景,丢一两条无所谓,用false更流畅。
4. WiFi 连接管理与网络数据拉取
BLE 负责近场配置,WiFi 负责后台干活。Status Deck 需要定时去拉天气、检查服务器状态、同步时间。这些任务不能阻塞主循环,否则屏幕会卡住。所以 WiFi 管理这块,核心是非阻塞连接和任务调度。
4.1 非阻塞 WiFi 连接的状态机设计
很多人写 WiFi 连接就是WiFi.begin()然后while(WiFi.status() != WL_CONNECTED) delay(500)。这种写法在简单例程里没问题,但在 Status Deck 上绝对不能用。因为delay会卡住整个循环,屏幕不刷新、BLE 不响应,用户体验极差。
我的做法是用状态机。WiFi 管理模块维护一个状态变量,在loop里每次检查一下当前状态,根据状态决定下一步动作。状态包括:IDLE、CONNECTING、CONNECTED、FAILED、RETRY_WAIT。
enum WifiState { IDLE, CONNECTING, CONNECTED, FAILED, RETRY_WAIT }; void wifiLoop() { switch (currentState) { case IDLE: if (shouldConnect) { WiFi.begin(ssid, password); connectStartTime = millis(); currentState = CONNECTING; } break; case CONNECTING: if (WiFi.status() == WL_CONNECTED) { currentState = CONNECTED; onConnected(); } else if (millis() - connectStartTime > 15000) { currentState = FAILED; } break; case FAILED: retryCount++; if (retryCount < 3) { currentState = RETRY_WAIT; retryWaitStart = millis(); } else { currentState = IDLE; shouldConnect = false; } break; case RETRY_WAIT: if (millis() - retryWaitStart > 5000) { currentState = IDLE; shouldConnect = true; } break; case CONNECTED: if (WiFi.status() != WL_CONNECTED) { currentState = IDLE; shouldConnect = true; } break; } }这段代码放在loop里每次跑一遍,不阻塞任何东西。连接超时设 15 秒,失败后等 5 秒重试,最多重试 3 次。重试次数到了就放弃,等用户通过 BLE 重新配置。
4.2 HTTP 请求与 JSON 解析的配合
WiFi 连上之后,下一步就是拉数据。我用的是HTTPClient库,配合WiFiClient。请求天气 API 的流程是:创建客户端、发起 GET 请求、读取响应体、解析 JSON、提取需要的字段。
void fetchWeather() { WiFiClient client; HTTPClient http; http.begin(client, "http://api.example.com/weather?city=beijing"); int code = http.GET(); if (code == 200) { String payload = http.getString(); DynamicJsonDocument doc(1024); DeserializationError err = deserializeJson(doc, payload); if (!err) { float temp = doc["main"]["temp"]; const char* desc = doc["weather"][0]["description"]; displayWeather(temp, desc); } } http.end(); }这里 JSON 文档给了 1024 字节,因为天气 API 返回的数据结构比较深,嵌套了好几层。如果你不确定 API 返回多大,先用串口把payload打印出来看看长度,再决定分配多少。
实操心得:HTTP 请求一定要设超时。默认超时是 5 秒,但有些 API 响应慢,5 秒不够。我改成 10 秒,同时加了重试逻辑。另外,
http.getString()会把整个响应体读进内存,如果响应体很大(比如超过 10KB),ESP32-S3 的内存会紧张。这时候可以用http.getStream()流式解析,边读边解析,省内存。
4.3 定时任务与看门狗配合
Status Deck 需要定时拉数据,比如每 10 分钟更新一次天气,每 30 秒检查一次服务器状态。我用的是Ticker库来注册定时回调。但这里有个坑:回调函数里不能做太耗时的操作,否则会触发看门狗复位。
我的做法是,Ticker 回调只设置一个标志位,真正的网络请求放在主循环里根据标志位执行。这样既保证了定时精度,又不会阻塞系统任务。
volatile bool weatherUpdateFlag = false; Ticker weatherTicker; void setup() { weatherTicker.attach(600, []() { weatherUpdateFlag = true; }); } void loop() { if (weatherUpdateFlag) { weatherUpdateFlag = false; fetchWeather(); } // 其他任务 }看门狗方面,ESP32-S3 默认开启了任务看门狗,如果某个任务长时间不让出 CPU,会被强制复位。所以任何可能阻塞的操作,比如while循环等待、delay超过 100ms,都要拆成状态机或者用vTaskDelay让出 CPU。
5. 踩坑实录与排查技巧
做这个项目的过程中,我踩了不少坑。有些是 ESP32-S3 特有的,有些是 BLE 协议本身的限制,还有些纯粹是自己粗心。这一章我把典型问题整理出来,你遇到类似情况可以直接对照排查。
5.1 BLE 配对失败与连接不稳定的排查
问题现象:手机能搜到 Status Deck 的广播,但点击连接后一直转圈,最后提示连接失败。偶尔能连上,但几秒后就断开。
排查思路:先看串口日志。如果日志里显示ESP_GATTS_CONNECT_EVT之后马上出现ESP_GATTS_DISCONNECT_EVT,说明连接建立后被对方主动断开了。这种情况通常是 GATT 服务定义有问题,比如某个 Characteristic 的权限和属性不匹配。
我遇到的具体原因是:状态通知那个 Characteristic 我设了 Notify 权限,但没有设 Read 权限。有些手机在连接后会尝试读取所有 Characteristic 的值,读到没有读权限的就直接断开。后来我把所有 Characteristic 都加上 Read 权限,问题解决。
另一个常见原因是广播间隔设得太长。默认是 100ms,如果设成 1000ms,手机搜索和连接都会变慢。我改成 200ms,兼顾功耗和响应速度。
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 搜不到广播 | 广播未启动或名称未设置 | 检查esp_ble_gap_set_device_name和esp_ble_gap_start_advertising |
| 连接后立即断开 | Characteristic 权限不完整 | 给所有特征值加上 Read 权限 |
| 连接不稳定 | 广播间隔过长或 MTU 协商失败 | 广播间隔设 200ms,主动协商 MTU |
| 写入数据无响应 | 写入长度超过 MTU | 分包发送或协商更大 MTU |
5.2 JSON 解析内存溢出的解决过程
问题现象:解析一个稍微大一点的 JSON 时,串口打印DeserializationError: NoMemory,然后设备重启。
排查思路:ArduinoJson 在解析时需要在内存里构建文档树,如果分配的容量不够,就会报NoMemory。更严重的是,如果用了StaticJsonDocument且容量不够,会直接栈溢出,导致设备崩溃。
我的解决过程分三步。第一步,把所有StaticJsonDocument换成DynamicJsonDocument,让内存在堆上分配,至少不会栈溢出。第二步,用 ArduinoJson Assistant 工具精确计算每个 JSON 需要的容量。第三步,在解析前先检查 JSON 字符串长度,如果超过预期,直接拒绝解析并返回错误。
bool safeParse(const char* json, JsonDocument& doc) { size_t len = strlen(json); if (len > 2048) { Serial.println("JSON too large, rejected"); return false; } DeserializationError err = deserializeJson(doc, json); return !err; }提示:ArduinoJson 7.x 版本引入了
JsonDocument基类,DynamicJsonDocument和StaticJsonDocument都继承自它。新代码建议直接用JsonDocument,让库自己决定内存分配策略。但要注意,7.x 的 API 和 6.x 有不少差异,升级时仔细看迁移指南。
5.3 WiFi 与 BLE 共存时的性能调优
问题现象:BLE 连接着手机的同时,WiFi 去拉数据,结果 BLE 断开,或者 WiFi 请求超时。
排查思路:ESP32-S3 的射频模块是共享的,BLE 和 WiFi 分时复用天线。如果两者同时高负载工作,就会互相抢资源。解决办法是错峰使用。
我的调优策略是:BLE 连接期间,暂停 WiFi 的定时拉取任务;WiFi 拉数据时,如果 BLE 有数据传输,优先保证 BLE。具体实现上,用一个全局变量标记当前射频使用状态,任务调度时检查这个变量。
另外,把 WiFi 的省电模式打开,WiFi.setSleep(true),能减少射频占用时间。但省电模式会增加延迟,如果对实时性要求高,可以关掉。
// 射频资源互斥标记 volatile bool radioBusy = false; void fetchData() { if (radioBusy) return; // BLE 正在用,跳过本次 radioBusy = true; // 执行 WiFi 请求 radioBusy = false; } void onBleData() { radioBusy = true; // 处理 BLE 数据 radioBusy = false; }这个方案简单粗暴,但实测有效。BLE 和 WiFi 冲突的概率从原来的三成降到了几乎为零。
5.4 固件 OTA 升级的注意事项
Status Deck 装进外壳之后,再插 USB 线升级就很麻烦。所以 OTA 升级是必须的。我用的是 ArduinoOTA 库,配合 WiFi 连接。
OTA 升级有几个坑要注意。第一,分区表必须包含 OTA 分区,默认的分区表可能不够。在platformio.ini里指定board_build.partitions = default_16MB.csv,确保有足够的 OTA 空间。第二,OTA 过程中不能断电,否则设备会变砖。第三,升级完成后要自动重启,并且验证新固件能正常启动。
void setupOTA() { ArduinoOTA.setHostname("status-deck"); ArduinoOTA.setPassword("admin123"); ArduinoOTA.onStart([]() { displayMessage("OTA Updating..."); }); ArduinoOTA.onEnd([]() { displayMessage("OTA Done, rebooting"); }); ArduinoOTA.onError([](ota_error_t error) { displayMessage("OTA Failed"); }); ArduinoOTA.begin(); }注意:OTA 密码一定要设,而且不要用默认的。我见过有人不设密码,结果同网络下任何人都能往设备里刷固件。虽然 Status Deck 是个小项目,但基本的安全意识还是要有的。
6. 项目扩展方向与个人体会
Status Deck 第一版跑通之后,我又陆续加了一些功能。比如用 BLE 鼠标的 UUID 思路,把 Status Deck 模拟成一个 BLE 外设,电脑可以直接把它识别成键盘,推送快捷键。还试过把 JSON 配置文件放在 SPIFFS 里,开机自动读取,不用每次通过 BLE 配置。
这个项目最让我意外的是 JSON 的通用性。本来只是用来做 BLE 数据交换,后来发现天气 API、服务器状态接口、甚至屏幕布局配置,全都可以用 JSON 统一描述。整个项目的配置文件、网络请求、BLE 通信,三种场景共用一套解析逻辑,代码量少了很多。
如果你打算复现这个项目,我的建议是先把 BLE 和 WiFi 分别跑通,再合到一起。很多人一上来就写完整逻辑,结果 BLE 连不上、WiFi 也连不上,根本不知道是哪部分出了问题。分开调试,每部分都确认没问题了再集成,效率高得多。
另外,串口日志一定要打足。ESP32-S3 的串口输出是排查问题的生命线,每个关键步骤都加一句Serial.printf,出问题的时候一眼就能看出卡在哪。日志级别用 verbose,虽然刷屏快,但关键时刻能救命。等稳定了再降级别,减少干扰。