STM32F407与FreeRTOS结合外扩SRAM实现JPEG图片LCD显示
2026/9/8 5:20:55 网站建设 项目流程

简介:面向嵌入式开发者的正点原子探索者STM32开发板工程资源,以FreeRTOS实时操作系统为核心,整合SRAM运行内存、JPEG图像解码与LCD液晶显示,演示如何在STM32平台上完成多任务调度、大容量图片解码与实时显示。压缩包共1449个文件,整体约35.61MB,包含683个C源码、327个头文件、编译生成的o/d/lst/s文件、IAR与STM32CubeMX工程配置(icf/ioc)、以及hex/bin/elf等可直接烧录的镜像文件,目录结构清晰,方便对照学习。资源特别涉及FreeRTOS Heap_5内存管理策略,帮助理解嵌入式动态内存分配与防碎片化实现。目前已有872人学习下载,适合正在学习STM32+FreeRTOS或从事物联网显示终端的开发者参考。通过该工程可快速掌握JPEG解码显示的关键流程、LCD驱动方式,并基于完整源码进行二次开发。 把“正点原子探索者 + FreeRTOS + SRAM + JPEG + LCD显示图片”这一串关键词放到一起,其实是嵌入式学习路上非常典型的一道坎。很多人都想把一张JPEG图片显示到LCD上,但一跑起来就遇到黑屏、花屏、HardFault,甚至卡死,问题基本出在内存规划和任务设计上。这篇就把我实际调通的思路和踩过的坑完整拆一遍。

1. 这个项目为什么绕不开外扩SRAM

1.1 内存账本:768KB的帧缓冲从哪来

探索者开发板用的是STM32F407ZGT6,这颗芯片内部SRAM共192KB,分成两块:128KB的主SRAM(0x20000000起始)和64KB的CCM RAM(0x10000000起始)。光看数字好像不小,但你算算一张LCD全屏图片要占多少内存。

探索者的LCD接口最高支持800x480分辨率,用RGB565格式存储的话,每个像素2字节:

800 x 480 x 2 = 768000 字节 = 750KB

一帧全屏图片就要750KB。就算你把分辨率降到480x272:

480 x 272 x 2 = 261120 字节 = 255KB

直接把内部192KB全部塞进去都不够,更别说你的系统还要跑FreeRTOS,任务栈、消息队列、解码工作区全都要内存。所以在F407上做JPEG显示,外扩SRAM不是可选项,是刚需。

1.2 软件解码是硬约束:F407没有硬件JPEG单元

这里要先说明一个容易混淆的点。STM32F4系列里,F429、F469这些型号带了硬件JPEG编解码器(DJPEG),F407没有。探索者F407做JPEG解码,只能用软件解码库——正点原子官方例程用的是TjpgDec。

TjpgDec是一个专为嵌入式设计的轻量级JPEG解码器,作者是日本一位工程师。它的特点是:

  • 纯C实现,不依赖任何硬件外设
  • RAM占用可以做到非常低,支持分块输出
  • 解码过程通过回调函数驱动底层输入流
  • 支持输出RGB565、RGB888、灰度图等格式

因为没有硬件解码,所有的霍夫曼解码、反量化、IDCT变换、颜色空间转换全部靠CPU算,也就是全部靠软件跑。一颗168MHz的Cortex-M4,跑一张800x480的JPEG图,解码时间通常在1到3秒之间,具体看图片压缩率和内容复杂程度。

这个速度决定了后面FreeRTOS的任务设计思路:解码是一个CPU密集型的耗时操作,不能阻塞整个系统,必须放到独立任务里跑,还要考虑显示任务在解码期间的调度。

2. 硬件链路:FSMC接口与1MB SRAM的配合细节

2.1 探索者板载IS62WV51216的接线逻辑

探索者开发板上有一颗IS62WV51216,容量1M x 16bit,正好1MB。它挂在F407的FSMC接口上,使用的Bank1的第1个片选区(NE1),映射地址从0x60000000开始。

