简介:基于正点原子miniSTM32与OV7725摄像头,实现串口将彩色图像数据发送至上位机并重构为BMP图片的完整工程,覆盖数据采集、格式打包、UART传输与上位机解码显示,适合嵌入式图像传输、串口通信及上位机开发的学习者参考。压缩包共499个文件,约10.92MB,包含STM32固件源码(h、c文件)、Keil工程配置(uvprojx、uvoptx)、上位机C#工程(cs、csproj、sln)以及说明文档(txt、md),另有调试生成的目标文件和Hex/Bin固件,可按工程结构对照理解摄像头DMA采集、BMP文件头构造、串口分包发送与上位机字节序处理全流程。已有5794人学习下载,资源热度较高。通过阅读源码、工程配置和上位机逻辑,可快速复现串口传图功能,并迁移到其他MCU平台或扩展为远程图像监控应用,适合需要完整嵌入式通信项目范例的开发者。
1. 串口传图,为什么先折腾上位机协议
很多人第一次接触STM32串口发彩色图片这个需求,多半是卡在同一个点上:串口助手能发字符串,却发不了一整张图;用串口助手直接发十六进制流,屏幕要么露白、要么花得像雪花。于是回头搜“STM32 上位机 发送图片”,发现几乎每个示例都绕不开协议设计、分包、校验、DMA接收这几块硬骨头。这篇文章分享的就是一套已经跑通的落地做法:上位机读图 → 转RGB565 → 分包加CRC → 串口发送,STM32端用DMA空闲中断收包 → 状态机校验 → 刷进屏幕,同时把参数怎么选、坑在哪一步写清楚。适合正在调LCD屏幕、做简易图传工装,或想把手上的开发板加一个“图片显示”能力的人,读完可以直接照着改出自己的一版。
2. 串口分包协议设计:为什么不能一把梭直发整图
2.1 应用层协议的三个底线问题
串口本身是字节流管道,发送一张240×320的彩色图片,RGB565格式下是240×320×2字节,约150KB。如果上位机一口气把这些数据全丢给write接口,STM32端会怎样?常见情况是:串口外设缓冲区早被塞满,空闲中断被不断打断,DMA搬运速度跟不上,数据一多就开始丢字节——表现就是屏幕只有上半截画面,或者颜色错位成“彩虹图”。
所以绕不开的第一个问题,是必选做应用层分包。每个数据包大小设成256B还是512B,直接决定STM32端缓冲区的占用和DMA搬运的稳定度。我一般会把包长控制在512B以内,因为很多USART+DMA的板子都配有1KB的接收缓冲,512B的载荷加上8字节的帧头帧尾,单包不超过600B,一个缓冲能装下。
第二件事是帧边界规则。串口是一个字节一个字节进来的,STM32端必须能回答“哪几个字节算一帧,哪几个字节是下一帧”。只靠0xAA 0x55这种帧头是不够的,因为图片数据本身可能携带0xAA 0x55,一旦出现“帧头+伪帧头”就会错位。所以还需要长度字段和CRC字段配合。
第三件是握手顺序。如果上位机开局就疯狂刷数据,STM32还没跑起来,数据已经丢了。稳妥的顺序是:先发元数据帧(图片宽高、像素格式),再发若干数据帧,最后发结束帧。STM32收到元数据帧才初始化屏幕区域,收完结束帧才做整体刷新。这样整个传输是被控的,而不是靠运气。
2.2 协议字段与参数表
下面是我在这套Demo里实际用到的帧结构,定义在项目文档里可以直接套用,也可以按需改。
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| 帧头 | 2 | 固定 0xF1 0xF2,拒绝用0xAA 0x55,避数据歧义 |
| 帧类型 | 1 | 0x01元数据 / 0x02图片数据 / 0x03结束帧 |
| 载荷长度 | 2 | 小端模式,范围0~512,不含CRC |
| 载荷 | 0~512 | 元数据或图像数据 |
| CRC16 | 2 | 对“帧头+帧类型+载荷长度+载荷”做Modbus CRC16 |
这里参数怎么定,背后有明确理由。帧头选0xF1 0xF2,而不是0xAA 0x55,是因为0xAA在RGB565高字节里出现的概率接近一半。用0xAA做帧头会让错位概率翻倍。载荷长度用小端,是因为大部分上位机脚本和ARM芯片在这个场景下习惯小端解析,少一次字节反转就是少一次踩坑。CRC16选Modbus多项式0xA001是工程惯例,查表法实现简单,STM32跑起来几乎不占周期。
帧类型拆成三种,对单片机的状态机极其友好。元数据帧只有一包,里面依次是图片宽度、高度、像素格式代号。图片数据帧若干包,每包都是“图像段”。结束帧的载荷为空,帧type为0x03。三个阶段的划分让STM32端的解析逻辑从“猜是不是图片”变成了“知道当前该收什么”,调试难度下降一个等级。
2.3 STM32解析流程:先收块,再找帧
协议层收到串口字节后,不逐字节硬判,而是等一块数据累积起来再扫描。这块数据通常就是DMA搬运的整段缓冲区。收到完整一个缓冲区后,STM32从缓冲区头开始找0xF1 0xF2,找到后再取帧类型、解析长度、校验CRC。只有CRC通过才认为这是一帧,否则把起始指针挪一位继续找。
这个“先收块,再找帧”的做法,大于“逐字节状态机”的优点是:即使数据里有伪帧头,也不会破坏缓冲区里的其他数据——最多这一帧CRC失败,丢弃重找。逐字节状态机遇到伪帧头,很容易把状态翻到“已经看到帧头”的死角。而从缓冲区扫描的方式天然自带自愈能力,尤其适合图片数据这种频繁出现跳跃字节的场景。
3. 上位机脚本:读图、缩放、转RGB565、分包发送
3.1 Python发送端完整脚本
上位机我用Python实现,选了PySerial和Pillow这两个库,理由很直接:PySerial在处理串口流、超时控制方面比串口助手脚本更可控,Pillow则统一处理图片解码、缩放和RGB888到RGB565的转换。代码里我把每一步都做成独立函数,方便你改分辨率或换图片。
import serial from PIL import Image SER_PORT = "COM12" SER_BAUD = 921600 PACK_SIZE = 512 FRAME_HEAD = b"\xf1\xf2" FRAME_ID_META = 0x01 FRAME_ID_DATA = 0x02 FRAME_ID_END = 0x03 def calc_crc16(data: bytes) -> int: crc = 0xFFFF for byte in data: crc ^= byte for _ in range(8): if crc & 1: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return crc & 0xFFFF def build_frame(frame_type: int, payload: bytes) -> bytes: header = FRAME_HEAD + bytes([frame_type]) header += len(payload).to_bytes(2, "little") crc = calc_crc16(header + payload) return header + payload + crc.to_bytes(2, "little") def img_to_rgb565(data: bytes, width: int, height: int) -> bytes: img = Image.open(data).convert("RGB").resize((width, height)) out = bytearray() for r, g, b in img.getdata(): rgb565 = ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3) out += rgb565.to_bytes(2, "little") return bytes(out) def send_image(ser: serial.Serial, raw_rgb565: bytes, width: int, height: int): meta = width.to_bytes(2, "little") + height.to_bytes(2, "little") + bytes([0x01]) ser.write(build_frame(FRAME_ID_META, meta)) for i in range(0, len(raw_rgb565), PACK_SIZE): chunk = raw_rgb565[i:i + PACK_SIZE] ser.write(build_frame(FRAME_ID_DATA, chunk)) ser.write(build_frame(FRAME_ID_END, b"")) if __name__ == "__main__": raw = img_to_rgb565("test.jpg", 240, 320) ser = serial.Serial(SER_PORT, SER_BAUD, timeout=0.5) send_image(ser, raw, 240, 320) ser.close()这段代码跑完一下流程,重点参数都写在函数签名和常量里。PACK_SIZE=512对应协议里的载荷上限,改小了会增加分包数量,改大了要同步调大STM32端接收缓冲区。
calc_crc16用的是逐位计算方式,优点是逻辑透明、不依赖外部库,代价是速度稍慢。对150KB的图片来说,312个包,每包512B,CRC计算总耗时在Python侧不足500ms,完全不影响体验。如果你做的是大批量连续传图,建议把CRC计算改成查表法,Python侧性能还能再快一倍。
img_to_rgb565里我把图片转换成RGB888后,再手动把R、G、B降位到5位、6位、5位。这里有个容易翻车的地方:某些上位机工具会把RGB565按大端字节序发送,而屏幕驱动通常按小端解释。我在这里统一用小端字节序,即先写低字节,后写高字节,STM32端不需要再交换字节序。不同屏幕驱动IC的字节序偏好可能不同,拿到资料先做一张纯红色测试图,再判断要不要翻转字节序。
3.2 与上位机UI工程集成的思路
如果你目标不是跑脚本,而是要做一个带界面的工具,PySerial部分可以复用,UI层用PySide或Tkinter都行。常见做法是:界面上放一个“选择图片”按钮和一个“发送”按钮,发送时直接调send_image函数。注意一个关键点:按钮点击后要在子线程里跑发送,别在UI主线程里做耗时的CRC计算和串口write,否则界面会卡死。
这套脚本的资源也适配串口通信调试场景。比如在开发阶段,我会先用脚本把一张测试图分多次发出去,每次发送后延时200ms,观察STM32端接收是否完整。这样能把问题定位在协议层还是传输层,而不是一股脑丢到串口里就完事。
4. STM32端接收与解析:DMA空闲中断 + 状态机
4.1 DMA与串口空闲中断的配置
STM32端接收不定长数据,我优先选“串口空闲中断+DMA”的组合。串口空闲中断在总线上出现字节空隙时触发一次,DMA则负责把连续字节搬进内存。这样CPU不需要逐字节中断,省出来的时间可以用来跑LCD刷新或做CRC校验。
DMA配置关键参数有四个:数据方向为外设到内存、外设地址固定为串口数据寄存器地址、内存地址指向接收缓冲区、传输长度设为缓冲大小。这里有个容易被忽略的细节:h串口句柄的Init.Mode不选普通模式,选循环模式,是因为空闲中断触发后,DMA可能已经搬了不止一包数据。用循环模式可以让DMA持续“绕圈”写缓冲区,CPU再根据DMA计数器差值计算实际收到的数据块。
初始化流程大致如下:
#define RX_BUF_SIZE 1024 uint8_t dma_rx_buf[RX_BUF_SIZE]; UART_HandleTypeDef huart; DMA_HandleTypeDef hdma_usart; void uart_dma_init(void) { __HAL_UART_ENABLE_IT(&huart, UART_IT_IDLE); hdma_usart.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_usart.Init.PeriphInc = DMA_PINC_DISABLE; hdma_usart.Init.MemInc = DMA_MINC_ENABLE; hdma_usart.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_usart.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_usart.Init.Mode = DMA_CIRCULAR; HAL_DMA_Init(&hdma_usart); __HAL_LINKDMA(&huart, hdmarx, hdma_usart); HAL_UART_Receive_DMA(&huart, dma_rx_buf, RX_BUF_SIZE); }循环模式带来的一个好处是,DMA写到缓冲区末尾之后会自动回到开头继续写,代码里通过记录上次读取的索引,来找到“新收到的字节区间”。如果不配置循环模式,每次都要先停DMA再重新设置传输长度,接收过程中一停一开,很容易丢掉一行数据。
RX_BUF_SIZE建议设置成协议载荷长度+帧头帧尾的整数倍,比如载荷512B,缓冲区设1024B,一包数据加最多624B,缓冲区能放得下。如果缓冲区设太小,一包数据跨越DMA回绕边界,处理代码就要做两次内存拷贝,复杂度翻一倍。
4.2 帧解析状态机与LCD写入
拿到DMA缓冲区的数据后,下一步就是扫描帧。我一般把扫描逻辑单独拆一个函数,输入是缓冲区指针和长度,输出是处理完毕的帧数。这个函数里用while循环找帧头,找到后依次解析帧类型、长度、CRC,然后根据帧类型分发。
void parse_rx_data(uint8_t *buf, uint32_t len) { uint32_t i = 0; while (i + 4 < len) { if (buf[i] == 0xF1 && buf[i + 1] == 0xF2) { uint8_t type = buf[i + 2]; uint16_t payload_len = buf[i + 3] | (buf[i + 4] << 8); uint16_t crc_recv = 0; uint32_t crc_calc = 0; if (i + 5 + payload_len + 2 > len) break; // 数据不完整,等下一块 crc_recv = buf[i + 5 + payload_len] | (buf[i + 6 + payload_len] << 8); crc_calc = calc_crc16(&buf[i], 5 + payload_len); if (crc_recv == crc_calc) { handle_frame(type, &buf[i + 5], payload_len); i += 5 + payload_len + 2; } else { i += 2; } } else { i++; } } }这个状态机的核心逻辑是“不完整则下一轮再拼”。现在由于DMA缓冲区回绕,buf里的数据可能只有半帧,但parse_rx_data通过if (...) break;跳出循环,把未处理的数据留给下一次空闲中断处理。这种做法需要配合一个全局偏移量,把上一轮剩余的半帧数据先搬到缓冲区前部,再接新的一轮数据。常见做法是维护一个环形结构,但为了降低复杂度,可以把dma_rx_buf做成双缓冲,一轮接收结束后整块拷贝到处理缓冲。
handle_frame函数根据类型做三件事:类型0x01解析宽高和像素格式;类型0x02把数据写入LCD的GRAM,写入地址由当前偏移量计算;类型0x03刷一次屏或者结束。写GRAM时注意,屏幕的地址增量方向与扫描方向一致,否则图像会上下颠倒或左右镜像。调试时先发一张最左上角为纯红色的图,再发一张最右下角为纯蓝色的图,这样能快速确认扫描方向。
5. 串口传图常见问题与排查:六个真实踩坑记录
5.1 帧头重复导致的错位与死循环
现象:传图时,偶尔从某一帧开始,后面的画面全部错乱,有些像素颜色对不上。
原因:图片数据中存在与帧头一致的0xF1 0xF2组合。扫描到伪帧头后,CRC大概率失败,代码跳到下一位继续找。但如果伪帧头恰好和后面4字节组成“合法帧”,就会出现一整个错误帧,导致大量LCD数据写入错位。
解决:强化帧头匹配条件。可以在帧头后面强加一字节“帧类型校验”,伪帧头后一字节不是0x01/0x02/0x03时就跳过。最简单的方式是:当检测到0xF1 0xF2后,多检查下一字节是否等于有效值,不等于就放弃。项目里我在parse_rx_data的if条件里多写了buf[i+2] == 0x01 || buf[i+2] == 0x02 || buf[i+2] == 0x03,这个误判率降到了接近零。
5.2 串口空闲中断触发后DMA计数差值为0
现象:上位机已经发了数据,STM32端也配置了空闲中断,但缓冲区里始终是空的。
原因:HAL空闲中断回调里直接读hdma->Instance->NDTR,但此时DMA还没有把数据搬完或者缓冲区从0开始写。更常见的情况是,调用HAL_UART_Receive_DMA之前没有使能串口IDLE中断,导致中断到位但没进回调。
解决:排查顺序为先确认IDLE中断是否已经使能,然后在空闲中断回调里读NDTR,再用上一次的NDTR值做差值。差值即为本段收到的数据长度。每次处理完,更新“上次NDTR值”。注意NDTR递减,差值是上次值 - 本次值。若差值为0,说明DMA还没来得及搬入数据,可以直接跳过这次回调。
5.3 数据全丢但串口助手能收到
现象:用串口助手能看到完整数据,但STM32端LCD什么也不显示。
原因:大多不是接收问题,而是DMA与串口的时钟没配对。某些开发板的串口DMA输入时钟来自APB2,串口外设时钟来自APB1,如果两边时钟不匹配,DMA搬运错位导致数据全乱。另外还有一种可能:串口的中断优先级低于别的外设,频繁被更高优先级中断打断,空闲中断死等。
解决:先检查RCC时钟树里USART和DMA都挂在同一个总线时钟域上。再在HAL_NVIC_SetPriority里把USART全局中断优先级设到最高,比如优先级组4下的0。DMA中断不需要开启,只靠IDLE中断反而最简单。最后在调试中打印串口接收长度,确定DMA确实把数据搬到了缓冲区,再去刷屏幕逻辑。
5.4 上位机发送速率过高导致串口缓冲溢出
现象:小图片能传,大图片传一半停住或长期花屏。
原因:Python脚本里for循环写串口,不等的STM32端的应答,也不等串口硬件FIFO清空。150KB数据以921600波特率发送,耗时约1.3秒,但逐个串口write本身也有CPU耗时。如果发完一包立刻发下一包,底层驱动会积压数据,STM32端DMA处理速度跟不上时就会丢。
解决:我一般会做“流控”。两个方式任选:一是上位机每发送50个数据帧后,等待STM32回复一个ACK字节;二是直接降低波特率到460800或230400,让传输速度适配STM32端的处理吞吐。对240×320图片来说,460800波特率已经很流畅。如果要高波特率,必须开硬流控RTS/CTS,或者把DMA接收缓冲加大到几KB。
5.5 RGB565大小端导致颜色错乱
现象:画面能显示,但整体颜色异常,比如红色变成蓝色,或每个像素颜色都不对。
原因:上位机以“高字节在前”发送,STM32端LCD驱动按“低字节在前”解释,导致每两个字节被翻转读取。很多TFT屏幕驱动内部接收顺序是固定的,改不了。
解决:统一在上位机端解决。把img_to_rgb565里out += rgb565.to_bytes(2, "little")改成"big",重新发送,观察颜色是否恢复。注意同时检查CRC计算时用的数据是否也按修改后的字节序处理。CRC是对“发送出去的字节流”计算,只要发送端与接收端对字节序理解一致,颜色就不会花。
5.6 屏幕显示图像抖动但不报错
现象:画面偶尔闪动、横向撕裂,看起来像刷新时序不对,但CRC全部通过。
原因:LCD写GRAM的速度和DMA搬运数据之间发生了竞争。STM32端收到帧数据就立刻写GRAM,但屏幕内部像素时钟和DMA速度不匹配,GRAM写入一半时屏幕刷新到中间行,就产生撕裂。
解决:在结束帧到达后再整体刷屏,而不是边收边刷。具体做法是先把接收的RGB565数据存到一块外部SRAM或内部大数组,等收到结束帧后再一次性写入LCD。如果内存不够,至少做成“每32行刷新一次”的局部整刷,这样撕裂只发生在32行内,肉眼看起来不明显。
6. 用回环测试验证收发链路完整性
串口传图有没有bug,光看画面有时不够直观。我习惯在拿到上位机脚本后,先做一轮“回环测试”:上位机发送一幅特殊生成的测试图,STM32端收到结束帧后,把收到的字节数与CRC值回传给上位机,由上位机比对预估值。这样能把问题定位在“发送端不对”还是“接收端不对”。
测试图我用Python生成,不做图片解码,直接构造一个240×320的RGB565渐变图,左边红右边蓝,上边绿下边黑。然后用同样的build_frame打包发给STM32。STM32端在收到结束帧后,应把total_received_count和last_crc_value通过串口打印到上位机。上位机脚本用列表收集回传信息,与预估值比对。
# 回环验证主流程 test_data = generate_gradient_rgb565(240, 320) send_image(ser, test_data, 240, 320) ack = ser.read(32) # 等待STM32回传的校验信息 print("回传数据:", ack)这套回环测试的价值在于:如果STM32回传的总字节数和CRC都对,那问题必然在LCD驱动或GRAM写入逻辑,不需要反复折腾差分调试。如果对不上,就说明要么上位机发送时丢包,要么STM32端解析时丢帧,这时再去查DMA和协议。
从那以后,我每次接新板子的串口屏模块,都会强制先走一遍回环测试,再进入正式功能开发。这个习惯帮我省掉了很多“代码看起来没错,画面就是花”的玄学调试时间。回环脚本和前面的发送脚本可以合并成一个文件,放进项目包里,建议你也保留一个,以后换屏幕驱动或改协议时都用得上。希望帮到你。
本文还有配套的精品资源,点击获取