☰
STM32串口传彩色图片:RGB565、DMA接收与帧协议解析
2026/11/6 6:22:32 网站建设 项目流程

简介:围绕 STM32 串口发送彩色图片的完整工程包,面向已具备 STM32 基础、希望打通图像采集—串口传输—上位机显示链路的嵌入式学习者与开发者。内容以正点原子 miniSTM32 搭配 OV7725 为主线,涉及 GPIO/DMA 读取像素、UART 分包发送、BMP 文件头与 RGB 数据组织,以及上位机串口接收、字节序处理和图像重构显示等关键环节。压缩包共 499 个文件,约 10.92MB,包含 45 个 .h 与 43 个 .c 源码、Keil 工程文件(.uvprojx/.uvoptx)、编译产物(.axf/.hex/.map)、示例 BMP 图片,以及 C# 上位机相关 .sln/.csproj/.exe 等,兼顾下位机固件与 PC 端解析程序。已有 5808 人学习,适合按工程结构对照调试,理解串口传图的数据组织、传输效率与显示验证过程,也可作为课程设计、毕业设计或图像传输实验的参考。

1. 从一块点不亮的屏说起:STM32 串口发彩色图片到底难在哪

屏幕点亮了,单色刷屏也跑通了,可一旦往串口里塞一张手机拍的彩色照片,画面就开始花——要么斜着一条一条错位,要么红蓝互换,要么前两行正常后面全黑。STM32 串口发彩色图片这件事,真正卡人的从来不是 UART 初始化和HAL_UART_Transmit,而是三件更脏的活:怎么把一张 PNG 压成屏幕认识的 RGB565 字节流,怎么让这十几万字节在一条没有边界的串口上不丢不乱,以及怎么控制发送节奏别把接收端撑爆。这套流程做完,你会得到一个可复用的图片传输通道,后续改成相册、开机动画、远程截图都只是换命令的事。适合手上有 STM32 开发板加一块 SPI 彩屏、想脱离 SD 卡塞图的人。

2. 链路拆解:一张 PNG 到一次像素写入要经过几道手

先把整条链路的每个环节摆出来,不然写代码的时候很容易把「图片预处理」和「字节序对齐」混成一件事,最后排查到怀疑人生。

整体方向是上位机发、STM32 收。上位机把图片解码成 RGB 像素,量化成 RGB565,再按自定义帧格式打包,通过 USB 转串口送到 STM32 的 USART;STM32 侧用 DMA 接收,进环形缓冲,由一个帧解析状态机把包拆出来,像素载荷直接往 LCD 的 GRAM 里灌。这条链路上没有文件系统、没有 SD 卡,屏幕要显示什么完全由上位机决定,改一张图只需要在上位机换文件名。

2.1 工程目录与模块划分

我一般会把工程拆成三层:驱动层、协议层、应用层。驱动层只认寄存器,协议层只认字节,应用层才认「图片」这个概念。这样分层之后,换屏幕型号只动驱动层,换上位机只动协议层,两边不会互相牵连。

stm32_uart_image/ ├── Drivers/ │ ├── lcd/ # 屏幕驱动:ILI9341 初始化、写命令、写数据 │ │ ├── lcd.c │ │ └── lcd.h │ └── uart/ # 串口底层:DMA 接收 + 空闲中断 + 环形缓冲 │ ├── uart_rx.c │ └── uart_rx.h ├── Protocol/ │ ├── frame_parser.c # 帧解析状态机,输出完整载荷 │ └── frame_parser.h ├── App/ │ ├── image_recv.c # 收到载荷后的业务处理:写窗口、灌像素 │ └── image_recv.h ├── Core/ # CubeMX 生成的 main.c、中断向量 └── host/ └── send_image.py # 上位机发送脚本

这个结构里最值得说的是Protocol/frame_parser.c。它不依赖任何硬件,输入是一段字节和长度,输出是一个回调。好处是可以在 PC 上把同一份解析逻辑编译一遍,喂假数据做单元测试,比在板子上打断点高效得多。

屏幕侧我按 240×320 的 ILI9341 来写,一帧 RGB565 的数据量是240 × 320 × 2 = 153600字节,也就是 150KB。这个数字要先记住,后面算速度和分包全都围绕它。

