STM32软件解码JPEG:从码流到RGB565的嵌入式实现

发布时间:2026/11/4 14:51:22
STM32软件解码JPEG:从码流到RGB565的嵌入式实现

简介:面向嵌入式开发者的STM32平台JPEG解码源程序,针对资源受限微控制器上图像数据难以高效处理的问题,提供一套可在裸机环境运行的轻量级解码实现。压缩包共2个文件,包含1个C源程序与1份Word技术文档,整体约43KB,源码可直接在Keil MDK下编译,文档则系统讲解JPEG编解码流程与各阶段数据结构,便于对照理解实现逻辑。程序完整实现DCT变换、量化与Huffman熵编码等关键环节,将JPEG二进制数据还原为可显示的RGB像素,运行开销小于2.6K,解码一幅800×480彩色图片约需6秒,适合实时性要求不高的嵌入式视觉场景。已有1027人学习,读者可据此掌握解码原理、内存优化策略与中断驱动思路,并在此基础上进行定制与提速。

1. 从一张 320×240 的 JPEG 说起:为什么要在 STM32 上自己解 JPEG

前阵子帮朋友看一个带屏的小设备,屏幕是 320×240 的 SPI 彩屏,主控是 STM32F103,Flash 只有 512KB、SRAM 64KB。他想在开机时显示一张产品图,图是现成的 JPEG,结果把文件直接塞进 Flash 后,屏幕上一片雪花。问题很典型:STM32 没有硬件 JPEG 解码单元,JPEG 是压缩格式,必须解成 RGB565 位图才能刷屏。这份「JPEG图片STM32解码源程序」解决的正是这件事——把 JPEG 解码算法用 C 语言落到单片机上,让没有硬件解码器的 STM32 也能把 JPEG 还原成像素。适合正在做带屏设备、想省掉外挂解码芯片或大容量外部 RAM 的嵌入式开发者,也适合想搞懂 JPEG 解码到底怎么在资源受限环境里跑起来的人。下面按「能干什么 → 怎么跑 → 怎么改」拆开讲。

2. 解码链路拆解:从 JPEG 码流到 RGB565 的完整路径

2.1 整体架构:三层结构各管什么

这类 STM32 JPEG 解码源程序,常见做法是分成三层,而不是把所有逻辑塞进一个main.c。第一层是码流解析层,负责读文件头、解析 SOI/APP0/DQT/SOF0/DHT/SOS 这些标记段,把量化表、霍夫曼表、图像宽高、采样因子提取出来。第二层是熵解码与反量化层,按 8×8 的 MCU 块做霍夫曼解码、反 ZigZag、反量化、IDCT,得到 YCbCr 分量。第三层是色彩转换与输出层,把 YCbCr 转成 RGB,再按屏幕要求打包成 RGB565 写进显存或通过 SPI/DMA 刷出去。

分层的好处很直接:换屏幕只需要改输出层,换文件来源(Flash、SD 卡、串口)只需要改输入层,解码核心不动。我一般会把解码核心做成一个jpeg_decode()入口,输入是码流指针和长度,输出是一个回调函数,每解完一行或一个块就回调一次,这样内存占用能压到最低。

/* 解码器对外暴露的核心接口,输入码流,输出通过回调逐行交付 */ typedef void (*jpeg_line_cb)(uint16_t y, const uint16_t *rgb565_line, uint16_t width); int jpeg_decode(const uint8_t *jpg_buf, uint32_t jpg_len, jpeg_line_cb cb, uint16_t *line_buf);

jpg_buf是整张 JPEG 在内存里的起始地址,jpg_len是字节数,cb是每解出一行像素就调用的回调,line_buf是调用方提供的一行 RGB565 缓冲。这样设计的关键是:解码器自己不需要一整帧的帧缓冲,320×240 的 RGB565 一帧要 150KB,F103 根本放不下,逐行输出就把峰值内存压到「一行 + 若干 MCU 块」的量级。

2.2 关键数据结构:量化表和霍夫曼表怎么存

JPEG 文件里 DQT 段给的是量化表,DHT 段给的是霍夫曼表。解码器要在 RAM 里维护这两类表。量化表是 64 个 8 位或 16 位整数,霍夫曼表则要展开成「码长 → 码字 → 符号」的查找结构。资源紧张的 MCU 上,常见做法是用「最小码字 + 最大值 + 偏移」的经典查表法,而不是建一棵真正的树。

/* 霍夫曼表:用 mincode/maxcode/valptr 三张表实现快速解码 */ typedef struct { uint8_t bits[17]; /* bits[i] = 长度为 i 的码字个数 */ uint8_t huffval[256]; /* 按码长顺序排列的符号值 */ int32_t mincode[17]; /* 每个码长的最小码字 */ int32_t maxcode[18]; /* 每个码长的最大码字,maxcode[17] 恒为 -1 */ int32_t valptr[17]; /* 每个码长在 huffval 中的起始下标 */ } jpeg_huff_table; typedef struct { uint16_t quant[64]; /* 反量化用的量化表,按自然顺序存放 */ uint8_t quant_valid; /* 该表是否已被 DQT 段填充 */ } jpeg_quant_table;

