ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32串口发送彩色图片:RGB565分包、DMA与上位机拼图实战

STM32串口发送彩色图片:RGB565分包、DMA与上位机拼图实战

简介:面向嵌入式开发者的STM32串口图像传输完整工程包,涵盖正点原子miniSTM32与OV7725摄像头采集、串口发送及PC上位机BMP解码显示的全链路实现。资源共499个文件,压缩包约10.92MB,主控端包含大量C/C源代码、H头文件、Keil MDK工程配置(uvprojx/uvoptx)、DMA与UART驱动模块;上位机侧含C#工程文件(cs/csproj/sln)及调试生成文件,便于直接编译运行。核心实现讲解了通过GPIO与DMA读取OV7725像素数据、按BMP格式封装图像、串口波特率适配,以及上位机字节序转换与像素重构等关键环节。包内目录结构清晰,既有可直接烧录的固件工程,也有配套上位机源码,适合快速复现实验并在此基础上二次开发。已有5794人学习下载,适合正在学习STM32图像采集、串口通信或上位机开发的读者参考借鉴。

1. 先说结论:把彩色图片从 STM32 串口发出来,难点不在“发”,在“能完整收回”

做屏幕截图回传、摄像头预览上传这类需求时,最省事的通道往往就是现成的串口:板子上几乎一定会留一个 UART,接一根 USB 转串口线就能和电脑通信。STM32 串口发彩色图片,就是把一张位图切碎成若干数据包,通过串口依次送出,上位机收齐后按序号拼回、校验、显示。听起来不复杂,但真正动手会发现,坑全藏在细节里:数据区里随机冒出的帧头字节会把同步打乱,高波特率下一堆误码,RGB565 的高低字节顺序不对,整张图就是花的。这篇文章直接按一套可复用的方案来讲:选型、协议、STM32 端实现、Python 端解析,以及五条最常见踩坑记录。适合正在做屏幕截图回传、摄像头预览回传或远程调试画面的开发者。

2. 方案选型:RGB565 加自定义分包,串口送图最稳的组合

2.1 先算数据量:320×240 的彩色图一帧有多少字节

先说格式。彩色图像常见的原始格式有三种:RGB888、RGB565、RGB444。RGB888 每个像素 3 字节,颜色准但体积大,一张 320×240 的图就是 230400 字节,在串口上按 921600 波特率传也要 2.5 秒左右;RGB565 每个像素 2 字节,颜色数量 65536 种,视觉上差别很小,但体积直接少了三分之一,这张图只有 153600 字节。更重要的是,绝大多数 STM32 驱动 TFT LCD 时的显存组织和底层像素格式就是 RGB565,MCU 侧可以直接把显存 buffer 当作图片源,不需要做任何像素转换,省掉一大块 CPU 开销。所以只要不是做专业图像处理,RGB565 是串口送图的第一选择。

按 921600bps、8N1 的串口参数,每字节实际占用 10 个位(起始位 1 + 数据 8 + 停止位 1),理论字节速率是 92160 B/s。153600 字节图像裸传需要约 1.67 秒,加上协议头尾、校验字段,实际一帧大约 1.7 到 1.8 秒。如果这个速度能接受,那整个方案就建立在串口上;如果觉得太慢,后面第 6 章会给出压缩和增量传输两个方向。

2.2 协议帧怎么设计:每个字段都要能对抗误判

自定义协议要解决三个问题:怎么找到一包的开头、怎么知道自己收的包缺不缺、怎么确认收到的数据没被串口弄坏。我的常见做法是定成固定头加变长载体的帧结构,具体字段如下。

字段长度(字节)值/含义
同步字 11固定 0xAA
同步字 21固定 0x55
总包数 total2整帧分了多少包,小端序
包序号 seq2当前是第几包,从 0 开始,小端序
长度 len2本包有效载荷字节数,小端序
载荷 payload0~512图像数据
CRC162从 total 到 payload 末尾的校验值,小端序

同步字用两字节 0xAA 0x55,是为了降低随机误触发概率;total 和 seq 用两字节,是因为一包 512 字节时,320×240 的图像要拆成 300 包,单字节序号根本装不下。len 字段的合法性检查是防止误判的关键:如果解出来的 len 大于 512 或者等于 0,说明当前不是真正的帧头,要滑过一个字节继续找。CRC16 用 Modbus 多项式 x^16+x^15+x^2+1,初始值 0xFFFF,在串口这种低误码率通道上已经足够,不需要上 CRC32。

