Sunday, April 03, 2005

 

Some notes on PE extensions in DotNet - 2

1.
FrameSniper's .NET Cabin
From http://www.cnblogs.com/framesniper/

(1)
Little-endian和Big-endian!在PE文件头(PE头在COFF头之后,PE头有的地方也叫可选头——Optional Header,而COFF头有的地方也直接叫做文件头)的第一个字段Machine中存放的是PE文件所运行于的目标计算机的编号。这里的编号意味着对目标计算机系统的特性描述号码,类似一个指针的功能,不同的值表达着目标计算机的不同性能特征。参考《Inside Microsoft IL Assembler》Page.31中的Machine字段值我们可以看到里面提到了两个概念:小尾数法和大尾数法。这里对这个概念进行一下描述,如下:

小尾数法——低权重字节位于高权重字节之前;
大尾数法——相反;

举例:假设一个DWORD值i,地址是n,那么这个i占据的四字节空间分别是n+0、n+1、n+2和n+3,如果这个i的值是0x11223344,那么对于小尾数的计算机来说,地址为n+0的字节中存放的是44,地址为n+1的字节中存放的是33,依次类推.....而大尾数恰恰相反。仅次而已!

(2)
PE文件中的双重访问机制

关键字:高频访问内容

对于PE文件,在MZ头、Signature、文件头和可选头以及节头表之后便开始存放具体的节实体。节实体是程序员编写程序时候在程序内容指定的一切代码、数据和资源的集合。不同的内容分别放在不同的节之中。但作为PE文件,节实体中的内容有些是需要经常使用的,也就是说使用频率更高一些,而这些内容在节实体中的分布有些是随机的,而有些是等同于节实体的。因此,系统仅仅通过节头表的内容来定位读取节实体中的高频访问内容是肯定不行的,至少对于那些随机分布的高频访问内容是肯定办不到的,因此,为了定位这些内容,PE文件的设计者将定位如此内容的信息组织成数据目录表,并将这个表存放在可选头的尾部。这也是数据目录表的存在的主要原因。

定位高频访问内容的步骤:要定位高频访问内容,就必须知道这些内容在节中的起始地址;因此需要对节的信息进行查询访问,而节的信息是存放在节头表中的,因此也就是对节头表进行遍历查询;所以为了对节头表进行遍历需要知道节头(表)的起始地址和节头表的项数量;因此大体步骤如下:
1.首先获取节头表的起始地址和表项数量,从而具备对节头表进行遍历的基本条件;
2.要定位节的内容,除了前面提到的利用地址进行范围定位外,还可以直接通过节名称进行查询,因为对于类似.rsrc这样的资源数据,其本身就等同于一个节——占据一个专有的节内容,因此可以直接通过名称进行查询定位。至于范围定位是如何进行的,可查找相关资料,这里不在赘述。

由此可以看到,对于一个PE文件,在通过提供了以节头表定位普通内容的基础上又提供了利用数据目录表定位高频访问内容的双重访问机制。

(3)
.NET PE中托管内容布局与三级访问方式对于传统的原生开发环境产生的PE文件,在PE头整体结构之后就是各种节实体。但对于.NET环境下的PE文件,虽然在整体结构上与传统PE一致,但在PE后部的节实体中存放了公共语言运行环境头和公共语言运行环境数据两块内容,这是传统的PE文件所不具备的,存在理由显而易见——公共语言运行环境头可以看做是一本书的目录,而公共语言运行环境数据则可以看做书的内容,而这本书则是用户在支持.NET的IDE中创建.NET PE中的所有托管内容的集合。《Inside Microsoft .NET IL Assembler》看到这里,我才明白原来在.NET PE里面托管和非托管代码的组织是一个很复杂的内容,同时也发现自己利用笔记形式传达自己的心得体会以期通过交流提高的过程需要很长时间。

