找回密码
 立即注册
首页 业界区 业界 深入了解CLR的加载过程

深入了解CLR的加载过程

吟氅 2025-5-29 20:15:39
我们知道,.net编译器在生成托管代码时会将一些重要信息写入PE文件的header和.text section(后边我会介绍这些写入程序集的重要信息是什么),本文介绍当我们双击一个托管代码写的exe程序时发生的事情。
    以下说明所使用的工具是VS2005+sos.dll,示例程序代码如下:    using System;
using System.Collections.Generic;
using System.Text;

namespace hello
{
    class Program
    {
        static void Main(string[] args)
        {
            Int32 a = 1;
            Int32 b = 2;
            b = a + b;
            Console.WriteLine(b);

            Console.ReadKey();
        }
    }
}
    那么CLR是如何被加载的呢?
     1、当你双击一个.exe文件时,Windows操作系统提供的PE Loader会将该exe文件载入内存;
(1)、首先明确一点,PE Loader问什么能加载exe文件呢?因为exe文件就是一种PE文件,PE(Portable Execute)文件是微软Windows操作系统上的程序文件,
         EXE、DLL、OCX、SYS文件以及COM组件都是PE文件
      (2)、有必要了解一下PE文件的结构:
1.jpeg
图 1

    1) Dos stub
  由100个左右的字节所组成,用来输出类似“这个程序不能在DOS下运行!”这样的错误信息;
    2) PE Signature
        DWORD类型,PE文件签名,用来表示这是个PE文件,用ASCII码表示;

    3) File Header

        包含PE文件最基本信息,通过dumpbin可以看到,如图2所示 从这里可以看到:CPU类型为14c,是Intel I386、I486或者I586;section的数量为2;链接器产生这个文件的日期;COFF符号表的文件偏移量,为0;COFF符号表的符号数目,为0;Optional Header的大小。


2.bmp
图2

        4) Optional Header

 用来存储除了基本信息以外的其他重要信息,具体含义大家可以查阅PE文件格式的相关资料,我这里对一些关心的域根据图3进行一下说明:

 -- entry point,指明这个PE文件的入口地址,是一个RVA(相对虚拟地址);

 -- base of code,代码块起始地址的RVA,在内存中,代码块通常在PE首部之后,数据块之前;

 -- base of data,数据块;

 -- image base,PE文件被链接器重定位后的内存地址,可以是链接器优化,节省载入时间和空间;

 -- subsystem,可执行文件的用户界面使用的子系统类型。具体值的含义为:

1 不需要子系统(比如设备驱动)

  2 在Windows图形用户界面子系统下运行

  3 在Windows字符子系统下运行(控制台程序)

  5 在OS/2字符子系统下运行(仅对OS/2 1.x)

  7 在 Posix 字符子系统下运行

      所以可以看到我们的程序是一个控制台程序。

       -- 最后定义了一些数据目录,具体内容不再赘述。


3.bmp

                                                                     图 3
 

        5)  section header
             Section header可以有一个或多个,见图4、图5、图6。
             -- name,表示这个section的名字,例如这个section的名字为.text;
             -- virtual address,保存section中数据被载入内存后的RVA;
             -- file pointer to raw data,从文件开头到section中数据的偏移量。


4.bmp
                                                                     图 4
                                                                        
             -- Section 的原始数据
5.bmp
                                                                     图 5
6.bmp
                                                                      图 6
            -- CLR头,从图7可以找到随托管代码IL同时生成的元数据表的RVA。

