ESP32-C3 串口传输 RGB565 图像:25.6KB 分块、CRC32 与应答机制实战

ESP32-C3 串口传图实战

在 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 字节,接收端会遇到三个问题:

  1. 不知道这一帧从哪个字节开始;
  2. 不知道目标宽高和预期长度;
  3. 不知道数据是否完整、是否被截断或错位。

所以帧头至少要解决“同步、描述、校验”三件事。本文使用 CDX1 帧格式:

CDX1 串口帧格式

字段长度字节序说明
Magic4 字节ASCII固定为 CDX1
Width2 字节小端图像宽度,本文为 160
Height2 字节小端图像高度,本文为 80
Length4 字节小端Payload 长度,本文为 25600
CRC324 字节小端对整个 Payload 计算
Payload25600 字节小端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

按下面顺序检查:

  1. MCU 是否打印过 CODEX_POCKET READY 160x80
  2. PC 和 MCU 是否都使用 921600 baud;
  3. 帧头是否为 CDX1,字段是否全部为小端;
  4. ARDUINO_USB_CDC_ON_BOOT 等原生 USB CDC 配置是否正确;
  5. 是否误连了另一个 CH340 串口,而不是目标 ESP32-C3。

3. 返回 ERR FRAME_SIZE

直接打印 PC 端的 WIDTHHEIGHTlen(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 刷新耗时。

无论怎样扩展,都建议保留三个原则:

  1. 帧头必须能界定边界和长度;
  2. 数据完整性必须由接收端校验;
  3. 发送端必须等待接收端的明确应答。

总结

25.6KB 看起来不大,但它已经足以暴露 USB 串口传输中的缓冲、同步和确认问题。可靠链路不是把 write() 调通就结束,而是形成完整闭环:

图片转 RGB565
→ 帧头描述尺寸、长度和 CRC32
→ Payload 以 512 字节节奏写入
→ ESP32-C3 状态机收满整帧
→ 校验通过后刷新屏幕
→ 返回 OK FRAME

只要把“发送成功”和“设备验收成功”分开看,串口传图的多数偶发花屏、半帧和无响应问题都会更容易定位。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值