简介:一份围绕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 |
| 2 | seq | 2 | 包序号,从 0 开始,小端 |
| 4 | len | 2 | 本包 payload 字节数,≤1024 |
| 6 | total | 2 | 总包数,上位机据此预分配缓冲 |
| 8 | payload | len | RGB565 像素数据 |
| 8+len | crc16 | 2 | 覆盖 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>