ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

STM32串口DMA发送RGB565图片:分包协议与CRC校验实践

STM32串口DMA发送RGB565图片:分包协议与CRC校验实践

简介:一份围绕STM32串口传输彩色图像的嵌入式实践资料包,面向学习摄像头采集、串口通信与上位机图像还原的开发者,也可用于课程设计或项目原型验证。压缩包共499个文件、约10.92MB,包含C/H源码、Keil工程与配置、hex/axf/map等编译产物、BMP示例图片,以及由cs/csproj/sln/resx等组成的上位机工程文件,基本覆盖从下位机采集发送到PC端接收显示的完整链路。已有5808人学习下载,说明其在STM32图像传输入门与调试场景中具有较高参考价值。资料围绕OV7725图像数据采集、DMA读取、串口编码发送、上位机按BMP格式解析与显示等环节展开,读者可据此对照源码理解数据打包、字节序处理和串口速率配置,并借助现成工程与示例图快速搭建实验环境、排查传输丢包或图像错位问题。

1. 从一张 320×240 的彩图说起:STM32 串口发彩色图片到底难在哪

上位机点一下「读图」,STM32 那边一帧 320×240 的彩色图片就开始从串口往外发数据:153600 字节,115200 波特率要跑 13 秒,中间错一个字节,屏幕上就是一片雪花。我第一次做 STM32 串口发彩色图片,卡住的不是串口初始化,也不是上位机接收代码,而是协议——没有帧头、没有序号、没有校验,接收端靠「读到多少算多少」去拼接,结果必然是花屏加错位。这个项目就是解决这一整套问题:把 RGB565 图片从 SPI Flash 读出来,按自定义帧格式分包,用 UART+DMA 推到上位机,上位机校验、重传、把 RGB565 还原成 RGB888 并实时显示。适合手上有 STM32 开发板、想把屏幕之外的图像通路打通的开发者。

2. 链路选型与工程骨架:从 Flash 里的一张 RGB565 到上位机的一帧

动手写第一行代码之前,有三个决定会一直影响后面的所有设计:图片存成什么格式、协议长什么样、工程目录怎么切。这三件事定错了,后面改起来要重写一整层。

2.1 三种存储格式的取舍:BMP、RGB565 裸流与 JPEG

STM32 内部 Flash 就那么点,一张 320×240 的图根本放不下,所以图片一定要落到外部存储上。常见做法是挂一颗 SPI Flash(比如 8MB 的 W25Q64),把预先转换好的图片二进制烧进去,片内只保留读 Flash 和发送的代码。

图片本身用什么格式,直接决定传输时间。按 320×240 这一档算:

格式单帧字节数115200bps 耗时921600bps 耗时下位机成本上位机成本
BMP 24bit约 230 KB约 20.0 s约 2.5 s无,原样透传无,直接打开
RGB565 裸流153,600 B约 13.3 s约 1.7 s无需要一次颜色转换
JPEG q80约 12 KB约 1.0 s约 0.13 s需要编解码能力需要 JPEG 解码

串口 8N1 每字节实际占 10 个位,所以 115200bps ≈ 11520 B/s,921600bps ≈ 92160 B/s,上表就是按这个折算的。

选型建议很直接:先跑通就选 RGB565 裸流。它没有文件头、没有行对齐填充,一张图就是宽 × 高 × 2字节的连续数组,下位机只管从 Flash 里按块读、按块发,逻辑最少。BMP 多出来的 54 字节文件头和每行 4 字节对齐填充对嵌入式毫无意义,纯属负担。等你把流程跑通、嫌慢了,再换 JPEG——但要清楚,JPEG 省的是传输时间,代价是把复杂度挪到了编解码上,STM32F1 软解一张 320×240 的 JPEG 要几十毫秒到上百毫秒,这也是成本。

RGB565 的位分配是:高 5 位红、中间 6 位绿、低 5 位蓝。绿色多一位是因为人眼对绿光最敏感,这是所有 16 位色格式的通行做法。

2.2 分包协议设计:帧头、序号、长度、CRC 一个都不能省

串口是字节流,没有消息边界。你连着发 150 个 1KB 的包,PC 端收到的可能是一个 150KB 的大缓冲,也可能被切成无数个 64 字节的小块——粘包和半包是必然而不是偶然。所以协议里必须自带边界信息。

我用的帧格式是这样:

偏移字段长度说明
0帧头2固定0xAA 0x55
2seq2包序号,从 0 开始,小端
4len2本包 payload 字节数,≤1024
6total2总包数,上位机据此预分配缓冲
8payloadlenRGB565 像素数据
8+lencrc162覆盖 seq..payload 全部字节,小端

几个设计点值得说一下。

为什么要有 total 字段。上位机收到第一包就知道整张图多大,可以一次性bytearray(w*h*2)把缓冲区开好,后面按seq * 1024直接算出写入偏移。不靠「累计已收字节数」来定位,这一条能省掉后面最恶心的一类错位 bug。

为什么用 CRC16 而不是累加和。累加和对字节顺序不敏感,两个字节互换它检测不出来;CRC16 对单比特、双比特、突发错误都有很好的检出率,一查表也就几微秒的事。多项式用 MODBUS 的0xA001反序形式,网上到处是对得上的实现,下位机和上位机各写一份不会对不上。

为什么 ACK 只用 1 个字节。上位机校验通过回0x06,校验失败回0x15,下位机收到0x06才发下一包。这样做的代价是每包多一个来回,收益是丢包能被立刻发现并重发,不用做复杂的滑动窗口。命令通道也复用同一条串口:0x01查图像信息,0x02开始发图。

提示:ACK 一定要配超时。我见过不止一个工程在while(!ack_received);上死等,上位机一拔线,整个固件就挂在那儿,看门狗都救不回来。

2.3 目录结构与工具链:一份可复现的工程骨架

工程分成下位机固件、上位机程序、离线工具三块,边界清晰:

stm32-img-link/ ├── firmware/ │ ├── Core/Src/main.c /* 命令分发主循环 */ │ ├── Core/Src/uart_dma.c /* USART1 + DMA 初始化与回调 */ │ ├── Core/Src/img_proto.c /* 组帧、CRC、ACK 握手 */ │ ├── Core/Src/spi_flash.c /* W25Q64 读接口 */ │ └── Core/Inc/img_proto.h /* 帧格式常量与对外接口 */ ├── host/ │ ├── protocol.py /* 帧解析状态机、CRC16 */ │ ├── receiver.py /* 收包线程 + 重传逻辑 */ │ ├── viewer.py /* PySide6 窗口与图像渲染 */ │ └── img2rgb565.py /* PNG/JPG → .bin 转换工具 */ └── tools/ └── flash_writer.md /* 烧录地址与校验方法说明 */

离线工具是整条链路的入口,把任意一张图片转成固件认识的裸流:

# host/img2rgb565.py import sys import numpy as np from PIL import Image def convert(src: str, dst: str, width: int = 320, height: int = 240) -> None: img = Image.open(src).convert('RGB') # 先裁再缩放,避免非等比拉伸把画面搞变形 img = img.resize((width, height), Image.LANCZOS) a = np.asarray(img, dtype=np.uint16) r = (a[..., 0] >> 3) & 0x1F # 5 bit g = (a[..., 1] >> 2) & 0x3F # 6 bit b = (a[..., 2] >> 3) & 0x1F # 5 bit v = (r << 11) | (g << 5) | b # RGB565 # '<u2' 表示小端 16 位,必须和 STM32 的内存字节序一致 v.astype('<u2').tofile(dst) print(f'{src} -> {dst}, {v.size * 2} bytes, {width}x{height}') if __name__ == '__main__': convert(sys.argv[1], sys.argv[2])

这里的关键是'<u2'。Cortex-M 是小端机,uint16_t的0xF800(纯红)在内存里是00 F8两个字节。工具端如果按大端写出去,上位机解出来就是红蓝互换,人脸变蓝精灵——这个坑我在第 5 章还会细说。

转换出来的.bin用烧录工具按固定地址写进 SPI Flash,固件里定义IMG_BASE_ADDR指向同一个地址,两边对齐就行。我一般会把地址和图片尺寸写进img_proto.h,改图的时候只改这两个宏,别散落在各处。

3. 下位机实现:HAL + DMA 的分包发送与 ACK 握手机制

下位机的活分三件:把串口和 DMA 配好、把一块图像数据包成协议帧、在发每帧之后等上位机确认。用阻塞式HAL_UART_Transmit也能跑,但 1KB 数据在 921600 波特率下要占 CPU 将近 11 毫秒,主循环被完全堵死;换成 DMA 之后 CPU 只负责填缓冲和等中断,实际项目里必须这么干。

3.1 USART1 + DMA1 通道 4 的初始化与回调