2.2 自定义帧协议:为什么不能裸发像素

串口是纯字节流,没有包的概念。如果上位机直接把 153600 字节像素倒进去,STM32 那边只要因为中断延迟漏掉一个字节,后面所有像素就会整体错位一个,表现出来就是整屏斜纹。更糟的是你没法知道漏了——数据看起来还是连续的一串。

所以必须自己造边界。我用的是固定头 + 变长载荷的结构:

字段长度取值说明
帧头2 字节0xA5 0x5A连续两个字节才认,降低误判
命令1 字节0x01/0x02/0x030x01 图片信息,0x02 像素数据,0x03 传输结束
序号2 字节从 0 递增用来发现丢包和乱序
长度2 字节载荷字节数小端,最大 4096
载荷N 字节像素或参数像素数据固定 960 字节
校验1 字节异或覆盖命令到载荷末尾

先发一条 0x01 命令,载荷里带图片宽高和格式,STM32 收到后清屏、设好显示窗口、回一个 ACK。然后发 0x02,序号从 0 一路递增。最后发 0x03 收尾。这样 STM32 在收第一包像素之前就已经知道该往哪个坐标写,不用在像素流里夹坐标信息。

校验用异或而不是 CRC16,原因是这一层主要防的是「长度字段被噪声打错」这种粗错,真丢字节靠序号发现更直接,而且异或计算在中断上下文里几乎不占时间。如果你跑在电磁环境差的地方,换成 CRC8 也只是一行表的事。

提示:帧头用两个字节而不是一个,是为了让解析器在失步之后能快速重新对齐。单字节帧头的误判率在 150KB 的流量下会明显偏高。

2.3 像素格式:RGB565 与那个必须想清楚的字节序

屏幕是 16 位色,每个像素两个字节。RGB 三个分量按 5-6-5 压缩:

bit15..11 = R (5 位) bit10..5 = G (6 位) bit4..0 = B (5 位)

r取高 5 位、g取高 6 位、b取高 5 位,拼成一个uint16_t。麻烦在于这两个字节的先后顺序:SPI 写 GRAM 时通常是高字节先出,而 STM32 内部是小端,如果把uint16_t的内存直接当字节数组发出去,低字节会跑到前面,屏幕上就是红蓝互换加颜色发灰。

所以上位机在打包时必须显式按高字节在前输出,STM32 侧则可以直接按uint16_t指针写进 GRAM。两边约定死了这个顺序,后面所有问题都不用再猜。

另外还有一个坑:部分屏幕模组的默认像素顺序是 BGR 而不是 RGB,这会让你明明字节序对了,颜色还是红蓝颠倒。这个问题在 ILI9341 上通过MADCTL寄存器的 BGR 位解决,一条初始化命令的事,但如果不知道,能排查半天。

3. 上位机发送端:把一张照片切成 STM32 吃得下的包

上位机是整个链路里最容易改的部分,也是我建议先写完再动板子的一端。原因很实际:Python 里调试图省事,print一下把二进制写文件,拿十六进制编辑器看一眼就知道协议对不对;反过来在 MCU 上抓串口数据,工具链代价高得多。

3.1 图片转 RGB565 字节流

用 Pillow 打开图片,缩放到屏幕分辨率,逐像素量化。这段代码不复杂,但有两个细节必须做对:缩放要用resize而不是裁切,否则长宽比不对;输出的字节序必须手动指定。

from PIL import Image def image_to_rgb565(path, width=240, height=320): """把任意图片转成 width*height 个像素的 RGB565 字节流,高字节在前。""" im = Image.open(path).convert("RGB") im = im.resize((width, height), Image.LANCZOS) # 强制缩放到屏幕尺寸 out = bytearray() for r, g, b in im.getdata(): # 5-6-5 量化:低位直接截断,够用且快 v = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3) out.append((v >> 8) & 0xFF) # 高字节先,对应 SPI 发送顺序 out.append(v & 0xFF) # 低字节后 return bytes(out)

Image.LANCZOS比默认的NEAREST慢一点,但缩到 240×320 这种小尺寸时差别肉眼可见,尤其是细节多的照片,用最近邻会出现明显锯齿和摩尔纹。量化时r & 0xF8等价于取高 5 位,g & 0xFC取高 6 位,截断而不是四舍五入,颜色会整体略偏暗一点点,但省掉一次除法,在实际显示效果上完全看不出来。