2.3 包长、波特率、校验方式的选择权衡

包长决定开销和重传代价。包越小,单包出错重传成本越低,但帧头、序号这类固定开销占比会变大。以 512 字节载荷为例,每包固定开销 8 字节,加 2 字节 CRC 共 10 字节,开销比约 2%;如果换成 64 字节载荷,固定开销占比直接到 15% 左右,同样一张图的传输时间会凭空多出一截。我一般选 512,另一个原因是它在 Cortex-M 内核 DMA 的常规突发长度内,单次传输效率高,而且一包出错只重发 512 字节,代价可控。

波特率方面,普通 STM32F1 系列 USART 最高支持到 4.5Mbps,但实际常用上限是 1Mbps 左右,因为再往上对线路长度、电平质量、上位机 USB 转串口芯片的稳定性都很敏感。921600 是试错之后比较通用的一个档位,常见 USB 转串口芯片在这个速率下工作稳定,MCU 端的波特率小数分频误差也能控制在 0.2% 内。CRC16 相对 CRC32 的取舍:串口单比特误码能被 CRC16 以极高概率检出,CRC32 在 MCU 上多花的时间和代码复杂度并不值当。先跑通 CRC16,等真出现漏检再说。

2.4 为什么不选 SPI/I2C/USB,串口普适在哪里

SPI 速度可以到几十 Mbps,但需要四根线,而且电脑端没有原生 SPI 口,必须借助 USB 转 SPI 桥接器,成本和工作量都上去了。I2C 速度太慢,最高也就 3.4Mbps,而且地址冲突、电平匹配问题不少。USB 是最好的,但 STM32 很多型号没有 USB 外设,有 USB 的还得移植设备协议栈,小项目不值当。串口方案最普适:几乎所有 STM32 都有 USART,电脑端插一根 USB 转 TTL 线就能收,驱动和工具都是现成的,这也是为什么在很多实际项目里,串口送图被当成最优先的备选方案。

3. STM32 端实现:串口 DMA 发送与分包调度

3.1 串口和 DMA 初始化,关键参数一次配好

用 HAL 库驱动 USART 是当前最常见做法。发送通道我用 DMA,而不是直接用 HAL_UART_Transmit 阻塞发送。原因很简单:一张图 150KB,按包发的话单包最长就是 522 字节,但主循环还要做采集、显示、按键响应,不能让 CPU 卡在等待串口上。DMA NORMAL 模式单包发送,每包发完由完成中断推进到下一包,这样发送动作本身不占 CPU。

/* 前提:APB2 时钟 72MHz,PA9=TX, PA10=RX 以 STM32F1 为例,默认映射无需重映射; F4/G 系列需要额外加 gpio.Alternate = GPIO_AF7_USART1 */ static void Serial_Init(void) { __HAL_RCC_USART1_CLK_ENABLE(); __HAL_RCC_DMA1_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitTypeDef gpio = {0}; gpio.Pin = GPIO_PIN_9 | GPIO_PIN_10; gpio.Mode = GPIO_MODE_AF_PP; /* 复用推挽 */ gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &gpio); huart1.Instance = USART1; huart1.Init.BaudRate = 921600; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; HAL_UART_Init(&huart1); hdma_usart1_tx.Instance = DMA1_Channel4; /* F1 系列 USART1_TX 对应 DMA1 通道 4 */ hdma_usart1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH; hdma_usart1_tx.Init.PeriphInc = DMA_PINC_DISABLE; hdma_usart1_tx.Init.MemInc = DMA_MINC_ENABLE; hdma_usart1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_usart1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_usart1_tx.Init.Mode = DMA_NORMAL; hdma_usart1_tx.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_usart1_tx); __HAL_LINKDMA(&huart1, hdmatx, hdma_usart1_tx); HAL_UART_DMAStop(&huart1); }

这里的几个参数按项目经验说:HwFlowCtl 先不开,两线制最简单,如果后文出现丢包,再考虑开 RTS/CTS;DMA 通道映射因系列不同,F4 系列和 G0 系列不是 DMA1_Channel4,需要查对应参考手册的 DMAMUX 映射表;BaudRate 921600 的前提是系统时钟准确,如果用了内部 HSI 而不是外部晶振,误差可能超过 2%,后面避坑章会专门讲。

3.2 分包发送:一个函数把整帧图送完