7.bmp
                                                                      图 7
 
    2、PE loader通过查找CLR头发现该目录不为空,则自动将mscoree.dll载入进程地址空间中,mscoree.dll一定是唯一的,且总是处于系统目录的system32下,例如我的机器为C:\WINDOWS\system32目录下。.net 2.0的mscoree.dll的大小只有256k左右,这个dll被叫做shim,它的作用是连接PE文件和CLR之间的一个桥梁。
    3、PE loader接着会找到entry point,例如本例中图3所示,这个PE文件的入口点地址为0040251E,然后通过这个地址来查找.text section的原始数据表,由图6所示,0040251E这个地址开始的6个字节的内容为【FF 25 00 20 40 00】,这个内容就是由编译器写入PE文件的.text section的重要信息,FF在x86汇编语言与机器码对照表中代表无条件转移指令Jmp,这条指令的作用是无条件跳转到00402000地址处,从图3可以看到image base 是00400000,2000是import address table的RVA地址,由图7可以看到,此时程序会跳转到00402000这个地址所引用的mscoree.dll的_CorExeMain(_CorExeMain为mscoree.dll的入口方法)方法,所有的托管应用都会通过上述过程找到并执行_CorExeMain方法;
    4、_CorExeMain方法会帮助程序找到并载入适当的CLR版本,在.net 2.0以后实现CLR的程序集为mscorwks.dllmscorsvr.dll,例如,在我的机器上mscorwks.dll的位置是:C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\;
    5、启动CLR服务,开始初始化工作,这个初始化工作包括:
      -- 分配一块内存空间,建立托管堆及其它必要的堆,由GC监控整个托管堆
      -- 创建线程池
      -- 创建应用程序域(AppDomain):利用sos.dll可以查看CLR创建了哪些AppDomain。
用VS2005打开我们的程序,即时窗口中敲入:.load sos.dll
       在VS2005的即时窗口中敲入:!dumpdomain,(注:这个结果是完成步骤7后的结果),但是依然可以说明问题:

8.jpeg
 
 
                                                                 图 8
   由图8可见,CLR创建了System Domain、Shared Domain和Domain1,这个Domain1是默认Appdomain。
    6、接下来就会向默认AppDomain中载入mscorlib.dll,由图八可见,任何托管代码,CLR在创建好默认AppDomain后,第一个载入的组件一定是mscorlib.dll,实际上这个组件定义了System.Object、所有基元类型:如System.Int32等,利用sos.dll可以看到有哪些类被载入,依据Domain 1里的Module地址,在即时窗口敲入命令!dumpmodule -mt 790c2000,结果如下,比较长,我只列出部分:


9.jpeg
                                                                          图 9

   从图9可以看到System.Object被第一个加载进来,接着是System.ICloneable、System.IEnumerable、System.Collection.ICollection、
    System.Collection.IList、System.Array……
    7、产生主线程后可能会触发一些mscorlib.dll里的类型并加载入内存,接着,当你的PE文件:hello.exe被载入后,默认Appdomain的名字被改为你的PE文件的名字,载入过程完成后的结果可见图8。  
8、包含在mscorwks.dll中的_CorExeMain2方法接管主线程,_CorExeMain2的代码如下:

10.gif
11.gif
_CorExeMain2
__int32 STDMETHODCALLTYPE _CorExeMain2( // Executable exit code.
    PBYTE   pUnmappedPE,                // -> memory mapped code
    DWORD   cUnmappedPE,                // Size of memory mapped code
    __in LPWSTR  pImageNameIn,          // -> Executable Name
    __in LPWSTR  pLoadersFileName,      // -> Loaders Name
    __in LPWSTR  pCmdLine)              // -> Command Line
{

    // This entry point is used by clix
    BOOL bRetVal = 0;

    //BEGIN_ENTRYPOINT_VOIDRET;

    // Before we initialize the EE, make sure we've snooped for all EE-specific
    // command line arguments that might guide our startup.
    HRESULT result = CorCommandLine::SetArgvW(pCmdLine);

    if (!CacheCommandLine(pCmdLine, CorCommandLine::GetArgvW(NULL))) {
        LOG((LF_STARTUP, LL_INFO10, "rogram exiting - CacheCommandLine failed\n"));
        bRetVal = -1;
        goto exit;
    }

    if (SUCCEEDED(result))
        result = CoInitializeEE(COINITEE_DEFAULT | COINITEE_MAIN);

    if (FAILED(result)) {
        VMDumpCOMErrors(result);
        SetLatchedExitCode (-1);
        goto exit;
    }

    // This is here to get the ZAPMONITOR working correctly
    INSTALL_UNWIND_AND_CONTINUE_HANDLER;


    // Load the executable
    bRetVal = ExecuteEXE(pImageNameIn);

    if (!bRetVal) {
        // The only reason I've seen this type of error in the wild is bad
        // metadata file format versions and inadequate error handling for
        // partially signed assemblies.  While this may happen during
        // development, our customers should not get here.  This is a back-stop
        // to catch CLR bugs. If you see this, please try to find a better way
        // to handle your error, like throwing an unhandled exception.
        EEMessageBoxCatastrophic(IDS_EE_COREXEMAIN2_FAILED_TEXT, IDS_EE_COREXEMAIN2_FAILED_TITLE);
        SetLatchedExitCode (-1);
    }

    UNINSTALL_UNWIND_AND_CONTINUE_HANDLER;

exit:
    STRESS_LOG1(LF_STARTUP, LL_ALWAYS, "rogram exiting: return code = %d", GetLatchedExitCode());

    STRESS_LOG0(LF_STARTUP, LL_INFO10, "EEShutDown invoked from _CorExeMain2");

    EEPolicy::HandleExitProcess();
    
    //END_ENTRYPOINT_VOIDRET;

    return bRetVal;
}   
      我们可以看到它有一句bRetVal = ExecuteEXE(pImageNameIn); 而ExecuteEXE将调用System Domain中的SystemDomain::ExecuteMainMethod方法,代码如下:
 