返回的bytes长度必须是width * height * 2。这个断言建议加上,一旦写错,后面花屏的时候你会先怀疑协议而不是转换函数。

3.2 分包发送与节奏控制

960 字节一包,正好是 240 宽屏幕两行的数据量(240 × 2 × 2)。这个数字不是随便定的:包长如果是行字节数的整数倍,STM32 侧每次收到的数据都能完整落在若干行上,写 GRAM 的地址计算简单,出问题也容易定位到具体行。

import serial, struct, time def build_frame(cmd, seq, payload): """组装一帧:帧头 + 命令 + 序号 + 长度 + 载荷 + 异或校验。""" body = struct.pack("<BHH", cmd, seq, len(payload)) + payload checksum = 0 for byte in body: checksum ^= byte return b"\xA5\x5A" + body + bytes([checksum]) def send_image(port="COM3", baud=921600, path="test.jpg"): ser = serial.Serial(port, baud, timeout=1) pixels = image_to_rgb565(path) assert len(pixels) == 240 * 320 * 2 # 0x01:先告诉对方尺寸,让它把显示窗口准备好 info = struct.pack("<HHB", 240, 320, 0x01) ser.write(build_frame(0x01, 0, info)) ser.flush() chunk = 960 seq = 0 for offset in range(0, len(pixels), chunk): ser.write(build_frame(0x02, seq, pixels[offset:offset + chunk])) seq += 1 # 每 8 包歇 1ms,给接收端留出写 GRAM 的时间 if seq % 8 == 0: time.sleep(0.001) ser.write(build_frame(0x03, seq, b"")) # 0x03:结束标志 ser.close()

build_frame里struct.pack("<BHH", ...)用的是小端,和 STM32 的字节序一致,解析时可以直接按小端读。校验只覆盖 body 不含帧头,因为帧头的两个字节本身就是固定的,参与计算反而增加一次比较。

time.sleep(0.001)这个节流是血泪经验。921600 波特率下理论速率约 90KB/s,一帧 150KB 数据 1.7 秒发完,但 STM32 那边每收一包都要进中断、拷数据、算地址、走 SPI 写显存,SPI 时钟如果只有 18MHz,写 960 字节就要 0.43ms。发送端不作任何停顿,接收端的 DMA 缓冲很快就会被下一包覆盖。每 8 包停 1ms 是个经验值,具体要看你的 SPI 速度和 DMA 缓冲大小。

注意:不要用ser.write的返回值判断成功,它返回的是写入驱动缓冲的字节数,不是对方收到的字节数。真正确认要靠 STM32 回的 ACK,或者干脆不确认、靠序号在显示上发现问题。

3.3 先跑一遍空转校验

正式上板之前,我习惯先把整帧拼出来写进文件,用十六进制编辑器核对前几个字节,再算一遍总长度对不对。这一步花不了两分钟,但能挡掉大半「协议本身写错」的低级问题——比如长度字段用了大端、校验漏了最后一个字节、最后一包不足 960 字节没处理。

4. STM32 接收端:DMA 加空闲中断把包收干净

接收端是整套东西里最容易翻车的地方。用HAL_UART_Receive一字节一字节收,150KB 数据光中断就能把 CPU 打满,而且一定会丢。正确做法是 DMA 搬运加空闲中断判包边界,再加一层环形缓冲做解耦。

4.1 DMA 接收与环形缓冲

CubeMX 里把 USART1 的 RX 配成 DMA Circular 模式,然后开USART1_IRQn。用HAL_UARTEx_ReceiveToIdle_DMA启动,这个 API 的好处是:DMA 一直搬,总线空闲一个字符时间就触发一次回调,告诉你从启动到现在收了多少字节。

