C# MSIL学习

1 引言

  你可曾想到,我们的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文件;

  继续执行,将多个模块打包为程序集

csc /t:library /addmodule:One.netmodule Two.cs  

 

  执行之后,将生成名为Two.dll文件;

  最后,编译Main函数和Two.dll为可执行文件

csc /out:Anytao.Insidenet.MetatdataIL.exe /t:exe /r:Two.dll /r:mscorlib.dll Program.cs  

 

  最终将得到本文开始时所加载的用于反编译的程序集文件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,我们将在下节继续分析这个过程的来龙去脉。


我们对PE文件、程序集、托管模块,这些概念与元数据、IL的关系进行了必要的铺垫,同时顺便熟悉了以ILDASM工具进行反编译的基本方法认知,下面是时候来了解什么是元数据,什么是IL这个话题了,我们继续。

  很早就有说说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在程序执行时的角色和关联。

你可能感兴趣的:(C#)