从二进制到数据结构






4.53/5 (19投票s)
如何将格式正确的二进制数据解析到您的数据结构中。
目录
引言
有时,您想解析格式良好的二进制数据并将其导入到您的对象中进行一些“脏活”。
在 Windows 世界中,大多数数据结构都存储在特殊的二进制格式中。我们要么调用 WinApi
函数,要么想从特殊文件(如图像、打印文件、可执行文件,或者可能还有之前宣布的 Outlook 个人文件夹文件)读取数据。
有关这些文件的most specification,可以在 MSDN Library 中找到:Open Specification。
在我的例子中,我们将从 PE (Portable Executable) 中获取 COFF (Common Object File Format) 文件头。确切的规范可以在这里找到:PECOFF。
PE 文件格式和 COFF 头
在我们开始之前,我们需要了解这个文件是如何格式化的。下图显示了 Microsoft PE 可执行格式的概述。 来源:Microsoft
我们的目标是获取 PE 头。正如我们所见,图像以 MS-DOS 2.0 头开始,这对于我们来说并不重要。从文档中,我们可以读到
"...在 MS DOS 存根之后,在偏移量 0x3c 处指定的文件的偏移量处,是一个 4 字节的...".
有了这些信息,我们就知道我们的读取器需要跳转到位置 0x3c
并读取签名的偏移量。签名总是 4 个字节,用于确保图像是 PE 文件。签名是:PE\0\0
。
为了证明这一点,我们首先定位到偏移量 0x3c
,然后读取文件是否包含签名。
所以我们需要声明一些常量,因为我们不希望使用魔术数字。
private const int PeSignatureOffsetLocation = 0x3c;
private const int PeSignatureSize = 4;
private const string PeSignatureContent = "PE";
然后是一个将读取器移动到正确位置以读取签名偏移量的方法。使用此方法,我们始终将底层 BinaryReader
的 Stream
移动到 PE 签名开始的位置。
private void SeekToPeSignature(BinaryReader br) {
// seek to the offset for the PE signature
br.BaseStream.Seek(PeSignatureOffsetLocation, SeekOrigin.Begin);
// read the offset
int offsetToPeSig = br.ReadInt32();
// seek to the start of the PE signature
br.BaseStream.Seek(offsetToPeSig, SeekOrigin.Begin);
}
现在,我们可以通过读取接下来的 4 个字节来检查它是否是有效的 PE 图像,这些字节包含 PE
的内容。
private bool IsValidPeSignature(BinaryReader br) {
// read 4 bytes to get the PE signature
byte[] peSigBytes = br.ReadBytes(PeSignatureSize);
// convert it to a string and trim \0 at the end of the content
string peContent = Encoding.Default.GetString(peSigBytes).TrimEnd('\0');
// check if PE is in the content
return peContent.Equals(PeSignatureContent);
}
有了这个基本功能,我们就拥有了一个良好的基础读取器类,可以尝试使用不同的方法解析 COFF 文件头。
COFF 文件头
COFF 头具有以下结构
偏移量 | 大小 | 字段 |
0 | 2 | Machine |
2 | 2 | NumberOfSections |
4 | 4 | TimeDateStamp |
8 | 4 | PointerToSymbolTable |
12 | 4 | NumberOfSymbols |
16 | 2 | SizeOfOptionalHeader |
18 | 2 | Characteristics |
如果我们把这个表翻译成代码,会得到类似这样的东西
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct CoffHeader {
public MachineType Machine;
public ushort NumberOfSections;
public uint TimeDateStamp;
public uint PointerToSymbolTable;
public uint NumberOfSymbols;
public ushort SizeOfOptionalHeader;
public Characteristic Characteristics;
}
BaseCoffReader
所有读取器都做同样的事情,所以我们的大脑会想到模式库,并且看到 策略模式 或 模板方法模式 在书架上显眼地摆放着。
我决定在这个例子中使用模板方法模式,因为 Parse()
应该处理所有实现的 IO,而具体的解析应该由其派生类完成。
public CoffHeader Parse() {
using (var br = new BinaryReader(File.Open
(_fileName, FileMode.Open, FileAccess.Read, FileShare.Read))) {
SeekToPeSignature(br);
if (!IsValidPeSignature(br)) {
throw new BadImageFormatException();
}
return ParseInternal(br);
}
}
protected abstract CoffHeader ParseInternal(BinaryReader br);
首先,我们打开 BinaryReader
,定位到 PE 签名,然后检查它是否包含有效的 PE 签名,其余的工作由派生实现完成。
Byte4ByteCoffReader
第一个解决方案是使用 BinaryReader
。这是获取数据的通用方法。我们只需要知道顺序、数据类型和大小。如果逐字节读取,我们可以注释掉 CoffHeader
结构中的第一行,因为我们可以控制成员赋值的顺序。
protected override CoffHeader ParseInternal(BinaryReader br) {
CoffHeader coff = new CoffHeader();
coff.Machine = (MachineType)br.ReadInt16();
coff.NumberOfSections = (ushort)br.ReadInt16();
coff.TimeDateStamp = br.ReadUInt32();
coff.PointerToSymbolTable = br.ReadUInt32();
coff.NumberOfSymbols = br.ReadUInt32();
coff.SizeOfOptionalHeader = (ushort)br.ReadInt16();
coff.Characteristics = (Characteristic)br.ReadInt16();
return coff;
}
如果结构像这里的 COFF 头一样短,并且规范永远不会改变,那么很可能没有理由改变策略。但是,如果数据类型发生更改,添加新成员,或者更改成员的顺序,这种方法的维护成本会非常高。
UnsafeCoffReader
将数据导入此结构的另一种方法是使用“神奇的”不安全技巧。如上所述,我们知道数据结构的布局和顺序。现在,我们需要 StructLayout
属性,因为我们必须确保 .NET 运行时以与源代码中指定的相同顺序分配结构。我们还需要在项目的构建属性中启用“允许不安全代码 (/unsafe)”。
然后我们需要在 CoffHeader
结构中添加以下构造函数。
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct CoffHeader {
public CoffHeader(byte[] data) {
unsafe {
fixed (byte* packet = &data[0]) {
this = *(CoffHeader*)packet;
}
}
}
}
“魔术”技巧在于语句
this = *(CoffHeader*)packet;
这里发生了什么?我们在内存中的某个位置有一个固定大小的数据,并且因为 C# 中的 struct
是值类型,赋值运算符 =
会复制整个结构的数据,而不仅仅是引用。
要用数据填充结构,我们需要将数据作为字节传递给 CoffHeader
结构。这可以通过从 PE 文件中读取结构的精确大小来实现。
protected override CoffHeader ParseInternal(BinaryReader br) {
return new CoffHeader(br.ReadBytes(Marshal.SizeOf(typeof(CoffHeader))));
}
这个解决方案是解析数据并将其导入结构的最快方法,但它是不安全的,并且可能带来一些安全和稳定性风险。
ManagedCoffReader
在这个解决方案中,我们使用与上面相同的结构赋值方法。但我们需要用以下托管部分替换构造函数中的不安全部分
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct CoffHeader {
public CoffHeader(byte[] data) {
IntPtr coffPtr = IntPtr.Zero;
try {
int size = Marshal.SizeOf(typeof(CoffHeader));
coffPtr = Marshal.AllocHGlobal(size);
Marshal.Copy(data, 0, coffPtr, size);
this = (CoffHeader)Marshal.PtrToStructure
(coffPtr, typeof(CoffHeader));
} finally {
Marshal.FreeHGlobal(coffPtr);
}
}
}
结论
我们看到可以使用不同的方法将格式良好的二进制数据解析到我们的数据结构中。第一种方法可能是最清晰的方法,因为我们知道每个成员及其大小和顺序,并且我们可以控制每个成员的数据读取。但是,如果我们添加成员或结构因某种原因而改变,我们需要更改读取器。
其他两种解决方案使用了结构赋值的方法。在不安全实现中,我们需要使用 /unsafe
选项编译项目。我们提高了性能,但带来了一些安全风险。
历史
- 2010 年 3 月 6 日:初始帖子