#define RX_DMA_SIZE 2048 #define RING_SIZE 8192 static uint8_t s_dma_buf[RX_DMA_SIZE]; static uint8_t s_ring[RING_SIZE]; static volatile uint16_t s_ring_wr = 0; // 只由中断写 static volatile uint16_t s_ring_rd = 0; // 只由主循环写 /* DMA + 空闲中断回调:Size 是本次空闲前累计收到的字节数 */ void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance != USART1) return; for (uint16_t i = 0; i < Size; i++) { uint16_t next = (s_ring_wr + 1) % RING_SIZE; if (next == s_ring_rd) { /* 环形缓冲满,说明主循环处理不过来,丢新数据并置标志 */ g_ring_overflow = 1; break; } s_ring[s_ring_wr] = s_dma_buf[i]; s_ring_wr = next; } /* 重新武装,继续接收下一批 */ HAL_UARTEx_ReceiveToIdle_DMA(&huart1, s_dma_buf, RX_DMA_SIZE); }

回调里做的事很少:把 DMA 缓冲里的新数据搬进环形缓冲,然后重新启动接收。这里刻意不在中断里解析协议,因为状态机要处理变长载荷,耗时不可控;主循环轮询环形缓冲,解析失败也不影响收数据。

RX_DMA_SIZE取 2048,是 960 包长的两倍多,正常情况下一次空闲中断能收一到两包。RING_SIZE给到 8192,留了大约 8 包的余量。这两个值不用抠太细,但环形缓冲一定要比 DMA 缓冲大,否则等于没做解耦。

启动时记得关掉 DMA 的半满中断,只留空闲中断,不然中断次数翻倍,收益为零。

4.2 帧解析状态机

解析器吃环形缓冲里的字节,吐出完整载荷。用状态机而不是「找帧头然后 memcpy」,是因为状态机可以随时中断、下次接着来,天然适配流式数据。

