ESP32-S3全栈实战:BLE GATT与JSON打造桌面状态显示设备
1. 从一块裸板到能用的桌面摆件:Status Deck 到底要做什么
Status Deck 这个概念,最早是我在折腾桌面信息展示时冒出来的。手头有一块 ESP32-S3 开发板,带 1.9 寸 LCD 屏,本来只是拿来跑跑点灯和 WiFi 扫描,后来觉得太浪费,就想把它做成一个能实时显示状态的小摆件——比如当前时间、天气、待办事项、服务器负载,甚至电脑上正在播放的音乐。这个系列的第一篇聊了硬件选型和外壳设计,这一篇专门讲技术栈怎么定、项目怎么落地。
关键词里出现了 ESP32-S3、BLE、GATT、JSON、全栈,这几个词基本勾勒出了整个项目的技术轮廓。ESP32-S3 是主控,负责驱动屏幕、处理逻辑、联网;BLE 和 GATT 用来做近场配置和手机端通信;JSON 是数据交换格式,不管是设备跟服务端通信,还是手机 App 跟设备通信,都靠它来承载结构化数据;全栈则意味着这个项目不只是写个固件就完事,还涉及后端接口、前端配置页面、甚至手机端的调试工具。
适合谁来参考这篇内容?如果你手上有 ESP32 系列开发板,想做一个带联网和蓝牙功能的桌面小设备,或者你正在学全栈开发但不想只做 Web 项目,想找一个能摸得着的硬件载体,那这篇会比较对路。我会把选型理由、踩过的坑、实际跑通的配置都摊开讲,代码和配置尽量给到能直接抄的程度。
先说清楚这个项目的最终形态:一块 ESP32-S3 板子插在桌面上,屏幕轮播显示几组信息;手机通过 BLE 连上设备后,可以发送 JSON 格式的配置指令,比如切换显示模式、修改 WiFi 凭据、调整刷新频率;设备本身也能主动连 WiFi,从自建的后端接口拉取数据。整个链路里,JSON 贯穿始终,BLE 负责近场配置,WiFi 负责远程数据同步。这就是 Status Deck 的核心骨架。
2. 主控为什么锁定 ESP32-S3 而不是其他方案
2.1 算力、内存和外设的平衡点
选主控这件事,我前后对比过好几轮。ESP32-S3 的双核 Xtensa LX7 跑在 240MHz,自带 512KB SRAM 和 384KB ROM,通常模组还会外挂 8MB PSRAM 和 16MB Flash。这个配置放在桌面状态显示这个场景里,属于明显过剩,但过剩有过剩的好处——你可以同时跑 WiFi 协议栈、BLE 协议栈、LVGL 图形库、JSON 解析,还能留出余量做 OTA 升级。
对比几个常见选项:ESP8266 便宜但只有单核 80MHz,跑个 WiFi 加屏幕就喘,BLE 更是别想;树莓派 Pico W 的 RP2040 没有原生 BLE,得外挂模块,而且 WiFi 性能一般;STM32 系列要自己配 WiFi 和 BLE 模组,开发周期拉长。ESP32-S3 把 WiFi 和 BLE 5.0 都集成在片内,省掉了外挂模组的麻烦,这对个人项目来说太重要了。
还有一个容易被忽略的点:ESP32-S3 支持 USB OTG 和原生 USB Serial/JTAG,烧录和调试不用额外买 USB-TTL 转换器,一根 Type-C 线搞定。我在早期用 ESP32 老版本时,每次烧录都要手动按 BOOT 和 EN 键,S3 上这个问题基本消失了。
2.2 BLE 5.0 和 WiFi 共存的实际表现
ESP32-S3 支持 BLE 5.0,包括 2M PHY 和 Coded PHY,理论速率比 BLE 4.2 翻倍。实际用下来,在 GATT 通信场景里,2M PHY 对吞吐的提升感知不算特别强,因为 GATT 本身有 MTU 限制,默认 23 字节,协商后一般能到 247 字节甚至 512 字节。真正让我满意的是 BLE 和 WiFi 共存时的稳定性。
早期 ESP32 在 WiFi 和 BLE 同时工作时,容易出现 BLE 连接断流或者 WiFi 延迟飙升。S3 上这个问题改善明显,我实测在 WiFi 持续拉取数据的同时,BLE 保持连接并接收 JSON 配置指令,连续跑 48 小时没有出现断连。当然,前提是你在软件层面做好任务优先级分配,别把 BLE 回调塞在 WiFi 任务里。
注意:BLE 和 WiFi 共用 2.4GHz 射频,理论上存在干扰。实际项目中建议把 BLE 连接间隔设大一点,比如 30ms 到 50ms,减少射频切换频率,对稳定性有帮助。
2.3 开发框架的选择:ESP-IDF 还是 Arduino
这个问题我被问过很多次。Arduino 框架上手快,库多,适合快速验证;ESP-IDF 更底层,配置灵活,适合做产品化。Status Deck 这个项目我最终选了 ESP-IDF,原因有三个。
第一,BLE GATT 服务在 ESP-IDF 里的 API 更完整,能精细控制广播参数、连接参数、MTU 协商,Arduino 的 BLE 库封装太厚,遇到问题不好排查。第二,JSON 解析我用的是 cJSON,ESP-IDF 直接集成,内存管理更可控。第三,项目后期要做 OTA 和分区表定制,ESP-IDF 的构建系统更顺手。
如果你只是想快速跑个 Demo,Arduino 完全够用。但如果你打算把这个设备长期放在桌面上用,建议直接上 ESP-IDF,省得后期迁移。
3. BLE GATT 服务设计:让手机和设备说同一种语言
3.1 服务与特征的划分逻辑
BLE 通信的核心是 GATT 层,设备对外暴露服务(Service),服务里包含特征(Characteristic),手机端通过读写特征来交换数据。Status Deck 里我设计了两个服务:一个是配置服务,用来接收 JSON 格式的设置指令;另一个是状态服务,用来上报设备当前状态。
配置服务里放了三个特征:一个可写特征用来接收配置 JSON,一个可读特征用来返回当前配置,一个通知特征用来在配置变更后主动推送确认。状态服务里放了一个通知特征,定期上报设备运行状态,比如内存占用、WiFi 信号强度、当前显示模式。
为什么这么分?因为配置和状态是两类不同的数据流。配置是低频、小数据量、需要确认的;状态是高频、可能较大、允许丢包的。分开之后,手机端可以只订阅状态通知,不碰配置特征,逻辑更清晰。
3.2 UUID 的规划与避坑
每个服务和特征都需要一个 UUID。标准 UUID 是 128 位,但 BLE 允许使用 16 位短 UUID 用于标准服务。自定义服务必须用 128 位 UUID,我一般用在线生成器随机生成,然后固定下来写进代码。
这里有个坑:UUID 的字节序。BLE 协议里 UUID 是小端序传输的,但很多生成器给你的是大端序字符串。如果你在代码里直接按字符串填,手机端可能扫不到服务。我的做法是生成后手动确认字节序,或者在代码里用宏定义转换。
另一个坑是特征权限。可写特征要设成 WRITE 或 WRITE_NR,可读设成 READ,通知设成 NOTIFY。如果你设了 NOTIFY 但没设 READ,有些手机端库会报错。我一般把通知特征同时设成 READ 和 NOTIFY,兼容性更好。
3.3 JSON 在 BLE 上的分片传输
BLE 的 MTU 默认 23 字节,协商后通常能到 247 字节。JSON 配置如果超过 MTU,就得手动分片。我的做法是在 JSON 外面包一层简单的协议:第一个字节表示总包数,第二个字节表示当前包序号,后面跟数据。手机端按序号拼接,设备端收齐后解析。
实测下来,一个典型的配置 JSON 大概 200 到 400 字节,协商 MTU 到 247 后,两包就能传完。如果你不想自己做分片,也可以把 JSON 压缩一下,去掉空格和换行,能省不少字节。
// 简化的分片接收逻辑示意 typedef struct { uint8_t total; uint8_t current; uint8_t data[240]; } ble_packet_t; static ble_packet_t rx_buffer[10]; static uint8_t rx_count = 0; void on_ble_write(uint8_t *data, uint16_t len) { ble_packet_t *pkt = (ble_packet_t *)data; rx_buffer[pkt->current] = *pkt; rx_count++; if (rx_count == pkt->total) { // 拼接并解析 JSON char json_buf[1024]; uint16_t offset = 0; for (int i = 0; i < pkt->total; i++) { memcpy(json_buf + offset, rx_buffer[i].data, 240); offset += 240; } parse_config_json(json_buf); rx_count = 0; } }提示:分片传输时一定要加超时重置。如果手机端发到一半断开,设备端的 rx_count 会卡住,下次连接就乱了。我一般设 5 秒超时,超时后清空缓冲区。
4. JSON 作为数据交换格式的实战细节
4.1 为什么选 JSON 而不是二进制或 MessagePack
JSON 的可读性是最大优势。调试的时候,你直接看串口打印的 JSON 字符串就能知道数据对不对,不用对着十六进制猜。而且手机端、后端、设备端都能轻松解析 JSON,跨语言兼容性好。
二进制协议省空间,但调试痛苦;MessagePack 比 JSON 紧凑,但生态不如 JSON 广。Status Deck 的数据量不大,JSON 的体积劣势可以接受。真正需要注意的是解析时的内存分配,cJSON 在解析大 JSON 时会频繁 malloc,ESP32-S3 虽然有 PSRAM,但也要小心碎片。
4.2 cJSON 在 ESP32 上的内存管理
cJSON 的 API 很简单,cJSON_Parse 解析,cJSON_GetObjectItem 取字段,用完 cJSON_Delete 释放。但这里有个常见错误:解析失败时返回 NULL,如果你不检查就直接取字段,程序会崩。
我的习惯是封装一层安全解析函数:
cJSON *safe_parse(const char *json_str) { cJSON *root = cJSON_Parse(json_str); if (root == NULL) { ESP_LOGE(TAG, "JSON parse failed: %s", cJSON_GetErrorPtr()); return NULL; } return root; } void handle_config(const char *json_str) { cJSON *root = safe_parse(json_str); if (root == NULL) return; cJSON *mode = cJSON_GetObjectItem(root, "mode"); if (cJSON_IsString(mode)) { set_display_mode(mode->valuestring); } cJSON_Delete(root); }另外,cJSON 解析后的对象树是动态分配的,如果你在 BLE 回调里解析,注意回调栈大小。ESP-IDF 默认 BLE 回调栈可能不够,我遇到过解析稍大 JSON 就栈溢出重启的情况。解决办法是在 menuconfig 里把 BLE 任务栈调到 4096 以上。
4.3 JSON 字段设计的一些经验
字段名尽量短,但别短到看不懂。比如用 "mode" 而不是 "display_mode",用 "wifi_ssid" 而不是 "w"。嵌套层级别太深,两层足够,太深了解析和调试都麻烦。
数值类型要注意,JSON 里数字都是 double,ESP32 上解析后取整要小心精度。布尔值用 true/false,别用 0/1,可读性差。数组用来传列表,比如待办事项列表,但数组长度要限制,别让设备端解析时内存爆掉。
5. 全栈视角下的项目分层与数据流
5.1 设备端、后端、手机端各自负责什么
Status Deck 虽然是个硬件项目,但它的数据流是典型的前后端架构。设备端负责采集和显示,后端负责聚合和转发,手机端负责配置和调试。
设备端:ESP32-S3 跑 FreeRTOS,一个任务驱动屏幕刷新,一个任务处理 BLE 通信,一个任务定时拉取 WiFi 数据。三个任务通过队列通信,避免直接共享全局变量。
后端:我用 Python Flask 写了一个极简接口,提供天气、时间、自定义消息三个端点,返回 JSON。部署在本地树莓派上,设备通过 HTTP GET 拉取。为什么不用云服务?因为延迟低,而且数据不出局域网,心里踏实。
手机端:初期用 BLE 调试助手手动发 JSON,后期用 MIT App Inventor 拼了一个简单 App,能扫描设备、连接、发送预设配置。如果你不想做 App,用 nRF Connect 这类通用工具也够用。
5.2 数据流的完整链路
一条典型的配置指令从手机到设备再到后端,链路是这样的:手机端构造 JSON,通过 BLE 写入配置特征;设备端收到后解析,如果是 WiFi 凭据就保存到 NVS 并尝试连接;连接成功后,设备端定时向后端发 HTTP 请求,后端返回 JSON 数据;设备端解析后更新屏幕显示,同时通过 BLE 通知特征把状态推给手机。
这个链路里,JSON 出现了三次:BLE 配置、HTTP 响应、BLE 状态通知。格式统一用 JSON,解析逻辑可以复用,代码量少很多。
5.3 错误处理与重试机制
全栈项目最怕的就是某一环断了,整个系统卡死。我的做法是每一层都加超时和重试。BLE 写入超时 3 秒,HTTP 请求超时 5 秒,WiFi 连接失败重试 3 次后进入配网模式。
配网模式是个兜底方案:如果设备连不上 WiFi,就自动开启 BLE 广播,等待手机发送新的 WiFi 凭据。这样即使你换了路由器,设备也不会变砖。
6. 项目实施中的踩坑记录与排查思路
6.1 BLE 连接后立刻断开的问题
这个问题折腾了我一个下午。现象是手机能扫描到设备,也能连接,但连上后一两秒就断开。排查思路:先看串口日志,发现 GATT 服务注册成功,但连接参数协商失败。查资料发现,ESP32-S3 默认的连接参数可能跟某些手机不兼容,尤其是 Android 端。
解决办法是在 GAP 事件回调里,连接建立后主动发起连接参数更新请求,把最小连接间隔设成 24(30ms),最大设成 40(50ms),从机延迟设成 0,超时设成 400(4 秒)。改完之后,连接稳定了。
case ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT: ESP_LOGI(TAG, "Connection params updated, status=%d", param->update_conn_params.status); break; // 连接建立后主动请求更新 esp_ble_conn_update_params_t conn_params = { .min_int = 0x18, // 30ms .max_int = 0x28, // 50ms .latency = 0, .timeout = 400 // 4s }; esp_ble_gap_update_conn_params(&conn_params);6.2 JSON 解析导致设备重启
前面提过栈溢出的问题,这里展开说。现象是手机发送一个稍大的 JSON 配置,设备直接重启,串口打印 "Stack canary watchpoint triggered"。这是典型的栈溢出。
排查方法:在 menuconfig 里把 BLE 任务栈从默认的 3584 调到 4096,如果还不够就调到 5120。另外,cJSON 解析时如果 JSON 嵌套太深,递归调用也会吃栈。我的建议是限制 JSON 嵌套不超过 3 层,数组长度不超过 20。
还有一个隐蔽的坑:cJSON_Parse 在解析失败时会分配内存然后返回 NULL,如果你不调用 cJSON_Delete 就泄漏了。虽然单次泄漏不大,但 BLE 回调频繁触发,累积起来会耗尽堆内存。所以解析失败也要记得释放。
6.3 WiFi 和 BLE 同时工作时的射频冲突
前面提过共存问题,这里给一个实际案例。我最初把 WiFi 的省电模式设成 WIFI_PS_MIN_MODEM,结果 BLE 通知延迟明显增大,有时候几秒才收到。后来改成 WIFI_PS_NONE,BLE 延迟恢复正常,但功耗上去了。
对于桌面设备来说,功耗不是首要考虑,所以我最终用了 WIFI_PS_NONE。如果你做电池供电的版本,就得在延迟和功耗之间权衡,可以试试 WIFI_PS_MAX_MODEM,但要做好 BLE 延迟增大的心理准备。
6.4 屏幕刷新与 BLE 回调的优先级冲突
LVGL 刷新屏幕是耗时操作,如果 BLE 回调里直接调用屏幕更新函数,会导致 BLE 响应变慢甚至断连。我的做法是 BLE 回调只负责解析 JSON 和更新数据模型,屏幕刷新交给独立的 LVGL 任务,通过队列传递更新事件。
这样解耦之后,BLE 回调执行时间控制在毫秒级,不会阻塞协议栈。LVGL 任务按自己的节奏刷新,即使慢一点,用户也感知不到。
7. 从能跑到好用:几个提升体验的细节
7.1 配置持久化与恢复出厂设置
设备配置存在 NVS(非易失性存储)里,断电不丢。但有时候配置错了导致设备连不上,就需要恢复出厂设置。我的做法是长按板载 BOOT 键 5 秒,清除 NVS 并重启,设备进入配网模式。
NVS 的读写要注意键名长度限制,最大 15 个字符。我一般用 "wifi_ssid"、"wifi_pass"、"disp_mode" 这种短键名。写入前先 nvs_open,写完 nvs_commit,别忘了 nvs_close。
7.2 OTA 升级的预留设计
虽然第一版没做 OTA,但分区表我预留了 OTA 分区。ESP32-S3 的 Flash 是 16MB,我分了两个 app 分区各 4MB,剩下给文件系统和 NVS。这样后期加 OTA 不用重新分区,直接改代码就行。
OTA 的触发方式我打算用 BLE 发送一个特殊 JSON 指令,设备收到后从指定 URL 下载固件。这样不用拆机接线,升级方便。
7.3 屏幕显示内容的布局思路
1.9 寸 LCD 分辨率 170x320,竖屏。我分了三行:第一行显示时间和日期,第二行显示天气和温度,第三行显示自定义消息或待办事项。字体用 LVGL 内置的 montserrat 16 和 24,中文显示需要额外加载字库,比较占 Flash,我暂时只显示英文和数字。
刷新频率设成 1 秒一次,时间每秒更新,天气每 10 分钟拉一次。屏幕背光用 PWM 控制,晚上自动调暗,避免刺眼。
8. 关于技术栈选型的一些个人体会
这个项目做下来,最大的感受是:选型没有绝对的对错,只有适不适合。ESP32-S3 不是性能最强的,但它在 WiFi、BLE、算力、生态、价格之间找到了一个很好的平衡点。JSON 不是最紧凑的格式,但它的可读性和跨平台兼容性让调试效率高了很多。ESP-IDF 不是最易用的框架,但它给的掌控感是 Arduino 给不了的。
如果你也在做类似的项目,我的建议是先把最小闭环跑通:一块板子、一个 BLE 服务、一个 JSON 解析、一个屏幕刷新。这四个跑通了,剩下的就是堆功能。别一上来就想着做完整产品,那样容易卡在细节里出不来。
另外,串口日志是你的好朋友。ESP32-S3 的 USB Serial/JTAG 可以直接打印日志,配合 ESP_LOGI 分级输出,排查问题效率翻倍。我习惯在关键路径上都加日志,比如 BLE 连接、JSON 解析、WiFi 状态变化,出问题时一眼就能定位。
最后说一个实际使用中的小技巧:BLE 调试时,手机端用 nRF Connect 比用自己写的 App 方便得多,能直接看服务、特征、读写数据。等协议稳定了,再去做 App,省得来回改。