
在 PC 上调用一次 serial.write(),返回成功,能不能说明 ESP32-C3 已经收到完整图像?
不能。
一次 160×80 的 RGB565 画面有 25,600 字节。数据从 Python 经过操作系统、USB CDC 和芯片接收缓冲区,最后才进入应用程序。发送端没有报错,只能说明数据交给了本机驱动,不能证明接收端已经收全,更不能证明内容没有错位。
本文不讨论复杂 UI,只解决一个具体问题:怎样把一帧 RGB565 图像可靠地传给 ESP32-C3,并得到可以验证的接收结果。
实际方案使用:
- 160×80、RGB565,小端像素数据;
- 921600 baud;
- 16 字节自描述帧头;
- Payload 每次写入 512 字节,块间暂停 1 ms;
- ESP32-C3 使用状态机接收;
- CRC32 校验整帧;
- 成功返回
OK FRAME,失败返回明确错误。
完整终端项目、屏幕接线和中文分页可参考上一篇:用 ESP32-C3 + ST7735S 做一个 160×80 Codex 桌面终端。本文专门展开其中的串口传图链路。
一、先算清楚一帧到底有多大
RGB565 用 16 bit 表示一个像素,也就是 2 字节:
160 × 80 × 2 = 25,600 字节
固件中的定义如下:
constexpr uint16_t SCREEN_WIDTH = 160;
constexpr uint16_t SCREEN_HEIGHT = 80;
constexpr uint32_t FRAME_PIXELS = SCREEN_WIDTH * SCREEN_HEIGHT;
constexpr uint32_t FRAME_BYTES = FRAME_PIXELS * sizeof(uint16_t);
constexpr uint32_t SERIAL_BAUD = 921600;
uint16_t frameBuffer[FRAME_PIXELS];
frameBuffer 本身占用 25,600 字节 RAM。协议、串口缓冲区和显示库还会继续消耗内存,所以换到分辨率更高的屏幕时,不能只修改宽高常量,必须重新评估内存。
若暂时忽略 USB 和协议开销,常见的 8N1 串口每字节通常需要约 10 bit。25,600 字节在 921600 baud 下的理论线上时间约为:
25600 × 10 ÷ 921600 ≈ 0.278 秒
这是理想下限,不是最终刷新帧率。Python 调度、USB 数据包、分块间隔、CRC 计算和 LCD SPI 刷新都会增加耗时。
二、为什么不能只发裸像素
假设直接连续发送 25,600 字节,接收端会遇到三个问题:
- 不知道这一帧从哪个字节开始;
- 不知道目标宽高和预期长度;
- 不知道数据是否完整、是否被截断或错位。
所以帧头至少要解决“同步、描述、校验”三件事。本文使用 CDX1 帧格式:

| 字段 | 长度 | 字节序 | 说明 |
|---|---|---|---|
| Magic | 4 字节 | ASCII | 固定为 CDX1 |
| Width | 2 字节 | 小端 | 图像宽度,本文为 160 |
| Height | 2 字节 | 小端 | 图像高度,本文为 80 |
| Length | 4 字节 | 小端 | Payload 长度,本文为 25600 |
| CRC32 | 4 字节 | 小端 | 对整个 Payload 计算 |
| Payload | 25600 字节 | 小端 | RGB565 像素 |
帧头共 16 字节,整帧为 25,616 字节。Magic 不负责安全认证,它的作用是让接收状态机在字节流中重新找到帧边界。
三、PC 端:把图片转成 RGB565 小端数据
下面的转换代码读取 RGB888 像素,将 R、G、B 分别压缩为 5、6、5 bit:
import struct
WIDTH = 160
HEIGHT = 80
def rgb565_bytes(image):
image = image.convert("RGB")
output = bytearray(WIDTH * HEIGHT * 2)
offset = 0
for red, green, blue in image.getdata():
color = ((red & 0xF8) << 8) | ((green & 0xFC) << 3) | (blue >> 3)
output[offset:offset + 2] = struct.pack("<H", color)
offset += 2
return bytes(output)
这里的 "<H" 表示 16 bit 无符号整数、小端编码。它必须和 ESP32-C3 端写入 uint16_t frameBuffer[] 的方式一致。
如果红蓝颜色颠倒,不要第一时间修改 CRC 或串口协议。优先检查三件事:
- RGB565 的通道位移是否正确;
- 主机与 MCU 对 16 bit 像素的字节序是否一致;
- 显示库是否启用了额外的 byte swap。
四、PC 端:打包帧头并控制写入节奏
Python 标准库 zlib.crc32() 可直接计算 Payload 的 CRC32:
import struct
import time
import zlib
FRAME_MAGIC = b"CDX1"
def send_page(port, image):
payload = rgb565_bytes(image)
header = FRAME_MAGIC + struct.pack(
"<HHII",
WIDTH,
HEIGHT,
len(payload),
zlib.crc32(payload),
)
port.write(header)
for offset in range(0, len(payload), 512):
port.write(payload[offset:offset + 512])
time.sleep(0.001)
port.flush()
25,600 恰好可以分成 50 个 512 字节写入块。
这里有一个容易混淆的点:512 字节不是新的应用层数据包。 每块没有额外序号、长度和 CRC,接收端看到的仍是一条连续 Payload。分块的目的只是限制单次 write() 的规模,为 USB CDC、驱动和 MCU 接收任务留出调度空间。
如果链路以后需要丢包重传、乱序恢复或者跨网络传输,就应设计真正的分片协议,为每片增加帧号、片号、长度和独立校验。本文的 USB 串口点对点场景没有这个需求。
五、ESP32-C3:先给接收缓冲区留出空间
初始化串口时,将接收缓冲区设为略大于一帧:
void setup() {
Serial.setRxBufferSize(FRAME_BYTES + 128);
Serial.begin(SERIAL_BAUD);
delay(250);
Serial.println("CODEX_POCKET READY 160x80");
}
这一步与 PC 端分块并不冲突:
- 大接收缓冲区用于吸收主机和 MCU 调度速度的短时差异;
- 512 字节写入块用于降低一次大写入造成的突发压力;
- 状态机负责从任意串口读取粒度中还原完整帧。
不要假设一次 Serial.available() 就能拿到 512 字节,更不要假设它会一次返回完整帧。串口是字节流,应用层必须允许任意粒度到达。
六、用三个状态恢复完整帧
接收端只需要三个状态:
enum class ReceiveState : uint8_t {
FindMagic,
ReadHeader,
ReadPayload,
};

