2. 常见嵌入式图片格式解析

嵌入式GUI开发中,图片格式的选择直接影响资源占用和显示效率。我见过不少团队在项目初期随便选个格式,结果后期内存爆了、解码太慢,改起来非常痛苦。今天咱们就把BMP、JPEG、PNG这三种最常见的格式掰开揉碎,看看它们到底是怎么存图片的。

核心观点:没有最好的格式,只有最合适的格式。BMP简单直接,JPEG压缩率高但有损,PNG无损且支持透明。理解它们的原理,你才能做出正确的选择。

嵌入式GUI常见图片格式对比 BMP 位图 • 文件头 + 信息头 • 像素数据无压缩 • 结构简单,解码快 • 体积大,适合小图 JPEG 有损压缩 • DCT + 量化 + 霍夫曼 • 压缩比高(10:1~20:1) • 适合照片类图像 • 解码需浮点运算 PNG 无损压缩 • 行过滤 + DEFLATE • 无损,支持Alpha通道 • 适合图标、UI元素 • 解码内存开销较大 选择原则:小图标/UI元素 → PNG | 照片/背景 → JPEG | 裸数据/帧缓冲 → BMP

2.1 BMP格式详解

BMP是Windows时代的老将了。它最大的特点就是——简单。说白了就是把像素数据原封不动地存下来,顶多加个文件头描述一下宽高和颜色深度。我最早做单片机GUI时,用的就是BMP,因为解码代码几十行就能搞定。

文件结构三件套

一个标准的BMP文件由三部分组成:文件头、信息头、像素数据。咱们一个一个看。

组成部分 大小 关键字段 说明
BMP文件头 14字节 bfType, bfSize, bfOffBits 标识"BM"、文件总大小、数据偏移
位图信息头 40字节 biWidth, biHeight, biBitCount 图像宽高、颜色深度、压缩方式
像素数据 可变 RGB/BGR像素阵列 从下到上、从左到右存储

避坑提醒:BMP的像素数据是从左下角开始存储的!也就是说,文件里的第一行像素对应的是图片的最下面一行。我曾经在移植一个图片显示驱动时,忘了这个细节,结果图片上下颠倒显示了一个星期才找到原因。

来看一个实际的BMP文件头解析代码。我习惯用结构体来映射文件布局,这样读起来最直观:

// BMP文件头结构体(14字节)
typedef struct {
    uint16_t bfType;      // 固定为0x4D42,即"BM"
    uint32_t bfSize;      // 整个BMP文件的大小(字节)
    uint16_t bfReserved1; // 保留,必须为0
    uint16_t bfReserved2; // 保留,必须为0
    uint32_t bfOffBits;   // 从文件头到像素数据的偏移量
} __attribute__((packed)) BMPFileHeader;

// 位图信息头结构体(40字节)
typedef struct {
    uint32_t biSize;          // 本结构体大小,固定40
    int32_t  biWidth;         // 图像宽度(像素)
    int32_t  biHeight;        // 图像高度(像素),正数表示倒置
    uint16_t biPlanes;        // 颜色平面数,固定为1
    uint16_t biBitCount;      // 每像素位数:1/4/8/16/24/32
    uint32_t biCompression;   // 压缩方式:0=不压缩
    uint32_t biSizeImage;     // 像素数据大小(字节)
    int32_t  biXPelsPerMeter; // 水平分辨率
    int32_t  biYPelsPerMeter; // 垂直分辨率
    uint32_t biClrUsed;       // 实际使用的颜色数
    uint32_t biClrImportant;  // 重要颜色数
} __attribute__((packed)) BMPInfoHeader;

这里有个细节要注意:__attribute__((packed)) 告诉编译器不要做内存对齐。如果不加这个,结构体成员之间可能会有填充字节,读出来的数据就全乱了。嗯,嵌入式开发里这种坑特别多。

像素数据的存储规则

24位BMP每个像素用3个字节表示,顺序是BGR(蓝、绿、红),不是我们熟悉的RGB。32位BMP则多了一个Alpha通道,顺序是BGRA。

还有一个容易忽略的点——行对齐。BMP要求每行像素数据的字节数必须是4的倍数。如果不够,会在行尾补0。计算公式如下:

// 计算每行实际占用的字节数(含对齐填充)
uint32_t rowSize = ((biBitCount * biWidth + 31) / 32) * 4;

// 或者更直观的写法:
uint32_t rowSize = (biWidth * (biBitCount / 8) + 3) & ~3;

注意:如果你直接按 biWidth * (biBitCount/8) 来读取每行数据,遇到宽度不是4的倍数时,读出来的像素会错位。我见过有人因为这个bug,图片显示出来是一道一道的斜纹,排查了两天才发现是行对齐的问题。

2.2 JPEG格式原理

JPEG和BMP完全是两个世界。BMP是"所见即所得",JPEG则是"能省就省"。它利用人眼对高频细节不敏感的特点,丢掉一部分人眼察觉不到的信息,从而实现极高的压缩比。

JPEG的压缩流程可以概括为三个步骤:离散余弦变换(DCT)→ 量化 → 霍夫曼编码。咱们一个一个说。

离散余弦变换(DCT)

JPEG先把图像分成8×8的小块,然后对每个块做DCT变换。DCT的作用是把空间域的像素值转换到频率域。变换后得到一个8×8的系数矩阵,左上角是低频分量(人眼敏感),右下角是高频分量(人眼不敏感)。

公式我就不写了,网上一搜一大把。你只需要记住:DCT本身是无损的,真正的"有损"发生在下一步。

量化——压缩的关键