连线关系大概是:

  • FSMC_NE1 → SRAM片选CS
  • FSMC_A[18:0] → SRAM地址线A[18:0](19根地址线,寻址2^19 = 512K,再乘以16bit位宽正好1MB)
  • FSMC_D[15:0] → SRAM数据线
  • FSMC_NOE → SRAM读使能OE
  • FSMC_NWE → SRAM写使能WE

要注意FSMC的Bank1支持NOR Flash、PSRAM、SRAM等设备,每个Bank又分4个片选区,每个片选区对应一段64MB的空间。探索者的SRAM用NE1,所以访问地址就是0x60000000到0x60FFFFFF这段。

数据手册里FSMC Bank1的地址映射是这样的:

片选区对应引脚地址范围
Bank1 NE1FSMC_NE10x60000000 - 0x6FFFFFFF
Bank1 NE2FSMC_NE20x64000000 - 0x6FFFFFFF
Bank1 NE3FSMC_NE30x68000000 - 0x6FFFFFFF
Bank1 NE4FSMC_NE40x6C000000 - 0x6FFFFFFF

在正点原子的例程里,操作外部SRAM已经是宏定义好的,比如Bank1_SRAM3_ADDR这类地址宏,然后用FSMC初始化结构体配置时序参数。

2.2 FSMC时序配置:以SRAM读周期为准

FSMC挂SRAM其实比挂TFT-LCD简单,因为SRAM是标准的异步读写时序。关键在FSMC_BCR和FSMC_BTR这两个寄存器:

  • FSMC_BCR:总线配置寄存器,控制存储器类型、位宽、突发模式等
  • FSMC_BTR:总线时序寄存器,控制读时序
  • FSMC_BWTR:总线写时序寄存器,控制写时序

核心时序参数有四个:

  • ADDSET:地址建立时间,单位HCLK周期
  • ADDHOLD:地址保持时间
  • DATAST:数据建立时间
  • BUSTURN:总线恢复时间

对于IS62WV51216这种标准SRAM,典型读周期在55ns级别。F407的HCLK如果跑168MHz,一个HCLK周期约5.95ns。工程上常见的一组配置如下:

SRAM_InitStructure.FSMC_AddressSetupTime = 0x02; // 地址建立时间 SRAM_InitStructure.FSMC_AddressHoldTime = 0x00; // 地址保持时间 SRAM_InitStructure.FSMC_DataSetupTime = 0x05; // 数据建立时间 SRAM_InitStructure.FSMC_BusTurnAroundDuration = 0x00; // 总线恢复时间 SRAM_InitStructure.FSMC_DataLatency = 0; // 不用于SRAM SRAM_InitStructure.FSMC_AccessMode = FSMC_AccessMode_A;

我实测的感受是:ADDSET给2、DATAST给5这个组合比较稳,读写都没问题。这里有个容易被忽略的点——写时序和读时序可以分开配置(BWTR),但如果你没有单独配置写时序寄存器,FSMC会按读时序来执行写操作。SRAM的写周期通常比读周期短,所以只要读时序配置合理,写大概率也没问题。

如果配置得过快(比如DATAST给1甚至0),SRAM的数据线还没稳定,FSMC就采样了,读出来就是错数据,表现就是花屏、颜色错乱、某些区域随机跳变。反过来配置过慢,画面能用但刷新速度明显变慢。

2.3 字节对齐与MPU:没有Cache也不能忽视的细节

探索者F407是Cortex-M4内核,没有L1 Cache,这一点和F429/H7不同。所以网上很多讲外部SRAM的Cache一致性问题的文章,在F407上其实用不上。但有两个问题依然要处理。

第一个问题是字节序。外部SRAM是16位宽,FSMC访问时最小单位是16位。如果你通过uint8_t指针去逐字节写外部SRAM,FSMC会自动处理字节选通信号(NBL0/NBL1),看起来没问题。但如果你用uint32_t批量写,就要确保数据是对齐的,否则会产生总线错误。最稳妥的做法是:所有要放到外部SRAM的缓冲区,起始地址按4字节对齐。

