1. 项目缘起为什么要在STM32内部FLASH里存图片最近在做一个基于STM32F103的便携式设备需要显示一个公司的Logo和一些简单的状态图标。一开始我理所当然地想到了用SD卡或者外置SPI FLASH来存储这些图片资源毕竟现在一张128x128的彩色图片用RGB565格式存储也要32KB内部FLASH那点空间哪够用但客户提了一个“简单”的要求设备必须一体化不能有任何可插拔的部件并且要保证极端环境下的可靠性。这意味着SD卡方案首先被排除了。外置FLASH呢虽然可靠但需要多占用几个IO口增加BOM成本和PCB面积对于这个成本敏感的项目来说也不是最优选。于是我把目光投向了那颗STM32F103C8T6自带的64KB FLASH。Logo和几个图标加起来经过优化大概需要40KB的空间。如果程序本身写得精简似乎……有戏这个想法让我既兴奋又忐忑。兴奋的是如果成功将极大地简化硬件设计提升可靠性忐忑的是这需要非常精细地管理有限的存储空间并且要解决图片从电脑上的PNG/JPG格式转换成单片机可以直接读取的二进制数组这一系列繁琐但关键的步骤。这个项目就是一次将“不可能”变为“可能”的实践。它不仅仅是显示一张图片更是对单片机资源规划、数据转换流程和显示驱动优化的一次深度探索。如果你也在为如何在资源紧张的嵌入式设备上优雅地显示图片而烦恼那么我踩过的坑和总结的方法或许能给你带来一些直接的启发。2. 核心原理图片数据如何“住进”FLASH并被点亮在开始动手前我们必须搞清楚两个核心问题图片数据以什么格式存储单片机如何找到并读取它们2.1 图片数据的“嵌入式形态”从像素到常量数组在电脑上我们看到的彩色图片如PNG、JPG是经过高度压缩的这种格式对于单片机来说解码负担太重。单片机需要的是最“原始”的像素数据。因此第一步是格式转换。对于STM32驱动的一般LCD如ILI9341、ST7789等最常用的像素格式是RGB565。在这个格式中一个像素用16位2字节表示高5位是红色中间6位是绿色低5位是蓝色。它是在色彩表现和存储空间之间一个很好的平衡点。转换后的数据就是一个按顺序排列的、巨大的二维数组。例如一张100x100的图片转换后就是一个包含10000个uint16_t类型数据的数组。为了让编译器把这个数组放到FLASH里而不是默认的RAM我们需要使用关键字来修饰它。最常用的是const但为了更明确地指定存储区域很多开发环境如Keil MDK的ARMCC、IAR或通过链接脚本可以使用类似__attribute__((section(“.ARM.__at_0x0800F000”)))或 “.text”的语法将数组绝对定位到FLASH的某个地址。这里有一个关键点这个数组必须是全局常量。这样在程序编译后数组数据就成为二进制程序的一部分被直接烧录到了单片机的FLASH指定区域。上电后这些数据就静静地“躺”在那里等待被读取。2.2 寻址与读取指针的魔法数据存好了怎么用呢单片机CPU不能直接操作FLASH地址它需要通过内存地址来访问数据。这里就用到指针。我们声明一个指向这个常量数组的指针。由于数组本身在FLASH中这个指针指向的也是FLASH地址。当我们通过指针访问数据时单片机内核会通过总线发起一次对FLASH的读取操作。对于STM32访问内部FLASH的速度比RAM慢但对于显示图片这种操作通常通过SPI或FSMC发送数据FLASH的读取速度通常是足够的不会成为瓶颈。读取过程非常简单// 假设图片数组定义为const uint16_t myImage[10000] {0x1234, ...}; const uint16_t *pixel_data myImage; // 指针指向FLASH中的数组首地址 // 要读取第i个像素 uint16_t pixel_color pixel_data[i];然后我们将这个pixel_color值通过SPI或FSMC接口发送给LCD的GRAM显存对应的像素点就会被点亮。通过一个双重循环遍历所有像素整张图片就显示出来了。注意这里有一个常见的误解。有人会尝试用memcpy把FLASH中的数据先拷贝到RAM数组再发送。对于大图片这会消耗大量宝贵的RAM完全不可取。正确的做法是像上面那样直接从FLASH地址读取并流式地发送给LCD实现“读取-发送”的流水线操作RAM仅需几个字节的缓冲区即可。3. 实战全流程从图片到显示的完整链路拆解理解了原理我们来看具体怎么做。整个过程可以分解为四个关键步骤图片处理、数据转换、工程集成、驱动显示。3.1 第一步图片预处理与优化在转换之前对图片进行预处理能事半功倍。尺寸裁剪用Photoshop、GIMP或任何图片工具将图片精确裁剪到你需要显示的尺寸比如128x128。LCD显示多大图片就做多大一像素都不要浪费。色彩简化如果你的图片颜色不多如Logo可以考虑在电脑上先将其转换为索引色模式并手动调整调色板使其更接近RGB565的色彩分布可以减少转换后的色彩失真。格式保存保存为PNG无损或BMP格式。避免使用JPG因为其有损压缩会在转换引入的失真上叠加新的失真。3.2 第二步格式转换工具的选择与使用这是核心环节把图片变成C数组。有多个工具可选在线转换网站搜索“Image to C array”或“BMP to C array”有很多选择。上传图片选择RGB565格式生成数组。优点是快缺点是对批量处理不友好且数组格式可能需要调整。本地软件LCD Image Converter是一个功能强大的开源工具。它允许你定义输出格式RGB565 字节顺序设置数组名称甚至能生成整个位图字体库。我强烈推荐这个工具。Python脚本如果你喜欢自动化用PIL库写个Python脚本是最灵活的方式。下面是一个极简的示例from PIL import Image import numpy as np img Image.open(‘logo.png’).convert(‘RGB’) img_data np.array(img) # 将RGB888转换为RGB565 (简化版) r (img_data[..., 0] 3) 0x1F g (img_data[..., 1] 2) 0x3F b (img_data[..., 2] 3) 0x1F rgb565 (r 11) | (g 5) | b # 将rgb565数组以C语言数组格式写入文件 with open(‘logo.c’, ‘w’) as f: f.write(‘const uint16_t logo[%d] {\n’ % rgb565.size) # … 遍历rgb565.flat并格式化写入 … f.write(‘};\n’)使用脚本可以轻松集成到构建系统中实现图片修改后自动更新数组。关键参数设置无论用哪种工具务必确认像素格式RGB565。字节顺序Little Endian小端模式STM32是小端机。这决定了数组里每个16位数据的高低位排列顺序如果错了显示的颜色会完全混乱。扫描方向通常选择Top to Bottom, Left to Right从上到下从左到右这与大多数LCD的GRAM填充顺序一致。3.3 第三步在MDK-Keil工程中集成图片数组生成logo.c和logo.h文件后将它们添加到你的工程。头文件声明在logo.h中使用extern声明这个数组方便其他文件引用。#ifndef __LOGO_H #define __LOGO_H #include “stdint.h” extern const uint16_t logo[128*128]; // 假设是128x128图片 #endif源文件定义在logo.c中包含头文件并定义数组。为了确保其被放入FLASH最通用的方法是使用const关键字并将其放在一个独立的代码文件中。链接器通常会将所有.c文件中的const全局变量分配到只读区域即FLASH。#include “logo.h” const uint16_t logo[128*128] { 0xFFFF, 0xF800, 0x07E0, … // 巨大的数据数组 };链接脚本检查进阶对于默认配置上述方法足够。但如果你的程序很大FLASH临近用完需要精确控制数组位置以避免链接错误就需要修改链接脚本.sct文件。你可以指定将logo.o这个目标文件放在FLASH的某个特定区域。不过对于大多数项目让链接器自动管理即可。3.4 第四步编写LCD驱动显示函数这是最后一步也是最体现效率的一步。一个基础的显示函数如下/** * brief 在指定位置显示FLASH中的图片 * param x, y: 起始坐标 * param width, height: 图片宽高 * param img: 指向FLASH中图片数组的指针 * retval None */ void LCD_ShowImage_FLASH(uint16_t x, uint16_t y, uint16_t width, uint16_t height, const uint16_t *img) { LCD_SetWindow(x, y, xwidth-1, yheight-1); // 设置LCD显示窗口 LCD_WriteRAM_Prepare(); // 发送写GRAM指令 const uint16_t *pixel img; uint32_t total_pixels (uint32_t)width * height; // 方法1逐个像素发送简单但效率较低 // for(uint32_t i0; itotal_pixels; i) { // LCD_WriteData(*pixel); // } // 方法2使用DMA或批量发送推荐高效 // 假设你的LCD_WriteDataBuffer函数能连续发送一个数据缓冲区 LCD_WriteDataBuffer((uint8_t*)pixel, total_pixels * 2); // RGB565每个像素2字节 }效率优化要点避免单次发送如果LCD驱动函数LCD_WriteData一次只发送一个16位数据并伴随复杂的指令和等待速度会非常慢。务必优化底层驱动实现连续写入。使用DMA如果LCD接口是SPI或FSMC启用DMA来搬运数据是终极解决方案。CPU只需要启动DMA传输就可以去处理其他任务显示过程零消耗CPU。这是显示大图片或动画时的必备技能。窗口设置一次确保LCD_SetWindow只调用一次。如果在循环内部设置窗口会极大降低速度。4. 资源管理的艺术在64KB的FLASH里“精打细算”当我决定把图片塞进STM32F103C8T6的64KB FLASH时我就知道这是一场与空间的博弈。以下是我的“精打细算”心得4.1 FLASH空间布局分析以STM32F103C8T6为例64KB FLASH地址范围是0x0800 0000~0x0800 FFFF。通常0x0800 0000开始中断向量表。之后程序代码.text。再之后只读数据.constdata我们的图片数组就属于这里。最后可能还有其他数据。编译后通过IDE的map文件可以查看详细的内存分布。你需要关注的是程序本身有多大优化编译选项如-O2 去除调试信息可以显著减小代码体积。图片数组有多大(宽度 * 高度 * 2)字节。预留空间FLASH末尾要留出一部分空间至少几KB用于其他常量数据或未来功能扩展不要用到100%。一个简单的估算公式可用FLASH空间 芯片总FLASH - 程序代码大小 - 预留空间可存储图片大小 ≈ 可用FLASH空间 / 2因为每个像素占2字节例如程序编译后占20KB预留4KB那么可用空间为40KB大约可以存储一张160x12820480像素约40KB的RGB565图片。4.2 图片压缩的权衡RLE与色彩深度降低当空间实在紧张时可以考虑对图片数据进行压缩但需要在CPU开销和空间节省之间权衡。RLE压缩对于大面积纯色块如Logo行程长度编码RLE非常有效。例如连续100个白色像素存储为(0xFFFF, 100)而不是存储100次0xFFFF。但这需要在显示时实时解压会增加CPU负担。对于STM32F103如果图片简单解压开销可以接受。降低色彩深度从RGB565降到RGB555高位补0甚至自定义的12位色可以节省25%的空间。但色彩表现力会下降可能需要抖动算法来改善观感。这通常用于对色彩要求不高的图标。使用索引色如果一张图片中颜色种类很少比如少于256种可以存储一个调色板颜色表和每个像素的索引值。例如一个8位的索引可以表示256种颜色每个像素只占1字节比RGB565的2字节节省一半空间。显示时根据索引查表获取真实的RGB565值。这是空间和速度的一个很好折中尤其适合图标集。个人经验不要盲目压缩。首先评估图片特征。对于复杂的照片类图片压缩率低且解压耗时不如直接换用外置FLASH。对于简单的图形和图标RLE或索引色是神器。在我的项目中一个多色Logo用RLE压缩后体积减少了60%而解压显示增加的耗时几乎可以忽略不计。4.3 链接脚本的精确控制当自动链接无法满足需求时比如你想把多张图片固定在FLASH末尾就需要手动修改链接脚本。以Keil MDK的分散加载文件.sct为例LR_IROM1 0x08000000 0x00010000 { ; 64KB FLASH区域 ER_IROM1 0x08000000 0x0000F000 { ; 前60KB放代码和初始化数据 *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) } IMAGE_DATA 0x0800F000 0x00001000 { ; 最后4KB固定放图片数据 logo.o (RO) ; 将logo.o中的所有只读数据放到这里 icon.o (RO) } }这样无论代码如何增长图片数据都牢牢地固定在0x0800F000地址开始的地方。在代码中你可以直接通过绝对地址访问#define LOGO_ADDRESS ((const uint16_t*)0x0800F000) void show_logo() { LCD_ShowImage_FLASH(0, 0, 128, 128, LOGO_ADDRESS); }这种方法提供了最强的控制力但牺牲了灵活性需要更深入的工具链知识。5. 避坑指南与性能优化实战在实际操作中我遇到了不少问题也总结了一些优化技巧。5.1 常见问题排查清单现象可能原因排查步骤与解决方案图片显示全白/全黑/错乱1. 字节顺序错误。2. 图片数组未正确放入FLASH。3. 指针类型或访问错误。4. LCD初始化或窗口设置错误。1.检查字节序在转换工具中确认是Little Endian。用一个纯色如红色0xF800小图片测试最直观。2.检查数组定义确认使用了const且数组体积与图片大小匹配。查看map文件确认数组地址在FLASH范围0x08xxxxxx。3.检查指针显示函数参数应为const uint16_t*。用调试器查看指针指向的地址和数据是否正确。4.检查LCD驱动先用画点函数画一个方框确保LCD基础驱动正常。显示颜色不正确1. RGB565转换公式错误。2. LCD驱动IC的色彩格式设置不匹配如有的LCD支持RGB666/888。1.验证转换用已知颜色值如纯红、纯绿、纯蓝测试转换工具的输出是否正确。2.查阅LCD数据手册确认初始化代码中像素格式设置为RGB565通常是某个寄存器如ILI9341的0x3A寄存器。程序编译后大小激增图片数组被意外链接到了多个位置或者调试信息包含其中。1. 确保图片数组只在.c文件中定义一次在.h中用extern声明。2. 在Release模式下编译并开启优化选项如-Oz for size。3. 检查map文件看是否有重复的符号。显示速度极慢1. LCD写数据函数效率低下单次发送有冗余延迟。2. 在显示循环中频繁设置窗口或发送命令。1.优化底层驱动实现连续写函数LCD_WriteDataBuffer利用SPI的连续传输或FSMC的连续写模式。2.DMA化将SPI或FSMC配置为DMA模式让硬件自动搬运数据。3.确保窗口只设置一次在循环外完成。5.2 性能优化让图片显示“飞起来”使用硬件加速接口如果MCU有FSMCFlexible Static Memory Controller或LTDCLCD-TFT Display Controller一定要用起来。FSMC可以将LCD映射为内存设备像读写内存一样操作LCD速度远超SPI。SPIDMA组合拳对于SPI接口的LCD配置DMA是标准操作。步骤是设置好SPI和DMA通道将图片数组的FLASH地址作为DMA的源地址LCD的数据寄存器作为目标地址设置传输数据量像素数*2然后启动DMA。CPU在此期间完全自由。// 伪代码示例 (HAL库风格) HAL_SPI_Transmit_DMA(hspi1, (uint8_t*)image_array, pixel_count * 2); // 等待DMA传输完成回调或进行其他任务双缓冲与局部刷新如果需要显示动画或多张图片切换可以考虑在RAM中开辟一个小的缓冲区一行的像素或一个块将FLASH中的数据先解压或预处理到这个缓冲区再一次性发送。这比直接从FLASH零星读取效率更高。对于局部更新如更新一个数字只刷新屏幕的那一小块区域而不是全屏刷新。图片数据对齐确保图片数组在内存中按字4字节对齐有时可以提升DMA或CPU的访问效率。可以使用编译器指令如__attribute__((aligned(4)))。5.3 一个进阶技巧将图片转换为字体库模式如果你需要显示多个小图标比如Wi-Fi信号强度、电池电量等可以将它们整合到一个“图片集”中就像字模一样。定义一个结构体包含图标的宽度、高度和在“图片集”中的偏移量。显示时根据偏移量计算出图标的起始指针。这样你只需要管理一个大的图片数组文件而不是几十个小文件链接和管理都更方便。typedef struct { uint16_t width; uint16_t height; uint32_t offset; // 在总图片数组中的字节偏移量 } IconDef; const IconDef icon_wifi_full {16, 16, 0}; const IconDef icon_battery_80 {16, 16, 512}; // 假设前一个图标占16*16*2512字节 const uint8_t total_icon_data[4096] { ... }; // 所有图标的数据 void LCD_ShowIcon(uint16_t x, uint16_t y, const IconDef *icon) { const uint16_t *img_ptr (const uint16_t*)(total_icon_data[icon-offset]); LCD_ShowImage_FLASH(x, y, icon-width, icon-height, img_ptr); }6. 项目总结与扩展思考经过这一轮折腾最终成功地将Logo和5个状态图标总计约38KB塞进了那颗64KB FLASH的STM32F103里程序本体控制在22KB以内预留了4KB空间游刃有余。设备运行稳定上电显示迅速客户对一体化的设计非常满意。回顾整个过程最关键的不是某个高深的技巧而是系统性的资源规划意识。在项目开始时就估算FLASH和RAM的用量为数据预留空间比后期再来抠抠搜搜地优化要轻松得多。对于更复杂的项目当内部FLASH真的不够用时思路可以这样扩展升级MCU选择FLASH更大的型号如STM32F103RC256KB或STM32F407系列1MB这是最直接的方案。外置SPI FLASH使用W25Q系列等芯片成本低容量大从1Mb到128Mb通过SPI接口访问。可以将图片、字体甚至文件系统放在里面。显示时需要先将数据从SPI FLASH读到RAM缓冲区再发送给LCD对RAM有一定要求。使用QSPI接口和内存映射模式一些高端STM32如F7/H7系列支持QSPI内存映射模式可以将外置FLASH映射到MCU的地址空间如0x90000000像访问内部FLASH一样直接读取数据无需缓冲极大地简化了编程并提升了速度。最后分享一个我调试时的小技巧在图片数组定义后紧接着定义一个特殊的结束标记数组比如const uint32_t image_end_marker 0xDEADBEEF;。然后在代码中检查这个标记的地址就能清晰地知道图片数据在FLASH中实际占用的结束地址便于精确计算剩余空间。嵌入式开发就是这样在有限的资源里舞蹈每一个字节都值得被认真对待。