1. 从一块裸板到能用的桌面小终端这个项目到底在做什么Status Deck 这个名字听起来挺唬人其实拆开看就是一块带屏幕的小硬件放在桌面上实时显示一些我关心的信息——比如电脑的 CPU 占用、当前时间、待办事项数量或者某个服务的运行状态。第一篇文章里我把硬件部分搭完了一块 ESP32-S3 开发板、一块 SPI 接口的 TFT 屏、几个按键焊好、点亮、能跑个 Hello World。到了这第二篇真正麻烦的部分来了技术栈怎么选项目怎么落地。很多人做嵌入式项目有个通病硬件调通之后就卡住了因为不知道上层软件该怎么组织。裸机写个 loop 当然能跑但一旦要处理屏幕刷新、按键响应、数据接收、状态管理这几件事同时发生裸机代码很快就会变成一团乱麻。我一开始也想过偷懒直接用 Arduino 框架堆代码但写到第三天就发现不行了——屏幕刷新的时候按键会丢响应数据接收的时候屏幕会闪这种互相阻塞的问题在单线程轮询模型里几乎无解。所以这个项目的核心技术决策就围绕一个问题展开在一块只有几百 KB 内存的 MCU 上怎么优雅地同时处理多个任务并且保持代码可维护。围绕这个核心我把技术栈拆成了几个层面来考虑运行环境选 MicroPython 还是 ESP-IDF 原生开发通信协议选 BLE 还是 WiFi数据格式选 JSON 还是二进制任务调度用 FreeRTOS 还是自己写状态机。每一个选择背后都有取舍没有绝对的对错只有适不适合当前这个场景。这篇文章适合两类人看。一类是已经玩过 ESP32 基础例程、想做一个完整小项目但不知道从何下手的嵌入式爱好者另一类是做惯了上层开发、想往硬件方向延伸的全栈开发者你们对 JSON、BLE 这些概念不陌生但对 MCU 上的资源约束可能还没什么体感。我会把每个技术选型的理由讲透把实操步骤写细把踩过的坑都摊开来说。2. 技术栈选型每个决定背后的取舍逻辑2.1 运行环境MicroPython 和 ESP-IDF 到底选哪个这是整个项目第一个要拍板的事也是影响后续所有开发体验的决定。ESP32-S3 这颗芯片有两个主流的开发路径一是乐鑫官方的 ESP-IDF基于 C 语言功能最全、性能最好、文档最完整二是 MicroPython用 Python 语法操作硬件开发速度快、代码量少、调试方便。我最终选了MicroPython理由很实际。这个项目的核心逻辑是数据接收、解析、显示计算量不大对实时性要求也没到微秒级。MicroPython 在这类场景下完全够用而且它内置了bluetooth、json、machine这些模块写起来非常顺手。举个直观的对比用 ESP-IDF 初始化一个 BLE 服务光是 GATT 服务的属性表定义就要写几十行 C 代码还要处理各种回调和内存管理用 MicroPython 的bluetooth模块十几行就能跑起来一个可连接的服务。但 MicroPython 不是没有代价。它的内存占用比原生开发高固件本身就吃掉了一部分 RAM运行时还有 GC垃圾回收的开销。ESP32-S3 一般有 512KB 的 SRAMMicroPython 固件跑起来之后可用堆内存大概在 100KB 到 200KB 之间具体取决于固件版本和编译选项。这个数字听起来不多但对于显示状态信息这种场景是够的——前提是你别在内存里缓存大图片或者长字符串。提示如果你选 MicroPython务必刷带 BLE 支持的固件版本。标准固件不一定包含bluetooth模块刷之前去官方下载页确认一下固件说明里有没有 BLE 字样。选 MicroPython 还有一个隐性好处迭代速度快。改一行代码通过串口或者文件传输推上去重启就能看到效果不需要每次编译整个固件。对于我这种边写边调的风格来说这个体验太重要了。ESP-IDF 每次改代码都要重新编译、烧录一个来回少说一两分钟一天下来浪费的时间很可观。2.2 通信协议BLE 和 WiFi 的适用边界Status Deck 需要从外部获取数据通信方式的选择直接决定了使用场景。ESP32-S3 同时支持 WiFi 和 BLE两个都能用但用哪个要看你的数据来源在哪。WiFi 的优势是带宽大、协议成熟、可以直接走 TCP 或者 HTTP 拿数据。如果你的数据来自局域网内的服务器或者互联网上的 APIWiFi 是自然选择。ESP32-S3 连接 WiFi 接收 TCP 消息这个场景非常常见MicroPython 里用socket模块几行就能建立连接。但 WiFi 的缺点也明显功耗高、连接建立慢、需要知道 SSID 和密码换个网络环境就要重新配置。BLE 的优势是低功耗、配对简单、不需要网络基础设施。手机打开蓝牙就能连适合做配置界面或者近距离数据传输。BLE 的 GATT 协议天然适合结构化数据交换——你可以定义几个特征值Characteristic一个用来接收命令一个用来推送状态手机端读写这些特征值就行。缺点是带宽小传输大量数据会很慢而且 BLE 的连接稳定性在某些手机上表现一般。我的方案是两个都保留按场景切换。日常使用时通过 WiFi 连接局域网内的数据源稳定可靠需要现场配置或者手机快速查看时切到 BLE 模式。这个切换逻辑在代码里就是一个模式变量初始化的时候根据配置决定启动哪套通信栈。这样设计的好处是灵活性高坏处是代码复杂度上去了两套通信逻辑都要维护。如果你只想快速出成果建议先做 WiFi 版本BLE 作为后续扩展。2.3 数据格式JSON 在 MCU 上的真实表现热词里 JSON 出现频率极高这个项目里也确实绕不开它。JSON 的好处是通用、可读、前后端都熟悉手机端、服务器端、MCU 端可以用同一套数据结构。但 JSON 在 MCU 上有两个现实问题解析开销和内存占用。MicroPython 内置了json模块json.loads()和json.dumps()直接可用。解析一个几百字节的 JSON 字符串在 ESP32-S3 上大概几毫秒到十几毫秒这个速度对于状态显示场景完全够用。内存方面解析后的字典对象会占用堆内存一个包含五六个字段的 JSON 对象大概占几百字节到一两 KB也在可接受范围内。真正需要注意的是JSON 字符串的长度。如果你从服务器收到一个几 KB 的 JSON解析过程中会同时存在原始字符串和解析后的对象内存峰值可能是数据本身的两三倍。ESP32-S3 的堆内存有限几 KB 的 JSON 就可能触发内存不足。我的做法是让服务器端只发送必要字段把 JSON 控制在 500 字节以内。如果确实需要传大量数据考虑用二进制格式或者分片传输。import json # 典型的接收和解析流程 raw {cpu:45,mem:62,temp:38.5,tasks:3} try: data json.loads(raw) cpu data.get(cpu, 0) mem data.get(mem, 0) temp data.get(temp, 0.0) tasks data.get(tasks, 0) except ValueError as e: print(JSON parse error:, e)这段代码看起来简单但有几个细节值得说。json.loads()在遇到格式错误时会抛ValueError一定要用 try-except 包住否则一个坏包就能让整个程序崩溃。另外取值的时候用.get()而不是[]这样字段缺失时返回默认值而不是抛异常健壮性更好。2.4 任务调度FreeRTOS 还是状态机ESP32-S3 跑的是 FreeRTOSMicroPython 底层也是基于 FreeRTOS 的。但 MicroPython 层面并没有直接暴露完整的 FreeRTOS API你能用的是_thread模块部分固件支持或者asyncio。这就引出了任务调度的问题屏幕刷新、按键扫描、数据接收这三件事怎么协调。最朴素的做法是写一个主循环每轮依次做三件事。但前面说过屏幕刷新是耗时操作SPI 写一屏数据可能要几十毫秒这期间按键就丢了。解决办法有两个一是用asyncio做协程调度把耗时操作拆成非阻塞的步骤二是用_thread开真正的线程让不同任务并行跑。我选的是asyncio。原因是 MicroPython 的_thread支持并不完整线程间共享数据要加锁稍不注意就出竞态问题调试起来很痛苦。asyncio是单线程事件循环所有任务在同一个线程里协作式调度不存在竞态代码逻辑清晰得多。代价是你必须保证每个协程都不会长时间阻塞否则整个事件循环就卡住了。import asyncio async def task_display(): while True: refresh_screen() await asyncio.sleep_ms(100) async def task_buttons(): while True: check_buttons() await asyncio.sleep_ms(20) async def task_data(): while True: await fetch_data() await asyncio.sleep_ms(1000) async def main(): await asyncio.gather( task_display(), task_buttons(), task_data() ) asyncio.run(main())这个结构就是整个项目的主干。三个协程各管一摊通过asyncio.sleep_ms()主动让出控制权事件循环自动调度。屏幕每 100ms 刷一次按键每 20ms 扫一次数据每秒拉一次互不阻塞。实际跑下来按键响应很跟手屏幕也没有明显闪烁。3. 核心模块拆解从 BLE 服务定义到屏幕渲染3.1 BLE GATT 服务设计UUID 怎么规划BLE 通信的核心是 GATTGeneric Attribute Profile它把数据组织成服务Service和特征值Characteristic。每个服务和特征值都有一个 UUID 来标识。UUID 有 16 位的短 UUID 和 128 位的长 UUID 两种标准服务用短 UUID自定义服务用长 UUID。Status Deck 需要两个自定义服务一个用于接收命令比如切换显示模式、设置参数一个用于推送状态把当前显示的数据同步给手机。每个服务下面再定义特征值命令服务有一个可写特征值状态服务有一个可读加通知的特征值。UUID 的生成没有强制规则只要保证唯一就行。我习惯用在线 UUID 生成器生成一组然后固定下来写死在代码里。这里给出一组示例你可以直接拿去用也可以自己生成用途UUID属性命令服务6E400001-B5A3-F393-E0A9-E50E24DCCA9E主服务命令特征值6E400002-B5A3-F393-E0A9-E50E24DCCA9E可写状态服务6E400003-B5A3-F393-E0A9-E50E24DCCA9E主服务状态特征值6E400004-B5A3-F393-E0A9-E50E24DCCA9E可读、通知注意UUID 一旦确定就不要随便改因为手机端的代码是硬编码这些 UUID 的。改一次就要同步改手机端很麻烦。项目开始前就把 UUID 规划好。MicroPython 里定义 BLE 服务的代码大概长这样import bluetooth from ble_advertising import advertising_payload _IRQ_CENTRAL_CONNECT 1 _IRQ_CENTRAL_DISCONNECT 2 _IRQ_GATTS_WRITE 3 class StatusDeckBLE: def __init__(self, nameStatusDeck): self._ble bluetooth.BLE() self._ble.active(True) self._ble.irq(self._irq) self._connections set() self._payload advertising_payload(namename) self._register_services() self._advertise() def _register_services(self): cmd_service ( bluetooth.UUID(6E400001-B5A3-F393-E0A9-E50E24DCCA9E), (( bluetooth.UUID(6E400002-B5A3-F393-E0A9-E50E24DCCA9E), bluetooth.FLAG_WRITE, ),), ) status_service ( bluetooth.UUID(6E400003-B5A3-F393-E0A9-E50E24DCCA9E), (( bluetooth.UUID(6E400004-B5A3-F393-E0A9-E50E24DCCA9E), bluetooth.FLAG_READ | bluetooth.FLAG_NOTIFY, ),), ) ((self._cmd_handle,), (self._status_handle,),) \ self._ble.gatts_register_services((cmd_service, status_service))这段代码的关键在于gatts_register_services的返回值结构。它返回一个嵌套元组顺序和你注册服务的顺序一致每个服务下面的特征值句柄按定义顺序排列。拿到句柄之后读写数据都通过句柄操作。3.2 数据接收与解析从字节流到可用状态BLE 收到的是字节流需要解码成字符串再解析 JSON。这里有个容易踩的坑BLE 单次传输有长度限制默认 MTU 是 23 字节实际可用载荷只有 20 字节。如果你的 JSON 超过 20 字节要么协商更大的 MTU要么分片传输。协商 MTU 在 MicroPython 里可以调用self._ble.gatts_set_buffer(handle, size)来设置缓冲区大小但实际生效的 MTU 取决于手机端。安卓和 iOS 对 MTU 的处理不一样iOS 通常能协商到 185 字节左右安卓设备差异较大。稳妥的做法是把 JSON 控制在 20 字节以内或者用分片协议。我选择的是控制 JSON 长度。状态数据只传最关键的几个字段用短键名{c:45,m:62,t:38,k:3}这个 JSON 只有 30 个字符左右加上可能的引号和括号控制在 40 字节以内。如果 MTU 协商到 40 以上就能一次传完不行就分两次。解析的时候把短键名映射回可读的变量名KEY_MAP {c: cpu, m: mem, t: temp, k: tasks} def parse_status(raw_bytes): try: text raw_bytes.decode(utf-8).strip() data json.loads(text) return {KEY_MAP.get(k, k): v for k, v in data.items()} except (ValueError, UnicodeDecodeError) as e: print(Parse failed:, e) return None用短键名是个很实用的技巧能显著减少传输数据量。代价是可读性差一点但配合映射表就解决了。3.3 屏幕渲染局部刷新和全屏刷新的选择屏幕刷新是性能大头。一块 240x320 的 TFT 屏全屏刷新一次要写 153600 字节的数据SPI 时钟就算跑到 40MHz光传输就要 30ms 左右加上 MicroPython 的解释开销实际可能要 50ms 以上。如果每秒刷 10 次CPU 有一半时间花在刷屏上。优化思路是局部刷新。状态信息通常只有几个数字在变没必要整屏重绘。我的做法是把屏幕分成几个区域每个区域对应一个数据项只有数据变化时才刷新对应区域。比如 CPU 占用从 45 变成 46只重绘 CPU 那个小区域其他区域不动。class Region: def __init__(self, x, y, w, h, font_size2): self.x x self.y y self.w w self.h h self.font_size font_size self.last_value None def update(self, display, value): if value self.last_value: return display.fill_rect(self.x, self.y, self.w, self.h, 0x0000) display.text(font, str(value), self.x, self.y, 0xFFFF) self.last_value value这个Region类封装了区域的位置、大小和上次的值。update方法先比较新值和旧值相同就直接返回不同才清空区域重绘。实测下来局部刷新能把刷屏开销降到全屏的十分之一以下CPU 占用大幅下降。实操心得局部刷新时清空区域用fill_rect比fill快因为fill会清整个屏幕。另外字体渲染是另一个开销大头字号越大越慢。状态显示用最小字号就够了别为了好看用大字体。3.4 按键处理消抖和长按识别按键看起来简单实际上手写起来细节很多。机械按键按下和松开时会有几十毫秒的抖动如果不处理一次按下会被识别成多次。标准做法是软件消抖检测到电平变化后延时 20ms 再读一次如果电平稳定才认为是有效按下。class Button: def __init__(self, pin, callback, long_press_cbNone): self.pin pin self.callback callback self.long_press_cb long_press_cb self.last_state 1 self.press_time 0 self.long_press_fired False def scan(self): state self.pin.value() now time.ticks_ms() if state 0 and self.last_state 1: self.press_time now self.long_press_fired False elif state 0 and self.last_state 0: if (not self.long_press_fired and self.long_press_cb and time.ticks_diff(now, self.press_time) 800): self.long_press_cb() self.long_press_fired True elif state 1 and self.last_state 0: if (not self.long_press_fired and time.ticks_diff(now, self.press_time) 20): self.callback() self.last_state state这个实现同时处理了消抖和长按。短按在松开时触发长按在按住超过 800ms 时触发。time.ticks_diff是 MicroPython 提供的安全时间差计算函数能正确处理计数器溢出比直接相减靠谱。4. 完整实施流程从零到能跑的每一步4.1 环境搭建和固件烧录第一步是准备开发环境。你需要一台电脑、一根 USB 数据线、一块 ESP32-S3 开发板。软件方面装两个东西串口驱动和烧录工具。Windows 上一般需要装 CH340 或者 CP210x 驱动Mac 和 Linux 通常免驱。烧录工具推荐用esptool命令行操作跨平台。pip install esptool esptool.py --chip esp32s3 --port /dev/ttyUSB0 erase_flash esptool.py --chip esp32s3 --port /dev/ttyUSB0 write_flash -z 0 \ ESP32_GENERIC_S3-20240602-v1.23.0.binerase_flash先清空芯片write_flash写入新固件。-z表示压缩传输速度快一些。端口号根据你的系统改Windows 上是COM3这种Mac 上是/dev/tty.usbserial-xxx。烧录完成后用串口工具连上去应该能看到 MicroPython 的 REPL 提示符。这时候输入import bluetooth测试一下如果不报错说明固件带 BLE 支持。注意烧录前一定要先erase_flash否则旧固件的残留数据可能导致新固件启动异常。这个坑我踩过折腾了半天才发现是没擦干净。4.2 项目文件结构规划MicroPython 的文件系统是扁平的没有真正的目录层级部分固件支持目录但不推荐用。所以文件命名要清晰避免混乱。我的项目结构是这样的文件名作用main.py入口启动事件循环config.py配置参数WiFi 密码、UUID 等ble_service.pyBLE 服务定义和数据处理display_driver.py屏幕驱动和区域刷新button.py按键扫描和事件data_source.py数据获取WiFi 或 BLEui.py界面布局和渲染逻辑main.py是 MicroPython 上电后自动执行的文件所以入口逻辑放这里。其他文件通过import引入。这种拆分方式让每个文件职责单一改屏幕逻辑不用动 BLE 代码维护起来轻松很多。4.3 主循环的组装和启动把所有模块拼起来的主循环大概是这样import asyncio import config from display_driver import Display from button import Button from ble_service import StatusDeckBLE from data_source import DataSource from ui import UI async def main(): display Display() ui UI(display) ble StatusDeckBLE() source DataSource(config.DATA_MODE) btn_mode Button(config.BTN_MODE_PIN, ui.next_mode) btn_bright Button(config.BTN_BRIGHT_PIN, ui.toggle_brightness) async def loop_display(): while True: ui.render() await asyncio.sleep_ms(100) async def loop_buttons(): while True: btn_mode.scan() btn_bright.scan() await asyncio.sleep_ms(20) async def loop_data(): while True: data await source.fetch() if data: ui.update_data(data) await asyncio.sleep_ms(1000) await asyncio.gather(loop_display(), loop_buttons(), loop_data()) asyncio.run(main())三个循环各司其职通过共享的ui对象交换数据。ui.update_data()更新内部状态ui.render()负责把状态画到屏幕上。这种生产者-消费者模式解耦了数据获取和显示两边互不干扰。4.4 WiFi 数据接收的实现细节WiFi 模式下ESP32-S3 作为客户端连接服务器接收 TCP 推送的数据。MicroPython 的socket模块支持非阻塞模式配合asyncio用起来很顺。import socket import network class WiFiSource: def __init__(self, ssid, password, host, port): self.ssid ssid self.password password self.host host self.port port self.sock None def connect_wifi(self): sta network.WLAN(network.STA_IF) sta.active(True) if not sta.isconnected(): sta.connect(self.ssid, self.password) for _ in range(50): if sta.isconnected(): break time.sleep_ms(100) return sta.isconnected() async def fetch(self): if self.sock is None: if not self.connect_wifi(): return None self.sock socket.socket() self.sock.setblocking(False) try: self.sock.connect((self.host, self.port)) except OSError: pass try: data self.sock.recv(256) if data: return parse_status(data) except OSError: pass return None非阻塞 socket 的connect和recv都可能抛OSError这是正常的表示数据还没准备好不是错误。用 try-except 包住忽略掉就行。recv(256)一次最多读 256 字节对于状态数据足够了。实操心得WiFi 连接不稳定是常态尤其是信号弱的时候。我的做法是加一个重连计数器连续失败超过 10 次就重新初始化 WiFi。另外connect_wifi里的等待循环不要写死while not sta.isconnected()那样信号不好时会卡死用有限次数的 for 循环更安全。5. 踩坑实录与排查技巧5.1 BLE 连接不上的常见原因BLE 连接问题排查起来最头疼因为现象往往很模糊——手机搜不到设备或者搜到了连不上或者连上了马上断。我整理了一个排查顺序按这个顺序走基本能定位问题。现象可能原因排查方法手机搜不到设备广播没启动检查_advertise()是否被调用搜到但连不上服务定义有误用 BLE 调试助手看服务列表连上马上断内存不足看串口有没有 MemoryError能连但读不到数据特征值属性不对确认 FLAG_READ 已设置写入无反应没处理写事件检查_IRQ_GATTS_WRITE分支最常见的问题是广播没启动。MicroPython 的 BLE 模块不会自动广播必须显式调用gap_advertise。另一个常见问题是服务注册后没有重新广播导致手机端看不到更新后的服务。def _advertise(self): self._ble.gap_advertise(100000, adv_dataself._payload)100000是广播间隔单位微秒也就是 100ms 广播一次。这个值越小越容易被搜到但功耗越高。100ms 是个平衡点。5.2 JSON 解析失败的边界情况JSON 解析失败的原因五花八门我遇到过的有数据没接收完整、编码不是 UTF-8、JSON 里有非法字符、字段类型和预期不符。排查的时候第一步是把原始字节打印出来看很多时候问题一眼就能看出来。def parse_status(raw_bytes): print(RAW:, raw_bytes) try: text raw_bytes.decode(utf-8).strip() print(TEXT:, repr(text)) data json.loads(text) return data except Exception as e: print(Parse error:, type(e).__name__, e) return Nonerepr(text)能显示不可见字符比如\x00或者\r\n。有时候数据末尾带了换行符json.loads会报错strip()能解决。有时候数据被截断了比如只收到{cpu:4这种就要检查 MTU 和分片逻辑。注意MicroPython 的json.loads对某些边界情况处理不如 CPython 严格。比如它可能接受一些 CPython 会拒绝的输入也可能拒绝一些 CPython 接受的。测试的时候多用真实数据别只用理想格式。5.3 内存不足的识别和处理MicroPython 在内存不足时会抛MemoryError但有时候不会立刻抛而是表现为各种奇怪的行为——比如 BLE 突然断开、屏幕花屏、程序重启。识别内存问题的方法是定期打印gc.mem_free()。import gc def check_memory(tag): gc.collect() free gc.mem_free() print(f[MEM] {tag} free{free}) if free 10000: print([MEM] WARNING: low memory)gc.collect()手动触发垃圾回收然后看剩余内存。如果低于 10KB 就要警惕了。减少内存占用的方法有几个及时删除不用的对象、避免创建大字符串、用bytearray代替bytes做缓冲区、把常量放在const()里。5.4 屏幕闪烁和撕裂的处理屏幕闪烁通常是因为刷新太频繁或者刷新时没有做双缓冲。MicroPython 的显示驱动一般不支持硬件双缓冲但可以用软件方式缓解先在一个内存缓冲区里画好再一次性推给屏幕。import framebuf class BufferedDisplay: def __init__(self, width, height): self.width width self.height height self.buf bytearray(width * height * 2) self.fb framebuf.FrameBuffer( self.buf, width, height, framebuf.RGB565) def flush(self, tft): tft.blit_buffer(self.buf, 0, 0, self.width, self.height)framebuf是 MicroPython 内置的帧缓冲模块支持基本的绘图操作。在fb上画完用blit_buffer一次性推给屏幕。这样屏幕看到的是完整的一帧不会出现画一半的撕裂现象。代价是内存占用增加240x320 的 RGB565 缓冲区要 150KBESP32-S3 的堆内存可能扛不住。如果内存紧张可以只对变化区域做缓冲。6. 项目扩展方向和我的实际体会这套架构跑通之后扩展就很容易了。想加新数据项在ui.py里加一个Region在数据源里加一个字段其他都不用动。想换显示主题改ui.py里的颜色和布局就行。想加新的通信方式实现一个和DataSource同接口的类在config.py里切换。我实际用下来最深的体会是约束比自由更重要。MCU 资源有限反而逼着我把每个字节、每毫秒都算清楚。上层开发习惯了随便 new 对象、随便开线程到了 MCU 上这些习惯都要改。JSON 要精简、字符串要复用、任务要合并这些约束让代码反而更干净。另一个体会是调试手段要提前准备。串口打印是最基础的但光靠 print 不够。我在项目里加了一个简单的日志分级DEBUG 级别打印详细信息INFO 级别只打关键节点通过配置切换。这样开发时看详细日志部署时只看关键信息不用改代码。最后分享一个小技巧把配置和代码分离。WiFi 密码、服务器地址、UUID 这些容易变的东西全放在config.py里代码文件里不出现硬编码。这样换环境的时候只改一个文件不用满项目找。而且config.py可以加进.gitignore避免敏感信息进版本库。这个习惯在嵌入式项目里尤其重要因为烧录到设备上的代码往往不好改配置分离能让后期维护轻松很多。
阅读完成 · 觉得有帮助?