ESP32-S3选型与BLE/WiFi/JSON架构设计:Status Deck实战 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 IDEPlatformIO 的依赖管理和库版本控制强太多。platformio.ini文件里我把配置分成三块通用配置、ESP32-S3 专属配置、调试配置。通用配置里指定平台和框架platform espressif32framework 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_LEVEL3 -DARDUINO_USB_MODE1 -DARDUINO_USB_CDC_ON_BOOT1 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.2MBOTA 升级都快了不少。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 收齐后再解析。或者更简单点协商更大的 MTUESP32-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?citybeijing); 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()会把整个响应体读进内存如果响应体很大比如超过 10KBESP32-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分包发送或协商更大 MTU5.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虽然刷屏快但关键时刻能救命。等稳定了再降级别减少干扰。