☰
ESP32-S3全栈自造桌面状态屏:MicroPython+BLE+JSON技术选型与实操
2026/11/4 6:02:27 网站建设 项目流程

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 + JSON

2.1 主控芯片为什么选 ESP32-S3

ESP32 系列在国内嵌入式圈子的普及程度不用多说,资料多、价格低、供货稳。那为什么具体选 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 框架还是 MicroPython,S3 的板级支持包都很完善,踩坑的概率低。

注意: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,不需要额外配置网络。
  • 场景 B:Status 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 数据线(注意是数据线,不是纯充电线)
  • 电脑上安装esptool(pip 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.bin

Windows 下端口号是COMx,macOS 下是/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, name="StatusDeck"): 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, baudrate=40000000, polarity=0, phase=0) self.lcd = st7789.ST7789(self.spi, 135, 240, reset=Pin(5), dc=Pin(4), cs=Pin(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(f"CPU: {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 显示的完整流程:

  1. 手机端打开 BLE 调试 App(比如 nRF Connect 或 LightBlue)
  2. 扫描设备,找到名为 "StatusDeck" 的设备
  3. 连接后,找到自定义 Service 和 RX Characteristic
  4. 向 RX Characteristic 写入 JSON 字符串,比如{"cpu":45,"mem":62,"net":12}
  5. 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(b"GET /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 b"GET /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 运行时堆内存约 100KB,JSON 解析临时占用约 2KB,屏幕缓冲区 1352402 = 64.8KB。总计约 170KB,S3 的 512KB SRAM 完全够用。

注意:如果开启 PSRAM,MicroPython 的堆内存可以扩展到几 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"

这个错误通常是因为收到的数据不完整或包含非法字符。排查步骤:

  1. 打印原始字节,看是否包含不可见字符(如\x00)
  2. 检查发送端是否在 JSON 前后加了换行或空格
  3. 确认 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 末尾加一个特殊标记(比如\n),ESP32 端收到标记后才开始解析。这样比判断}更可靠,因为 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 线的时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询