第二个问题是MPU。虽然F407没有Cache,但代码里如果开了MPU,外部SRAM区域的MPU配置会影响访问行为。常见的配置是把外部SRAM设为Normal、Non-cacheable、Bufferable,或者直接Write-through。如果配置成Strongly-ordered类型,虽然也能用,但会让编译器生成的优化代码在访问该区域时降低效率。

MPU_Region_InitTypeDef MPU_InitStruct; MPU_InitStruct.Enable = MPU_REGION_ENABLE; MPU_InitStruct.BaseAddress = 0x68000000; // 外部SRAM基地址 MPU_InitStruct.Size = MPU_REGION_SIZE_1MB; MPU_InitStruct.AccessPermission = MPU_REGION_FULL_ACCESS; MPU_InitStruct.IsBufferable = MPU_ACCESS_BUFFERABLE; MPU_InitStruct.IsCacheable = MPU_ACCESS_NOT_CACHEABLE; MPU_InitStruct.IsShareable = MPU_ACCESS_NOT_SHAREABLE; MPU_InitStruct.Number = MPU_REGION_NUMBER2; MPU_InitStruct.TypeExtField = MPU_TEX_LEVEL1; MPU_InitStruct.SubRegionDisable = 0x00; MPU_InitStruct.DisableExec = MPU_INSTRUCTION_ACCESS_ENABLE;

我一开始没有单独给外部SRAM配置MPU区域,跑小图没问题,跑大图时偶尔出现随机错位的情况。后来排查发现FSMC的总线仲裁本身没问题,是MPU的Region0默认配置覆盖了全部地址空间,属性不合适。单独分配一个Region给外部SRAM区域后,问题消失。

3. 解码层策略:TjpgDec与缓冲区三段式设计

3.1 解码器选型对比

嵌入式场景JPEG解码,常见方案有TjpgDec和libjpeg两大阵营。

libjpeg是桌面和服务器领域的事实标准,功能全、优化好,但代码体积大,内存占用高,依赖文件I/O层,对MCU来说太臃肿。TjpgDec则专门为MCU设计,整个库只需要两个源文件(tjpgd.c和tjpgd.h),通过回调函数机制把输入流抽象化,你可以从SD卡读JPEG数据流,也可以从内存数组读,灵活性极高。

从实际体验看,TjpgDec在168MHz的F407上表现可以接受,库本身的API也非常简单:

JDEC jdec; JRESULT rc; rc = jd_prepare(&jdec, in_func, work, sizeof(work), (void*)&file); rc = jd_decomp(&jdec, out_func, 0);

核心API就两个:jd_prepare负责解析JPEG头、准备解码工作区;jd_decomp执行实际解码,解码结果通过输出回调函数逐块返回。

3.2 三段缓冲区设计

现在重点来了:外部SRAM的1MB怎么分配,直接决定项目成败。

我把外部SRAM分成三段:

用途大小说明
JPEG文件输入缓冲100-200KB存放从SD卡读出的压缩图片原始数据
TjpgDec工作区约30KB解码过程中的中间数据,jd_prepare需要
解码输出缓冲(RGB565帧缓冲)750KB存放解码后的整帧图像数据

这种划分有个关键前提:TjpgDec的jd_decomp支持分块输出,你可以设置JD_SZBUF让输出回调每次返回一小块数据。理论上不需要一整块750KB的帧缓冲,可以一边解码一边往LCD刷。但这样做的代价是:解码和显示深度耦合,如果中间有其他高优先级任务打断,LCD刷新时序就不连续,可能出现撕裂。

我选择的是整图解码到SRAM的帧缓冲,然后再从帧缓冲刷新到LCD。虽然解码期间不能同时显示,但换来了代码逻辑简单、显示阶段可以独立优化、后续如果要加特效(缩放、旋转、淡入淡出)也方便。

实际分配的时候要注意一个细节:TjpgDec的工作区大小可以通过JD_WORK_SIZE宏设置,这个值不是越大越好,也不一定越小越省。它必须能满足解码器内存需求的上限,如果给太小,jd_prepare会返回JD_MEMERR