接下来是核心函数。输入是 RGB565 像素数组的地址和总字节数,函数内部把数据切成多个包,逐包填同步字、序号、长度、CRC,然后通过 DMA 发出。为了让 DMA 异步发送期间缓冲区数据不被后续流程改写,发送缓冲区必须用全局或 static。

#define PKT_PAYLOAD_MAX 512 /* 每包最多携带的像素字节数 */ static uint8_t s_txbuf[PKT_PAYLOAD_MAX + 10]; /* 固定头 8 + payload + CRC 2 */ static volatile uint8_t s_tx_busy = 0; /* 发送占用标志 */ void UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { s_tx_busy = 0; /* 本包已全部移出移位寄存器 */ } } /* img: RGB565 像素数组首地址;img_len: 总字节数 */ void Image_SendFrame(const uint8_t *img, uint32_t img_len) { uint16_t total = (img_len + PKT_PAYLOAD_MAX - 1) / PKT_PAYLOAD_MAX; uint16_t crc; for (uint16_t seq = 0; seq < total; seq++) { uint32_t offset = (uint32_t)seq * PKT_PAYLOAD_MAX; uint16_t cur_len = (img_len - offset > PKT_PAYLOAD_MAX) ? PKT_PAYLOAD_MAX : (uint16_t)(img_len - offset); s_txbuf[0] = 0xAA; /* 同步字 1 */ s_txbuf[1] = 0x55; /* 同步字 2 */ s_txbuf[2] = (uint8_t)(total & 0xFF); s_txbuf[3] = (uint8_t)(total >> 8); s_txbuf[4] = (uint8_t)(seq & 0xFF); s_txbuf[5] = (uint8_t)(seq >> 8); s_txbuf[6] = (uint8_t)(cur_len & 0xFF); s_txbuf[7] = (uint8_t)(cur_len >> 8); memcpy(&s_txbuf[8], img + offset, cur_len); crc = CRC16_Modbus(&s_txbuf[2], 6 + cur_len); /* 从 total 算到 payload 末尾 */ s_txbuf[8 + cur_len] = crc & 0xFF; s_txbuf[8 + cur_len + 1] = (crc >> 8) & 0xFF; while (s_tx_busy) { } /* 等待上一包发完 */ s_tx_busy = 1; HAL_UART_Transmit_DMA(&huart1, s_txbuf, 8 + cur_len + 2); } while (s_tx_busy) { } /* 确保最后一包也发完再返回 */ }

这段代码的关键点有两个。第一,CRC 计算范围是 s_txbuf[2] 到 payload 末尾,也就是越过了同步字,这样同步字只承担定位帧头的任务,不参与校验,避免因为同步字本身出错而影响整包判断。第二,每次启动 DMA 之前都要等 s_tx_busy 清零,否则上一包还没发完,新的 DMA 请求会把旧的传输打断,这也是最容易出现“发送数据错乱”的地方。

CRC16_Modbus 的实现很简单,直接搬运标准查表法或逐位法都行,这里给出逐位版本:

uint16_t CRC16_Modbus(const uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; while (len--) { crc ^= *data++; for (int i = 0; i < 8; i++) { if (crc & 1) crc = (crc >> 1) ^ 0xA001; else crc >>= 1; } } return crc; }

3.3 用状态机替代阻塞循环,防止主循环卡死

上面 Image_SendFrame 的 while 等待,在裸机工程里会让主循环一直卡到整帧发完,约 1.7 秒。如果项目里还要同时响应按键、刷新显示、采集传感器,这个卡顿不可接受。我一般会把发送拆成一个轻量状态机,在主循环里每轮调用一次,每轮只推进一个包:

typedef enum { TX_STATE_IDLE, /* 无发送任务 */ TX_STATE_FILL, /* 填下一包 */ TX_STATE_WAIT_DMA /* 等 DMA 发送完成 */ } TxState_t; static TxState_t s_tx_state = TX_STATE_IDLE; static const uint8_t *s_img_ptr; static uint32_t s_img_len; static uint16_t s_total_pkts; static uint16_t s_seq_now; void Image_Start(const uint8_t *img, uint32_t len) { if (s_tx_state != TX_STATE_IDLE) return; /* 上一次还没发完 */ s_img_ptr = img; s_img_len = len; s_total_pkts = (len + PKT_PAYLOAD_MAX - 1) / PKT_PAYLOAD_MAX; s_seq_now = 0; s_tx_state = TX_STATE_FILL; } void Image_Task(void) /* 主循环周期调用,比如每 1ms */ { if (s_tx_state == TX_STATE_FILL) { uint32_t offset = (uint32_t)s_seq_now * PKT_PAYLOAD_MAX; uint16_t cur = (s_img_len - offset > PKT_PAYLOAD_MAX) ? PKT_PAYLOAD_MAX : (uint16_t)(s_img_len - offset); /* 组装一包的代码同上,省略 */ s_tx_state = TX_STATE_WAIT_DMA; s_tx_busy = 1; HAL_UART_Transmit_DMA(&huart1, s_txbuf, 8 + cur + 2); } }

