STM32 LCD显示图片全链路解析:从取模到刷新的工程实践
1. 从点亮一块屏到显示一张图中间隔着多少坑很多人第一次接触STM32驱动LCD都是从点亮屏幕、刷个纯色背景开始的。这一步其实不难网上随便找个例程把初始化时序对着数据手册抄一遍屏幕能亮颜色能刷心里就踏实了。但接下来想显示一张真正的图片比如一张产品Logo、一张开机界面、或者一张传感器采集后的可视化图问题就来了——屏幕要么花屏要么颜色完全不对要么图片显示到一半就卡死甚至直接白屏。这个标题“STM32——LCD显示图片”看起来只是嵌入式开发里一个很基础的功能点但它背后牵扯的东西其实相当多图片怎么取模、取模后的数据怎么组织、显存怎么规划、DMA怎么配合、颜色格式怎么匹配、Flash空间怎么分配、刷新效率怎么优化。任何一个环节没对齐结果就是屏幕上那团让你怀疑人生的乱码。这篇文章适合两类人看。一类是刚学完STM32基础外设、准备把LCD用起来的嵌入式新手另一类是做了一段时间但一直用现成例程、没真正搞明白图片显示链路的开发者。我会从图片数据的来源讲起一直讲到实际工程里怎么把一张图稳定、高效地显示出来中间会穿插我自己踩过的坑和验证过的方案。全程不依赖任何特定开发板品牌只讲通用思路和可复现的操作。2. 图片取模这件事决定了后面一半的成败2.1 取模软件到底在做什么图片取模的本质是把一张彩色或黑白的图像转换成单片机可以逐像素写入LCD的字节流。你可以把它理解成“把一幅画翻译成单片机看得懂的语言”。这个翻译过程涉及三个关键决策颜色格式、扫描方向、数据排列方式。颜色格式决定了每个像素占多少位。常见的有单色1bit、灰度8bit、RGB56516bit、RGB88824bit。STM32上最常用的是RGB565因为一个像素刚好占两个字节既节省空间又足够表达大部分UI需求。但如果你用的是SPI接口的小屏很多模块默认是RGB666甚至RGB888这时候就需要在取模软件里选对格式否则颜色会整体偏掉。扫描方向决定了像素数据是按行从左到右、还是按列从上到下排列。这个必须和LCD驱动芯片的扫描模式一致。我见过太多人取模时选了“横向扫描”结果屏幕上的图片旋转了90度然后花半天时间去改代码里的坐标映射其实只要在取模软件里改一个选项就能解决。数据排列方式则涉及字节序。有些取模软件输出的是高字节在前有些是低字节在前。STM32是小端模式如果取模软件默认大端输出而你在代码里直接按16位写入颜色就会变成“红蓝互换”的诡异效果。2.2 取模参数怎么选才不返工我自己的习惯是在取模之前先把LCD的驱动芯片手册翻到“像素数据格式”那一页确认三件事接口是8080并口还是SPI、颜色格式是565还是666、扫描方向是横屏还是竖屏。这三件事确认之后再打开取模软件参数就照着填基本不会返工。具体来说如果你用的是常见的2.4寸或2.8寸TFT模块驱动芯片多为ILI9341或ST7789默认都是RGB565、横屏扫描、高字节在前。取模软件里就选“16位真彩色”、“横向扫描”、“高位在前”。输出格式选C数组每个像素两个字节数组类型用const uint16_t。这里有个细节值得注意取模软件生成的数组大小。一张240x320的图片RGB565格式下需要240x320x2153600字节也就是150KB。这个大小对于STM32F103C8T6这种只有64KB Flash的芯片来说根本放不下。所以取模之前一定要算清楚图片尺寸和Flash容量的关系必要时先压缩图片尺寸或者改用外置Flash。2.3 大图取模的折中方案如果你确实需要显示一张大图但芯片Flash又不够有几个折中方案可以考虑。一是降低颜色深度比如把RGB565降成RGB332每个像素只占一个字节空间直接减半代价是颜色过渡会有些生硬。二是把图片切成若干小块只把当前需要显示的那一块加载到RAM里显示完再换下一块。三是外挂一颗SPI Flash把图片数据存到外置Flash里需要时再读进来。我实际项目里用得最多的是第二种方案因为不需要额外硬件只需要在代码里做好分块索引。比如一张240x320的图切成10块240x32的小图每块占15KBRAM里开一个15KB的缓冲区就够了。显示的时候按顺序读一块、写一块、刷一块虽然刷新速度慢一些但对于静态图片来说完全够用。3. 显存规划别让图片数据把RAM吃光了3.1 什么时候需要显存什么时候不需要显存这个概念在STM32 LCD开发里经常被混淆。严格来说显存是一块和屏幕像素一一对应的RAM区域所有绘图操作先写到显存再统一刷到屏幕。但很多STM32项目并不需要显存因为LCD控制器本身自带GRAM你直接往LCD写数据它自己就存下来了。那什么时候需要自己开显存呢两种情况。一是你要做复杂的图形叠加比如在图片上画线、写字、再画个半透明遮罩如果每次都直接写LCD屏幕会闪烁得厉害。二是你要做局部刷新比如只更新屏幕右下角的一个数值如果没有显存做对比你就得把整个屏幕重刷一遍。对于“显示图片”这个需求来说如果只是单纯显示一张静态图不需要显存直接把取模数据通过FSMC或SPI写到LCD的GRAM就行。但如果图片显示之后还要在上面叠加动态内容那就建议开一块显存先把图片数据拷到显存再在显存上做各种操作最后统一刷新。3.2 显存大小的精确计算显存大小等于屏幕分辨率乘以每个像素的字节数。以240x320的RGB565屏幕为例显存需要240x320x2153600字节也就是150KB。这个数字对STM32F4系列来说不算什么F407有192KB RAM开完显存还能剩不少。但对F103系列来说就是灾难F103C8T6只有20KB RAM连显存的零头都不够。所以如果你用的是F103这类小RAM芯片就别想着开全屏显存了。要么用前面说的分块方案要么直接用LCD自带的GRAM不做显存映射。我早期做过一个F103驱动2.4寸屏的项目就是直接写GRAM图片显示没问题只是后来想加个动态进度条的时候发现每次刷新都会闪最后改成只刷新进度条所在的那一行区域才把闪烁压下去。3.3 显存和图片数据的存放策略假设你用的是F407这类RAM充足的芯片开了150KB显存之后图片数据放哪里如果图片只有一张可以直接放在Flash里用const修饰编译后自动分配到Flash区域。显示的时候从Flash读出来写到显存再从显存刷到LCD。但如果图片有多张比如一个开机动画有20帧每帧150KB总共3MB那Flash肯定放不下。这时候就需要外置存储比如SD卡或SPI Flash。我的做法是把图片数据统一转成二进制文件存到SD卡STM32通过FatFS读取读一块显示一块。这样Flash里只需要放文件系统代码和LCD驱动空间压力小很多。这里有个经验从SD卡读图片数据的时候尽量用DMA方式不要用轮询。因为SD卡读取本身有延迟如果CPU一直等着刷新率会很低。用DMA把数据从SD卡搬到RAM缓冲区搬完再触发LCD刷新CPU可以在等待期间处理其他任务。4. 从Flash到屏幕图片数据是怎么一步步走过去的4.1 直接写GRAM的完整链路最简单的图片显示链路是这样的图片取模得到C数组数组放在Flash里代码里用指针遍历数组每次取两个字节通过FSMC或SPI写到LCD的数据寄存器。LCD控制器收到数据后自动写入内部GRAM屏幕就显示出对应的像素。这条链路里FSMC并口是最快的。以ILI9341为例FSMC配置成16位模式写一个像素只需要一次总线操作240x320的图片理论上几毫秒就能写完。但实际速度受限于FSMC的时序配置如果时序参数设得太保守写一个像素要等好几个时钟周期整体刷新时间就会拉长。SPI接口就慢多了。即使用STM32F4的硬件SPI跑到42MHz写一个像素需要两个字节加上命令和地址开销实际刷新一张240x320的图要几十毫秒甚至上百毫秒。所以如果你对刷新速度有要求尽量选FSMC并口屏SPI屏只适合显示静态内容或小尺寸屏幕。4.2 DMA在图片刷新里的正确用法DMA在图片刷新里的作用是把CPU从“搬运工”的角色里解放出来。以FSMC为例你可以配置DMA通道源地址是图片数组在Flash里的位置目标地址是FSMC的数据寄存器传输长度是图片的像素数乘以2。配置好之后启动DMACPU就可以去干别的事了DMA会自动把数据一个一个搬到LCD。但这里有个坑FSMC的地址映射。LCD的数据寄存器和命令寄存器通常映射到两个不同的地址写数据的时候要往数据地址写写命令的时候要往命令地址写。DMA配置的时候目标地址必须是数据地址不能搞错。我见过有人把目标地址写成命令地址结果DMA搬了一堆数据过去全被LCD当成命令解析了屏幕直接花掉。另一个坑是DMA的传输完成中断。图片数据搬完之后DMA会触发一个中断你需要在中断里做一些收尾工作比如关闭DMA通道、清除标志位、或者启动下一块数据的传输。如果忘了关DMA下次再启动的时候可能会报错。如果忘了清标志位中断会一直触发CPU被卡在中断里出不来。4.3 分块刷新时的边界处理分块刷新图片的时候最容易出问题的地方是边界。比如你把一张240x320的图切成10块240x32的每块单独刷新。刷新第一块的时候LCD的写指针要定位到(0,0)刷新第二块的时候要定位到(0,32)以此类推。这个定位操作是通过写LCD的“列地址设置”和“行地址设置”命令来完成的。如果定位错了图片就会错位。比如第二块写到了(0,31)的位置那第一块的最后一行就会被覆盖掉屏幕上会出现一条重复的像素线。这种问题在调试的时候很难发现因为图片整体看起来是对的只是某一行有点怪。我的习惯是在分块刷新的时候每块刷新完都打印一下当前的坐标范围确认无误后再关掉打印。还有一个边界问题是图片宽度不是屏幕宽度的整数倍。比如屏幕240宽图片200宽那每行末尾就有40个像素是空白的。如果你直接按200的宽度写数据LCD的写指针会自动跳到下一行的开头但下一行的起始位置是按240算的所以图片会斜着显示。解决办法是在取模的时候就把图片补成240宽或者在代码里手动填充空白像素。5. 颜色不对、花屏、卡死三个最典型问题的排查链路5.1 颜色整体偏红或偏蓝的根因定位颜色偏红或偏蓝十有八九是RGB565的字节序搞反了。RGB565里红色占高5位绿色占中间6位蓝色占低5位。如果取模软件输出的是高字节在前而你在代码里按低字节在前解析红色和蓝色就会互换。排查方法很简单写一个测试图案比如纯红色(0xF800)、纯绿色(0x07E0)、纯蓝色(0x001F)依次显示。如果红色显示成蓝色蓝色显示成红色那就是字节序反了。解决办法有两个一是改取模软件的设置二是改代码里的字节交换逻辑。我一般倾向于改代码因为取模软件的输出格式有时候不好改而代码里加一个__REV16或者手动移位交换更可控。还有一种颜色不对的情况是整体偏暗或偏亮。这通常是RGB565的位对齐问题。比如你把24位色当成16位色取模每个像素多读了一个字节颜色数据就全错位了。这种问题的特征是颜色看起来“脏脏的”像是蒙了一层灰。解决办法是重新取模确认颜色深度和代码里的解析方式一致。5.2 花屏的三种典型表现和对应原因花屏是LCD调试里最常见的问题但花屏和花屏不一样不同表现对应不同原因。第一种花屏是“雪花屏”屏幕上全是随机噪点。这通常是初始化时序不对LCD控制器没有正确进入工作状态。排查方法是检查复位时序、初始化命令序列、以及FSMC或SPI的时序参数。我遇到过因为FSMC的地址建立时间设得太短导致LCD偶尔能初始化成功、偶尔失败的情况后来把时序参数放宽了一倍问题就消失了。第二种花屏是“条纹屏”屏幕上出现规律的横条或竖条。这通常是显存地址映射错了或者图片数据的宽度和LCD的宽度不匹配。比如图片是240宽但LCD的显存一行是320个像素你按240写数据LCD就会把下一行的数据接到这一行后面形成斜条纹。解决办法是确认LCD的显存宽度取模时按这个宽度来。第三种花屏是“局部花”只有屏幕的某一块区域显示异常。这通常是分块刷新时的坐标计算错误或者DMA传输长度不对。排查方法是把分块刷新改成整屏刷新如果整屏刷新正常那就是分块逻辑的问题。然后再逐块检查坐标和长度一般都能找到问题。5.3 刷新到一半卡死的排查思路图片刷新到一半卡死最常见的原因是DMA传输完成中断没有正确处理。比如DMA搬完数据后触发了中断但中断服务函数里没有清除标志位导致中断反复触发CPU一直卡在中断里。或者中断服务函数里做了太耗时的操作比如延时、打印导致主循环被阻塞。另一个可能的原因是RAM溢出。如果你开了显存又开了DMA缓冲区还开了其他大数组RAM可能已经接近极限了。这时候程序运行到某一步栈空间不够就会HardFault。排查方法是看编译后的map文件确认RAM使用率。如果超过80%就要考虑优化内存布局或者换更大RAM的芯片。还有一个不太常见但很隐蔽的原因是Flash读取速度跟不上。如果你把图片数据放在Flash里DMA直接从Flash读而Flash的访问速度又比较慢DMA可能会因为等待Flash数据而超时。这种情况在F1系列上比较少见但在一些低功耗系列上确实存在。解决办法是把图片数据先拷到RAM再从RAM用DMA搬。6. 刷新效率的优化从能显示到显示得快6.1 FSMC时序参数的调优经验FSMC的时序参数直接决定了LCD的写入速度。以ILI9341为例数据手册里给出的最小写周期是66ns对应大约15MHz的时钟。但实际配置的时候FSMC的时钟往往远高于这个频率所以需要通过插入等待周期来满足时序要求。FSMC的时序参数主要有四个地址建立时间(ADDSET)、地址保持时间(ADDHLD)、数据建立时间(DATAST)、数据保持时间(DATHLD)。对于写操作来说最关键的是DATAST和DATHLD。如果这两个值设得太大写入速度就慢设得太小LCD可能来不及响应导致数据丢失。我的经验是先按数据手册的推荐值配置然后逐步减小DATAST直到屏幕出现花屏或颜色异常再往回加一两个周期。这样能在稳定性和速度之间找到最佳平衡点。对于ILI9341我实测下来DATAST2、DATHLD1是比较稳的配置再小就不行了。6.2 SPI屏的刷新加速技巧SPI屏的刷新速度受限于SPI时钟和命令开销。STM32F4的硬件SPI最高可以跑到42MHz甚至更高但很多SPI屏的驱动芯片最高只支持到20MHz左右。所以第一步是确认屏幕支持的最高SPI时钟然后尽量往这个频率靠。第二步是减少命令开销。SPI屏每次写数据之前都要先发命令设置地址窗口这个命令序列有固定的字节数。如果每次刷新都重新设置窗口开销就很大。优化方法是如果连续刷新的区域是连续的可以只设置一次窗口然后连续写数据。比如整屏刷新的时候设置一次全屏窗口然后一口气把150KB数据全写完中间不再发命令。第三步是用DMA。SPI的DMA和FSMC的DMA类似都是把数据从内存搬到外设。但SPI的DMA需要配合SPI的发送完成标志来用不能简单地搬完就完事。我的做法是DMA搬完一块数据后在中断里检查SPI的发送完成标志确认数据真的发出去了再启动下一块。6.3 双缓冲和局部刷新的取舍双缓冲是指开两块显存一块用于当前显示一块用于后台绘制绘制完成后交换两块显存的角色。这样做的好处是绘制过程不会影响显示屏幕不会闪烁。但代价是显存需求翻倍240x320的屏幕需要300KB RAM只有F4系列以上的芯片才扛得住。局部刷新是指只刷新屏幕上发生变化的那一块区域。比如屏幕上有个数字在变那就只刷新数字所在的那一小块其他区域不动。这样做的好处是刷新数据量小速度快而且不需要额外显存。但代价是代码复杂度高需要精确计算变化区域的坐标和大小。我实际项目里如果是静态图片显示直接用局部刷新就够了不需要双缓冲。如果是动态UI比如有动画、有实时数据更新那就要看芯片RAM够不够。RAM够就上双缓冲RAM不够就用局部刷新加显存映射把变化区域标记出来定时统一刷新。7. 图片数据放在哪Flash、SD卡和外置存储的选择7.1 内部Flash的容量边界和取舍STM32的内部Flash容量从16KB到2MB不等。对于图片显示来说如果图片数量少、尺寸小内部Flash是最方便的选择。比如一张100x100的RGB565图片占20KBF103C8T6的64KB Flash放一张绰绰有余。但如果图片尺寸大或者数量多内部Flash就不够了。比如一张240x320的图占150KBF103C8T6根本放不下F103RCT6的256KB Flash也只能放一张。这时候就要考虑外置存储。内部Flash的另一个问题是写入寿命。Flash的擦写次数有限通常10万次左右。如果你把图片数据放在内部Flash而且频繁更新Flash很快就会坏掉。所以内部Flash只适合存放不变的图片数据比如Logo、开机界面。7.2 SD卡方案的完整实现路径SD卡方案适合图片数量多、尺寸大的场景。实现路径是把图片转成二进制文件存到SD卡STM32通过FatFS文件系统读取读到的数据直接写LCD。这里的关键是FatFS的配置。FatFS默认的扇区大小是512字节而LCD的像素数据是16位所以读出来的数据需要按16位重新组织。我的做法是在FatFS的读回调函数里直接把读到的字节流按16位解析存到一个uint16_t缓冲区然后再写LCD。这样避免了在应用层做字节拼接。另一个关键是SD卡的读取速度。SD卡在SPI模式下的读取速度通常只有几MB/s对于150KB的图片来说读一次要几十毫秒。如果图片是连续显示的比如动画这个速度就不够。解决办法是用SD卡的4位SDIO模式速度可以到20MB/s以上但需要芯片支持SDIO外设。7.3 外置SPI Flash的性价比分析外置SPI Flash是介于内部Flash和SD卡之间的方案。容量通常有1MB到16MB价格便宜接口简单只需要SPI的4根线。读取速度比SD卡快比内部Flash慢但足够应付大部分图片显示需求。我的做法是把图片数据通过取模软件生成二进制文件然后用烧录器写到SPI Flash里。STM32通过SPI读取Flash数据读到的数据直接写LCD。这样Flash里只需要放LCD驱动和SPI Flash驱动不需要文件系统代码量小启动快。SPI Flash的缺点是写入麻烦需要额外的烧录器。如果图片需要频繁更新SD卡更方便。但如果图片是固定的SPI Flash的稳定性和速度都更好。8. 我踩过的几个印象深刻的坑第一个坑是取模软件的“自动调整”功能。有些取模软件会自动把图片缩放到某个尺寸或者自动调整颜色深度。我一开始没注意取模出来的数据和原图对不上屏幕上显示的图片被拉伸了。后来发现是软件里有个“自动适应屏幕”的选项默认勾选了取消之后就好了。所以取模的时候一定要确认输出尺寸和原图一致。第二个坑是FSMC的地址映射。我一开始把LCD的数据寄存器和命令寄存器地址搞反了结果初始化命令全被当成数据写进了GRAM屏幕显示出一堆乱码。后来对着数据手册的地址映射表仔细核对才发现命令寄存器是0x6C000000数据寄存器是0x6C000002两个地址只差一个字节但功能完全不同。第三个坑是DMA传输完成中断的优先级。我把DMA中断的优先级设得比SysTick还高结果DMA中断频繁触发SysTick被阻塞整个系统的延时函数都不准了。后来把DMA中断优先级降到SysTick之下问题解决。这个坑的教训是中断优先级不是越高越好要根据实际需求来分配。第四个坑是图片数据的对齐问题。STM32的DMA在某些模式下要求源地址和目的地址都是4字节对齐的。我一开始把图片数组定义成uint8_t类型地址没有对齐DMA传输的时候偶尔会出错。后来改成uint16_t类型并且用__attribute__((aligned(4)))强制4字节对齐问题就再也没出现过。9. 一个完整的图片显示函数应该长什么样把前面所有东西串起来一个完整的图片显示函数应该包含这几个部分参数检查、坐标设置、数据写入、完成等待。参数检查是确认图片的宽高和起始坐标没有超出屏幕范围。坐标设置是通过写LCD的命令寄存器设置列地址和行地址的起始和结束位置。数据写入是通过FSMC或SPI把图片数据逐像素写到LCD的GRAM。完成等待是确认所有数据都写完了LCD的写指针回到了起始位置。如果是用DMA数据写入部分就换成DMA配置和启动。DMA配置里要设置源地址、目的地址、传输长度、传输方向、传输模式。启动之后CPU可以去做别的事等DMA传输完成中断触发后再处理下一块数据。如果是分块显示还需要一个循环把图片分成若干块每块调用一次显示函数每次更新坐标。循环里要注意块的顺序和坐标的累加别搞错了。最后别忘了在显示完成后加一个短暂的延时让LCD有时间把最后一个像素真正显示出来。这个延时不需要太长几个毫秒就够了但如果没有有时候最后一行像素会显示不全。10. 后续可以继续折腾的方向图片显示跑通之后可以继续折腾的东西还有很多。比如把图片显示和触摸屏结合起来做一个图片浏览器手指滑动切换图片。或者把图片显示和SD卡结合起来做一个数码相框自动轮播SD卡里的图片。再或者把图片显示和网络模块结合起来做一个远程图片推送的终端服务器发一张图STM32显示出来。如果对性能有极致要求可以研究一下STM32的LTDC外设。LTDC是STM32F4/F7/H7系列自带的LCD控制器支持多层叠加、硬件加速、DMA2D图形加速。用LTDC驱动RGB屏刷新率和显示效果都比FSMC或SPI好很多。但LTDC的配置也复杂得多需要仔细规划显存布局和时钟树。如果对成本敏感可以研究一下用STM32的SPI接口驱动OLED小屏。OLED屏不需要背光对比度高适合显示简单的图标和文字。虽然尺寸小但驱动简单代码量少适合低成本项目。我在实际项目里最深的体会是图片显示这个功能看起来简单但真正要做好需要把取模、存储、传输、刷新这几个环节都吃透。任何一个环节偷懒后面都会以花屏、卡死、颜色不对的形式还回来。所以别急着抄例程先把原理搞清楚后面调试的时候会省很多时间。