/* uart_dma.c */ #include "main.h" UART_HandleTypeDef huart1; DMA_HandleTypeDef hdma_usart1_tx; void MX_USART1_UART_Init(void) { huart1.Instance = USART1; huart1.Init.BaudRate = 921600; /* APB2=72MHz 时误差约 0.16% */ 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; huart1.Init.OverSampling = UART_OVERSAMPLING_16; HAL_UART_Init(&huart1); /* 普通模式,每包重新装填长度;不用循环模式,避免包尾多吐字节 */ hdma_usart1_tx.Instance = DMA1_Channel4; 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_NVIC_SetPriority(DMA1_Channel4_IRQn, 1, 0); HAL_NVIC_EnableIRQ(DMA1_Channel4_IRQn); } /* 发送完成回调:只置标志,不做任何耗时操作 */ volatile uint8_t g_tx_done = 0; void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) g_tx_done = 1; }

DMA_NORMAL和DMA_CIRCULAR这里必须选前者。循环模式下 DMA 会把缓冲当成环形区一直转,包发完了还继续往外吐旧数据,上位机就会看到一堆莫名其妙的脏字节。普通模式每次发送都要重新调用HAL_UART_Transmit_DMA,多一次函数调用,换来的是边界干净。

波特率这块有个容易忽略的点:921600在 72MHz 的 APB2 上算出来的 USARTDIV 是 4.875,实际波特率约 923077,误差 0.16%,完全可用。但如果你的工程用的是内部 HSI 时钟(8MHz 经 PLL 倍频,实际可能偏 1% 以上),这个误差会被放大到接近 1.2%,超过了 UART 能容忍的 2%~3% 的一半,长时间连续收发就容易偶发错位。串口发图这种长时间大数据量的场景,一定要用外部晶振。

还有一点:g_tx_done加了volatile。中断里改、主循环里读,不加编译器可能把它优化进寄存器,主循环永远等不到。

3.2 组帧函数:把 1KB 图像块塞进协议帧

/* img_proto.h */ #define IMG_HEAD0 0xAA #define IMG_HEAD1 0x55 #define IMG_CHUNK 1024 #define IMG_TX_TIMEOUT 100 /* ms,单包等 ACK 的上限 */ #define IMG_RETRY_MAX 3 #define IMG_ACK_OK 0x06 #define IMG_ACK_NAK 0x15 #define IMG_BASE_ADDR 0x00000000UL /* W25Q64 里的图片起始地址 */ #define IMG_WIDTH 320 #define IMG_HEIGHT 240 uint16_t crc16_modbus(const uint8_t *p, uint32_t n); int img_send_image(uint32_t addr, uint32_t size);
/* img_proto.c */ static uint8_t tx_buf[IMG_CHUNK + 16]; /* 帧头8 + 数据 + CRC2,留点余量 */ extern volatile uint8_t g_tx_done; uint16_t crc16_modbus(const uint8_t *p, uint32_t n) { uint16_t crc = 0xFFFF; while (n--) { crc ^= *p++; for (uint8_t i = 0; i < 8; i++) crc = (crc & 1) ? (uint16_t)((crc >> 1) ^ 0xA001) : (uint16_t)(crc >> 1); } return crc; } /* 发一帧并等 ACK,成功返回 0 */ static int send_frame_and_wait(uint16_t seq, const uint8_t *buf, uint16_t n) { uint8_t ack; for (uint8_t retry = 0; retry < IMG_RETRY_MAX; retry++) { g_tx_done = 0; if (HAL_UART_Transmit_DMA(&huart1, (uint8_t *)buf, n) != HAL_OK) continue; /* 等 DMA 把最后一位从移位寄存器推出去 */ uint32_t t0 = HAL_GetTick(); while (!g_tx_done && (HAL_GetTick() - t0) < IMG_TX_TIMEOUT) { } if (!g_tx_done) { HAL_UART_AbortTransmit(&huart1); /* 卡住了就复位发送器 */ HAL_UART_Abort(&huart1); MX_USART1_UART_Init(); /* 极端情况下重新初始化 */ continue; } if (HAL_UART_Receive(&huart1, &ack, 1, IMG_TX_TIMEOUT) == HAL_OK && ack == IMG_ACK_OK) return 0; /* 收到 NAK 或超时:带着同一个 seq 重发,上位机靠 seq 去重 */ } return -1; } int img_send_image(uint32_t addr, uint32_t size) { uint16_t total = (uint16_t)((size + IMG_CHUNK - 1) / IMG_CHUNK); for (uint16_t seq = 0; seq < total; seq++) { uint32_t off = (uint32_t)seq * IMG_CHUNK; uint16_t len = (size - off > IMG_CHUNK) ? IMG_CHUNK : (uint16_t)(size - off); uint8_t *p = tx_buf; *p++ = IMG_HEAD0; *p++ = IMG_HEAD1; /* 逐字节写,不用 *(uint16_t*)p 强转,M0 内核上非对齐访问会 HardFault */ *p++ = (uint8_t)(seq & 0xFF); *p++ = (uint8_t)(seq >> 8); *p++ = (uint8_t)(len & 0xFF); *p++ = (uint8_t)(len >> 8); *p++ = (uint8_t)(total & 0xFF); *p++ = (uint8_t)(total >> 8); w25q_read(addr + off, p, len); /* SPI Flash 直读进发送缓冲 */ p += len; uint16_t crc = crc16_modbus(tx_buf + 2, (uint32_t)(6 + len)); *p++ = (uint8_t)(crc & 0xFF); *p++ = (uint8_t)(crc >> 8); if (send_frame_and_wait(seq, tx_buf, (uint16_t)(p - tx_buf)) != 0) return -(int)seq; /* 负数返回出错包号,方便上位机定位 */ } return 0; }