bits[]和huffval[]是直接从 DHT 段读出来的原始数据,mincode/maxcode/valptr是在解析完 DHT 后一次性算出来的加速结构。解码一个符号时,逐位读码流、和maxcode[len]比较,命中就用valptr[len] + (code - mincode[len])去huffval里取符号。这套结构是 JPEG 解码的经典实现,好处是查表快、内存固定,不需要动态分配。

2.3 解码主循环:一个 MCU 块的处理顺序

真正干活的是 MCU 块循环。对 4:2:0 采样,一个 MCU 是 16×16 像素,包含 4 个 Y 块、1 个 Cb 块、1 个 Cr 块。每个 8×8 块的处理顺序是固定的:霍夫曼解码得到量化后的 DCT 系数 → 反 ZigZag → 反量化 → IDCT → 得到空间域样本。这个顺序不能乱,反量化必须在 IDCT 之前,因为 IDCT 的输入是真实 DCT 系数。

/* 单个 8x8 块:熵解码 -> 反量化 -> IDCT,输出 64 个空间域样本 */ static void decode_block(jpeg_decoder *d, int16_t *coef, uint8_t *out) { int i; memset(coef, 0, 64 * sizeof(int16_t)); /* 1. 霍夫曼解码,按 ZigZag 顺序填系数,遇到 EOB 提前结束 */ huffman_decode_block(d, coef); /* 2. 反 ZigZag:把 ZigZag 顺序还原成 8x8 自然顺序 */ dezigzag(coef); /* 3. 反量化:逐元素乘以量化表 */ for (i = 0; i < 64; i++) coef[i] = (int16_t)(coef[i] * d->qtab[d->cur_qtab].quant[i]); /* 4. IDCT:8x8 整数或浮点逆离散余弦变换 */ idct_8x8(coef, out); }

coef是 64 个 int16,out是 64 个 uint8 的空间域样本。注意反量化那一步乘法可能溢出 int16,实际实现里常用 int32 中间量再饱和回 int16,或者用定点缩放。IDCT 是整条链路里最耗时的部分,后面会专门讲怎么在 M3 上把它压下来。

2.4 色彩转换与 RGB565 打包

YCbCr 到 RGB 的转换公式是固定的,但直接按浮点算在 M3 上太慢,常见做法是定点化:把系数放大 2^16 倍,用整数乘加再右移。转换完得到 R、G、B 各 8 位,再打包成 RGB565:R 取高 5 位、G 取高 6 位、B 取高 5 位,拼成一个 uint16。

