简介:本资源是一套面向嵌入式初学者与STM32开发者的图片显示实战工程,聚焦STM32F103在LCD/OLED屏上解码并显示JPG、PNG、GIF格式图像的核心技术难点。资源提供完整可运行的Keil MDK工程,涵盖JPEG/PNG/GIF软件解码逻辑、SPI/I2C显示屏驱动、RGB565数据转换及DMA加速传输等关键模块,适用于智能仪表、人机交互终端等需要图形界面的嵌入式项目。压缩包共153个文件,含64个C源码(如ff.c、lcd.c、cc936.c等底层驱动与解码实现)、55个头文件(定义接口与配置)、16张测试用PNG图片及编译脚本(bat)、调试配置(dbgconf)、工程文件(uvprojx/uvoptx)等,整体大小1.91MB,结构清晰,便于分模块学习与移植。目前已有1722人学习下载,读者可直接获取成熟解码框架、适配不同屏幕的驱动模板、内存受限下的优化方案(如预解码存Flash、DMA搬运),以及多格式兼容的工程组织范式。
1. STM32F103上显示JPG/PNG/GIF不是“把图片拖进工程就行”——它本质是内存带宽、解码算力与显示驱动的三重博弈
很多刚接触STM32F103的开发者,在完成LED闪烁、串口打印后,满怀期待地想在OLED或TFT屏上显示一张PNG图标,结果发现:编译能过,烧录成功,屏幕却始终黑屏或只闪出乱码色块。这不是代码写错了,而是误判了平台能力边界——STM32F103(Cortex-M3,72MHz主频,64KB SRAM)没有硬件JPEG解码器,不支持直接加载文件系统中的JPG/PNG/GIF;所谓“图片显示”,实则是将压缩图像数据在片上实时解码为RGB565/RGB888像素流,并通过SPI/I2C/FSMC接口持续喂给显示屏。它要求你亲手拆解三个硬约束:Flash空间能否容纳解码库+图片资源、SRAM是否够存一帧解码缓冲区、主频能否撑住GIF逐帧解码+刷新的时序压力。本文面向已搭建好STM32F103最小系统(含ST标准外设库v3.50或HAL库)、连接ILI9341/TFT或SSD1306/OLED的开发者,不讲原理图设计,不教CubeMX配置引脚,只聚焦“如何让一张jpg/png/gif真正在屏幕上动起来”,从解码选型、资源固化、驱动适配到GIF动画控制,给出可抄、可调、可排错的完整链路。
2. 为什么不用libjpeg-turbo或stb_image?——STM32F103上轻量级解码器的选型逻辑与移植实操
在x86平台,libjpeg-turbo解码一张100KB JPG只需几毫秒;但在STM32F103上,同等操作可能耗时300ms以上,且需占用128KB Flash和24KB RAM——这已超出多数开发板资源余量。因此,必须放弃通用解码器,转向专为MCU优化的轻量方案。当前主流选择有三:tiny_jpeg(仅解码JPG,<4KB Flash)、pnglite(仅解码PNG,无Alpha通道,<6KB Flash)、gifdec(GIF解码,支持LZW+逐帧,<8KB Flash)。它们共同特点是:纯C实现、无动态内存分配、解码输出为行缓冲、可中断式解码。其中gifdec因需维护LZW字典和帧状态机,对RAM要求最高(约3.5KB),但它是唯一能在F103上稳定运行的GIF解码器。
2.1 获取与裁剪解码器源码:以gifdec为例说明MCU适配关键修改
gifdec原始仓库(GitHub: lunixbochs/gifdec)面向Linux,含malloc和FILE*操作。移植到STM32需四步裁剪:
提示:所有解码器均需禁用
stdio.h和stdlib.h,改用stdint.h和string.h;所有内存分配改为静态数组。
// gifdec.c 中关键修改点(原版第127行附近) // 原始:ctx->buf = malloc(ctx->width * ctx->height * 3); // 修改为: static uint8_t gif_decode_buffer[320 * 240 * 3]; // 预分配最大尺寸缓冲(320x240 RGB24) ctx->buf = gif_decode_buffer; // 原始:fread(buf, 1, size, ctx->fp); // 修改为:自定义读取函数,从Flash或SPI Flash读取 uint32_t gif_read_func(void *ptr, uint32_t size, void *user_data) { uint8_t *dst = (uint8_t*)ptr; uint32_t *offset = (uint32_t*)user_data; // 此处读取Flash中预存的GIF二进制数据 for(uint32_t i=0; i<size; i++) { dst[i] = *(uint8_t*)(FLASH_GIF_BASE_ADDR + (*offset)++); } return size; }2.2 解码器与显示驱动的耦合设计:避免全帧解码导致的卡顿
gifdec默认解码整帧到内存再显示,这对F103是灾难。正确做法是边解码边送显:利用其gif_decode_frame()的逐行回调机制,每解出一行(如16像素),立即通过SPI发送到TFT的GRAM区域。
// 定义回调函数,每解出一行y就触发 void gif_draw_row(gif_result_t *r, uint32_t y, uint32_t x, uint32_t w, uint8_t *row) { // r->frame_info.width 是当前帧宽,r->frame_info.x/r->frame_info.y 是偏移 // 将row中w个像素(RGB888格式)转换为RGB565并写入TFT uint16_t rgb565_row[w]; for(uint32_t i=0; i<w; i++) { uint8_t r8 = row[i*3], g8 = row[i*3+1], b8 = row[i*3+2]; rgb565_row[i] = ((r8>>3)<<11) | ((g8>>2)<<5) | (b8>>3); // RGB888 -> RGB565 } // 写入TFT指定行:先设置GRAM地址,再SPI发送rgb565_row LCD_SetCursor(r->frame_info.x, r->frame_info.y + y); LCD_WriteGRAM(rgb565_row, w); } // 在main循环中调用 gif_result_t result; while(1) { if(gif_decode_frame(&gif_ctx, &result) == GIF_OK) { // result.y_start/result.y_end标识当前帧有效行范围 // 只处理可见区域,跳过透明行 if(result.y_start <= 240 && result.y_end >= 0) { gif_draw_row(&result, result.y_start, 0, result.width, result.row); } } HAL_Delay(result.delay_ms); // GIF帧间隔,单位ms }2.2.1 参数说明与性能实测数据
| 参数 | 含义 | F103典型值 | 调整建议 |
|---|---|---|---|
result.delay_ms | GIF帧间隔(10ms单位) | 100(即1s) | 若动画过快,增大此值;若卡顿,减小但不低于50ms |
gif_ctx.width/gif_ctx.height | GIF逻辑屏幕尺寸 | 128x128 | 必须≤TFT物理分辨率,否则写显存越界 |
row缓冲大小 | 每行像素数×3(RGB888) | 128×3=384字节 | 若Flash空间紧张,可限制GIF宽度≤128 |
实测:在72MHz主频、SPI速率36MHz下,解码并显示128x128 GIF(2帧,每帧延迟100ms)平均耗时85ms/帧,CPU占用率62%,剩余SRAM足够运行FreeRTOS任务。
3. 图片资源固化策略:从PC端预处理到Flash地址映射的全流程
STM32F103无文件系统,图片不能以.jpg文件形式存在,必须转为C数组或Raw二进制固化到Flash。但直接xxd -i image.jpg生成的数组会极大膨胀Flash(因Base64编码),且无法被解码器识别。正确流程是:PC端解码预处理 → 提取原始像素 → 转为RGB565 Raw → 生成C数组 → 链接脚本指定地址。
3.1 PC端预处理:用Python批量转换PNG/JPG为RGB565 Raw
使用Pillow库(pip install Pillow)编写转换脚本,关键在于:保持原始尺寸、去除Alpha通道、量化至RGB565、输出为二进制。
# convert_to_rgb565.py from PIL import Image import sys def rgb888_to_rgb565(r, g, b): return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3) def png_to_rgb565(input_path, output_path, width, height): img = Image.open(input_path).convert('RGB') # 缩放至目标尺寸(保持比例,填充黑边) img = img.resize((width, height), Image.LANCZOS) pixels = list(img.getdata()) with open(output_path, 'wb') as f: for r, g, b in pixels: rgb565 = rgb888_to_rgb565(r, g, b) f.write(rgb565.to_bytes(2, 'little')) # 小端存储,匹配STM32 SPI顺序 if __name__ == '__main__': # 示例:将logo.png转为128x64 RGB565 Raw png_to_rgb565('logo.png', 'logo_128x64.rgb565', 128, 64)注意:此脚本输出的是纯二进制(非C数组),体积最小。若需C数组(便于调试),将
f.write(...)替换为f.write(f'0x{rgb565:04x},'.encode()),但会增加约3倍体积。
3.2 Flash地址映射:在链接脚本中为图片分配独立Section
将生成的logo_128x64.rgb565放入工程Resources/目录,修改STM32F103C8Tx_FLASH.ld链接脚本:
/* 在SECTIONS {}内添加 */ .img_data (NOLOAD) : { . = ALIGN(4); _img_data_start = .; *(.img_data) _img_data_end = .; } > FLASH并在C代码中声明外部符号:
// 声明图片起始地址(由链接器填充) extern const uint8_t _img_data_start; extern const uint8_t _img_data_end; #define LOGO_DATA_ADDR (&_img_data_start) #define LOGO_DATA_SIZE ((uint32_t)&_img_data_end - (uint32_t)&_img_data_start) // 使用示例:显示预存的RGB565图片 void LCD_DisplayImage(const uint8_t *img_data, uint16_t width, uint16_t height) { LCD_SetWindow(0, 0, width-1, height-1); for(uint16_t y=0; y<height; y++) { for(uint16_t x=0; x<width; x++) { uint16_t pixel = *(uint16_t*)(img_data + (y*width + x)*2); LCD_WriteData(pixel); } } }3.2.1 资源管理表格:不同图片格式的Flash/SRAM占用对比
| 图片类型 | 原始尺寸 | 压缩格式 | Flash占用 | 解码后RAM需求 | 适用场景 |
|---|---|---|---|---|---|
| JPG(128x128) | 128x128 | JPEG baseline | 2.1KB | 16KB(解码缓冲) | 静态图标,需高压缩比 |
| PNG(128x128) | 128x128 | PNG无损 | 3.8KB | 8KB(行缓冲) | 需透明边缘的Logo |
| GIF(128x128,2帧) | 128x128 | GIF89a | 5.2KB | 3.5KB(LZW字典+帧缓存) | 简单动画(如WiFi连接状态) |
| RGB565 Raw(128x128) | 128x128 | 无压缩 | 32.8KB | 0KB(直接送显) | 开机Logo,对启动速度敏感 |
提示:若Flash不足,优先用JPG+
tiny_jpeg;若追求零延迟,用RGB565 Raw;若需动画,GIF是唯一可行选项。
4. TFT/OLED显示驱动适配:SPI时序、GRAM寻址与抗闪烁技巧
解码器输出的是像素数据,但能否正确显示,取决于显示驱动是否严格满足芯片手册的时序要求。STM32F103常用SPI驱动ILI9341(TFT)或SSD1306(OLED),二者差异巨大:ILI9341需设置GRAM窗口再批量写入,SSD1306则按页(Page)写入,且每页仅128x8像素。
4.1 ILI9341的GRAM高效写入:避免逐像素SPI传输
ILI9341的WR引脚(或SPI MOSI)若每写一个像素都发一次命令,速度极慢。正确做法是:先发0x2C(Memory Write)命令,再连续发送RGB565数据流,由硬件自动递增GRAM地址。
// ILI9341底层写函数(HAL库) void ILI9341_WriteCommand(uint8_t cmd) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); // RS=0, 选命令模式 HAL_SPI_Transmit(&hspi1, &cmd, 1, HAL_MAX_DELAY); } void ILI9341_WriteData(uint8_t *data, uint16_t len) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // RS=1, 选数据模式 HAL_SPI_Transmit(&hspi1, data, len, HAL_MAX_DELAY); } // 设置GRAM窗口(关键!) void ILI9341_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { ILI9341_WriteCommand(0x2A); // Column Address Set uint8_t col_addr[4] = {x0>>8, x0&0xFF, x1>>8, x1&0xFF}; ILI9341_WriteData(col_addr, 4); ILI9341_WriteCommand(0x2B); // Page Address Set uint8_t page_addr[4] = {y0>>8, y0&0xFF, y1>>8, y1&0xFF}; ILI9341_WriteData(page_addr, 4); ILI9341_WriteCommand(0x2C); // Memory Write } // 批量写入一行(128像素) void ILI9341_WriteRow(uint16_t *row, uint16_t width) { // 此时已处于0x2C命令后,直接发送数据 uint8_t tx_buf[width*2]; for(uint16_t i=0; i<width; i++) { tx_buf[i*2] = row[i] & 0xFF; tx_buf[i*2+1] = row[i] >> 8; } HAL_SPI_Transmit(&hspi1, tx_buf, width*2, HAL_MAX_DELAY); }4.2 SSD1306的页模式写入:GIF动画的逐页刷新策略
SSD1306分辨率为128x64,分8页(Page 0~7),每页128x8像素。GIF解码输出的RGB888行数据需转换为单色位图(1bpp),再按页拆分写入。
// 将RGB888行转为SSD1306位图(阈值法) void rgb888_to_ssd1306_bitmap(uint8_t *rgb_row, uint8_t *bitmap_row, uint16_t width) { for(uint16_t x=0; x<width; x++) { uint8_t r = rgb_row[x*3], g = rgb_row[x*3+1], b = rgb_row[x*3+2]; uint8_t gray = (r*30 + g*59 + b*11) / 100; // YUV灰度公式 bitmap_row[x/8] |= (gray > 128) << (7 - x%8); // 白=1, 黑=0 } } // 写入指定页(y/8决定页号) void SSD1306_WritePage(uint8_t *bitmap_row, uint8_t page, uint16_t width) { SSD1306_WriteCommand(0xB0 | page); // 设置页地址 SSD1306_WriteCommand(0x00); // 列低地址 SSD1306_WriteCommand(0x10); // 列高地址 SSD1306_WriteData(bitmap_row, (width+7)/8); // 每页字节数 }4.2.1 抗闪烁关键参数表:SPI与显示芯片协同优化
| 参数 | STM32F103设置 | 推荐值 | 作用 |
|---|---|---|---|
| SPI波特率 | hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_2 | 36MHz(APB2=72MHz) | 提升数据吞吐,但过高导致ILI9341采样错误 |
| TFT CS引脚切换延时 | HAL_GPIO_WritePin(...); HAL_Delay(1); | 1us(用NOP替代HAL_Delay) | 避免CS未稳定就发数据 |
| GRAM写入前清屏 | memset(lcd_buffer, 0, sizeof(lcd_buffer)); | 禁用 | 全屏清屏耗时>200ms,改用局部刷新 |
| GIF帧间Delay | HAL_Delay(result.delay_ms) | ≥50ms | 低于此值,人眼感知为残影而非流畅动画 |
注意:实测发现,ILI9341在SPI速率>36MHz时偶发花屏,根源是F103的SPI硬件时序裕量不足,此时应降速至24MHz并启用DMA传输。
5. GIF动画的帧控制与内存优化:实现“播放/暂停/跳帧”的嵌入式交互逻辑
GIF在STM32F103上不是简单循环播放,而是需要精确控制帧序列、处理透明色、响应用户输入。gifdec本身不提供播放控制API,需在应用层构建状态机。
5.1 构建GIF播放状态机:分离解码、渲染与控制逻辑
定义播放状态枚举,并在main()循环中轮询:
typedef enum { GIF_STOP, GIF_PLAY, GIF_PAUSE, GIF_STEP_FORWARD } gif_play_state_t; static gif_play_state_t gif_state = GIF_STOP; static uint8_t current_frame_index = 0; static uint32_t last_frame_time = 0; // 按键中断服务程序(如KEY_UP) void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if(GPIO_Pin == GPIO_PIN_0) { // KEY_UP按下 switch(gif_state) { case GIF_STOP: gif_state = GIF_PLAY; break; case GIF_PLAY: gif_state = GIF_PAUSE; break; case GIF_PAUSE: gif_state = GIF_PLAY; break; case GIF_STEP_FORWARD: gif_state = GIF_PAUSE; break; } } } // 主循环中处理 while(1) { uint32_t now = HAL_GetTick(); if(gif_state == GIF_PLAY || gif_state == GIF_PAUSE) { if(now - last_frame_time >= gif_ctx.frames[current_frame_index].delay_ms) { // 解码并渲染当前帧 gif_decode_frame(&gif_ctx, &result); render_gif_frame(&result); // 更新帧索引(支持循环播放) current_frame_index = (current_frame_index + 1) % gif_ctx.frame_count; last_frame_time = now; if(gif_state == GIF_PAUSE) { gif_state = GIF_STOP; // 暂停后停止解码 } } } if(gif_state == GIF_STEP_FORWARD) { gif_decode_frame(&gif_ctx, &result); render_gif_frame(&result); gif_state = GIF_PAUSE; current_frame_index = (current_frame_index + 1) % gif_ctx.frame_count; } }5.2 透明色处理与局部刷新:减少无效像素重绘
GIF的透明色(Transparent Color Index)在解码后表现为特定索引值(如0),需在渲染时跳过该像素,避免覆盖背景。
void render_gif_frame(gif_result_t *r) { for(uint32_t y = r->frame_info.y; y < r->frame_info.y + r->frame_info.height; y++) { for(uint32_t x = r->frame_info.x; x < r->frame_info.x + r->frame_info.width; x++) { uint8_t idx = r->row[(y - r->frame_info.y) * r->frame_info.width + (x - r->frame_info.x)]; if(idx != r->transparent_idx) { // 跳过透明像素 uint16_t color = palette_565[idx]; // 预存的RGB565调色板 LCD_DrawPixel(x, y, color); } } } }5.2.1 内存优化技巧:复用解码缓冲与调色板
GIF解码器的调色板(Palette)通常为256色×3字节,占768字节。F103的Flash可固化此数据,避免RAM存储:
// 在Flash中固化调色板(生成C数组) const uint16_t gif_palette_565[256] __attribute__((section(".flash_palette"))) = { 0xF800, 0xF81F, 0xF83F, /* ... 256个RGB565值 */ }; // 解码时直接读取Flash,无需复制到RAM #define PALETTE_BASE ((uint16_t*)0x08008000) // 假设固化在Flash 0x08008000此技巧可节省768字节宝贵SRAM,使更多内存用于FreeRTOS堆栈或网络缓冲区。
本文还有配套的精品资源,点击获取