
PE结构这个系列写到第8篇前面把DOS头、NT头、节表都过了一遍接下来最绕不开的就是PE对齐。我印象很深刚开始看PE文件时最让我懵的就是为什么文件里某个节的偏移明明是0x400而加载到内存后的RVA却是0x1000的倍数这两套数字怎么也对不上。后来才明白这背后就是两个关键字段在起作用FileAlignment文件对齐也叫磁盘对齐和SectionAlignment节对齐也叫内存对齐。这一篇就把这两个概念彻底掰开揉碎后面不管你是要解析导入表、处理重定位还是手写PE加载器都绕不开这套换算逻辑。先说清楚一个容易混淆的点标题里写的“文件对齐VS磁盘对齐”并不是两个独立概念标准文档里它们都叫FileAlignment中文社区只是习惯不同有人按“文件”理解有人按“磁盘存储”理解指的全是同一件事。真正形成对比关系的是文件/磁盘对齐与内存/节对齐这一对。PE文件在磁盘上是一串连续的字节加载到内存后又是一套全新的虚拟地址布局两套坐标系统之间的转换规则就是对齐。这篇文章适合所有刚接触PE结构的人也适合那些已经会看节表、但遇到RVA换算就头疼的读者。我会把对齐的数学定义、为什么默认值是0x200和0x1000、VirtualSize与SizeOfRawData的几种关系以及实际调试中怎么用公式换算一次讲全。1. 对齐的本质一个PE文件的两套坐标系统PE文件表面上就是一串字节但Windows加载器不会把这串字节原封不动地映射进内存。它要做一次“翻译”磁盘上的文件偏移File Offset是一套坐标加载后的虚拟地址RVA是另一套坐标。而对齐值就是这两套坐标各自使用的“刻度”。1.1 磁盘坐标与内存坐标先建立直观印象。磁盘上的PE文件从第一个字节开始数第0x00个字节是DOS头的第一个字节第0x3C个字节是e_lfanew字段这部分都是绝对偏移。加载到内存后整个文件映射到进程地址空间模块基址加上某个偏移量才是真正的内存地址。我们讨论RVA时说的是“相对于模块基址”的偏移。关键来了磁盘偏移和RVA虽然都是数字但它们的基准和刻度都不一样。比如一个节的VirtualAddress是0x1000并不表示它在文件里也位于偏移0x1000处。因为DOS头、NT头、节表加在一起可能只占0x400个字节第一个节的PointerToRawData通常就是0x400。如果非要按RVA 0x1000去文件里找数据你找到的其实是别的内容或者是一片对齐填充的空白。这就是为什么必须搞懂对齐。1.2 两个对齐字段在可选头中的位置这两个对齐值都存放在可选头IMAGE_OPTIONAL_HEADER里。32位和64位程序中字段偏移完全一致这一点倒是省了不少心。字段名在可选头中的偏移默认值作用对象常见叫法SectionAlignment0x200x1000 (4096)加载后的节起始RVA节对齐、内存对齐FileAlignment0x240x200 (512)磁盘上的节数据文件对齐、磁盘对齐用十六进制编辑器打开任意一个正常的PE把光标定位到可选头偏移0x20处读一个DWORD再往后4字节读第二个DWORD基本就是0x00001000和0x00000200。如果你拿到一个被特殊工具链处理过的文件这两个值可能不一样但只要符合规范就能被系统正常加载。1.3 为什么要对齐而不是紧凑排列很多人一开始会问为什么不把每个节按实际大小紧挨着排放这样文件不是能小很多吗答案在于性能和维护成本。磁盘按扇区管理、内存按页面管理文件系统的最小分配单元和内存映射单元都有自己的粒度。如果PE节不对齐加载器就无法把文件中的一个节直接映射到内存页面里只能逐字节拷贝、拼接加载速度会大幅下降。对齐到0x1000还有一个好处一个节刚好对应若干个内存页加载器可以用内存映射的方式直接把文件内容映射到虚拟内存省去数据搬运。用生活里的例子比喻书架上如果每本书都按厚薄随意摆放虽然省空间但外观混乱取书时要挨个看编号如果统一按固定高度分组摆放虽然可能留出一些空位但找书时扫一眼就知道在哪一区。PE的对齐就是这种“以少量空间换定位速度”的思路。2. 文件对齐磁盘对齐深入拆解FileAlignment控制的是节数据在磁盘文件里的存放粒度。它直接决定了节表中PointerToRawData和SizeOfRawData这两个字段的取值。2.1 FileAlignment的数学含义FileAlignment规定了两件事每个节的原始数据在文件中的起始偏移PointerToRawData必须是它的整数倍每个节的SizeOfRawData也必须是它的整数倍。换句话说节数据在磁盘上是从某个对齐边界开始占据的总长度也要向上取整到同一个边界。计算公式可以写成对齐后大小 (原始大小 FileAlignment - 1) / FileAlignment * FileAlignment这里因为FileAlignment是2的幂所以更常用的写法是按位运算对齐后大小 (原始大小 FileAlignment - 1) ~(FileAlignment - 1)举个例子。假设.text节的VirtualSize是0x1B8440字节FileAlignment为0x200512字节那么SizeOfRawData就是0x200。如果另一个节的VirtualSize是0x7661894字节向上取整到0x200的倍数就是0x8002048字节。也就是说磁盘上节尾巴后面至少要补一小段填充字节把长度补齐。2.2 为什么默认是0x200而不是1字节0x200正好是512字节这个数字有历史渊源。早期磁盘的扇区大小常以512字节为单位文件系统读写的最小单位也与之相关。PE格式设计时保留了这个粒度一方面是为了兼容既有的存储设备另一方面也是在空间利用率和寻址效率之间取一个平衡点。如果FileAlignment设为1也就是不对齐每次定位节数据都要用计算器重新算偏移操作系统做内存映射时也享受不到任何便利。如果FileAlignment设得太大比如0x1000那么每个节动辄浪费几百、几千字节一个包含5个节的程序光填充就可能多出几十KB资源浪费严重。0x200这个值既能让节起始位置保持整齐又能把浪费控制在单节512字节以内。这里还要补充一个规范约束FileAlignment的值必须是2的幂范围在0x200到0x10000之间如果是这个范围之外的非2的幂数值加载器会报无效镜像。此外如果FileAlignment大于0x200系统会施加更严格的限制所以绝大多数工具链都老老实实用0x200。2.3 文件对齐引起的末端填充由于SizeOfRawData要对齐磁盘上每个节的实际数据区域后面都会有一小段“多余”的空间。这段空间通常填0也可能残留上一次编译链接时留下的内容取决于链接器的行为。这里要留意这段填充区域不会因为对齐就自动变成“有效数据”。你在填充区域里手动塞入一组字节加载器不会把它们映射成有意义的节内容除非正好位于节的映射区间内。很多人在修改PE时往节尾部填充区塞代码结果程序一运行就崩根因就在这里。3. 节对齐内存对齐深入拆解SectionAlignment控制的是加载后节的虚拟地址布局。它决定了每个节在内存中的起始RVA必须落在哪个边界上。3.1 SectionAlignment的数学含义对加载器而言一个节的VirtualAddress在内存里必须能被SectionAlignment整除。假设SectionAlignment是0x1000那么第一个节的VirtualAddress通常是0x1000第二个节是0x2000第三个是0x3000以此类推。每个节在内存中占据的长度取决于VirtualSize对齐到SectionAlignment后的值而不是SizeOfRawData。我把这个概念翻译成人话内存里每个节就像划好的一块地皮地块的边界必须和0x1000那条线对齐节内实际盖多少房VirtualSize是另一回事但地块的边界线不能歪。节表里的VirtualAddress字段就是某个节的起始RVA。注意这个字段在磁盘上时并不是文件偏移它只是“将来加载到内存后的位置”的预订值。3.2 为什么内存对齐通常是0x10000x1000等于4096字节正好是x86/x64平台经典的内存页面大小。Windows把进程虚拟内存按页管理所有内存映射、权限设置都以页为最小单位。SectionAlignment取0x1000是为了让每个节的起始地址正好落在页面边界上这样加载器可以把整个节按页映射并且对每一页统一设置权限。这种设计对节权限设置极其重要。比如.text节是“可读可执行”.data节是“可读可写”如果节的起始地址和页面对齐那么设置页权限时只需要按页批量操作不用担心一个页面里同时包含两个权限不同的节。规范里还有一种特殊情况如果SectionAlignment小于页面大小比如0x200那么SectionAlignment必须等于FileAlignment。否则加载器无法用页映射的方式正确载入只能退化到逐块拷贝的模式这是很多特殊工具链产出的小于0x1000对齐文件能加载运行的原因。3.3 VirtualSize与SizeOfRawData的三种关系这两个字段是初学者最常混淆的一对。我直接列出它们之间可能出现的三种关系每种都有对应的实际场景。VirtualSize小于SizeOfRawData这是最常见的情况。节的真实数据长度是0x1B8对齐后磁盘上是0x200多出来的0x48个字节就是填充区域。内存中虽然按0x1000的对齐粒度分配了页面但节的有效内容只有0x1B8其余部分是加载器补零得来的。VirtualSize等于SizeOfRawData少见但合法。说明原始数据长度恰好不需要对齐填充或者工具链刻意把VirtualSize也做了相同的对齐处理。VirtualSize大于SizeOfRawData典型场景是未初始化数据节比如.bss。这类节在磁盘上几乎没有内容SizeOfRawData可能只有0或一丁点但加载到内存后需要一大片零初始化区域所以VirtualSize会明显大于SizeOfRawData。加载器看到这种情况会自动把VirtualAddress到VirtualAddressVirtualSize之间的内存区域补零。关系含义典型场景VS SizeOfRawData磁盘节包含填充数据普通代码节、数据节VS SizeOfRawData原始数据恰好对齐特殊工具链产物VS SizeOfRawData内存需要比磁盘更多零页.bss未初始化数据节4. 从文件偏移换算到RVA有了对齐概念后最实用的技能就是把任意一个RVA换算成文件偏移。这一节直接给公式再拿导入表做一个完整演算。4.1 换算公式与前提条件RVA换算成文件偏移不能直接拿RVA当偏移用必须经过节表。公式如下file_offset PointerToRawData (rva - VirtualAddress)使用条件这个RVA必须落在某个节的区间内即VirtualAddress RVA VirtualAddress max(VirtualSize, SizeOfRawData)。如果RVA落在节的VirtualSize区间之外但在SizeOfRawData区间内那它在文件里可能只有填充数据如果RVA落在VirtualSize区间内但SizeOfRawData不足以覆盖那么这部分在磁盘上不存在加载后是零初始化区域。反过来从文件偏移换算到RVArva VirtualAddress (file_offset - PointerToRawData)前提是file_offset位于该节的PointerToRawData到PointerToRawDataSizeOfRawData之间。4.2 拿导入表举个实例假设某个PE文件的导入表RVA是0x0000109C。节表里有个节叫.idataVirtualAddress是0x00001000PointerToRawData是0x00000400。那么换算过程是file_offset 0x400 (0x109C - 0x1000) 0x400 0x9C 0x49C打开十六进制编辑器跳到文件偏移0x49C你会看到一串指向导入表的指针结构。如果直接把0x109C当作文件偏移去文件里找找到的是节表后面的内容十有八九不对。这里要提醒一下不是每个PE都有名为.idata的节。有些链接器把导入表塞进.text或.data节里比如MSVC就可能把导入目录放到.rdata附近。换算方法万变不离其宗关键是找到待换算RVA到底落在哪个节的范围内。4.3 用一段Python脚本批量换算实际调试中手动算一两个RVA还行面对一长串目录项就累了。我习惯用Python做批量换算代码不复杂却能省下大量时间。import struct def rva_to_offset(file_data, rva): e_lfanew struct.unpack_from(I, file_data, 0x3C)[0] num_sections struct.unpack_from(H, file_data, e_lfanew 0x06)[0] opt_size struct.unpack_from(H, file_data, e_lfanew 0x14)[0] sec_table e_lfanew 0x18 opt_size for i in range(num_sections): off sec_table i * 40 vs struct.unpack_from(I, file_data, off 0x08)[0] va struct.unpack_from(I, file_data, off 0x0C)[0] raw_size struct.unpack_from(I, file_data, off 0x10)[0] raw_ptr struct.unpack_from(I, file_data, off 0x14)[0] if va rva va vs: delta rva - va if delta raw_size: return raw_ptr delta else: # 落在VirtualSize内但超出磁盘原始数据说明是未初始化零页 return None return None with open(sample.exe, rb) as f: pe f.read() # 假设已知某个RVA比如导入表 result rva_to_offset(pe, 0x0000102C) print(File offset: 0x%X % result)这段脚本按节表的40字节结构解析每个节把VirtualAddress、PointerToRawData这些关键字段读出来再套用上面的公式。实际使用中往往还要先读数据目录拿RVA再传给这个函数两步连起来就是一个简易的PE解析器。5. 实操验证与修改PE时的注意事项了解理论之后必须亲手验证一遍不然看完就忘。我建议你用自己电脑上的一个普通exe拿工具把每个节的对齐数据摆出来再对照十六进制内容逐项核对。5.1 用工具核对节表与填充CFF Explorer或者PE-bear这类工具都能直接展示节表打开后看Optional Header的SectionAlignment和FileAlignment再对照每个节的VirtualAddress、VirtualSize、PointerToRawData、SizeOfRawData。以常见的记事本程序为例你大概率会看到类似下面的数据节名VirtualAddressVirtualSizePointerToRawDataSizeOfRawData.text0x10000x1B80x4000x200.rdata0x20000x1400x6000x200第一眼看上去.text的PointerToRawData是0x400VirtualAddress是0x1000中间差了0xC00个字节。这就是两套坐标的差异。再用十六进制编辑器跳转到0x400能看到.text的开头往后再看0x1B8字节的位置一直到0x600之前几乎都是0x00填充这就是SizeOfRawData比VirtualSize多出来的部分。5.2 修改PE时最容易踩的几个坑在节尾填充区直接塞代码刚才说过填充区不一定映射成有效内存内容而且就算映射了节与节之间的空隙也可能不被加载器初始化。正确做法是扩展现有节的SizeOfRawData或者新增一个节并且同步更新节表和文件头里的SizeOfImage。在文件中间插入字节后不重新对齐一旦改变了某个节之前的数据长度后面所有节的PointerToRawData、VirtualAddress都要重新计算否则加载器定位节内容时全错。修改VirtualSize但不改SizeOfRawData如果VirtualSize大于SizeOfRawData加载器会按零初始化处理多出的区域但如果VirtualSize小于原来的SizeOfRawData定义改变之后加载器只映射更少的字节原来多出的磁盘数据就形同虚设。轻易改动FileAlignment把FileAlignment改成非2的幂或者一个过大的值轻则加载失败重则整个文件被安全软件当成异常样本。没有充分理由不要动它。5.3 一个对齐错误的修复案例有次我给一个exe注入自定义代码直接在文件尾部追加了数据按0x1000的SectionAlignment新加了一个节一切正常。但有一次图省事没加新节把原有节的VirtualSize改大了磁盘上也没补数据。结果运行时该节区域被加载器当作零初始化数据我写的代码根本不在那里程序直接崩溃。排查过程其实不难用工具一看该节的VirtualSize大于SizeOfRawData说明加载器认为这节有“未初始化部分”那些字节全部来自零页。修复方式是我把磁盘文件重新补齐数据并把SizeOfRawData对齐到FileAlignment的整数倍同时把VirtualSize保持为实际逻辑大小两者同步后程序恢复。这个案例说明一个道理对齐不是“好看”用的而是加载器的硬规则。你改了字段却不按规则补数据加载器永远只认规则不认你的意图。6. 常见问题速查与避坑心得这一节把平时群里问得最多的几个问题整理成速查表再分享一点我自己的调试习惯。问题答案文件对齐和磁盘对齐是同一个东西吗是都是FileAlignment与内存里的SectionAlignment对应SectionAlignment可以比FileAlignment小吗规范要求SectionAlignment必须大于等于FileAlignment为什么很多文件的SectionAlignment等于0x200这类文件通常SectionAlignment小于页面大小规范要求它必须等于FileAlignmentVirtualSize小于SizeOfRawData正常吗完全正常多出的区域是对齐填充VirtualSize大于SizeOfRawData正常吗正常常见于未初始化数据节加载器补零RVA能直接当文件偏移用吗不能必须通过节表换算我自己加了一个节为什么加载失败多半是节表的节数量、头部SizeOfImage、各节PointerToRawData没有同步更新还有一个我屡试不爽的小方法拿到任何PE样本我第一件事不是反汇编而是先画一张“节表布局图”。把每个节的名称、VirtualAddress、VirtualSize、PointerToRawData、SizeOfRawData按表格列出来再按比例画一条横向的“磁盘布局”和一条“内存布局”用色块标出各节位置。画完之后哪些偏移能对上、哪些地方有填充、哪些节内存比磁盘大一眼就清楚。很多加载异常、注入失败的问题在画完这张图之后自己就暴露了。回头看PE对齐这道坎并不难但它是一个“不踩坑不长记性”的知识点。我把自己的经历写出来就是希望你能少走这些弯路。如果你是刚开始学PE结构看完这篇之后建议别急着关页面找个系统自带的小工具自己写一遍RVA到文件偏移的换算再用十六进制编辑器核一次结果。半个小时后你就能彻底掌握这套坐标转换的逻辑后面再遇到节表、导入表、重定位都会顺畅得多。