你可曾想到,我们的C#代码,编译之后究竟为何物?你可曾认知,我们的可执行程序,运行之时的轨迹究竟为那般?那么,本文通过对Metadata(元数据)和IL(Intermediate Language, 中间语言)的认识开始,来逐步给出答案。在这个探索轨迹上,元数据、IL、程序集、程序域、JIT、虚分派、方法表和托管堆这些形形色色的神秘嘉宾将在某个时刻不期而遇,作为你必须知道的.NET 系列2.0版本的一部分,本文首先从认识元数据和IL这两位重量级选手开始,而其他的嘉宾也将很快登场。
2 初次接触
在事实上,编译之后的cs代码被组织为两种基本的元素:元数据(Metadata)和IL。我们可以以最简单的方式来了解程序集(*.dll)或可执行文件(*.exe)中包含的Metadata和IL的秘密,这种方式就是我们常说的反编译,打开ILDasm并加载实现准备的程序集,我们可以看到托管PE文件的相关内容:
详细的结构信息和IL代码分析,可以参见[你必须知道的.NET]第3章 “一切从IL开始”的介绍,在此就不做太多的分析。另外,我们可以通过执行“View/MetaInfo/Show!”或者Ctrl+M快捷键来获取该程序集所使用的MetaData信息列表:
图片看不清楚?请点击这里查看原图(大图)。
其中该程序集使用的元数据主要有:Module、TypeRef、TypeDef、Method、Param、MemberRef、CostomAttribute、Assembly、AssemblyRef等,同时还包括#Strings、#GUID、#Blob、#US堆等。
当然,关于ILDasm工具,还有很多好玩的使用方式来满足我们探索IL代码的好奇心,例如:
ildasm Anytao.Insidenet.MetadataIL.exe /output:my.il,将反编译结果导出为il代码格式,生成一个my.il包含了所有的IL代码和一个my.res包含了所有的资源文件。
ildasm Anytao.Insidenet.MetadataIL.exe /text,将反编译结果以Console形式输出。
当然我们还是推荐以GUI形式来查看IL细节,组织结构良好的Class View:
ildasm Anytao.Insidenet.MetadataIL.exe
下面首先给出参与编译的相关代码文件,然后再展开我们对Metadata和IL的讨论:
// Release : code01, 2009/02/12
// Author : Anytao, http://www.anytao.com
// List : One.cs
public class One
{
public int ID { get; set; }
}// Release : code02, 2009/02/12
// Author : Anytao, http://www.anytao.com
// List : Two.cs
public class Two
{
public string SayHello()
{
return "Hello, world.";
}
}// Release : code03, 2009/02/12
// Author : Anytao, http://www.anytao.com
// List : Program.cs
class Program
{
static void Main(string[] args)
{
int id = 1;
One one = new One();
one.ID = id;
Two two = new Two();
Console.WriteLine(two.SayHello());
}
}
接着,我们对上述程序的编译执行过程进行一点探索,以命令行编译器来演化其大致的编译过程,以此进一步了解托管模块,程序集和可执行文件之间的关系:
打开Visual Studio 2008 Command Prompt,并定位到cs代码所在文件夹,编译One.cs为托管模块,执行命令:
csc /t:module One.cs
执行之后,将生成名为One.netmodule文件;
继续执行,将多个模块打包为程序集
执行之后,将生成名为Two.dll文件;
最后,编译Main函数和Two.dll为可执行文件
最终将得到本文开始时所加载的用于反编译的程序集文件Anytao.Insidenet.MetadataIL.exe,在该执行命令中对几个指示符开关做点说明:
/out:Anytao.Insidenet.MetadataIL.exe,表示输出的可执行文件,及其名称
/t:exe,表示输出的文件类型为CUI(控制台界面程序)程序;而/t:winexe,表示输出为GUI(图形界面程序)程序
/r:Two.dll,表示引用刚刚生产的Two.dll程序集
/r:mscorlib.dll,表示因为外部程序集mscorlib.dll,因为我们的程序中使用了Console静态方法,而该方法则被定义在mscorlib.dll中。mscorlib.dll是如此的重要,我们将在本文之后的某些时候再次与mscorlib.dll握手,那时在对其进行一个详细的分析,敬请期待。
在cmd中的执行过程可以参考:
图片看不清楚?请点击这里查看原图(大图)。
通过分步执行的方式我们对csc编译器的执行过程有个基本的了解,也同时从侧面认识了每次在Visual Studio中执行“Build“或者“ReBuild”的缩影。综上分析,我们可以简单的看到:
图片看不清楚?请点击这里查看原图(大图)。
Note:在Visual Studio中,编译是分模块进行的,编译结果保存在obj目录中,最后再合并为可执行文件于bin目录,同时默认情况下,编译过程是增量式的,仅编译发生修改的模块,我将在后文给出较为详细的过程。
同时,我们还可以收获以下几个基本的结论:
cs代码编译之后将生成元数据和IL,并组成托管模块(Module)的基本单元。
多个托管模块组成程序集,其实还包括一定的资源文件,只是没有在此体现。
程序集或者可执行文件是逻辑组织的基本单元,符合基本的Windows PE文件格式,可以被x86或者x64Windows直接加载执行。
3 继续深入
一个或者多个模块,再加上资源文件就形成了程序集(Assembly),作为逻辑组织的基本单元,
事实上,此图仅仅从粗粒度对程序集的基本组成有个大致的了解,实际上程序集中包含了复杂的结构和要素,例如PE Signature、Managed Resources、Strong Name Signature Hash,而其中最核心的要素则体现在上图。
程序集清单(MANIFEST)包含了程序集的自描述信息,主要包含AssemblyDef、FileDef、ManifestResourceDef和ExportedTypeDef,在反编译选项中MANIFEST包含了详细的内容。在《你必须知道的.NET》3.1节 “从Hello,world开始认识IL”对其有过详细的描述,此不赘述。
PE文件头,标准Windows PE头文件(PE32或PE32+),PE文件的基本信息,例如文件类型,创建时间,本地CPU信息等。
CLR头,包含CLR版本、模块元数据、资源等信息。
资源文件。
执行View/Statisctics菜单,可以打开相关的统计信息:
File size : 5632
PE header size : 512 (496 used) ( 9.09%)
PE additional info : 1691 (30.02%)
Num.of PE sections : 3
CLR header size : 72 ( 1.28%)
CLR meta-data size : 2212 (39.28%)
CLR additional info : 0 ( 0.00%)
CLR method headers : 52 ( 0.92%)
Managed code : 287 ( 5.10%)
Data : 2048 (36.36%)
Unaccounted : -1242 (-22.05%)
Num.of PE sections : 3
.text - 3072
.rsrc - 1536
.reloc - 512
CLR meta-data size : 2212
Module - 1 (10 bytes)
TypeDef - 4 (56 bytes) 0 interfaces, 0 explicit layout
TypeRef - 25 (150 bytes)
MethodDef - 8 (112 bytes) 0 abstract, 0 native, 8 bodies
FieldDef - 1 (6 bytes) 0 constant
MemberRef - 29 (174 bytes)
ParamDef - 2 (12 bytes)
CustomAttribute- 16 (96 bytes)
StandAloneSig - 4 (8 bytes)
PropertyMap - 1 (4 bytes)
Property - 1 (6 bytes)
MethodSemantic- 2 (12 bytes)
Assembly - 1 (22 bytes)
AssemblyRef - 1 (20 bytes)
Strings - 920 bytes
Blobs - 328 bytes
UserStrings - 68 bytes
Guids - 16 bytes
Uncategorized - 192 bytes
CLR method headers : 52
Num.of method bodies - 8
Num.of fat headers - 4
Num.of tiny headers - 4
Managed code : 287
Ave method size - 35
我们将在后篇《深入程序集和模块》中对PE头,CLR头和资源文件进行详细论述。
IL代码被组织为
.class public auto ansi beforefieldinit Anytao.Insidenet.MetadataIL.Two
extends [mscorlib]System.Object
{
.method public hidebysig instance string
SayHello() cil managed
{
// Code size 11 (0xb)
.maxstack 1
.locals init ([0] string CS$1$0000)
IL_0000: nop
IL_0001: ldstr "Hello, world."
IL_0006: stloc.0
IL_0007: br.s IL_0009
IL_0009: ldloc.0
IL_000a: ret
} // end of method Two::SayHello
.method public hidebysig specialname rtspecialname
instance void .ctor() cil managed
{
// Code size 7 (0x7)
.maxstack 8
IL_0000: ldarg.0
IL_0001: call instance void [mscorlib]System.Object::.ctor()
IL_0006: ret
} // end of method Two::.ctor
} // end of class Anytao.Insidenet.MetadataIL.Two
包装在类似于汇编模样的外衣下,我看依稀可见class, System.Object, method, public, string这些面向对象高级语言中的熟悉面孔,不同的只是多了很多benforefieldinit(参考:[你必须知道的.NET]第二十三回:品味细节,深入.NET的类型构造器), ret, maxstack, ldstr, stloc这些陌生的指令。然而IL并非一个怪胎,而正是基于其本身面向对象的汇编式风格,才造就了IL代码成为名副其实的“中间语言”的重任。通过IL代码,CLR就可在编译时由JIT编译转换为Native Code,我们将在下节继续分析这个过程的来龙去脉。
很早就有说说Metadata(元数据)和IL(中间语言)的想法了,一直在这篇开始才算脚踏实地的对这两个阶级兄弟投去些细关怀,虽然来得没有《第一回:恩怨情仇:is和as》那么迅速,但是Metadata和IL却是绝对重量级的内容,值得我们在任何时间关注,本文就是开始。
3 元数据是什么?
元数据,就是描述数据的数据。这一概念并非CLR之独创,Metadata存在于任何对数据和数据关系中,例如程序集清单信息也被称为程序集元数据。而不同系统的元数据也相应具有本身的特点,.NET元数据也是如此。那么,CLR元数据描述的是那些内容呢?正如前文的描述一样,编译之后,类型信息将以元数据的形式保存在PE格式文件中。.NET是基于面向对象的,所以元数据描述的主要目标就是面向对象的基本元素:类、类型、属性、方法、字段、参数、特性等,主要包括:
定义表,描述了源代码中定义的类型和成员信息,主要包括:TypeDef、MehodDef、FieldDef、ModuleDef、PropertyDef等。
引用表,描述了源代码中引用的类型和成员信息,引用元素可以是同一程序集的其他模块,也可以是不同程序集的模块,主要包括:AssemblyRef、TypeRef、ModuleRef、MethodsRef等。
指针表,使用指针表引用未知代码,主要包括:MethodPtr、FieldPtr、ParamPtr等。
堆,以stream的形式保存的信息堆,主要包括:#String、#Blob、#US、#GUIDe等。
如前文所述,我们以ILDasm.exe可以通过反编译的方式,通过执行Ctrl+M快捷键来获取该程序集所使用的MetaData信息列表,在.NET中每个模块包含了44个CLR元数据表,如下:
表记录 | 元数据表 | 说明 |
0(0) | ModuleDef | 描述当前模块 |
1(0x1) | TypeRef | 描述引用Type,为每个引用到类型保存一条记录 |
2(0x2) | TypeDef | 描述Type定义,每个Type将在TypeDef表中保存一条记录 |
3(0x3) | FieldPtr | 描述字段指针,定义类的字段时的中间查找表 |
4(0x4) | FieldDef | 描述字段定义 |
5(0x5) | MethodPtr | 描述方法指针,定义类的方法时的中间查找表 |
6(0x6) | MethodDef | 描述方法定义 |
7(0x7) | ParamPtr | 描述参数指针,定义类的参数时的中间查找表 |
8(0x8) | ParamDef | 描述方法的参数定义 |
9(0x9) | InterfaceImpl | 描述有那些类型实现了那些接口 |
10(0xa) | MemberRef | 描述引用成员的情况,引用成员可以是方法、字段还有属性。 |
11(0xb) | Constant | 描述了参数、字段和属性的常数值 |
12(0xc) | CustomAttribute | 描述了特性的定义 |
13(0xd) | FieldMarshal | 描述了与非托管代码交互时,参数和字段的传递方式。 |
14(0xe) | DeclSecurity | 描述了对于类、方法和程序集的安全性 |
15(0xf) | ClassLayout | 描述类加载时的布局信息 |
16(0x10) | FieldLayout | 描述单个字段的偏移或序号 |
17(0x11) | StandAloneSig | 描述未被任何其他表引用的签名 |
18(0x12) | EventMap | 描述类的事件列表 |
19(0x13) | EventPtr | 描述了事件指针,定义事件时的中间查找表 |
20(0x14) | Event | 描述事件 |
21(0x15) | PropertyMap | 描述类的属性列表 |
22(0x16) | PropertyPtr | 描述了属性指针,定义类的属性时的中间查找表 |
23(0x17) | Property | 描述属性 |
24(0x18) | MethodSemantics | 描述事件、属性与方法的关联 |
25(0x19) | MethodImpl | 描述方法的实现 |
26(0x1a) | ModuleRef | 描述外部模块的引用 |
27(0x1b) | TypeSpec | 描述了对TypeDef或者TypeRef的说明 |
28(0x1c) | ImplMap | 描述了程序集使用的所有非托管代码的方法 |
29(0x1d) | FieldRVA | 字段表的扩展,RVA给出了一个字段的原始值位置 |
30(0x1e) | ENCLog | 描述在Edit-And-Continue模式中那些元数据被修改过 |
31(0x1f) | ENCMap | 描述在Edit-And-Continue模式中的映射 |
32(0x20) | Assembly | 描述程序集定义 |
33(0x21) | AssemblyProcessor | 未使用 |
34(0x22) | AssemblyOS | 未使用 |
35(0x23) | AssemblyRef | 描述引用的程序集 |
36(0x24) | AssemblyRefProcessor | 未使用 |
37(0x25) | AssemblyRefOS | 未使用 |
38(0x26) | File | 描述外部文件 |
39(0x27) | ExportedType | 描述在同一程序集但不同模块,有那些类型 |
40(0x28) | ManifestResource | 描述资源信息 |
41(0x29) | NestedClass | 描述嵌套类型定义 |
42(0x2a) | GenericParam | 描述了泛型类型定义或者泛型方法定义所使用的泛型参数 |
43(0x2b) | MethodSpec | 描述泛型方法的实例化 |
44(0x2c) | GenericParamConstraint | 描述了每个泛型参数的约束 |
然后是6个命名堆:
堆
|
说明
|
#String | 一个AscII string数组,被元数据表所引用,来表示方法名、字段名、类名、变量名以及资源相关字符串,但不包含string literals。 |
#Blob | 包含元数据引用的二进制对象,但不包含用户定义对象 |
#US | 一个unicode string数组,包含了定义在代码中的字符串(string literals),这些字符串可以直接由ldstr指令加载获取,还记得吗?我们在《第二十二回:字符串驻留(上)---带着问题思考》中对字符串创建过程的论述吗? |
#GUID | 保存了128byte的GUID值,由元数据表引用 |
#~ | 一个特殊堆,包含了所有的元数据表,会引用其他的堆。 |
#- | 一个未压缩的#~堆。除了#-堆,其他堆都是压缩的。 |
Note:对于#String和#US,一个简单的区别就是:
string hello = "Hello, World";
变量hello名,将保存在#String,而代码中字符串信息“Hello, World”则被保存在#US中。
关于元数据信息的详细描述,例如每个表包含那些列,不同表间的关系,请参考[Standard ECMA-335]和[The .NET File Format]。
在PE文件格式中,Metadata有着复杂的结构,我试图以数据库管理数据的角度出发来理解元数据的结构和关系,所以表示元数据的逻辑结构被成为元数据表,类似于数据库表有主键和Sechema,元数据表以RID(表索引)和元-元数据表示类同的概念,以TypeDef表为例,通过数据引用关系同时与Field、Method、TypeRef等表发生关联,其他表间又有类似的关系,从而形成一个复杂的类数据库结构:
因此,元数据是保存了类型的编译后数据,是.NET程序运行的基础,我们可以在运行时动态的以反射的方式获取元数据信息,而这些信息在.NET Framework中以System.Type、MethodInfo等封装,例如截取MSDN中一个类间关系的简单示例:
对于每个CLR类型而言都可以通过Object.GetType方法返回其Type,从而任意的取到所有的运行时元数据信息:
// Release : code04, 2009/02/21
// Author : Anytao, http://www.anytao.com
// List : Program.cs
private static void ShowMemberInfo()
{
var assems = AppDomain.CurrentDomain.GetAssemblies();
foreach (Assembly ass in assems)
{
foreach (Type t in ass.GetTypes())
{
foreach (MemberInfo mi in t.GetMembers())
{
Console.WriteLine("Name:{0}, Type:{1}", mi.Name, mi.MemberType.ToString());
}
}
}
}
执行上述方法,将获取一个长长的列表,看到很多熟悉的符号:-)
4 IL是什么?
IL,又称为CIL或者MSIL,翻译为中文就是中间语言,由ECMA组织(Standard ECMA-335)提供完整的定义和规范。顾名思义,中间语言正如它的名称所言,任何与CLR兼容的编译器所生成的都是中间语言代码,这是实现CLR跨语言的基础结构之一。IL就像一座桥梁,其指令集独立于CPU指令而存在,可以由JIT编译器在运行时翻译为本地代码执行,连接了任何遵守CLS规范的高级语言,为.NET平台提供了最基本的支持。在[你必须知道的.NET]一书中,我用一整章(第3章 “一切从IL开始”)的篇幅对IL的基本内容进行了相应的介绍,所以关于IL的基础内容例如基本类型、IL分析方法、常见指令、基本运算等,就不在本文有所赘述,只对IL基本内容进行一点小结:
IL是一种面向对象的机器语言,因此具有面向对象语言的所有特性,类、对象、继承、多态等仍然是IL语言的基本概念。
IL指令独立于CPU指令,CLR通过JIT编译机制将其转换为本地代码。
IL和元数据是了解CLR运行机制的重要内容,对于我们打开CLR神秘面纱有着重要的意义。
如前文[初次接触]部分论述的一样,可以通过ILDasm.exe或者Reflector工具对托管代码执行反编译来查看其IL代码,对于很多情况下IL代码分析可以解决很多高级语言隐藏的语法糖游戏,例如C#3.0提出的自动属性、隐式类型、匿名类型、扩展方法等都可以很快从IL分析中找到答案,所以适当的了解IL是必要的。那么我们在下面JIT编译时的一个片段来了解IL代码对于托管程序执行的作用。
另外,Metadata描述了静态的结构,而IL阐释了动态的执行,而IL代码是通过一个4字节大小的地址引用元数据表的。该引用被称为元数据符号(Metadata Token,也就是记录元数据表的位置信息),在ILdasm.exe工具中选中“Show token values”,就可以在IL代码中看到IL代码通过Metadata Token引用元数据表的情况:
.method /*06000003*/ private hidebysig static
void Main(string[] args) cil managed
{
.entrypoint
// Code size 36 (0x24)
.maxstack 2
.locals /*11000002*/ init ([0] int32 id,
[1] class Anytao.Insidenet.MetadataIL.One/*02000004*/ one,
[2] class Anytao.Insidenet.MetadataIL.Two/*02000002*/ two)
IL_0000: nop
IL_0001: ldc.i4.1
IL_0002: stloc.0
IL_0003: newobj instance void Anytao.Insidenet.MetadataIL.One/*02000004*/::.ctor() /* 06000007 */
IL_0008: stloc.1
IL_0009: ldloc.1
IL_000a: ldloc.0
IL_000b: callvirt instance void Anytao.Insidenet.MetadataIL.One/*02000004*/::set_ID(int32) /* 06000006 */
IL_0010: nop
IL_0011: newobj instance void Anytao.Insidenet.MetadataIL.Two/*02000002*/::.ctor() /* 06000002 */
IL_0016: stloc.2
IL_0017: ldloc.2
IL_0018: callvirt instance string Anytao.Insidenet.MetadataIL.Two/*02000002*/::SayHello() /* 06000001 */
IL_001d: call void [mscorlib/*23000001*/]System.Console/*01000012*/::WriteLine(string) /* 0A000011 */
IL_0022: nop
IL_0023: ret
} // end of method Program::Main
其中,按照ECMA定义的规范,元数据第一个字节表示引用的元数据表,而其余三个字节则表示在相应元数据表中的记录,例如06000003表示了引用了MethodDef(06)表的000003项Main方法。
我们可以通过Type的MetadataToken属性在运行时反射获取类型的元数据符号,例如:
static void Main(string[] args)
{
Console.WriteLine(typeof(One).MetadataToken);
}
有了上述所有的准备,我们就可以着手分析元数据和IL在程序执行时的角色和关联。