12.gif
13.gif
ExecuteEXE
BOOL STDMETHODCALLTYPE ExecuteEXE(__in LPWSTR pImageNameIn)
{
    STATIC_CONTRACT_GC_TRIGGERS;

    WCHAR               wzPath[MAX_PATH];
    DWORD               dwPathLength = 0;

    EX_TRY_NOCATCH
    {
        // get the path of executable
        dwPathLength = WszGetFullPathName(pImageNameIn, MAX_PATH, wzPath, NULL);

        if (!dwPathLength || dwPathLength > MAX_PATH)
        {
            ThrowWin32( !dwPathLength ? GetLastError() : ERROR_FILENAME_EXCED_RANGE);
        }

        SystemDomain::ExecuteMainMethod( NULL, (WCHAR *)wzPath );
    }
    EX_END_NOCATCH;

    return TRUE;
}            
      然后由ExecuteMainMethod方法对默认应用程序域进行一些设置,最后通过pDomain->m_pRootAssembly->ExecuteMainMethod(NULL),这里pDomain是一个AppDomain类型的实例,代码如下:


14.gif
15.gif
Assembly::ExecuteMainMethod
INT32 Assembly::ExecuteMainMethod(PTRARRAYREF *stringArgs)
{
    CONTRACTL
    {
        INSTANCE_CHECK;
        THROWS;
        GC_TRIGGERS;
        MODE_ANY;
        ENTRY_POINT;
        INJECT_FAULT(COMPlusThrowOM());
    }
    CONTRACTL_END;

    HRESULT hr = S_OK;
    INT32   iRetVal = 0;

    BEGIN_ENTRYPOINT_THROWS;

    Thread *pThread = GetThread();
    MethodDesc *pMeth;
    {
        // This thread looks like it wandered in -- but actually we rely on it to keep the process alive.
        pThread->SetBackground(FALSE);
    
        GCX_COOP();

        pMeth = GetEntryPoint();
        if (pMeth) {
            RunMainPre();
            hr = ClassLoader::RunMain(pMeth, 1, &iRetVal, stringArgs);
        }
    }

    //RunMainPost is supposed to be called on the main thread of an EXE,
    //after that thread has finished doing useful work.  It contains logic
    //to decide when the process should get torn down.  So, don't call it from
    // AppDomain.ExecuteAssembly()
    if (pMeth) {
        if (stringArgs == NULL)
            RunMainPost();
    }
    else {
        StackSString displayName;
        GetDisplayName(displayName);
        COMPlusThrowHR(COR_E_MISSINGMETHOD, IDS_EE_FAILED_TO_FIND_MAIN, displayName);
    }

    if (FAILED(hr))
        ThrowHR(hr);
    END_ENTRYPOINT_THROWS;
    return iRetVal;
}
     

      代码中GetEntryPoint将通过MetaData表提供的接口查找包含.entrypoint的类型,接着返回入口方法(在C#中这个入口方法一定是Main方法)的一个MethodDesc类型的实例,获取MethodDesc类型实例的这个过程我认为是:CLR通过读取元数据表,定位入口方法所属的类型,建立这个类型的EECLASS(EECLASS结构中包含重要信息有:指向当前类型父类的指针、指向方法表的指针、实例字段和静态字段等)和这个类型所包含方法的Method Table(方法表由一个个Method Descripter组成,具体到内存中就是指向若干MethodDesc类型实例的地址),通过EEClass::FindMethod方法找到并返回入口方法的MethodDesc类型实例。
      最后通过ClassLoader::RumMain来执行入口方法。

 

        (1)、在即时窗口敲入命令!dumpmodule -mt 0097c24,有如下结果:

16.jpeg
                                                                       图 10  
      从图10可以看到在当前模块中所定义的类型:hello.Program和所引用的类型:System.Object和System.Console。
        (2)、在即时窗口敲入命令!dumpmt –md 00972ff和!dumpclass 00971260后,有如下结果:

17.jpeg

图 11
      由图11可以得到如下信息:为hello.Program类型分配的EECLASS在内存中的地址为00971260,通过这个地址查看其信息,发现hello.Program的父类地址为: 790f8a18,在即时窗口敲入命令!dumpclass  790f8a18,有图12,可见hello.Program类型的父类为:System.Object。

18.jpeg

图 12
    方法表Method Table的地址为00972ff8。

      (3)、方法表里存的是什么呢?其实是当前类型中所有定义和引用到的方法的入口点,这个入口点被叫做Method descriptors,从图11可以看到。

      (4)、实际上Method descriptors被分为两个部分,第一部分是m_CodeOrIL,在当前方法没有被JIT的时候,m_CodeOrIL存的是这个方法的MSIL的RVA,也就是从这个RVA可以找到当前方法的MSIL代码;第二部分是对JIT编译器的一个Stub(存根),当方法是第一次被调用的时候,CLR会通过这个Stub调用mscorjit.dll组件,通过查找元数据表中入口方法的RVA和.Text section中的原始表的数据,找到这个方法对应的MSIL代码,然后将其编译为本地CPU指令,假设这里存到地址RVA1,最后将m_CodeOrIL和Stub的值都修改为RVA1,那么当这个方法第二次被调用的时候将会直接通过RVA1去寻找本地代码,换句话说只有当方法第一次被调用的时候才会被Jit编译器编译,之后则直接使用编译好的本地代码。同时这也说明托管代码被编译了两次,第一次编译是将托管代码编译为MSIL代码,并同时生成Metadata元数据文件,第二次编译发生在方法被调用时由Jit编译器完成。

      (5)、在即时窗口敲入命令!dumpmd 00972fe8和!dumpmd 00972f0可以看到已经被Jit过的和还没有被Jit的方法的信息:
19.jpeg
                                                                         图 13
 
     被Jit过得方法则会修改m_CodeOrIL,如Main方法的m_CodeOrIL被指向地址00e50070,而没有被Jit的方法m_CodeOrIL的值为ffffffffffffffff。
      (6)、在即时窗口敲入命令!u 00e50070,结果如下:

20.jpeg

图 14 
      图14列出helloProgram.Main方法的本地代码。而如果在即时窗口敲入命令!u ffffffffffffffff则显示Unmanaged code。
    9、进入Main方法,进而执行后续程序。

    最后,从上述分析也可以看出,.NET的几个核心组件的被调用顺序大致是: mscoree.dll -----> mscorwks.dll(mscorsvr.dll)  -----> mscorlib.dll ----->mscorjit.dll。
    一般来说调试.NET程序使用VS2005就可以了,但是要想得到更详细的信息,如内存情况等就需要借助其他工具了,个人觉得sos.dll和Windbg是很好的工具,Windbg可以在http://www.microsoft.com/whdc/devtools/debugging/default.mspx下载,而如果你装的是VS2005 Team Version,那么自带sos.dll。
    关于CLR的详细内容,大家可以通过微软的 Shared Source Common Language Infrastructure(SSCLI),来了解关于CLR的一些内部机理,大家可以到
http://www.microsoft.com/downloads/details.aspx?FamilyId=8C09FD61-3F26-4555-AE17-3121B4F51D4D&displaylang=en下载,相信会对理解CLR有所帮助,另外就是由蔡學鏞写的http://www.microsoft.com/taiwan/msdn/columns/DoNet/loader.htm,文章挺早,但很经典,大家可以看看。

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
您需要登录后才可以回帖 登录 | 立即注册