在 DMA 完成回调里推进状态:

void UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { s_tx_busy = 0; if (s_tx_state == TX_STATE_WAIT_DMA) { s_seq_now++; s_tx_state = (s_seq_now >= s_total_pkts) ? TX_STATE_IDLE : TX_STATE_FILL; } } }

这个状态机把一个长时间操作化解为“准备好才发下一包”,主循环不会被占用,是实际嵌入式工程里和 UI、传感器采集并存的正确姿势。注意:发送缓冲区 s_txbuf 是全局共享的,所以在 FILL 状态填充新一包前,必须确保上一包已经发送完成,也就是 s_tx_busy 已经归零,否则会出现新数据覆盖还没发完的旧数据。

4. 上位机接收与拼图:Python 状态机按字节流恢复彩色图片

4.1 串口参数与接收线程:为什么必须独立线程

上位机我一般用 Python 加 pyserial、Pillow。接收线程独立于 UI,用阻塞读加大缓冲。921600 波特率下,如果上位机在主线程里边读边刷新界面,容易出现接收不及时导致的缓冲溢出,所以正确做法是开一个后台线程持续读串口,把读到的原文喂给解析器。

import serial import threading SER_PORT = 'COM3' # Windows 示例,Linux 下是 /dev/ttyUSB0 SER_BAUD = 921600 def read_worker(ser: serial.Serial, receiver: 'ImageReceiver'): while True: data = ser.read(4096) # 每次最多读 4KB if data: receiver.feed(data) ser = serial.Serial(SER_PORT, SER_BAUD, timeout=0) recv = ImageReceiver(width=320, height=240) threading.Thread(target=read_worker, args=(ser, recv), daemon=True).start()

注意 timeout=0,这样 read 不会等满超时,而是有数据就返回;read(4096) 返回的可能是 0 到 4096 任意长度,必须交给状态机按帧解析,不能假设一次调用正好是一帧或一包。

4.2 状态机解析:从字节流里稳定抓出每一包

状态机的核心是:持续把新数据追加到缓冲区,反复尝试从缓冲区开头解析一帧。如果不能解析,判断是“数据没到齐”还是“帧头误判”。以下代码直接可用。

class ImageReceiver: """解析串口字节流中的图片帧。 帧格式: [0xAA, 0x55] + total(2LE) + seq(2LE) + len(2LE) + payload + crc16(2LE) """ MAX_PAYLOAD = 512 def __init__(self, width=320, height=240): self.width = width self.height = height self.buf = bytearray() self.packets = {} # seq -> payload self.total = 0 def feed(self, data: bytes): self.buf.extend(data) self._parse() def _parse(self): while True: if len(self.buf) < 8: # 同步头 + 固定字段都不够 return if self.buf[0] != 0xAA or self.buf[1] != 0x55: del self.buf[0] # 逐字节滑动找同步头 continue total = self.buf[2] | (self.buf[3] << 8) seq = self.buf[4] | (self.buf[5] << 8) plen = self.buf[6] | (self.buf[7] << 8) if plen > self.MAX_PAYLOAD or plen == 0: del self.buf[0] # 误判同步头,跳过重找 continue need = 8 + plen + 2 # 帧总长度 if len(self.buf) < need: return # 数据没到齐,等下一轮 payload = bytes(self.buf[8:8 + plen]) crc_rx = self.buf[8 + plen] | (self.buf[9 + plen] << 8) crc_calc = self._crc16(self.buf[2:8 + plen]) if crc_calc == crc_rx: self.packets[seq] = payload self.total = total del self.buf[:need] # 无论校验是否通过都移走这帧 @staticmethod def _crc16(data: bytes) -> int: crc = 0xFFFF for b in data: crc ^= b for _ in range(8): if crc & 1: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return crc