1. FindMagic:寻找帧起点
逐字节匹配 CDX1。如果串口刚打开时混有启动日志,或者上一帧中途被打断,接收器仍能继续扫描并重新同步。
case ReceiveState::FindMagic:
if (value == magic[magicMatched]) {
++magicMatched;
if (magicMatched == sizeof(magic)) {
receiveState = ReceiveState::ReadHeader;
headerUsed = 0;
}
} else {
magicMatched = value == magic[0] ? 1 : 0;
}
break;
2. ReadHeader:先拒绝不可能的尺寸
Magic 之后还有 12 字节参数区。宽、高或 Payload 长度不符合当前屏幕时,立即拒绝,不让错误长度写进帧缓冲区:
const uint16_t width = readLe16(header);
const uint16_t height = readLe16(header + 2);
const uint32_t length = readLe32(header + 4);
expectedCrc = readLe32(header + 8);
if (width != SCREEN_WIDTH ||
height != SCREEN_HEIGHT ||
length != FRAME_BYTES) {
Serial.println("ERR FRAME_SIZE");
resetReceiver();
break;
}
长度检查不仅是协议正确性问题,也是内存安全边界。必须在进入 ReadPayload 前完成。
3. ReadPayload:收满后再校验和显示
case ReceiveState::ReadPayload:
reinterpret_cast<uint8_t *>(frameBuffer)[payloadUsed++] = value;
if (payloadUsed == FRAME_BYTES) {
const uint32_t actualCrc = crc32(
reinterpret_cast<const uint8_t *>(frameBuffer), FRAME_BYTES);
if (actualCrc == expectedCrc) {
display.drawRGBBitmap(0, 0, frameBuffer, SCREEN_WIDTH, SCREEN_HEIGHT);
Serial.println("OK FRAME");
} else {
Serial.println("ERR CRC");
}
resetReceiver();
}
break;
关键顺序是:收满 → 校验 → 刷屏 → 应答。CRC 错误时保留上一帧画面,不把损坏数据展示出来。
固件的 CRC32 使用反射多项式 0xEDB88320,初值为 0xFFFFFFFF,最终按位取反,与 Python 的 zlib.crc32(payload) 结果一致。
七、必须让接收端说“我收到了”
发送端应在写完一帧后等待接收结果:
deadline = time.monotonic() + 3.0
while time.monotonic() < deadline:
line = port.readline().decode("utf-8", "replace").strip()
if not line:
continue
if line == "OK FRAME":
print("设备已接收并校验完整帧")
break
if line in ("ERR CRC", "ERR FRAME_SIZE"):
raise RuntimeError("设备拒绝该帧:" + line)
else:
raise TimeoutError("未收到设备确认")
这三个结果的含义很清楚:
OK FRAME:宽高、长度、CRC 全部通过,并已调用刷屏;ERR FRAME_SIZE:帧头与固件预期不一致;ERR CRC:收到了预期长度,但内容校验失败;- 超时:设备可能未运行对应固件、串口选错、端口被占用、链路中断,或固件根本没有读完一帧。
注意:OK FRAME 证明的是“应用层完整接收并执行了显示调用”。屏幕方向、颜色、接线和实际可见效果,仍需观察实体屏幕确认。
八、四类常见故障怎么定位
1. Python 报 PermissionError 或 Access is denied
大概率是同一个 COM 口已经被串口监视器、常驻桥接程序或另一个 Python 进程打开。Windows 下串口通常不能被两个进程同时独占。
先关闭占用者,再重试。不要因为设备管理器里还能看到 COM 口,就判断端口一定可打开。
2. 能发送,但一直等不到 OK FRAME
按下面顺序检查:
- MCU 是否打印过
CODEX_POCKET READY 160x80; - PC 和 MCU 是否都使用 921600 baud;
- 帧头是否为
CDX1,字段是否全部为小端; ARDUINO_USB_CDC_ON_BOOT等原生 USB CDC 配置是否正确;- 是否误连了另一个 CH340 串口,而不是目标 ESP32-C3。
3. 返回 ERR FRAME_SIZE
直接打印 PC 端的 WIDTH、HEIGHT 和 len(payload)。本文正确值应为:
WIDTH = 160
HEIGHT = 80
LENGTH = 25600
最常见原因是图片尺寸没有先转换为 160×80,或者 RGB 数据仍是每像素 3 字节。
4. 返回 ERR CRC
先保存发送端 Payload,并分别打印两端 CRC。若帧头正确但 CRC 不同,重点检查:
- 计算 CRC 的对象是否都只包含 Payload;
zlib.crc32()的结果是否按无符号 32 bit 打包;- 发送过程中是否还有调试文本混入同一二进制通道;
- MCU 是否越界写入或提前重置了接收状态。
九、如何验证,不要只看“程序没报错”
建议把验证分成三层:
第一层:纯软件检查
- 生成图片尺寸必须是 160×80;
len(rgb565_bytes(image))必须是 25,600;- 帧头固定为 16 字节;
- 宽高、长度、CRC 的小端解包结果正确。
第二层:传输闭环
- ESP32-C3 返回
OK FRAME; - 故意修改宽高能得到
ERR FRAME_SIZE; - 故意翻转一个 Payload 字节能得到
ERR CRC; - 拔插设备或中断一帧后,下一帧能够依靠 Magic 重新同步。
第三层:实体屏幕
- 图像方向正确;
- 红、绿、蓝颜色没有互换;
- 四条边都没有裁切或偏移;
- 连续刷新时没有撕裂、花屏或上一帧残留。
只有三层都通过,才能说整条传图链路真正可用。
十、这套设计还能怎样扩展
当前协议追求最小可用。如果要用于更复杂的桌面副屏,可以继续增加:
- 帧序号:识别重复帧和统计丢帧;
- 命令类型:区分整帧、局部刷新、亮度控制和设备信息;
- 超时复位:Payload 中断后自动回到
FindMagic; - 双缓冲:接收下一帧时保持上一帧稳定显示;
- 局部矩形:只发送变化区域,减少带宽和刷新时间;
- 速率统计:记录发送耗时、校验耗时与 LCD 刷新耗时。
无论怎样扩展,都建议保留三个原则:
- 帧头必须能界定边界和长度;
- 数据完整性必须由接收端校验;
- 发送端必须等待接收端的明确应答。
总结
25.6KB 看起来不大,但它已经足以暴露 USB 串口传输中的缓冲、同步和确认问题。可靠链路不是把 write() 调通就结束,而是形成完整闭环:
图片转 RGB565
→ 帧头描述尺寸、长度和 CRC32
→ Payload 以 512 字节节奏写入
→ ESP32-C3 状态机收满整帧
→ 校验通过后刷新屏幕
→ 返回 OK FRAME
只要把“发送成功”和“设备验收成功”分开看,串口传图的多数偶发花屏、半帧和无响应问题都会更容易定位。

31

被折叠的 条评论
为什么被折叠?