.NET PE的公共语言运行环境头(Common Language Runtime Environment Header,以后简称CLREH)不是作为一个单独的模块结构出现在PE中的。同样,公共语言运行环境数据(Common Language Runtime Environment Data,以后简称CLRED)也是如此。在.NET PE里面,CLREH和CLRED都是有机的融合到了传统PE的节实体之中——作为目录用途的CLREH存放在.text节,因为是高频访问内容,所以在数据目录中的第15项中包含了指向CLREH的指针和信息,并且CLREH的内容是只读的(原因很简单)。在.NET SDK的CorHdr.H中定义了描述CLREH结构的数据结构,如下:
typedef struct IMAGE_COR20_HEADER
{
ULONG cb;
USHORT MajorRuntimeVersion;
USHORT MinorRuntimeVersion;
IMAGE_DATA_DIRECTORY MetaData; //①
ULONG Flags;
ULONG EntryPointToken;
IMAGE_DATA_DIRECTORY Resources; //②
IMAGE_DATA_DIRECTORY StrongNameSignature; //③
IMAGE_DATA_DIRECTORY CodeManagerTable; //④
IMAGE_DATA_DIRECTORY VTableFixups; //⑤
IMAGE_DATA_DIRECTORY ExportAddressTableJumps; //⑥
IMAGE_DATA_DIRECTORY ManagerNativeHeader; //⑦
} IMAGE_COR20_HEADER;
由这个结构不难看出,在CLREH中定义了7个类型为IMAGE_DATA_DIRECTORY数据目录表项。以前我们说过,IMAGE_DATA_DIRECTORY类型定义在WinNT.H中:
typedef struct _IMAGE_DATA_DIRECTORY{
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
这个结构是为了提高高频访问内容的访问速度而定义的。结构中定义了数据块在内存映像文件中的RVA和尺寸(注意:不是指针,而是加载到内存后的相对地址偏移)。因此,在CLREH中包含着7个不同内容数据块的位置和尺寸信息,其中包含原数据MetaData的定位信息,也包含如资源等的其他数据块的定位信息,但需要始终记住的一点是,CLREH中定义的所有定位信息定位到的数据块都是托管的,都不是直接被本机指令系统执行和使用的,而是通过CLR来实现的。另外,到这里我们可能会提出一个问题:是否.NET PE里面所有的托管内容都包含在这里?答案很明确:如果不从这里定位托管内容以进行访问,.NET PE里面再找不到第二个地方可以承担这样的职责了。因此,CLREH就是OS定位.NET PE托管内容的入口,这也就意味着对于一个.NET PE里面的所有托管内容都可以从这里进行定位以访问,并且只能从这里开始访问。

很有意思的一点是,按照常理,当传统PE演变到.NET PE的时候,对于其中新增加的内容,习惯性的做法应该是增加新的数据模块并与原先的传统PE构件形成物理上的独立。但MS没有这么做,而是将托管内容有机的插入到了传统PE的同等内容和功能的模块中(至少以我目前的知识范畴来看来是如此)。为什么这么做,我想唯一的答案就是:符合已有思维。而这种做法中最令人感觉诧异的一点就是将CLREH的内容放到了.text节而不是想当然的放到了数据目录表中。因此,综观整个.NET PE,对于托管和非托管的高频访问内容的访问还是有略微不同的:对于非托管高频访问内容的访问存在两个步骤,而对于托管高频访问内容的访问则存在三个步骤——首先访问到数据目录表,再通过此处访问到CLREH,最后才访问具体的CLRED。

(4)
CLREH中Flags字段的理解3月27日的Post中,我阐述了.NET PE与传统PE的不同之处,以及MS是如何将托管内容在传统PE的基础上进行融合的,并在最后描述了一下系统访问托管内容的三步跨的方式。那里提到了CLREH的数据结构IMAGE_COR20_HEADER。从今天开始的几篇Posts中我将陆续对IMAGE_COR20_HEADER结构中的若干个有重要意义的字段进行描述。首先将描述的是Flags字段。

Flags意为“旗帜,标签”,因此CLREH结构中的Flags字段应该是存放一些关于托管内容存在特征的信息(我们暂时这么认为),这个字段的功能性应该是类似于文件头中的Characteristics字段的作用的。其值应该是多个个体值的组合,下面我们来看看在Flags字段中可以取到哪些值——Flags字段可取的值并不多,只有5个,如下:
1.COMIMAGE_FLAGS_ILONLY
2.COMIMAGE_FLAGS_32BITREQUIRED
3.COMIMAGE_FLAGS_IL_LIBRARY
4.COMIMAGE_FLAGS_STRONGNAMESIGNED
5.COMIMAGE_FLAGS_TRACKDEBUGDATA
从值的命名不难看出,即使事先不告诉我们这些值是哪个字段的可取值,我们也可以轻易的猜测到正确的答案:“COMIMAGE”代表“Common Language Runtime Environment’s memory image(公共语言运行环境的内存映像)”的意思,因此,这些以“COMIMAGE”打头的值应该是和公共语言运行环境所处理的PE文件中的托管内容相关的值(直到这里,我们仍然认为Flags值表达的约束的受体仅仅是托管内容),换句话说,其中存储的应该是描述托管内容的信息,因此这些值所归属于的字段应该不是在CLREH中就是在CLRED中。“FLAGS”进一步明确的表达了这些值具体归属于哪个字段——CLREH中的Flags字段。最后的部分,例如“ILONLY”或“32BITREQUIRED”等,则明确的表达了字段值所表达的施加于托管内容的约束条件(假设继续存在),详细描述如下:
1.“ILONLY”——从字面意义不难看到,这个值所表达的约束的含义就是“仅仅存在IL代码”。说到这里,我们会问自己:“仅仅存在IL代码”描述的主体是谁?是的,这是一个很重要的问题!细想一下,答案很明确,主体就是.NET PE文件本身,而不是.NET PE中的CLREH或者CLRED。因此,关于Flags字段的含义描述到这里,我们应该可以肯定地推翻我们前面的假设(兰色字体部分):Flags字段所描述的约束的受体不是.NET PE中的托管内容,而是.NET PE本身的内存映像文件。这里的“ILONLY”所表达的约束就是:当前映像文件中只包含IL代码。这意味着整个.NET PE里面只有托管内容吗?不是,还有启动占位程序,除此以外,别无其他任何非托管的本地代码;
2.“32BITREQUIRED”——表达的约束的含义是:当前映像文件只能被32位的CPU所处理;
3.“IL_LIBRARY”——已经过时的标志,含义不是很明确;个人猜测应该是指明映像文件的存在目的是函数输出;
4.“STRONGNAMESIGNED”——表达的约束的含义是:当前映像文件将会受到强命名的保护。关于强命名的含义待定;
5.“TRACKDEBUGDATA”——表明Loader和JIT编译器记录方法的调试信息;记录的目的个人猜测应该是在将来供外部调试程序使用;
至此,Flags字段的所有可选择值的描述已完成,但各种细节仍存在进一步挖掘的巨大空间。我会在以后的学习中与大家共同分享.....牢记一点,CLREH的Flags描述的受体是.NET PE而不是CLREH和CLRED!

(5)
CLREH中EntryPointToken字段的理解接着上一个Post,现在对CLREH中的EntryPointToken字段进行描述。

看字面意思,EntryPointToken意为“进入点的标记”。停下思考,看看PE文件前面所描述内容,我们会发现,在PE可选头中存在一个AddressOfEntryPoint字段,其中存放的是入口点函数的RVA。那么这两个字段都是指向入口点的,那么他们的意义有什么不同呢?进一步查看PE Optional Header(以后简称PEOH)中的AddressOfEntryPoint,可以看到这样的描述“对于非托管DLL,这个值将会是0。对于托管PE文件,这个值总是指向公共语言运行环境调用占位程序”。因此,不难看出,在.NET PE文件中存在两个入口点描述字段,一个是PEOH中的AddressOfEntryPoint,另外一个是CLREH中的EntryPointToken。其中对于PEOH中的AddressOfEntryPoint字段,里面存放的内容对于不同内容类型的可执行体来说是不同的,理解如下:
1.如果PE是非托管的本地.EXE文件,那么这里存放的就是入口函数的地址;
2.如果PE是非托管的本地.DLL文件,那么这里的值为0;
3.如果PE是纯IL代码的.EXE文件,那么这里存放的是CLREH中的EntryPointToken的RVA;
4.如果PE是纯IL代码的.DLL文件,那么这里的值为0;
5.如果PE是托管和非托管结合的.EXE文件,那么这里存放的是CLREH中EntryPointToken的RVA;
6.如果PE是托管和非托管结合的.DLL文件,那么这里的值为0;
综合一下,就是:如果PE是.DLL形式,那么PEOH中的AddressOfEntryPoint的值始终为0;如果PE是.EXE形式,那么当PE是纯本地代码时,此字段即为入口函数地址,否则指向CLREH中的EntryPointToken。

接下来,我们看看CLREH中的EntryPointToken字段,按照Serge Lidin的说法:“公共语言运行环境头的EntryPointToken字段会包含方法定义(MethodDef)或者文件引用(File)的一个标识(元数据标识符)”(《Inside Microsoft .NET IL Assembler》P.40 L.15)。由此可以看到,CLREH中的EntryPointToken可以包含两中元素:
1.方法定义标识;
2.文件引用标识;
对于方法定义标识,其实说白了就是一个方法的地址,但这个方法不同于模块(即配件或应用)中的普通方法——这个方法应该是作为配件的入口点方法。说到入口点方法(Entry Point Method),这里有必要说明一点:所有的托管.EXE文件都必须有一个入口点,但对于组成托管.EXE文件的各个模块未必每个都需要一个EPM,而是只要其中一个有EPM就足够了。那么对于多模块的.EXE中只有一个模块具备EPM的情况下,系统又是如何去定位这个EPM的呢?答案就是文件引用标识。假设一个托管.EXE文件由A、B、C和D四个模块组成,其中A是主模块,其他为辅助模块,并假设D中存在一个EPM,那么这个时候A中的CLREH的EntryPointToken中存放的将是模块D的文件引用,通过此文件引用找到模块D,而模块D的CLREH的EntryPointToken中存放的则是一个方法定义标识,指向D中的EPM。因此,不难看出,只有一种情况才会将CLREH的EntryPointToken字段设置为文件标识,即:多模块托管.EXE文件中的EPM不在主模块中。这个时候会将主模块的CLREH—>EntryPointToken指向具备EPM的辅助模块,并将辅助模块的CLREH—>EntryPointToken指向自己的EPM。

对于.EXE形式的可执行体,必须指定一个EPM,否则ILAsm将拒绝编译没有入口点的模块,除非使用/DLL命令行选项。

从.NET PE里面的两个入口点描述字段我们也可以体会到一点:因为.NET托管程序最终并非是被OS直接执行的,而是被CLR处理执行,因此,两个入口点描述字段的存在是以帮助CLR定位EPM以启动程序为目的的。但是,在支持CLR的OS中,CLR似乎并不是常驻内存的(由《Applied Microsoft .NET Framework Programming》P.9 L.12可见),所以在OS Loader加载程序的过程中需要通过一些信息来判定是否需要加载CLR,而这些信息存在于哪里,唯一的答案就是.NET PE里!

(6)
CLREH中的VTableFixups字段存放的是一个数据目录类型的数据。数据目录我们曾经讲过,是一个包含两个域的结构类型。其中分别存放着.NET PE V表的RVA和容量数据。按照理解,.NET PE V表应该类似于Delphi Object Model中的类的VMT中的虚拟方法表。为了进一步了解V表的作用,我们需要对.NET PE中的方法有所了解。

.NET的世界中的一切都可以动态地实现,同时也可以动态的引用与销毁,这个是Don Box的观点。但Don Box并没有将.NET中最有吸引力的桂冠放在类型、对象和值这些基本语素上面,在他看来,似乎具有动态性地被构造及被使用管理的事物远不如操作这些构造、使用及管理过程的事物更有魅力,而方法作为实体间相互作用的主要方式,无论在任何语言系统中都扮演着极其重要的角色,可以毫不夸张地说,方法就是一个语言系统中最明亮的闪光点,如果一个语言系统没了方法,那么这个语言系统将是沉寂的、缺乏活力的。.NET也是如此!

虽然CLR接受的对象是IL代码,但我们要明白一点:那仅仅是接受,CLR并不会执行IL代码,相反,IL代码必须被转换为本机代码才可以被CLR执行(这点似乎和我以前的说法有冲突,现在看来,CLR应该是负责将IL代码转换为本机代码,并协调系统执行这些本机代码)。将IL转换为Native Code(简称NC)的方法在.NET系统中不外乎两种:
1.Precompiling
2.JIT-compiling
Precompiling的实现依赖于CLR提供的部署工具NGEN.EXE和底层库MSCORPE.DLL;而JIT-compiling的实现则依赖于运行库加载器创建的插桩(stub)程序。虽然默认的转换方式是Precompiling,但并不代表Precompiling更具有优势!Precompiling的缺点是非常明显的,其中最主要的一点就是当方法中使用的某个类型定义于相对于当前模块独立的物理模块(组件)时,外部模块中类型原型的变动将导致当前方法的本地代码无效。针对这个遗憾,MS给出的解决方案是在模块的编译阶段被赋予模块版本标识符(module version identifier,MVID)以标记对于模块的特定的编译具备唯一性,由此可以见,MVID是存放在NC中的。有了MVID,在每次CLR加载器试图缓存NC映像的时候就会自动检测MVID以确定是否存在重编译情况。如果有,则CLR将忽略NC缓存同时返回到IL代码进行JIT-compiling。如我们前面所说,JIT-compiling的实现依赖于运行库加载器创建的stub程序,的确,试想:在CLR检查到NC缓存无效的情况下将对IL代码进行JIT-compiling,而将IL代码转换为NC必定需要CLR中的某个应用的支持,这个支持由.NET中的JIT编译器(JIT-Compiler)提供。但要将控制权转换给JIT-Compiler必须对其进行显示的调用,这个过程绝对不是IDE users的职责,那么这个显示的调用由谁来做呢?答案就是加载器!是的,方法在第一次被执行前CLR会实时编译方法的IL代码,但编译过程之前,CLR需要加载编译中使用的所有类型,包括方法所属类型本身(毕竟方法是类型的行为)。说到这里,我们可以很轻易的猜测出转换控制权到JIT-Compiler的时机是在何处完成的了!的确,在加载类型以初始化的时候,加载器会创建一个stub程序,并把此程序附加到类型的任何方法中。在stub程序中包含着对JIT-Compiler的显示调用。由此,CLR完成了对方法IL代码到CN代码的转换工作。这就完了吗?细想一下,似乎还有什么没做,是的,MS不可能让stub程序把控制权转交给JIT-Compiler就没事了。在JIT-Compiler完成了IL代码到NC的转换后,JIT-Compiler会回写stub程序(加入一条jmp指令)以使stub程序直接跨过IL代码而跳到刚刚产生的NC进行执行。至此,CLR对方法的转换以及执行过程全部结束。

最后总结一下:实时编译依赖于类型加载器产生的插桩程序,对本地代码的直接执行又依赖于实时编译器回写到插桩程序的跳转指令。转换的前奏是类型加载。........这就是计算机,转来转去,始终如一。



<< Home

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