这段代码有个关键行为:校验失败时不会对数据做任何处理,直接丢弃该帧继续找下一帧。实际中如果出现连续 CRC 错误,需要在上层统计错误包数并考虑重新发送,这是后面避坑章要讲的话题。另外,如果 plen 恰好等于 0,在协议里是非法值,直接视为帧头误判,避免一个空包把自己循环消费掉。

4.3 拼图与显示:RGB565 到 RGB888 的像素扩展

收满所有包之后拼出原始像素缓冲区。RGB565 每个像素两个字节,小端序,低字节在前。转换时先拼出 uint16,再拆红绿蓝分量并扩展到 8 位。

from PIL import Image def rebuild_image(recv: ImageReceiver) -> Image.Image: # 按序号拼接原始字节 raw = bytearray() for seq in range(recv.total): if seq not in recv.packets: raise ValueError(f'missing packet seq={seq}') raw.extend(recv.packets[seq]) # 检查长度是不是 width*height*2 expect = recv.width * recv.height * 2 if len(raw) < expect: raise ValueError(f'data too short: {len(raw)} < {expect}') img = Image.new('RGB', (recv.width, recv.height)) pix = img.load() for y in range(recv.height): for x in range(recv.width): idx = (y * recv.width + x) * 2 val = raw[idx] | (raw[idx + 1] << 8) # 小端 r5 = (val >> 11) & 0x1F g6 = (val >> 5) & 0x3F b5 = val & 0x1F r8 = (r5 << 3) | (r5 >> 2) # 5bit 扩到 8bit g8 = (g6 << 2) | (g6 >> 4) b8 = (b5 << 3) | (b5 >> 2) pix[x, y] = (r8, g8, b8) return img

扩展逻辑说明:5bit 最大 31,要映射到 0-255,位运算 (v<<3)|(v>>2) 是在不依赖浮点的情况下均匀放大,比直接 v*255/31 快得多,在上位机纯 Python 像素循环里能明显减少耗时。320×240 的图纯 Python 逐像素转换大约需要一两秒,如果需要连续预览,建议把这段改成 numpy 向量化,后面会提。

验证是否收完整,可以在每帧结束后统计 len(recv.packets) 是否等于 recv.total,不相等就打印缺包的 seq 列表。这一步在做通讯调试时非常有用,能把“图片坏了”快速归因到“缺包”还是“像素转换错”。

5. 串口发图的避坑排查:从花屏到粘包的 5 个实战记录

5.1 现象一:颜色错乱但轮廓正常,红色变蓝色

现象:图片能拼出来,轮廓完全正常,但红色变成蓝色,绿色变成品红。

原因:MCU 侧如果是在小端序下直接 memcpy 一个 uint16_t 数组,发送的第一个字节是像素低位;上位机如果按大端拼,val = (raw[idx]<<8) | raw[idx+1],就把高低字节反了。RGB565 里高 5 位是红色,低 5 位是蓝色,反序后红蓝互换。

解决:确定发送侧的字节排列。最简单的方式是发一张纯红色、纯绿色、纯蓝色的测试图,上位机看哪个分量被错置。我习惯固定为小端,并在上位机代码里加一个 BYTE_ORDER 常量,以后换平台时只需要改一行,不用再追查像素循环。

5.2 现象二:帧头误判导致粘包,图片缺行错位

现象:解析器不断丢弃字节,最终图片缺少若干段,偶尔出现整体位移,甚至一帧还没收完就开始拼下一帧。

原因:上位机接收端发现数据区里出现 0xAA 0x55 就把它当成新帧起始。只要之前丢过一包,状态机同步就会错乱,错乱之后可能把上一帧的 payload 当作下一帧包头。

解决:我在状态机里对 len 做了合法性校验,plen 大于 512 或等于 0 就直接判为误判,滑过一个字节继续找。CRC 校验失败的帧直接丢弃,不给后续数据污染机会。如果图像数据里大量出现 0xAA 0x55(实际中常见,比如深色的图像数据里很容易碰到),可以在发送前做转义处理:把 0xAA 转成 0xAA 0x00,接收端反转义。代价是数据量可能增加,但换来同步稳定性,图像场景里很值。

5.3 现象三:DMA 发送完成后数据还是坏的,往前看全是乱码

现象:函数返回后界面显示发送成功,但接收端收到的是乱码,或者前几个字节对、后面全乱。

