一个很常见的需求:UI 上要显示一张图,图片放在板载的外部 Flash 里,MCU 上电后用 LTDC 控制器把它刷到 LCD 屏幕上。STM32H7S78-DK 这块板子的硬件路径其实非常典型——外部 QSPI Flash 存资源、SDRAM 做帧缓冲、LTDC 驱动 LCD。很多朋友卡在“Flash 里明明有图,屏上就是不显示”这个环节,问题往往不是单点,而是整条链路里某个细节没对齐。
这篇文章我就按自己实际做过的流程走一遍:从为什么要把图放外部 Flash、CubeMX 里怎么配置 QSPI 和 LTDC、图片怎么转成 bin 烧进去,到最终代码里怎么把图送到帧缓冲,一次讲透。适合刚接触 STM32H7 系列图形开发的工程师,也适合已经在用 TouchGFX 但想自己手动实现一次底层显示的玩家。
1. 先理清楚数据流:图片从 Flash 到屏幕要经过哪几段路
很多人一上来就打开 CubeMX 点 LTDC、配置屏幕参数,然后发现屏幕始终没图。原因很简单:LTDC 本身只是个“扫描器”,它不会主动去 Flash 里读图片,它只知道按固定的时序从某个内存地址连续读像素数据,然后送给 LCD。
1.1 每条路径上的角色分工
STM32H7S78-DK 上典型的分工是这样的:
- 外部 Flash(QSPI/OctoSPI NOR Flash):存图片、字库、音频等静态资源,掉电不丢,容量从几 MB 到几十 MB 不等。
- SDRAM(外部内存):作为帧缓冲,也就是 LTDC 实际扫描读取的内存区域。
- LTDC 控制器:按时序从 SDRAM 地址读取像素,转换成 RGB 信号,配上 HSYNC/VSYNC/CLK/DE 送给屏幕。
所以图片从 Flash 到屏幕不是一个直接动作,而是分成两步:
外部 Flash(资源)→ 拷贝/解码 → SDRAM(帧缓冲)→ LTDC 扫描 → LCD第二步是硬件自动完成的,LTDC 使能后就会一直刷。第一步才是我们要写代码控制的:把图片从 Flash 搬进 SDRAM。
1.2 为什么不用内部 Flash 或者直接让 LTDC 读 Flash
STM32H7S78-DK 这颗芯片的内部 Flash 并不大,存完固件、协议栈之后剩余空间很紧张。而 UI 的图片资源动辄几百 KB,1920x1080 的 RGB565 原始图一帧就要 4MB 左右,内部 Flash 根本装不下。即便装得下,NOR Flash 在内存映射模式下读速度远不如 SDRAM,LTDC 在 60Hz 刷新率下对帧缓冲带宽要求按 MB 算,让 LTDC 直接读外置 Flash 做帧缓冲基本不现实(少数高性能 OctoSPI 在低分辨率场景可以试,但别拿来当通用方案)。
正确的思路就是:Flash 负责“存”,SDRAM 负责“跑”,LTDC 负责“刷”。理解了这个数据流,后面每一步配置你都会知道自己在做什么。
2. CubeMX 初始化:QSPI 和 LTDC 这两个外设怎么配才不出幺蛾子
STM32H7S78-DK 的板载 QSPI Flash 和 LCD 接口在 CubeMX 里都是现成的,但有几个参数需要手动确认,不是默认值就能直接用。
2.1 QSPI 参数配置与时钟树
在 STM32CubeMX 里选中芯片后,先打开QUADSPI(如果是新版系列也可能是 OCTOSPI,要看具体型号,H7S78 上以实际 CubeMX 显示为准)。以最常见的四线 QSPI NOR Flash 为例,需要关注这几个参数:
- Clock prescaler:这个决定了 QSPI 的时钟频率。假设 QSPI 外设时钟来自 AHB 总线,CubeMX 默认分频值可能偏保守,但对首次调通来说,先按 2 分频(即 100MHz 左右)跑,稳定性优先,别一上来就超频。
- Fifo threshold:一般保持默认 4 或 8,不用动。
- Clock mode:Mode 0 在大多数 Flash 上都支持,如果 Flash 手册要求 Mode 3,再切换。
- Memory size:换算关系是
2^(N+1)字节,16MB Flash 对应 N=23,这个是常见失误点,配错会导致读取地址空间不对。 - Flash ID / 指令长度:需要根据你板子上具体 Flash 型号的手册配置读命令,常见的是 0x03(常规读)、0x0B(快速读)、0xEB(四线快速读)。如果选错指令,读出来全 FF 或者全 00。
时钟树方面,建议在 CubeMX 的 Clock Configuration 里确认 QSPI 时钟源不是来自一个被关闭的 PLL,否则运行时 QSPI 初始化会直接卡死。这个坑很隐蔽,因为编译下载都不报错,调试器单步走到HAL_QSPI_Init就超时。
2.2 LTDC 面板参数到底怎么填
LTDC 配置界面看起来参数很多,实际核心就三部分:时序参数、颜色格式、背景色。
时序参数完全来自你的屏幕数据手册,不能凭感觉填。一个 800x480 的 RGB 屏幕常见参数大概是:
| 参数 | 典型值 | 说明 |
|---|---|---|
| Horizontal Sync Width | 48 | 行同步信号宽度,像素时钟个数 |
| Horizontal Back Porch | 40 | 行同步结束到有效数据开始 |
| Horizontal Front Porch | 40 | 有效数据结束到下一行同步开始 |
| Vertical Sync Width | 3 | 帧同步信号宽度,行数 |
| Vertical Back Porch | 29 | 帧同步结束到有效数据开始 |
| Vertical Front Porch | 13 | 有效数据结束到下一帧同步开始 |
不同屏的数据手册会给出 HSync/VBackPorch/VFrontPorch 这些值,直接把表格里的参数搬进去,然后把同步信号的 polarity(极性)也按手册设置。很多屏是HSYNC 低有效、VSYNC 低有效,如果弄反了,屏幕能亮但画面会随机偏移或者滚动。
这里有个经验技巧:一旦屏幕能亮但画面位置不对,比如整体向右偏移一截、上半屏黑边,基本都是 porch 参数或者极性反了。先别怀疑代码逻辑,回头查屏幕手册的时序图,一行行核对。
色深一般选 RGB888 或者 RGB565。如果屏幕是 24bit RGB 接口,建议 LTDC 输出格式选 RGB888,这样颜色还原准。如果板子的 FPC 线只接了 16bit,那就选 RGB565。STM32H7S78-DK 板载屏用哪种,看原理图里 LCD_R0-R7、LCD_G0-G7、LCD_B0-B7 有没有全部接上。
2.3 中间件和缓存:别忘了使能 DMA2D
如果把显示相关的外设都配置完了,再额外检查一下 DMA2D 外设时钟有没有打开。虽然 DMA2D 不是 LTDC 的组成部分,但后面把图片从 Flash 拷贝到帧缓冲时会用到它,这是最高效的搬运方式。CubeMX 里把 DMA2D 勾上,这样 HAL 库函数就能正常调用。
另外,如果你的工程使用了 Cache(STM32H7 系列 Cortex-M7 默认开 D-Cache 很常见),先记着“这里有坑”,具体处理我在第 7 节专门讲。
3. 图片资源准备:不是直接塞一张 JPG 就能用的
很多朋友从 PC 上拿一张 JPG 就往 Flash 里放,然后 LTDC 显示不出来,就以为代码有问题。其实问题出在格式上:LTDC 不认识 JPEG,它只认识原始像素数据(RGB565、RGB888、ARGB8888 这类)。所以图片必须要先预处理成裸像素格式的 bin 文件,再烧进 Flash。
3.1 选 RGB565 还是 ARGB8888
选哪个取决于两个因素:一是屏幕接口的实际色深,二是你是否需要透明度。
- 如果屏幕是 16bit 接口,直接用 RGB565,省一半带宽,适合显示照片类素材。
- 如果需要做图标叠加、圆角、阴影、半透明效果,优先 ARGB8888,因为 LTDC 的 alpha 混合功能需要这个格式。
- RGB888 格式最尴尬,它没有 alpha 通道,且占用 3 字节/像素,LTDC 虽然支持,但带宽利用率不高。除非屏幕要求 24bit 且你不需要透明,否则通常不如 RGB565 或 ARGB8888。
从带宽角度算一笔账:800x480 的 RGB565 一帧数据量是 800×480×2 = 768,000 字节,约 750KB;60Hz 刷新需要约 45MB/s 的带宽。SDRAM 完全扛得住,但如果是 ARGB8888,一帧就是 1.5MB,带宽翻倍。所以显示照片时选 RGB565 是更务实的做法。
3.2 把图片转成 bin 的三种办法
这里我推荐三种,按场景选:
方案一:Python + Pillow 脚本转 RGB565 bin
from PIL import Image import sys def convert_to_rgb565(src_path, dst_path, width, height): img = Image.open(src_path).convert('RGB') img = img.resize((width, height), Image.LANCZOS) pixels = img.load() with open(dst_path, 'wb') as f: for y in range(height): for x in range(width): r, g, b = pixels[x, y] rgb565 = ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3) f.write(rgb565.to_bytes(2, 'big'))这段脚本生成的是大端序 RGB565 数据。注意:STM32 是小端 CPU,但 LTDC 的字节序约定是按颜色分量在 16bit 中的位置,RGB565 的位域是固定的RRRRRGGGGGGBBBBB,直接用上面的脚本生成没问题。不过如果你用的是 CubeMX 生成的工程、LL 库驱动,还要确认你读 Flash 时设置的地址和读取长度是字节对齐的,别用uint8_t数组去拼 16bit 像素,效率太低。
方案二:STM32 官方工具或 TouchGFX Image 转换器
TouchGFX Designer 自带图像转换功能,可以把图片直接输出为 Raw RGB565 bin,并且支持自动生成 C 数组格式。用这个工具的好处是它处理了色序、对齐问题,缺点是它默认给 TouchGFX 框架用,如果你想自己写裸驱动,反而要剥离框架代码。
方案三:在线工具或者 PC 端 Image2Lcd
Image2Lcd 是老牌工具,输出格式选择 RGB565、扫描方式选择“水平扫描”、输出数据类型选“二进制 bin”,注意字节序选“高字节在前”(大端序),这样得到的 bin 跟脚本生成的是一致的。
不管用哪种方案,输出图像尺寸必须和屏幕上你要显示的区域一致。比如你想全屏显示,就缩放成 800x480;如果只显示一个窗口,那就按窗口大小输出。LTDC 本身没有缩放功能,你给多大的帧缓冲区域,它就显示多大,缩放得在生成图片时完成。
3.3 Flash 地址规划
在写代码之前,先给 Flash 里的资源规划一个地址表。比如 16MB Flash 的空间划分:
| 分区 | 地址偏移 | 大小 | 内容 |
|---|---|---|---|
| 固件/字库 | 0x000000 | 1MB | 可选 |
| 图片区 | 0x100000 | 4MB | 全屏图、UI 素材 |
| 扩展区 | 0x500000 | 剩余 | 后续资源 |
在 QSPI 内存映射模式下,Flash 的起始地址通常是 0x90000000。所以图片的绝对地址就是0x90000000 + 0x100000。这个地址后面代码里会用来做映射读取。建议把地址定义成宏,写清楚注释,规划得好能省很多后期维护的麻烦。
4. 烧写 Flash:把 bin 放进去的几种姿势与验证方法
图片 bin 文件准备好了,下一步烧进外部 Flash。这一步看着简单,但“看着简单的事翻车最多”。
4.1 使用 STM32CubeProgrammer 烧写
打开 STM32CubeProgrammer,选择板卡对应的 ST-LINK 接口,连接后左侧选External Flash标签页。
在烧写前要确保加载了正确的外部 Flash loader。STM32H7S78-DK 板卡的支持包里会带 .stldr 文件,一般路径在 STM32CubeProgrammer 安装目录的bin/ExternalLoader下。选择正确型号后,地址填写 Flash 的基地址(通常是 0x90000000),然后加载 bin 文件烧写。
我遇到过一个情况:烧写的时候报错Error: Data cannot be programmed,最后排查下来是 Flash loader 版本和芯片小版本不匹配。解决办法是更新 STM32CubeProgrammer 到最新版,并且确保从板卡的例程里找对应的 .stldr。
4.2 验证烧写结果
烧写完成以后别急着跑代码,先用 STM32CubeProgrammer 的Memory Display功能直接读 Flash 地址,看看开头几个字节是不是预期的像素数据。比如一张纯红色 RGB565 图片,前两个字节应该是0xF800(如果大端序存储,可能是0x00F8)。
这个验证有个大好处:如果读出来的数据不对,说明烧写或者图片格式有问题,这时候修还来得及;如果数据对了但屏幕不显示,那问题就集中在 LTDC 配置和拷贝代码上,定位范围一下子缩小很多。
4.3 从应用代码里写 Flash 的备用方案
有时候你没法用 ST-Link 烧外部 Flash,比如产品已经出厂、只有固件升级通道,这时候就需要在应用代码里实现 QSPI 写入。这个方案要复杂很多:你需要实现擦除、编程、状态轮询、页大小对齐等操作。第一次做建议单独写一个固件工具,不要和显示代码混在一起。
我在实际项目里更推荐这种分层:“上位机生成 bin + 量产烧录器烧 Flash”,应用代码只管读,不负责写 Flash。这样简化运行时代码,也避免误擦除导致系统崩溃。
5. 核心实现:从 Flash 搬运图片到帧缓冲的三条路
这里涉及的是整篇的重点,也是“图出不来”的高发区。
5.1 方式一:CPU 直接拷贝(最直观,但别用于生产)
你可能会想:既然 QSPI 能内存映射,把 Flash 当数组读,再用 memcpy 拷到帧缓冲不就行了?
代码确实能跑:
uint16_t *src = (uint16_t *)(0x90000000 + IMAGE_OFFSET); uint16_t *dst = (uint16_t *)LCD_FB_ADDR; memcpy(dst, src, IMAGE_SIZE_BYTES);这段代码在小图片、低分辨率场景能正常显示,但有几个问题:
- CPU 全程参与拷贝,阻塞了主循环。800x480 的 RGB565 图片约 750KB,CPU 从 QSPI 读再写 SDRAM,耗时可能在几十毫秒到一两百毫秒,期间其他任务全都卡住。
- 从 QSPI 内存映射区读数据时,如果 QSPI 工作在间接模式而不是内存映射模式,这个地址根本不可读。
- CPU 拷贝时会经过 D-Cache,如果后续 LTDC 读到的是旧数据(cache 没回写),就会出现花屏、残影。
这个方式唯一的价值是用来快速验证你前面的 Flash 读取、LTDC 配置对不对。调试阶段先用它,等确认链路通了再换高性能方案。
5.2 方式二:DMA2D 内存到内存传输(推荐)
DMA2D 是 STM32 图形图像传输的硬件加速器。它的内存到内存模式可以在不占用 CPU 的情况下完成数据搬运,而且还能在搬运过程中顺便转换像素格式。
初始化后用 DMA2D 搬运的核心代码:
void dma2d_copy_buffer(uint32_t src_addr, uint32_t dst_addr, uint32_t width, uint32_t height) { DMA2D_HandleTypeDef hdma2d; hdma2d.Instance = DMA2D; hdma2d.Init.Mode = DMA2D_M2M; hdma2d.Init.ColorMode = DMA2D_RGB565; hdma2d.Init.OutputOffset = 0; hdma2d.LayerCfg[1].InputOffset = 0; hdma2d.LayerCfg[1].InputColorMode = DMA2D_RGB565; hdma2d.LayerCfg[1].AlphaMode = DMA2D_NO_MODIF_ALPHA; hdma2d.LayerCfg[1].InputAlpha = 0xFF; HAL_DMA2D_Init(&hdma2d); HAL_DMA2D_Start(&hdma2d, src_addr, dst_addr, width, height); HAL_DMA2D_PollForTransfer(&hdma2d, 1000); // 非阻塞调用可以用中断 }注意两点:
- DMA2D 的源地址和目的地址都要求 32 位对齐。如果你用的 Flash 偏移地址不是 4 的倍数,DMA2D 会进入错误状态,HAL 库里会 timeout。建议地址规划时统一用 4KB 对齐。
- 如果图片存的是 RGB565 而你想以 ARGB8888 格式显示,DMA2D 可以在搬运的同时完成颜色转换,只需把输出 ColorMode 改为 DMA2D_ARGB8888。这是它比 memcpy 强的一个重要原因。
5.3 方式三:QSPI DMA 读取 + LTDC 直接在 Flash 上显示(进阶)
在特定的低分辨率、低刷新场景,可以尝试直接让 LTDC 扫 QSPI 内存映射地址,把 Flash 当成帧缓冲。代码上只需要把 LTDC 层的帧缓冲地址设成 Flash 地址:
LTDC_LayerCfgTypeDef layer_cfg; // 设置其他参数... layer_cfg.FBStartAdress = 0x90000000 + IMAGE_OFFSET; HAL_LTDC_ConfigLayer(&hltdc, &layer_cfg, 0);但这么做的前提非常苛刻:
- QSPI Flash 必须支持足够高的连续读速度,且配置为内存映射模式、四线快速读,时钟拉高。
- 每次 LCD 刷新都要从 Flash 读一帧数据,产生了持续的读压力,Flash 的随机读取性能往往比 SDRAM 差很远。
- 只要 Flash 在忙(比如写操作),显示就会撕裂、卡顿。
所以我一般只把这种方式当作“验证 Flash 读取能力”的测试方案,真正产品里绝不这么干。生产环境里 SDRAM 做帧缓冲、QSPI Flash 做数据源,配合 DMA2D ,这才是稳定方案。
6. LTDC 层配置:让帧缓冲里的数据真正显示出来
数据拷到 SDRAM 后,LTDC 要能正确显示,还需要完成一系列的层配置。
6.1 层配置必须设置的信息
LTDC 的“层”可以理解为一条独立的显示通道。我们只要用层 0 来显示图片就够。配置代码里至少需要:
static void ltdc_layer_init(void) { LTDC_LayerCfgTypeDef layer_cfg; layer_cfg.WindowX0 = 0; layer_cfg.WindowY0 = 0; layer_cfg.WindowX1 = LCD_WIDTH; // 800 layer_cfg.WindowY1 = LCD_HEIGHT; // 480 layer_cfg.PixelFormat = LTDC_PIXEL_FORMAT_RGB565; layer_cfg.Alpha = 0xFF; layer_cfg.Alpha0 = 0x00; layer_cfg.BlendingFactor1 = LTDC_BLENDING_FACTOR1_CA; layer_cfg.BlendingFactor2 = LTDC_BLENDING_FACTOR2_CA; layer_cfg.FBStartAdress = LCD_FB_ADDR; layer_cfg.ImageWidth = LCD_WIDTH; layer_cfg.ImageHeight = LCD_HEIGHT; layer_cfg.BackColor.Blue = 0; layer_cfg.BackColor.Green = 0; layer_cfg.BackColor.Red = 0; HAL_LTDC_ConfigLayer(&hltdc, &layer_cfg, 0); }几个容易踩的细节:
- WindowX1/WindowY1 是“结束坐标 +1”,不是实际像素坐标的最后一个。800x480 的屏,X1 应该填 800 而不是 799,填错会黑屏或者显示错位。
- PixelFormat 必须和帧缓冲里的数据格式一致。如果你拷贝时用的是 RGB565,这里就不能填 ARGB8888,否则颜色完全乱套。
- ImageWidth/ImageHeight 是指帧缓冲中图片的尺寸,它不一定等于窗口尺寸。如果只显示 400x240 的图片,窗口开 400x240,这里就填 400x240,LTDC 会把窗口区域按这个尺寸去帧缓冲里取数据。
- Alpha 和 BlendingFactor 配合:单层不混合时,Alpha 设为 0xFF,BlendingFactor 都设为常量 alpha 即可。如果你开了两层,底层用
BLENDING_FACTOR1_PAxCA这类参数做混合,效果是半透明叠加。
6.2 LTDC 的启动顺序
正确顺序是:
- 配置 LTDC 时钟(CubeMX 已完成)。
HAL_LTDC_Init()初始化 LTDC 控制器。- 配置至少一个 layer,调用
HAL_LTDC_ConfigLayer。 - 显式使能 LTDC 输出:
HAL_LTDC_Enable()。
有个细节:很多人配置完 layer 后,觉得HAL_LTDC_ConfigLayer内部会自动使能显示,于是跳过HAL_LTDC_Enable(),结果屏幕亮着背光但黑屏,怎么改参数都不生效。事实上 HAL 库里 layer 配置只是把层接到控制器上,显示输出必须调用HAL_LTDC_Enable()才能真正启动。
如果配置了中断(比如行中断、寄存器同步中断),还需要注意HAL_LTDC_Start这类带中断的启动函数,并且中断优先级别设成高于你系统节拍,否则高频率显示中断会拖死系统。
6.3 层配置和拷贝配合的坑:画面残留
当你用 DMA2D 往帧缓冲里写新图时,如果新图比旧图小,边界外的区域还残留上一次的数据。这就会看到“上一张图的边缘残影”。解决办法有两个:
- 每次 DMA2D 搬运时,把整个窗口区域全部覆盖,包括搬运一个全尺寸纯色背景层。
- 或者用 DMA2D 的 fill 功能,在窗口外区域刷成背景色,再搬运图片。
代码上用 fill 更经济:
DMA2D->CR &= ~DMA2D_CR_START; DMA2D->CR = DMA2D_R2M; // register-to-memory DMA2D->OPFCCR = DMA2D_RGB565; DMA2D->OCOLR = 0x0000; // 黑色背景 DMA2D->OMAR = LCD_FB_ADDR + offset; DMA2D->OOR = 0; DMA2D->NLR = (height << 16) | width; DMA2D->CR |= DMA2D_CR_START;7. 显示异常排查:黑屏、花屏、偏移、卡顿的常见原因
前面链路都走通了,不代表就完事了。实际上我调这种项目时,至少一半时间花在“画面不太对”的排查上。这里按现象分类,给出排查思路。
7.1 黑屏但背光亮
- 先确认 LTDC 有没有使能输出,是否调用了
HAL_LTDC_Enable()。 - 确认层配置里
WindowX1/WindowY1是否正确,特别是坐标结束值是否为“宽高”(而非宽高减 1)。 - 检查层使能状态:
HAL_LTDC_ConfigLayer之后再添加HAL_LTDC_EnableLayer(&hltdc, 0)。这个函数 CubeMX 生成的代码里经常被忽略。 - 用调试器查看 LTDC 的
LCD_CR寄存器,确认LTDC_LCDEN位已经置 1。
7.2 花屏、颜色错乱
- 检查帧缓冲里的数据格式和 LTDC 层配置的 PixelFormat 是否一致。RGB565 和 ARGB8888 混着用,最容易花屏。
- 检查 QSPI 读取的数据字节序。如果你的 bin 是“低字节在前”存的,而代码里按“高字节在前”解析,颜色通道全部互换,表现为红蓝交换的奇异画面。
- 检查 DMA2D 的地址对齐。源地址和目的地址都要 32 位对齐。如果 QSPI 映射地址
0x90000000 + IMAGE_OFFSET不是 4 的倍数,DMA2D 直接异常。
7.3 画面偏移、滚动、有斜纹
- 优先查 LTDC 的 porch 参数和同步信号极性是否和屏幕手册一致。这个我第 2 节提醒过,实际排查时最容易忽略。
- 查 LCD 的像素时钟极性。有些屏要求数据在时钟上升沿采样,有些在下降沿,配反了会出现水平方向整体虚影或者偏移。
- 如果用双缓冲/多缓冲,还要确认切换缓冲时是否犯了同步错误。LTDC 扫描到帧缓冲的某个位置时,你正好修改那块区域,就会撕裂,表现是一条横向的错位线。
7.4 显示卡顿、CPU 占用高
- CPU 拷贝图片耗时太长。换成 DMA2D,或者把图片解码、格式转换放到空闲时间处理。
- 频繁在 LTDC 帧读取期间触发 cache 操作。每次 DMA2D 搬运完做 cache invalidate/clean 是正确的,但如果每个像素都去 invalidate,那性能就崩了。
- 图层数太多,每个层都开了混合,LTDC 的带宽占用剧烈增加。单层能解决的问题不要用两层。
7.5 Cache 一致性:STM32H7 上最隐蔽的坑
STM32H7 系列的 Cortex-M7 有 D-Cache,默认开启后,CPU 写内存会先写 cache,不会立刻到 SDRAM。LTDC 访问 SDRAM 时走的是 AXI 总线,看不到 CPU cache 里的数据。于是你发现:用 CPUmemcpy拷贝后去读帧缓冲,看到的是新的;但屏幕显示的还是老图——因为 LTDC 根本没看到你 cache 里的最新数据。
解决办法是:CPU 写完帧缓冲后,做一次 cache clean;DMA2D 写完帧缓冲后,做一次 cache invalidate(保证 CPU 后续读到的不是脏数据)。对应 HAL 库函数:
SCB_CleanDCache(); // CPU 写完帧缓冲后调用 SCB_InvalidateDCache(); // DMA2D 写完后调用注意,对整个 cache 做 clean/invalidate 简单省事,但高性能场景建议用按地址范围操作:
SCB_CleanDCache_by_Addr((uint32_t *)LCD_FB_ADDR, IMAGE_SIZE_BYTES); SCB_InvalidateDCache_by_Addr((uint32_t *)LCD_FB_ADDR, IMAGE_SIZE_BYTES);地址必须 32 字节对齐(Cortex-M7 Cache Line 大小),长度也要是 32 的倍数,否则操作不生效。这里多说一句,H7 系列如果开了 D-Cache,DMA2D 自动搬运经过 AXI,不经过 CPU cache,所以 DMA2D 场景一般只需要 CPU 侧 invalidate。
7.6 QSPI 读取慢 / 读取超时
如果 QSPI 初始化和读数据总是超时,除了配置问题,还有一个可能:Flash 的 WIP(写进行中)位没有轮询。比如你上电后马上读 Flash,但 Flash 还在上一轮擦除/编程后的状态,需要等待 WIP 清零。HAL 库的HAL_QSPI_Command+HAL_QSPI_Receive循环里最好加上超时保护,避免挂死。
另外,QSPI 用的是间接模式时候选函数和片选信号要正确。ST 的 H7 系列 QSPI 只有一个片选,如果你的板上 Flash 挂在 QSPI_CS0 上,代码里别错用 CS1。
8. 性能优化与进阶思路:这套方案离产品还有多远
完成了从 Flash 读图、DMA2D 搬运、LTDC 显示这一整套链路后,框架已经可以支撑很多 UI 场景了。如果继续往下深入,还有几个方向值得做。
8.1 图片压缩存储
如果 Flash 容量有限,但图片很多,可以考虑用 JPEG 压缩存储,运行时用 ST 提供的JPEG硬件编解码器解码到 SDRAM。STM32H7 系列自带硬件 JPEG 编解码器,解一张 800x480 的 JPEG 通常只要几十毫秒,适合闪存小、图片多的场景。
代价是代码复杂度和内存开销上升:解码需要分配一张全尺寸 RGB888 的中间缓冲,然后在交给 LTDC 前可能还要转 RGB565。
8.2 局部刷新与多缓冲
如果只是 UI 局部变化,不需要全部重绘。LTDC 支持把层窗口设置成局部区域,你只需要刷新那个区域对应的帧缓冲范围。DMA2D 也可以只搬运窗口大小的数据,减少带宽。
多缓冲方面,常见做法是双缓冲或者三重缓冲:一个缓冲用于当前显示,另一个用于后台渲染,渲染完成后查一下 LTDC 当前扫描位置,选择合适的时机切换帧缓冲地址。CubeMX 和 HAL 库可以配置 LTDC 重新加载时机为垂直消隐期,这样画面切换不会撕裂。
8.3 配合图形库使用
如果你的应用需要控件、动画、触摸交互,直接在裸机上开发会非常痛苦。这时候建议考虑 TouchGFX。TouchGFX 的分层渲染、资源管理、缓存和地址分配已经非常成熟,外置 Flash 加载图片也是它的标准用法。你只需要在做完上面这些底层初始化后,把帧缓冲地址和层配置交给 TouchGFX 接管。
不过即便用 TouchGFX,也建议先理解本文讲到的整条链路。很多 TouchGFX 的坑——比如外置 Flash 图片显示不出来、Cache 一致性、LTDC 时序不对——原理都和裸机调试完全一样。
8.4 测量与优化带宽
如果要跑复杂 UI、动画、视频,关注 LTDC 的实时带宽占用很重要。可以用调试器的性能计数器,或者用 LTDC 的状态寄存器观察行中断频率是否稳定。如果发现画面偶发撕裂,优先检查帧缓冲是否跨越了 SDRAM 的非交错区域——SDRAM 的 bank/row 切换会增加随机访问延迟,DMA2D 连续搬运比随机读写更友好。
我做这套系统时,最后的调优手段往往不是改代码,而是重新规划内存布局:把常用的帧缓冲固定在 SDRAM 的高地址段,把 SPI Flash 的图片缓存放在另一个段,减少 LTDC 和 DMA2D 的访问冲突。
最后说两个我在实际项目里经常被坑到的细节
纯手写这套流程做下来,最深刻的体会是:显示系统从来不是“一个外设”的事,而是 Flash、SDRAM、DMA2D、LTDC、Cache 五个环节一起协作。任何一环的参数错位,表面现象都可能是“黑屏”或者“花屏”,但排查方向完全不同。你只有对数据流有清晰的把握,才能快速缩小问题范围。
第二个体会是:首次调通之前不要开太多优化。把 D-Cache 先关了跑通,把 LTDC 双缓冲先停掉用单缓冲,把 Flash 用间接模式读通了再切内存映射,每加一个优化就验证一次。这个“先通后优”的思路看着慢,实际是最快的方法。等你把整条链路摸透了,再逐个打开性能开关,会轻松很多。
如果你也正在 STM32H7S78-DK 上做类似的事情,建议先用一张纯色图片把链路跑通,再切到复杂图片。纯色图可以一眼看出是格式问题、数据问题还是时序问题,调试效率会高得多。