我实测800x480的图,工作区给4KB也跑得起来,但解码速度明显慢;给30KB时速度和稳定性最佳。原理是工作区还要存放霍夫曼表、量化表、位流缓冲等中间数据,太小了频繁换入换出反而拖慢速度。

3.3 缩放也是一种提速方案

TjpgDec支持在解码时直接缩放,jd_decomp的输出回调会得到缩放到1/2、1/4、1/8尺寸的画面。这在大图显示场景里非常实用。

就拿800x480的原始JPEG来说,如果你只是想全屏显示,但LCD分辨率只有480x272,直接解码800x480再缩小既费时又费内存。改成在jd_decomp设置缩放比例1/2,解码输出的就是400x240,内存占用直接降到原来的四分之一,解码速度也快很多。

不过程序里要注意,jd_decomp的缩放参数是JRESULT jd_decomp(JDEC *jd, JD_FUNC out_func, UINT out_x, UINT out_y)这个函数里没有直接的缩放参数——实际上TjpgDec的缩放是在jd_prepare阶段通过JD_SCALE宏控制的,不同版本API有差异。建议直接看正点原子例程里怎么调的,不同版本的函数签名略有不同。

4. FreeRTOS任务设计:双缓冲流水线与同步机制

4.1 任务拆分与职责边界

有了外部SRAM的大帧缓冲,接下来就是FreeRTOS的任务设计。我采用的是典型的生产者-消费者模式:

  • 解码任务:从SD卡读JPEG文件,调用TjpgDec解码到SRAM帧缓冲
  • 显示任务:从SRAM帧缓冲读取RGB565数据,通过FSMC写入LCD GRAM

如果用户在显示过程中按下按键切换图片,还需要一个输入检测任务,或者直接在显示任务里轮询按键。

任务拆分的原则是:CPU密集型的解码任务是生产者,FSMC刷屏是消费者,两者通过队列或信号量同步。解码期间其他任务照常运行,系统不会卡死。

4.2 双缓冲机制

这里必须用双缓冲。如果只用单缓冲,解码任务往缓冲A写入新图片时,显示任务还在从缓冲A刷屏,两者会互相踩数据,画面就会出现半张旧图半张新图的撕裂现象。

双缓冲的思路是:

  1. 解码任务解码到缓冲B
  2. 解码完成后,通知显示任务“缓冲B已就绪”
  3. 显示任务开始从缓冲B刷屏,与此同时解码任务可以开始解码下一张图到缓冲A

两个缓冲交替使用,互不干扰。

// 帧缓冲句柄 #define FRAME_BUF_SIZE (800 * 480 * 2) uint8_t frame_buf[2][FRAME_BUF_SIZE] __attribute__((section(".ARM.__at_0x68000000"))); // 空闲缓冲队列:记录哪些缓冲可以用 QueueHandle_t xFreeBufQueue; // 就绪缓冲队列:记录哪些缓冲已解码完成待显示 QueueHandle_t xReadyBufQueue; // 解码任务 void vJpegDecodeTask(void *pvParameters) { uint8_t *pFreeBuf; uint8_t *pReadyBuf; while (1) { // 等一个空闲缓冲 xQueueReceive(xFreeBufQueue, &pFreeBuf, portMAX_DELAY); // 从SD卡读取并解码JPEG到pFreeBuf jpeg_decode_to_buf(pFreeBuf); // 解码完成,交给显示任务 xQueueSend(xReadyBufQueue, &pFreeBuf, portMAX_DELAY); } } // 显示任务 void vLcdDisplayTask(void *pvParameters) { uint8_t *pFrameBuf; while (1) { // 等解码任务完成 xQueueReceive(xReadyBufQueue, &pFrameBuf, portMAX_DELAY); // 刷新到LCD lcd_show_frame(pFrameBuf); // 刷完释放缓冲,回到空闲队列 xQueueSend(xFreeBufQueue, &pFrameBuf, portMAX_DELAY); } }

两个队列的角色很清楚:xFreeBufQueue初始时放入两个缓冲的地址,表示两个缓冲都可写。解码任务取出一个写入,显示任务刷完后又归还一个。xReadyBufQueue只存放解码完成待显示的缓冲。