原因:s_txbuf 如果是函数的局部变量,函数返回时栈空间被收回,DMA 还在异步往外搬数据,搬出来的全是栈上被复用或已销毁的内容。这是典型的黑匣子问题,表面上是数据乱了,实际是异步访问生命周期没管好。

解决:把发送缓冲区定义为全局或 static,并在函数返回前确保 DMA 已经搬完。使用状态机方案时,还要在启动下一次 DMA 前确认上一包的完成回调已经把 s_tx_busy 清零。另外,如果用了断言或内存保护单元,可以直接给这块 buffer 加区域属性,让非法写立即触发异常,便于定位。

5.4 现象四:921600 波特率下持续误码,只有低速时才稳

现象:单独发几个字节没问题,连续高速发送就误码,波特率越高越明显。

原因:波特率误差超限。如果 MCU 的系统时钟不是预期值,比如 HSI 代替 HSE 或 PLL 配置不对,USARTDIV 的计算结果会偏离理想值;APB 时钟 72MHz 配 921600 时误差约 0.16%,但 APB 是 64MHz 时,取整后误差可能到 1.5% 以上,接近 UART 容错界限。

解决:先核对 RCC 配置,确认系统时钟和 APB 分频。用逻辑分析仪或示波器测 TX 引脚,看实际波特率是否接近 921600,偏差超过 1% 就调整。系统时钟不方便改的项目,可以退一档用 460800,实测在内部振荡器上也能稳定工作。

5.5 现象五:上位机图片收到一半被截断,缺最后一截

现象:图片传输中显示进程卡顿,接收线程偶尔掉数据,最后缺包。上位机读串口的日志里能看到很长一段时间没有数据。

原因:有些上位机串口库默认缓冲只有 4KB 或 16KB,921600 波特率下 100ms 就有 9KB 数据涌入,如果读取不及时,串口硬件缓冲溢出,后面的包直接丢失。另一种情况是上位机在收到完整一帧后做图像转换时占用了主线程,导致读串口被阻塞。

解决:读取循环独立成线程,并保证 read(4096) 持续执行,不要在读取循环里做耗时的图像转换。可以打开串口硬件流控 RTS/CTS,让上位机来不及读时拉低 RTS 通知 MCU 暂停发送。注意 MCU 端也要把 USART 的 HwFlowCtl 配成 UART_HWCONTROL_RTS_CTS,并接好 RTS/CTS 两根线,不是只改上位机就有效。

6. 还有余量可挖:RLE 压缩、增量传输与双缓冲

6.1 RLE 压缩把传输量降一半

RGB565 图像做 RLE 压缩非常简单:按行扫描,遇到连续相同像素就记下像素值和重复次数。界面截图这类图像,背景色块、标题栏颜色相同区域很大,压缩率经常能到 40% 到 60%。MCU 端只需在打包前把当前行跑一遍 RLE,上位机解压后再拼 RGB565 像素数组。注意一帧分成多包时,RLE 流不能跨包拆断,我一般会在每包开头标记“本包是否从 RLE 编码中间开始”,或者干脆让 RLE 以行为单位切分,每行独立编码,这样丢包后恢复也容易。

6.2 增量传输只发变化区域

如果摄像头固定不动,画面大部分区域每帧都一样。常见做法是做块级比较:把图像分成 16×16 的块,对每个块算一个简单的均值或 CRC,只有变化的块才进传输队列。这个方案可以把传输量降到原来的几分之一,代价是 MCU 要多维护一个上一帧的缓存。注意块比较算法要选便宜一点的,比如直接比较每个块内像素的累加和,逐像素比较在 320×240 分辨率下大约要跑 30ms 左右,如果 MCU 主频足够可以接受。

6.3 双缓冲让采集和发送彻底并行

DMA 发送期间,图像源 buffer 不能被改写。如果摄像头或屏幕采集是持续进行的,一套 buffer 来回倒腾容易出花屏。解决办法是双缓冲:采集线程写 buffer A,DMA 从 buffer B 发送;发送完成后再交换角色。实现上就是两个静态数组加一个 swap 标志,在 DMA 完成回调里翻转。我一般会把 Image_Start 的参数改成结构体,里面包含双缓冲的地址和长度,避免在多个源之间硬编码。

最后说句实际感受:这个方案我做过不止一次,每次翻车都翻在细节上,字节序、缓冲区、波特率误差这些坑基本是轮着来。如果按文章里的帧格式和状态机走一遍,至少能把问题范围缩小到“协议层”还是“物理层”。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进