4、BMP文件格式深度解析:文件头、信息头、调色板、像素数据的结构分析
做嵌入式图形开发,尤其是要在LVGL里加载外部存储的图片时,BMP格式是绕不开的。为什么?因为它结构简单、没有压缩算法,解码起来最直接。但简单归简单,你要是没搞懂它的字节对齐和颜色表偏移,解码出来的图照样花屏。
今天我就带你把这层窗户纸捅破。咱们从文件头开始,一路读到像素数据,把BMP的每一块骨头都拆开看看。
4.1 BMP文件的整体骨架
一个标准的BMP文件,说白了就是四块数据拼在一起:
- 文件头(BITMAPFILEHEADER):14字节,告诉你这是个BMP文件,以及像素数据从哪开始。
- 信息头(BITMAPINFOHEADER):40字节(最常见),告诉你图片多宽多高、多少位色、有没有压缩。
- 调色板(Palette):可选,只有8位及以下的BMP才有。每个颜色占4字节(BGR+保留)。
- 像素数据(Pixel Data):真正的图像内容,从下往上、从左到右存储。
我当年第一次手动解析BMP时,就是被这个“从下往上”坑了一把。你想想看,文件里第一行像素其实是图片的最后一行,不处理好这个,显示出来就是倒着的。
核心记忆点:BMP像素数据是倒序存储的——文件开头存的是图像最下面一行,文件末尾才是最上面一行。
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文件格式就这些内容。结构虽然简单,但每个字节都有它的意义。搞懂了这些,后面做解码和色彩转换就轻松多了。