[原创]探索CLR原理系列(1):类型 (适合老鸟,新人勿沉迷其中)

时间:2021-08-13 14:55:16

   

    CLR是整个Dotnet的灵魂,CIL则是这个灵魂可以发挥其跨越平台,穿越语言,跳跃....的保证.其实有很多书籍和文章都介绍了什么是CLR,什么是CIL,CTS,CLS这样的一大堆概念,可是他们具体的表现形式,以及运作的原理是大部分人都想知道的秘密,却没有什么太好的途径来获取这些信息.本系列将从C#代码->CIL->CLR来探索我们编写的C#代码,最终如何成为本地机器语言,并且执行.过程中会使用VS2010+SOS调试.关于SOS调试的详细内容,大家可以上网搜索,这里不再赘述.
一直想写这个系列的文章,可是内容太多,其中各部分之间的相关性很复杂,让我感觉无从下手.终于决定先硬着头皮写一篇看看,否则可能永远也不知道该从哪里些起.文章中的内容都是笔者大量的阅读书籍以及从网络搜索资料加上自己的试验,调试所理解到的东西,笔者能力有限,如有误导观众之处,欢迎大家指出.我们可以一起讨论,一起来完善这些知识.
 
先给大家一个图,笔者吐血画出来的,可以先看看Load堆中类型大致是一个怎么样的状态。图中的其他内容,笔者将陆续介绍给大家,这个图只是其中的一部分,还有线程栈的部分,由于图太大,等说到那部分再发吧.图中的MatedataToken就是我们今天介绍的从IL到CLR,类型的标记的一部分,即TestObjectType1的类型标记码(2000003)。 

 

 [原创]探索CLR原理系列(1):类型 (适合老鸟,新人勿沉迷其中)

由于某些媒体和个人喜欢拿来主义,所以笔者加了水印,见谅见谅。。。不影响大家看就是了,字比较小,可将浏览器放大。 

 

今天先来说说.net中的类型,我们先不去区分什么值类型在声明它的地方,引用类型类型的实例在GC堆中.实际在CLR中有3个堆(GC堆,Load堆,大对象堆),我们今天要描述的是Load堆用来存放类型而不是类型的实例,关于GC堆是后面要做的事情.只说类型,首先看一下笔者定义的类型.

 

 1       public   class  TestObjectType1
 2      { }
 3 
 4       public   struct  TestValueType1
 5      { }
 6 
 7       public   interface  ITestInterface1
 8      { }
 9 
10       public   delegate   void  TestDelegate1();

  在这里我只定义了类型,类型中没有任何成员,下面我将编译生成程序集,然后来看看CIL中他们是什么样子.

  首先来看看引用类型TestObjectType1

 1 TypeDef #1 (02000002) //类型标记
 2  -------------------------------------------------------
 3       TypDefName:  TestDemo1.Type.TestObjectType1  ( 02000003 )
 4      Flags     : [ Public ]   [AutoLayout]//类型字段加载方式  [ Class ]  [AnsiClass] [ BeforeFieldInit
 5       Extends    :  01000001  [TypeRef] System.Object //父类
 6      
 7  TypeRef # 1  ( 01000001 )
 8  -------------------------------------------------------
 9  Token:               0x01000001
10  ResolutionScope:     0x23000001
11  TypeRefName:         System.Object

 

可以看到在CIL的元数据部分,可以清晰地看到TestObjectType1继承自Object以及它的类型内存布局,这是CLR用来区分如何分配实例的内存的关键.还有类型标记(反射和JIT编译时会使用类型标记来快速的查找类型).

 

