4、BMP文件格式深度解析:文件头、信息头、调色板、像素数据的结构分析

做嵌入式图形开发,尤其是要在LVGL里加载外部存储的图片时,BMP格式是绕不开的。为什么?因为它结构简单、没有压缩算法,解码起来最直接。但简单归简单,你要是没搞懂它的字节对齐和颜色表偏移,解码出来的图照样花屏。

今天我就带你把这层窗户纸捅破。咱们从文件头开始,一路读到像素数据,把BMP的每一块骨头都拆开看看。

4.1 BMP文件的整体骨架

一个标准的BMP文件,说白了就是四块数据拼在一起:

  • 文件头(BITMAPFILEHEADER):14字节,告诉你这是个BMP文件,以及像素数据从哪开始。
  • 信息头(BITMAPINFOHEADER):40字节(最常见),告诉你图片多宽多高、多少位色、有没有压缩。
  • 调色板(Palette):可选,只有8位及以下的BMP才有。每个颜色占4字节(BGR+保留)。
  • 像素数据(Pixel Data):真正的图像内容,从下往上、从左到右存储。

我当年第一次手动解析BMP时,就是被这个“从下往上”坑了一把。你想想看,文件里第一行像素其实是图片的最后一行,不处理好这个,显示出来就是倒着的。

核心记忆点:BMP像素数据是倒序存储的——文件开头存的是图像最下面一行,文件末尾才是最上面一行。

BMP文件整体结构 文件头 14字节 信息头 40字节 调色板 可选 像素数据 核心 关键说明: • 文件头固定14字节,信息头固定40字节(Windows BMP v3) • 调色板仅8位及以下色深存在,每个颜色4字节 • 像素数据从文件头中 bfOffBits 字段指定的偏移量开始 • 像素数据是倒序存储:文件开头存最后一行

4.2 文件头:14字节的身份证

文件头就两个关键字段,但决定了你能不能正确找到像素数据。

偏移量 大小 字段名 说明
0x00 2字节 bfType 固定为 0x4D42,即 "BM"
0x02 4字节 bfSize 整个BMP文件的大小(字节)
0x06 4字节 bfReserved1/2 保留字段,通常为0
0x0A 4字节 bfOffBits 像素数据的起始偏移量

这里有个坑:bfOffBits 这个字段特别重要。它告诉你像素数据到底从文件的哪个字节开始。对于24位BMP,它通常是 0x36(54),也就是文件头+信息头的大小。但对于8位BMP,因为有调色板,它可能是 0x436(1080)。

我曾经踩过的坑:有一次解析一个8位BMP,我直接硬编码偏移量0x36去读像素数据,结果读出来的全是调色板的颜色索引,画面完全不对。后来才发现bfOffBits是0x436,中间差了1024字节的调色板。从那以后,我再也不敢偷懒,每次都老老实实读bfOffBits。

4.3 信息头:40字节的画像描述

信息头告诉你这张图长什么样。最常见的版本是 BITMAPINFOHEADER,40字节。

偏移量 大小 字段名 说明
0x0E 4字节 biSize 信息头大小,固定40
0x12 4字节 biWidth 图像宽度(像素)
0x16 4字节 biHeight 图像高度(像素)
0x1A 2字节 biPlanes 固定为1
0x1C 2字节 biBitCount 色深:1/4/8/16/24/32
0x1E 4字节 biCompression 压缩方式:0=不压缩
0x22 4字节 biSizeImage 像素数据大小(可填0)
0x26 4字节 biXPelsPerMeter 水平分辨率
0x2A 4字节 biYPelsPerMeter 垂直分辨率
0x2E 4字节 biClrUsed 实际使用的颜色数(0表示全部)
0x32 4字节 biClrImportant 重要颜色数(0表示全部)

我个人习惯重点关注三个字段:biWidth、biHeight、biBitCount。这三个决定了你要分配多大的内存来存解码后的图像。

小技巧:biHeight 如果是正数,表示图像是倒序存储(最常见的)。如果是负数,表示图像是正序存储。但绝大多数工具生成的BMP都是正数,所以解码时记得从文件末尾往前读。

4.4 调色板:8位及以下才有的颜色表

调色板只存在于 biBitCount <= 8 的BMP中。每个颜色表项占4字节:

  • 第1字节:蓝色分量
  • 第2字节:绿色分量
  • 第3字节:红色分量
  • 第4字节:保留(通常为0)

调色板的大小 = 2^biBitCount × 4 字节。比如8位BMP就是 256 × 4 = 1024字节。

这里有个细节:颜色顺序是BGR,不是RGB。如果你直接按RGB顺序去读,颜色会偏蓝偏红。我在LVGL里做解码时,每次都要手动把B和R交换一下。

4.5 像素数据:真正的硬骨头

像素数据是BMP里最复杂的一块。有几个关键点你必须搞清楚:

4.5.1 行对齐规则

BMP要求每一行像素数据的字节数必须是4的倍数。如果不是,后面会补0填充。

计算公式:

// 每行实际字节数(含填充)
uint32_t rowSize = ((biWidth * biBitCount + 31) / 32) * 4;

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

举个例子:一张宽度为1像素的24位BMP,每行像素数据本来只需要3字节。但BMP要求4字节对齐,所以实际每行占4字节,最后1字节是填充。

我曾经踩过的坑:有一次解码一张宽度为3像素的24位BMP,我直接按 biWidth * 3 去算每行大小,结果读出来的数据全是错位的。后来才发现每行实际是12字节(3像素×3字节=9字节,对齐到4的倍数是12),多出来的3字节是填充。不跳过这些填充,后面的行就全乱了。

4.5.2 像素数据的存储顺序

对于24位BMP,每个像素占3字节,顺序是:B、G、R。

对于32位BMP,每个像素占4字节,顺序是:B、G、R、A(Alpha通道)。

对于8位BMP,每个像素占1字节,存储的是调色板索引。

4.5.3 倒序读取的实现

因为BMP是从下往上存的,所以解码时要从文件末尾开始读,或者把读出来的行倒序排列。

// 伪代码:读取24位BMP像素数据并翻转行序
uint8_t *pixelData = malloc(biWidth * biHeight * 3);
uint32_t rowSize = ((biWidth * 24 + 31) / 32) * 4;

for (int y = 0; y < biHeight; y++) {
    // 从文件读取一行(含填充)
    uint8_t rowBuffer[rowSize];
    fread(rowBuffer, 1, rowSize, file);

    // 跳过填充字节,只取有效像素
    // 目标行:从最后一行开始存
    uint32_t destRow = biHeight - 1 - y;
    memcpy(pixelData + destRow * biWidth * 3, rowBuffer, biWidth * 3);
}

4.6 实战:用C语言读取BMP文件头

说了这么多理论,咱们来点实际的。下面这段代码演示了如何读取BMP文件头和信息头:

#include <stdio.h>
#include <stdint.h>

#pragma pack(push, 1)  // 取消字节对齐
typedef struct {
    uint16_t bfType;      // 0x4D42
    uint32_t bfSize;      // 文件大小
    uint16_t bfReserved1;
    uint16_t bfReserved2;
    uint32_t bfOffBits;   // 像素数据偏移
} BMPFileHeader;

typedef struct {
    uint32_t biSize;          // 信息头大小
    int32_t  biWidth;         // 宽度
    int32_t  biHeight;        // 高度
    uint16_t biPlanes;        // 固定1
    uint16_t biBitCount;      // 色深
    uint32_t biCompression;   // 压缩方式
    uint32_t biSizeImage;     // 像素数据大小
    int32_t  biXPelsPerMeter;
    int32_t  biYPelsPerMeter;
    uint32_t biClrUsed;       // 使用的颜色数
    uint32_t biClrImportant;
} BMPInfoHeader;
#pragma pack(pop)

int read_bmp_header(const char *filename) {
    FILE *fp = fopen(filename, "rb");
    if (!fp) return -1;

    BMPFileHeader fileHeader;
    BMPInfoHeader infoHeader;

    fread(&fileHeader, sizeof(BMPFileHeader), 1, fp);

    // 验证BMP标志
    if (fileHeader.bfType != 0x4D42) {
        printf("不是有效的BMP文件\n");
        fclose(fp);
        return -1;
    }

    fread(&infoHeader, sizeof(BMPInfoHeader), 1, fp);

    printf("宽度: %d\n", infoHeader.biWidth);
    printf("高度: %d\n", infoHeader.biHeight);
    printf("色深: %d位\n", infoHeader.biBitCount);
    printf("像素数据偏移: 0x%X\n", fileHeader.bfOffBits);

    fclose(fp);
    return 0;
}

注意:代码里用了 #pragma pack(push, 1),这是必须的。因为结构体默认会对齐到4字节边界,不加这个的话,bfOffBits 的偏移量会算错,整个文件头就读不对了。

4.7 不同色深的BMP对比

色深 每像素字节数 调色板 常见用途
1位 1/8字节 有(2色) 单色图标
4位 1/2字节 有(16色) 老式游戏
8位 1字节 有(256色) 索引色图片
16位 2字节 无 RGB565格式
24位 3字节 无 真彩色(最常见)
32位 4字节 无 带Alpha通道

在LVGL里,我们最常处理的是24位和32位BMP。24位需要转换成RGB565或RGB888才能显示,32位则可以直接利用Alpha通道做透明效果。

嗯,BMP文件格式就这些内容。结构虽然简单,但每个字节都有它的意义。搞懂了这些,后面做解码和色彩转换就轻松多了。