手撕BMP:用UltraEdit逐字节解析24位位图结构 很多朋友一听“手撕BMP”就觉得头大觉得这是底层图形学、嵌入式驱动才需要碰的东西。但我自己这几年调试图像显示、写文件解析工具、甚至只是排查一张图片为什么颜色发紫最常干的一件事就是把BMP文件拖进UltraEdit一字节一字节对着十六进制看。这种看似原始的笨办法反而能解决很多代码层看不出来的诡异问题。BMP作为Windows标准位图格式核心结构就三块文件头、信息头、像素数据。难吗不难。但很多人拿着文档还是对不上号就是因为没在真实文件里亲眼看过这些字节。这篇文章我就拿UltraEdit从头到尾拆一个实际的24位BMP顺带把8位调色板、RLE压缩、行补齐这些容易踩坑的点一起说清楚。适合刚开始学文件格式解析的人也适合那些图像库用到BMP但总出幺蛾子、想彻底搞明白底层原因的人。1. 为什么我一定要用UltraEdit去看BMP而不是直接扒代码1.1 BMP这种格式天生就适合“肉眼解析”我特别喜欢BMP的一个原因它的结构极其朴素朴素到几乎没有任何花哨机制。不像PNG那样有IHDR、IDAT、IEND这些chunk也不像JPEG那样有SOI、APP0、DQT各种段。BMP的头部信息是固定偏移的前14字节是BITMAPFILEHEADER接着40字节或更长的变体是BITMAPINFOHEADER再往后就是调色板和像素数组。这意味着什么意味着你可以把文件从第0字节开始按偏移量一个个字段去读自己都能推算出每个字段的含义。对于想真正理解文件格式的人来说这是最理想的入门教材。我经常打一个比方BMP像一本格式固定的说明书每个字段的位置是提前写死的你只要照着页码翻就行。而很多其他格式像流式协议字段之间要靠标识符去“猜”。BMP这种“所见即所得”的特性配合UltraEdit的十六进制视图效果特别好。1.2 UltraEdit在十六进制查看上到底强在哪市面上能看十六进制的编辑器不少但UltraEdit有几个细节让我用起来很舒服偏移量显示得清楚。UltraEdit左侧会自动标记每一行的偏移地址对照文档里的字段偏移直接就能定位。支持以16字节为一行的视图读DWORD、WORD结构特别方便。BMP的头部字段基本就是4字节、2字节对齐排列的这种一行16字节的布局天然匹配。可以做到“边看边改”。确认某个字节是干什么的之后直接切到十六进制编辑模式改数值、保存再用图片查看器验证效果。整个过程非常顺手。查找定位快。搜索特定十六进制序列时UltraEdit能快速跳到位置比如直接搜索28 00 00 00来找信息头。如果你只是写代码调用库函数那你永远看不到文件的“真身”。而用UltraEdit直接看结构的逻辑感会瞬间建立。等到你自己写解析器时脑海里会自然浮现出字节偏移和字段名而不是对着文档瞎猜。1.3 解析前的准备脑子里先画好一张字节地图动手之前我先习惯性地在纸上画一张结构草图把三个关键区域列出来0x00 到 0x0D文件头14字节包含“BM”标识、文件大小、像素数据偏移。0x0E 开始信息头常见为40字节包含图像宽度、高度、位深、压缩类型、图像数据大小。信息头之后调色板仅8位及以下位深需要然后才是真正的像素数据。这张地图不用背但每次解析时我都会对照着看。你会发现所谓解析BMP其实就是“从头到尾按表格逐项填空”的过程。先用UltraEdit把文件打开再按这张图一点点验证比任何教程都管用。2. 从头文件到像素区BMP内部结构的字节级地图2.1 文件头14字节里藏着文件和像素的“门牌号”我先说文件头这在BITMAPFILEHEADER结构里定义了从偏移0x00开始偏移大小字段名典型值含义0x002bfType0x4D42固定标识ASCII码就是“BM”0x024bfSize0x0000004E整个文件大小小端存储0x064bfReserved10保留必须为00x0A4bfOffBits0x00000036像素数据相对文件头的偏移这里有两个关键点新手最容易忽视频。一个是小端字节序。BMP所有多字节整数都是小端存储也就是低字节在前。比如文件大小0x0000004E实际字节序就是4E 00 00 00而不是00 00 00 4E。用UltraEdit看时你必须主动把字节反着读否则数值全错。另一个是bfOffBits。这个字段代表像素数据在文件中的绝对偏移不是从信息头开始偏移而是从文件第0字节开始算。它的存在是因为信息头可能不是标准的40字节比如老式的BITMAPCOREHEADER是12字节扩展的BITMAPV4HEADER是108字节V5是124字节。如果写文件的工具用了非标信息头但你依然按“1440”去猜像素位置肯定翻车。正确的做法永远是读bfOffBits而不是拍脑袋算。2.2 信息头宽度、高度、位深和压缩类型全在这里信息头从偏移0x0E开始最常见的BITMAPINFOHEADER占40字节。我用表格拆一下它包含的关键字段偏移大小字段名说明0x0E4biSize本结构体大小标准为0x28即400x124biWidth图像宽度单位像素0x164biHeight图像高度正值代表自底向上负值代表自顶向下0x1A2biPlanes颜色平面数固定为10x1C2biBitCount位深1、4、8、16、24、320x1E4biCompression压缩类型0为不压缩1为RLE82为RLE40x224biSizeImage图像数据大小不压缩时可填00x264biXPelsPerMeter水平分辨率0x2A4biYPelsPerMeter垂直分辨率0x2E4biClrUsed调色板实际使用的颜色数0x324biClrImportant重要颜色数0表示全部重要这里我特别提醒biHeight符号的问题。很多人在做BMP解析时会把它当无符号整数处理结果解析出来的图像上下颠倒。BMP的规则是如果biHeight是正数那么文件里的像素数据第一行是图像的最后一行也就是自底向上存储如果biHeight是负数数据才是自顶向下存储。很多工具软件保存BMP时用的数值不同解析前一定要先判断正负。还有biBitCount常见值里面24和32最好理解每像素分别占3和4字节。8位及以下就需要调色板。而16位是最容易被忽视的它在不同版本的信息头里像素格式可能完全不一个样有可能是RGB555也有可能是RGB565甚至可能存在掩码定义。2.3 像素数据的“地板规则”为什么每行要按4字节对齐很多第一次手写BMP解析器的人会被“行补齐”搞崩溃。BMP规定每行像素数据的字节数必须是4的倍数。如果一个24位位图宽度是3像素那每行3乘3等于9字节这时必须在每行末尾补3个0字节让行字节数变成12字节。这个补齐算法的标准公式是每行字节数 ((宽 × 位深 31) / 32) × 4注意这里全是整数除法。结合我前面那句话理解先向上取整到32位再转成字节数。举个例子宽度5像素、24位位图(5 × 24 31) / 32 4 4 × 4 16也就是说5像素宽的24位BMP每行占16字节其中前15字节是真正像素数据最后多出1字节是填充0。这类填充字节不计入有效像素但必须存在于文件中否则图像显示就会错行。2.4 调色板8位及以下位深的“颜色查表”调色板紧跟在信息头之后在像素数据之前。每项固定4字节顺序是蓝、绿、红、保留字节也就是BGR0。注意A是没有的第4字节永远是0。对于8位BMP像素数据里每个字节存的是调色板索引渲染时需要拿这个索引去调色板里查找颜色。调色板项数由biClrUsed决定如果biClrUsed为0则默认是2的biBitCount次方项比如8位图是256项。调色板总大小就是256乘以4等于1024字节。4位图调色板是16项1位图是2项。所以看到像素偏移不是简单的54字节而可能在1078之类的位置不用惊讶这是把调色板的大小也算了进去。3. UltraEdit实操手把手把一个24位BMP拆到字节3.1 准备样本文件一个4×2、24位的BMP废话少说直接上干货。我构造了一个4×2、24位色的BMP文件像素数据只有24字节整个文件只有78字节非常适合教学。你完全可以把下面这段十六进制内容复制下来存成二进制文件重命名为test.bmp再打开42 4D 4E 00 00 00 00 00 00 00 36 00 00 00 28 00 00 00 04 00 00 00 02 00 00 00 18 00 00 00 00 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 FF 00 00 00 FF 00 00 00 FF FF FF 00 FF FF FF 00 00 00 80 80 80 FF 00 FF保存时请注意每行字节连在一起中间不要有空格或换行符混入实际字节。如果操作不方便也可以用任意图像编辑器生成一张4像素宽、2像素高的24位BMP后续字段原理一样。这个文件虽然小但五脏俱全。3.2 用UltraEdit打开后从0x00开始核验用UltraEdit打开这个文件把视图切换到十六进制模式。默认情况下左侧是偏移量中间是十六进制字节右侧是ASCII显示。我建议把视图选项设置成每行16字节看起来更直观。从偏移0x00开始第一个字节是42第二个是4D。这是ASCII码“BM”也就是BMP的类型标识。紧接着的4E 00 00 00是小端整数0x0000004E也就是78。正好等于文件总字节数。偏移0x0A处的36 00 00 00是0x36等于54。说明像素数据在文件偏移54字节处开始。看到这里你会意识到文件头信息其实都是“自描述”的。它自己告诉了你文件有多大、像素数据在哪。不用任何额外元数据。3.3 拆解40字节的信息头继续从0x0E开始读。前四个字节28 00 00 00是0x28也就是40说明这是标准的BITMAPINFOHEADER。如果你的解析代码里写死“信息头一定是40字节”那看到这个值是对的。紧接着04 00 00 00宽度4 02 00 00 00高度2 18 00位深24 00 00压缩0 18 00 00 00图像数据大小24我这里把图像数据大小填成了实际值24但很多软件会填0。如果填0解析器通常也不受影响因为像素数据大小可以根据宽度、高度、位深和行补齐算出来。信息头里其余字段全是0分别代表水平/垂直分辨率、调色板颜色数、重要颜色数。因为这里是24位图不需要调色板所以后面两个颜色数字段写0完全合理。3.4 像素数据到底怎么读像素数据从偏移0x36开始。前12字节FF 00 00 | 00 FF 00 | 00 00 FF | FF FF 00这是第一行4个像素。注意每像素3字节顺序是B、G、R不是R、G、B。FF 00 00蓝色因为蓝色通道全亮红绿为0。00 FF 00绿色。00 00 FF红色。FF FF 00B和G都满R为0这是青色。第二行12字节FF FF FF | 00 00 00 | 80 80 80 | FF 00 FF对应的颜色是白色、黑色、80灰、洋红。你把这个文件用图片查看器打开会看到上半部分是第二行的颜色下半部分才是第一行的颜色这是因为高度为正数据自底向上排列。很多新手以为文件里第一行像素就是图像第一行这是大坑。如果你希望数据顺序自然可以在构造文件时把biHeight设为负数比如FE FF FF FF代表-2。3.5 动手修改改一个字节看颜色变化现在你可以做个实验在UltraEdit中把第一行第一个像素的字节从FF 00 00改成00 00 FF保存重新打开图片。你会发现那个像素从蓝色变成了红色。就这么直接。这个小实验的价值在于图像颜色变化能让你把“文件字节”和“屏幕显示”真正对应起来。当你理解了BGR三个通道的位置以后遇到颜色偏蓝偏红的问题第一反应就会检查字节序而不是瞎改显示代码。4. 位深、调色板与RLE压缩解析逻辑在哪些地方变了样4.1 8位索引位图字节不再是颜色而是索引前面那个24位的例子每个像素字节直接就是颜色分量。但8位图逻辑完全变了。它每个像素占1字节但这1字节不是颜色而是调色板索引。一个8位BMP的头部结构通常是文件头14字节。信息头40字节。调色板1024字节256色 × 4字节。像素数据从偏移0x436也就是1078开始。用UltraEdit打开一个8位图你会看到信息头之后有一大段按B G R 00排列的调色板表。比如00 00 FF 00意思是索引0对应红色。然后像素数据里出现00就代表这个像素用索引0的颜色来渲染。这里有个特别容易踩的坑调色板项数不一定等于256。有的文件biClrUsed只写16那调色板就只有16项但索引数据仍然可能用到0到15之外的索引值这时候行为就未定义了。解析时一定要优先看biClrUsed。4.2 4位和1位位图一个字节要拆成两个/八个像素4位BMP更反直觉。它每个字节里存两个像素高4位是第一个像素的索引低4位是第二个像素的索引。1位BMP更夸张一个字节里存8个像素按位从左到右排列。举个例子一个1位BMP的像素字节如果是A5二进制就是10100101那么它表示8个像素的索引依次是1、0、1、0、0、1、0、1。这种位级数据的解析不能简单按字节乘位深算行宽必须结合行补齐一起算。比如宽度为6像素的4位图每行像素占3字节但6像素总共只需要3字节且3字节已经是4的倍数不用补。而宽度为5像素的4位图每行需要2.5字节向上取整到3字节正好是4的倍数也不用补。宽度为7像素的4位图每行3.5字节向上取整到4字节就必须补到4字节。这种零碎计算很容易出错所以我建议动手前先用UltraEdit观察文件的每行起始位置是否相差一个固定值。如果是原始不压缩数据每行像素区起始位置应该是严格等距的。如果发现不等距就要怀疑前面行数据里混进了填充字节。4.3 RLE压缩biCompression不是0时别按固定偏移硬读很多BMP文件为了节省体积会使用BI_RLE8或BI_RLE4压缩。这时候biCompression字段不再是0而是1或2。RLE8压缩的基本码流是两字节一组第一个字节代表后续连续的长度第二个字节代表颜色索引。比如04 0A表示连续4个像素都用调色板中的第10号颜色。特殊指令由第一个字节为0开头00 00行结束。00 01位图数据结束。00 02 dx dy光标向右移动dx像素向下移动dy像素。00 n data...紧跟n个原始像素索引当n为奇数时补一个字节对齐。所以在解析压缩BMP时行长度完全不能用宽度去算必须按码流逐步解码。你用UltraEdit看压缩后的像素区会看到大量00 01、00 02这类控制序列而非整齐的像素数据。不过实际应用中不压缩的BMP占绝大多数。你只需要先看biCompression字段为0就按普通方式读为非0才进入RLE解码逻辑。很多解析库只支持非压缩BMP就是因为它实现简单遇到RLE就直接摇头。4.4 16位和32位掩码和透明通道16位BMP的情况比较混乱。在BITMAPINFOHEADER下常见的16位格式是RGB555或者RGB565也就是5位红、5位绿、5位蓝或5位红、6位绿、5位蓝。但具体是哪种从标准40字节信息头里看不出来除非文件里附带颜色掩码。如果你遇到的是BITMAPV4HEADER或BITMAPV5HEADER那么信息头后面会有专门的颜色掩码字段分别指定R、G、B通道占哪些位。这时候偏移计算就变得很重要因为信息头变成108或124字节像素数据偏移必然后移。只要始终读取bfOffBits字段就不会出错。32位BMP通常每个像素4字节多出来的字节可能是Alpha通道也可能只是填充0。要看biCompression是否等于4或5对应BITFIELDS压缩格式。如果等于则信息头后面会跟着3个或4个DWORD掩码。5. 解析BMP时容易翻车的几个坑排查速查表5.1 文件尺寸和文件头对不上我见过最多的解析失败原因是文件大小算错了。有人直接拿宽度 × 高度 × 每像素字节数去算整个文件大小却忘了行补齐。举例宽度为3像素、高度为3像素的24位BMP。如果忽略行补齐像素数据大小是3 × 3 × 3 27字节但BMP实际要求每行补到12字节于是像素数据大小是12 × 3 36字节文件总大小是54加36等于90字节而不是81字节。用UltraEdit查看文件末尾你会发现确实比“理想值”多出9个填充字节。5.2 图像上下颠倒、左右镜像其实左右镜像相当少见主要是上下颠倒常见。根源就是我前面说的biHeight正负。你从UltraEdit的十六进制数据里如果看到文件底部高偏移处是图像顶部数据那说明biHeight为正。想要解析成自顶向下显示一种办法是把biHeight读出来做绝对值并且把像素行顺序反转。也可以直接在读取时把文件指针从最后一行开始往前读。5.3 RGB和BGR的通道顺序误解我当年第一次手写BMP渲染图片里原本应该是红色的部分全变成了蓝色查了半天才发现BMP像素顺序是BGR。这个问题在UltraEdit里看得尤其明显。你修改字节时如果想把某个像素改成纯红必须写00 00 FF而不是FF 00 00。24位和32位BMP尤其要注意8位索引图反而没有这个问题因为它查表是按调色板走的。5.4 信息头大小不是40字节的变体有的BMP文件不是用BITMAPINFOHEADER而可能用BITMAPCOREHEADER信息头只有12字节。这种情况下位深、宽度、高度的偏移全变了甚至“BM”之后的文件头也可能有细节差异。用UltraEdit解析时如果发现bfOffBits与14加40不一致比如等于26或者94先检查bfSize之后、信息头开头处的biSize等于多少。12老式格式。40标准。108V4。124V5。不要让自己的解析代码只认40。5.5 调色板数量不等于2的位深次方虽然标准规定8位BMP默认有256色调色板但有些优化过的文件只用了一部分颜色调色板项数不到256。这时像素数据偏移也会相应变化但bfOffBits字段仍然会指出正确位置所以顺手用UltraEdit跳到bfOffBits看一眼就能确认调色板实际占多大。5.6 用UltraEdit检查这些坑的小技巧我在实际解析时不管屏幕上写了多少代码都会先用UltraEdit做一次“字段冒烟测试”跳到bfOffBits看那里是不是刚好是像素数据的起点再跳到文件末尾数一下距离bfOffBits的字节数看看和行宽乘高度是否吻合。这套检查最多一分钟却能省掉后面调试的大把时间。另外把UltraEdit的十六进制视图列宽调成16字节后多字节整数会呈现“低字节在前”的排列特征。看到36 00 00 00这类结构你就条件反射知道这是0x36而不是0x36000000。这个手感很重要。6. 拆完BMP后我积累的一点心得如果你之前只靠文档和代码去理解BMP我强烈建议你亲手在UltraEdit里改一个字节试试。改几个像素然后把图像重新打开那种“字节即像素”的直接感比看十遍文档都深刻。我自己后来学PNG、ICO、甚至某些私有私有格式时第一件事一定是用UltraEdit打开看原始字节而不是急着找解析库。因为二进制格式再复杂文件里总会有蛛丝马迹。只要先看文件头标识、再找到数据偏移、最后分析数据排列这套方法论几乎能套用到所有格式上。BMP是个特别理想的练手对象结构简单、资料多、出错了也容易反查。如果你能把BMP从第一个字节到最后一个字节都说出用途那以后再面对其他图像格式你就有了从容的本钱。去看里面那些零零散散的十六进制你会慢慢发现文件格式的真相永远藏在字节本身里。