typedef enum { ST_HEAD0 = 0, ST_HEAD1, ST_CMD, ST_SEQ_L, ST_SEQ_H, ST_LEN_L, ST_LEN_H, ST_PAYLOAD, ST_CRC } parse_state_t; static parse_state_t s_state = ST_HEAD0; static uint8_t s_cmd, s_crc_calc; static uint16_t s_seq, s_len, s_payload_idx; static uint8_t s_payload[4096]; /* 每吃一个字节返回一次,返回 1 表示一帧收完 */ int frame_parser_feed(uint8_t byte) { switch (s_state) { case ST_HEAD0: if (byte == 0xA5) { s_state = ST_HEAD1; } break; case ST_HEAD1: s_state = (byte == 0x5A) ? ST_CMD : ST_HEAD0; // 失配则重新找头 break; case ST_CMD: s_cmd = byte; s_crc_calc = byte; s_state = ST_SEQ_L; break; case ST_SEQ_L: s_seq = byte; s_crc_calc ^= byte; s_state = ST_SEQ_H; break; case ST_SEQ_H: s_seq |= (uint16_t)byte << 8; s_crc_calc ^= byte; s_state = ST_LEN_L; break; case ST_LEN_L: s_len = byte; s_crc_calc ^= byte; s_state = ST_LEN_H; break; case ST_LEN_H: s_len |= (uint16_t)byte << 8; s_crc_calc ^= byte; s_payload_idx = 0; if (s_len > sizeof(s_payload)) { s_state = ST_HEAD0; break; } // 长度非法,丢弃 s_state = (s_len == 0) ? ST_CRC : ST_PAYLOAD; break; case ST_PAYLOAD: s_payload[s_payload_idx++] = byte; s_crc_calc ^= byte; if (s_payload_idx >= s_len) { s_state = ST_CRC; } break; case ST_CRC: s_state = ST_HEAD0; if (byte == s_crc_calc) { return 1; } // 校验通过,交给上层 break; } return 0; }

状态机里最关键的一行是if (s_len > sizeof(s_payload))的越界保护。串口线上出现噪声时,长度字段可能被打成一个很大的值,没有这一句就是一次实打实的缓冲区溢出,表现是随机死机,用调试器都很难抓到。加了这一句,非法帧直接丢弃并重新找帧头,最坏情况是丢一包而不是整机重启。

校验通过后return 1,主循环拿着s_cmd、s_seq、s_payload、s_len去分派。命令 0x01 时记录宽高并调用屏幕初始化窗口,0x02 时把载荷当像素写进去,0x03 时收尾。序号s_seq用来检查是否连续,如果发现跳号,可以在屏幕上打一个标记,调的时候一眼就能看出来丢了哪一包。

4.3 写显存:一次设窗,连续灌数据

ILI9341 的写像素流程是固定的:先发0x2A设列地址范围,再发0x2B设行地址范围,然后发0x2C进入写 GRAM 模式,之后连续发数据,地址会自增。所以每包数据都要先算出它覆盖哪几行,设一次窗口,再灌 960 字节。

void lcd_write_pixels(uint32_t pixel_offset, const uint8_t *data, uint16_t byte_len) { uint16_t x0 = 0, x1 = LCD_WIDTH - 1; uint16_t y0 = pixel_offset / LCD_WIDTH; uint16_t rows = (byte_len / 2) / LCD_WIDTH; // 本包覆盖的完整行数 uint16_t y1 = y0 + rows - 1; lcd_cmd(0x2A); lcd_data16(x0); lcd_data16(x1); // 列范围 lcd_cmd(0x2B); lcd_data16(y0); lcd_data16(y1); // 行范围 lcd_cmd(0x2C); // 进入写 GRAM lcd_cs_low(); lcd_dc_high(); // 数据模式 for (uint16_t i = 0; i < byte_len; i++) { lcd_spi_write(data[i]); } lcd_cs_high(); }

pixel_offset由主循环维护,每收完一包加上byte_len / 2。这样不管包怎么分,写入位置都是连续的,不会出现包与包之间错位。

SPI 传输这段建议改成 DMA 或者至少用寄存器直写DR加忙等待,用HAL_SPI_Transmit一字节一发在这个数据量下太慢。960 字节在 18MHz SPI 下大约 0.43ms,如果每字节都走一次 HAL 函数调用,实际会拖到 2ms 以上,直接导致接收端跟不上。

一个更省事的优化是:包长固定为整行(480 字节)时,rows永远等于 1,坐标计算退化成y0 = offset / 480,连除法都能省掉。

5. 避坑与排查:串口传图最常见的五个翻车点

下面这几条基本覆盖了我在这个链路上遇到过的绝大多数问题,按出现频率排。每条都写清楚现象、原因和改法,遇到时对着看,比盲猜快得多。

5.1 整屏斜纹错位:包长和行宽对不上

现象是图片能显示,但每一行都往右偏一点,越往下偏得越多,形成斜纹。有时重传能恢复,有时一直歪。

原因是包长不是行字节数的整数倍,比如改成了 1000 字节。写 GRAM 时地址自增是按像素走的,包与包之间如果没重新设窗口,偏移量就会累积。再加上丢包后序号没做处理,错位会越滚越大。

改法是把包长严格设成行字节数 × 整数,240 宽就是 480 或 960。同时在接收端校验序号连续,发现跳号就用序号反推pixel_offset重设窗口,而不是继续按累计值写。

5.2 颜色整体发灰或红蓝互换:字节序和 BGR 位

现象是图片结构正常,但颜色不对,人脸发蓝,天空发橙,或者整体像蒙了一层灰。

前者是字节序反了,上位机按小端输出了低字节在前,STM32 直接当uint16_t用就颠倒了。后者是屏幕模组的MADCTL默认走 BGR 顺序,和你按 RGB 打包的数据对不上。

改法分两步:先在十六进制编辑器里看上位机输出的前四个字节,和手算的 RGB565 值对一遍;再把MADCTL的值改成0x48(RGB)或0x08差异位,逐个试。这两个问题经常同时出现,先修字节序再调 BGR,不要一起改。

5.3 前半张正常后半张全黑:串口溢出

现象很典型,图片上半部分完全正常,到某个位置之后变成纯黑或者纯色,重启再传位置还不一样。

原因是ORE(溢出错误)标志置位之后,USART 接收就停了,后面所有数据全丢。触发条件是发送端太快,DMA 缓冲被覆盖,或者主循环在写 GRAM 时长时间没取环形缓冲的数据。

改法是三件事一起做:在HAL_UART_ErrorCallback里清ORE标志并重新启动 DMA 接收,这一步是后悔药,不做的话一次溢出后面全废;把环形缓冲从 2KB 加到 8KB;上位机每 8 包加 1ms 延时。三件事做完基本不会再出现。

5.4 第一屏总有噪点或第一行花:上电时序没对齐

现象是每次上电后第一次传图,屏幕最上面几行是彩色噪点,第二次开始正常。

原因是 STM32 的屏幕初始化还在跑,或者串口接收已经开了,上位机就开始发数据了。第一包数据落在屏幕还没设好窗口的时候,被当成命令写进去了。

改法是在协议里加一个握手:STM32 完成 LCD 初始化、串口 DMA 启动之后,主动发一条「就绪」帧给上位机,上位机收到才开始发图片。多写十几行代码,后续调试能省掉大量「这到底是硬件问题还是时序问题」的自我怀疑。

5.5 传完一帧要五六秒:波特率和 SPI 都没吃满

现象是画面完整,但慢得让人怀疑人生,一张图要等五六秒。

算一下就清楚:153600 字节,115200 波特率下 8N1 每字节 10 位,理论极限 11520 B/s,至少 13 秒;就算 921600 也要 1.7 秒以上。如果实际比这个还慢,说明 SPI 写屏那一侧拖了后腿。

改法是把两头的瓶颈分开量:先用秒表测纯串口传输时间(把写屏部分注释掉),再测纯写屏时间。串口慢就提波特率到 921600 或 2M,SPI 慢就把HAL_SPI_Transmit换成 DMA 或者寄存器直写。这两件事分开做,不要一起改,否则你永远不知道是哪一项起了作用。

6. 二次开发:从单张图到相册和开机动画

单张图跑通之后,这套链路最值钱的延伸是做成一个小相册或者开机动画。改动量不大,但需要动几处设计。

先说相册。最直接的做法是在 STM32 侧加一颗 SPI Flash(比如 8MB 的 W25Q64),上位机传图时用命令 0x01 的载荷带一个槽位号,STM32 收到像素后不写屏,而是写 Flash;再加一条命令 0x04 表示「从第 N 个槽位显示」,STM32 从 Flash 读出来往 GRAM 灌。一张 150KB 的图大概占 150KB 空间,8MB 能存五十多张,够用了。协议上只需要多定义一个命令字和两字节槽位号,其余不变。

/* 显示指定槽位的图片:从 Flash 搬到 GRAM */ void album_show(uint16_t slot) { uint32_t addr = ALBUM_BASE + (uint32_t)slot * FRAME_BYTES; uint8_t buf[960]; lcd_set_window_full(); /* 整屏设窗,只设一次 */ lcd_cmd(0x2C); for (uint32_t off = 0; off < FRAME_BYTES; off += sizeof(buf)) { w25q_read(addr + off, buf, sizeof(buf)); lcd_write_bytes(buf, sizeof(buf)); } }

FRAME_BYTES是240*320*2 = 153600,槽位号乘上去就是起始地址。整屏只设一次窗口,后面连续灌数据,比按包设窗口快不少。播放速度受 Flash 读取和 SPI 写屏限制,8MB Flash 在 40MHz SPI 下读 150KB 大约 30ms,屏幕写入大约 60ms,理论上能做到十几帧每秒,做个几帧的开机动画完全够。

再说动画。如果只是循环播放固定几帧,把上面的album_show放进定时器回调里按帧号递增就行。要做流畅一点,就在 RAM 里开两块 960 字节的缓冲做乒乓:一边从 Flash 读下一包,一边把当前包往屏幕写,读和写并行,能把帧率再拉高一些。

改的时候有两个地方容易踩:一是 Flash 写入必须以扇区为单位擦除,4KB 一个扇区,你不能只覆盖一张图的一部分,得整扇区擦完再写;二是写入期间不能同时接收串口数据,否则 Flash 操作的几十毫秒里串口就溢出了,得在协议上加一个「传输中」标志,让上位机等确认再发下一张。

我自己的习惯是,每次改协议加新命令之前,先在纸上把帧格式重新画一遍,把新字段的位置定死,再动代码。这个动作看着笨,但我吃过太多次「命令字加了一个字节,忘了同步改上位机」的亏,两边的校验一错,现象就是整屏黑,而黑屏能有一百种原因。从那以后我每次改完协议,都强制先用上位机发一包假数据、在 STM32 侧把解析出的字段打印到串口上核对一遍,确认无误再往下接屏幕。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询