
1. 项目缘起与整体设计思路Status Deck 这个项目最初的出发点其实很朴素我想在桌面上放一个能实时显示各种状态信息的小屏幕比如当前电脑的 CPU 占用、内存使用率、网络延迟、待办事项数量甚至是一些自定义的提醒。市面上成品不少但要么功能固定不可定制要么价格虚高要么数据要经过第三方服务器隐私和延迟都不可控。既然自己有一点嵌入式开发的基础干脆全栈自造一个。这个系列的第一篇我聊了需求拆解和硬件选型的大方向第二篇重点落在技术栈选型和项目实施上。为什么技术栈选型值得单独拿出来讲因为在一个全栈自造项目里选型决策的影响是贯穿始终的——它决定了你后面写代码的顺手程度、调试的难易、功能扩展的天花板以及整个项目能不能在合理的时间内跑通。我见过太多人卡在选型阶段反复横跳最后项目烂尾。这个项目的核心链路是这样的ESP32-S3 作为主控通过 BLE低功耗蓝牙与手机或电脑通信接收 JSON 格式的状态数据解析后驱动屏幕刷新。同时 ESP32-S3 也可以连接 WiFi通过 TCP 或 HTTP 从局域网内的服务端拉取数据。整个系统涉及嵌入式端固件开发、通信协议BLE GATT、TCP、HTTP、数据格式JSON、以及上位机或服务端的配合。适合谁来参考如果你有基本的编程概念玩过 Arduino 或 MicroPython想做一个完整的、能落地的全栈小项目那这篇内容会对你有直接帮助。如果你是完全零基础建议先补一下 ESP32 的基本开发流程和 Python 语法不然中间的一些操作会有点吃力。选型这件事我的原则是优先选生态成熟、社区活跃、文档齐全的方案其次考虑性能最后才考虑“酷不酷”。因为全栈项目涉及的面太广任何一个环节卡住都会拖慢整体进度成熟的生态能让你在遇到问题时快速找到答案。2. 技术栈选型为什么是 ESP32-S3 MicroPython BLE JSON2.1 主控芯片为什么选 ESP32-S3ESP32 系列在国内嵌入式圈子的普及程度不用多说资料多、价格低、供货稳。那为什么具体选 S3 而不是经典的 ESP32 或者 C3几个关键原因第一ESP32-S3 是双核 Xtensa LX7主频 240MHz算力对于驱动一块小屏幕、解析 JSON、跑 BLE 协议栈来说绰绰有余。我实测过在 S3 上同时跑 BLE 服务和屏幕刷新帧率稳定没有明显卡顿。第二S3 自带 USB OTG 支持这意味着你可以直接用 USB 线做固件烧录和串口调试不需要额外的 USB-TTL 转换模块。对于桌面小项目来说少一根线少一个模块体验提升很明显。第三S3 的 GPIO 数量和 PSRAM 选项更灵活。如果你后续想加传感器、加按键、换更大的屏幕S3 的扩展余量比 C3 大得多。我选的是带 8MB PSRAM 的模组虽然当前项目用不满但留了余量心里踏实。第四社区对 S3 的支持已经非常成熟。无论是 Arduino 框架还是 MicroPythonS3 的板级支持包都很完善踩坑的概率低。注意ESP32-S3 有不同的模组版本比如带不带 PSRAM、Flash 大小不同买的时候看清楚参数。如果只是做这个项目4MB Flash 无 PSRAM 的版本也够用但如果你打算后续扩展建议直接上 8MB Flash 8MB PSRAM 的配置。2.2 开发框架为什么选 MicroPython 而不是 Arduino 或 ESP-IDF这是选型里争议最大的一个点。三种主流方案各有优劣我列个表对比一下方案优势劣势适合场景Arduino (C)生态最大库最多性能好代码结构松散字符串处理麻烦JSON 解析库占用大对性能要求高、逻辑简单的场景ESP-IDF (C)官方框架性能最优控制最细学习曲线陡开发效率低调试周期长商业产品、对稳定性要求极高的场景MicroPython开发效率极高语法简洁交互式调试性能弱于 C内存占用相对高部分底层功能受限快速原型、个人项目、逻辑较复杂的场景我最终选 MicroPython核心理由是开发效率。这个项目的逻辑复杂度主要在数据解析、状态管理和通信协议处理上这些用 Python 写比 C 写快太多了。JSON 解析在 Python 里就是json.loads()一行的事在 C 里你得引入 cJSON 库处理内存分配和释放代码量翻好几倍。性能方面MicroPython 在 S3 上跑 BLE 和屏幕刷新完全够用。我实测屏幕刷新率能到 30fps 以上BLE 数据接收延迟在几十毫秒级别对于状态显示这种场景绰绰有余。内存方面MicroPython 固件本身占用大约 1.5MB Flash运行时堆内存可以通过gc模块管理。S3 的 512KB SRAM 加上 PSRAM跑这个项目很宽裕。实操心得MicroPython 的固件版本建议选最新的稳定版老版本对 S3 的 BLE 支持可能有 bug。我一开始用的是 1.20 版本BLE 连接偶尔会断升级到 1.22 之后稳定了很多。2.3 通信协议为什么选 BLE GATT JSON通信协议的选择取决于你的数据源在哪里。我的使用场景有两种场景 A手机或电脑作为数据源主动推送状态数据到 Status Deck。这种情况下 BLE 最合适因为手机和电脑都原生支持 BLE不需要额外配置网络。场景 BStatus Deck 主动从局域网内的服务端拉取数据。这种情况下 WiFi TCP/HTTP 更合适。我两个都实现了但主力用 BLE因为更方便。BLE 的 GATT 协议是标准化的手机端可以用现成的 BLE 调试 App 发送数据电脑端可以用 Python 的bleak库。为什么数据格式选 JSON因为通用、可读、解析方便。JSON 在 MicroPython 里有内置的json模块解析和生成都是一行代码。而且 JSON 的结构灵活你可以随时加字段而不用改协议。比如一开始我只传 CPU 和内存后来想加一个天气信息直接在 JSON 里加个字段就行ESP32 端解析时判断字段是否存在即可。GATT 的 UUID 设计也有讲究。我用了一个自定义的 Service UUID 和两个 Characteristic一个用于接收数据Write一个用于发送确认或状态Notify。UUID 我用的是一组随机生成的 128 位 UUID避免和标准服务冲突。# BLE GATT UUID 定义示例 SERVICE_UUID 6E400001-B5A3-F393-E0A9-E50E24DCCA9E CHAR_RX_UUID 6E400002-B5A3-F393-E0A9-E50E24DCCA9E # 接收数据 CHAR_TX_UUID 6E400003-B5A3-F393-E0A9-E50E24DCCA9E # 发送通知注意UUID 不要用标准服务的 UUID否则可能和系统服务冲突。用在线的 UUID 生成器随机生成一组就行确保唯一性。2.4 屏幕和驱动选型屏幕我选的是 1.14 寸 IPS 液晶屏ST7789 驱动240x135 分辨率SPI 接口。选它的原因尺寸小巧适合桌面摆放SPI 接口占用 GPIO 少ST7789 驱动在 MicroPython 里有成熟的库。驱动库我用的是st7789的 MicroPython 移植版配合framebuf做图形绘制。字体方面内置字体太小我单独生成了中文字库用font模块加载。这里有个坑中文字库文件比较大如果 Flash 空间紧张建议只提取常用字。接线方面SPI 屏幕需要 CS、DC、RST、SCLK、MOSI 五根线加上电源和地一共七根。ESP32-S3 的 SPI 引脚可以灵活映射我用的默认硬件 SPI 引脚性能最稳。3. 核心细节解析与实操要点3.1 MicroPython 环境搭建与固件烧录第一步是烧录 MicroPython 固件。你需要准备ESP32-S3 开发板USB 数据线注意是数据线不是纯充电线电脑上安装esptoolpip install esptool下载对应 S3 的 MicroPython 固件.bin文件烧录命令如下# 擦除 Flash esptool.py --chip esp32s3 --port /dev/ttyUSB0 erase_flash # 烧录固件 esptool.py --chip esp32s3 --port /dev/ttyUSB0 --baud 460800 write_flash -z 0x0 firmware.binWindows 下端口号是COMxmacOS 下是/dev/tty.usbserial-xxx或/dev/tty.wchusbserialxxx。如果找不到端口检查驱动是否安装CH340 或 CP2102 驱动。烧录完成后用串口工具比如mpremote或PuTTY连接应该能看到 MicroPython 的 REPL 提示符。# 用 mpremote 连接 mpremote connect /dev/ttyUSB0实操心得烧录时如果报错 Failed to connect按住开发板上的 BOOT 键再点一下 RST 键进入下载模式再试。这是 ESP32 系列的经典操作新手很容易卡在这里。3.2 BLE GATT 服务的实现细节MicroPython 的bluetooth模块提供了 BLE 支持。实现一个 GATT 服务的基本流程import bluetooth from ble_advertising import advertising_payload class BLEService: def __init__(self, nameStatusDeck): self.ble bluetooth.BLE() self.ble.active(True) self.ble.irq(self._irq_handler) self._connections set() self._register_services() self._advertise(name) def _register_services(self): # 定义 Service 和 Characteristic SERVICE_UUID bluetooth.UUID(6E400001-B5A3-F393-E0A9-E50E24DCCA9E) CHAR_RX (bluetooth.UUID(6E400002-B5A3-F393-E0A9-E50E24DCCA9E), bluetooth.FLAG_WRITE | bluetooth.FLAG_WRITE_NO_RESPONSE) CHAR_TX (bluetooth.UUID(6E400003-B5A3-F393-E0A9-E50E24DCCA9E), bluetooth.FLAG_NOTIFY) SERVICE (SERVICE_UUID, (CHAR_RX, CHAR_TX)) ((self.rx_handle, self.tx_handle),) self.ble.gatts_register_services((SERVICE,)) def _irq_handler(self, event, data): if event 1: # 连接事件 conn_handle, _, _ data self._connections.add(conn_handle) elif event 2: # 断开事件 conn_handle, _, _ data self._connections.discard(conn_handle) self._advertise(StatusDeck) elif event 3: # 收到数据 conn_handle, value_handle data if value_handle self.rx_handle: data self.ble.gatts_read(self.rx_handle) self._handle_data(data)这段代码的关键点gatts_register_services返回的是 handle 元组顺序和定义顺序一致。收到数据的事件是 event 3需要判断 value_handle 是否匹配。断开连接后要重新广播否则设备无法再次被发现。注意BLE 单次传输的数据量有限默认 MTU 是 23 字节实际可用载荷约 20 字节。如果 JSON 数据超过这个长度需要分包发送或者在 ESP32 端做缓冲拼接。我建议在手机端发送时就把 JSON 压缩去掉不必要的空格。3.3 JSON 数据的解析与容错处理JSON 解析在 MicroPython 里很简单但实际使用中会遇到各种异常情况必须做好容错。import json def parse_status_data(raw_bytes): try: text raw_bytes.decode(utf-8).strip() data json.loads(text) # 校验必要字段 if cpu not in data and mem not in data: return None return data except (ValueError, UnicodeDecodeError) as e: print(JSON parse error:, e) return None常见的异常情况数据不完整BLE 分包导致编码错误发送端用了非 UTF-8 编码JSON 格式错误多了逗号、少了引号字段缺失发送端版本不一致我的处理策略是解析失败时返回 None主循环跳过这次更新屏幕保持上一次的数据。同时通过串口打印错误信息方便调试。实操心得在开发阶段建议在 JSON 里加一个seq字段序列号每次发送递增。ESP32 端记录上一次的 seq如果发现 seq 跳跃说明有丢包可以在屏幕上显示一个警告图标。这个技巧在调试 BLE 稳定性时非常有用。3.4 屏幕刷新与 UI 布局设计屏幕刷新我用的是双缓冲策略先在framebuf里绘制完整画面再一次性推送到屏幕。这样可以避免闪烁。import st7789 import framebuf class Display: def __init__(self): self.spi SPI(1, baudrate40000000, polarity0, phase0) self.lcd st7789.ST7789(self.spi, 135, 240, resetPin(5), dcPin(4), csPin(6)) self.lcd.init() self.buf bytearray(135 * 240 * 2) self.fb framebuf.FrameBuffer(self.buf, 135, 240, framebuf.RGB565) def render(self, data): self.fb.fill(0x0000) # 清屏 # 绘制标题 self.fb.text(STATUS, 10, 10, 0xFFFF) # 绘制 CPU 使用率 cpu data.get(cpu, 0) self.fb.text(fCPU: {cpu}%, 10, 40, 0x07E0) # 绘制进度条 bar_width int(cpu / 100 * 100) self.fb.fill_rect(10, 60, bar_width, 10, 0x07E0) self.fb.rect(10, 60, 100, 10, 0xFFFF) # 推送显示 self.lcd.blit_buffer(self.buf, 0, 0, 135, 240)UI 布局的原则信息层级清晰关键数据用大字体或颜色突出次要信息用小字体。我分了三个区域顶部标题栏、中部核心数据区、底部状态栏。注意blit_buffer的坐标参数是 (x, y, width, height)但 ST7789 的坐标系可能和屏幕物理方向不一致需要根据实际显示效果调整旋转参数。我一开始显示是镜像的调了rotation参数才正常。4. 实操过程与核心环节实现4.1 项目文件结构与代码组织MicroPython 的文件系统是扁平的没有目录概念部分版本支持目录但兼容性一般。我的文件组织如下/boot.py # 启动脚本初始化硬件 /main.py # 主循环 /ble_service.py # BLE 服务封装 /display.py # 屏幕驱动封装 /status_parser.py # JSON 解析和状态管理 /config.py # 配置参数UUID、引脚等boot.py在每次上电时执行负责初始化外设。main.py在boot.py之后执行是主程序入口。# boot.py import gc import machine # 初始化硬件 machine.freq(240000000) # 设置 CPU 频率为 240MHz gc.collect() # 回收内存 print(Boot complete)# main.py import time from ble_service import BLEService from display import Display from status_parser import parse_status_data display Display() ble BLEService() last_data {cpu: 0, mem: 0, net: 0} while True: # 检查是否有新数据 raw ble.get_received() if raw: data parse_status_data(raw) if data: last_data.update(data) display.render(last_data) time.sleep_ms(100)实操心得MicroPython 的main.py里不要写死循环里的time.sleep()太长否则 BLE 中断响应会延迟。我一开始用了time.sleep(1)结果 BLE 连接经常超时断开。改成time.sleep_ms(100)后稳定了。4.2 BLE 数据接收的完整流程从手机端发送数据到 ESP32 显示的完整流程手机端打开 BLE 调试 App比如 nRF Connect 或 LightBlue扫描设备找到名为 StatusDeck 的设备连接后找到自定义 Service 和 RX Characteristic向 RX Characteristic 写入 JSON 字符串比如{cpu:45,mem:62,net:12}ESP32 收到数据解析后刷新屏幕电脑端可以用 Python 的bleak库import asyncio from bleak import BleakClient ADDRESS XX:XX:XX:XX:XX:XX CHAR_RX_UUID 6E400002-B5A3-F393-E0A9-E50E24DCCA9E async def send_status(): async with BleakClient(ADDRESS) as client: data {cpu:45,mem:62,net:12} await client.write_gatt_char(CHAR_RX_UUID, data.encode()) print(Sent:, data) asyncio.run(send_status())注意BLE 地址在 macOS 和 Windows 上格式不同macOS 用的是 UUID 格式Windows 用的是 MAC 地址格式。bleak库会自动处理但你要确保传入的地址格式正确。4.3 WiFi TCP 模式的实现除了 BLE我还实现了 WiFi 模式让 ESP32 主动从局域网服务端拉取数据。这个模式适合数据源在电脑上、且不想每次手动推送的场景。import network import socket def connect_wifi(ssid, password): wlan network.WLAN(network.STA_IF) wlan.active(True) if not wlan.isconnected(): wlan.connect(ssid, password) while not wlan.isconnected(): time.sleep(1) print(WiFi connected:, wlan.ifconfig()) def fetch_data(host, port): try: addr socket.getaddrinfo(host, port)[0][-1] s socket.socket() s.settimeout(5) s.connect(addr) s.send(bGET /status\n) data s.recv(1024) s.close() return data except Exception as e: print(TCP error:, e) return None服务端可以用 Python 的socket模块快速搭一个import socket import json import psutil server socket.socket() server.bind((0.0.0.0, 8888)) server.listen(1) while True: conn, addr server.accept() request conn.recv(1024) if bGET /status in request: status { cpu: psutil.cpu_percent(), mem: psutil.virtual_memory().percent, net: 0 } conn.send(json.dumps(status).encode()) conn.close()实操心得WiFi 模式下ESP32 的功耗会明显高于 BLE 模式。如果是电池供电建议优先用 BLE。另外WiFi 连接失败时要加超时和重试机制否则程序会卡死在while not wlan.isconnected()循环里。4.4 参数计算与性能调优屏幕刷新率、BLE 连接间隔、数据发送频率这三个参数需要平衡。屏幕刷新率ST7789 的 SPI 时钟我设的是 40MHz全屏刷新一次约 16ms理论最高 60fps。但实际不需要那么高我设的是 10fps每 100ms 刷新一次肉眼看起来已经很流畅。BLE 连接间隔默认是 30ms我改成 50ms降低功耗。对于状态显示场景50ms 的延迟完全可接受。数据发送频率手机端每 500ms 发送一次数据ESP32 端每 100ms 检查一次缓冲区。这样即使有丢包下一次数据很快就能补上。内存占用方面MicroPython 运行时堆内存约 100KBJSON 解析临时占用约 2KB屏幕缓冲区 1352402 64.8KB。总计约 170KBS3 的 512KB SRAM 完全够用。注意如果开启 PSRAMMicroPython 的堆内存可以扩展到几 MB但 PSRAM 的访问速度比 SRAM 慢不建议把频繁访问的数据放在 PSRAM 里。屏幕缓冲区放 SRAM 就够了。5. 常见问题与排查技巧实录5.1 BLE 连接不稳定或频繁断开这是最常见的问题。排查思路现象可能原因解决方法连接后几秒断开主循环阻塞太久缩短time.sleep()改用sleep_ms(100)扫描不到设备广播未启动或已停止检查_advertise()是否在断开后重新调用连接后无法收发数据UUID 不匹配核对 Service 和 Characteristic UUID数据写入失败MTU 太小分包发送或压缩 JSON我的经验是90% 的 BLE 问题都出在主循环阻塞上。MicroPython 的 BLE 中断需要及时响应如果主循环里有耗时操作比如屏幕全屏刷新中断会被延迟导致连接超时。解决办法把屏幕刷新拆分成小块每次主循环只刷新一部分。或者用micropython.schedule()把耗时操作放到中断之外执行。5.2 JSON 解析报错 no start of json char found这个错误通常是因为收到的数据不完整或包含非法字符。排查步骤打印原始字节看是否包含不可见字符如\x00检查发送端是否在 JSON 前后加了换行或空格确认 BLE 分包是否完整拼接raw ble.get_received() print(Raw:, raw) # 先看原始数据如果数据是分包的需要在 ESP32 端做缓冲buffer b def on_data_received(data): global buffer buffer data if buffer.endswith(b}): # 假设 JSON 以 } 结尾 parse_status_data(buffer) buffer b实操心得在 JSON 末尾加一个特殊标记比如\nESP32 端收到标记后才开始解析。这样比判断}更可靠因为 JSON 内部也可能有}。5.3 屏幕显示花屏或颜色异常ST7789 花屏通常有几个原因SPI 时钟太快信号完整性差。降到 20MHz 试试。颜色格式不对。ST7789 支持 RGB565 和 RGB666确认framebuf的格式和屏幕初始化参数一致。电源不稳。屏幕背光电流较大如果 USB 供电不足会出现闪烁或花屏。建议单独给屏幕供 3.3V。我遇到过一次花屏排查了半天发现是杜邦线太长导致 SPI 信号衰减。换成短线后问题消失。5.4 WiFi 连接失败或 TCP 超时WiFi 问题的排查相对简单确认 SSID 和密码正确注意大小写和特殊字符确认路由器没有开启 MAC 地址过滤确认 ESP32 和路由器在同一网段TCP 连接超时设置合理我设的 5 秒如果 WiFi 连接成功但 TCP 连不上检查服务端防火墙是否放行了对应端口。Windows 防火墙默认会拦截入站连接需要手动添加规则。5.5 内存不足导致程序崩溃MicroPython 的内存管理比较粗糙长时间运行后可能出现MemoryError。解决办法定期调用gc.collect()避免在循环里创建大量临时对象用bytearray代替bytes拼接减少全局变量的使用import gc gc.collect() print(Free memory:, gc.mem_free())我实测下来在main.py的主循环里每 100 次迭代调用一次gc.collect()可以保持内存稳定。6. 项目扩展方向与个人体会这个项目跑通之后扩展空间其实很大。我目前想到的几个方向多屏幕支持。用 ESP32-S3 的多个 SPI 接口驱动多块屏幕分别显示不同类别的信息。比如一块显示系统状态一块显示日程提醒一块显示天气。传感器集成。加一个温湿度传感器比如 SHT30把环境数据也纳入状态显示。I2C 接口接线简单MicroPython 有现成库。语音提醒。加一个 I2S 功放模块当某个指标超过阈值时播放提示音。这个需要额外的音频文件存储和播放逻辑但 S3 的算力完全能撑住。Web 配置界面。在 ESP32 上跑一个轻量 HTTP 服务器通过浏览器配置 WiFi 密码、显示布局、刷新频率等参数。MicroPython 有microdot这样的轻量 Web 框架实现起来不难。数据记录与回传。把接收到的状态数据存到 SD 卡或 Flash 里定期回传到服务端做长期分析。这个适合想监控电脑长期运行状态的场景。我个人在实际操作中的体会是全栈自造项目最大的挑战不是某个单点技术而是把各个模块串起来并保持稳定。BLE 调通了屏幕可能花屏屏幕好了JSON 解析又出问题都好了内存又不够了。每一个环节都需要耐心调试。我的建议是先跑通最小闭环再逐步加功能。不要一上来就把所有功能都写进去那样出了问题很难定位。先让 BLE 能收到数据并打印到串口再加上屏幕显示再加上 WiFi 模式一步一步来。最后分享一个小技巧在开发阶段把 ESP32 的串口日志同时输出到屏幕的一个小角落这样不用连电脑就能看到运行状态。调试完成后把这个功能关掉就行。这个技巧帮我省了很多来回插拔 USB 线的时间。