
简介基于正点原子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以内因为很多USARTDMA的板子都配有1KB的接收缓冲512B的载荷加上8字节的帧头帧尾单包不超过600B一个缓冲能装下。第二件事是帧边界规则。串口是一个字节一个字节进来的STM32端必须能回答“哪几个字节算一帧哪几个字节是下一帧”。只靠0xAA 0x55这种帧头是不够的因为图片数据本身可能携带0xAA 0x55一旦出现“帧头伪帧头”就会错位。所以还需要长度字段和CRC字段配合。第三件是握手顺序。如果上位机开局就疯狂刷数据STM32还没跑起来数据已经丢了。稳妥的顺序是先发元数据帧图片宽高、像素格式再发若干数据帧最后发结束帧。STM32收到元数据帧才初始化屏幕区域收完结束帧才做整体刷新。这样整个传输是被控的而不是靠运气。2.2 协议字段与参数表下面是我在这套Demo里实际用到的帧结构定义在项目文档里可以直接套用也可以按需改。字段长度(字节)说明帧头2固定 0xF1 0xF2拒绝用0xAA 0x55避数据歧义帧类型10x01元数据 / 0x02图片数据 / 0x03结束帧载荷长度2小端模式范围0~512不含CRC载荷0~512元数据或图像数据CRC162对“帧头帧类型载荷长度载荷”做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, timeout0.5) send_image(ser, raw, 240, 320) ser.close()这段代码跑完一下流程重点参数都写在函数签名和常量里。PACK_SIZE512对应协议里的载荷上限改小了会增加分包数量改大了要同步调大STM32端接收缓冲区。calc_crc16用的是逐位计算方式优点是逻辑透明、不依赖外部库代价是速度稍慢。对150KB的图片来说312个包每包512BCRC计算总耗时在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[i2] 0x01 || buf[i2] 0x02 || buf[i2] 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和协议。从那以后我每次接新板子的串口屏模块都会强制先走一遍回环测试再进入正式功能开发。这个习惯帮我省掉了很多“代码看起来没错画面就是花”的玄学调试时间。回环脚本和前面的发送脚本可以合并成一个文件放进项目包里建议你也保留一个以后换屏幕驱动或改协议时都用得上。希望帮到你。本文还有配套的精品资源点击获取