这样还有一个额外好处:SD卡读取和JPEG解码被隔离在解码任务里,即使SD卡某个扇区读取慢,也不会拖累LCD刷新。

4.3 优先级设计:为什么显示任务优先级更高

优先级分配我这样定的:

任务优先级说明
显示任务3高,保证刷屏流畅
解码任务2中,CPU密集但可被抢占
空闲任务(或输入检测)1

显示任务刷屏是个长时间操作,刷一屏800x480的时间大概在50ms到100ms量级(取决于FSMC时序和LCD控制器)。如果解码任务优先级更高,解码任务可能持续占着CPU,显示任务迟迟得不到调度,画面就卡顿。

反过来,显示任务只在有就绪缓冲时才被唤醒,平时阻塞在队列上,不会浪费CPU。即使解码任务优先级低,它也能在显示任务阻塞期间占满CPU跑解码。

一个典型的运行时间线:

解码任务解码中(占用CPU约1.5秒)→ 解码完成,发送队列 → 显示任务被唤醒,开始刷屏(占用CPU约80ms)→ 刷完发队列,继续阻塞 → 解码任务接管CPU,解码下一张

这样系统整体看起来就是:LCD连续显示上一张图,后台静默解码下一张,整个过程用户无感知卡顿。

5. 实测踩坑记录与调优经验

5.1 花屏排查链路:从纯色填充到逐段定位

花屏是这个项目最常碰到的问题。我总结了一套排查链路,按顺序走,基本能锁定问题根源。

第一步,先用纯色填充验证外部SRAM读写。程序里对SRAM整段写入、读出、比对,比如写0xAA55再读回来。如果这一步都错,说明FSMC时序配置或硬件连接有问题,跟JPEG、FreeRTOS都没关系。

第二步,用小尺寸JPEG测解码链路。比如一张32x32的小图,解码后直接显示到LCD左上角。如果小图正常、大图花屏,问题大概率出在内存越界或缓冲区不足。

第三步,检查缓冲区是否越界。注意frame_buf数组如果用__attribute__((at()))或section指定到外部SRAM,要确认起始地址和结束地址在SRAM范围内,不能超过0x60FFFFFF。我之前踩过一次,缓冲区定义了两个帧缓冲共1.5MB,超过了1MB物理SRAM,结果第二部分被写到不存在的地址,FSMC读回全F,图片下半屏全是雪花噪点。

第四步,排查字节对齐和memcpy问题。从SD卡读JPEG数据时,如果f_read到外部SRAM缓冲区,而缓冲区地址不是4字节对齐,FATFS在部分配置下会出现字节错位,导致JPEG解码器直接返回格式错误。

5.2 HardFault与JD_MEMERR的隐藏含义

项目中最容易让人心态崩的,是不定期HardFault和jd_prepare返回JD_MEMERR

JD_MEMERR字面意思是内存错误,但实际触发原因有两个:一是工作区真的给太小,二是JPEG本身的尺寸、色彩空间超出解码器预期。比如某些相机拍出的JPEG可能是YUV422格式的,TjpgDec对YUV444、YUV422、YUV420、灰度图都支持,但不同版本的支持程度有细微差别。碰到JD_MEMERR时,先用排除法:换一张标准测试图(比如Photoshop导出的小尺寸YCbCr444 JPEG)验证解码器本身没问题,再检查自己的缓冲区分配。

HardFault则更麻烦。我遇到的一种典型场景是:在解码任务里调用FatFS读文件时,任务栈不够大。FatFS的f_read内部会频繁调用memcpy和文件系统内部函数,如果任务栈只有512字节,很容易压栈溢出。排查方法有两个:

  • 在FreeRTOSConfig.h里开启configCHECK_FOR_STACK_OVERFLOW并实现vApplicationStackOverflowHook
  • 硬件上直接看故障发生时的PC指针位置

正点原子例程的写法里,解码任务栈通常给512到1024字(word),也就是2KB到4KB。我建议至少1024字,稳妥一点给1536字。

5.3 提升刷新速度的两个土办法

