设备里存着几百张BMP位图,平时看图软件一开就是正常画面,可真有一天需要自己从文件里捞像素数据时,才会意识到自己对二进制一无所知。我前阵子从一套老采集软件里导出图片,扩展名全是BMP,程序一读却全是花的,折腾半天才意识到是颜色通道顺序和文件里的存储方式对不上。后来静下心来,用UltraEdit切到十六进制模式,把一张1x1的测试图从头到尾逐字节读了一遍,很多疑问当场就解开了。
这篇文章就是把当时那个“边读边记”的过程完整整理出来。目标很明确:让读者真正看懂BMP文件的内部布局,知道文件头、信息头、像素数据分别是哪几段,每个字节代表什么,以及为什么用UltraEdit这类十六进制工具来观察是最直观的手段。适合刚接触文件格式解析的新手,也适合平时用UltraEdit做数据查看但不太清楚BMP内存布局的人。
1. 为什么拿BMP开刀:格式选型与工具就位
1.1 BMP在常见图像格式里算“最接近内存原样”的一种
先说明为什么是BMP,而不是JPG或者PNG。JPEG内部有分段标记、量化表、霍夫曼表,像素数据还要经过离散余弦变换和熵编码,想在十六进制视图里直接看到“一个像素是什么颜色”几乎不可能。PNG稍微好一点,有IHDR、IDAT、IEND这样的数据块结构,但IDAT里的像素流仍然经过zlib压缩,肉眼没法直接对应到屏幕颜色。
BMP则完全不同。至少对最常见的24位真彩色BMP来说,文件基本就是“文件头 + 信息头 + 原始像素数据”三段,没有压缩,没有复杂编码。你甚至可以把像素区理解成一块内存快照:屏幕上某个点是什么颜色,在文件里就是那三五个字节。BMP的缺点当然是文件体积大,但正因为大得“诚实”,拿它来做二进制格式分析的入门教材再合适不过。
用UltraEdit打开一张BMP,你会看到左侧十六进制、右侧ASCII预览的经典界面。ASCII预览区通常全是乱码和点号,这很正常,因为BMP本身就是二进制数据,不是文本。真正需要关注的是十六进制部分。UltraEdit也可以切到按字节查看的列模式,但对BMP解析来说默认的十六进制视图已经够用,重点是学会以十六进制为单位“读位置”,而不是只扫一眼内容。
1.2 UltraEdit里怎么看“字节的地址”
打开文件后,UltraEdit视图左侧会显示每一行的偏移量,通常用十六进制表示。这个偏移量就是文件内字节的位置,是手工解析时最重要的参照物。BMP文件头里常常会写一个“像素数据从哪开始”的偏移值,看到这个值后,就要在UltraEdit里跳到对应位置。可以直接用快捷键Ctrl+G,输入十六进制偏移,秒跳到目标地址,比肉眼一行行数快得多。
还有一个小习惯:建议把UltraEdit的显示调成“每行16字节”或“每行8字节”,这样跟很多格式文档里的示例一一对应。比如某个字段在偏移10处,占4字节,那么你一眼就能在地址列里找到0A,然后往后数4个字节。熟练以后,凡是跟二进制打交道,这个工作流都通用。
2. 先记住这张解剖图:BMP的三大区块与字节序规则
2.1 文件头(BITMAPFILEHEADER):前14字节把“总目录”写完
一张标准BMP文件的前14字节是文件头,术语叫BITMAPFILEHEADER。这14字节非常固定,逐个拆开看:
| 偏移 | 字段 | 长度 | 示例值 | 含义 |
|---|---|---|---|---|
| 0 | bfType | 2字节 | 42 4D | 固定签名,ASCII字符就是“BM” |
| 2 | bfSize | 4字节 | 3A 00 00 00 | 整个文件大小,单位是字节 |
| 6 | bfReserved1 | 2字节 | 00 00 | 保留字段,通常为0 |
| 8 | bfReserved2 | 2字节 | 00 00 | 保留字段,通常为0 |
| 10 | bfOffBits | 4字节 | 36 00 00 00 | 像素数据起始偏移,常见值为54(0x36) |
第一个字节是42,第二个是4D,转成ASCII就是字母B和M,是BMP的标志性签名。看到这两个字节,基本可以断定这是一个BMP文件,这比看扩展名可靠得多。bfSize是整个文件总大小,单位字节,注意这里的数值是“小端序”存储,也就是低位字节在前。比如3A 00 00 00,实际含义是0x0000003A,换成十进制就是58,而不是0x3A000000。小端是大端容易搞错,这个后面专门讲坑。
bfOffBits出现在偏移10的位置,它告诉解析程序“真正的像素数据从哪里开始”。对没有调色板的标准24位BMP来说,这个值通常是54,也就是14字节文件头加40字节信息头。但如果文件带调色板,或者信息头不是40字节,这个偏移就会变化,所以解析时不能想当然,必须读取这个字段。
2.2 信息头(BITMAPINFOHEADER):40字节把“画布尺寸和颜色格式”写明白
文件头之后紧接着是信息头,最常见的版本叫BITMAPINFOHEADER,长度40字节。它的地位相当于一张画布的施工图:宽多少、高多少、每个点用几个bit表示、有没有压缩,全在这段里。
| 偏移 | 字段 | 长度 | 示例值 | 含义 |
|---|---|---|---|---|
| 14 | biSize | 4字节 | 28 00 00 00 | 信息头自身长度,40 |
| 18 | biWidth | 4字节 | 01 00 00 00 | 图像宽度,像素为单位 |
| 22 | biHeight | 4字节 | 01 00 00 00 | 图像高度,正负号有讲究 |
| 26 | biPlanes | 2字节 | 01 00 | 颜色平面数,恒为1 |
| 28 | biBitCount | 2字节 | 18 00 | 每像素位数,24表示真彩色 |
| 30 | biCompression | 4字节 | 00 00 00 00 | 压缩方式,0为不压缩 |
| 34 | biSizeImage | 4字节 | 04 00 00 00 | 像素数据区大小 |
| 38 | biXPelsPerMeter | 4字节 | 12 0B 00 00 | 水平分辨率 |
| 42 | biYPelsPerMeter | 4字节 | 12 0B 00 00 | 垂直分辨率 |
| 46 | biClrUsed | 4字节 | 00 00 00 00 | 实际使用的颜色数 |
| 50 | biClrImportant | 4字节 | 00 00 00 00 | 重要颜色数 |
biWidth和biHeight往往最容易理解,就是图像的像素尺寸。biBitCount表示每个像素用多少二进制位描述,常见值是1、4、8、16、24、32。24位就是最常见的真彩色,每个点用3字节表示红绿蓝。biCompression为0表示没有压缩,这个值对初学者是最友好的,因为它意味着像素数据可以直接看到。
还有一个值得注意的点:biHeight是带符号整数。如果为正,说明像素行从下往上存储,这是BMP最常见的模式;如果为负,像素行则从上往下存储。很多解析工具只处理正数情况,遇到负高度就乱了,后面会提到这个坑。
2.3 先算一次偏移:为什么像素数据正好在54字节处
把上面两部分合起来,就能理解为什么很多BMP的bfOffBits都是54。14字节文件头加40字节信息头,正好54字节,中间没有调色板。那么像素数据就从文件偏移54开始。
自己动手验证一下:取文件块头里的bfOffBits字段,也就是偏移10到13的四个字节,读数36 00 00 00,小端转成十六进制是0x36,十进制是54。到UltraEdit里按Ctrl+G,输入54,光标落下的位置就是像素数据区的起点。这一步验证通了,后面读像素就只是重复劳动。
如果BMP是索引色模式,比如8位灰度,文件头和40字节信息头之后还要跟一段调色板,长度是颜色数量乘以4字节。此时bfOffBits会大于54,这正是信息头中biClrUsed字段存在的意义:告诉解析器后面那一段调色板到底有多少个颜色项。
3. UltraEdit实操:从0x42到像素区的逐字节拆解
3.1 造一张最干净的测试样本
与其拿现成的复杂图片分析,不如自己生成一张1x1像素的BMP。像素尺寸越小,干扰越少,能一眼看穿所有结构。用系统自带的画图工具新建一张1x1位图,填成纯红色,另存为24位BMP,文件名随意。然后拖进UltraEdit,切换到十六进制视图。
这里先给个预期:1x1的24位BMP,理论上文件大小应该是58字节。为什么是58?54字节的头部,加1个像素的3字节,再加1字节的行填充,正好58。别看像素只有1个点,BMP有个规则:每行像素数据的字节数必须是4的倍数,不够就补零。3字节不是4的倍数,所以补1字节,这个规则后半部分还会反复提到。
3.2 逐字节念出前54字节
打开文件后看到的十六进制数据,我按16字节一组列出来:
42 4D 3A 00 00 00 00 00 00 00 36 00 00 00 28 00 00 00 01 00 00 00 01 00 00 00 01 00 18 00 00 00 00 00 00 00 04 00 00 00 12 0B 00 00 12 0B 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 FF 00逐行解释才能真正读懂:
- 第1字节
42、第2字节4D,即“BM”签名。 - 偏移2到5是
3A 00 00 00,表示整个文件0x3A个字节,即58。 - 偏移10到13是
36 00 00 00,表示像素数据从偏移54开始。 - 偏移14到17是
28 00 00 00,表示信息头长40字节。 - 偏移18到21是
01 00 00 00,宽度为1。 - 偏移22到25是
01 00 00 00,高度为1。 - 偏移26到27是
01 00,颜色平面数为1。 - 偏移28到29是
18 00,每像素24位。 - 偏移30到33是
00 00 00 00,压缩方式为BI_RGB,即未压缩。 - 偏移34到37是
04 00 00 00,像素数据区4字节。 - 偏移38到45的
12 0B 00 00,是水平和垂直分辨率,0x0B12等于2834,常见默认值。
一直数到偏移54,看到00 00 FF 00这4个字节,就是像素区的全部内容。00 00 FF按BGR顺序对应红通道255、绿通道0、蓝通道0,也就是纯红色,最后一个00是补齐字节。到这里,整个文件已经没有秘密了。
3.3 颜色顺序为什么是BGR而不是RGB
上面这个例子会立刻让人发现一个反直觉的地方:明明红色的视觉直觉是“R在前”,但文件里却是蓝通道的字节00在前,绿通道00居中,红通道FF最后。BMP规范就是按BGR顺序存储真彩色像素的,读取的时候也要按这个顺序重组。
我自己第一次解析时,直接按RGB顺序读取,结果把红色读成了蓝色,一张整体偏蓝的图就这么出现了。此类问题在某些嵌入设备上尤其隐蔽,因为屏幕输出的颜色和文件里存储的颜色通道顺序大概率不一致。以后只要看到24位BMP,第一反应应该是BGR而不是RGB,避免再踩同一个坑。
4. 往后走会碰到的变化:调色板、色深切换和压缩标记
4.1 8位及以下:调色板出现在像素区之前
不是所有BMP都是24位真彩色。老式的8位BMP每个像素只有1字节,这个字节不是颜色值本身,而是调色板索引。调色板是一个颜色表,每项4字节,按顺序排成“蓝、绿、红、保留”的格式。8位图的调色板共有256项,一共1024字节,位置就在信息头之后、像素数据之前。
这意味着8位BMP的bfOffBits不是54,而是54加1024,等于1078。如果在UltraEdit里看到一张BMP的偏移字段是36 04 00 00,换算一下是1078,就基本能判断这是一张带调色板的8位图。4位图调色板16项,1位图调色板2项,规则完全一致。脑子里始终留一根弦:解析BMP时,先看biBitCount,再看bfOffBits,两者配合才不会漏掉调色板。
4.2 不同位深度的结构速查
| 位深度 | 每像素字节数 | 调色板 | 说明 |
|---|---|---|---|
| 1位 | 8个点占1字节 | 2项×4字节 | 每个字节按8个点打包 |
| 4位 | 2个点占1字节 | 16项×4字节 | 高半字节和低半字节各是一点 |
| 8位 | 1字节索引 | 256项×4字节 | 索引指向调色板 |
| 16位 | 2字节 | 无或掩码 | 常见RGB555或RGB565 |
| 24位 | 3字节 | 无 | BGR顺序 |
| 32位 | 4字节 | 无 | 多一字节透明或保留 |
这张表不是背的,是用来“对答案”的。拿到一个BMP,打开UltraEdit,先看biBitCount字段,比如显示08 00,再跳去看bfOffBits,发现像素偏移确实比54大很多,那就可以确认是8位调色板图。从速查表反推文件结构,比从头猜高效得多。
4.3 compression字段:RLE压缩长什么样
前面一直假设压缩方式是0,也就是BI_RGB。但BMP也支持游程编码,字段值1是BI_RLE8,2是BI_RLE4。这种压缩常见于老的16色或256色图像,结构会比较绕人:压缩流由若干“控制对”组成,每对两个字节,第一个表示连续像素数量,第二个表示颜色值或动作。比如05 0A表示连续5个点都使用索引0x0A,00 02 03 00表示跳到第3列继续画。
在UltraEdit里识别压缩BMP,主要是看偏移30处的4字节。如果是四字节全是0,就是未压缩,直接按整块读像素;如果是01 00 00 00或02 00 00 00,说明用了RLE压缩,这时biSizeImage对应的像素数据区不再是原始位图数据,而是一串控制指令。新手刚接触时不建议立刻深挖RLE解码,先把未压缩的BMP玩熟,知道这类标记存在即可。
5. 实测里最容易踩的坑:小端读反、补位算错和替换误伤
5.1 小端序:别把文件大小数成天文数字
小端序是Windows上常见的数据存储方式,含义是数字的低位字节写在内存低地址处。用1x1示例来看,文件大小是58,也就是十六进制0x3A,存储为3A 00 00 00。如果把它当成大端序读,就成了0x3A000000,十进制是973078528,完全是个天文数字。第一次只会栽在这里的人非常多。
这句话说得直白一点:在UltraEdit里看到一个32位字段的4个字节是XX YY ZZ WW,解析成数字应该是WW ZZ YY XX的排列,也就是从右往左读回权值。文件大小、宽度、高度、偏移量,全是这个规则。只有分组牢记这一条,后面手工计算才不会翻车。
5.2 行补位:每行字节数必须对齐到4的倍数
BMP行存储有个硬性规则:每行像素数据的总字节数必须是4的倍数。这个规则对24位图极其重要。假设图像宽度是1像素,每行原始像素数据是3字节,但实际存储行大小是4字节,最后一个字节是填充。假设宽度是2像素,每行6字节,对齐后变成8字节,多两个填充。宽度是3像素,每行9字节,对齐后是12字节。
很多人计算像素区大小时会直接用“图像宽度 × 3字节 × 图像高度”,结果总比文件的实际大一点或少一点,就是因为没算行补位。对于24位图,行大小可以用公式:每行字节数 = (宽度 × 3 + 3)÷ 4 × 4,除法取整。宽度如果是2,那每行是6字节,6加3再整除4得到2,再乘4等于8。这才能和文件里看到的像素区大小对上。
5.3 二进制替换可能把文件头一起改坏
Windows位图手工改图时,经常有人在UltraEdit里用查找替换功能,比如想把图片中的某种颜色值全部换成另一种。这种方式极容易殃及文件头或调色板,因为二进制查找并不会识别“当前处在哪个区块”。比如查找00 00 FF去替换颜色,文件头里如果恰好也有这几个字节,就会被顺手改写,结果是整张图打不开。
更稳妥的做法是:先在UltraEdit里用Ctrl+G跳到bfOffBits指定的像素区起点,选中范围,再考虑批处理。或干脆不用全局替换,而是按地址范围编辑。实在需要批量处理时,可以临时把调色板或文件头保护起来,或者直接写一段小脚本按偏移区间操作。手工编辑前,备份原始文件是另一个多花一秒钟就能救命的习惯。
6. 解析能力怎么落地:手工改色、脚本校验与日常排障
6.1 实战:把1x1红点改成蓝点
拿第3节的1x1示例继续操作。在UltraEdit里跳到偏移54,看到00 00 FF 00。红色在文件里对应BGR格式,也就是蓝字节00、绿字节00、红字节FF。想改成蓝色,就把顺序改成FF 00 00 00,保存后用看图软件重新打开,红点就变成蓝点。
整个过程不需要任何图像处理软件,一个十六进制编辑器就够了。进一步尝试:把宽度和高度字段从01 00 00 00改成02 00 00 00,同时把像素区数据扩展成两行,就能手工拼出一张2x1的渐变图。虽然麻烦,但对理解文件格式是极好的练习,能让人彻底摆脱“图像文件等于黑盒”的思维定式。
6.2 用一段简单脚本固化解析逻辑
手工读一遍之后,可以用脚本把逻辑固化下来。拿Python举例,核心解读代码非常短:
import struct with open("sample.bmp", "rb") as f: data = f.read() signature = data[0:2] file_size = struct.unpack("<I", data[2:6])[0] data_offset = struct.unpack("<I", data[10:14])[0] width = struct.unpack("<i", data[18:22])[0] height = struct.unpack("<i", data[22:26])[0] bit_count = struct.unpack("<H", data[28:30])[0] compression = struct.unpack("<I", data[30:34])[0] print("签名:", signature) print("文件大小:", file_size) print("像素偏移:", data_offset) print("图像尺寸:", width, "x", height) print("位深:", bit_count) print("压缩方式:", compression)这个脚本没什么魔法,就是把UltraEdit里人工判断的步骤用代码重写了一遍。<I表示按小端序解析无符号32位整数,<i表示有符号32位,<H表示小端16位。跑一次就能看到,脚本输出的偏移量和一张正常BMP的bfOffBits完全一致。解析脚本的价值在于:面对成百上千张BMP时,不会再手工一张张看。
6.3 这段解析能力能用到哪
学会BMP结构,直接受益的是排查丢图事故。图片打不开时,先看文件头两个字节是不是42 4D,不是就说明文件坏了或根本不是BMP。再看文件大小字段和实际大小是否吻拼,差异大的往往是文件被截断或追加了垃圾数据。在嵌入式开发和图像采集相关场景中,BMP数组经常被按裸数据直接写入,此时的文件又可能没有完整头部;这时候用UltraEdit打开,找像素数据的规律反而比依赖标准头更实际。
我自己目前的固定动作是:任何可疑文件先拖进UltraEdit,看前几十个字节里有几个字段能解释通。如果解释得通,再按偏移解析;如果前54字节完全是垃圾,那文件基本没救了。这个方法不单适用于BMP,JPEG、PNG、TIFF的排查思路同样如此,只是字段定义不一样。
聊到这儿,BMP文件的内部结构已经被拆得差不多了。起初我也觉得这些二进制知识很“底层”,实际工作中未必用得上,但后来发现,判断一个文件到底是不是位图、有没有损坏、为什么程序读出来颜色错乱,拼到最后全是这些底层字节的基本功。尤其是用UltraEdit手工改过一个像素之后,再看任何格式文档都能更快进入状态,因为你已经知道“地址、偏移、字节序”这几个关键词究竟对应编辑器里的什么内容。最后分享一个小技巧:遇到结构不明的二进制文件,先别急着查资料,把它当作BMP来试读一遍,先把bfSize和bfOffBits比对一下,很多文件的轮廓就已经自己跳出来了。