最近在自制的STM32N6板子上完成了从SD卡读一张 480×480的JPEG,用硬件JPEG解码,然后显示到屏幕上。
流程也很朴素:SD 卡读文件 → JPEG 解码 → framebuffer → LTDC 显示
看上去四步,甚至不像一个值得写文章的功能。
后来才发现,JPEG 能不能解出来,只是整件事里最早、也最容易过的一关。初始化可能 Fault,轮询解码可能慢到超时,颜色可能出现奇怪的蓝边,解出来的像素往 PSRAM 一写还可能把相邻数据带坏。LTDC 正在读 PSRAM 时,CPU 再跟着写,甚至有机会直接把总线卡住。一张图而已,把整条显示链路的坑都踩了一遍。
1、JPEG 到底是什么
先把一个很容易混淆的概念说清楚。
JPEG 不是 RGB565、RGB888 这种「像素在内存里怎么摆放」的格式。JPEG 是一种有损图片压缩格式。屏幕最终还是要吃 RGB 像素,但 JPEG 文件里装的是一套经过处理、压缩后的图像信息。
一张 480×480 的 RGB888 图片,原始数据量大约是:
480 × 480 × 3 = 691200 bytes,约 675 KiB
如果它是一个普通的照片,JPEG 压缩后可能只剩几十 KiB,甚至十几 KiB。
JPEG 之所以能把图压得这么小,利用人眼的特点。人眼对亮暗变化很敏感,对颜色细节相对没有那么敏感,所以 JPEG 会尽量保住亮度信息,砍掉一部分颜色细节。
JPEG 通常不直接处理 RGB,而是先转成YCbCr:
Y,亮度,也就是画面明不明、细节清不清。
Cb,偏蓝和偏黄的颜色信息。
Cr,偏红和偏绿的颜色信息。
亮度是人眼最在意的部分,颜色信息则可以适当少留一点。
JPEG 不会把整张图一次性压缩,而是把图片拆成一个个 8×8 的小块,也就是后面嵌入式调试里经常会遇到的 MCU碰到的问题。
每一块再经过 DCT、量化和熵编码。可以把它理解成,先把一块画面拆成不同粗细的纹理,再把人眼不敏感的细节压小,最后把重复信息压紧。
压缩率很高,代价是那些被量化丢掉的细节回不来了,这就是JPEG的有损压缩
2、STM32的JPEG外设
STM32内置了JPEG编解码器可以实现图片从RGB编码为JPEG格式,也可以实现从JPEG格式解码为YCbCr格式,中间通过输入 FIFO 和输出 FIFO 与外部内存交换数据。
解码时,JPEG外设,压缩 JPEG 数据→ 输入 FIFO→ JPEG 硬件解析、熵解码、反量化、反 DCT→ 输出 FIFO→ YCbCr数据。
它有三种常见使用方式:轮询模式、中断模式、DMA 模式:
轮询模式中CPU 不断查看 JPEG 输入、输出 FIFO 的状态,自己把压缩数据送进去,再把解码出来的数据搬到内存里。
缺点也很直白,CPU 在搬数据期间基本被占住了。JPEG 核心是硬件在算,但 FIFO 的数据搬运仍然会消耗 CPU 时间。
中断模式:HAL_JPEG_Decode_IT()会在输入 FIFO 需要数据、输出 FIFO 有数据、头部解析完成或解码结束时触发中断,再由回调函数继续喂数据和取数据。
它比轮询更适合把 JPEG 接进事件驱动系统,但回调状态机、缓冲区边界和错误恢复都会复杂不少。
DMA模式的HAL_JPEG_Decode_DMA()`用 DMA 在内存与 JPEG FIFO 间搬运数据,CPU 可以少参与很多重复搬运。
这通常是追求吞吐量时会走的方向,但不是「把 DMA 打开就结束了」。DMA 缓冲区的地址、对齐、Cache Clean/Invalidate、DMA 通道和中断优先级都要重新确认。
轮询模式下,CPU 负责 FIFO 搬运。即使用 DMA,后面的 YCbCr 重排、颜色空间转换、framebuffer 发布和显示同步仍然都需要 CPU 参与。
所以看 JPEG 性能时,至少要把它拆成四段:
SD 卡读文件耗时
JPEG 硬件解码与数据搬运耗时
YCbCr 转 RGB 耗时
发布到 LTDC framebuffer 的耗时
3、在STM32N6中使用的坑
第一个坑,JPEG 初始化就可能异常
还没开始解码,JPEG 初始化阶段就可能进异常。
这里需要去检查初始化的Fault状态、SCB->CCR中的 UNALIGN_TRP,以及 JPEG HAL 初始化过程。
排查后发现STM32N6的JPEG HAL初始化过程里会访问未自然对齐的Huffman表。如果启用了非对齐访问陷阱,可能直接触发UsageFault。
JPEG 句柄保持 8 字节对齐;只在 HAL_JPEG_Init()执行期间临时关闭 UNALIGN_TRP,结束后立即恢复原来的 CCR 设置。
这其实是两个独立问题叠在一起处理。
UNALIGN_TRP 是 Cortex-M 的一个控制位。它为 1 时,CPU 遇到未自然对齐的内存访问会立刻触发 UsageFault;为 0 时,CPU 会帮你完成这类访问。
例如:uint32_t value = *(uint32_t *)0x20000001;
这里读32位数据,但地址不是4字节对齐。若 UNALIGN_TRP=1,会Fault。
HAL_JPEG_Init() 在初始化Huffman表时会发生这种未自然对齐访问。即使hjpeg本身对齐,HAL内部某个压缩表或结构成员的地址仍可能不是 4/8 字节对齐,所以: __attribute__((aligned(8)));只能保证 JPEG 句柄地址对齐,不能从根上消除 HAL 内部访问未对齐表的问题。
uint32_t saved_ccr = SCB->CCR; // 保存系统原配置
SCB->CCR &= ~SCB_CCR_UNALIGN_TRP_Msk;// 暂时允许非对齐访问
__DSB();
__ISB();
init_status = HAL_JPEG_Init(handle);
SCB->CCR = saved_ccr; // 无论 HAL 成功或失败都恢复。
__DSB();
__ISB();
DSB 保证 CCR 写入已经完成;ISB 刷新执行流水线,确保接下来执行的指令立刻按新的对齐规则运行。恢复配置后也要同样执行一次,避免 CPU 继续沿用旧配置。
这样做的目的不是全局关闭对齐检查,而是把放宽范围压缩到 HAL_JPEG_Init() 这一小段已知有兼容性问题的库代码。
有一个要注意的是这段时间内如果中断已经启用,ISR 也会暂时处于“允许非对齐访问”的状态。因此最好在系统启动早期调用MX_JPEG_Init(),且不要把这个开关长期关闭。
除此之外,JPEG 文件可能只有十几 KiB,但解码过程还是可能慢到触发超时。压缩文件小,不代表输出小。480×480 图片解码后的 MCU 数据仍然是数百 KiB。轮询模式下,CPU 需要参与 JPEG FIFO 的输入输出搬运;如果 JPEG HAL 和颜色转换仍然用Debug `-O0` 编译,会导致消耗的时间非常多。
4、外部PSRAM的大坑
至于外部 PSRAM,那是另一个更大的坑。
一开始我只是想把 framebuffer 放到外部内存,给内部 SRAM 腾点位置。后来才发现,memory-mapped 下的窄写可能污染相邻数据,LTDC 扫描没排空时 CPU 写入可能卡住,Cache、XSPI 预取和总线事务宽度还会一起出来添乱。这篇先到这里。
下一期单独聊聊,使用4位QSPI的PSRAM遇到的这么多踩坑的地方。
283