资讯详情

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

📅 2026/11/5 11:28:52 | 华诺云谱 👁 阅读
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 读出来按自定义帧格式分包用 UARTDMA 推到上位机上位机校验、重传、把 RGB565 还原成 RGB888 并实时显示。适合手上有 STM32 开发板、想把屏幕之外的图像通路打通的开发者。2. 链路选型与工程骨架从 Flash 里的一张 RGB565 到上位机的一帧动手写第一行代码之前有三个决定会一直影响后面的所有设计图片存成什么格式、协议长什么样、工程目录怎么切。这三件事定错了后面改起来要重写一整层。2.1 三种存储格式的取舍BMP、RGB565 裸流与 JPEGSTM32 内部 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/s921600bps ≈ 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 0x552seq2包序号从 0 开始小端4len2本包 payload 字节数≤10246total2总包数上位机据此预分配缓冲8payloadlenRGB565 像素数据8lencrc162覆盖 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, dtypenp.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; /* APB272MHz 时误差约 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(); /* W25Q6418MHz模式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, dtypeu2).astype(np.uint32) if swap: # 字节序搞反 p a hrefhttps://download.csdn.net/download/qq_37832932/12258502 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