/* YCbCr -> RGB565,定点实现,系数放大 16 位 */ static inline uint16_t ycbcr_to_rgb565(int y, int cb, int cr) { int r, g, b; cb -= 128; cr -= 128; /* 系数来自标准 BT.601 转换矩阵,放大 65536 倍后取整 */ r = y + ((91881 * cr) >> 16); g = y - ((22554 * cb + 46802 * cr) >> 16); b = y + ((116130 * cb) >> 16); r = r < 0 ? 0 : (r > 255 ? 255 : r); g = g < 0 ? 0 : (g > 255 ? 255 : g); b = b < 0 ? 0 : (b > 255 ? 255 : b); return (uint16_t)(((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)); }

y是亮度,cb、cr是色度,进来时是 0~255 的无符号值,先减 128 变成有符号。三个系数 91881、22554、46802、116130 是 BT.601 矩阵乘 65536 后的定点值。最后那行位运算就是 RGB565 打包:R 左移 8 位保留高 5 位,G 左移 3 位保留高 6 位,B 右移 3 位保留高 5 位。这个函数会被逐像素调用,所以写成static inline,编译器会内联展开。

3. 把程序跑起来:工程搭建、文件组织与刷屏验证

3.1 工程目录与文件职责

拿到这类源程序,先别急着编译,先看目录。典型的组织方式是把解码核心、平台适配、应用入口分开,这样移植时只动适配层。

目录/文件职责移植时是否要改
jpeg/解码核心:标记解析、霍夫曼、IDCT、色彩转换基本不改
port/平台适配:文件读取、内存分配、延时必须改
app/应用入口:初始化、调用解码、刷屏按需改
lcd/屏幕驱动:SPI/DMA 初始化、写窗口、写像素按屏幕改
main.c主流程:读图 → 解码 → 显示按需改

核心目录jpeg/里通常有jpeg_decode.c、huffman.c、idct.c、color.c几个文件。移植时最容易被忽略的是port/里的内存分配——很多实现用malloc申请 MCU 块缓冲,在裸机上要么换成静态数组,要么换成自己的内存池,否则跑着跑着就进 HardFault。

3.2 编译配置:栈、堆和优化等级

STM32F103 的默认启动文件给的栈通常只有 1KB 左右,而 JPEG 解码里 IDCT 和霍夫曼解码的局部变量不少,递归或大数组一压就爆栈。血泪经验是:先把栈调到 2KB 以上,再开-O2。优化等级也有讲究,-O0下 IDCT 慢到刷一屏要好几秒,-O2能快三到四倍,但要注意volatile用得对不对,别让编译器把硬件寄存器访问优化掉。

# 以常见 ARM GCC 工具链为例,关键编译选项 arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -O2 \ -ffunction-sections -fdata-sections \ -Wl,--gc-sections \ -T stm32f103.ld \ jpeg/*.c port/*.c app/*.c lcd/*.c main.c -o firmware.elf

-mcpu=cortex-m3 -mthumb指定内核和指令集,-O2是性能关键,-ffunction-sections -fdata-sections配合--gc-sections能把没用的函数裁掉,省 Flash。链接脚本stm32f103.ld里要确认.stack段大小,别用默认值。

3.3 图片存放与读取:Flash 直读还是 SD 卡

图片放哪决定了输入层怎么写。如果图不大、Flash 有余量,最省事的是用工具把 JPEG 转成 C 数组,直接编进固件,解码时传数组地址即可,连文件系统都省了。如果图多或图大,就得上 SD 卡 + FatFs,这时port/层要实现一个read回调,按需从卡里读码流。

/* 方案一:图片编进 Flash,直接给指针 */ extern const uint8_t product_jpg[]; extern const uint32_t product_jpg_len; void show_product(void) { jpeg_decode(product_jpg, product_jpg_len, lcd_write_line, line_buf); }

product_jpg是用xxd -i或类似工具生成的数组,product_jpg_len是长度。lcd_write_line是输出回调,line_buf是一行 RGB565 缓冲。这种方案启动快、无文件系统开销,缺点是改图要重新烧固件。SD 卡方案则把jpeg_decode的输入换成一个带缓冲的流式读取器,适合图片经常换的场景。

3.4 刷屏验证:先看颜色对不对,再看速度

第一次跑通,别急着看整图,先确认三件事:图像宽高解析对不对、颜色通道顺序对不对、有没有花屏。常见做法是在lcd_write_line里加一句串口打印,把第一行的前几个像素值打出来,和原图对比。如果整体偏蓝,多半是 RGB565 的 R/B 位序反了;如果颜色发灰,可能是 YCbCr 转换时cb/cr没减 128。

速度方面,320×240 在 72MHz 的 F103 上,纯软件解码常见在几百毫秒到一两秒量级,具体取决于 IDCT 实现和优化等级。如果慢得离谱,先查是不是每解一个块都重新解析了量化表,或者 IDCT 用了浮点double。刷屏本身如果走 SPI,还要算上传输时间,320×240×2 字节约 150KB,SPI 18MHz 下光传输就要几十毫秒,别把传输时间算到解码头上。

4. 避坑与排查:五个真实会翻车的点

4.1 现象:进 HardFault,PC 指向 IDCT 附近

原因通常是栈溢出。IDCT 里如果用了int32_t tmp[64]这类局部大数组,加上调用链上的其他栈帧,1KB 栈根本不够。解决是把大数组改成static或放到全局,或者把栈调到 2KB 以上,同时用-fstack-usage编译选项看每个函数的栈用量,把最耗栈的函数找出来。

4.2 现象:图像上半部分正常,下半部分错位或重复

原因多半是 MCU 块的行列计数没重置。JPEG 是按 MCU 逐行扫描的,每行 MCU 处理完要重置列计数,每帧开始要重置行计数。如果解码循环里mcu_x没在行末清零,第二行就会接着第一行的列号继续走,导致错位。解决是检查for (mcu_y...) for (mcu_x...)的双层循环边界,确认内层循环结束后mcu_x归零。

4.3 现象:颜色整体偏绿或偏紫

原因是 YCbCr 转换时 Cb、Cr 的顺序搞反了,或者采样因子解析错。4:2:0 下 Cb 和 Cr 是交替存放的,如果读块时把 Cb 当成了 Cr,颜色就会整体偏。解决是打印 SOF0 段解析出的三个分量 ID 和采样因子,确认分量 1 是 Y、分量 2 是 Cb、分量 3 是 Cr,再对照解码时取块的顺序。

4.4 现象:某些图片能解,某些图片花屏

原因是霍夫曼表或量化表有多张,而解码器只用了第一张。JPEG 允许 DQT/DHT 段出现多次,每个分量可以指定用哪张表(通过 SOS 段里的表选择符)。如果代码里写死了qtab[0],遇到用第二张表的图片就会花。解决是解析 SOS 段时读出每个分量的Td/Ta,解码时按分量选表,别写死下标。

4.5 现象:解码速度忽快忽慢,偶尔卡顿

原因是内存分配在解码过程中反复malloc/free,碎片化后分配变慢,甚至失败。裸机上更常见的是根本没实现malloc,链接时用了半主机(semihosting),一调用就卡住。解决是把所有缓冲在初始化时一次性静态分配好,解码过程中零动态分配;如果确实要用堆,确认_sbrk已正确实现,且堆大小够用。

5. 二次开发:换屏幕、换采样、提速度的三个改造方向

5.1 换屏幕:只改输出回调,不动解码核心

这套代码最值钱的地方是解码核心和输出解耦。换一块 ILI9341 也好、换 ST7789 也好,只要实现一个新的lcd_write_line,在里面设置显示窗口、按行写 RGB565 数据,解码核心一行不用改。我一般会在lcd/下按屏幕型号建文件,用宏在编译期选,避免运行时判断拖慢刷屏。

/* 换屏只需实现这个回调:把一行 RGB565 写到屏幕指定行 */ void lcd_write_line(uint16_t y, const uint16_t *line, uint16_t width) { lcd_set_window(0, y, width - 1, y); /* 设置单行显示窗口 */ lcd_write_pixels(line, width); /* DMA 或 SPI 批量写 */ }

y是行号,line是这一行的像素,width是宽度。lcd_set_window和lcd_write_pixels是屏幕驱动提供的,不同屏实现不同,但回调签名不变。这样换屏的改动被限制在一个文件里,回归测试也好做。

5.2 换采样格式:4:2:0 到 4:2:2 的适配点

如果图片源从 4:2:0 换成 4:2:2,MCU 结构会变:4:2:2 下一个 MCU 是 16×8,包含 2 个 Y 块、1 个 Cb、1 个 Cr。适配点在两处:一是 SOF0 解析后按采样因子算 MCU 尺寸和每 MCU 的块数,二是上采样时 Cb/Cr 的水平复制次数不同。建议把「每 MCU 的块布局」做成一张小表,按采样因子查,而不是写死 4:2:0 的 6 块。

5.3 提速度:IDCT 定点化与 AAN 快速算法

IDCT 是性能大头。浮点 IDCT 在 M3 上没 FPU,全靠软件模拟,慢得离谱。常见改造是换成整数 IDCT,比如 AAN 快速算法,把 8×8 的 64 点运算降到几十次乘加。另一个技巧是利用 JPEG 系数大量为零的特性,在反量化后先判断 AC 系数是否全零,全零就跳过 IDCT 直接填 DC 值,纯色块能省掉整块运算。

改造点改动位置预期收益风险
浮点 IDCT 换整数idct.c速度提升数倍精度略降,需对比 PSNR
AC 全零跳过decode_block纯色图大幅加速判断逻辑要正确
输出改 DMAlcd_write_line刷屏不占 CPUDMA 与解码争总线
表查找替代逐位huffman.c熵解码加速表内存增加

改完一定要用同一张图做前后对比:解码耗时用 GPIO 翻转 + 示波器量,或者用 DWT 周期计数器;画质用肉眼对比加简单 PSNR。别只看「感觉快了」,我吃过亏,一次把 IDCT 改成近似算法,速度上去了,但渐变区域出现明显色带,返工重调系数。

5.4 一个具体技巧:用 DWT 周期计数器定位热点

M3 内核带 DWT,可以数周期,比SysTick精度高。在解码前后各读一次DWT->CYCCNT,差值就是周期数,除以主频就是耗时。把这段包成宏,在decode_block、idct_8x8、ycbcr_to_rgb565前后各插一对,跑一次就知道时间花在哪。

/* 用 DWT 周期计数器测单段耗时,需先使能 DWT->CTRL 的 CYCCNTENA */ #define DWT_BEGIN() do { DWT->CYCCNT = 0; } while (0) #define DWT_END() (DWT->CYCCNT) DWT_BEGIN(); idct_8x8(coef, out); uint32_t cycles = DWT_END();

DWT->CYCCNT是 32 位自由运行计数器,72MHz 下约 59 秒回绕一次,单次测量足够。使能方法是在初始化里写CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;。有了这个数,优化就有靶子,不用瞎猜。

从那以后我每次移植这类解码器,都先把 DWT 计数和栈用量这两个「黑匣子」打开,再谈优化和改屏,不然改了半天不知道快在哪、崩在哪。这份源程序的价值不在于代码多长,而在于它把 JPEG 解码在 MCU 上的完整链路摊开给你看,照着改、照着测,比读十篇原理文都实在。希望帮到你。

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