下面来看看Struct和Class有什么不同?

 1  TypeDef # 2  ( 02000004 ) 类型标记码
 2  -------------------------------------------------------
 3       TypDefName:  TestDemo1.Type.TestValueType1  ( 02000004 )
 4      Flags     : [ Public [SequentialLayout] //类型字段加载方式    [ Class ] [Sealed]  [AnsiClass] [ BeforeFieldInit
 5       Extends    :  01000002  [TypeRef]  System.ValueType
 6       Layout    :  Packing: 0 Size: 1
 7 
 8  TypeRef # 2  ( 01000002 )
 9  -------------------------------------------------------
10  Token:               0x01000002
11  ResolutionScope:     0x23000001
12 TypeRefName:       System.ValueType

  值类型都继承自ValueType,当然ValueType也最终继承自Object,在这里大家可以看到它的Flags中也是Class,而且还是Sealed.还有一个需要注意的是Layout中,它的Size竟然是1,我们并没有定义任何的成员阿?这里的1笔者将会在后面给出答案.

我们再来看看接口有什么不同

1  TypeDef # 3  ( 02000005 )//类型标记码
2  -------------------------------------------------------
3       TypDefName:  TestDemo1.Type.ITestInterface1  ( 02000005 )
4      Flags     : [ Public] [AutoLayout]  [Interface] [Abstract] [AnsiClass]  
5     Extends   : 01000000 [TypeRef] //父类不存在

  可以看到接口不存在任何父类,它只是一个纯抽象的契约而已,很多书上都说任何类型都最终继承自Object是不严谨的.那么为什么接口不继承自Object呢,简单来说由于Object已实现的方法,如果接口继承自它,那么接口也就有了这些虚方法和受保护的方法,虚方法可以重写,那么就破坏了接口做为契约的本质.在实现了接口的所有类型的方法表中,将开辟两个Tostring,两个GetHashcode等的方法槽造成你调用方法时产生二义性,并且还会大量的占用内存(虽然一个方法槽只有4个字节,但是架不住类型多).你或许会说两个方法槽最终可以指向同一个方法地址,Ok.这样说两个相同的方法有同一种实现,即占用了内存,还什么用都没有,为什么要这样做呢?

  OK,讨论完接口我们来看看委托,这里我们只是为了解一下委托的类型,关于委托笔者会单独写一篇文章来探索它.

1  TypeDef # 4  ( 02000006 )//类型标记码
2  -------------------------------------------------------
3       TypDefName:  TestDemo1.Type.TestDelegate1  ( 02000006 )
4      Flags     : [ Public ] [AutoLayout]  [ Class ] [Sealed]  [AnsiClass]  
5       Extends    :  01000003 [TypeRef]  System.MulticastDelegate //父类

委托在实际就是一个类,继承自(MulticastDelegate,它又继承自Delegate,而最终也是Object的孩子),并且和值类型一样都是Sealed.表示它们不可以被继承,至于为什么不可以被继承,笔者将会在后续的文章中介绍. 我们在这里讨论了Class,Interface,struct,Delegete 发现他们的类型标记都是0200000X,都是用TypeDef来定义类型,用TypeRef来表述父类,并且有足够的信息来描述你写的C#代码.而且除了Interface,全部都被定义为Class.那么当CLR来加载这些元数据时,靠什么来区分你所定义的Struct和Class呢?CLR如何快速去查找类型呢?

dll中的数据到底是什么样子的,当CLR加载一个DLL乃至加载一个类型到物理内存的时候如何查找类型?

在编译好的dll中,实际上是由很多张表,以及一些特定的string堆,Guid堆,BLOB堆来组成的元数据,我们先来看看TypeDef表.你所定义的类型都被放在TypeDef表中,这个表你可以想象成如同二维的数据库表一样,有Token类型标记码作为主键,还可以查找到它的父类(Extends)的类型标记码.以及类型到底是Class,Valuetype,Interface(图中Flags的注释中的C,I,V)前两个是根据他们的父类来识别,而Interface有关键字来表示,参照上面的元数据.

 

 [原创]探索CLR原理系列(1):类型 (适合老鸟,新人勿沉迷其中)


  我们现在来看看CLR如何加载一个类型.

1         TestObjectType1 obj =  new  TestObjectType1();
2        
3         TestValueType1 val =  new TestValueType1();
4       

  相关IL

 
  IL_0001:  newobj     instance void TestDemo1.Type.TestObjectType1/*02000003*/::.ctor() 
  

  IL_0010:  initobj    TestDemo1.Type.TestValueType1/*02000004*/

原来在IL代码中是使用类型标记码来标记语句,然后CLR通过标记加载它的.我们使用SOS来调试一下,看看在CLR在内存中把类型表现为什么样子.


  TestObjectType1 obj =  new  TestObjectType1();   //调试C#代码的当前语句  
  TestValueType1 val =  new TestValueType1(); 

 

0022eb9c 0037009e TestDemo1.Type.TestMain.Main()
    
LOCALS://线程栈地址 0x0022eba8 = 0x00000000//GC堆地址(引用类型) or 线程栈地址(值类型)

   //可以看到引用类型也被初始化为0,但它是一个地址,地址为0也就是引用不存在,为null

        0x0022eba4 = 0x00000000  //这里可以看到值类型被默认初始化为0

可以看到当new还没有执行时, 0x0022eba8(obj )指向的值是0,由于地址为0的引用就是不存在的,所以等于null.val 的值等于0,也就是说在线程栈上,没有东西可以为null,所有的东西都有值,这也就是为什么值类型不需要构造函数也一样可以初始化.没有人可以阻止值类型的初始化.

 

  TestObjectType1 obj =  new  TestObjectType1();      
  TestValueType1 val =  new  TestValueType1();    //调试C#代码的当前语句
 
LOCALS:
        
0x0022eba8   0x0240b928  
         0x0022eba4   0x00000000

在new执行后,obj的内存在GC中被分配,可以看出new关键字分配了内存,并将分配好的内存地址返回给栈上的地址空间。现在我们来看看0x0240b928这块内存空间有什么?

 !dumpobj 0x0240b928

Name:         TestDemo1.Type.TestObjectType1
MethodTable:  001538f4  // 方法表
EEClass:       00151564   // 类型关系图
Fields: none   // 字段

 可以看到在在这块地址上有方法表的地址,字段列表的地址,以及类型继承关系的地址。让我们一个一个来看,首先EEclass

!dumpclass  00151564
Class   Name:       TestDemo1.Type.TestObjectType1
mdToken:           ed13f792 0 2000003  //红色部分是Dll被加载到虚拟内存空间的地址
Parent 
Class :    6c1a3ef8  // 父类的地址
Method   Table:     001538f4  // 方法表的地址,与上面的指向是一样的        
Vtable  Slots: 4 // 4个虚方法

Total MethodSlots:5//方法表中的方法个数

ClassAttributes:100001//类型的类别 (引用类型)

NumInstanceFields:0//实例字段的个数

NumStaticFields:     0  //静态字段的个数 

MdToken是记录了类型在元数据表里(IL编译后的类型标记码)的标记。这样我们在使用反射时,就可以很快地定位到元数据的信息,前面的那一部分(ed13f792)是dll在虚拟内存中的地址.

例如  System.Type t = typeof(TestObjectType1);  将会定位到类型的mdToken,然后找到类型所在Dll中的元数据表Typedef,根据2000003这个主键找到元数据,读取并返回一个Type类型的实例 t,到当前线程栈的内存空间中。

  接下来我们看看父类的内容,ParentClass

 !dumpclass 6c1a3ef8      

ClassName:      System.Object  

 //红色部分是Dll被加载到虚拟内存空间的地址
mdToken:         f5dcf17a 02000002 //注意TypeRef的Token,并不是Type的真实Token,这里是Object在MSCorlib.dll中的真实标记。

Parent Class:    00000000//没有父类

MethodTable:    6c4bf5e8 //方法表

Vtable Slots:4//4个虚方法

Total MethodSlots:  a //总计10个方法

ClassAttributes:102001

NumInstanceFields:0//无任何字段

NumStaticFields:0

 

注意TypeRef的Token(01000001),并不是Type的真实Token,这里是Object在MSCorlib.dll中的真实标记。Object是*类,所以他没有父类,也没有任何字段,但是它有虚方法。注意看一下,TestObjectType1没有任何成员,他完全继承了Object,但是他们的方法表地址却不同。关于方法表将在后续的文章中介绍。
那么值类型会是什么样呢?我们继续调试

 

TestValueType1 val =  new  TestValueType1(); 

Console.Read(); 

LOCALS://当前线程栈地址  

0x002cf1c4  =  0x00000000   // 由于结构没有定义任何字段所以默认为0

在这里我们没有定义任何字段,上面的元数据中我们看到了值类型不定义任何字段,它的大小也为1,因为值类型不可能为Null,所以它不可能没有大小,也就是说值类型在栈上只要分配了,就一定要有值.

关于值类型和引用类型在栈上的状态,我们以后会继续分析,这里为了简化仅仅是点到为止.

这里为了可以看到值类型实例的类型在内存中的状态,笔者只有将它装箱后才能根据它栈中指向GC堆的地址来拿到它类型的地址,具体原因我们在以后介绍.

          

  TestValueType1 val  =   new  TestValueType1();
  
object  obj  =  val; //将值类型装箱

  Console.Read();  //调试当前点

!dumpobj 0x023cb928
Name:        TestDemo1.Type.TestValueType1
MethodTable: 00313894
EEClass:     003114e0

Fields:      None 

  

 

  这里可以看到装箱后我们找到了实例(这时候是一个引用了)的地址,并且跟踪到了类型.我们来看看它的EEClass是怎样的?

!dumpclass 003114e0
Class   Name:       TestDemo1.Type.TestValueType1
mdToken:          d6f0251 02000004 //与元数据中的类型标识码一样
Parent 
Class :    6b908a10
Module:           00312e9c
Method   Table:      00313894
Vtable 
Slots:      4
Total 
Method   Slots:    4
Class   Attributes:      100109   //代表是值类型的布局方式
NumInstanceFields:     0
NumStaticFields:       0


与引用类型相比只有类型布局不一样,而且方法表的总数不一样,这是因为值类型编译器不会生成默认构造函数,而我们上面可以用new完全是c#给我们的一种语法糖而已。

我们来看看它的基类吧。

!dumpclass 6b908a10
Class   Name:        System.ValueType
mdToken:          9590d7902000009
Parent 
Class :     6c1a3ef8 //都是Object
Method   Table:     6bbcf730
Vtable 
Slots:      4
Total 
Method   Slots:    5
Class   Attributes:      102081    Abstract,  不可被实例化
NumInstanceFields:     0
NumStaticFields:       0

它的父类是ValueType,那么Valuetype的父类呢, 和上面TestObjectType1的ParentClass地址一样。说明了什么呢? 仔细看这里你会发现,他总共有5个方法,其中4个是虚的,那是从Object继承下来的,但是它的子类TestValueType1却只有4个方法。为什么?因为ValueType有一个受保护的构造函数,而构造函数是不继承的,那么为什么是受保护的?因为当你定义抽象类后,会在编译时默认的生成受保护的构造函数,自ValueType以后,它的所有子类都表现为了值类型的特性,没有默认构造函数,在声明的地方初始化,即可能在栈中,也可能在GC堆中。


 总结:

当代码编译为dll时,每一个类型在元数据的Typedef表中,会分配一个MdToken(类型标记),当你写的方法需要访问这个类型时,也是使用MdToken到相关Dll的元数据表去加载它到Load Heap,LoadHeap是用来存放类型的空间,它并不保存类型的实例.当clr加载完类型后,就会根据你的代码初始化值类型或者引用类型,clr会根据元数据表中父类类型object,valuetype来区分是引用类型,值类型到相应的地址空间(GC堆或线程栈) ,实例化这部分内容,我们在后续的文章中继续讨论.在C#的类型中,我们可以定义字段,属性,方法,事件和嵌套类,但我们跟踪类型的EEclass,发现类型中只有两类成员,字段(事件就是一个委托,而委托只是一个类型,所以事件就是一个字段而已,但表现有些特殊后续介绍)和方法(属性实际就是方法).


 

在后续的文章中我们将陆续的研究字段,方法,然后再回过头来谈论类型和类型的实例(Gc堆和栈),反射以及垃圾回收器,异常管理等内容.