zoukankan      html  css  js  c++  java
  • .NET CLR 运行原理

    原文: Drill Into .NET Framework Internals to See How the CLR Creates Runtime Objects

    文章讨论了:

        SystemDomain, SharedDomain和Default Domain

        对象布局和其他的内存细节

        方法表布局

        方法分派

    文章使用的技术:

        .NET Framework

        C#

    因为公共语言运行时(CLR)即将成为在Windows上创建应用程序的主角级基础架构, 多掌握点关于CLR的深度认识会帮助你构建高效的, 工业级健壮的应用程序. 在这篇文章中, 我们会浏览,调查CLR的内在本质, 包括对象实例布局, 方法表的布局, 方法分派, 基于接口的分派, 和各种各样的数据结构.

    我们会使用由C#写成的非常简单的代码示例, 所以任何对编程语言的隐式引用都是以C#语言为目标的. 讨论的一些数据结构和算法会在Microsoft? .NET Framework 2.0中改变, 但是绝大多数的概念是不会变的. 我们会使用Visual Studio? .NET 2003 Debugger和debugger extension Son of Strike (SOS)来窥视一些数据结构. SOS能够理解CLR内部的数据结构, 能够dump出有用的信息. 通篇, 我们会讨论在Shared Source CLI(SSCLI)中拥有相关实现的类, 你可以从msdn.microsoft.com/net/sscli下载到它们. 图表1 会帮助你在搜索一些结构的时候到SSCLI中的信息.

    图表1 SSCLI索引

    ItemSSCLI Path
    AppDomain sscliclrsrcvmappdomain.hpp
    AppDomainStringLiteralMap sscliclrsrcvmstringliteralmap.h
    BaseDomain sscliclrsrcvmappdomain.hpp
    ClassLoader sscliclrsrcvmclsload.hpp
    EEClass sscliclrsrcvmclass.h
    FieldDescs sscliclrsrcvmfield.h
    GCHeap sscliclrsrcvmgc.h
    GlobalStringLiteralMap sscliclrsrcvmstringliteralmap.h
    HandleTable sscliclrsrcvmhandletable.h
    InterfaceVTableMapMgr sscliclrsrcvmappdomain.hpp
    Large Object Heap sscliclrsrcvmgc.h
    LayoutKind sscliclrsrcclsystem untimeinteropserviceslayoutkind.cs
    LoaderHeaps sscliclrsrcincutilcode.h
    MethodDescs sscliclrsrcvmmethod.hpp
    MethodTables sscliclrsrcvmclass.h
    OBJECTREF sscliclrsrcvm ypehandle.h
    SecurityContext sscliclrsrcvmsecurity.h
    SecurityDescriptor sscliclrsrcvmsecurity.h
    SharedDomain sscliclrsrcvmappdomain.hpp
    StructLayoutAttribute sscliclrsrcclsystem untimeinteropservicesattributes.cs
    SyncTableEntry sscliclrsrcvmsyncblk.h
    System namespace sscliclrsrcclsystem
    SystemDomain sscliclrsrcvmappdomain.hpp
    TypeHandle sscliclrsrcvm ypehandle.h

    在我们开始前请注意, 这篇文章提供的信息仅适用于在x86平台架构下的.NET Framework 1.1(有可能多数信息对于Shared Source CLI 1.0中, 一些互操作情形下的多数值得注意的异常来说, 也还是正确的). 对于.NET Framework 2.0来说, 很多信息可能会改变, 所以不要创建依赖于这些内部结构不会改变的软件.

    CLR辅助程序创建的域

    =================

    在CLR执行第一行托管代码之前, 它先创建三个应用程序域. 其中的两个是从托管代码中产生的, 是透明的, 甚至对于CLR宿主来说都是不可见的. 这两个domain只能通过CLR bootstrap进程创建出来, 这个进程受助于两个垫板作用一样的dll文件, mscoree.dll和mscorwks.dll(当是多处理器系统的时候, 为mscorsvr.dll). 在图表2中, 你可以看到System Domain和Shared Domain, 这两个都是Singleton的(只用唯一一个实例). 第三个域是default app domain, 它是一个AppDomain类的实例, 也是唯一命名的domain. 对于简单的CLR宿主程序, 比方说控制台程序, default domain的名字是由可执行镜像的名字组成的. 其他的域可以通过在托管代码中使用AppDomain.CreateDomain方法, 或者在非托管宿主代码中通过调用ICORRuntimeHost接口, 来创建. 类似ASP.NET这样的复杂的宿主, 基于Web Site的数目来创建多个域.

    图表2 CLR辅助程序创建的域

    2009-11-12 8-45-03

    系统域-System Domain

    ===================

    系统域负责创建和初始化Shared Domain和default appdomain. 它加载系统库mscorlib.dll到Shared Domain中. 它还显式或隐式的保持着进程范围的字符串的字面值.

    保存字符串的字面值(string interning)在.NET Framework 1.1中是一项有点点笨拙的优化特性, 因为CLR并不给assemblies机会来选择是否使用它. 不论如何, 它在所有的应用程序域范围内, 提供给定字符串值的唯一实例(相同值的字符串在内存中只有一份).

    系统域还负责生成进程范围的接口ID, 这些接口ID被用来在每一个AppDomain中创建InterfaceVtableMaps. 系统域记录并监控着进程中的所有域, 并实现了加载和卸载AppDomain的功能.

    共享域-Shared Domain

    ===================

    所有的域-中立的代码都被加载到shared domain中.

    • Mscorlib, 这个系统库, 是被所有的appdomain中的用户代码使用和需要的, 它会被自动的加载到SharedDomain中. 像Object, ValueType, Array, Enum, String, 还有Delegate之类的System命名空间中的基础类型, 都会在CLR辅助程序进程(CLR bootstrapping process)中, 被预先加载到SharedDomain里. 
    • 用户代码(user code)也可以被加载到该域中, 方法是通过在调用CorBindToRuntimeEx方法时, 指定LoaderOptimization属性. LoaderOptimization属性是由CLR宿主应用程序指定的.
    • 控制台程序可以给应用程序的main方法编写属性来加载代码到SharedDomain中, 这个属性是System.LoaderOptimizationAttribute.

    共享域还管理由基地址(the base address)索引的assembly map, assembly map的功能类似于一种查找表, 这个查找表用于明确被加载到Default Domain中的assembly和在其它应用程序域的托管代码中创建的assembly的共享依赖关系.

    默认域(Default Domain)是非共享的用户代码加载的地方.

    默认域-DefaultDomain

    ===================

    默认域是一个AppDomain的实例, 典型地, 应用程序代码在这个域中执行.

    当一些应用程序需要在运行时创建额外的appdomain的时候(比如拥有插件式架构的应用程序, 或者是正在生成相当大量的运行时代码的应用程序), 多数的应用程序会在他们的生命期中创建一个这样的一个域: 所有执行在这个域中的代码都是在域层次上进行了上下文绑定的. 

    如果一个应用程序有多个appdomain, 那么任何跨domain的访问都要通过.NET Remoting proxies(.net远程代理).

    额外的domain内的上下文边界可以通过继承自System.ContextBoundObject的类型来创建.

    每一个AppDomain都有自己的SecurityDescriptor, SecurityContext和DefaultContext, 同样的, 还有自己的加载者堆(高频堆, 低频堆, 和Stub堆), 句柄表(句柄表, 大对象堆句柄表), 接口虚表映射管理器(Interface Vtable Map Manager), 和Assembly Cache.

    加载者堆-LoaderHeaps

    ====================

    加载者堆是用来加载各种各样的CLR runtime artifacts【译注:artifact这里可以理解为一种structure】和优化artifacts的, 这些artifacts在域的生命期中都存在.

    这些堆按照可以预见的大小的块来增长, 从而最小化内存碎片.

    加载者堆与GC堆(在对称的多处理器情况下, 是多重堆-multiple heap) 不同, 不同之处在于GC堆保存对象实例,而加载者堆保存的是整个类型系统.

    经常访问到的artifact比如MethodTables, MeghodDescs, FieldDescs和InterfaceMaps, 都在高频堆上分配, 而不那么经常访问的数据结构比如说EEClass和ClassLoader还有ClassLoader的查找表, 在低频堆(LowFrequencyHeap)上分配.

    StubHeap保存着很多stub,  stub可以帮助代码访问security (CAS), COM wrapper calls和P/Invoke.

    简单在高层次上过了一遍各种域和加载者堆之后, 我们现在来看一下以一个简单应用程序为上下文背景的, 这些结构的物理细节. 见图表3. 我们将程序的执行中断在了"mc.Method1();", 并且使用SOS debugger extension的DumpDomain命令dump出了域的信息,  这里是编辑过的输出结果:

    ---------------------------------------------------

    !DumpDomain
    System Domain: 793e9d58, LowFrequencyHeap: 793e9dbc, 
    HighFrequencyHeap: 793e9e14, StubHeap: 793e9e6c,
    Assembly: 0015aa68 [mscorlib], ClassLoader: 0015ab40
    
    Shared Domain: 793eb278, LowFrequencyHeap: 793eb2dc,
    HighFrequencyHeap: 793eb334, StubHeap: 793eb38c,
    Assembly: 0015aa68 [mscorlib], ClassLoader: 0015ab40
    
    Domain 1: 149100, LowFrequencyHeap: 00149164,
    HighFrequencyHeap: 001491bc, StubHeap: 00149214,
    Name: Sample1.exe, Assembly: 00164938 [Sample1],
    ClassLoader: 00164a78

    图表3 Sample1.exe

    using System;
    
    public interface MyInterface1
    {
        void Method1();
        void Method2();
    }
    public interface MyInterface2
    {
        void Method2();
        void Method3();
    }
    
    class MyClass : MyInterface1, MyInterface2
    {
        public static string str = "MyString";
        public static uint ui = 0xAAAAAAAA;
        public void Method1() { Console.WriteLine("Method1"); }
        public void Method2() { Console.WriteLine("Method2"); }
        public virtual void Method3() { Console.WriteLine("Method3"); }
    }
    
    class Program
    {
        static void Main()
        {
            MyClass mc = new MyClass();
            MyInterface1 mi1 = mc;
            MyInterface2 mi2 = mc;
    
            int i = MyClass.str.Length;
            uint j = MyClass.ui;
    
            mc.Method1();
            mi1.Method1();
            mi1.Method2();
            mi2.Method2();
            mi2.Method3();
            mc.Method3();
        }
    }

    我们的控制台程序, Sample1.exe, 被加载到名为"Sample1.exe"的AppDomain中.

    Mscorlib.dll被加载到SharedDomain中, 但是它还是被列在SystemDomain中, 因为他是核心的系统库.

    每个域中都分配了自己的高频堆,低频堆,和stub堆. 系统域和共享域使用同样的ClassLoader, 而Default AppDomain使用的是它自己的ClassLoader.

    输出结果中并没有显示出加载者堆保存的尺寸和已经committed的尺寸. 高频堆初始保留尺寸是32KB, committed的尺寸是4KB. 低频堆和Stub堆初始保留尺寸是8KB, committed的尺寸是4KB.

    在SOS输出中同样没有显示出来的是InterfaceVtableMap堆. 每个域都有一个InterfaceVtableMap堆(后面再用的时候就简写为IVMap), 在域初始化阶段它,被创建在自己的加载者堆上. IVMap堆初始保留大小为4KB, 初始committed的大小是4KB. 我们将在接下来的部分中,探索类型布局的时候,讨论IVMap的重要性.

    图表2 展示了默认的进程堆, JIT代码堆, GC堆(针对小对象的), 和大对象堆(针对大于等于85000字节的对象的), 通过他们来说明了:这些堆和加载者堆在语义上的不同.

    just-in-time(JIT)编译器生成x86指令, 并且把它们存储在JIT代码堆上.

    GC堆和大对象堆都是垃圾收集堆, 托管对象是在这些堆上实例化出来的.

    类型基础- Type Fundamentals

    ========================

    类型是在.NET编程中的基础单位. 在C#中, 一个类型使用关键字class, struct,和interface来声明. 多数的类型是显示的由程序员来创建的, 然而, 在特殊的互操作情形下, 或者在远程对象激活(.NET remoting)场景中, .NET CLR隐式的生成一些类型. 这些生成的类型包括COM和运行时可调用的包装器, 还有透明的代理.(COM and Runtime Callable Wrappers and Transparent Proxies).

    我们接下来探索一下类型基础, 从包含一个对象引用的栈开始.(典型地, 栈是一个对象实例开始他的生命期的位置.) 代码在图表4中, 其中包括一个简单的程序, 有调用静态方法的控制台入口点. Method1创建了一个类型为SmallClass的的实例, SmallClass中包括一个字节数组, 我们通过这个数组来demo在大对象堆上的对象实例的创建. 代码的实用价值不高, 但是足够为我们的讨论服务了.

    图表 4 大对象和小对象-Large Objects and Small Objects

    using System;
    
    class SmallClass
    {
        private byte[] _largeObj;
        public SmallClass(int size)
        {
            _largeObj = new byte[size];
            _largeObj[0] = 0xAA;
            _largeObj[1] = 0xBB;
            _largeObj[2] = 0xCC;
        }
    
        public byte[] LargeObj
        {
            get { return this._largeObj; }
        }
    }
    
    class SimpleProgram
    {
        static void Main(string[] args)
        {
            SmallClass smallObj = SimpleProgram.Create(84930, 10, 15, 20, 25);
            return;
        }
    
        static SmallClass Create(int size1, int size2, int size3,
            int size4, int size5)
        {
            int objSize = size1 + size2 + size3 + size4 + size5;
            SmallClass smallObj = new SmallClass(objSize);
            return smallObj;
        }
    }

    图表5 显示了断点在Create方法中的"return smallObj;"语句的栈的一个快照(snapshot), 这是一个典型的fastcall的栈框架. (Fastcall是.NET的一种调用约定, 在这种调用约定下, 传递给函数的参数在可能的情况下会通过寄存器来传递, 其他的参数从右至左的压入栈中供函数调用, 函数调用结束后, 由函数自身将栈中的参数清除.)

    值类型变量objSize存储在栈框架之内.

    类似smallObj的引用类型以一个固定的大小(4字节的双字), 存储在栈中, 双字的内容是在普通GC堆上的对象实例的地址.

    在传统C++中, 这是一个对象指针; 在托管世界中, 这是一个对象引用. 不论如何, 它包含对象实例的地址. 我们将会对存储在对象引用的地址中的数据结构使用术语ObjectInstance.

    图表5 简单程序的栈框架和堆-SimpleProgram Stack Frame and Heaps

    2009-11-12 12-52-09

    smallObj对象的实例(object instance), 存储在普通GC堆上, 其中包含一个字节数组, 叫做_largeObj, 这个字节数组的大小是85000字节(注意, 图中显示的是85016字节, 这是真实存储的空间大小.)

    CLR对待大小>=85000字节的对象, 跟对待比这小的对象的方式不同. 大对象分配在Large Object Heap(LOH)中, 而小对象是创建在普通GC堆上的. 因为普通GC堆对于对象的分配和垃圾收集是有优化的(所以适合存储小对象的效率高). 大对象堆是没有压缩的(夯实的), 而GC堆在GC垃圾收集发生的时候是压缩的. 更重要的是, 大对象堆(LOH)仅在完全垃圾回收的时候才被释放(LOH is only collected on full GC collections).

    smallObj的ObjectInstance包含TypeHandle(类型句柄), TypeHandle指向相关连的类型的MethodTable.

    任何一个声明了的类型都仅有一个MethodTable, 并且所有同样类型的对象的实例都指向同一份MethodTable.

    MethodTable包含

            关于这种类型的信息(属于哪一个? interface, abstract class, concrete class, COM Wrapper还是Proxy).

            实现了的接口数量

            为了方法分配而设立的接口映射表(interface map for the method dispatch)

            方法表中的槽的数量(方法表中方法的数量)(number of slots in the method table)

            一张满是指向方法的实现的槽的表格

    一个由MethodTable指向的重要的数据结构, 是EEClass. 在MethodTable展开之前, CLR类加载器(class loader)从元数据(Metadata)中创建出EEClass. 在图表4中, SmallClass的MethodTable指向它的EEClass. 这些结构指向他们的模块和assembly.

    MethodTable和EEClass典型地分配在具体域的加载者堆上. 字节数组(Byte[])是一个特例. 方法表MethodTable和EEClass分配在共享域中的加载者堆上.

    加载者堆是appdomain-specific的, 任何这里提到的数据结构(MethodTable和EEClass)一旦加载起来就不会被移除, 除非它的AppDomain被卸载掉.

    同样, 默认的appdomain也不能被卸载掉, 因此代码直到CLR关闭都还存在着.

    对象实例-ObjectInstance

    ====================

    正如我们提到的, 所有的值类型要么以inline(内联)地存储在线程栈中, 要么内联地存储在GC堆当中. 所有的引用类型都是在GC堆上或者大对象堆创建的. 图表6 显示了一个典型的对象实例的布局.

    一个对象可以被以下的结构引用:

    1. 基于栈的局部变量;

    2. interop或者P/Invoke情形下的句柄表;

    3. 寄存器(寄存器中的内容是:执行方法时的this指针或方法参数)

    4. 服务于拥有finalizer方法的对象的finalizer queue.

    OBJECTREF并不指向Object Instance的首地址, 而是指向一个以DWORD(4个字节)为单位的一个偏移量.

    这个DWORD的偏移量叫做Object Header, 并且拥有一个指向SyncTableEntry表的索引值(a 1-based syncblk number). 通过索引的链锁效应, CLR在需要增长内存尺寸的情况下, 可以在内存中自由的移动SyncTableEntry表.

    SyncTableEntry中保存着一个指回对象的weak reference, 这样CLR就可以追踪到SyncBlock的所有权(属于哪个对象). Weak Reference可以让GC在没有其他强引用的情况下, 收集到这个对象.

    SyncTableEntry中还存着一个指向SyncBlock的指针, SyncBlock中保存着有用的信息, 但是这些信息很少被所有的对象实例使用到. 这些信息包括对象锁(object's lock), 它的Hash Code, 一些转换数据(thunking data), 和它的AppDomain index.

    对多数的对象实例来说, 他们当中没有为SyncBlock分配的存储空间, syncblk number是0. 然而,当线程执行遇到例如lock(obj), 或者obj.GetHashCode的时候, 就不同了. 就像下面的代码一样:

    SmallClass obj = new SmallClass()
    // Do some work here
    lock(obj) { /* Do some synchronized work here */ }
    obj.GetHashCode();

    图表6 对象实例布局-Object Instance Layout

    2009-11-12 13-46-57

    在这段代码中, smallObj会使用0(没有syncblk)做为它起始时的syncblk number. 那句lock语句引发了CLR创建一个syncblk entry的动作, 并用相应的数值来更新对象的object header. 由于C#的lock关键字可以展开为一个try-finally块, 用来使用Monitor类, 所以Monitor对象在为同步化(synchronization)而准备的syncblk中创建出来. 对GetHashCode方法的调用把对象的hash code填入到syncblk中.

    SyncBlock中还有些其他的数据域, 它们有的用在COM的interop上, 有的用在针对非托管代码的marshaling delegate上. 但是这些数据域跟典型的对象使用无关.

    TypeHandle的位置是紧跟着ObjectInstance中的syncblk number的. 为了保持连续性, 我会在详细阐述变量实例之后, 讨论TypeHandle.

    在TypeHandle之后紧跟着一个实例的变量列表域. 默认情况下, 这个实例域会按照能让内存高效使用的方式来压缩, 或者按照能让内存读取高效的对齐来做最小程度的填充. 图表7中的代码显示了一个SimpleClass, 该class拥有很多包含不同大小的变量的实例.

    图表7 拥有实例变量的SimpleClass- SimpleClass with Instance Variables

    class SimpleClass
    {
        private byte b1 = 1;                // 1 byte
        private byte b2 = 2;                // 1 byte
        private byte b3 = 3;                // 1 byte
        private byte b4 = 4;                // 1 byte
        private char c1 = 'A';              // 2 bytes
        private char c2 = 'B';              // 2 bytes
        private short s1 = 11;              // 2 bytes
        private short s2 = 12;              // 2 bytes
        private int i1 = 21;                // 4 bytes
        private long l1 = 31;               // 8 bytes
        private string str = "MyString"; // 4 bytes (only OBJECTREF)
    
        //Total instance variable size = 28 bytes 
    
        static void Main()
        {
            SimpleClass simpleObj = new SimpleClass();
            return;
        }
    }

    图表8 显示出了SimpleClas对象实例在Visual Studio Debugger内存窗口中的一个例子. 我们在图表7的return语句上下断点, 然后用在寄存器ECX中存储的simpleObj的地址来在内存窗口中显示对象的实例. 头4个字节的块就是syncblk number. 因为我们之前没有在任何synchronizing的代码中使用这个实例, 它被设置为0. 以变量形式存在栈中的的对象引用, 指向偏移量为4的四个字节. 字节变量b1, b2, b3和b4都被一个挨着一个的排放着. 两个short型的变量被放在一起. 字符串型的变量str是一个4字节的OBJECTREF, 指向GC堆中字符串实际存在的地址. 字符串是一种特殊的类型, 在assembly加载的进程中, 它们的所有包含着相同内容的实例, 都会被指向相同的在全局字符串表中的那一份唯一实例. 这个进程叫做string interning, 是用来优化内存的使用的.

    如同我们之前提到的, 在.NET Framework1.1中, 一个assbmbly不可能从这个interning process中退出(opt out of), 尽管未来的CLR版本可能会修改这种能力.

    图表8 调试器内存窗口中的object instance-  Debugger Memory Window for Object Instance

    2009-11-12 14-42-55

    所以, 默认情况下, 在源代码中声明的成员变量的字面顺序, 在内存中并不会被保留下来. 在Interop的场景下, 变量的字面顺序必须被正向的依次放到内存中, StructLayoutAttribute属性可以用来完成这个设定, 该属性接受LayoutKind枚举类型的变量作为参数. LayoutKind.Sequential会为marshaled的数据设定字面的顺序, 尽管在.NET Framework 1.1中,这个设定还不会对托管布局生效.(.NET Framework 2.0就会了). 在interop场景下, 你实在需要额外的填充(padding)和显式的对于数据域顺序的控制时,  LayoutKind.Explicit可以和FieldOffset这个修饰符结合起来在field level帮助您达到目的.

    看过了原始内存的内容, 让我们用SOS来看一下对象实例吧. 一个有用的命令是DumpHeap, 它可以列出针对某一类型的所有堆中的内容, 还有这一类型的所有实例.  不依赖寄存器, DumpHeap命令可以show出我们创建的唯一实例的地址.

    ---------------------------------------------------
    !DumpHeap -type SimpleClass
    Loaded Son of Strike data table version 5 from 
    "C:WINDOWSMicrosoft.NETFrameworkv1.1.4322mscorwks.dll"
     Address       MT     Size
    00a8197c 00955124       36
    Last good object: 00a819a0
    total 1 objects
    Statistics:
          MT    Count TotalSize Class Name
      955124        1        36 SimpleClass

    整个对象的大小是36字节. 不论字符串多大, SimpleClass的instance中只包含一个DWORD OBJECTREF. SimpleClass的实例变量只占28个字节. 剩下的八个字节是由TypeHandle(4字节), 和syncblk number(4字节)组成的.

    找到了simpleObj实例的地址后, 让我们用DumpObj命令来dump出这个实例吧, 如下:

    ---------------------------------------------------
    !DumpObj 0x00a8197c
    Name: SimpleClass
    MethodTable 0x00955124
    EEClass 0x02ca33b0
    Size 36(0x24) bytes
    FieldDesc*: 00955064
          MT    Field   Offset                 Type       Attr    Value Name
    00955124  400000a        4         System.Int64   instance      31 l1
    00955124  400000b        c                CLASS   instance 00a819a0 str
        << some fields omitted from the display for brevity >>
    00955124  4000003       1e          System.Byte   instance        3 b3
    00955124  4000004       1f          System.Byte   instance        4 b4

    如上所述, 由C#编译器生成的的默认布局是LayoutType.Auto. 对于结构体来说是LayoutType.Sequential. 由于class loader重新安排了实例的数据域, 所以填充(padding)的部分达到了最小化. 我们可以用ObjSize命令来dump出实例占用空间的图示. 这里是输出结果:

    ---------------------------------------------------
    !ObjSize 0x00a8197c
    sizeof(00a8197c) =       72 (    0x48) bytes (SimpleClass)

    ******************************************************************************************************

    Son of Strike

    在本文中, SOS调试器扩展时用来展现CLR数据结构内容的.

    它是.NET Framework安装程序的一部分, 位置在%windir%Microsoft.NETFrameworkv1.1.4322.

    在你加载SOS到你的进程之前, 在Visual Studio .NET的工程属性里选择允许托管代码调试.(enable managed debugging)

    添加SOS.dll所在的文件夹到环境变量中. 要在断点时, 加载SOS.dll, 打开Debug | Windows | Immediate. 在Immediate窗口中,

    执行.load sos.dll命令. 用!help命令来得到关于debugger 命令的帮助. 更多关于SOS的信息, 参见the June 2004Bugslayer column

    ******************************************************************************************************

    如果你从object graph的大小(72字节)减去SimpleClass实例的大小(36字节), 你会得到变量str的长度(36字节). 让我们通过dump出这个字符串实例来确认一下吧. 输出结果如下:

    ---------------------------------------------------
    !DumpObj 0x00a819a0
    Name: System.String
    MethodTable 0x009742d8
    EEClass 0x02c4c6c4
    Size 36(0x24) bytes

    如果你把字符串的长度(36字节)加上SimpleClass实例的大小(36字节), 你就得到了对象的整个大小(72字节), 正与前面ObjSize命令的结果相同.

    注意, ObjSize方法并不包括由syncblk架构占用的内存. 在.NET Framework 1.1中, CLR并不了解被非托管资源占据的内存, 比如说GDI对象, COM对象, 文件句柄等等. 因此, 他们的大小是不会被这个命令的结果报告中反映出来的.

    TypeHandle, 是一个指向MethodTable的指针, 它的位置紧跟在syncblk number之后. 在一个对象实例创建之前, CLR会查询加载了的类型,

    如果这个类型没有找到就加载它, 获得类型的MethodTable的地址, 创建对象实例, 然后填充对象的TypeHandle值. JIT编译器产生的代码使用TypeHandle来寻找MethodTable, 用于实现method dispatching. CLR可以在任何时候通过TypeHandle指向的MethodTable来反向追溯已经加载了的类型.

    方法表- MethodTable

    =================

    任何一个类或者接口, 当他们加载到AppDomain当中的时候, 都会由一个叫做MethodTable数据结构来代表. 在对象的第一个实例都还没被加载的情况下, 创建出一个MethodTable是类的加载动作的执行成果。

    ObjectInstance代表的是对象的状态, MethodTable代表的是对象的行为.

    MethodTable把object instance与language compiler-generated memory-mapped metadata structures, 通过EEClass联系起来. 在MethodTable中的信息和metadata structure可以在托管代码中通过Systen.Type来访问到.

    在托管代码中, 一个指向MethodTable的指针可以通过Type.RuntimeTypeHandle属性来获得. TypeHandle, 存在于ObjectInstance中, 它指向一个偏移量, 这个偏移量是从MethodTable的首地址算起的. 这里的偏移量默认是12字节。这开头的12个字节包含GC的一些信息, 我们并不打算在这里讨论这些信息.

    图表9展现了一个典型的MethodTable的布局. 我们会show一些重要的TypeHandle的数据域, 但是为了一个更完整的列表, 还是看图表吧. 让我们从Base Instance Size开始吧, 因为它与运行时的内存轮廓有直接关系.

    图表9 方法表布局- MethodTable Layout

    cc163791.fig09(en-us)

    基本实例尺寸-Base Instance Size

    ===========================

    基本实例尺寸是由class loader计算出来的对象的大小, 是基于代码中的数据域声明来计算的. 如同前面讨论的, 当前GC的实现需要一个对象的大小至少是12个字节. 如果一个类没有任何的实例数据域被定义, 它会白白的用前4个字节作为占位字节. 剩下的8字节会被Object Header(可能包括一个syncblk number), 和TypeHandle占据. 再次强调, 对象的大小是可以被StructLayoutAttribute属性影响的.

    看看图表3(MyClass和两个接口)中MyClass的MethodTable的内存快照吧(Visual Studio .NET 2003 memory window). 请拿它和SOS生成的输出结果进行比较. 在图表9中, 对象大小是在4字节的偏移量的地方的, 其值为12 (0x0000000C)字节. 下面是 SOS中命令DumpHeap的输出结果

    --------------------------------------
    !DumpHeap -type MyClass
     Address       MT     Size
    00a819ac 009552a0       12
    total 1 objects
    Statistics:
        MT  Count TotalSize Class Name
    9552a0      1        12    MyClass

    方法槽表-Method Slot Table

    ======================

    在MethodTable中内嵌的是一张指向各自方法的方法描述器(MethodDesc)的指针组成的表格. 他们的存在允许了这个类型拥有一些行为. Method Slot表是根据按如下顺序实现了的方法的线性表来创建的: 继承的虚函数, 新虚函数, 实例方法, 和静态方法.(Inherited virtuals, Introduced virtuals, Instance Methods, and Static Methods).

    ClassLoader遍历当前类的, 基类的, 和接口的metadata, 然后创建出method table. 在layout process, 任何重载了的虚函数都会被取代, 取代并隐藏父类的方法, 创建新的slot, 必要的情况下复制slot. 对slot的复制对于创建一个illusion是必不可少的, 所谓illusion是指每个接口都有他自己的小虚函数表. 然而, 复制的slot指向相同的物理实现. MyClass有三个实例方法, 一个类构造函数(.cctor), 和一个对象构造函数(.ctor). 对象构造函数是由C#编译器为所有没有显式定义构造函数的对象,自动生成的. 类构造函数是由编译器生成的, 因为我们定义并初始化了一个静态变量. 图表10 显示出了MyClass

    的方法表的布局. 布局显示出了10个方法, 为了IVMap,Method2有重复的slot,这个重复将会在后面介绍. 图表11显示了MyClass的方法表在编辑过后的SOS dump.

    图表10 MyClass的方法表布局

    2009-11-12 22-40-49

    图表11 SOS Dump of MyClass Method Table

    -----------------------------------------------
    !DumpMT -MD 0x9552a0
      Entry  MethodDesc  Return Type       Name
    0097203b 00972040    String            System.Object.ToString()
    009720fb 00972100    Boolean           System.Object.Equals(Object)
    00972113 00972118    I4                System.Object.GetHashCode()
    0097207b 00972080    Void              System.Object.Finalize()
    00955253 00955258    Void              MyClass.Method1()
    00955263 00955268    Void              MyClass.Method2()
    00955263 00955268    Void              MyClass.Method2()
    00955273 00955278    Void              MyClass.Method3()
    00955283 00955288    Void              MyClass..cctor()
    00955293 00955298    Void              MyClass..ctor()

    任何类型的头四个方法永远都会是ToString, Equals, GetHashCode, 和Finalize.

    他们是从System.Object继承来的虚方法. Method2的slot是duplicated的, 但是二者都指向相同的方法描述器(method descriptor). 显式编码的.cctor会和静态方法分为一组, .ctor会和实例方法分为一组. (The explicitly coded .cctor and .ctor will be grouped with static methods and instance methods, respectively.)

    方法描述- MethodDesc

    ======================

    方法描述(Method Descriptor)(MethodDesc)是方法实现的的一种封装, CLR是知道,了解这种封装的. 有好几种Method Descriptor, 他们的存在不仅使得调用托管代码的实现更容易, 而且使得对interop的实现的调用也变得容易了一些. 在这篇文章中, 我们只研究以图表3的代码为上下文的托管MethodDesc.

    一个MethodDesc是作为类加载过程的一部分(class loading process)而产生出来的, MethodDesc初始情况下指向中间语言Intermediate Language(IL).

    每一个MethodDesc都被一个叫做PreJitStub的填充, PreJitStub负责触发JIT的编译过程.

    图表12展示了一个典型的布局. 方法表的slot entry实际指向PreJitStub, 而不是指向实际的MethodDesc. 这是一个从MethodDesc算起, 负5个字节的偏移量, 并且是每个方法继承的8个字节的填充的一部分.

    那五个字节包含调用PreJitStub函数的指令. 这5字节的偏移量可以从SOS的DumpMT命令的结果输出中看到(图表11的MyClass), 因为MethodDesc总是在MethodSlot Table入口指向的位置往后数5个字节的位置上. 紧接着第一个调用, 一个对于JIT编译函数的调用被触发. 在编译结束之后, 这五个字节所包含的调用指令会被覆盖为一条无条件转移到JIT编译的x86的代码的jmp指令.

    图表12 Method Descriptor

    2009-11-12 23-12-01

    图表12中的Method Table Slot入口指向的代码的反汇编结果中, 显示出了对于PreJitStub的调用. 这是一个删节了的Method2的在JIT之前的反汇编代码:

    -------------------------------
    !u 0x00955263
    Unmanaged code
    00955263 call        003C3538        ;call to the jitted Method2()
    00955268 add         eax,68040000h   ;ignore this and the rest 
                                         ;as !u thinks it as code

    现在让我们执行这个方法并且反汇编同样的地址:

    --------------------------------
    !u 0x00955263
    Unmanaged code
    00955263 jmp     02C633E8        ;call to the jitted Method2()
    00955268 add     eax,0E8040000h  ;ignore this and the rest 
                                     ;as !u thinks it as code

    只有从给定地址开始的五个字节内容是代码, 后面包含的是Method2的MethodDesc的数据. 这里的"!u"命令对这一点是不知情的, 所以生成了一些胡言乱语, 故尔你可以忽略那五个字节之后的任何东西(它们不是指令).

    CodeOrIL在JIT编译之前, 包含方法实现的IL码的Relative Virtual Address(RVA). 这个数据域被一个标志位标识: 其中存储的是IL. 一经要求, CLR完成了编译之后, CLR会使用JIT处理过的代码的地址来更新这个数据域.让我们从列表中选择一个方法, 然后使用DumpMT命令dump出来JIT编译之前和之后的MethodDesc吧:

    ---------------------------------
    !DumpMD 0x00955268
    Method Name : [DEFAULT] [hasThis] Void MyClass.Method2()
    MethodTable 9552a0
    Module: 164008
    mdToken: 06000006 
    Flags : 400
    IL RVA : 00002068

    编译之后, MethodDesc看起来像这样:

    ---------------------------------
    !DumpMD 0x00955268
    Method Name : [DEFAULT] [hasThis] Void MyClass.Method2()
    MethodTable 9552a0
    Module: 164008
    mdToken: 06000006
    Flags : 400
    Method VA : 02c633e8

    在方法描述器(method descriptor)中的Flags数据域会根据方法的类型来编码, 所谓方法类型是指: 静态方法, 实例方法, 接口方法, 或者是COM实现方法.

    让我们看看MethodTable的复杂的另一面吧: Interface implementation.

    托管环境下, Interface implementation被实现的看起来简单一些, 达到这个效果的方式是把所有的复杂度都吸收到布局过程中. 下一步, 我们将要show给你接口是如何布局的, 还有基于接口的方法分派(method dispatching)是如何工作的.

    接口虚表映射和接口映射- Interface Vtable Map and Interface Map

    =====================================================

    在MethodTable中, 偏移量为12的位置, 存储着一个重要的指针, IVMap. 如图表9, IVMap指向一个AppDomain等级的映射表, 该映射表以一个进程等级的接口ID为索引. 这个接口ID是在接口类型第一次加载的时候生成的. 任何一个接口的实现都会有一个IVMap的入口. 如果MyInterface1被两个类实现了, 那么在接口的IVMap中就会有两个入口. 入口会指回到MyClass的方法表内嵌的sub-table的开头, 见图表9. 这个是method dispatching发生时, 基于接口的索引. IVMap是根据内嵌在方法表中的Interface Map的信息而创建出来的. Interface Map是在布局方法表的过程中, 根据类的metadata创建出来的. 一旦类型加载结束, 只有IVMap会在method dispatching中使用到.

    Interface Map在偏移量28的位置, 它会指向内嵌在MethodTable中的InterfaceInfo的入口. 这样, 对于两个被MyClass实现了的接口中的任何一个接口, 都会有两个入口了.

    第一个InterfaceInfo入口的头四个字节指向MyInterface1的TypeHandle(参考图表9图表10).

    接下来的WORD(两字节)被Flags占据(其中0是指继承自父类, 1指的是被当前类实现).

    从Flags再接下来的WORD是Start Slot, 通过它, class loader得以编排接口实现的sub-table. 对于MyInterface1, 这个值是4, 意味slot 5 和slot 6指向implementation. 对于MyInterface2, 这个值是6, 所以, slot 7 和slot 8 指向implementation. 如果必要的话, ClassLoader会复制这些slot来创建illusion. 这里的illusion指在物理映射到相同的method descriptor的同时, 任何一个接口都得到了自己的实现.  在MyClass中, MyInterface1.Method2 和MyInterface2.Method2会指向相同的实现.

    基于接口的方法分派(method dispatching)是通过IVMap发生的, 而直接的方法分派是通过各自槽的MethodDesc的地址发生的. 如前所述, .NET Framework使用fastcall这种调用约定. 如果可能的话, 头两个参数典型地被传入ECX和EDX寄存器(译注:参见文章汇编语言基础之六- 调用栈和各种调用约定的总结对比)(最左边参数, 通过ECX传递, 第二个, 通过EDX传递).

    对象实例的方法中, 第一个参数永远是this指针, 通过ECX来传递. 下面的语句中"mov ecx, esi"展示了这一点.

    -------------------------------------
    mi1.Method1();
    mov    ecx,edi                 ;move "this" pointer into ecx        
    mov    eax,dword ptr [ecx]     ;move "TypeHandle" into eax 
    mov    eax,dword ptr [eax+0Ch] ;move IVMap address into eax at offset 12
    mov    eax,dword ptr [eax+30h] ;move the ifc impl start slot into eax
    call   dword ptr [eax]         ;call Method1
    
    mc.Method1();
    mov    ecx,esi                 ;move "this" pointer into ecx
    cmp    dword ptr [ecx],ecx     ;compare and set flags
    call   dword ptr ds:[009552D8h];directly call Method1

    这里的反汇编代码表明了在对MyClass的实例方法的直接调用中, 并没有使用偏移量. JIT编译器将MethodDesc的地址直接的用在了代码中. 基于接口的方法分派是通过IVMap来发生的, 并且比直接分派需要多一些指令. 一条用来拿到IVMap的地址, 另一条拿到MethodTable中接口实现的start slot. 并且, 将一个对象实例转换为一个接口也仅仅是拷贝一下这个指针到目标变量当中就可以了. 在图表3 【译注:原文中这个地方时图表2,显然有错误,应该是图表3】中, 语句 "mi1 = mc;" 只用了一条指令就把mc中的OBJECTREF拷贝给了mi1.

    虚拟分派- Virtual Dispatch

    ========================

    让我们来看一下虚拟分派吧, 恩, 再比较一下跟基于接口的直接分派有什么不同. 下面是图表3中对于虚方法MyClass.Method3调用的反汇编代码:

    -----------------------------
    mc.Method3();
    Mov    ecx,esi               ;move "this" pointer into ecx
    Mov    eax,dword ptr [ecx]   ;acquire the MethodTable address
    Call   dword ptr [eax+44h]   ;dispatch to the method at offset 0x44

    虚拟分派总是通过一个定死了的slot number来发生, 与给定的类的实现层次的MethodTable指针无关. 在MethodTable布局的时候, ClassLoader替换掉父类的实现, 而使用子类的实现. 结果, 对于父类对象的方法调用被分派到子类对象的实现上. 反汇编代码展现了分派通过slot number 8来发生的, debugger memory window中的DumpMT命令的输出结果也是这样(见图表10).

    静态变量- Static Variables

    ========================

    静态变量是MethodTable数据结构的重要组成部分. 他们在method table slot数组之后被分配在MethodTable上. 所有原始的静态类型都是内联的, 而静态值类型(结构体), 引用类型是通过AppDomain的handle table(句柄表)上的OBJECTREF来引用的. MethodTable中的OBJECTREF指向AppDomain的句柄表中的OBJECTREF, 这个OBJECTREF会使得堆上创建出来的对象实例一直存活下去, 直到AppDomain被卸载掉. 在图表9中, 一个静态的字符串变量, str, 指向句柄表中的OBJECTREF, 而这个OBJECTREF指向GC堆上的MyString.

    EEClass

    ========================

    EEClass在MethodTable被创建之前就存在了, 在与MethodTable结合的时候, 是一个类型声明的CLR版. 实际上, EEClass和MethodTable在逻辑上是一个数据结构(他们共同代表一个单个类型), 他们中的内容是基于使用频率不同而分开的. 非常经常使用的数据域存在MethodTable中, 而不太经常使用的数据域存在EEClass中. 所以, JIT编译函数需要的信息(比如说names, fields, 和offsets)就存在EEClass中, 然而, 运行时需要的信息(比如虚表slot和GC信息)就存在MethodTable中.

    加载到AppDomain中的任何一个类型都有一个EEClass. 这里所说的类型包括: 接口, 类, 抽象类, 数组, 和结构体. 任何一个EEClass 都是被执行引擎跟踪的树的节点. 为了诸如:加载类, 布局MethodTable, 辨别类型, 类型转换,这样的目的, CLR使用这个网络来导航到需要的EEClass结构. EEClass之间的孩子到父亲的关系是基于继承关系来创建的, 然而, 从父亲到孩子的关系是建立在继承关系和类的加载顺序的联合的基础上的. 随着在托管代码的执行, 新的EEClass节点被一个个的添加, 旧的节点之间的关系被不断修补, 新的节点关系也被建立起来.  

    网络中EEClass的兄弟之间还有水平的关系呢. EEClass有三个数据域被用来建立加载起来的类型之间的关系: ParentClass, SiblingChain, 和ChildrenChain. 参见图表13, 来看看以图表4为上下文的MyClass的EEClass的扼要图解.

    图表13展现了一些与我们的讨论相关联的数据域. 因为我们忽略了布局中的一些数据域, 我们并没有在这张图表中展现出真是的偏移量. EEClass有一个针对MethodTable的环形引用. EEClass也指向在默认AppDomain的高频堆中分配的MethodDesc块. 进程堆上有一个对FieldDesc对象列表的引用, 它提供了在MethodTable创建时的field布局信息. EEClass在AppDomain的低频堆上, 这样,操作系统可以更好的进行内存的页面管理, 因此减小了working set.

    图表13 EEClass布局- EEClass Layout

    ================================

    2009-11-13 11-50-06

    图表13中的其他数据域光看名字就能理解他们在MyClass(图表3)上下文中的作用了. 让我们看一下使用SOS工具dump出的EEClass的真实物理内存吧. 在mc.Method1这一行设定断点后, 运行图表3中的代码.首先, 通过运行命令!Name2EE获得MyClass的EEClass的地址:

    ------------------------------------

    !Name2EE C:Working	estClrInternalsSample1.exe MyClass
    
    MethodTable: 009552a0
    EEClass: 02ca3508
    Name: MyClass

    Name2EE命令的第一个参数是模块名称, 这个模块名称可以通过DumpDomain命令来获得. 现在我们有了EEClass的地址了, 我们来dump出EEClass的内容:

    -----------------------------------------

    !DumpClass 02ca3508
    Class Name : MyClass, mdToken : 02000004, Parent Class : 02c4c3e4 
    ClassLoader : 00163ad8, Method Table : 009552a0, Vtable Slots : 8
    Total Method Slots : a, NumInstanceFields: 0,
    NumStaticFields: 2,FieldDesc*: 00955224
    
          MT    Field   Offset  Type           Attr    Value    Name
    009552a0  4000001   2c      CLASS          static 00a8198c  str
    009552a0  4000002   30      System.UInt32  static aaaaaaaa  ui 

    图表13和DumpClass的输出结果看起来本质上是相同的. Metadata token(mdToken)代表着模块PE文件映射在内存中的metadata表的index.

    指向System.Object. Sibling 链(图表13)的Parent类, 说明了他的加载是加载Program类的结果造成的.

    MyClass有8个vtable slot(可以被虚拟分派的方法). 尽管Method1和Method2并不是虚拟方法, 它们在通过接口来分派的时候,还是被认为是虚函数并添加到列表中. 算上.cctor和.ctor到列表中, 这样你就一共有10个方法了. 这个类有两个静态域, 都被列在后面了. MyClass没有实例域. 其余的域都挺自我说明问题的.

    结论

    =========

    我们一起游历了一下CLR中最重要的一些内部信息. 很显然, 还有很多方面需要被覆盖到, 并且还要更加深入, 但是我们希望这篇文章可以给你一个CLR怎么工作的大致印象. 这里展现的很多的信息在未来版本的CLR和.NET Framework中可能会改变, 但是尽管这篇文章覆盖到的数据结构更改了, 概念是不会变的.

    -----------------------------------------------------------

    作者介绍

    Hanu Kommalapati is an Architect at Microsoft Gulf Coast District (Houston). In his current role at Microsoft, he helps enterprise customers in building scalable component frameworks based on the .NET Framework. He can be reached at hanuk@microsoft.com.

    Tom Christian is an Escalation Engineer with Developer Support at Microsoft, working with ASP.NET and the .NET debugger extension for WinDBG (sos/psscor). He is based in Charlotte, NC and can be contacted attomchris@microsoft.com.

    后记: 很多学习.NET的资料都推荐这篇文章, <Windows用户态程序高效排错>中称这篇文章是字字珠玑, 于是学友舍得花两天的时间翻译,校对这篇文章. 主要目的是让自己能更深刻的理解文章所述及的技术细节. 希望你能和我一样, 看了这篇文章后能有所收获.

    其实文章中涉及到的技术不仅仅是标题部分列出来的.NET Framework和C#。 如果读者懂一些汇编的基础知识,将会有更好的理解。我的博文中有个汇编基础的系列, 我觉得作为这篇文章的一点知识准备挺适合的.

    看了这篇文章之后, 相信你也觉得.net不像从前那么神秘了,对么? :)

    好多长句, 理清楚从句之间的从属和修饰关系很累人. 如果忠于原文, 光看懂长句就要花上一两分钟, 所以还是把长句子拆成了许多短句. 方便大家快速获取一些印象. 有的时候概念还是英文的好懂些, 就没翻译的那么彻底. 有些中英文概念都在紧随其后的括号中有另外一个的注解. 第一次出现的名词, 一般用英文直接写出, 后面概念重复了的时候才适当翻译成中文. 总之, 以易读为目标组织语言.

    不知道大家的习惯如何, 个人感觉在银屏上读文章就希望一目了然, 所以为了概念和结构的清晰, 原文的某些段落被用换行拆开了.

    翻译水平有限, 技术水平也有限, 让读者见笑了. 原文的链接在页面的顶部. 如果有困惑可以对照着看看.

    欢迎批评指正!

    posted @ 2009-11-11 23:13 中道学友 阅读(1144) 评论(0) 编辑

    CLR是如何工作的

    MetData和引擎初始化

    ====================

    托管Assembly本身只包含CLR可识别的MetaData(元资料), 不包含机器指令. 托管Assembly都与mscoree.dll绑定. mscoree.dll在system32目录下, 全称是Microsoft Core Execution Engine. 它的功能是选择合适的CLR Execution Engine来加载.

    2009-11-11 18-18-06

    多个版本的CLR可以共存. CLR的目录在C:WindowsMicrosoft.NETFramework. 当前系统中最新版本的CLR对应的mscoree.dll文件被拷贝到system32目录下.

    当mscoree.dll加载后, 它根据托管代码的metadata和app.config, 选择恰当版本的引擎加载. 同时mscoree还负责判断应该用何种GC Flavor. GC Flavor包括Workstation GC和Server GC. 在CLR1中, Workstation GC对应到mscorwks.dll, 而Server GC对应到mscorsvr.dll文件. 在CLR2中虽然保留了mscorsvr.dll文件, 但是mscorwks.dll已经包含了两种GC Flavor的实现, 只需要加载mscorwks就可以了.

    CLR加载后, 先初始化CLR需要的各种功能, 比如必要的全局变量, 引擎需要的模块(ClassLoader, assembly Loader, JitEngine, Copntext等), 启动Finalizer thread和GC thread, 创建System AppDomain和Shared AppDomain, 创建RCDebugger Thread, 加载CLR基础类(比如mscorlib.dll, system.dll)

    当CLR引擎初始化完成后, CLR会找到当前exe的元数据, 然后找到Main函数, 编译Main函数, 执行Main函数.

    JIT动态编译

    =================

    1. 全托管代码

    假设C#函数foo1要调用foo2. 当CLR编译foo1的时候, 无论foo2是否已经编译成机器代码, call指令都是吧执行指向到跟foo2相关的一个内存地址(stub). 当执行这个call指令的时候, 如果foo2没有被CLR编译, stub中的代码就会把执行定向到CLR JitEngine, 这样对foo2的调用便导致了CLR JitEngine的启动来编译foo2函数. Jit Engine编译完成之后, CLR把编译好的机器代码拷贝到进程中由cLR管理的某一块内存(loader heap)上, 然后Jit Engine把编译好的foo2函数入口地址填回到stub中.

    通过这样的技术, 第二次对foo2调用的时候, foo2的stub指向的已经是编译好的地址了, 于是不需要再次编译. 当然第一次编译完成之后, JitEngine同时需要负责执行刚刚编译好的函数.

    2. 托管代码调用非托管代码

    在CLR的执行过程中, 如果使用到的都是托管代码, 编译和执行就按照上面的逻辑进行. 但是不可避免的, 托管代码需要调用非托管代码. 这里分两种情况.

    第一种是调用系统API和DLLImport. 比如CLR中使用FileStream打开一个文件, 最终还是要调用到CreateFileW. 通过DLLImport调用自定义的非托管函数, 以及COM Interop也属于这种情况.

    第二种是调用CLR Runtime的功能, 比如内存分配, 异常派发.

    两种情况都使用stub技术. 对于第一种情况, 不关事PInvoke还是COM Interop发生的时候, 托管代码调用的都是由CLR创建的stub. 在这个stub中CLR会做一些必要的工作, 然后把控制权交给对应的非托管代码. 必要的工作包括把必要的函数参数拷贝到非托管的内存上, marshal必要的类型, 锁住需要跟非托管代码交互的托管内存区域, 防止GC移动这块内存. 如果是COM Interop, 还包括对非托管接口指针进行必要的QueryInterface等等. 当非托管调用结束后, 执行权返回stub, 再次进行必要的工作后, 回到托管代码.

    第二种情况中, 对CLR功能的调用往往是隐式发生的.

    一类是编译器直接生成对CLR stub的调用. 比如new/throw关键词. 动态编译引擎对这些关键词的处理是生成函数调用到特殊的stub上, stub再把执行定位到CLR引擎中的关键函数. 就分配内存来讲, 比如new一个StringBuilder object, 动态编译生成的指令吧执行权定向到特殊的stub, 该stub包含了指令来调用CLRzhong的内存分配函数, 同时传入类型信息.

    另一类是通过吧托管代码标示为internal call来编译. Internal call表示该托管函数其实是某些unmanaged函数的映像, 编译引擎在编译internal call的时候, 会直接把标记的internalcall属性的CLR方法, 直接跟unmanaged的函数实现对应起来. 该对应关系是在CLR的实现中通过C++的一张静态表定义的.

    GC内存管理

    =================

    CLR引擎初始化的时候会向操作系统申请连续内存作为managed heap. 所有的managed object都分配在managed heap中. 对于任何一种托管类型, 由于类型信息保存在metadata中, 所以CLR清楚如何生成正确的内存格式.

    当托管类型分配请求定向到CLR中后, CLR首先检查managed heap是否足够. 如果足够, CLR直接使用鲜有内存, 根据类型信息填入必要的格式资料, 然后把地址传递给托管代码使用. 如果托管堆不够, CLR执行GC试图请扫除一部分内存. 如果GC无法清扫出内存, CLR 向OS请求更多的内存作为managed heap.如果OS拒绝内存请求, OutOfMemory就发生了. CLR内存分配的特点是:

    1. 大多数情况下比非托管代码内存分配速度快. CLR保持内部指针指向当前托管堆中的free point. 只要内存足够, CLR直接把当前指针所在地址作为内存分配出去, 然后用指针加/减分配出去的内存的长度. 对于非托管代码的内存分配,不管是Heap Manager, 还是Virtual Memory allocation, 都需要做相应计算才能找到合适的内存进行分配.

    2. 由于托管对象受到CLR的管理, GC发生的时候CLR可以对托管object 进行随意移动, 然后休整保存object的stub信息, 保证托管代码不会受此影响. 移动object 可以防止内存碎片的产生, 提高内存使用效率. 对于非托管代码来说, 由于程序可以直接使用指针, 所以无法进行内存碎片整理.

    3. GC可以在任何时候触发, 但是GC不能在任何时候发生. 比如某一个线程正在做regex的匹配, 访问到大量的托管object, 很多object的地址保存到CPU寄存器上进行优化. 如果GC发生,导致object地址变化, 恢复运行后CPU寄存器上的指针可能就会无效. 所以GC必须在所有线程的执行状态都不会受到GC 影响的时候发生. 当线程的执行状态不受影响时, 该线程的PreEmptive GC属性石1, 否则是0.  这个开关受到CLR的控制, 很多stub中的代码会操作这个开关. 比如托管代码调用了MessageBox.Show, 该方法最后会调用到MessageBox API. 在stub调用API从托管代码变化到非托管代码前, stub会通过CLR内部方法把PreEmptive设定为1, 表示GC可以发生了. 大致的情况是, 当线程idle的时候(线程idle的时候肯定是在等某一个系统API, 比如sleep护着WaitForSingleObject), PreEmptive为1. 当线程在托管代码中干活的时候, PreEmptive为0. 当GC触发的时候, GC必须等到所有的线程都进入了PreEmptive模式后, 才可以发生.

    Exception Handling 异常处理

    ==========================

    异常处理在CLR中也非常有特色. 比如, NullReferenceException和Access Violation其实是密切相关的. 当编译的托管代码执行的时候, 对于NULL object的访问, 首先出发的是Access Violation. 但是聪明的CLR已经设定好了对应的FS:[0]寄存器来截获可能的异常. CLR截获异常后, 首先检查异常的类型, 对于Access Violation, CLR先检查当前的代码是否是托管代码, 对应的类型信息是什么. 发现是NULLobject访问后, CLR再把这个Access Violaiton异常标记为已处理, 然后生成对应的NullReferenceException抛出来. 当NullReferenceException被CLR设定的FS:[0]截获后, CLR发现异常是CLR Exception, 于是找对应的catch语句执行.

    CLR异常发生之后可以打印出callstack, 原因在于CLR可以通过原数据采集所有的类型信息, 同时CLR在thread中通过多种机制记录运行状态. 保存在Stack中的Frame就是其中的一种重要的数据结构. Frame是CLR保存在stack中的小块数据结构. 当therad的执行状态发生改变的时候, 比如在托管代码和非托管代码中切换, 异常产生, remoting调用等等的时候, CLR会恰当的插入Frame来标示状态的改变. thread中所有的frame是通过指针链接在一起的, 所以CLR可以方便的获取一个thread的各种状态情况.

    总结:

    1. 运行托管assembly的时候, 先会加载mscoree.dll.

    2. 系统中最新的mscoree.dll被加载, 然后mscoree.dll根据托管assembly的metadata决定该加载那个版本的CLR. 同时加载GC Flavor.

    3. CLR执行初始化

    4. CLR找到当前exe的metadata, 找到, 编译, 执行main函数.

    5. 过程中后可能遇到另外的函数, 第一次运行的时候都要先编译, 然后用stub技术让调用者拿到编译后的函数入口, 完成调用.

    6. 运行过程中, 如果请求内存会用到GC的一些特性.

    7. 出了异常, 会用到CLR的一些特性.

    资料来源《Windows用户态程序高效排错》

    posted @ 2009-11-11 18:22 中道学友 阅读(240) 评论(0) 编辑

    快速识别汇编中等价的C语言语句(if, while, for, switch)

    可能经常看汇编的朋友会一眼就认出跟C语言中一些语句等价的汇编代码, 经验使然也. 而不经常接触汇编的同学, 可能就对相对繁琐的寄存器操作指令有点云里雾里了.

    汇编是编译器翻译中级语言(也可以把C语言称作高级语言, 呵呵)的结果, 只要是机器做的事儿,一般都有规律可循. 那我们现在就来看看一下这些基本语句的汇编规律吧.

    注意:本文使用的汇编格式为GAS(Gnu ASembler GNU汇编器). 它同Intel文档中的格式以及微软编译器使用的格式差异很大,

    具体请看文章AT&T汇编格式与Intel汇编格式的比较.

    条件转移语句- if

    ============================

    C语言中的if-else语句的通用形式如下

    1. if(test-expr)
    2.     then-statement;
    3. else
    4.     else-statement;

    对于这种通用形式, 汇编实现通常会使用下面这种形式

    1.     ttest-expr;
    2.     if (t)
    3.         goto true;
    4.     else-statement
    5.         goto done;
    6. true:
    7.     then-statement
    8. done:

    也就是汇编器为then-statement 和else-statement各自产生代码块, 并插入条件和无条件分支, 以保证正确代码块的执行.

    下面我们来看C语言源代码, 汇编结构的C代码, 和汇编代码的比较.

    Code Snippet
    1. //----------Classic C code------------
    2. int absdiff(int xint y)
    3. {
    4.     if (x < y)
    5.         return y - x;
    6.     else
    7.         return x - y;
    8. }
    9. //----------Classic C code------------
    Code Snippet
    1. //----------Equivalent Goto Version------------
    2. int gotodiff(int xint y)
    3. {
    4.     int rval;
    5.  
    6.     if (x < y)
    7.         goto less;
    8.     rval = x - y;
    9.     goto done;
    10. less:
    11.     rval = y - x;
    12. done:
    13.     return rval;
    14. }
    15. //----------Equivalent Goto Version------------
    Code Snippet
    1. //----------Equivalent assembly Version------------
    2.     movl 8(%ebp),%edx          ;Get x
    3.     movl 12(%ebp),%eax         ;Get y
    4.     cmpl %eax,%edx             ;Compare x:y
    5.     jl .L3                     ;If <, goto less:
    6.     subl %eax,%edx             ;Compute y-x
    7.     movl %edx,%eax             ;Set as return value
    8.     jmp .L5                    ;Goto done:
    9. .L3:                           ;less:
    10.     subl %edx,%eax             ;Compute x-y as return value
    11. .L5:                           ;done:Begin completion code
    12. //----------Equivalent assembly Version------------

    do-while循环

    ========================

    do-while循环的通用形式是这样的:

    1. do
    2. {body-statement}
    3. while (test-expr);

    循环的效果就是重复执行body-statement, 对test-expr求值, 如果不是0, 就继续循环. 注意, 循环体至少执行一次.

    通常, do-while 的实现有下面的通用形式:

    1. loop:
    2.     body-statement
    3.     ttest-expr;
    4.     if (t)
    5.         goto loop;

    下面是一个例子, 找找感觉吧.

    Code Snippet
    1. //----------Original C Version------------
    2. do{
    3.     int t = val + nval;
    4.     val = nval;
    5.     nval = t;
    6.     i++;
    7. while (i < n);
    8. //----------Original C Version------------
    Code Snippet
    1. //----------Corresponding assembly code------------
    2. .L6loop:
    3.     leal (%edx,%ebx),%eax ;Compute t = val + nval
    4.     movl %edx,%ebx        ;copy nval to val
    5.     movl %eax,%edx        ;Copy t to nval
    6.     incl %ecx             ;Increment i
    7.     cmpl %esi,%ecx        ;Compare i:n
    8.     jl .L6 If less,       ;goto loop
    9. //---------Corresponding assembly code------------

    while循环

    ========================

    while语句循环的通用形式是这样的

    1. while(test-expr)
    2.     body-statement

    与do-while的不同之处在于对test-expr求值, 在第一次执行body-statement之前, 循环就可能终止了. 翻译成goto语句的形式就是

    1. loop:
    2.     ttest-expr;
    3.     if (!t)
    4.         goto done;
    5.     body-statement
    6.         goto loop;
    7. done:

    这种翻译需要在内循环(也就是执行次数最多的代码部分)中, 有两条goto语句. 大多数的编译器将这段代码转换成do-while循环, 把一个条件分支语句从循环体中拿到外面来.

    1.     if (!test-expr)
    2.         goto done;
    3.     do
    4.     body-statement
    5.         while (test-expr);
    6. done:

    然后, 再把这段代码换成带goto的语句的代码, 如下

    1.     ttest-expr;
    2.     if (!t)
    3.         goto done;
    4. loop:
    5.     body-statement
    6.         ttest-expr;
    7.     if (t)
    8.         goto loop;
    9. done:

    for循环

    ========================

    for循环的通用形式是这样的:

    1. for (init-exprtest-exprupdate-expr)
    2.     body-statement

    C语言的标准说明, 这样的一个循环的行为与下面这段使用while循环的代码的行为一样:

    1. init-expr;
    2. while (test-expr){
    3.     body-statement
    4.     update-expr;
    5. }

    然后再用前面讲过的从while到do-while的转换. 首先给出do-while形式

    1.     init-expr;
    2.     if (!test-expr)
    3.         goto done;
    4.     do{
    5.         body-statement
    6.         update-expr;
    7.     }while (test-expr);
    8. done:

    再转换成goto代码

    1.     init-expr;
    2.     ttest-expr;
    3.     if (!t)
    4.         goto done;
    5. loop:
    6.     body-statement
    7.         update-expr;
    8.     ttest-expr;
    9.     if (t)
    10.         goto loop;
    11. done:

    相信现在, 你已经对汇编中的循环指令簇有点模式的感觉了吧? 呵呵. 我们再来看一个switch语句, 然后收工.

    switch语句

    ======================

    switch语句提供了一个整数索引值, 通过它来进行多重分支. 那么switch语句和一组很长的if-else语句相比, 有什么优势呢? 我先把答案说出来, 然后看看汇编, 就知道了.

    优势就是: 执行开关语句的时间与开关情况的数量无关.

    能做到这样的原因是跳转表. 跳转表是一个数组, 表项i是一个代码段的地址, 这个代码段实现的就是开关索引值等于i的时候应该采取的动作.

    让我们来看一个例子, 这个例子包含一些很有意思的特征, 情况标号(case label)不连续, 比如101, 105; 一个情况有多个标号, 比如104, 106; 有些情况会落入其他情况(102), 因为该情况没有用break结尾.

    1. //----------Original C code------------
    2. int switch_eg(int x)
    3. {
    4.     int result = x;
    5.  
    6.     switch (x) {
    7.  
    8.         case 100:
    9.             result *= 13;
    10.             break;
    11.  
    12.         case 102:
    13.             result += 10;
    14.             /* Fall through */
    15.  
    16.         case 103:
    17.             result += 11;
    18.             break;
    19.  
    20.         case 104:
    21.         case 106:
    22.             result *= result;
    23.             break;
    24.  
    25.         default:
    26.             result = 0;
    27.     }
    28.  
    29.     return result;
    30. }
    31. //----------Original C code------------

    说明问题的C的伪代码

    1. /* Next line is not legal C */
    2. code *jt[7] = {
    3.     loc_Aloc_defloc_Bloc_C,
    4.     loc_Dloc_defloc_D
    5. };
    6. int switch_eg_impl(int x)
    7. {
    8.     unsigned xi = x - 100;
    9.     int result = x;
    10.     if (xi > 6)
    11.         goto loc_def;
    12.     /* Next goto is not legal C */
    13.     goto jt[xi];
    14. loc_A/* Case 100 */
    15.     result *= 13;
    16.     goto done;
    17. loc_B/* Case 102 */
    18.     result += 10;
    19.     /* Fall through */
    20. loc_C/* Case 103 */
    21.     result += 11;
    22.     goto done;
    23. loc_D/* Cases 104, 106 */
    24.     result *= result;
    25.     goto done;
    26. loc_def/* Default case*/
    27.     result = 0;
    28. done:
    29.     return result;
    30. }
    1. //----------Corresponding assembly code------------
    2. //***********
    3. // Code that Set up the jump table access
    4. //***********
    5.     leal -100(%edx),%eax         ;Compute xi = x-100
    6.     cmpl $6,%eax                 ;Compare xi:6
    7.     ja .L9                       ;if >, goto done
    8.     jmp *.L10(,%eax,4)           ;Goto jt[xi]
    9. //Case 100
    10. L4:                              ;loc A:
    11.     leal (%edx,%edx,2),%eax      ;Compute 3*x
    12.     leal (%edx,%eax,4),%edx      ;Compute x+4*3*x
    13.     jmp .L3                      ;Goto done
    14. //Case 102
    15. L5:                              ;loc B:
    16.     addl $10,%edx                ;result += 10, Fall through
    17. //Case 103
    18. L6:                              ;loc C:
    19.     addl $11,%edx                ;result += 11
    20.     jmp .L3                      ;Goto done
    21. //Cases 104, 106
    22. L8:                              ;loc D:
    23.     imull %edx,%edx              ;result *= result
    24.     jmp .L3                      ;Goto done
    25. //Default case
    26. L9:                              ;loc def:
    27.     xorl %edx,%edx               ;result = 0
    28. //Return result
    29. L3:                              ;done:
    30.     movl %edx,%eax               ;Set result as return value
    31. //----------Corresponding assembly code------------

    参考资料<深入理解计算机系统>

    posted @ 2009-11-11 11:24 中道学友 阅读(1439) 评论(0) 编辑

    AT&T汇编格式与Intel汇编格式的比较

    GCC采用的是AT&T的汇编格式, 也叫GAS格式(Gnu ASembler GNU汇编器), 而微软采用Intel的汇编格式. 
    一 基本语法 
    语法上主要有以下几个不同. 
    1、寄存器命名原则

    AT&T Intel 说明
    %eax eax Intel的不带百分号


    2、源/目的操作数顺序

    AT&T Intel 说明
    movl %eax, %ebx mov ebx, eax Intel的目的操作数在前,源操作数在后


    3、常数/立即数的格式

    AT&T Intel 说明
    movl $_value,%ebx mov eax,_value Intel的立即数前面不带$符号
    movl $0xd00d,%ebx mov ebx,0xd00d 规则同样适用于16进制的立即数


    4、操作数长度标识

    AT&T Intel 说明
    movw %ax,%bx mov bx,ax Intel的汇编中, 操作数的长度并不通过指令符号来标识

    在AT&T的格式中, 每个操作都有一个字符后缀, 表明操作数的大小. 例如:mov指令有三种形式:

    movb  传送字节

    movw  传送字

    movl   传送双字

    因为在许多机器上, 32位数都称为长字(long word), 这是沿用以16位字为标准的时代的历史习惯造成的.

    ---------摘自《深入理解计算机系统》


    5、寻址方式

    AT&T Intel
    imm32(basepointer,indexpointer,indexscale) [basepointer + indexpointer*indexscale + imm32)

    两种寻址的实际结果都应该是

    imm32 + basepointer + indexpointer*indexscale

    AT&T的汇编格式中, 跳转指令有点特殊.

    直接跳转, 即跳转目标是作为指令的一部分编码的.

            例如: jmp Label_1

    间接跳转, 即跳转目标是从寄存器或存储器位置中读出的. 写法是在" * "后面跟一个操作数指示符.

            例如: jmp *%eax 用寄存器%eax中的值作为跳转目标

                     jmp *(%eax) 以%eax中的值作为读入的地址, 从存储器中读出跳转目标

    --------摘自《深入理解计算机系统》

    下面是一些寻址的例子:

    AT&T: `-4(%ebp)'         相当于 Intel: `[ebp - 4]' AT&T: `foo(,%eax,4)' 相当于 Intel: `[foo + eax*4]'AT&T: `foo(,1)'           相当于 Intel `[foo]'AT&T: `%gs:foo'          相当于 Intel`gs:foo
    例子摘自http://sourceware.org/binutils/docs/as/i386_002dMemory.html#i386_002dMemory
  • 相关阅读:
    C#高级编程第11版
    C#特性
    设计模式 单一职责原则
    设计模式 依赖倒置原则
    C# 预处理指令
    毕业设计 python opencv实现车牌识别 矩形矫正
    毕业设计 python opencv实现车牌识别 颜色判断
    毕业设计 python opencv实现车牌识别 界面
    南昌大学航天杯第二届程序设计竞赛校赛网络同步赛 I
    南昌大学航天杯第二届程序设计竞赛校赛网络同步赛 G
  • 原文地址:https://www.cnblogs.com/zhyj/p/3771377.html
Copyright © 2011-2022 走看看