简介:围绕STM32串口传输彩色图片的完整工程包,面向正在学习嵌入式图像采集、串口通信与上位机解码的开发者,也适合作为正点原子miniSTM32搭配OV7725摄像头模块的实践参考。内容覆盖下位机图像数据采集、BMP格式打包与UART发送,以及上位机串口接收、字节序处理、图像重构和显示等环节,可帮助读者理解从传感器像素到PC端彩色位图的完整链路。压缩包共499个文件,约10.92MB,以h头文件、c源码、d依赖描述、sample示例、o编译产物和crf工程索引为主,同时包含sln、csproj、exe、config、resx等上位机工程与运行文件,便于对照固件端和PC端实现。已有5808人学习,适合按模块查阅采集、编码、传输与解码细节,并借鉴其中的调试思路与工程组织方式。
1. 从一张 320×240 的彩色图说起:STM32 串口发彩色图片到底卡在哪
调试现场最常见的画面是:屏幕已经能显示字符,图片一传就偏色,红色变蓝、蓝色发紫,或者只出半张。STM32串口发彩色图片这条链路拆开只有五段:图片转 RGB565、上位机分块打包、串口按帧传输、STM32 解析校验、LCD 按窗口刷像素。资源包给的是能直接对照的工程,包含上位机转换脚本、带帧头和 CRC16 的分帧协议、STM32 端 DMA 加空闲中断接收、以及刷图函数。适合已经把屏点亮、能读写显存的嵌入式开发者,也适合要做 STM32 上位机 发送图片 小工具的人。下面按复现顺序推进:先把图片变成字节,再把一帧切稳,最后看现场翻车点。
2. 图片先变成字节:RGB565 转换脚本与三个对齐参数
串口发图第一步不是写串口,而是把 PNG/JPG 变成 MCU 和屏都能吃的字节流。很多问题在这一步埋下:宽高不对、颜色通道顺序反、行尾没对齐。资源包里转换脚本是 Python,依赖 Pillow,输出 .bin。下面按“选格式—转像素—加文件头”三步走。
2.1 为什么选 RGB565,而不是把 BMP 原样丢进串口
320×240 一帧 RGB565 是 153600 字节,RGB888 是 230400 字节。921600 8N1 理论 92160 B/s,实际跑到 80% 左右,RGB565 约 2 秒,RGB888 约 3 秒;115200 下 RGB565 也接近 16 秒。BMP 有文件头、行填充,不能直接刷。RGB565 一个像素 2 字节,红 5 位、绿 6 位、蓝 5 位,屏的显存和 DMA 传输都按 16 位走。选它不是因为好看,是因为带宽和显存都省。颜色精度上,渐变图会有轻微阶梯,照片能看,纯色 UI 更稳。若屏支持 RGB888,也没必要在串口链路上传 24 位,先传 565 再在屏端扩展更划算。
还有一个经常被忽略的点:BMP 的像素顺序可能是 BGR,行尾还有 4 字节对齐填充。直接把 BMP 文件头后面的数据塞进串口,屏端按 RGB565 解释,红色和蓝色就会互换,行宽也会错。转换脚本先把图像统一到 RGB,再压成 565,等于把格式差异挡在 PC 端。字节序也要在这里定死:STM32 小端,uint16_t 0xF800在内存里是00 F8;SPI 屏通常高字节先发,并口屏直接写 16 位。转换脚本要能选小端或大端,别等屏上偏色再回头改。
2.2 用 Python 做缩放、裁剪和 RGB565 打包
from PIL import Image import struct TARGET_W, TARGET_H = 320, 240 BYTE_ORDER = "little" # "little" 给并口/内存直写,"big" 给高字节先发的 SPI 屏 ROW_ALIGN = 2 # 行字节数按 2 字节对齐;屏要求 4 字节时改成 4 def to_rgb565(img_path, out_path): img = Image.open(img_path).convert("RGB") # 先等比缩放再居中裁剪,避免直接拉伸把比例弄坏 scale = max(TARGET_W / img.width, TARGET_H / img.height) new_size = (max(1, int(img.width * scale)), max(1, int(img.height * scale))) img = img.resize(new_size, Image.LANCZOS) left = (img.width - TARGET_W) // 2 top = (img.height - TARGET_H) // 2 img = img.crop((left, top, left + TARGET_W, top + TARGET_H)) raw = bytearray() for y in range(TARGET_H): row = bytearray() for x in range(TARGET_W): r, g, b = img.getpixel((x, y)) rgb565 = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3) if BYTE_ORDER == "little": row += struct.pack("<H", rgb565) else: row += struct.pack(">H", rgb565) # 行尾补 0,保证每行字节数是 ROW_ALIGN 的整数倍 while len(row) % ROW_ALIGN: row.append(0) raw += row with open(out_path, "wb") as f: f.write(raw) if __name__ == "__main__": to_rgb565("input.png", "image_320x240_rgb565.bin")逻辑说明:先convert("RGB")去掉透明通道和灰度,再按max比例缩放保证短边填满,居中裁剪到目标尺寸,避免拉伸。逐像素取 r/g/b,用位运算压成 16 位:红取高 5 位左移 11,绿取高 6 位左移 5,蓝取高 5 位。struct.pack决定内存字节序。行尾补 0 是为了块大小按行对齐。参数说明:TARGET_W/H必须和 STM32 端宽高宏一致;BYTE_ORDER要与屏的写入函数一致;ROW_ALIGN在并口直写时通常 2,SPI 屏或 DMA 要求 4 字节对齐时改 4。
提示:先用 16×16 纯色块验证字节序,不要拿照片试。
2.3 给图片流加文件头:从裸数组到可解析的二进制
import struct MAGIC = 0xAA55 def build_image_file(raw_path, out_path, width, height): with open(raw_path, "rb") as f: payload = f.read() # 文件头:magic + width + height + payload_len + crc16(负载) header = struct.pack("<HHHI", MAGIC, width, height, len(payload)) crc = crc16_ccitt(payload) with open(out_path, "wb") as f: f.write(header) f.write(payload) f.write(struct.pack("<H", crc)) def crc16_ccitt(data, crc=0xFFFF): for b in data: crc ^= b << 8 for _ in range(8): if crc & 0x8000: crc = ((crc << 1) ^ 0x1021) & 0xFFFF else: crc = (crc << 1) & 0xFFFF return crc逻辑说明:文件头固定 10 字节,magic 用来快速判断是不是图片流,width/height 防止上位机参数和固件宏不一致,payload_len 让接收端知道整图多大,CRC16 用来先验完整性。CRC 初值0xFFFF、多项式0x1021,只算负载,不算文件头。参数说明:magic 可以改,但两端必须一致;width/height 用uint16,最大 65535;payload_len 用uint32,320×240×2 是 153600,别用uint16装。
注意:文件头本身不参与 CRC 时,接收端要先读头再算负载 CRC,顺序不能反。
常见误用是拿截图工具直接导出 BMP,再把文件头一起塞进串口。BMP 每行有 4 字节对齐填充,像素顺序还可能是 BGR,屏端按 RGB565 解释就会偏色。另一个坑是只改宽高宏不改上位机裁剪,图像被拉伸后花屏不是串口问题。转换脚本跑完先用十六进制查看器看前几个字节,纯红0xF800在小端下应是00 F8,在大端下应是F8 00,对不上就先别往下走。
3. 串口一帧不丢:分块协议、CRC16 与 DMA 空闲中断
整图 150KB 不能一次塞进串口,必须分块。分块不是随便切,块大小、帧头、校验、重传要一起定。下面给一套能跑 921600 的帧结构,再算块大小,最后落到 STM32 状态机。
3.1 帧结构定死:9 字节头 + 负载 + CRC
| 字段 | 偏移 | 长度 | 说明 |
|---|---|---|---|
| magic | 0 | 2 | 0xAA55,小端发送为先0x55后0xAA |
| cmd | 2 | 1 | 0x01图片块,0x02ACK,0x03NAK |
| seq | 3 | 2 | 块序号,从 0 开始 |
| total | 5 | 2 | 总块数,接收端可算进度 |
| len | 7 | 2 | 负载长度,最大 1024 或 2048 |
| payload | 9 | len | RGB565 字节流 |
| crc16 | 9+len | 2 | 算 magic 到 payload 末尾,不含自身 |
CRC 范围一定要写死在协议里。上位机算magic到payload末尾,STM32 端也按同样范围算。ACK 和 NAK 可以没有负载,len=0,只回magic+cmd+seq+crc。这样上位机每发一块等一个 5 字节回包,超时就重发。协议表看起来简单,真正容易翻车的是“一个算头一个不算头”,以及 CRC 字节序反了,现象是数据看着对但全部校验失败。
3.2 波特率与块大小:先算时间再选参数
| 波特率 | 8N1 理论 B/s | 常见实际吞吐 | 153600 字节耗时 | 建议块大小 |
|---|---|---|---|---|
| 115200 | 11520 | 9–10KB/s | 15–17 秒 | 512 |
| 460800 | 46080 | 38–42KB/s | 3.6–4 秒 | 1024 |
| 921600 | 92160 | 78–85KB/s | 1.8–2.1 秒 | 1024 或 2048 |
块太小,中断和回包次数多;块太大,RAM 吃紧,DMA 缓冲也难排。1024 字节对应 320 宽 RGB565 的 1.6 行,不是整行,刷屏时要处理跨行;1280 字节正好两行,刷图坐标最好算。总块数也要看:153600/1024 约 150 块,每块多 11 字节头尾,额外 1650 字节,能接受;512 字节就是 300 块,ACK 等待会把实际速度拖下来。我一般先定 1280 或 2560,让块和行对齐,再调波特率。
3.3 STM32 端接收状态机:头、长度、CRC 三段式
#define RX_BUF_SIZE 4096 #define MAX_PAYLOAD 2048 typedef enum { S_MAGIC1, S_MAGIC2, S_CMD, S_SEQ, S_TOTAL, S_LEN, S_PAYLOAD, S_CRC } parse_state_t; static uint8_t rx_dma[RX_BUF_SIZE]; static parse_state_t state = S_MAGIC1; static uint8_t frame[9 + MAX_PAYLOAD + 2]; static uint16_t idx, payload_len; void USART_IDLE_IRQHandler(void) { if (LL_USART_IsActiveFlag_IDLE(USART1)) { LL_USART_ClearFlag_IDLE(USART1); uint16_t len = RX_BUF_SIZE - LL_DMA_GetDataLength(DMA1, LL_DMA_CHANNEL_5); // 中断里只做搬运,CRC 和刷图放主循环 ringbuf_put(rx_dma, len); LL_DMA_SetDataLength(DMA1, LL_DMA_CHANNEL_5, RX_BUF_SIZE); } } void parse_rx(void) { uint8_t b; while (ringbuf_get(&b)) { switch (state) { case S_MAGIC1: if (b == 0x55) state = S_MAGIC2; break; case S_MAGIC2: state = (b == 0xAA) ? S_CMD : S_MAGIC1; break; case S_CMD: frame[2] = b; idx = 3; state = S_SEQ; break; case S_SEQ: frame[idx++] = b; if (idx == 5) state = S_TOTAL; break; case S_TOTAL: frame[idx++] = b; if (idx == 7) state = S_LEN; break; case S_LEN: frame[idx++] = b; if (idx == 9) { payload_len = frame[7] | (frame[8] << 8); if (payload_len > MAX_PAYLOAD) { state = S_MAGIC1; break; } idx = 0; state = S_PAYLOAD; } break; case S_PAYLOAD: frame[9 + idx++] = b; if (idx >= payload_len) { idx = 0; state = S_CRC; } break; case S_CRC: frame[9 + payload_len + idx++] = b; if (idx >= 2) { uint16_t crc_rx = frame[9 + payload_len] | (frame[9 + payload_len + 1] << 8); uint16_t crc_calc = crc16_ccitt(frame, 9 + payload_len); if (crc_rx == crc_calc) image_on_chunk(&frame[2], &frame[9], payload_len); else send_nak(frame[3] | (frame[4] << 8)); state = S_MAGIC1; } break; default: state = S_MAGIC1; break; } } }逻辑说明:DMA 空闲中断只把数据搬进环形缓冲,解析放在主循环,避免中断里算 CRC 拖死串口。状态机按帧头、固定字段、负载、CRC 逐字节走;帧头用0x55 0xAA是因为小端发送0xAA55。收到完整帧后算 CRC,对得上就交给刷图函数,不对回 NAK。参数说明:RX_BUF_SIZE要大于最坏中断间隔内收到的数据,921600 下 4KB 约有 40ms 余量;MAX_PAYLOAD和上位机块大小一致;frame数组要9+MAX_PAYLOAD+2,不能省。
注意:DMA 空闲中断里必须清 IDLE 标志再重装长度,顺序反了会少收一截。
ACK 流控建议做成“每块等回包,超时 50ms 重发,最多 3 次”。STM32 回 ACK 只发 5 字节,不占多少带宽。固定长度接收遇到半包就卡死,状态机能从任意字节重新找帧头,现场更耐造。CRC 先用已知向量对表,再上串口,别等屏花才怀疑数据。
4. 从接收缓冲到 LCD 显存:刷图函数与双缓冲的边界
收到一块数据不等于屏幕能看。刷图函数要处理窗口、字节序、跨行、DMA 忙。尤其块大小 1024 不是行字节 640 的整数倍,直接写会把行写歪。先讲窗口刷像素,再讲双缓冲,最后给自检图。
4.1 一块图怎么落到屏幕:窗口设置 + 连续写像素
// 假设 LCD 已初始化,坐标原点在左上,RGB565 16 位 void lcd_draw_rgb565(uint16_t x, uint16_t y, uint16_t w, uint16_t h, const uint8_t *data, uint32_t len) { lcd_set_window(x, y, x + w - 1, y + h - 1); // 设定连续写入区域 for (uint32_t i = 0; i + 1 < len; i += 2) { uint16_t px = (uint16_t)data[i] | ((uint16_t)data[i + 1] << 8); // 小端拼像素 lcd_write_data16(px); } }逻辑说明:先设窗口,再连续写像素,屏内部地址会自动递增,比一个一个设坐标快。data是上位机来的 RGB565 小端字节流,拼成uint16_t再写。参数说明:x/y是块左上角,w/h是块的像素宽高;len是负载字节数,必须是 2 的倍数;如果屏是 SPI 且要求高字节先发,在lcd_write_data16里交换高低字节,或者转换脚本BYTE_ORDER改成big。跨行时w要按块在整图中的实际宽度算,不能每块都从 0,0 开始。
4.2 双缓冲与分块刷新:先收齐一行还是先收齐一帧
320×240×2 是 153600 字节,整帧缓冲大部分 MCU 放不下。常见做法是行缓冲 ping-pong:两个 640 字节行缓冲,DMA 刷 A 行时接收 B 行。但串口块 1024 跨行,所以接收端要维护“当前行偏移”,把负载拆成行片段。更省事的做法是把块大小改成 640
本文还有配套的精品资源,点击获取