p的推进顺序和协议表严格对齐:先 2 字节帧头,再 2 字节 seq、2 字节 len、2 字节 total,共 8 字节,然后挂数据、挂 CRC。CRC 的起点是tx_buf + 2、长度是6 + len,也就是从 seq 的第一字节算到 payload 的最后一字节,帧头不参与校验——帧头参与校验没有意义,它就是用来找边界的。

send_frame_and_wait里那个HAL_UART_AbortTransmit加重新初始化是个经验做法。DMA 卡住之后,USART 的 TXE/TC 标志有时候会停在奇怪的状态,光 abort 不清干净,后面每次发送都会被 HAL 判成 BUSY 直接返回。重新初始化一遍是最省心的恢复手段,代价是几个微秒。

还有一种更省事但性能更好的写法:不等每包 ACK,连发 8 包再统一等一次确认,丢包时按 seq 补发。这个版本的空口利用率能到 95% 以上,但上位机要维护接收窗口。先跑通单包握手,再升级到这个版本,改起来不难。

3.3 主循环与命令分发:握手节奏控制

/* main.c */ int main(void) { HAL_Init(); SystemClock_Config(); /* HSE 8MHz × 9 = 72MHz */ MX_GPIO_Init(); MX_DMA_Init(); MX_USART1_UART_Init(); MX_SPI1_Init(); /* W25Q64,18MHz,模式0 */ w25q_init(); uint8_t cmd; for (;;) { if (HAL_UART_Receive(&huart1, &cmd, 1, HAL_MAX_DELAY) != HAL_OK) continue; switch (cmd) { case 0x01: { /* 上报图像参数 */ uint8_t info[7] = { 0x81, 0, 0, 0, 0, 0, 0 }; info[1] = IMG_WIDTH & 0xFF; info[2] = IMG_WIDTH >> 8; info[3] = IMG_HEIGHT & 0xFF; info[4] = IMG_HEIGHT >> 8; info[5] = 0; /* 0 = RGB565 裸流 */ info[6] = 0; HAL_UART_Transmit(&huart1, info, sizeof(info), 100); break; } case 0x02: /* 开始发图 */ img_send_image(IMG_BASE_ADDR, IMG_WIDTH * IMG_HEIGHT * 2UL); break; case 0x03: /* 清空接收缓冲 */ HAL_UART_Abort(&huart1); MX_USART1_UART_Init(); break; default: break; } } }

主循环是个阻塞状态机,收到命令才动作。HAL_UART_Receive用HAL_MAX_DELAY死等是安全的,因为这只是接收命令字节,没人发就挂着,不占 CPU。真正的发送过程在img_send_image里,有完整的超时保护。

0x03这条复位命令很有用。调试阶段上位机经常在收图途中被强行关掉,STM32 那边还在等 ACK,等到超时重发三次之后返回错误码,正好卡在发送过程中。上位机重新连上时先发一条0x03,让两边状态都回到初始,能省掉大量「为什么重连之后就不动了」的排查时间。

HAL_UART_Transmit和HAL_UART_Transmit_DMA不能在同一个串口上交叉使用而不做保护。发图过程结束后 DMA 已经停了,主循环再用阻塞发送命令回复是安全的;但如果固件里有别的中断也在往 USART1 写数据,就会出现半包插队。我一般给发送路径加个简单的互斥标志,谁拿谁清,不搞复杂。