量化是JPEG唯一的有损步骤。它用一个量化表去除以DCT系数,然后取整。高频分量的量化值通常很大,除完以后很多系数直接变成0。这就是JPEG能大幅压缩的原因。

标准JPEG库提供了两张量化表——一张给亮度用,一张给色度用。亮度表对高频更宽容,因为人眼对亮度变化更敏感:

亮度量化表(部分) 色度量化表(部分)
16, 11, 10, 16, 24, 40, 51, 61 17, 18, 24, 47, 99, 99, 99, 99
12, 12, 14, 19, 26, 58, 60, 55 18, 21, 26, 66, 99, 99, 99, 99
14, 13, 16, 24, 40, 57, 69, 56 24, 26, 56, 99, 99, 99, 99, 99
14, 17, 22, 29, 51, 87, 80, 62 47, 66, 99, 99, 99, 99, 99, 99

你想想看,色度量化表右下角全是99,这意味着高频色度信息几乎全被干掉了。但人眼就是看不出来,这就是JPEG的聪明之处。

经验之谈:在嵌入式设备上解码JPEG,如果MCU没有硬件DCT加速器,纯软件解码会非常慢。我做过一个项目,用STM32F4软解一张800×480的JPEG图片,花了将近2秒钟。后来换成硬件JPEG解码器,直接降到50毫秒。所以,如果你的产品需要频繁显示JPEG图片,一定要选带硬件解码的芯片。

霍夫曼编码

量化之后,系数矩阵变得很稀疏(很多0)。JPEG用Zig-Zag扫描把这些系数排成一维序列,然后用游程编码(RLE)和霍夫曼编码进一步压缩。

霍夫曼编码的原理很简单:出现频率高的数据用短码字,出现频率低的用长码字。JPEG标准里预定义了两套霍夫曼表(DC表和AC表),也可以根据图像内容自定义。不过嵌入式场景下,用标准表就够了,省去建表的开销。

2.3 PNG格式原理

PNG是BMP的"进化版"。它保留了BMP的完整像素信息(无损),但通过行过滤和DEFLATE压缩把文件体积大幅缩小。而且PNG支持Alpha通道透明,这在UI开发中几乎是刚需。

行过滤——让数据更"好压"

PNG在压缩之前,先对每一行像素做一次"过滤"。过滤的目的是让相邻像素之间的差值变小,这样后续的DEFLATE压缩效率更高。

PNG定义了5种过滤模式:

过滤类型 名称 计算方法 适用场景
0 None 不处理,原始数据 随机噪声图像
1 Sub 当前像素 - 左边像素 水平渐变图像
2 Up 当前像素 - 上方像素 垂直渐变图像
3 Average 当前像素 - (左+上)/2 平滑过渡图像
4 Paeth 当前像素 - Paeth预测值 复杂纹理图像

Paeth预测器稍微复杂一点。它用左边、上边、左上角三个像素做线性预测,取最接近真实值的那个。我当年第一次看Paeth算法时觉得挺玄乎,后来发现其实就是个简单的边界检测器。

// Paeth预测器实现
uint8_t PaethPredictor(uint8_t a, uint8_t b, uint8_t c) {
    // a = 左边像素, b = 上方像素, c = 左上角像素
    int16_t p = a + b - c;          // 初始预测值
    int16_t pa = abs(p - a);        // 与左边的差异
    int16_t pb = abs(p - b);        // 与上边的差异
    int16_t pc = abs(p - c);        // 与左上角的差异
    
    // 返回差异最小的那个
    if (pa <= pb && pa <= pc) return a;
    if (pb <= pc) return b;
    return c;
}

注意:PNG解码时,每一行的第一个字节表示该行使用的过滤类型。解码器必须按行逐像素还原,不能跳着读。我曾经优化解码速度时想并行处理多行,结果发现过滤依赖上一行的数据,根本没法并行——除非你把整张图先解压到内存里。

DEFLATE压缩——核心引擎

过滤之后的数据会送入DEFLATE压缩器。DEFLATE是PNG的"心脏",它结合了LZ77算法和霍夫曼编码。

LZ77的原理是:在已经处理过的数据中寻找重复的字符串,用"距离+长度"的引用代替重复内容。比如"ABCDABCD"可以压缩成"ABCD(4,4)",意思是向前4个字节复制4个字节。

LZ77处理完后,再用霍夫曼编码对结果进行二次压缩。这个组合拳打下来,PNG的压缩率通常能达到BMP的2~5倍,而且完全无损。

关键数据:一张640×480的32位BMP图片大约1.17MB,同样的内容存成PNG通常只有200~400KB。如果图片有大面积纯色区域(比如UI按钮),PNG甚至能压到几十KB。

嵌入式PNG解码的注意事项

在资源受限的MCU上解码PNG,有几个地方要特别留意:

  • 内存开销大:DEFLATE解压需要维护一个32KB的滑动窗口。如果你的MCU只有64KB RAM,光这个窗口就占了一半。
  • 逐行处理:PNG是按行压缩的,解压时也是一行一行来。你可以边解压边显示,不用等整张图都解完。这个特性对嵌入式很友好。
  • Alpha通道:PNG的透明信息存在额外的Alpha通道里。如果你的LCD不支持Alpha混合,需要软件做合成,这会消耗不少CPU。

我个人建议,在资源紧张的嵌入式设备上,尽量用PNG8(8位索引色)代替PNG32。PNG8只有256种颜色,文件更小,解码更快,而且同样支持Alpha透明。对于UI图标来说,256色完全够用。

好了,三种格式的原理就讲到这里。BMP是基础,JPEG是"有损压缩"的代表,PNG是"无损压缩"的标杆。理解它们各自的优缺点,你在做嵌入式GUI选型时就能少走很多弯路。