PE文件格式精讲:从DOS头到节表与地址换算实战

发布时间:2026/10/20 3:21:57
PE文件格式精讲:从DOS头到节表与地址换算实战
研究PE文件格式的人大概率是被调试、逆向或者系统底层开发逼到这一步的。我也不例外。之前遇到一个动态链接库在目标机器上加载失败翻来覆去查依赖、查运行库最后才发现问题出在加载器解析一个标志字段上。从那时候起我就把PE格式当作系统编程的必修课。这篇文章是“PE文件格式详细解析”系列的第一篇先把整体骨架讲清楚它解决什么问题、头部每个字段干什么用、节表怎么读、地址怎么换算最后再实际拆一个文件。零基础的同学跟着走一遍也能看懂有基础但总觉得知识碎片化的同学这一篇能帮你把线索串起来。1. 先搞清楚PE要解决什么问题把一个磁盘文件变成运行中的进程1.1 加载器这道“工序”到底在做什么双击一个exe操作系统并不会像读普通数据文件那样把整个文件原样读进内存。真正的流程要复杂得多内核打开文件读取PE头解析出各个节的信息然后在进程的虚拟地址空间里按“节”分配内存把文件的各个部分映射到对应位置最后定位到入口点把控制权交给程序。这个过程中加载器只认PE头里写好的“图纸”。图纸上画了文件包含几个节、每个节该放哪个虚拟地址、入口点在哪、依赖哪些外部函数。如果图纸标错程序就起不来如果加载器读错程序同样起不来。所以理解PE格式本质上就是理解加载器这套工序的输入约定。PE的全称是Portable Executable直译叫“可移植的可执行文件格式”。这个名字挺有意思它想强调的是“不绑定某个特定CPU架构或Windows版本”。从最早的x86到后来的x64、ARM64Windows都沿用同一套PE框架差异主要落在可选头Optional Header里的一个叫Magic的字段上。可以说只要搞懂了框架换架构只是换参数。1.2 为什么这个问题值得专门研究我不太建议把PE格式当作“面试背题”来学它的价值在实战里会反复出现。比如调试一个崩溃的程序调试器停在模块加载阶段你需要知道要去看依赖表比如写一个补丁程序你要把补丁代码塞进某个节首先要算清楚RVA和文件偏移再比如做程序依赖分析、启动速度优化、甚至给某工具做体积优化都逃不开对PE头的解读。哪怕你只是日常写业务代码了解PE也有实际帮助。链接器选项里的“节对齐”、“合并节”、“固定基址”本质上都是在操作PE结构编译器为什么生成那些奇怪的节名也是因为COFF段名合并规则。这些以前觉得是“编译原理黑话”的东西一旦你打开十六进制看过一遍就全对上了。2. 从头开始读DOS头与DOS Stub老兼容层里藏着的关键机关2.1 四十多年的MZ头只有两个字段真正重要PE文件的前64个字节是IMAGE_DOS_HEADER也就是DOS头。它的前两个字节固定是4D 5A对应ASCII字符的M和Z所以这个头也叫MZ头。这两个字母不是随便来的它是当年DOS可执行格式的两位设计者的名字缩写一直保留到今天。任何Windows可执行文件exe、dll、sys、ocx都必须以这两个字节开头这也是判断一个文件是不是PE最直接的方法。DOS头里字段非常多但今天的Windows加载器真正用到的其实很少。e_ss、e_sp、e_ip这些字段是当年DOS下的栈段和入口点信息现在纯粹是为了兼容保留。真正有实际意义的是一个字段偏移0x3C处的e_lfanew它是4字节的LONG类型保存了从文件开头到NT头的偏移量。我给大家一个最低限度的DOS头结构示意图方便对照着看typedef struct _IMAGE_DOS_HEADER { WORD e_magic; // 0x00 MZ固定 0x5A4D WORD e_cblp; // 0x02 文件中最后扇区的字节数 WORD e_cp; // 0x04 文件扇区数 WORD e_crlc; // 0x06 重定位表项数 WORD e_cparhdr; // 0x08 头部大小单位是“段” WORD e_minalloc; // 0x0A 最小分配内存 WORD e_maxalloc; // 0x0C 最大分配内存 WORD e_ss; // 0x0E 初始栈段 WORD e_sp; // 0x10 初始栈指针 WORD e_csum; // 0x12 校验和 WORD e_ip; // 0x14 初始指令指针 WORD e_cs; // 0x16 初始代码段 WORD e_lfarlc; // 0x18 重定位表偏移 WORD e_ovno; // 0x1A 覆盖号 WORD e_res[4]; // 0x1C 保留字段 WORD e_oemid; // 0x24 OEM标识 WORD e_oeminfo; // 0x26 OEM信息 WORD e_res2[10]; // 0x28 保留字段 LONG e_lfanew; // 0x3C 指向NT头的文件偏移 } IMAGE_DOS_HEADER;我这些年看PE文件已经形成了一个肌肉记忆拿到文件先看前两个字节是不是4D 5A然后直接跳到0x3C读四个字节得到e_lfanew再跳到那个偏移验证NT头。这个流程一分钟都用不了却是整个PE解析的起点。2.2 DOS Stub一段老代码和一句容易吓到新手的字符串从DOS头结束位置0x40到e_lfanew指向的位置之间夹着一段叫DOS Stub的小程序。它诞生的背景是当年DOS系统还活着当你在DOS下运行一个Windows程序时程序不能直接启动而是显示一句提示或执行一段简单逻辑然后退出。这段逻辑就是DOS Stub。在传统的32位PE里DOS Stub通常包含一行ASCII字符串“This program cannot be run in DOS mode.”。很多人第一次用十六进制编辑器打开exe在MZ后面一两百字节处看到这句话会以为发现了什么神秘信息其实那只是DOS Stub的一部分。注意一点不同编译器、不同链接器生成的DOS Stub长度不一样所以e_lfanew的取值不是固定的。有的文件是0x80有的是0x100不能照抄别人的偏移。这也是为什么必须老老实实去读e_lfanew而不是猜一个固定值。我有一次排错就遇到过这种情况某个加固工具把DOS Stub改得很长e_lfanew被推到很远的位置如果拿着固定偏移去解析后面全乱套。3. 真正的门牌号NT头的Signature、文件头与可选头3.1 Signature与File Header先验明正身再看清单在e_lfanew指向的位置开始了真正的NT头IMAGE_NT_HEADERS。这个名字里的NT对应的是Windows NT那套内核设计一直沿用至今。NT头由三部分组成一个4字节的Signature一个20字节的FileHeader以及一个不定长但绝对必不可少的OptionalHeader。Signature固定是0x00004550对应ASCII字符“PE\0\0”。这四个字节的排列顺序是50 45 00 00。如果你跳到e_lfanew指向的位置看到的不是这四个字节那就要警惕了要么e_lfanew读错了要么文件被截断过要么这个文件根本就不是标准的PE。紧跟Signature的IMAGE_FILE_HEADER一共20字节真正决定文件性质的关键字段就这么几个字段偏移说明Machine0目标CPU架构0x014C是x860x8664是x640xAA64是ARM64NumberOfSections2节表条目数量解析节表时要用SizeOfOptionalHeader16紧随其后的OptionalHeader大小定位节表时要用Characteristics18文件属性标志位比如0x2000表示DLL0x0002表示PE32位文件我特别强调两个字段NumberOfSections和SizeOfOptionalHeader。前者告诉你后面节表有多少条记录后者告诉你OptionalHeader实际占了多少字节。这两个值是“动态”的不能想当然用固定结构体大小去算。尤其是SizeOfOptionalHeader正常情况下32位是224字节64位是240字节但有些工具链会写入自定义大小如果你的解析代码直接用sizeof(IMAGE_OPTIONAL_HEADER32)硬跳后续所有字段就全部错位了。3.2 OptionalHeader名字叫Optional但没有它加载器寸步难行接下来是IMAGE_OPTIONAL_HEADER。严格来说这个字段名有点误导它在PE里绝不是可选的当年从COFF格式继承名字时带了一个“Optional”实际上加载器每一步都离不开它。它区分两种形态32位的PE32Magic字段为0x10B64位的PE32Magic字段为0x20B。解析这个头时第一步永远都是读Magic再决定后面按哪种结构解。两种形态字段顺序完全一致区别在于64位版本把ImageBase、栈/堆保留大小等字段从4字节扩展到了8字节。这就意味着同一个字段在32位和64位文件里的偏移不同如果你用错结构体读出来的ImageBase、入口点全是乱的。下面给出32位OptionalHeader的核心字段示意typedef struct _IMAGE_OPTIONAL_HEADER32 { WORD Magic; // 0x10B PE320x20B PE32 BYTE MajorLinkerVersion; BYTE MinorLinkerVersion; DWORD SizeOfCode; // 代码节总大小 DWORD SizeOfInitializedData; DWORD SizeOfUninitializedData; DWORD AddressOfEntryPoint; // 入口点RVA程序启动的第一条指令 DWORD BaseOfCode; DWORD BaseOfData; DWORD ImageBase; // 建议加载基址实际可能被ASLR重定位 DWORD SectionAlignment; // 内存中节对齐粒度通常0x1000 DWORD FileAlignment; // 文件中节对齐粒度通常0x200 WORD MajorSubsystemVersion; DWORD SizeOfImage; // 加载后整个镜像的内存大小 DWORD SizeOfHeaders; // 所有头DOS头NT头节表占用的文件大小 DWORD CheckSum; WORD Subsystem; // GUI程序还是控制台程序 DWORD NumberOfRvaAndSizes; // DataDirectory数组的条目数 IMAGE_DATA_DIRECTORY DataDirectory[16]; // 导入表、导出表、资源表等的地址目录 } IMAGE_OPTIONAL_HEADER32;在这堆字段里有几项必须重点关注。AddressOfEntryPoint是整个程序的入口点RVA也就是CPU执行第一条指令的位置调试器的“入口断点”靠的就是它。ImageBase是加载器优先尝试的基址但现代系统开了ASLR之后实际加载地址会变所以后续所有绝对地址都要通过ImageBase RVA来算不能直接拿ImageBase当真理。SectionAlignment和FileAlignment则解释了为什么同一个节在文件里和内存里大小不一样。最后那个DataDirectory数组是通往导入表、导出表、资源表、重定位表的钥匙后面几篇讲这些表的时候会反复用到它。4. 节表设计为什么文件里的布局和内存里的布局不是一回事4.1 节表一条记录决定了一个节的两副面孔从NT头结束的位置开始紧接着的就是节表Section Table。NumberOfSections字段指明了节表里有多少个条目每一个条目固定40字节数据结构是IMAGE_SECTION_HEADER。节表记录一个节在磁盘上的位置和它在内存中的位置。之所以说一节有两个“面孔”是因为文件的存储粒度和内存映射粒度不一样文件里一个节通常按0x200字节对齐内存里则按0x1000字节一页对齐。于是同一个节在文件里有文件偏移和文件大小在内存里有虚拟地址和虚拟大小两套数字经常对不上。typedef struct _IMAGE_SECTION_HEADER { BYTE Name[8]; // 节名8字节不一定以\0结尾 DWORD VirtualSize; // 加载到内存后该节的实际逻辑大小 DWORD VirtualAddress; // 该节在内存中的RVA DWORD SizeOfRawData; // 该节在文件中对齐后的大小 DWORD PointerToRawData; // 该节原始数据在文件中的偏移 DWORD PointerToRelocations; // COFF重定位偏移现代PE几乎不用 DWORD PointerToLinenumbers; // 行号表偏移调试用 WORD NumberOfRelocations; // 重定位项数 WORD NumberOfLinenumbers; // 行号项数 DWORD Characteristics; // 节属性标志位 } IMAGE_SECTION_HEADER;重点理解VirtualSize和SizeOfRawData的差别。某个节的VirtualSize是0x1234但SizeOfRawData可能因为0x200对齐变成了0x1400。加载器映射内存时逻辑上关心的是VirtualSize但如果VirtualSize比SizeOfRawData大多出来的部分在文件里没有对应数据加载器会把剩余空间填零。这就是为什么调试器里看到的节内存长度和文件里的节长度经常不一致。Characteristics字段也很关键它是一堆标志位常见的有0x40000000表示可读0x80000000表示可写0x20000000表示可执行0x00000020表示包含代码0x00000040表示包含已初始化数据0x00000080表示包含未初始化数据。从这里能粗略判断一个节的用途和可疑程度。如果某个节同时具备可执行和可写标志说明该节可能动态生成代码但这种判断要结合数据目录来看不能一看到可写可执行就喊狼来了。4.2 那些常见节名和它们背后的习惯节名本身并不是Windows强制规定的它更像一种行业习惯。主流编译器和链接器几乎都遵守同一套命名约定所以你会看到大量PE文件里都是这些名字。节名常见用途典型Characteristics.text可执行代码可读 可执行.data已初始化的全局变量、静态变量可读 可写.rdata只读数据、导入表、导出表、常量字符串只读.bss未初始化的数据文件里通常不占空间可读 可写.rsrc菜单、图标、版本信息等资源只读.reloc重定位信息ASLR需要用到只读这里有个细节节名数组是8字节但字符串并不保证以\0结尾。比如“.text”实际占6字节后面两个字节是0但有些节名正好8字节直接占满没有结尾符所以不能拿它当普通C字符串做字符串拼接。还有一个更隐蔽的坑很多链接器生成的中间节名带有$符号比如“.text$mn”这是COFF段合并规则的一部分编译器把所有名字相同前缀的节按$后面的字符排序合并最终才得到我们看到的.text节。5. 地址换算这件“翻译”活VA、RVA与文件偏移的手算实战5.1 三种地址一次说清PE解析里最容易把人绕晕的就是三种地址文件偏移、RVA、VA。它们之间的关系是递进的。文件偏移是文件内部从0开始的物理位置十六进制编辑器里看到的地址就是它。RVA也就是相对虚拟地址是从镜像基址开始的偏移量。VA则是进程虚拟地址空间里的绝对地址计算公式是VA ImageBase RVA。调试器里反汇编窗口给的都是VA但PE头里存的口点、节地址写的都是RVA。之所以不在PE头里直接写VA是因为DLL被加载进进程时实际基址往往不是ImageBaseASLR会把它挪走。RVA不依赖具体基址无论加载到哪里相对偏移都不变。所以要把磁盘上的字节和运行中的内存对上号就必须先把VA换算成RVA再把RVA换算成文件偏移。换算逻辑的核心是遍历节表。对每个节判断目标RVA是否落在区间[VirtualAddress, VirtualAddress 有效大小)内然后用下面这个公式文件偏移 PointerToRawData (RVA - VirtualAddress)这个公式的意思是先在节表里找到这个RVA属于哪一个节然后再算出它在这个节内部偏移了多少最后加上这个节在文件里的起始位置。整个映射过程其实就是把“内存版节内偏移”翻译成“文件版节内偏移”。所有判断区间的有效大小都要用max(VirtualSize, SizeOfRawData)因为如果VirtualSize比SizeOfRawData大节在内存里会延伸到文件数据之外如果SizeOfRawData比VirtualSize大则文件里多出来的部分可能并不会被完整映射。用max做边界判断最稳妥。头部区域的RVA不需要经过节表换算直接当作文件偏移用就行。因为头区在文件和内存中都是从地址0开始映射RVA小于SizeOfHeaders时文件偏移等于RVA。这也是为什么大多数解析代码会先判断RVA SizeOfHeaders是就直接返回RVA。5.2 手算一个例子把入口点RVA翻译成文件偏移我找一个经典例子你把数字代进去算一遍整个换算逻辑就通了。假设一个32位exeImageBase 0x00400000AddressOfEntryPoint 0x14A0。节表里只有一个.text节VirtualAddress 0x1000VirtualSize 0x2E000SizeOfRawData 0x2E200PointerToRawData 0x400。第一步判断入口点RVA 0x14A0落在哪个节。0x14A0在0x1000和0x1000 0x2E000之间所以它属于.text节。第二步套公式文件偏移 0x400 (0x14A0 - 0x1000) 0x400 0x4A0 0x8A0第三步验证。用十六进制编辑器跳到文件的0x8A0处看到的字节应该和运行程序时入口点处的机器码一致。我每次教人算PE地址都会强调这个验证步骤因为它能立刻检验你的换算有没有算错。算错的人通常有两个原因一是把ImageBase当成文件偏移的一部分多加了一个0x00400000二是没有判断RVA落在哪个节直接拿RVA减VirtualAddress就完事。另外补充一个实战场景当你要往文件中注入代码或者修改入口点时实际改的是文件偏移但你从调试器里看到的是VA。中间必须走这一趟换算。很多补丁工具的原理说白了就是先解析节表再做这一步翻译。6. 亲手拆一个PE文件从十六进制字节到结构化视图6.1 准备一个最干净的实验对象解析PE最忌讳一开始就拿一个带壳、带大量资源的复杂程序。资源多、节多、还有各种目录项容易把人绕晕。我的建议是用你手头的任意C/C开发环境写一个最简程序int main() { return 0; }编译成exe不需要任何特殊选项。生成的程序节表通常只有.text、.rdata、.data这几个干净到适合教学。如果你用的是64位编译器还能顺便看到PE32和PE32在文件里的差异。拿到这个文件之后先拷贝一份放在工作目录里用十六进制编辑器打开。如果没有特定的十六进制编辑器用那种能显示十六进制和ASCII对照的编辑器都行最好带“跳转到偏移”功能。6.2 跟着字节从头走一遍我按刚才讲的顺序把拆解流程完整列出来你可以对着自己的文件一步一步做。第一步看文件开头的两个字节应该是4D 5A。打开十六进制编辑器大概率第一行就是“4D 5A 90 00 03 00 00 00 04 00 00 00 FF FF 00 00”。前两个字节是MZ后面跟着的90 00和03 00都是DOS头里那些兼容字段。第二步跳到偏移0x3C处读连续4个字节。大多数情况下这个值是0x00000080或者0x00000100之类的数字注意十六进制编辑器里的字节顺序是小端四字节要倒着读。假设你看到的是80 00 00 00那e_lfanew就是0x80。第三步跳到0x80看四个字节应该是50 45 00 00也就是PE\0\0。到了这一步基本可以确认这是个标准PE文件。第四步从0x84开始解读IMAGE_FILE_HEADER。先看Machine字段两个字节0x8664就是x640x014C就是x86。然后记下偏移0x86处的NumberOfSections比如02 00表示有两个节。再记下0x94处的SizeOfOptionalHeader和Characteristics。第五步接着读OptionalHeader。先看Magic如果是0x10B就是32位0x20B就是64位。随后在相应位置找到AddressOfEntryPoint、ImageBase、SectionAlignment、FileAlignment、SizeOfImage、SizeOfHeaders等字段。用CFF Explorer这类PE查看工具对比一下你会发现工具显示的正是这些原始字节的解析结果。第六步定位节表。用NT头的起始偏移加上4字节Signature再跳过20字节FileHeader再跳过SizeOfOptionalHeader就是节表的起始位置。节表从这里开始每个条目40字节一共NumberOfSections条。对着第一条记录读出Name、VirtualSize、VirtualAddress、SizeOfRawData、PointerToRawData和Characteristics。第七步做一次实际的RVA换算。把OptionalHeader里的AddressOfEntryPoint当作RVA按第五章节表里的公式手动算一次文件偏移然后跳到那个偏移去看字节。你看到的字节就是程序第一条指令的机器码。这一步做完整个PE格式的核心逻辑就在你手里跑通了。6.3 几个我常翻车的坑提前帮你踩平第一个坑是用固定偏移定位e_lfanew。网上很多教程直接说“跳到0x3C看到0x80然后跳到0x80”但换成不同编译器生成的exee_lfanew可能就是0xF0、0x100甚至更大。一定要读文件内容不要背固定值。第二个坑是搞混节数量的单位。NumberOfSections是“条”不是“字节”。每条节表记录40字节如果你直接把5当成字节数去跳后面的数据就全错了。第三个坑是32位和64位结构混用。解析OptionalHeader前不看Magic直接按32位结构去读64位文件ImageBase等高8字节字段全部错位。解决办法就一句话先读Magic再选结构体。第四个坑是拿VirtualAddress当“节在文件里的偏移”。节表里PointerToRawData才是文件偏移VirtualAddress是内存RVA二者经常差得很远。很多人用十六进制编辑器找.data节内容结果去VirtualAddress那个位置找当然找不到。第五个坑是忘了头部区域可以直接用RVA当文件偏移。入口点如果落在头部区域很少见但存在套节表公式反而会算出错误结果。先判断RVA SizeOfHeaders再决定用哪个公式。第一篇先到这里。数据目录里的导入表、导出表、资源表、重定位表才是PE深度知识的主战场下一篇会专门展开。最后说句我的实操体会PE结构光看字段表是记不住的一定要找个小文件从第一字节开始对一遍再手算一次入口点的文件偏移。我第一次算对之后后面所有字段的映射关系都顺了如果你也希望把PE格式这只大象彻底摸清我建议你也从这一次手算开始。