STM32串口发送彩色图片到LCD显示:协议设计与完整实现

发布时间:2026/9/9 1:47:28
STM32串口发送彩色图片到LCD显示:协议设计与完整实现 简介基于正点原子迷你STM32与OV7725摄像头的串口发送彩色图片完整项目面向STM32嵌入式学习者和图像传输应用开发者解决从摄像头采集、BMP格式封装、串口发送到PC上位机解码显示的全过程。压缩包共499个文件大小约10.92MB包含STM32的C源码与Keil工程配置、上位机C#源码与可执行程序、BMP样例图片及编译中间文件类型覆盖下位机到PC端完整链路能帮助快速定位工程中需要修改的模块。目前已有5785人学习下载。工程价值在于提供下位机与上位机配套完整代码可帮助理解DMA高速采集、BMP文件头结构、串口波特率匹配、字节序转换等要点从目录中还能看到编译中间文件与Git版本信息便于对照排查像素错乱、数据丢失等常见调试问题下位机部分可直接打开Keil工程生成hex文件烧录上位机部分可在Visual Studio中打开工程便于修改协议与界面。初学者可参考模块划分从底层寄存器配置逐步理解到协议解析有经验者可直接替换传感器或调整分辨率与波特率进行验证适合作为课程设计或入门智能图像传输的参考资料。 说真的刚接到“STM32串口发彩色图片”这个需求时我第一反应是“这有什么难的直接把图片数组扔进串口不就行了”。结果真上手做的时候才发现这里面的坑比想象中多得多串口是字节流图片是结构化数据彩色图的数据量又大没做预处理和分包传输的话发过去就是一堆乱码屏幕一片花。这篇文章我就把整个“PC端彩色图片通过串口发送到STM32并显示在LCD上”的完整链路、协议设计、代码实现和排坑经验一次讲透。这个需求在嵌入式圈子里非常典型尤其适合做桌面小相框、离线图片浏览器、或者给自家产品加个“截图回传”功能的人。无论你用标准库还是HAL库F103还是F407核心思路都是通用的。1. 先搞清楚这事的几个硬约束1.1 串口链路预算为什么“直接发原图”会翻车串口本质上是个水管不管发文字还是发图片它只认字节。所以关键不在于“能不能发图片”而在于“数据量和水管粗细匹配不匹配”。以最常见的320x240分辨率、RGB565格式的彩色图片为例单帧原始数据量是320 × 240 × 2 153,600 字节约150KB而串口在115200波特率下每秒实际有效数据传输量大约是115200 / 10 11,520 字节/秒注意这里除以10是因为UART每发一个字节除了8个数据位之外还要带1个起始位和1个停止位部分配置还有校验位那就是11位所以标称波特率除以10才是真实字节速率。这样算下来一张150KB的图光是裸数据就要13秒多。如果图上还带了文件头、做了RGB888格式每像素3字节那体积直接翻倍30秒都传不完。所以整个项目的第一个设计原则就是不要把“大图原样”塞进串口而是在PC端先把图片压缩到合适分辨率再转成RGB565然后分包发送。比如把图片缩到160x120RGB565格式下数据量变成160 × 120 × 2 38,400 字节同样是115200波特率传输时间就只有3.3秒左右。这个等待时间虽然还是有点长但配合进度条显示用户体验完全能接受。如果你用的是460800或921600这样更高的波特率传输时间还能进一步压到1秒以内。1.2 方案选型三种常见做法的取舍我在做之前也查了不少资料发现大家的实现路线大概分三种各有优劣这里直接列个对比表方案实现方式优点缺点适用场景A. 整图一次性发送把整张图片的RGB数组直接通过串口发出MCU端开一个大数组接收后再一次性写屏逻辑简单代码量少极占RAM320x240的RGB565数据需要150KBF103这种片子直接撑爆传输过程无反馈出错难排查超小分辨率96x96以内调试用B. JPEG压缩后发送MCU解码PC端把图片压缩成JPEGMCU接收后软件解码再写屏传输数据量小速度快STM32软解JPEG非常吃力需要大量Flash存解码器RAM开销也不小解码一张图可能要好几秒不推荐除非你的MCU带硬件JPEG解码器且屏幕分辨率高C. PC端预处理分包传输边收边写屏PC端把图片缩放、转换成RGB565自定义分帧协议分包发送MCU每收到一帧就写入LCD对应区域边收边显示RAM占用极小传输过程可做进度反馈出错了容易定位思路通用需要自己定义协议PC端要写个小工具最推荐也是本文采用的方案方案C的核心优势在于“边收边写”。MCU不需要一次攒齐整张图而是收一小块、画一小块这样哪怕F103这种只有20KB RAM的单片机也能轻松驱动320x240甚至更大分辨率的屏幕。这在实际项目里是非常实用的套路因为“内存不够”几乎是大图显示绕不开的坎用时间换空间才是正解。2. 核心细节协议设计与PC端工具准备2.1 定义一套简单可靠的分帧协议既然选了分包传输就绕不开协议设计。串口本身没有消息边界PC发来的是一串连续的字节流。如果不做分帧MCU这边根本不知道该从哪里截取屏幕的某一行数据。我用的协议帧格式非常简单经过多次项目验证稳定可靠帧头(2字节) | 命令字(1字节) | 数据长度(2字节) | 数据(N字节) | 校验(1字节)具体说明帧头固定为0xAA 0x55用于MCU在字节流里找帧起始位置。命令字0x01表示图片数据帧0x02表示结束帧0x03表示取消传输。后续扩展其他功能比如发文字、发命令也方便。数据长度2字节小端模式记录后面“数据”段的字节数。校验对数据段做累加和取低8位。别嫌简单这种场景下累加和足够发现绝大多数偶发错码比上CRC32性价比高得多。为什么不用串口助手自带的“发送文件”功能直接发图片因为裸文件发送没有帧头、没有长度、没有校验MCU收到一个错位字节后后续所有数据就全废了。尤其串口在电磁环境不好的场合偶发出一个错码整张图就会花掉一大片而且你都不知道错在哪。2.2 PC端图片预处理尺寸、格式与RGB565PC端是这套系统的“大脑”图片的缩放和格式转换都在这里完成。我用Python写了个简单的发送脚本核心逻辑分三步。第一步是用Pillow库打开图片并做缩放。缩放的目的刚才已经算过账了是为了匹配屏幕分辨率和串口速率。如果屏幕是320x240的ILI9341图片就缩到320x240如果屏更小就缩到对应尺寸。第二步是颜色格式转换。这里有个关键细节图片在电脑里通常以RGB888表示也就是每个像素红绿蓝各8位共24位。但大部分嵌入式LCD屏幕比如常见的ILI9341、ST7789原生支持的是RGB565格式每个像素16位红色5位、绿色6位、蓝色5位。RGB888转RGB565的公式其实很简单# RGB888转RGB565r、g、b取值范围0-255 rgb565 ((r 3) 11) | ((g 2) 5) | (b 3)为什么红和蓝是移位3位绿色是移位2位因为RGB565中红色占5位所以8位要砍掉低3位绿色占6位就砍掉低2位蓝色同样砍掉低3位。这样做的好处是每个像素从3字节压缩到2字节按320x240算一帧图从225KB减到150KB传输时间直接省掉三分之一。第三步是分包发送。每包数据量我习惯取240字节的整数倍这样和屏幕一行的像素数能对上MCU写屏的逻辑也简单。实际封装帧的时候数据长度字段填的就是这包实际字节数。2.3 免代码路径用串口助手的文件发送兜底如果你不想写Python脚本也有一个临时的“野路子”可以验证整条链路用串口助手的“文件发送”功能直接把提前转好的RGB565裸数据文件发过去。但这个方法有个硬前提——MCU端必须知道文件总长度。所以你需要在接收代码里提前约定好“收到N字节后算一帧完成”然后按整帧解析。这样做的缺点很明显没有校验、错一个字节整帧作废只适合实验室验证不适合做正式方案。我自己只在SD卡读取屏幕测试时这么玩过正式做小相框还是老老实实写协议。3. STM32端接收与LCD显示实操3.1 串口配置空闲中断DMA接收一帧STM32端我的做法是“串口空闲中断 DMA接收”。为什么这么选因为如果纯靠主循环一个字节一个字节地查询单片机在传输过程中几乎什么都干不了而且很容易丢字节。DMA能直接把串口收到的数据搬进内存等一帧收完了空闲中断会告诉你“数据到齐了”。以STM32F103标准库为例关键初始化逻辑如下/* 串口DMA接收配置 */ void UART_DMA_Config(void) { DMA_InitTypeDef DMA_InitStructure; // 使能DMA1时钟 RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel5); // USART1_RX使用的DMA通道 DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)uart_rx_buf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize UART_RX_BUF_SIZE; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode DMA_Mode_Normal; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel5, DMA_InitStructure); DMA_Cmd(DMA1_Channel5, ENABLE); // 使能串口空闲中断 USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); }DMA工作在下图这个思路DMA自动把串口收到的所有字节搬进uart_rx_buf当一帧数据结束、串口线上出现空闲时触发IDLE中断。在IDLE中断里用“总共接收长度减去DMA当前剩余计数”就是这轮实际收到的字节数。void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { // 先读SR再读DR清除IDLE标志 USART_ReceiveData(USART1); // 计算本轮DMA实际接收长度 uint16_t recv_len UART_RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); // 处理完整一帧 Process_Frame(uart_rx_buf, recv_len); // 重新装填DMA准备接收下一帧 DMA_Cmd(DMA1_Channel5, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel5, UART_RX_BUF_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); } }这里有个非常容易踩的坑DMA_SetCurrDataCounter之前一定要先失能DMA通道否则重新设置计数器不会生效轻则丢字节重则直接进HardFault。另外一个细节是在IDLE中断里处理函数要尽量轻量把“解析”和“写屏”的活放到主循环里做不要在中断上下文里耗太久。3.2 接收缓冲与分块写屏解决了“怎么收”接下来是“怎么显示”。这也是方案C最精华的部分不是等整张图收完再画而是收到一块画一块。我的推荐做法是把接收缓冲分成两个区当DMA把数据装满一个区MCU就去处理这个区的数据并写入LCDDMA同时用另一个区继续收。这就是双缓冲。因为LCD写屏本身耗时尤其SPI接口的屏几百个像素的数据写进去需要几毫秒到几十毫秒如果不用双缓冲DMA很容易把缓冲区覆盖掉。如果没有足够RAM做双缓冲退而求其次的做法是把缓冲区尺寸设得小一点比如每个区域存64字节这样DMA收满的时间很短MCU处理一个区域的速度几乎能跟上DMA的速度。但这会增加处理频率CPU占用偏大。我自己的项目里F103的RAM做双缓冲是足够的大家根据自己的芯片量力而行。写屏操作以ILI9341为例思路是先设置显示窗口再往GRAM里连续写像素void LCD_ShowImage_Block(uint16_t x0, uint16_t y0, uint16_t w, uint16_t h, uint8_t *pixel_data) { LCD_SetWindow(x0, y0, x0 w - 1, y0 h - 1); // 设置写屏窗口 LCD_WriteData_16b((uint16_t *)pixel_data, w * h); // 连续写像素数据 }实际项目中图片数据不是按整行发的而是按固定字节数分包。MCU收到一个包后需要维护一个“当前写到哪个坐标”的游标每处理完一包就更新游标。这个“游标维护”我建议放到一个简单的状态机里收到数据帧就填充、位置满了就换行这样逻辑清晰排查问题方便。3.3 进度回显与断帧恢复很多人做到“能显示图片”就收工了但实际用的时候会发现进度反馈和断帧恢复才是“能不能用”的分水岭。进度回显这件事特别简单MCU每处理完一帧数据就往PC回一个字节的ACK比如0xFE。上位机每收到一个ACK就更新一次进度条。不要觉得这是多此一举我在115200波特率下传150KB数据时如果没有进度显示用户面对13秒的黑屏等待第一反应绝对是“死机了”。断帧恢复是另一个隐藏功能。如果传输中途拔了串口、或者线被干扰导致某个帧的校验错误MCU应该自动丢弃当前这一帧并回到“找帧头”的状态。这其实只需在帧解析函数里做好状态判断当发现校验不对时重新扫描剩余缓冲区寻找下一个0xAA 0x55帧头。这个机制说起来简单但极大提升了系统的鲁棒性。在这个项目的开发过程中我是先实现了一个“裸接收版本”发现只要图片稍大、传输时间稍长一次偶发错误就导致整条链路“卡死”加了“断帧恢复”和“ACK回显”之后整个方案的稳定性才算真正达标。4. 常见问题与排查技巧实录4.1 现象速查表做这个项目过程中我自己加上帮朋友排查遇到过不少问题。这里整理成一张速查表按优先级排大家遇到问题可以先对号入座现象可能原因排查方向与解决方法屏幕花屏、出现横向色带数据错位、帧长度不匹配检查协议帧头是否解析正确检查分包长度和写屏游标是否一致在PC端软件里先固定发一帧单色数据验证整条链路屏幕显示颜色不对、偏色明显RGB888转RGB565时通道移位错误或高低字节顺序反了检查移位公式R3、G2、B3检查MCU写屏时字节序是Little-Endian还是Big-Endian必要时交换高8位和低8位传一会就卡住不再刷新DMA配置异常或者接收缓冲区被覆盖检查DMA模式是否为Normal检查空闲中断里是否重新装填了DMA计数器确认双缓冲逻辑是否有锁冲突接收端完全无反应波特率不匹配或串口线TX/RX接反先用串口助手发单字节测试用示波器/逻辑分析仪看TX脚的波形检查CH340驱动是否正常设备管理器中COM口号是否正确花屏但进度条正常走完图片尺寸和LCD初始化分辨率不一致确认LCD初始化中的宽高和PC端发送的图片宽高一致尤其注意常见的240x320和320x240方向差异上位机打开串口报“端口被占用”上个程序残留了串口句柄或驱动异常松动关闭所有串口助手再插拔USB检查设备管理器里有没有带感叹号的串口设备有就要重装驱动4.2 三个我踩得最深的坑第一个坑是关于DMA计数器重装的。我最早写的代码在清除IDLE标志之后立刻重新装填DMA结果发现偶尔会收丢一个字节。后来查了参考手册才发现清IDLE标志的这个读操作需要DMA已经完全停止才能保证安全。我当时的解决方法是先失能DMA、再读SR和DR清标志、重新设计数器、最后重新使能DMA。虽然代码多几行但稳定性提升了一个档次。如果你也用标准库不妨直接照这个顺序来。第二个坑是串口空闲中断和DMA的配合问题。我最初在IDLE回调里直接调用写屏函数屏幕刷新是快了但整个系统一旦在图片传输过程中插进来一个其他中断就会出现数据显示错乱。后来我把解析和写屏全部放到主循环中“IDLE中断里只收数据和置标志”主循环看到标志就去处理。这样虽然看起来绕了一层但系统的实时性和稳定性都好了很多。嵌入式的铁律就是“中断里尽量少干活”这句话真的是拿教训换来的。第三个坑是波特率太高导致不稳定。我一开始追求速度直接上921600结果实验室用短线没问题换到稍长一点的杜邦线就花屏。用示波器一看波形边缘已经严重畸变。后来我把传输拆成两档调试时用115200追求速度时用460800配合高质量屏蔽线才终于做到长时间传输不出错。所以如果你也遇到“短时间正常、长时间出错”的情况先怀疑接线和地线别只盯着软件。另外还遇到过“stm32 virtual com port叹号”这种驱动问题。这通常是换了一个USB口、或者之前异常拔插导致驱动状态出问题。解决方法是卸载设备后重新扫描或者干脆重装ST的VCP驱动。这个和代码没关系但卡住的时候真的会让人怀疑人生。5. 别忘了校验和重传机制前面提到协议里带了累加和校验但校验失败之后怎么办如果只是“丢掉这一帧”那屏幕上这一块区域就是空白的后续帧会继续画结果就是屏幕上出现一块黑斑或者花斑。比较实用的处理策略是MCU校验失败时回传一个NAK0xFF上位机收到NAK后立刻重发这个数据包。这比整张图重传高效得多。我在实现时还给重发加了一个次数上限比如3次超过上限就直接发送取消指令避免死循环。这个“错误重传”机制让我想起了Modbus RTU协议里面的处理思路。实际上如果你以后要在STM32上移植FreeModbus你会发现串口收发的框架思路是完全一样的——状态机解析、校验、超时重传。所以花点时间把这个逻辑吃透后续做Modbus从站会轻松很多。6. 扩展思路这套方案还能干什么“STM32串口发彩色图片”这个需求本身虽然简单但它打开了一扇门一旦你有了“PC端预处理 分帧协议 边收边显示”这套链路很多东西都可以往里面塞。比如你在做K210与STM32通讯的项目K210识别到目标后可以把裁剪后的目标图像通过串口发给STM32显示这样调试时就能直观看到“AI到底看到了什么”。又比如你在做一个离线仪表盘电脑端采集的波形数据可以先绘制成图片再通过串口发送到设备端显示这就是一个低成本的“无线/串口图传”雏形。如果你有条件换用ESP8266或ESP32走WiFi那这套协议设计基本不用改把“串口收发”换成“Socket收发”就行。核心的分包、校验、重传、游标写屏逻辑通用性非常强。最后再分享一个小技巧如果只是想在LCD上验证图片效果而不是测试串口链路可以先用SD卡把图片存进去读取SD卡里的RGB565文件直接写屏。这样可以把“取数据”和“显示”两个阶段分开调试少走很多弯路。我在前期调LCD驱动时就是这么干的等屏幕显示逻辑没问题了才去接串口那头整个开发周期反而缩短了不少。本文还有配套的精品资源点击获取