4. 上位机实现:状态机收包、CRC 校验与 RGB565 转 RGB888

上位机看着简单,实际上是整条链路里最容易翻车的一环。原因在于 PC 侧的串口接收是完全异步的:数据什么时候来、来多少、被切成几块,全看操作系统心情。所有解析逻辑必须写成「来什么喂什么」的状态机,不能假设一次read()就是一个完整帧。

4.1 收包状态机:半包、粘包与重传

# host/protocol.py import struct import serial HEAD = b'\xAA\x55' CHUNK = 1024 ACK_OK = b'\x06' ACK_NAK = b'\x15' # 一次性建表,避免每字节算 8 次循环拖慢 150KB 的校验 _CRC_TABLE = [] for _i in range(256): _c = _i for _ in range(8): _c = (_c >> 1) ^ 0xA001 if _c & 1 else _c >> 1 _CRC_TABLE.append(_c) def crc16_modbus(data: bytes) -> int: crc = 0xFFFF for b in data: crc = (crc >> 8) ^ _CRC_TABLE[(crc ^ b) & 0xFF] return crc
# host/protocol.py 续 class ImageReceiver: """按 seq 写固定偏移,不依赖累计字节数""" def __init__(self, ser: serial.Serial, width: int, height: int): self.ser = ser self.width, self.height = width, height self.buf = bytearray(width * height * 2) self.seen = set() self.total = None self._rx = bytearray() def _pop(self): """从缓冲里切出一个完整帧,返回 (seq, payload) 或 None""" while True: i = self._rx.find(HEAD) if i < 0: # 只保留最后一个字节,它可能是下一个帧头的 AA del self._rx[:max(0, len(self._rx) - 1)] return None del self._rx[:i] # 丢掉帧头之前的垃圾 if len(self._rx) < 8: return None # 半包,等下一次 read seq, ln, total = struct.unpack_from('<HHH', self._rx, 2) if ln > CHUNK or total == 0: del self._rx[:2] # 假帧头,跳过重找 continue if len(self._rx) < 10 + ln: return None # payload 还没收全 body = bytes(self._rx[2:8 + ln]) # seq..payload crc = self._rx[8 + ln] | (self._rx[9 + ln] << 8) del self._rx[:10 + ln] if crc16_modbus(body) != crc: self.ser.write(ACK_NAK) # 校验失败,要求重发 continue return seq, total, body[6:] # body[6:] 就是 payload def _handle(self, seq: int, total: int, payload: bytes) -> None: if self.total is None: self.total = total if seq in self.seen: # 重传包:数据已经有了,但 ACK 还是要回,否则下位机会一直重发到超时 self.ser.write(ACK_OK) return off = seq * CHUNK self.buf[off:off + len(payload)] = payload self.seen.add(seq) self.ser.write(ACK_OK) def run(self, timeout: float = 30.0): import time deadline = time.monotonic() + timeout while time.monotonic() < deadline: if self.total is not None and len(self.seen) == self.total: break data = self.ser.read(self.ser.in_waiting or 1) if not data: continue self._rx += data while True: pkt = self._pop() if pkt is None: break self._handle(*pkt) return bytes(self.buf), self.total, len(self.seen)

_pop里最容易写错的是最后那个「找不到帧头只保留 1 个字节」的处理。如果整段丢掉,正好卡在帧头第一个字节0xAA上,后面所有帧就全丢了;如果全保留,垃圾数据会让find每次都从头扫。保留 1 个字节是因为帧头是两字节,最后一个0xAA可能是下一帧的开头。

ln > CHUNK那个判断是防御性的。串口上突然来一段噪声,find找到的假AA 55概率不小,后面跟着的len可能是个几万的离谱值,不拦住的话10 + ln永远等不满,接收就死锁在等一个永远不会来的包。

_handle里「重复包也要 ACK」这条是血泪教训,第 5 章会展开说。

4.2 颜色还原:RGB565 解包与字节序

# host/protocol.py 续 import numpy as np def rgb565_to_rgb888(raw: bytes, swap: bool = False) -> bytes: """raw 是 width*height*2 字节,返回 width*height*3 的 RGB 数据""" a = np.frombuffer(raw, dtype='<u2').astype(np.uint32) if swap: # 字节序搞反 <p> <a href="https://download.csdn.net/download/qq_37832932/12258502" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
RELATED READING

延伸阅读

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