解码之后刷屏速度是另一个体验瓶颈。FSMC写一块800x480的RGB565数据,如果是一个像素一个像素循环写,时间非常可观。两个改进思路见效最明显。

第一个是循环展开和32位宽写。ILI9806这类LCD控制器支持16位/8位并口,但FSMC每次访问最小是16位。如果lcd_show_frame里用uint32_t指针一次写两个像素,直接把写屏时间砍半。注意LCD控制器的RS引脚接的是FSMC的A1还是A6,决定你写GRAM的地址偏移,这个在正点原子的LCD驱动里已有处理,不要自己乱改。

第二个是尽量利用正点原子例程里的LCD_Fast_DrawPoint和颜色填充函数。例程其实已经做了很多优化,比如刷色块时会用FSMC连续写地址,而不是每次重新计算坐标。在做图片显示功能时,直接复用这些底层函数,比自己重新实现快得多。

至于DMA刷数据到LCD,F407没有LTDC和DMA2D,所以DMA只能通过FSMC写LCD的GRAM,这个方式可实现但需要注意FSMC的AHB总线仲裁。正点原子探索者的例程默认是CPU直接写,实际使用中稳定性最好,我在项目中就保持了这种方式。

5.4 从单张显示到连续切换的细节

最后补充一个从单张显示到连续切换图片时容易忽略的点:SD卡的文件打开和关闭逻辑。

每显示一张图片就f_open一次,如果图片文件比较大(比如800KB的JPEG),f_open要扫描FAT表,时间不可忽视。更麻烦的是FATFS默认配置下f_read是按扇区读取的,如果JPEG文件不连续存储(SD卡碎片化),读取速度会明显变慢。

我在解码任务里做了一个小优化:启动时把要显示的图片列表扫描一次,记录文件名和起始扇区,显示时直接用f_lseek跳过不必要的数据,或者干脆在空闲时把整张JPEG读入SRAM的输入缓冲,解码时直接读内存,不走SD卡。

这里还要注意FatFS的互斥问题。如果多个任务同时访问SD卡(一个显示图片,一个记录日志),同一时刻只能有一个任务调用f_openf_read这些函数,否则FATFS内部状态会错乱。正确的是把SD卡访问全部收敛到解码任务里,其他任务要读SD卡数据,通过队列向解码任务发请求。

6. 一些实测环境参数,方便你对照

最后列一下我调通时的关键参数,不同批次板子可能略有差异,但可作为参照:

项目参数
主频168MHz
外部SRAM型号IS62WV51216,1MB,16位
FSMC时序ADDSET=2, DATAST=5, AccessMode A
LCD分辨率800x480 RGB565
输出帧缓冲750KB,外部SRAM
TjpgDec工作区30KB,外部SRAM
JPEG输入缓冲200KB,外部SRAM
解码任务栈1536 word(6KB),内部SRAM
显示任务栈512 word(2KB),内部SRAM
解码任务优先级2
显示任务优先级3

实际测试中,一张800x480、约200KB的JPEG图,从SD卡读取到完整显示,耗时大约1.8到2.5秒。其中解码占大头,刷屏占80ms左右,这个速度用于图片浏览器、开机Logo、菜单界面展示完全够用。

如果想让解码更快,可以尝试把TjpgDec的JD_FASTDECODE宏打开,它会用查表法加速部分运算,代价是代码体积增大。另外,把编译器优化级别从-O0改成-O2,解码速度提升非常明显,我从2.2秒压到1.5秒左右。注意开优化后要重新验证一下SD卡读取和JPEG解码的稳定性,个别编译器优化等级会影响位域操作的时序行为,不过实测GCC和MDK的-O2都没有问题。

关于外扩SRAM的驱动稳定性,我再多说一句。不要在运行中频繁调整FSMC时序配置,初始化一次后就别动了。FSMC时钟和AHB时钟关联,运行中调整FSMC_BTR可能造成总线事务断裂,轻则数据错乱,重则直接硬件错误。我调试初期为了测试不同时序参数,在程序里做了按键动态调整,结果偶尔触发HardFault,后来改成编译时宏开关,再没出现过类似问题。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询