STM32串口彩色图传:RGB565帧协议与DMA实现

发布时间:2026/10/14 18:18:59
STM32串口彩色图传:RGB565帧协议与DMA实现 简介这是一份面向STM32学习者和嵌入式图像传输开发者的完整工程包基于正点原子miniSTM32与OV7725摄像头实现串口发送彩色图片并配套PC上位机将数据解析为BMP格式覆盖从DMA高速采集、BMP数据封包、UART串口发送到上位机解码显示的完整链路。压缩包共499个文件约10.92MB包含大量.c/.h源码文件、Keil工程配置文件uvprojx/uvoptx、UART与DMA相关头文件、上位机C#工程cs/sln/exe以及若干bmp样例图片和编译中间文件o/axf/hex目录结构完整便于对照学习。已有5794人学习下载。该工程不仅提供可直接运行的固件与上位机源码还保留了调试过程中的关键配置适合有一定STM32基础、希望快速复现串口传图或在此基础上扩展图像应用的开发者参考借鉴也可作为课程设计项目的实现模板。1. STM32串口发彩色图片一根串口线怎样搬动一帧画面STM32串口发彩色图片听起来像玩具级需求但凡是做过带屏设备数据回传、测试工装图像比对、或者想把现场画面塞进串口线里的开发者都知道它背后是一整套取舍图片在单片机里怎么表示、串口协议怎么定、带宽和内存怎么分。串口本质是字节流图片本质是二维像素阵列两者之间隔着一层协议封装的距离。直接拿调试助手把图片二进制文件丢过去上位机收到的几乎必然是花屏波特率没算清楚一张320×240的图可能发十几秒看起来跟死机一样。这类问题不解决再快的MCU也白搭。这篇按一线做法把链路完整走一遍彩色图片的存储格式、帧协议设计、STM32端发送实现、上位机接收解析外加我实际踩过的四个坑。适合正在做串口图传、带屏设备数据回传、或者想把图像数据送给上位机的嵌入式开发者。要求不高懂一点C和Python就能跟着改。2. 图片在单片机里的存储形态RGB565、内存账与格式转换2.1 为什么优先选RGB565而不是RGB888串口发彩色图第一件事不是写代码是决定“彩色”用什么格式表示。最常见的选择是RGB888和RGB565。RGB888一个像素3字节RGB565一个像素2字节红5位、绿6位、蓝5位。单看数字差距不大放大到一张320×240的图上就很明显RGB888约225KBRGB565只有约150KB内存和传输时间同时省下三分之一。很多TFT屏的接口本就以RGB565为主STM32侧读显存、DMA搬运都是现成的。有人担心RGB565颜色精度不够。实际做下来串口发图主要用于记录、回放、对比上位机把565扩回888显示视觉差别很小只在渐变色区域偶尔能看到轻微条带。真正需要888的场景是图像识别训练样本采集那种场合颜色精度影响标注质量但那时应当换USB或以太网串口带宽已经不合适了。结论串口彩色图默认选RGB565能不上888就不上888。2.2 上位机预处理把BMP/PNG转成RGB565二进制流常见做法是图片先在电脑上完成预处理生成RGB565的二进制数据再交给STM32按地址读出、分包发送。不推荐在MCU端做格式转换原因是MCU上没有文件系统和图像解码库一张JPEG解出来要几百KB内存入门级芯片直接爆掉。我一般用Python的Pillow库在PC上把任意图片转成RGB565小端字节流from PIL import Image def rgb888_to_rgb565(data, width, height): 把RGB888像素数据打包为RGB565小端字节流 out bytearray() for i in range(0, width * height * 3, 3): r, g, b data[i], data[i 1], data[i 2] # 高位直接截断简单且色彩观感够用 rgb565 ((r 3) 11) | ((g 2) 5) | (b 3) out rgb565.to_bytes(2, little) # 小端低字节先发 return bytes(out) img Image.open(input.png).convert(RGB) w, h img.size raw rgb888_to_rgb565(img.tobytes(), w, h) with open(frame_565.bin, wb) as f: f.write(raw)代码逻辑分三步先把图片按RGB通道顺序取出来再按565公式把每3字节压成2字节最后用小端序写入文件。关键参数是to_bytes(2, little)串口传输按字节序进行STM32也是小端两边一致才能正确还原。需要特别强调的是convert(RGB)不能省BMP文件里像素顺序是BGR不转的话红蓝直接互换后面显示出来整幅图偏色。这套预处理脚本可以批量跑几十张图几秒就转完了。2.3 内存账一帧图放不下就流式发包决定传输方案前先算内存账。RGB565体积等于宽乘高乘2算下来是下面这个量级分辨率RGB565体积20KB RAM芯片192KB RAM芯片128×12832KB放不下可以240×320150KB放不下勉强但危险480×272255KB放不下放不下结论很直接大多数STM32芯片都不能把一整帧RGB565图像缓存到RAM里再发。正确路线是流式发送——图片存在外部Flash或SD卡里按块读取每读一块就封装成一帧串口包发出去发完再读下一块。这样RAM里只需要保留一个负载缓冲比如512字节整帧图150KB也只是从存储介质里流水过一遍。判断标准就一条缓冲区能容纳协议要求的最大负载即可别试图装下整张图。3. 串口发图协议设计帧头、长度、序号与CRC163.1 裸数据发送为什么必然翻车粘包、半包和错位直接把二进制的RGB565数据往串口里灌上位机那边基本不可能正确还原。串口是字节流不是消息流。上位机调用read时可能一次读到好几个分包残留的零碎字节也可能一个数据块被拆成两次才读完。没有帧边界接收端就不知道一帧从哪里开始、到哪里结束。更麻烦的是错位——某处丢一个字节后后续所有像素的起始位置全部偏移整幅图彻底花掉。我最早用串口调试助手直接发bin文件上位机收到的画面永远是花的有时甚至一屏里能看到好几张图叠在一起。原因就是没有协议层来做“消息边界”。解决思路不是去猜而是给每个数据块加上明确的帧结构让接收端能自动找到边界、校验完整性、丢弃坏帧。3.2 数据帧格式与打包实现串口图传的帧格式不用复杂做到“找头、知长、验错、编号”四件事就够了。我常用的帧结构如下帧头负载长度包序号数据类型负载CRC160xAA 0x552字节小端2字节小端1字节N字节2字节帧头固定为0xAA 0x55连续两个不同字节不容易和负载数据撞上。长度字段限制负载最大512字节既控制协议开销比例又避免上位机缓冲溢出。序号用2字节而不是1字节是有原因的一张320×240的RGB565图约150KB512字节负载要拆成约300包8位序号在255处回绕会干扰丢包检测。类型字段预留给后续扩展比如0x01表示固件升级包、0x02表示传感器数据流。STM32侧打包函数这样写#define FRAME_HEADER0 0xAA #define FRAME_HEADER1 0x55 #define PAYLOAD_MAX 512 typedef struct { uint8_t frame[PAYLOAD_MAX 9]; uint16_t len; } TxFrame; static uint16_t crc16_ccitt(uint8_t *buf, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ (uint16_t)buf[i] 8; for (uint8_t j 0; j 8; j) { crc (crc 0x8000) ? (crc 1) ^ 0x1021 : crc 1; } } return crc; } void build_frame(uint16_t seq, uint8_t type, uint8_t *payload, uint16_t payload_len, TxFrame *tx) { uint8_t *p tx-frame; p[0] FRAME_HEADER0; p[1] FRAME_HEADER1; p[2] payload_len 0xFF; p[3] (payload_len 8) 0xFF; p[4] seq 0xFF; p[5] (seq 8) 0xFF; p[6] type; memcpy(p 7, payload, payload_len); uint16_t crc crc16_ccitt(p 2, payload_len 5); // 长度字段开始到负载结束 p[7 payload_len] crc 0xFF; p[7 payload_len 1] (crc 8) 0xFF; tx-len payload_len 9; }帧头和长度字段不需要参与CRC计算它们是固定的或已在前面校验过CRC从长度字段算到负载末尾覆盖的是真正会变的内容。负载长度建议设为512太小时固定开销占比升高比如64字节负载有9字节开销占比超过12%512字节时只有1.7%对传输时间影响可以忽略。CRC多项式用CRC16-CCITT0x1021它的检错能力对串口这种单bit错误和突发错误场景完全够用。3.3 波特率、帧大小与传输时间的账本很多人写串口发图时不先算时间发出去发现慢得离谱才回头找原因。串口传输时间是硬约束协议优化只能降低开销改变不了总时间下限。8N1格式下每个字节实际占10个bit150KB的RGB565图像在不同波特率下的纯数据传输耗时如下波特率每字节耗时150KB耗时11520086.8us约13.3秒46080021.7us约3.3秒92160010.85us约1.66秒加上512字节负载、9字节开销总量约155KB921600波特率下总耗时不到1.8秒。如果需求要求“秒开”一张320×240图起步波特率就是921600同时还要保证上位机及时读取。这个账必须在写代码前算清楚不然协议写得再漂亮物理上限摆在那里。反过来说如果只发128×128的小图115200也能在3秒内完成硬件成本可以低不少。4. 从STM32到上位机的完整链路DMA、状态机与接收解析4.1 用DMA发送别让主循环被串口拖死串口发送有两条路阻塞发送和DMA发送。阻塞发送是HAL_UART_Transmit它把CPU钉在循环里等每个字节移位完成。一张150KB的图在115200波特率下要发13秒这个期间主循环直接瘫痪按键、屏幕刷新、传感器采集全部停摆。DMA方式完全不同CPU只需要在开始时刻发起传输之后由DMA控制器搬运数据发送完成触发中断CPU就能腾出手来准备下一包。STM32端用HAL库发起一次DMA发送非常直接extern UART_HandleTypeDef huart1; void uart_dma_send(uint8_t *buf, uint16_t len) { // 上一帧未发送完成时不能覆盖缓冲 while (HAL_UART_GetState(huart1) ! HAL_UART_STATE_READY) { // 等待DMA空闲正常只需几个us } HAL_UART_Transmit_DMA(huart1, buf, len); }HAL_UART_GetState检查串口是否处于READY状态确保上一次DMA传输已经结束。这里的坑是HAL_UART_Transmit_DMA是异步的它所在的缓冲区必须保持有效直到发送完成回调触发否则发送一半数据就被别的任务改写画面上会出现一截错位条纹。这也是下一节引入状态机和双缓冲的直接原因。发送完成回调里只需要做一件事static volatile uint8_t tx_done_flag 0; void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { tx_done_flag 1; } }不要在这个回调里做重活置个标志位就够主循环的状态机会在合适时机去消费它。4.2 发送状态机空闲、打包、发送、等待完成图像分包发送天然适合状态机。状态机的好处是每个时刻只做一件事不会出现DMA还没发完就改写缓冲的竞态。核心状态有四个空闲、打包、发送、等待完成。typedef enum { TX_IDLE, TX_FILL, TX_SEND, TX_WAIT_DONE } TxState; void tx_task(void) { static TxState state TX_IDLE; static TxFrame frame; // 当前发送缓冲 static uint8_t next_payload[512]; // 下一包的数据缓冲 switch (state) { case TX_IDLE: state TX_FILL; break; case TX_FILL: if (make_payload(next_payload)) { // 从Flash/SD卡读一块并组帧 state TX_SEND; } else { state TX_IDLE; // 整幅图发完了 } break; case TX_SEND: build_frame(frame_counter, 0x00, next_payload, 512, frame); uart_dma_send(frame.frame, frame.len); state TX_WAIT_DONE; break; case TX_WAIT_DONE: if (tx_done_flag) { tx_done_flag 0; state TX_FILL; } break; } }make_payload是自定义函数职责是从存储介质按偏移读512字节。注意这里只有一个发送缓冲state在WAIT_DONE时不会去碰frame里的数据只有等DMA完成后才进入FILL阶段。这样单缓冲也能安全运行。如果追求更高吞吐把frame也变成双份发送一帧的同时组帧下一帧后面会讲。4.3 Python上位机串口接收、组帧与图像显示上位机同样不能简单read了事也需要一个解析状态机来逐字节找帧头、校验长度和CRC。下面是一段可直接运行的Python接收代码import serial import numpy as np from PIL import Image PORT COM3 BAUD 921600 WIDTH, HEIGHT 320, 240 EXPECTED_BYTES WIDTH * HEIGHT * 2 # RGB565每像素2字节 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 ser serial.Serial(PORT, BAUD, timeout0.1) rx_buf bytearray() image bytearray(EXPECTED_BYTES) received 0 expected_seq 0 while True: data ser.read(4096) if not data: continue rx_buf.extend(data) while len(rx_buf) 9: if rx_buf[0] ! 0xAA or rx_buf[1] ! 0x55: rx_buf.pop(0) # 逐字节滑动找帧头 continue payload_len rx_buf[2] | (rx_buf[3] 8) if payload_len 512: rx_buf.pop(0) continue if len(rx_buf) payload_len 9: break # 等待完整帧 seq rx_buf[4] | (rx_buf[5] 8) if seq ! expected_seq: print(f丢包: expected {expected_seq}, got {seq}) expected_seq seq 1 payload rx_buf[7:7 payload_len] rx_buf rx_buf[payload_len 9:] # 移除已处理帧 image[received:received payload_len] payload received payload_len if received EXPECTED_BYTES: arr np.frombuffer(bytes(image), dtypenp.uint16).reshape(HEIGHT, WIDTH) r ((arr 11) 0x1F) 3 g ((arr 5) 0x3F) 2 b (arr 0x1F) 3 rgb np.dstack([r, g, b]).astype(np.uint8) Image.fromarray(rgb).save(received_frame.png) print(完整图像已保存) received 0 expected_seq 0上位机解析逻辑和STM32端打包是镜像关系先找帧头再读长度序号用于检测丢包CRC校验坏帧最后把负载按偏移拼进图像缓冲。参数上有两个关键点一是read(4096)一次尽量多读减少系统调用次数在高波特率下尤其重要二是timeout0.1秒数据不完整时尽快回到循环继续读而不是长时间阻塞。这段程序跑起来后每收到一整帧图像就保存一张PNG方便逐帧核对。5. 串口发图避坑花屏、假死、偏色与高波特率丢包5.1 上位机收到花屏先查帧对齐再查CRC最容易观察到的现象是画面大部分正常但某几行错位或者整屏出现密集噪点。背后原因通常有两个一是上位机从半包开始读第一个字节不是帧头后续所有字节全部错位二是某个坏帧没有通过CRC校验却被当作有效数据拼进了图像。我的处理方式是三重防护逐字节找帧头、严格按长度字段切分、CRC校验失败直接丢帧并重新同步。如果花屏还伴随偶发丢包大概率是上位机解析状态机里删帧的长度算错了检查一下rx_buf的切片长度是否对应完整帧而不是只删了负载长度。5.2 发送过程中主循环假死DMA完成回调没管好高波特率下连续发送偶尔出现主循环卡死、看门狗超时复位。发生这个问题的根源是用了HAL_UART_Transmit_DMA但没有正确等待上次传输完成就复用了同一个发送缓冲区。DMA还在搬运这块内存主循环已经把新数据填进去了发出去的内容就是新旧混合的脏数据。更隐蔽的是一个常见错误发送完成回调里没清标志位状态机永远停在TX_WAIT_DONE看起来就是死循环。解决方法是每次DMA发送前检查HAL_UART_GetState在回调里只置标志位状态机消费后立刻清零。这两步都做到假死问题基本绝迹。5.3 图片整体偏色字节序和BGR顺序的锅图像能完整显示但颜色不对红色变蓝、绿色变黄这种问题定位起来比花屏还烦。最常见的两个原因一是RGB565打包成了大端序而STM32按小端发送上位机也按小端解析高低字节对调二是BMP文件像素顺序本来就是BGRPC端转换时没有先执行convert(RGB)红蓝通道直接互换。我的排查方法是先发一张纯红纯绿纯蓝的三色测试图如果红蓝对调就是BGR问题如果颜色完全乱掉就是字节序问题。修复也简单上位机打包统一用小端to_bytes(2, little)MCU端不做任何字节交换PC端转换前强制convert(RGB)。5.4 高波特率丢包上位机读取节奏跟不上波特率调到460800以上后即使协议校验全对图像还是隔三差五缺一块。这时候问题往往不在STM32端而在上位机的读取节奏。USB转串口芯片内部FIFO通常只有几KB上位机如果隔几十毫秒才read一次来没来得及读的数据会被新数据覆盖。解决思路是上位机用独立线程持续读取并写入队列主线程只处理队列里的完整帧或者把单次read长度从1024改到4096甚至8192减少读取间隙。更稳妥的硬件方案是启用RTS/CTS硬件流控让USB转串口芯片在FIFO将满时主动拉低请求线从物理上避免数据丢失。这一点在低成本USB转串口模块上尤其明显靠谱模块和普通模块在高波特率下的丢包率差距非常大。6. 进阶双缓冲流水线把串口发图做成可靠的小件搬运系统6.1 双缓冲怎么换手才不丢数据单缓冲状态机有个浪费DMA发送期间CPU其实闲着完全可以去准备下一包数据。双缓冲就是把“填数据”和“发数据”重叠起来用两个发送缓冲轮流交换让DMA搬A缓冲时CPU在组B缓冲。换手的关键是只有当DMA完成回调触发后A才允许被重新填充。static uint8_t buf_a[512]; static uint8_t buf_b[512]; static uint8_t *active_buf buf_a; static uint8_t *fill_buf buf_b; void tx_task_dual(void) { static TxState state TX_FILL; switch (state) { case TX_FILL: if (make_payload(fill_buf)) { tx_dma_buf fill_buf; // 记录本次发送缓冲 build_frame(frame_counter, 0x00, fill_buf, 512, tx_frame); uart_dma_send(tx_frame.frame, tx_frame.len); state TX_WAIT_DONE; } break; case TX_WAIT_DONE: if (tx_done_flag) { tx_done_flag 0; // 交换角色刚才发送的缓冲变成下一包的填充目标 uint8_t *tmp active_buf; active_buf fill_buf; fill_buf tmp; state TX_FILL; } break; } }注意这里发送的仍是打包后的tx_frame但payload来源轮换到了已填充完成的fill_buf。整张图发完时make_payload返回0此时数据拼接顺序不受影响。双缓冲的收益在低波特率下不明显在921600时能把有效时间利用率从50%提到接近100%。如果项目里存储介质读速是瓶颈还可以再加一层三缓冲来吸收读取抖动不过对串口发图来说双缓冲通常就是性价比最高的方案。6.2 同一套协议延伸到固件升级与数据回传串口发图练熟后会发现这套帧协议的复用价值比图像本身大得多。类型字段现在派上用场0x00图像数据包、0x01固件升级包、0x02传感器数据流。传给上位机的一帧数据里带上宽、高、像素格式三个字段上位机就能自适应不同分辨率的图像。我在一个新的项目里就直接复用这套协议做固件升级只改了解析策略CRC校验和序号检测完全不用动。不要小看这类流水线设计的长期价值。我最早动手做串口发图时第一个版本拿着调试助手直接发bin花了整个周末才意识到帧同步才是核心后来老老实实把协议补上一次通过。之后每次新项目都先算带宽账和内存账再写打包函数反而比边写边调快很多。协议越稳定扩展功能需要的新代码就越少。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询