1. 从一块裸板到能用的桌面摆件,中间隔着多少决策
Status Deck 这个项目做到第二篇,硬件选型基本尘埃落定,接下来真正让人头疼的其实不是写代码,而是“用什么写、怎么组织、数据怎么流”。我见过太多人卡在这一步:板子买回来了,屏幕点亮了,然后面对一堆库和框架发呆,不知道该从哪一层开始搭。这篇就专门聊技术栈选型和项目实施,把我在这个阶段踩过的坑、做过的取舍、以及最终跑通的方案完整摊开。
先说清楚 Status Deck 是什么:它是一块基于 ESP32-S3 的桌面状态显示终端,通过 BLE 接收来自手机或电脑的状态数据,在本地屏幕上渲染出当前的工作状态、消息提醒、环境信息等内容。核心链路是“数据源 → BLE 传输 → ESP32 解析 → 屏幕渲染”。听起来简单,但每一段都有选型空间,而选型一旦定错,后面改起来就是伤筋动骨。
这篇文章适合已经上手过 ESP32、对 BLE 有基本概念、准备自己做一个类似项目的朋友。如果你还在纠结要不要买开发板,建议先看第一篇硬件选型的内容。这里默认你手里已经有一块 ESP32-S3 开发板,屏幕也接好了,接下来要解决的是软件层面的架构问题。
关键词里出现了 ESP32-S3、BLE、GATT、JSON、技术栈选型这几个词,正好对应本文的几条主线。我会围绕这几个点展开,但不会按教科书顺序讲,而是按照实际项目推进的顺序来:先定通信协议,再定数据格式,然后定固件框架,最后定渲染方案。每一步都会解释为什么这么选,以及不这么选会怎样。
2. 通信层选型:为什么 BLE 是唯一合理的选择
2.1 Wi-Fi 方案被排除的真实原因
很多人第一反应是用 Wi-Fi,毕竟 ESP32-S3 自带 Wi-Fi,走 HTTP 或 WebSocket 看起来更简单。我一开始也是这么想的,甚至已经把 HTTP 服务端的代码写了一半。但实际测试下来,Wi-Fi 方案在 Status Deck 这个场景里有三个硬伤。
第一个是功耗。Status Deck 是桌面常驻设备,虽然可以插电,但 Wi-Fi 保持长连接的平均功耗在 80mA 到 120mA 之间,而 BLE 广播加连接间隔优化后可以压到 15mA 以下。别小看这个差距,它直接决定了你能不能用一个小的锂电池做断电续命,也决定了设备发热量。桌面设备发热这件事,夏天摸上去烫手是很影响体验的。
第二个是配网复杂度。Wi-Fi 需要 SSID 和密码,要么做 AP 配网模式,要么做蓝牙配网再切 Wi-Fi,无论哪种都多了一层交互。而 BLE 不需要配网,设备广播出来,手机连上就能通信。对于 Status Deck 这种“拿起来就用”的定位,少一步配置就是少一个劝退点。
第三个是连接稳定性。Wi-Fi 在 2.4G 频段拥挤的环境下,断连重连是家常便饭。而 BLE 的连接参数可以调,配合合理的重连策略,实际用下来反而更稳。这里说的稳不是理论值,是我在办公室十几台设备同时开着的环境下实测的。
注意:如果你的 Status Deck 需要传输大量数据(比如图片或音频流),Wi-Fi 仍然是更好的选择。BLE 的带宽在 1M PHY 下理论 1Mbps,实际有效吞吐大概 100 到 200kbps,传状态数据绰绰有余,传大文件就不够了。
2.2 GATT 协议的角色划分与特征设计
定了 BLE 之后,下一个问题是 GATT 怎么设计。GATT 是 BLE 的属性协议,简单说就是把数据组织成“服务(Service)”和“特征(Characteristic)”。Status Deck 作为外设(Peripheral),手机或电脑作为中心设备(Central),数据从 Central 写到 Peripheral 的特征里。
我见过两种设计思路。一种是“一个特征走天下”,所有数据都塞进一个特征,用 JSON 区分类型。另一种是“按功能拆特征”,状态一个、消息一个、配置一个。我最终选了后者,原因是调试方便。用 nRF Connect 这类工具连上去,一眼就能看到每个特征的值,不用在一大坨 JSON 里找字段。
具体设计是这样的:
| 特征名称 | UUID 后缀 | 属性 | 用途 |
|---|---|---|---|
| Status | 0x2A01 | Read/Notify | 当前状态文本 |
| Message | 0x2A02 | Write | 推送消息内容 |
| Config | 0x2A03 | Read/Write | 亮度、刷新率等配置 |
| Command | 0x2A04 | Write | 控制指令 |
UUID 我用的是自定义 128 位 UUID,基于一个固定的基础 UUID 加后缀生成。这里有个小技巧:基础 UUID 不要用网上随便找的,自己生成一个,避免和别人冲突。生成之后写死在固件和客户端两边,不要每次编译都变。
2.3 连接参数调优:从“能用”到“好用”
BLE 的连接参数直接决定了功耗和响应速度的平衡。关键参数有三个:连接间隔(Connection Interval)、从机延迟(Slave Latency)、监督超时(Supervision Timeout)。
连接间隔决定了 Central 和 Peripheral 多久通信一次。间隔越小响应越快但功耗越高。我实测下来,对于 Status Deck 这种场景,连接间隔设在 30ms 到 50ms 之间比较合适。低于 30ms 功耗上升明显,高于 50ms 操作反馈会有可感知的延迟。
从机延迟允许 Peripheral 跳过若干次连接事件,进一步省电。我设的是 4,意思是如果没数据要发,Peripheral 可以跳过 4 个连接事件再响应。监督超时是判断连接丢失的时间,一般设为连接间隔的 10 倍以上,我设的是 500ms。
这些参数不是拍脑袋定的,是在实际使用中反复调整的结果。你可以从保守值开始(比如间隔 50ms、延迟 0、超时 1000ms),然后根据实际体验逐步优化。
3. 数据格式之争:JSON 在 BLE 场景下的取舍
3.1 为什么最终选了 JSON
数据格式这件事我纠结了很久。候选方案有三个:纯文本、二进制结构体、JSON。纯文本最简单但扩展性差,二进制最省带宽但调试痛苦,JSON 居中。
最终选 JSON 的原因很实际:调试成本低。BLE 调试本身就不如网络调试方便,如果数据还是二进制,每次都要对着文档解析字节,效率太低。JSON 可以直接在 nRF Connect 里看到可读的内容,出问题一眼就能定位。
但 JSON 在 BLE 场景下有个绕不开的问题:体积。一个简单的状态更新,JSON 格式大概要 80 到 120 字节,而二进制可能只要 20 字节。BLE 的 MTU 默认是 23 字节,即使协商到 247 字节,一次能传的数据也有限。如果 JSON 太大,就要分片传输,复杂度上升。
我的解决方案是控制 JSON 的粒度。每个特征只传自己需要的最小数据集,不做大而全的 JSON。比如 Status 特征只传{"s":"working","t":3600},s 是状态,t 是持续时间。字段名尽量短,但不要短到看不懂。这种“精简 JSON”的策略在实际使用中效果很好,单次传输基本不会超过 MTU。
3.2 JSON 解析在 ESP32 上的性能实测
ESP32-S3 跑 JSON 解析,性能是够的,但要看用什么库。我试过三个:ArduinoJson、cJSON、jansson。
ArduinoJson 是最常用的,API 友好,文档全。但在 ESP32 上有个坑:默认配置下它会在堆上分配内存,频繁解析小 JSON 会造成内存碎片。解决办法是用StaticJsonDocument而不是DynamicJsonDocument,把内存分配在栈上。对于 Status Deck 这种 JSON 结构固定的场景,静态文档完全够用。
cJSON 是纯 C 的,更轻量,但 API 比较原始,用起来没那么顺手。jansson 功能全但体积大,在 ESP32 上有点重。
最终我选了 ArduinoJson,配合静态文档。实测解析一个 100 字节左右的 JSON,耗时在 1ms 以内,对 Status Deck 的刷新率来说完全无感。
// 静态 JSON 文档示例 StaticJsonDocument<256> doc; DeserializationError err = deserializeJson(doc, incomingData); if (err) { Serial.print("JSON parse failed: "); Serial.println(err.c_str()); return; } const char* status = doc["s"] | "unknown"; int duration = doc["t"] | 0;这里有个细节:doc["s"] | "unknown"这种写法是 ArduinoJson 的默认值语法,如果字段不存在就返回默认值。这个特性在协议兼容性上很有用,客户端加了新字段,固件不认识也不会崩。
3.3 字段命名与版本兼容的实战经验
JSON 字段命名看起来是小事,但项目做久了就知道,字段一旦定下来再改就是灾难。我的经验是:字段名用短名,但要在代码里用常量定义,不要到处写字符串字面量。
namespace JsonKeys { constexpr const char* STATUS = "s"; constexpr const char* DURATION = "t"; constexpr const char* MESSAGE = "m"; constexpr const char* BRIGHTNESS = "b"; }这样改字段名只需要改一处。另外,建议在 JSON 里加一个版本字段v,虽然现在用不上,但将来协议升级时能救命。版本号从 1 开始,每次不兼容的修改就加一。
还有一个坑:JSON 里的数字类型。ArduinoJson 默认把整数解析成int或long,浮点数解析成float或double。如果你的数据里有时间戳这种大数,要注意用long或long long,不然会溢出。我一开始用int存毫秒时间戳,跑了 24 天就溢出了,排查了半天才发现。
4. 固件框架:Arduino 还是 ESP-IDF
4.1 两种框架的真实差异
这个问题几乎每个 ESP32 项目都会遇到。Arduino 上手快,库多,社区大。ESP-IDF 更底层,控制力强,但学习曲线陡。Status Deck 我两个都试过,最终选了 Arduino 框架,但理由可能和你想的不一样。
选 Arduino 不是因为简单,而是因为 BLE 库的成熟度。ESP32 的 Arduino 核心自带 BLE 库,封装得比较好,GATT 服务端和客户端的示例都很全。ESP-IDF 的 BLE 栈更底层,功能更全,但配置项多到让人头皮发麻。对于 Status Deck 这种 BLE 用法比较标准的场景,Arduino 的封装刚好够用。
但 Arduino 框架有个问题:它把很多东西藏起来了,出问题时不好排查。比如 BLE 连接不稳定,Arduino 层面只能看到“连上了”或“断开了”,看不到底层的断开原因。这时候就需要用 ESP-IDF 的日志系统来辅助调试。
我的做法是:主体用 Arduino 框架,但在关键位置调用 ESP-IDF 的 API 来获取底层信息。比如注册 GAP 事件回调,能看到具体的断开原因码。
// 在 Arduino 环境下访问 ESP-IDF 的 GAP 事件 esp_ble_gap_register_callback([](esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t* param) { if (event == ESP_GAP_BLE_DISCONNECT_COMPLETE_EVT) { Serial.printf("Disconnect reason: 0x%02x\n", param->disconnect_cmpl.reason); } });这个混合用法在 Arduino 框架下是合法的,因为 Arduino 核心本身就是构建在 ESP-IDF 之上的。你可以在需要的时候直接调 IDF 的 API,不需要完全切换框架。
4.2 任务划分与 FreeRTOS 的使用边界
ESP32-S3 是双核的,跑 FreeRTOS。Status Deck 的固件里我分了三个主要任务:BLE 通信任务、渲染任务、系统监控任务。
BLE 通信任务跑在核心 0,优先级较高,负责处理 GATT 读写和连接管理。渲染任务跑在核心 1,优先级中等,负责刷新屏幕。系统监控任务优先级最低,负责看门狗喂狗、内存监控、日志输出。
任务之间用队列通信。BLE 任务收到数据后,解析成结构体,丢进渲染队列。渲染任务从队列取数据,更新屏幕。这样解耦的好处是 BLE 的抖动不会影响渲染的流畅度,渲染的耗时也不会阻塞 BLE 响应。
但 FreeRTOS 用多了也有坑。最常见的是栈溢出。Arduino 环境下默认任务栈大小是 8192 字节,对于 JSON 解析加屏幕刷新来说有时候不够。我遇到过渲染任务栈溢出导致重启的情况,排查方法是打印每个任务的剩余栈空间。
Serial.printf("Render task stack high water mark: %d\n", uxTaskGetStackHighWaterMark(NULL));这个值表示任务运行过程中栈使用的峰值离栈顶还有多少字节。如果小于 1024,就说明栈快满了,需要加大栈大小。我最后把渲染任务的栈加到了 16384,问题解决。
4.3 OTA 升级要不要做,什么时候做
OTA 就是空中升级,通过 BLE 或 Wi-Fi 给设备更新固件。Status Deck 我一开始没做 OTA,觉得插着 USB 升级也挺方便。但设备装进外壳、放在桌面上之后,每次升级都要拔下来插电脑,体验很差。所以第二版加了 OTA。
BLE OTA 的实现思路是:把固件二进制通过 BLE 分片传输到设备,设备写入 OTA 分区,然后重启切换分区。ESP32 的 Arduino 核心自带Update库,配合 BLE 特征就能实现。
但 BLE OTA 有个现实问题:慢。一个 1MB 的固件,通过 BLE 传输大概要 2 到 3 分钟。这期间设备不能做别的事,屏幕会卡住。我的做法是 OTA 期间显示进度条,让用户知道在升级,不要断电。
如果你不想折腾 BLE OTA,也可以做 Wi-Fi OTA,速度快很多,但需要设备连 Wi-Fi。对于 Status Deck 这种平时不连 Wi-Fi 的设备,BLE OTA 是更自然的选择。
5. 渲染方案:从“能显示”到“看着舒服”
5.1 屏幕驱动库的选择与取舍
Status Deck 用的是一块 1.14 寸的 IPS 屏,ST7789 驱动,240x135 分辨率。驱动库我试过 TFT_eSPI 和 LovyanGFX。
TFT_eSPI 是老牌库,稳定,资料多。但配置方式比较原始,要改头文件里的宏定义来适配屏幕。LovyanGFX 是后起之秀,配置更灵活,支持运行时配置,而且性能更好。
我最终选了 LovyanGFX,主要原因是它的配置可以写在代码里,不用改库文件。这样项目迁移或者换屏幕的时候,只需要改配置结构体,不用去动库的源码。
// LovyanGFX 配置示例 class LGFX : public lgfx::LGFX_Device { lgfx::Panel_ST7789 _panel; lgfx::Bus_SPI _bus; public: LGFX() { { auto cfg = _bus.config(); cfg.spi_host = SPI2_HOST; cfg.spi_mode = 0; cfg.freq_write = 40000000; cfg.pin_sclk = 12; cfg.pin_mosi = 11; cfg.pin_miso = -1; cfg.pin_dc = 10; _bus.config(cfg); _panel.setBus(&_bus); } { auto cfg = _panel.config(); cfg.pin_cs = 9; cfg.pin_rst = 8; cfg.pin_busy = -1; cfg.panel_width = 135; cfg.panel_height = 240; cfg.offset_x = 52; cfg.offset_y = 40; cfg.invert = true; _panel.config(cfg); } setPanel(&_panel); } };这段配置里的offset_x和offset_y是 ST7789 的经典坑。240x135 的屏实际显存是 240x320,可视区域在中间,所以要偏移。不同厂家的屏偏移量可能不一样,买屏的时候要确认,或者自己试。
5.2 局部刷新与全屏刷新的性能差异
屏幕刷新是 Status Deck 里最耗时的操作之一。全屏刷新 240x135 的 16 位色数据,通过 SPI 在 40MHz 下大概要 15 到 20ms。如果每秒刷新 10 次,就是 150 到 200ms 的 CPU 时间被占用,对 BLE 响应有影响。
解决办法是局部刷新。只刷新变化的部分,比如时间数字变了就只刷时间区域。LovyanGFX 支持setClipRect来限定刷新区域,配合pushImage可以只更新指定矩形。
但局部刷新有个前提:你要知道哪里变了。我的做法是维护一个“脏矩形”列表,渲染逻辑更新数据时标记哪些区域需要重绘,渲染任务只重绘脏区域。对于 Status Deck 这种大部分区域静态、少部分动态的场景,局部刷新能把刷新耗时降到 2 到 5ms。
5.3 字体与图标资源的处理方式
Status Deck 上要显示文字和图标。字体我用的 LovyanGFX 自带的 GFX 字体,够用但不够好看。如果要自定义字体,可以用在线工具把 TTF 转成 C 数组,然后编译进固件。
图标我一开始用位图数组,每个图标一个数组,代码里直接引用。但图标多了之后固件体积涨得很快。后来改成用 SPIFFS 文件系统存图标,需要的时候从文件系统读。这样固件体积小了,图标也可以随时替换。
但 SPIFFS 读取速度比直接访问数组慢,对于频繁刷新的图标会有闪烁。我的折中方案是:常用图标编译进固件,不常用的放 SPIFFS。常用图标就那么几个,占不了多少空间。
6. 项目实施中的那些“没想到”
6.1 BLE 连接在手机端的兼容性差异
BLE 在理论上是标准协议,但实际用下来,不同手机、不同系统的行为差异很大。我测试了 Android 和 iOS 的几台设备,发现几个规律。
Android 的 BLE 扫描和连接行为比较“激进”,扫描间隔短,连接速度快,但有时候会重复连接。iOS 相对保守,扫描慢一些,但连接更稳定。另外,Android 不同厂商的省电策略也会影响 BLE,有些手机会在息屏后限制 BLE 活动。
应对策略是在固件端做连接管理:只允许一个 Central 连接,新连接进来时踢掉旧的。同时做好断连重连的逻辑,连接断开后自动重新广播,等待重连。
还有一个坑:MTU 协商。Android 默认请求 MTU 517,iOS 默认 185,而 ESP32 默认 23。如果不主动协商,实际 MTU 就是 23,传 JSON 会分片。解决办法是在连接建立后主动请求 MTU 协商,ESP32 端调esp_ble_gatt_set_local_mtu(247),客户端也请求大 MTU。
6.2 内存碎片:一个慢慢浮现的问题
ESP32-S3 有 512KB SRAM,看起来不少,但跑久了会出现内存碎片。表现是:刚启动时一切正常,跑几个小时或几天后,分配内存失败,设备重启。
排查这个问题花了我不少时间。最后定位到两个原因:一是 JSON 解析用了动态分配,二是 BLE 栈本身也会分配释放内存。两者交替进行,堆上就出现了碎片。
解决办法前面提过:JSON 用静态文档。另外,BLE 相关的缓冲区尽量复用,不要频繁创建销毁。还有一个小技巧:定期打印堆的空闲内存和最大连续块大小,监控碎片情况。
Serial.printf("Free heap: %d, Max alloc: %d\n", esp_get_free_heap_size(), heap_caps_get_largest_free_block(MALLOC_CAP_8BIT));如果最大连续块远小于空闲内存,就说明碎片严重了。这时候可以考虑主动重启,或者优化内存分配策略。
6.3 看门狗与异常恢复的设计
Status Deck 是常驻设备,稳定性很重要。我加了看门狗,但看门狗本身也会带来问题:如果某个任务卡住,看门狗重启设备,用户看到的是屏幕突然黑了又亮,体验不好。
我的做法是分级处理。先做任务级监控,如果某个任务长时间没响应,先尝试重启该任务。如果重启失败,再触发系统级看门狗重启。同时,重启前把当前状态存到 RTC 内存,重启后恢复,这样用户感知不到中断。
RTC 内存是 ESP32 在深睡眠和重启时不会丢失的一块小内存,大概 8KB。存一些关键状态足够了。
RTC_DATA_ATTR int bootCount = 0; RTC_DATA_ATTR char lastStatus[32] = {0};这两个变量在重启后仍然保留,可以用来做状态恢复和重启计数。如果重启太频繁,可以在启动时判断并进入安全模式,比如只跑最小系统,等待人工干预。
7. 技术栈定下来之后,项目才真正开始
回头看 Status Deck 的技术栈选型,核心决策其实就几个:BLE 而不是 Wi-Fi,JSON 而不是二进制,Arduino 而不是纯 IDF,LovyanGFX 而不是 TFT_eSPI。每个决策都有代价,也都有收益。重要的不是选“最好”的,而是选“最适合当前阶段”的。
我在这个项目里最大的体会是:不要过早优化。一开始我想把什么都做完美,协议要最省带宽,代码要最优雅,结果进度卡住。后来改成先跑通,再优化,反而顺利很多。JSON 体积大就先大着,跑通了再精简。局部刷新麻烦就先全屏刷,卡了再优化。
还有一个经验:多准备几块开发板。调试 BLE 的时候,一块做 Peripheral,一块做 Central,比用手机调试方便得多。手机端的 BLE 调试工具虽然多,但可控性不如自己写一个简单的 Central 端。
最后说一个实际使用中的小技巧:Status Deck 的屏幕亮度在晚上会刺眼,我加了一个根据时间自动调亮度的逻辑。这个逻辑不复杂,但体验提升很明显。类似的小细节还有很多,都是在实际用起来之后才发现的。技术栈选型解决的是“能不能跑”,而这些细节解决的是“好不好用”。两者都重要,但顺序不能反。