Saturday, April 02, 2005

 

Some notes on PE extensions in DotNet - 1

(按:这里附录的文章是为了管窥前人摸索的脚步和方法)

1.
浅析.NET Framework对PE文件格式的扩展

By WebCrazy
From http://webcrazy.yeah.net

Microsoft .NET Framework出来小阵子了,我也自从其Beta 1以来,第一次接触。本文将从.NET生成的一个小PE文件着手,旨在理解.NET Framework对PE文件格式的扩展。这种扩展目的是让Windows系统识别Common Language Runtime(CLR)。

PE文件是Windows系列操作系统的可执行文件格式。本文假设您对这一文件格式有相当的理解,文中未涉及PE在之前的win16及之后的win64上的讨论。在CLR出现之前,PE文件格式仅简单的由PE Header与Native Image(相对于以下介绍的CLR Header与CLR Data部分)组成。Native Image由各个section组成,如.text,.data,.rdate等等,需要指出的是PE文件的这些section名命名规则并不要求一定要以句点开头,事实上这只是Microsoft的对于代码段或数据段的默认说法,像Borland等其他编译器则相应分别命名为CODE,DATA等等。Native Image含有已编译的相应处理器的机器代码。

在CLR出现后PE文件扩展出了另外一部分,即CLR Header与CLR Data组成的供.NET Framework运行的支撑部分。CLR Header由.NET Framework SDK的CorHdr.h中的IMAGE_COR20_HEADER结构定义。从CorHdr.h或是IMAGE_COR20_HEADER的命名中Cor的全称Com+ Runtime即可隐隐约约的看到.NET Framework的发展过程,其与COM+的渊源关系了。事实上IMAGE_COR20_HEADER在平台SDK的winnt.h中也有定义,我查阅的了随Windows XP DDK Build 2505发行的winnt.h中Microsoft在给出这个定义时的注释为COM+ 2.0 header structure,而在.NET Framework SDK中即修改为CLR 2.0 header structure了。CLR Data则包含.NET metadata, IL method bodies等等。metadata及IL method是.NET中很关键的术语。IL即Microsoft Intermediate Language的缩写。她是为了.NET跨平台、跨语言的特性而引入的,有其自身的指令集。.NET SDK中的opcode.def列出的其支持的指令集。粗粗看来这些指令集与Intel的X86指令集十分的相像,也是由Prefix指定的的双字节进行编码。

下面的我将通过底下列出的这一段C# Console代码来简述C#编译器生成的PE文件的执行流程及PE文件的on disk结构。代只是简单的输出Hi,如下所示:

public class App {
static public void Main(System.String[] args) {
System.Console.WriteLine("Hi");
}
}

我们简单的使用csc /out:app.exe app.cs对其编译。生成的PE文件,与.NET出现前传统的编译器生成的PE文件一致,也含有IMAGE_DOS_HEADER,我们知道这部分的作用即是早期的DOS在遇到PE文件格式时,能判定这个可执行文件不能执行于DOS下而存在的。IMAGE_DOS_HEADER与将要谈及的一些结构在winnt.h中均有详细定义。Windows OS Loader根据IMAGE_DOS_HEADER中的e_lfanew成员定位紧挨着的IMAGE_NT_HEADERS。其定义如下:

typedef struct _IMAGE_NT_HEADERS {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

我们知道IMAGE_OPTIONAL_HEADER32的成员AddressOfEntryPoint 是PE可执行文件的入口,在.NET中其仍为执行入口,这应该是很好理解的。对于一个COMIMAGE_FLAGS_ILONLY(由IMAGE_COR20_HEADER 的成员Flags 指定)的Image,如我们生成的App.exe,这个入口也即间接定位至App.exe的Import表的_CorExeMain函数。_CorExeMain对应EXE文件,由mscoree.dll导出。mscoree.dll位于%WINNT%\system32下,是Microsoft .NET Runtime Execution Engine,应该指出的她是一个Native Image,负责调用IMAGE_COR20_HEADER中的 EntryPointToken 指定的.NET Token。这才是真正IL语言的入口。

Native Image部分的各个Section的定位,已经有很多文档介绍,而且winnt.h中都有详细的定义。我只简单的阐述一下:

.text、.data等section定位是由IMAGE_OPTIONAL_HEADER32中的DataDirectory成员指定。DataDirectory是一个IMAGE_DATA_DIRECTORY数组,个数为MAGE_NUMBEROF_DIRECTORY_ENTRIES(当前为16)个。各个DataDirectory功能分别由IMAGE_DIRECTORY_ENTRY_***指定,如EXPORT、IMPORT等等。因为IMAGE_DATA_DIRECTORY由VirtualAddress(RVA)与Size组成,所以我们即可以很容易的找到这些Section的位置。与这些Section一样,CLR Header的定位也是DataDirectory指定,其为IMAGE_DIRECTORY_ENTRY_COMHEADER(值为14,.NET Framework SDK V1 CorHdr.h中称谓,在DDK 2505的winnt.h中为IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR)。我们生成的App.exe有如下的格式:

.
.
.
AddressOfEntryPoint: 0x000022CE (+0x10)
.
.
.
DataDirectory[0] - IMAGE_DIRECTORY_ENTRY_EXPORT
VirtualAddress: 0x00000000 (+0x60)
Size: 0x00000000 (+0x64)
DataDirectory[1] - IMAGE_DIRECTORY_ENTRY_IMPORT
VirtualAddress: 0x0000227C (+0x68)
Size: 0x0000004F (+0x6C)
.
.
.
DataDirectory[14] - IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR
VirtualAddress: 0x00002008 (+0xD0)
Size: 0x00000048 (+0xD4)
.
.
.
OK,从DataDirectory[14]我们即可以很容易的定位CLR Header。CLR Header可以被合并到其它任何为只读属性的Section中。前面已经提及到CLR Header由IMAGE_COR20_HEADER结构定义。

// CLR 2.0 header structure.
typedef struct IMAGE_COR20_HEADER
{
// Header versioning
ULONG cb;
USHORT MajorRuntimeVersion;
USHORT MinorRuntimeVersion;

// Symbol table and startup information
IMAGE_DATA_DIRECTORY MetaData;
ULONG Flags;
ULONG EntryPointToken;
// Binding information
IMAGE_DATA_DIRECTORY Resources;
IMAGE_DATA_DIRECTORY StrongNameSignature;

// Regular fixup and binding information
IMAGE_DATA_DIRECTORY CodeManagerTable;
IMAGE_DATA_DIRECTORY VTableFixups;
IMAGE_DATA_DIRECTORY ExportAddressTableJumps;

// Precompiled image info (internal use only - set to zero)
IMAGE_DATA_DIRECTORY ManagedNativeHeader;

} IMAGE_COR20_HEADER;
这个结构的Flags与EntryPointToken上面已经提及。从这么多的IMAGE_DATA_DIRECTORY上看,这个定义很像IMAGE_OPTIONAL_HEADER32,后者可以理解成PE文件头的精华,其用于定位.text等Section,由Windows OS Loader执行。而前者用于定位.NET CLR Data,如MetaData、Resources、StrongNameSignature等等。不同的是IMAGE_COR20_HEADER是由mscoree.dll中的_CorExeMain(对应于EXE文件)负责调用(MSIL语言需经过JIT编译成机器码才可执行)。

虽然EnrtyPointToken与上面的AddressOfEntryPoint均是执行入口,但却有非常大的区别。AddressOfEntryPoint是一RVA,直接指向执行地址(相对于Image Base),其只能指向一本地机器代码用于装载NET Runtime(如mscoree.dll中的_CorExeMain,对于DLL文件其可以置为0)。而EntryPointToken只是一个.NET TOKEN。TOKEN是.NET Type的唯一识别,是一个DWORD值。其最高的8bit指明何种TOKEN。其由CorHdr.h中的CorTokenType enum定义。如mdtMethodDef为0x06000000,mdtEvent为0x14000000等等,而余下的24bit则为此类TOKEN的唯一识别。EnrtyPointToken只能是一METHOD,而不能是EVENT等等。如App.Exe的EnrtyPointTokeno为0x06000001,其对应于Main Method。您可以使用ildasm.exe(随.NET Framework SDK提供)进行验证。

App.exe的CLR Header如下(只列出了部分非空字段):

Size: 0x00000048
MajorRuntimeVersion: 0x0002
MinorRuntimeVersion: 0x0000
MetaData
VirtualAddress: 0x0000207C
Size: 0x00000200
Flags: 0x00000001
COMIMAGE_FLAGS_ILONLY
EntryPointToken: 0x06000001

.NET MetaData由MetaData成员指定。Microsoft在CorHdr.h中给出了ILMETHOD的on disk组织结构(IMAGE_COR_ILMETHOD)。随.NET Framework SDK也提供了一个例子metainfo用于分析Metadata。随QuickStart例子的Class Browser的ASP.NET范例也是.NET Framework很好的学习材料。Metainfo使用常规的COM方法,而Class Browser使用.NET Framework的System.Reflection Namespace。关于.NET的SOAP,Web Services,Web Forms,XML等等QuickStart真不愧为QuickStart,.NET看来是下阵子学习的方向啊。

2.
MS.Net CLR扩展PE结构分析

By Flier Lu
From http://www.chinaaspx.com/article/web.services/

1.概述

本文从系统底层角度,通过分析MS.Net CLR架构在Win32平台上对PE可执行文件
映像结构的扩展,解析CLR架构底层的部分运行机制,帮助读者从更深层次理解CLR架构
中某些重要概念。
本文读者应具备基本的Win32编程经验,了解CLR中常见概念意义,并对Win32平台
之PE可执行文件映像结构有一定了解,具体说明请参看Matt Pietrek于1994年3月发表在
MSJ上的经典文章《Peering Inside the PE: A Tour of the Win32 Portable
Executable File Format》,与之重复的部分本文一概跳过。

2.前言

对一个优秀Win32平台程序员来说,对PE结构的了解是对Win32架构了解的必经之路,
而从Chicago(Win95的开发代号,Win95正式发布以前的文档中对Win95的称呼)以来,
PE结构就相对稳定,直到MS.Net的出现,才发生了一些不大不小的变化。
之所以说是不大不小的变化,是因为CLR并没有对PE结构进行结构上的调整,只是利用现有
PE结构的优良可扩展性,将CLR所需的信息扩展到PE映像中。具体一点说,就是利用了PE结构中的
IMAGE_OPTIONAL_HEADER.DataDirectory[IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR]
来保存服务于CLR的IMAGE_COR20_HEADER结构,其它的PE结构基本不变。

3.CLR 头信息

与传统PE可执行文件一样,CLR程序也有一个文件头保存CLR相关的全局性信息。
但因为要保证现有PE结构的稳定性,CLR程序仅使用PE结构的一个Directory Entry
也就是前言中提到的IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR。
此入口原本是设计用于COM,但不知为何一直没有被使用。

3.1 IMAGE_COR20_HEADER

IMAGE_COR20_HEADER结构的定义,可以在FrameworkSDK\include\CorHdr.h
头文件中找到,如下:

// CLR 2.0 header structure.
typedef struct IMAGE_COR20_HEADER
{
// Header versioning
ULONG cb;
USHORT MajorRuntimeVersion;
USHORT MinorRuntimeVersion;

// Symbol table and startup information
IMAGE_DATA_DIRECTORY MetaData;
ULONG Flags;
ULONG EntryPointToken;

// Binding information
IMAGE_DATA_DIRECTORY Resources;
IMAGE_DATA_DIRECTORY StrongNameSignature;

// Regular fixup and binding information
IMAGE_DATA_DIRECTORY CodeManagerTable;
IMAGE_DATA_DIRECTORY VTableFixups;
IMAGE_DATA_DIRECTORY ExportAddressTableJumps;

// Precompiled image info (internal use only - set to zero)
IMAGE_DATA_DIRECTORY ManagedNativeHeader;

} IMAGE_COR20_HEADER;

详细的说明信息,可以在FrameworkSDK\Tool Developers Guide\docs
目录中找到。此结构虽然字段较多,但实际上其核心在于MetaData,其他信息都是围绕着
MetaData服务。

3.2 实例分析

下面我们分析两个实际的CLR程序的Cor20Header结构,wsdl.exe是.Net Framework
提供的wsdl分析工具、MgrCpp.exe是一个简单的VC.Net编写的Managed C++和
Unmanaged C++并存的程序,MgrCpp.cpp 代码如下

#include "stdafx.h"

#using
#include

#include

using namespace System;

// This is the entry point for this application
int _tmain(void)
{
// TODO: Please replace the sample code below with your own.
Console::WriteLine(S"Hello World");

std::cout << "Hello World" << std::endl;

return 0;
}

wsdl.exe 可以用Dumpbin /all wsdl.exe > wsdl.dump 导出,关键数据如下

...
500000 image base (00500000 to 00511FFF)
...
2008 [ 48] RVA [size] of COM Descriptor Directory
...
RAW DATA #1
00502000: ?? ?? ?? ?? ?? ?? ?? ?? 48 00 00 00 02 00 00 00 àü......H.......
00502010: F8 67 00 00 B0 74 00 00 09 00 00 00 21 00 00 06 g..°t......!...
00502020: 09 51 00 00 8E 16 00 00 50 20 00 00 80 00 00 00 .Q......P ......
00502030: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00502040: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................

MgrCpp.exe 类似处理,关键数据如下

...
400000 image base (00400000 to 0045CFFF)
...
4B24C [ 48] RVA [size] of COM Descriptor Directory
...
0044B240: ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? 48 00 00 00 @.@.à#@.H...
0044B250: 02 00 00 00 70 EB 04 00 D0 42 00 00 02 00 00 00 ....p..DB......
0044B260: 73 00 00 06 00 00 00 00 00 00 00 00 00 00 00 00 s...............
0044B270: 00 00 00 00 00 00 00 00 00 00 00 00 78 2E 05 00 ............x...
0044B280: C0 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 à...............
0044B290: 00 00 00 00 ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ....1E.@?@.....

3.3 字段分析

cb(0x48)是IMAGE_COR20_HEADER结构的大小。

MajorRuntimeVersion(0x02)和MinorRuntimeVersion(0x00)是版本号,
指执行此程序所需的最低CLR版本号。

Metadata是核心数据所在,我们等会再详细分析。

Flags是Runtime Image描述标志,描述此CLR映像的执行属性。wsdl.exe的Flags
是0x09或1001B,MgrCpp.exe的Flags是0x02或0010B,具体含义如下:

// CLR Header entry point flags.
COMIMAGE_FLAGS_ILONLY =0x00000001,
COMIMAGE_FLAGS_32BITREQUIRED =0x00000002,
COMIMAGE_FLAGS_IL_LIBRARY =0x00000004,
COMIMAGE_FLAGS_STRONGNAMESIGNED =0x00000008,
COMIMAGE_FLAGS_TRACKDEBUGDATA =0x00010000,

COMIMAGE_FLAGS_ILONLY 标志位说明此CLR程序由纯IL代码组成,IL代码是类似
Java的Bytecode的中间代码,由纯IL代码组成就代表此程序可以在任何实现了CLR环境
的平台,如支持CLI的FreeBSD上执行。一般由C#之类纯.Net语言生成的程序,都会设置此位,
如wsdl.exe就设置此位,而混合编译的VC.Net程序如MgrCpp因为代码由IL和Native Code
组成,因而此位置空。
COMIMAGE_FLAGS_32BITREQUIRED 标志位说明此CLR映像只能在32位系统上执行,
对以后的64位或嵌入式系统上的CLR无效(MS.Net很重要的一个目的就是为以后平滑过渡到64位
平台做准备,想想以前16位平台到32位平台过渡时的混乱,以及现在比以前翻了n倍的代码量就恐怖,
MS真是未雨绸缪啊,呵呵)。一般纯IL代码的程序都是具有良好移植性的,混合编码则必须32bit。
COMIMAGE_FLAGS_IL_LIBRARY标志位说明此CLR映像是作为IL代码库存在的。
COMIMAGE_FLAGS_STRONGNAMESIGNED说明此映像有strong name signature
这个strong name signature在CLR架构里起到了非常重要的作用。为什么这么说呢,
因为这个strong name signature起到Assembly的身份证的作用,
它关系到CLR中大量安全概念的实现,如保证代码完整性,作为代码权限获取凭证等等。
因此在发布.Net程序前,一定要加上strong name signature。
COMIMAGE_FLAGS_TRACKDEBUGDATA标志位目前没有使用到,缺省置0。

EntryPointToken则是指向IL程序的入口点,类似于以前PE结构中
IMAGE_OPTIONAL_HEADER.AddressOfEntryPoint的作用,只是以前的
AddressOfEntryPoint是一个RVA,直接指向程序入口代码所在地址,
(不要告诉我你不知道RVA是什么啊,呵呵,赶快去看Peering Inside the PE)
而现在EntryPointToken指向一个Token。(注意,是Token,而不是地址)
因为IL代码是JIT编译的,存在于映像中的都是IL形式的P-code(pseudo code),
在需要时才由CLR动态读取,在内存中编译展开为本机代码(Native Code),进而执行。
因此这里的程序入口只是一个MethodDef或File表的入口,一个Token而已。
如wsdl的EntryPointToken值0x06000021指向0x06号表(MethodDef)的21项
Microsoft.DevApps.WebServices.WebServiceUtil.Main方法。
这里的MethodDef是一个MetaData表,每行定义一个函数或方法;而File表则是
每行有一个File定义的表,每个File表项包含一个外部文件的信息。也就是说,
在执行程序时可以直接编译执行此映像中的一个方法的IL代码,也可能是重定向到另一个文件,
这就是Assembly作为一个逻辑代码单元,与传统DLL之类相比一个很大的不同。
DLL是一个完整的代码实体,而Assembly则是由一组物理上可分布存在的实体组成的
逻辑单元组。
剩下没有解说的几个字段都是IMAGE_DATA_DIRECTORY类型,这个类型是一个数据块
定义结构,类似一个数组定义。在Winnt.h中有定义

//
// Directory format.
//

typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

Resources定义资源所在;StrongNameSignature定义刚刚提到的strong name
signature所在;此外CodeManagerTable,ExportAddressTableJumps,
MangedNativeHeader目前都没用到。VTableFixups是由使用VTable的语言如C++
使用的,类似原来PE结构中的重定向表,结构如下

_IMAGE_COR_VTABLEFIXUP = packed record
RVA: DWORD; // Offset of v-table array in image.
Count, // How many entries at location.
Kind: Word; // COR_VTABLE_xxx type of entries.
end;
IMAGE_COR_VTABLEFIXUP = _IMAGE_COR_VTABLEFIXUP;
TImageCorVTableFixup = _IMAGE_COR_VTABLEFIXUP;
PImageCorVTableFixup = ^TImageCorVTableFixup;
TImageCorVTableFixupArray = array[0..MaxWord-1] of TImageCorVTableFixup;
PImageCorVTableFixupArray = ^TImageCorVTableFixupArray;

4.Metadata 的逻辑结构

在分析完CLR的头信息后,我们来看看核心数据Metadata的结构

4.1 什么是 Metadata

Metadata翻译成中文是“元数据”,可以理解为Type of Type,
实际上就是描述类型信息的类型信息。从最初级的语言层面支持的RTTI
(“近代”的编程语言基本上都提供了足够的支持,如C++,Delphi等,
部分较“落伍”的语言也通过不同形式如扩展库提供了模拟支持,
“现代”的编程语言则提供了强力的支持,如Java和C#<本质是CLR>),
到后来二进制级的COM的IDL和类型库(类型库是IDL编译后的二进制形式),
到现在的Metadata,到即将到来的WebService的SOAP/WSDL,
其实是遵循着相同的发展思路。只是出于不同的需求、设计、实现,
有这各自的优点缺点罢了。但随着语言的发展,更多的需求将集中到
灵活性方面,因而语言发展的趋势是元数据的使用越来越多、支持越来越强。
举个最简单的例子,在IDE中,动态显示当前对象的方法、属性名列表的功能
(MS叫IntelliSense,Borland叫CodeInsight),就得宜于元类型信息。
以前在VC里实现,比较麻烦,得预编译生成专用的符号库;在VB里强一点,可以通过
COM的IDispatch,ITypeInfo,ITypeLib等接口动态获取,但编程使用复杂;
到CLR,在基础库一级直接提供支持,可以通过System.Reflection完全控制,
甚至比COM类型库更高一级地支持动态创建。
对用户来说,可以完全了解当前程序结构、接口,有哪些Module,哪些Class,
哪些Method等等,这给开发者提供了巨大的创造空间。如DUnit(DotNet下
的XUnit单元测试平台)实现上就大量使用Reflection机制。
以后有时间我会写一篇CLR程序的静态、动态分析,专门讨论这个问题。

4.2 Metadata在CLR中的作用

对于CLR架构来说,Metadata可以算是核心数据所在,几乎绝大多数功能
都需要使用其数据。从静态的IL代码构成(二进制编码中直接使用Metadata里的Token)
到动态JIT编译器(使用Metadata定位IL代码及其关系);从简单的代码载入执行
(Class Loader通过Metadata定位代码入口、编译执行)到复杂的不同语言互操作
(如VB.Net继承C#的类,实际上是直接继承CLR中Metadata中的类)等等……
几乎所有地方都能看到Metadata的身影。

4.3 如何访问和使用 Metadata

在CLR里使用Metadata,可以在三个层面进行操作。

最简单的方法是直接通过.Net BCL基础类库提供的System.Reflection
命名空间中的辅助工具类进行访问,例如

using System.Reflection;
using System;

public class Simple
{
public static void Main ()
{
Module mod = Assembly.GetExecutingAssembly().GetModules () [0];
Console.WriteLine ("Module Name is " + mod.Name);
Console.WriteLine ("Module FullyQualifiedName is " + mod.FullyQualifiedName);
Console.WriteLine ("Module ScopeName is " + mod.ScopeName);
}
}

这种访问方式使用起来最简单,功能也足够强大,能够完成我们绝大多数的需要,
特别是在System.Reflection.Emit命名空间中,更提供了动态生成、修改代码的支持。
不过这种方式必须有CLR环境的支持,受到库功能的限制(后面我们会看到一些
在Reflection库一级里不提供支持的信息),因此MS为工具软件开发商提供了另一套
较底层的开发库:Metadata Unmanaged API。这套支持库通过一系列COM接口,
提供了直接访问Metadata的强大支持,有兴趣的朋友可以参看
FrameworkSDK\Tool Developers Guide\docs\Metadata Unmanaged API.doc文档,
里面有详细的使用说明。如同其名字所示,它必须用Unmanaged代码来使用,如VC,Delphi等。

可以说99%的工作,都可以通过上面两套库来完成,不过总有些象我这样的人,
喜欢对技术追根究底,想把隐藏在美好面纱下的底层架构纠出来暴露一把,呵呵
因此有了第三个层面,二进制层面的逆向工程分析。
好在MS为了让其CLI(CLR的子集)标准化,公开了大量底层实现文档,
FrameworkSDK\Tool Developers Guide\docs\Partition II Metadata.doc
文档中对Metadata的二进制格式实现给出了比较详尽的说明,MS也公布了一个支持Win32和
FreeBSD平台的CLI实现的源代码(已移植到Linux等其它平台)。加上GNOME的mono项目已经
做了很多工作,因而对Metadata的二进制层面分析不是那么困难。
接下去的文章中,会试图一步步将Metadata在PE中的组织结构剥离开来,
让大家能够了解这个神秘的CLR核心:Metadata到底是什么,里面隐藏了些什么,我们能够通过
他做什么,为什么要这样设计,等等……

4.4 Metadata在PE中的组织结构

回到正体上来,谈谈Metadata在PE中的组织结构。
上章我们提到CLR的头信息里面专门有一个字段指向Metadata数据块,
实际上这个数据块只是Metadata的一个头结构,保存有Metadata的全局信息,
而Metadata的实际数据,是通过若干不同的Heap或者说Stream保存的。
这里我统一使用Stream“流“作为他的名字,但很多文档中以Heap”堆“作为
其称呼,我们可以理解他是一个二进制流,其中数据以堆的结构进行组织。

Metadata里最常见的有五种流,#String, #Blob, #Guid,
#US(User String)和#~流("#"是流名字的前缀)

String流就是一个字符串堆,Metadata内部用到的所有字符串如类或方法
的名字等等都以UTF8编码保存在此堆内。而用户的字符串如字符串常量,
则以Unicode编码保存在US(User String)堆内。值得注意的是,
US流和String流在二进制结构组织上不同,我们后面将分析时会详细提及。
Guid流是保存程序中使用到的Guid的数组,如Assembly中Module的MVID。
Blob流是一个通用存储空间,除了Guid和字符串以外基本上所有
剩下的东西都以二进制数据形式放在里面,如PublicKey,常量的值等等。
最重要的是#~流,这是Metadata核心信息存放的地方。#~流物理上以
若干张表(Table)的形式组织,每张表存储某一方面的Metadata信息,
如MethodDef表存储所有方法的信息。每张表又由若干的行(Row)组成
每行有n个列(Column),每列代表一种信息,如MethodDef表中每一行
都有一个方法的RVA,类型标志,名字,Signature等等信息。在其中通过
各种索引来相互关联,整个组织结构和关系数据库很相似。
比较特殊的是,这里所有的表通过一个64bit的有效位图来表示表存在与否
每种类型的表有一个编号,如MethodDef表的编号是6,则第(1<<(6-1))位置1
因而每个表的每一行,可以使用一个唯一的Token表示。此Token是一个32bit
无符号整型数,最高一个字节表示表的序号,低三个字节表示表中的索引号。
如0x06000003表示0x06表(MethodDef)中第3行(如MyApp::Add)
这个Token概念在CLR中频繁使用,如IL代码调用函数、使用变量都是使用Token。
与之类似的还有Coded Index,等会讲二进制实现时再说。

5.Metadata 的物理结构

5.1 Metadata Header

前言中我们分析了CLR Header的结构,里面有一个Metadata字段

IMAGE_DATA_DIRECTORY MetaData;

字段指向一个数据块,里面包含着Metadata Header,是关于
Metadata信息所在,结构定义伪代码如下

PClrMetadataHeader = ^TClrMetadataHeader;
TClrMetadataHeader = packed record
Signature: DWORD; // Magic signature for physical metadata : $424A5342.
MajorVersion, // Major version, 1
MinorVersion: Word; // Minor version, 0
Reserved, // Reserved, always 0
Length: DWORD; // Length of version string in bytes, say m.
Version: array[0..0] of Char;
// UTF8-encoded version string of length m
// Padding to next 4 byte boundary, say x.
{
Version: array[0..((m+3) and (not $3))-1] of Char;
Flags, // Reserved, always 0
Streams: Word; // Number of streams, say n.
// Array of n StreamHdr structures.
StreamHeaders: array[0..n-1] of TClrStreamHeader;
}
end;

wsdl.exe的Metadata头信息
...
67F8 [ 74B0] RVA [size] of MetaData Directory
...
005067F0: ?? ?? ?? ?? ?? ?? ?? ?? 42 53 4A 42 01 00 01 00 l.pdb...BSJB....
00506800: 00 00 00 00 0C 00 00 00 76 31 2E 78 38 36 72 65 ........v1.x86re
00506810: 74 00 00 00 00 00 05 00 6C 00 00 00 64 1E 00 00 t.......l...d...
00506820: 23 7E 00 00 D0 1E 00 00 70 1F 00 00 23 53 74 72 #~..D...p...#Str
00506830: 69 6E 67 73 00 00 00 00 40 3E 00 00 18 13 00 00 ings....@>......
00506840: 23 55 53 00 58 51 00 00 10 00 00 00 23 47 55 49 #US.XQ......#GUI
00506850: 44 00 00 00 68 51 00 00 48 23 00 00 23 42 6C 6F D...hQ..H#..#Blo
00506860: 62 00 00 00 00 00 00 00 01 00 00 01 57 1F A2 03 b...........W.¢.
...

首先是Signature,是一个DWORD或4字符长的标记,$424A5342或'BSJB'
可以通过此标记判断Metadata是否存在,如

function TJclPeCLRHeader.GetHasMetadata: Boolean;
const
METADATA_SIGNATURE = $424A5342;
begin
with Header.MetaData do
Result := (VirtualAddress <> 0) and
(PDWORD(FImage.RvaToVa(VirtualAddress))^ = METADATA_SIGNATURE);
end;

通过检测Metadata节是否存在,以及头一个DWORD是否为Metadata标记来判断
Metadata是否存在。

接着的MajorVersion和MinorVersion保存Metadata格式的版本号,
一般设置为1.1。(文档中说明是1.0,实际为1.1)

跟着的Length+Version指定了UTF8格式的编译环境版本号,这个和你的CLR的编译环境
版本相同,如.Net Framework工具的编译环境版本号为v1.x86ret,你用它编译代码
生成的版本号则是诸如v1.0.3705类型,而MS CLI实现的编译环境版本号为v1.0.0。
注意这里Version字符串是按四字节对齐的,在分析二进制流时应按(m+3) and (not $3)
来计算实际的长度。

Streams和StreamHeaders则是Metadata流的信息数组,保存有Metadata不同类型
流的Offset, Size, Name等信息,每记录的结构如下

PClrStreamHeader = ^TClrStreamHeader;
TClrStreamHeader = packed record
Offset, // Memory offset to start of this stream from start of the metadata root
Size: DWORD; // Size of this stream in bytes, shall be a multiple of 4.
// Name of the stream as null terminated variable length
// array of ASCII characters, padded with \0 characters
Name: array[0..MaxWord] of Char;
end;

Offset是流实际位置相对Metadata头的偏移;Size是流的长度,以DWORD为单位;
Name是以#0字符结束的字符串,表示流的名称。
根据这些信息,我们可以从PE映像中读取相应流进行分析。

5.2 Metadata Stream

上一章我们曾经提过,Metadata Stream有五种常见类型,#String, #Blob,
#Guid, #US(User String)和#~流,每种类型流最多只能出现一次,#US和#Blob流
可省略。

5.2.1 #String 流

#String流是一个字符串堆,程序用到的字符串,如类名、函数名、参数名等字符串
保存在其中。字符串以UTF8编码保存,以#0字符分隔。流首总有一个#0字符代表一个
空字符串,分析代码如下。

constructor TJclClrStringsStream.Create(
const AMetadata: TJclPeMetadata;
const AHeader: PClrStreamHeader);
var
pch: PChar;
off: DWORD;
begin
inherited;

FStrings := TStringList.Create;
pch := Data;
off := 0;
while off < Size do
begin
if pch^ <> #0 then
FStrings.AddObject(pch, TObject(off));
pch := pch + StrLen(pch) + 1;
off := DWORD(pch - Data);
end;
end;

如碰到#0字符,直接作为空字符串跳过之,否则,以StrLen取得字符串长度,
并将字符串保存到一个字符串列表中,使用时需要动态将UTF8解码为Unicode。

function TJclClrStringsStream.GetString(const Idx: Integer): WideString;
begin
Result := UTF8ToWideString(FStrings.Strings[Idx]);
end;

因为流中数据是以堆形式存放,指向#String流的索引是字符串相对偏移值。

function TJclClrStringsStream.At(const Offset: DWORD): WideString;
var
Idx: Integer;
begin
Idx := FStrings.IndexOfObject(TObject(Offset));
if Idx <> -1 then
Result := GetString(Idx)
else
Result := '';
end;

CLR中包和类名的最大长度都是1024

5.2.2 #Guid 流

#Guid流格式很简单,就是一个GUID结构的数组,以流长度除以SizeOf(TGuid)就是
数组元素个数。因为流中数据是以数组形式存放,指向#Guid流的索引是以1开始的索引值,
如索引为0则表示此项索引不存在。(注意,CLR中所有索引都是以1开始的,0表示索引值
不存在)示例代码如下:

constructor TJclClrGuidStream.Create(
const AMetadata: TJclPeMetadata;
const AHeader: PClrStreamHeader);
var
I: Integer;
pg: PGUID;
begin
inherited;

SetLength(FGuids, Size div SizeOf(TGuid));
pg := Data;
for I:=0 to GetGuidCount-1 do
begin
FGuids[I] := pg^;
Inc(pg);
end;
end;

5.2.3 #Blob 和 #US 流

#Blob流是一个二进制数据堆,程序中的所有非字符串形式数据都堆放在这个流里面,
如常数的值,Public Key的值,方法的Signature等等。
在每个二进制数据块头,都有一个块长度数据,但为了节约存储空间,CLR使用了比较
麻烦的编码方法。
如果开始一个字节最高位为0,则此数据块长度为一个字节;
如果开始一个字节最高位为10,则此数据块长度为两个字节;
如果开始一个字节最高位为110,则此数据块长度为四个字节;
在屏蔽标志位后,通过移位运算即可计算出数据块的实际长度值,并依据此获得数据。

constructor TJclClrBlobRecord.Create(
const AStream: TJclClrStream;
const APtr: PByteArray);
var
b: Byte;
AData: Pointer;
ASize: DWORD;
begin
FPtr := APtr;
FOffset := DWORD(FPtr) - DWORD(AStream.Data);

b := FPtr[0];
if b = 0 then
begin
AData := @FPtr[1];
ASize := 0;
end
else if ((b and $C0) = $C0) and ((b and $20) = 0) then // 110bs
begin
AData := @FPtr[4];
ASize := ((b and $1F) shl 24) + (FPtr[1] shl 16) + (FPtr[2] shl 8) + FPtr[3];
end
else if ((b and $80) = $80) and ((b and $40) = 0) then // 10bs
begin
AData := @FPtr[2];
ASize := ((b and $3F) shl 8) + FPtr[1];
end
else
begin
AData := @FPtr[1];
ASize := b and $7F;
end;

Assert(not IsBadReadPtr(AData, ASize));

inherited Create(AData, ASize);
end;

constructor TJclClrBlobStream.Create(
const AMetadata: TJclPeMetadata;
const AHeader: PClrStreamHeader);
var
ABlob: TJclClrBlobRecord;
begin
inherited;

FBlobs := TObjectList.Create;

ABlob := TJclClrBlobRecord.Create(Self, Data);
while Assigned(ABlob) do
begin
if ABlob.Size > 0 then
FBlobs.Add(ABlob);
if (Integer(ABlob.Memory) + ABlob.Size) < (Integer(Self.Data) + Integer(Self.Size)) then
ABlob := TJclClrBlobRecord.Create(Self, Pointer(Integer(ABlob.Memory) + ABlob.Size))
else
ABlob := nil;
end;
end;

#US流是User String的存储空间,结构上和#Blob流相同,只是所有数据都是
Unicode格式的字符串。
#Blob和#US流的索引都是数据块偏移。

5.2.4 #~ 流

#~流是Metadata中最复杂也是最重要的信息所在,几乎所有Metadata信息
都以表的形式组织存放在#~流中。流起始处有一个#~ Stream Header如下

PClrTableStreamHeader = ^TClrTableStreamHeader;
TClrTableStreamHeader = packed record
Reserved: DWORD; // Reserved, always 0
MajorVersion, // Major version of table schemata, always 1
MinorVersion, // Minor version of table schemata, always 0
HeapSizes, // Bit vector for heap sizes.
Reserved2: Byte; // Reserved, always 1
Valid, // Bit vector of present tables, let n be the number of bits that are 1.
Sorted: Int64; // Bit vector of sorted tables.
// Array of n four byte unsigned integers indicating the number of rows
// for each present table.
Rows: array[0..MaxWord] of DWORD;
//Rows: array[0..n-1] of DWORD;
//Tables: array
end;

字段MajorVersion.MinorVersion是表格式的版本号,一般设置为 1.0

在介绍其他字段意义前,我们要先了解Metadata中表的表示方法。
在Metadata中,表最多有64种,每种表有一个唯一的确定的编号,如Assembly表的
编号为$20、Module表的编号为$00等等。目前的CLR只使用到了最多$2B(43)种表,
其间还有一些表编号是被保留或非公开的。经过对多个信息来源的整理,完整对应表编号如下

TJclClrTableKind = (
ttModule, // $00
ttTypeRef, // $01
ttTypeDef, // $02
ttFieldPtr, // $03
ttFieldDef, // $04
ttMethodPtr, // $05
ttMethodDef, // $06
ttParamPtr, // $07
ttParamDef, // $08
ttInterfaceImpl, // $09
ttMemberRef, // $0a
ttConstant, // $0b
ttCustomAttribute, // $0c
ttFieldMarshal, // $0d
ttDeclSecurity, // $0e
ttClassLayout, // $0f
ttFieldLayout, // $10
ttSignature, // $11
ttEventMap, // $12
ttEventPtr, // $13
ttEventDef, // $14
ttPropertyMap, // $15
ttPropertyPtr, // $16
ttPropertyDef, // $17
ttMethodSemantics, // $18
ttMethodImpl, // $19
ttModuleRef, // $1a
ttTypeSpec, // $1b
ttImplMap, // $1c
ttFieldRVA, // $1d
ttENCLog, // $1e
ttENCMap, // $1f
ttAssembly, // $20
ttAssemblyProcessor, // $21
ttAssemblyOS, // $22
ttAssemblyRef, // $23
ttAssemblyRefProcessor, // $24
ttAssemblyRefOS, // $25
ttFile, // $26
ttExportedType, // $27
ttManifestResource, // $28
ttNestedClass, // $29
ttTypeTyPar, // $2a
ttMethodTyPar); // $2b

在#~流头中,使用了两个64位位图表示这些表的状态,如Valid位图中第$20位为1
则Assembly表在当前Metadata中存在,Sorted位图中第$00位为1则Module表已排序等等。

这里就涉及到在Metadata中非常重要的一个概念Token。一个Token是在一个Metadata
中可以唯一性确定一个记录的标识符,他是一个32位无符号整型数,高8位表示表的编号
低24位表示记录在表中的索引。因此一个Token为$20000001实际上表示编号为$20的
Assembly表中第1项记录。Metadata Unmanaged API就是使用Token来表示每个记录。
对Valid位图进行从低到高位扫描,发现一个表存在,则可到Rows中获取此表中记录
的行数,Rows数组大小为Valid位图中设置的位的数量,也就是Metadata中存在的表
的数量。而Tables则是实际数据的开始。

constructor TJclClrTableStream.Create(
const AMetadata: TJclPeMetadata;
const AHeader: PClrStreamHeader);

function BitCount(const Value: Int64): Integer;
var
AKind: TJclClrTableKind;
begin
Result := 0;
for AKind:=Low(TJclClrTableKind) to High(TJclClrTableKind) do
if (Value and (Int64(1) shl Integer(AKind))) <> 0 then
Inc(Result);
end;

procedure EnumTables;
var
AKind: TJclClrTableKind;
pTable: Pointer;
begin
pTable := @Header.Rows[BitCount(Header.Valid)];
FTableCount := 0;
for AKind:=Low(TJclClrTableKind) to High(TJclClrTableKind) do
begin
if (Header.Valid and (Int64(1) shl Integer(AKind))) <> 0 then
begin
FTables[AKind] := ValidTableMapping[AKind].Create(Self, pTable, Header.Rows[FTableCount]);
pTable := Pointer(DWORD(pTable) + FTables[AKind].Size);
Inc(FTableCount);
end
else
FTables[AKind] := nil;
end;
end;
begin
inherited;

FHeader := Data;

EnumTables;
end;

这里还有一个HeapSizes,表示其他几个流中索引的大小,位$01表示#String流,
$02表示#Guid流,#04表示Blob流。如相应位设置为1,则表示对应流大于MaxWord
也就是大于2^16,因此需要以四字节来做索引,否则缺省用两字节做索引。
我们可以看到,为了节省存储空间,Metadata中使用了相对多的编码方式来合并字段。

6.小结

至此,MS.Net CLR对PE文件结构的扩展的分析,可以告一段落,更深入的对Metadata
表一级的分析,有兴趣的朋友可以参看FrameworkSDK\Tool Developers Guide中的文档、
mscli和mono的代码、也可向我索取为JCL项目编写的JclCLR解析代码(未完成)。
此外网上也有一些独立的开源项目完成了类似的工作。也欢迎大家来信与我讨论。

7.备注

.Net Framework SDK

http://msdn.microsoft.com/downloads/default.asp?url=/downloads/sample.asp?url=/msdn-files/027/000/976/ msdncompositedoc.xml&frame=true

.NET Framework Service Pack 2

http://msdn.microsoft.com/netframework/downloads/sp/download.asp

ms cli

http://msdn.microsoft.com/library/default.asp?url=/library/en-us/dndotnet/html/mssharsourcecli2.asp

mono

http://www.go-mono.com/index.html

anakrino

http://test.saurik.net/anakrino/

6.Metadata 的表结构

在上期中我们分析到Metadata的流结构,提到#~流中以表形式保存着几乎所有
Metadata的重要信息。这一期让我们一起来看看#~流中到底有些什么。

6.1 表的组织结构

在分析#~流时,我们了解到#~流头中两个Int64类型字段Valid和Sortd,
以位图形式表示当前#~流中有那些类型的表存在和已排序。因而我们可以先计算
Valid有多少位被设置为1,然后计算表在#~流中的实际偏移,代码如下

constructor TJclClrTableStream.Create(const AMetadata: TJclPeMetadata;
const AHeader: PClrStreamHeader);

function BitCount(const Value: Int64): Integer;
var
AKind: TJclClrTableKind;
begin
Result := 0;
for AKind:=Low(TJclClrTableKind) to High(TJclClrTableKind) do
if (Value and (Int64(1) shl Integer(AKind))) <> 0 then
Inc(Result);
end;

procedure EnumTables;
var
AKind: TJclClrTableKind;
pTable: Pointer;
begin
pTable := @Header.Rows[BitCount(Header.Valid)];
FTableCount := 0;
for AKind:=Low(TJclClrTableKind) to High(TJclClrTableKind) do
begin
if (Header.Valid and (Int64(1) shl Integer(AKind))) <> 0 then
begin
FTables[AKind] := ValidTableMapping[AKind].Create(Self, pTable, Header.Rows[FTableCount]);
pTable := Pointer(DWORD(pTable) + FTables[AKind].Size);
Inc(FTableCount);
end
else
FTables[AKind] := nil;
end;
end;
begin
inherited;

FHeader := Data;

EnumTables;
end;

实现上,对每个类型的表,可以通过ValidTableMapping数组,将之映射到一个TJclClrTableClass
的元类型数组(Delphi中通过RTTI提供的Type of Type),然后建立新的对象分析Metadata表。
如对ttMethodDef($06)映射到TJclClrTableMethodDef类,实际解析表的代码存在于
TJclClrTable的子类中。因为每个表都可以看作若干行(row)组成,所以抽象出基类TJclClrTableRow
表示#~流中每一行(row)的数据。

ValidTableMapping: array[TJclClrTableKind] of TJclClrTableClass = (
TJclClrTableModule, // $00 ttModule
TJclClrTableTypeRef, // $01 ttTypeRef
...
TJclClrTableTypeTyPar, // $2A ttTypeTyPar
TJclClrTableMethodTyPar); // $2B ttMethodTyPar

下面我们来看看#~流中不同表所起的作用与意义

6.2 Assembly 相关

在Assembly中最高级别的信息是关于Assembly的信息,相关表有ttAssembly($20),
ttAssemblyProcessor($21), ttAssemblyOS($22), ttAssemblyRef($23),
ttAssemblyRefProcessor($24), ttAssemblyRefOS($25)六种表。其中ttAssembly和
ttAssemblyRef是使用最多的,其它几种因为目前CLR不强调跨平台性,故而使用较少。
其次是关于File和Module一级的信息。每个Assembly可以有多个File和多个Module,
File分为有Metadata和没有Metadata两类,前者如IL代码所在,后者包括资源文件等等。
此外每个Assembly有一个唯一的Manifest,维护Assembly的命名数据列表。

6.2.1 ttAssembly($20)

首先来看看每个Assembly必须有的ttAssembly($20)表。

constructor TJclClrTableAssemblyRow.Create(const ATable: TJclClrTable);
begin
inherited;

FHashAlgId := Table.ReadDWord;

FMajorVersion := Table.ReadWord;
FMinorVersion := Table.ReadWord;
FBuildNumber := Table.ReadWord;
FRevisionNumber := Table.ReadWord;

FFlagMask := Table.ReadDWord;

FPublicKeyOffset := Table.ReadIndex(hkBlob);
FNameOffset := Table.ReadIndex(hkString);
FCultureOffset := Table.ReadIndex(hkString);
end;

每个ttAssembly表项(很多表有且只能有一项,如ttAssembly表),首先是一个双字的Hash
算法编号,如None(0), MD5($8003), SHA1($8004),此hash算法编号可通过IL汇编指令
.hash algorithm 指定,说明Assembly中hash操作使用的算法。算法的编号是MS CryptAPI
中定义的,可在较新的Platform SDK的WinCrypt.h中找到完整定义,如

#define ALG_CLASS_HASH (4 << 13)
#define ALG_TYPE_ANY (0)
#define ALG_SID_MD5 3
#define CALG_MD5 (ALG_CLASS_HASH | ALG_TYPE_ANY | ALG_SID_MD5) // $8003

接着的四个字表示此Assembly的版本号,如wsdl.exe的版本号为1.0.3300.0
接着的一个双字是Assembly的全局标志,我们等会再详谈
最后是Public Key在#Blob堆中的索引(偏移)、Assembly名字在#String流中的索引(偏移)
和Culture在#String流中的索引(偏移)。
这里的Public Key是用于assembly的完整性及发布者验证;Name是Assembly的名字;
Culture是此Assembly所支持的语言,可为中立"neutral"或其他符合RFC1766的语言名称如"en-US"
注意这里的三个索引都不是定长,是根据metadata相关字段推算出来的,因而使用Table.ReadIndex
函数进行读取,代码如下

function TJclClrTable.ReadIndex(const HeapKind: TJclClrHeapKind): DWORD;
begin
if IsWideIndex(HeapKind) then // 判断堆是否使用双字做索引
Result := ReadDWord
else
Result := ReadWord;
end;

function TJclClrTable.IsWideIndex(const HeapKind: TJclClrHeapKind): Boolean;
begin
Result := Stream.BigHeap[HeapKind];
end;

function TJclClrTableStream.GetBigHeap(const AHeapKind: TJclClrHeapKind): Boolean;
const
HeapSizesMapping: array[TJclClrHeapKind] of DWORD = (1, 2, 4);
begin
Result := (Header.HeapSizes and HeapSizesMapping[AHeapKind]) <> 0;
end;

上一期我们曾经提到过,TClrTableStreamHeader.BigHeap表示几个内建流的索引大小。
而对表的索引,也是采用类似的编码方式,解码代码如下

function TJclClrTable.ReadIndex(const TableKinds: array of TJclClrTableKind): DWORD;
begin
if IsWideIndex(TableKinds) then
Result := ReadDWord
else
Result := ReadWord;
end;

function TJclClrTable.IsWideIndex(const TableKinds: array of TJclClrTableKind): Boolean;
var
I: Integer;
ATable: TJclClrTable;
begin
Result := False;
for I:=Low(TableKinds) to High(TableKinds) do
if Stream.FindTable(TableKinds[I], ATable) then
Result := Result or (ATable.RowCount > MAXWORD);
end;

通过检测索引可能引用的几个表的大小是否超过MAXWORD,如超过则使用DWORD作为索引。
最后我们看看Assembly的几个标志位的意义

// Assembly attr bits, used by DefineAssembly.
typedef enum CorAssemblyFlags
{
afPublicKey = 0x0001, // The assembly ref holds the full (unhashed) public key.

afCompatibilityMask = 0x0070,
afSideBySideCompatible = 0x0000, // The assembly is side by side compatible.
afNonSideBySideAppDomain= 0x0010, // The assembly cannot execute with other versions if
// they are executing in the same application domain.
afNonSideBySideProcess = 0x0020, // The assembly cannot execute with other versions if
// they are executing in the same process.
afNonSideBySideMachine = 0x0030, // The assembly cannot execute with other versions if
// they are executing on the same machine.

afEnableJITcompileTracking = 0x8000, // From "DebuggableAttribute".
afDisableJITcompileOptimizer= 0x4000, // From "DebuggableAttribute".

} CorAssemblyFlags;

afPublicKey定义此Assembly包含未Hash处理的完整public key
通过此public key可以对assembly的完整性和发布者进行验证。
接下来的五个标志定义此Assembly在AppDomain中被载入时的兼容性行为。
传统的使用DLL的程序很容易因为DLL版本号不同造成冲突,也就是我们熟知的"DLL Hell"
COM通过注册表保存IID定位,部分解决此问题,CLR则使用gac(Global Assembly Cache)
完全解决了此问题。gac将对提交的每个版本的assembly保存唯一的备份,
在使用assembly时可以静态、动态指定版本号,彻底避免不同版本之间的冲突。
同时CLR还允许多个不同版本的Assembly同时使用,也就是Side By Side模式。
Assembly的标志位定义了此Assembly在Side By Side模式运行时的限制。
afNonSideBySideMachine(0x0030)要求一台机器同时只能有一个版本执行;
afNonSideBySideProcess(0x0020)要求一个进程中同时只能有一个版本执行;
afNonSideBySideAppDomain(0x0010)要求一个AppDomain中同时只能有一个版本执行。
afSideBySideCompatible(0x0000)则说明此Assembly可任意使用于Side by Side模式。
注意这里的AppDomain是CLR提出的一个介于Process和Thread之间的逻辑单元。
兼有Process的安全性、独立性、稳定性和Thread的轻便、资源共享的优点
Assembly在AppDomain中载入执行,一个Process可以有多个AppDomain,
逻辑线程存在于AppDomain中,物理线程则可跨AppDomain使用等等。
剩余的两个标志用于JIT编译器调试、跟踪和优化使用,略去。
下面是演示程序对wsdl.exe的ttAssembly表分析的结果:

Name: wsdl
Version: 1.0.3300.0
Flag: cafPublicKey cafSideBySideCompatible
Hash Algorithm: SHA1
Public Key:
00001AE5: 00 24 00 00 04 80 00 00 ; ........
...(略去)...
00001B7D: 6D C0 93 34 4D 5A D2 93 ; m..4MZ..

或者用IL格式代码表示为

.assembly /*20000001*/ wsdl
{
.publickey = (00 24 00 00 04 80 00 00 94 00 00 00 06 02 00 00 // .$..............
... (略去) ...
26 1C 8A 12 43 65 18 20 6D C0 93 34 4D 5A D2 93 ) // &...Ce. m..4MZ..

.hash algorithm 0x00008004
.ver 1.0.3300.0
}


6.2.2 ttAssemblyRef($23)

ttAssemblyRef表和ttAssembly表结构非常类似,只不过是用于Assembly引用声明
类似于原PE结构中的Import节中对DLL引用的声明。

constructor TJclClrTableAssemblyRefRow.Create(const ATable: TJclClrTable);
begin
inherited;

FMajorVersion := Table.ReadWord;
FMinorVersion := Table.ReadWord;
FBuildNumber := Table.ReadWord;
FRevisionNumber := Table.ReadWord;

FFlagMask := Table.ReadDWord;

FPublicKeyOrTokenOffset := Table.ReadIndex(hkBlob);
FNameOffset := Table.ReadIndex(hkString);
FCultureOffset := Table.ReadIndex(hkString);
FHashValueOffsetOffset := Table.ReadIndex(hkBlob);
end;

首先是四个字的版本号,接着是引用的Assembly的标志,但此标志只有一位
afPublicKey(0x0001)有效,其它位应该为0。此外PublicKeyOrToken
是一个#Blob流索引,一般指向引用的Assembly的public key的hash值,
如Flag被设置afPublicKey标志则保存完整的public key(很少)。
ttAssemblyRef表可以使用IL汇编的.assembly extern指令指定,如

.assembly extern /*23000001*/ mscorlib
{
.publickeytoken = (B7 7A 5C 56 19 34 E0 89 ) // .z\V.4..
.ver 1:0:3300:0
}

6.2.3 ttAssemblyProcessor($21) 和 ttAssemblyRefProcessor($24)
ttAssemblyOS($22) 和 ttAssemblyRefOS($25)

ttAssemblyProcessor表只有一个字段Processor表示Assembly所适用的处理器类型。
ttAssemblyRefProcessor表比ttAssemblyProcessor表多了一个AssemblyRef表的索引
表示指定AssemblyRef表项的引用Assembly所适用的处理器类型。

ttAssemblyOS表有三个DWORD字段,分别表示此Assembly所适用的平台及平台的主副版本号。
ttAssemblyRefOS表比ttAssemblyOS表多一个AssemblyRef表的索引
表示指定AssemblyRef表项的引用Assembly所适用的操作系统信息。

这四个表都是为CLR以后的跨平台实现设计的,目前都没有使用到。

6.2.4 ttFile($26)

ttFile表定义了此Assembly所包含的文件列表。
与传统DLL不同,Assembly只是一个逻辑单位,可以有一系列物理上独立的单元组成,
如一个或多个IL代码模块,任意个资源模块等等。

constructor TJclClrTableFileRow.Create(const ATable: TJclClrTable);
begin
inherited;

FFlags := Table.ReadDWord;
FNameOffset := Table.ReadIndex(hkString);
FHashValueOffset := Table.ReadIndex(hkBlob);
end;

ttFile表项包括一个双字的标志,和文件名字、Hash值偏移索引。

// File attr bits, used by DefineFile.
typedef enum CorFileFlags
{
ffContainsMetaData = 0x0000, // This is not a resource file
ffContainsNoMetaData = 0x0001, // This is a resource file or other non-metadata-containing file
} CorFileFlags;

Flags标志定义此File是否包含Metadata,如有Metadata则在载入此Module时,
系统Module Loader必须完成完整性、类型安全、权限等等一系列验证,而如果没有,
则作为资源文件直接载入,载入效率大大提高。

6.2.5 ttManifestResource($28)

ttManifestResource表定义此Assembly包含的信息列表所在。

constructor TJclClrTableManifestResourceRow.Create(
const ATable: TJclClrTable);
begin
inherited;

FOffset := Table.ReadDWord;
FFlags := Table.ReadDWord;
FNameOffset := Table.ReadIndex(hkString);
FImplementationIdx := Table.ReadIndex([ttFile, ttAssemblyRef]);
end;

对单个物理文件的Assembly来说,Manifest一般为空,仅有一个自动生成
或以.mresource指令定义的名称,如

.mresource /*28000001*/ public WsdlRes.resources
(
)

对于多个物理文件的Assembly来说,Manifest可以通过ImplementationIdx指出
实际的Assembly定义信息在引用的Assembly或者在指定File的指定Offset上。

.assembly extern
.file at

Manifest可以为公开public或者私有private,同时可以指定Manifest相关属性。

6.2.6 ttModule($00) 和 ttModuleRef($1a)

ttModule定义此文件的模块名称,缺省名称与可执行文件名相同

constructor TJclClrTableModuleRow.Create(const ATable: TJclClrTable);
begin
inherited;

FGeneration := Table.ReadWord; // Generation (reserved, shall be zero)
FNameOffset := Table.ReadIndex(hkString); // Name (index into String heap)
FMvidIdx := Table.ReadIndex(hkGuid); // Mvid (index into Guid heap)
FEncIdIdx := Table.ReadIndex(hkGuid); // Mvid (index into Guid heap)
FEncBaseIdIdx := Table.ReadIndex(hkGuid); // Mvid (index into Guid heap)
end;

Mvid是一个GUID表示此Module,其它域保留。反编译为IL代码为

.module wsdl.exe // MVID:{FBD1DD82-477A-4F2A-985D-347226229D8C}

ttModuleRef则只是指定引用的Module的名称而已

constructor TJclClrTableModuleRefRow.Create(const ATable: TJclClrTable);
begin
inherited;

FNameOffset := Table.ReadIndex(hkString);
end;

6.3 类型定义

Metadata中的类型一级的定义,基本上是围绕着TypeDef表的定义组织的。
每个TypeDef表项定义一个类或一个接口,每个类可以继承且仅可继承一个在
TypeDef、TypeRef或TypeSpec表中定义的父类,但可以通过InterfaceImpl
表的定义实现多个接口。

6.3.1 ttTypeDef ($02)

ttTypeDef表是定义类型的所在。

constructor TJclClrTableTypeDefRow.Create(const ATable: TJclClrTable);
begin
inherited;

FFlags := Table.ReadDWord;
FNameOffset := Table.ReadIndex(hkString);
FNamespaceOffset := Table.ReadIndex(hkString);
FExtendsIdx := Table.ReadIndex([ttTypeDef, ttTypeRef, ttTypeSpec]);
FFieldListIdx := Table.ReadIndex([ttFieldDef]);
FMethodListIdx := Table.ReadIndex([ttMethodDef]);

FFields := nil;
FMethods := nil;
end;

Flags字段是类型标志所在,等会再详细解释。NameOffset和NamespaceOffset定义此类型
的名称和所在命名空间,ExtendsIdx是指向TypeDef、TypeRef和TypeSpec表的索引,表示此
类型继承自的父类,而FieldList和MethodList则是定义字段和方法。
接下来我们来详细分析一下各个字段的意义和使用方法
首先是Flags字段:

tdVisibilityMask = 0x00000007,
tdNotPublic = 0x00000000, // Class is not public scope.
tdPublic = 0x00000001, // Class is public scope.
tdNestedPublic = 0x00000002, // Class is nested with public visibility.
tdNestedPrivate = 0x00000003, // Class is nested with private visibility.
tdNestedFamily = 0x00000004, // Class is nested with family visibility.
tdNestedAssembly = 0x00000005, // Class is nested with assembly visibility.
tdNestedFamANDAssem = 0x00000006, // Class is nested with family and assembly visibility.
tdNestedFamORAssem = 0x00000007, // Class is nested with family or assembly visibility.

CLR中的类型可见性有几个级别,private(NotPublic)、protected(Family)和public
类似于C++中的相应概念,而Assembly(internal)则是CLR中引入的仅对本Assembly中其它类型
可见的新概念,将其可见范围控制在可信的公开域中,使用非常方便。

tdLayoutMask = 0x00000018,
tdAutoLayout = 0x00000000, // Class fields are auto-laid out
tdSequentialLayout = 0x00000008, // Class fields are laid out sequentially
tdExplicitLayout = 0x00000010, // Layout is supplied explicitly

结构布局是CLR为了兼容现有代码和实现跨语言类型定义的新特性。它可以控制类型的数据成员
在内存中的物理组织布局。一般通过StructLayoutAttribute特性定义在类和类型前,限定其
布局方式。缺省状态下是auto由CLR以运行效率最高为目标自行决定;Sequential模式下将根据
StructLayoutAttribute.Pack字段指定的字节对齐方式,以定义顺序排列字段,可指定1, 2,
4, 8, 16, 32, 64或128字节对齐;Explicit模式则由开发者使用FieldOffsetAttribute特性
逐个指定字段的物理位置,如

[StructLayout(LayoutKind.Explicit)]
public struct Rect
{
[FieldOffset(0)] public int left;
[FieldOffset(4)] public int top;
[FieldOffset(8)] public int right;
[FieldOffset(12)] public int bottom;
}

这样一来开发人员可以做到最大限度兼容现有代码,在与其它非CLR系统的语言进行通讯时也更方 便。
此外通过此特性,也可以模拟诸如Pascal语言中的union之类的CLR不支持的特性。

tdClassSemanticsMask = 0x00000020,
tdClass = 0x00000000, // Type is a class.
tdInterface = 0x00000020, // Type is an interface.

TypeDef表的每个项目可以定义一个类Class或一个接口Interface,实现上就是通过此标志位区分

tdAbstract = 0x00000080, // Class is abstract
tdSealed = 0x00000100, // Class is concrete and may not be extended
tdSpecialName = 0x00000400, // Class name is special. Name describes how.

与C++中以=0后缀定义纯虚函数不同,CLR中引入了abstract关键字,但与Object Pascal中的
abstract不同,CLR中的abstract关键字可以引用于方法和类两个层面,进而可以更灵活地控制类的性质。
Sealed关键字则是CLR引入的新关键字,限定此类不能被继承,这个关键字非常好用,不像C++中得使用
特殊技巧来实现相似的效果。

tdImport = 0x00001000, // Class / interface is imported
tdSerializable = 0x00002000, // The class is Serializable.

Import类是使用诸如TlbImp.exe之类的工具,直接由COM类型库导入的包装类,其类只是作为
与COM库代码进行交互的stub存在,并不提供实现代码。可通过对ImportedFromTypeLibAttribute
特性的检测来判定。
Serializable类则是通过SerializableAttribute特性定义的可支持序列化操作的类。
对绝大部分类来说,CLR可以做到只需定义类支持序列化,就可以自动通过Reflection提供实现。
这正是由于CLR程序中强大的Metadata提供的支持才得以实现的。

tdStringFormatMask = 0x00030000,
tdAnsiClass = 0x00000000, // LPTSTR is interpreted as ANSI in this class
tdUnicodeClass = 0x00010000, // LPTSTR is interpreted as UNICODE
tdAutoClass = 0x00020000, // LPTSTR is interpreted automatically

在与现有DLL形式代码进行交互时,可以通过DllImportAttribute特性定义导入函数,
其中DllImportAttribute.CharSet字段定义了函数中使用字符串的形式。标准的Win32 API
一般都提供了A和W两种后缀的实现,前者是ANSI或者是WIDE的缩写,表示char和wchar_t两类
字符串。当定义字符串格式为auto时,CLR会自动判断使用宽窄字符,也可以用Ansi或Unicode指定。

tdBeforeFieldInit = 0x00100000, // Initialize the class any time before first static field access.
tdRTSpecialName = 0x00000800, // Runtime should check name encoding.
tdHasSecurity = 0x00040000, // Class has security associate with it.

其它几个标志使用机会不多,不再罗嗦了。
ExtendsIdx是指向TypeDef、TypeRef和TypeSpec表的索引,定义此类型的父类。
对接口来说,此字段应为空,而是通过InterfaceImpl表实现多个接口;而对于类型来说,
除了System.Object以外,所有类型都必须是从其它类型继承而来的,而最终类型树归结到
System.Object类型上,因而CLR中的类型是类似Java/Delphi中的单根组织模式。
这样的组织模式,结构跟清晰,实现诸如序列化之类的特性也更容易,是语言发展的大趋势。
值得注意的是这里有几个特殊限制,如凡是值类型都必须继承自System.ValueType,
所有枚举类型必须直接继承自System.Enum,所有delegate类型必须继承自
System.Delegate<=System.MulticastDelegate等等。这几种特殊形式继承的深度都
不能超过一层,这是在语言一级得到保障的。
FieldListIdx和MethodListIdx分别是一个索引标记,定义在Field和Method表中
一个终止点。在对Field和Method表的项目进行分配时,实际上是根据这些终止点将表项
分派的,上一个终止点后,此终止点前的所有项目挂接在此类型上。实现代码类似如下

procedure TJclClrTableTypeDefRow.UpdateFields;
var
FieldTable: TJclClrTableField;
Idx, MaxFieldListIdx: DWORD;
begin
with Table as TJclClrTableTypeDef do
if not Assigned(FFields) and (FieldListIdx <> 0) and
Stream.FindTable(ttFieldDef, TJclClrTable(FieldTable)) then
begin
if RowCount > (Index+1) then
MaxFieldListIdx := Rows[Index+1].FieldListIdx-1
else
MaxFieldListIdx := FieldTable.RowCount;
if (FieldListIdx-1) < MaxFieldListIdx then
begin
FFields := TList.Create;
for Idx:=FieldListIdx-1 to MaxFieldListIdx-1 do
begin
FFields.Add(FieldTable.Rows[Idx]);
FieldTable.Rows[Idx].SetParentToken(Self);
end;
end;
end;
end;

6.3.2 ttTypeRef ($01)

与AssemblyRef表类似,TypeRef表是定义对其它类型的引用的,
每个项目定义一个类型,通过名字在目标范围中定位。

constructor TJclClrTableTypeRefRow.Create(const ATable: TJclClrTable);
begin
inherited;

FResolutionScopeIdx := Table.ReadIndex([ttModule, ttModuleRef, ttAssemblyRef, ttTypeRef]);
FNameOffset := Table.ReadIndex(hkString);
FNamespaceOffset := Table.ReadIndex(hkString);
end;

ResolutionScopeIdx字段是指向ttModule, ttModuleRef,
ttAssemblyRef或ttTypeRef表的索引;Name和Namespace定义
类型的名称和命名空间。
这里的ResolutionScopeIdx可以非常灵活地定义此引用类型的解析域。
对Token在AssemblyRef表的情况,此类型在指定的外部Assembly中实现;
对ModuleRef情况,此类型在同一Assembly但不同的Module中实现;
对Module情况,此类型在同一Assembly的同一Module中实现(很少见);
对TypeRef情况,此类型为嵌套类型,在指定的类型中被定义。最后还有
ResolutionScopeIdx为空的情况,则ExportedType表中会有一项
定义此TypeRef的类型被哪个File或Assembly实现。

6.3.3 ttExportedType ($27)

ExportedType表用于在一个由多个Module组成的Assembly中,在一个Module里面
定义由其它Module定义、实现的公开类型。

constructor TJclClrTableExportedTypeRow.Create(const ATable: TJclClrTable);
begin
inherited;

FFlags := Table.ReadDWord;
FTypeDefIdx := Table.ReadDWord;
FTypeNameOffset := Table.ReadIndex(hkString);
FTypeNamespaceOffset := Table.ReadIndex(hkString);
FImplementationIdx := Table.ReadIndex([ttFile, ttExportedType]);
end;

其Flags基本上与TypeDef的标志位相同;ImplementationIdx和TypeDefIdx组合
用于定位一个类型,前者指定类型实现的文件或再次指向一个ExportedType表项目(用于
嵌套类型定义),TypeDefIdx表示目标文件的Metadata的TypeDef表的索引号;Name和
Namespace定义名字和名称空间。

6.3.4 ttTypeSpec ($1B)

在TypeDef表项的ExtendsIdx字段,指向的除了TypeDef和TypeRef表项,
还可以指向一个TypeSpec表项。

constructor TJclClrTableTypeSpecRow.Create(const ATable: TJclClrTable);
begin
inherited;

FSignatureOffset := Table.ReadIndex(hkBlob);
end;

TypeSpec表可以说是Metadata中最简单或者说最复杂的表 :),简单是指其只有一个字段
指向一个二进制数据块;复杂则是指此数据块的定义可以根据编码非常复杂。
这里就涉及到一个CLR中的Signature的概念。在传统语言如C++中,一个Signature往往
表示一个函数或类型,所有定义类型信息的集合,对函数而言,可能包括函数名、返回参数、
调用方式、参数个数、参数名称、参数类型等等一堆信息。在CLR中,通过将这些信息以有规律的
二进制编码进行压缩组织,组成所谓的Signature。你可以将之理解为一个小型的自有格式的
类型的Metadata信息块。
举个简单的例子,在一个较简单的字段定义的Signature中:第一个字节是一个标志位,
表示此Signature是一个FieldSig(标志字节为 IMAGE_CEE_CS_CALLCONV_FIELD=6),
接着是两个可选字节定义CustomMod(custom modifier 等会详细介绍),然后是几个字节
定义字段的类型。
对CustomMod来说,第一字节是CMOD_OPT($20)或CMOD_REQD($1F),前者说明此
CustomMod可忽略,后者则是必须的。然后跟着一个压缩了的Token指向TypeRef表项。
根据此表项,JIT可以做出定制的举措,如指向Microsoft.VisualC.IsConstModifier类型
表示此参数在方法中是const的;再比如可以使用此机制定义从被管理环境切换到本机代码环境后
调用方法的转换,如转为Cdecl、StdCall、ThisCall或者FastCall,JIT将根据此定义
自动生成转换调用方式的Thunk代码块。等等诸如此类应用可以无限扩充
然后对于FieldSig中的Type部分,在一个类型字节后,根据类型可以跟不同的信息。
如对ValueType和Class类型,后面跟一个编码后的TypeDef和TypeRef表的索引值,
表示此字段实现的值类型或类的定义,而对Ptr或者FnPtr类型的定义更加复杂,这里限于
篇幅不再详述,有兴趣的朋友可以自行参看文档或来信于我探讨……:)
这里值得一提的是,MS在文档中明确指出此TypeSpec将用于parametric polymorphism
的扩展,而且微软研究院最近也推出了基于开源CLI项目的支持泛型编程的C#编译器的原型。
因此可以预见在不远的将来,我们也可以在.Net语言中方便地使用泛型编程思想。
注意这里的parametric polymorphism和C++中支持的Template和Preprocess技术
有所不同:预处理是语言细节无关的普遍性替换,说白了就是字符串替换,虽然可以用于实现
泛型编程思想,但因为其替换过程与语言本身脱离,造成使用的种种不便,被逐渐废弃;
模板技术现在是大放异彩,可以说是泛型编程思想的最好展示工具,但其设计思想是基于静态解析
容易造成代码容量增殖,且模板实例间联系松散,存在编写调试复杂化等等诸多问题;
而参量多态则是Java/C#此类单根语言的GP实现思路,因为其所有类型都源自Object,
因而只需编写、调试、保留一份代码,通过编译器静态或动态对不同参量类型使用相同代码,
代码大小和运行效率都得到保障。此是题外话,就此打住。 :)

6.3.5 ttInterfaceImpl ($09)

前面在分析TypeDef表时,提到类型只能直接继承自一个父类,但可以通过接口映射表
实现多个接口;而接口类型不能直接继承其它类型,而是直接通过接口映射表实现接口。

constructor TJclClrTableInterfaceImplRow.Create(const ATable: TJclClrTable);
begin
inherited;

FClassIdx := Table.ReadIndex([ttTypeDef]);
FInterfaceIdx := Table.ReadIndex([ttTypeDef, ttTypeRef, ttTypeSpec]);
end;

Class是TypeDef表的一个索引,而Interface则是TypeDef、TypeRef或TypeSpec表
的一个索引,这里InterfaceImpl表只是建立一个映射关系而已。

6.3.6 小结

至此,类型一级的定义及实现基本上介绍完了,下一期将继续分析类型中的子元素,
字段(Field)、方法(Method)、属性(Property)和事件(Event)等等表的结构,
以及与之配套的其它辅助表。



<< Home

This page is powered by Blogger. Isn't yours?