代码改变世界

老赵谈IL(2):CLR内部有太多太多IL看不到的东西,包括您平时必须了解的那些

2009-06-03 14:34  Jeffrey Zhao  阅读(31999)  评论(124编辑  收藏  举报

我一直建议大家不要倾向于学习IL的原因有二:

  1. IL能够说明的内容太少,包括大部分.NET“必知必会”。
  2. IL能够获得的信息从高级语言中也大都可以知道。

而这篇文章便是希望通过实例来把第1点解释清楚,而第2点则留给下一篇文章来解释。

在文章开始之前,我先要承认两个错误:

  • 首先,上一篇文章对于“IL”和“汇编”的阐述还有些混淆。在这方面某些朋友给出了一些更确切地说法,IL是一种为.NET平台设计的汇编语言,拥有大量.NET平台中特有的高级特性。而x86汇编等则是与机器码一一对应的文字形式代码。不过为了方便表述,在这一系列文章中,还是以“IL”来指代.NET平台上的中间语言,以“汇编”来指代x86汇编这种和特定CPU平台紧密相关的事物——包括之前那篇文章,其实是在阐述IL和汇编之间的关系和区别,以及该如何对待它们的问题,而并非为IL是否可以被叫做是“汇编”进行争论。
  • 其次,在第1篇文章发布的时候,现在这篇文章,也就是本系列第2篇的标题是“汇编可以看到太多IL看不到的东西”。不过后来在半夜重读这篇文章,并且仔细整理这篇文章的示例时发现出了一个问题——我并不是在讲汇编,要探索CLR中的各种问题也并不是仅仅靠汇编来发现的。当时写文章的时候谈了太多的IL和汇编,最终把自己的思路也给绕了进去。现已修改,希望没有给朋友们造成误解,抱歉。今后我也会尽量避免此类情况发生。

好了,现在开始继续我们这次的话题。

既然是讨论IL,自然要讨论IL可以做什么,这样才能够以此得出IL究竟该不该学,如果该学的话有应该怎么学——这话当然是对你,我,他个人来说的,不可一概而论。不过也有一个东西需要了解的,就是IL到底表达出了什么,IL又没有表达出什么东西。

在这里继续强调一下上文的结论:无论IL是否算是一种“汇编”,都不影响它是一种非常高级的编程语言,拥有各种高级特性,例如泛型、引用类型,值类型,方法属性等等,这些特性才是我们判断的依据,而不是它是否能够被冠上“汇编”的“称号”。简单地说,我们应该看它“能(或不能)做什么”,而不是它“能(或不能)被叫做什么”。以下就通过几个示例来展示一些情况:

示例一:探究泛型在某些情况下的性能问题

为了契合本文的内容,也为了说明问题,我先举一个例子,那正是在《从汇编入手,探究泛型的性能问题》一文中使用过的例子:

namespace TestConsole
{
    public class MyArrayList
    {
        public MyArrayList(int length)
        {
            this.m_items = new object[length];
        }

        private object[] m_items;

        public object this[int index]
        {
            [MethodImpl(MethodImplOptions.NoInlining)]
            get
            {
                return this.m_items[index];
            }
            [MethodImpl(MethodImplOptions.NoInlining)]
            set
            {
                this.m_items[index] = value;
            }
        }
    }

    public class MyList<T>
    {
        public MyList(int length)
        {
            this.m_items = new T[length];
        }

        private T[] m_items;

        public T this[int index]
        {
            [MethodImpl(MethodImplOptions.NoInlining)]
            get
            {
                return this.m_items[index];
            }
            [MethodImpl(MethodImplOptions.NoInlining)]
            set
            {
                this.m_items[index] = value;
            }
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            MyArrayList arrayList = new MyArrayList(1);
            arrayList[0] = arrayList[0] ?? new object();

            MyList<object> list = new MyList<object>(1);
            list[0] = list[0] ?? new object();

            Console.WriteLine("Here comes the testing code.");

            var a = arrayList[0];
            var b = list[0];

            Console.ReadLine();
        }
    }
}

那篇文章的目的是证明“.NET中,就算在使用Object作为泛型类型的时候,也不会比直接使用Object类型性能差”。于是我准备了两个类,一个是MyList泛型容器,一个是MyArrayList直接使用Object类型的容器。在Main方法中将对MyList<Object>和MyArrayList的下标索引进行访问。至此,便出现了一些疑问,为泛型容器使用Object类型,是否比直接使用Object类型性能要差?于是乎,我们来看MyArrayList.get_Item和MyList<T>.get_Item两个方法的IL代码——没错,就是它们的下标get操作:

// MyArrayList的get_Item方法
.method public hidebysig specialname instance object get_Item(int32 index) cil managed noinlining
{
    .maxstack 8
    L_0000: ldarg.0 
    L_0001: ldfld object[] TestConsole.MyArrayList::m_items
    L_0006: ldarg.1 
    L_0007: ldelem.ref 
    L_0008: ret 
}

// MyList<T>的get_Item方法
.method public hidebysig specialname instance !T get_Item(int32 index) cil managed noinlining
{
    .maxstack 8
    L_0000: ldarg.0 
    L_0001: ldfld !0[] TestConsole.MyList`1::m_items
    L_0006: ldarg.1 
    L_0007: ldelem.any !T
    L_000c: ret 
}

朋友们一定已经发现了,这两个方法的区别只在于红色的两句。嗯,我们就“默认”ldfld指令的功能在两段代码中产生的效果完全相同(毕竟是相同的指令嘛),但是您觉得ldelem.ref指令和ldelem.any两条指令的效果如何,它们是一样的吗?我们通过查阅一些资料可以了解到说,ldelem.any的作用是加载一个泛型向量或数组中的元素。不过它的性能如何?您能得出结果说,它就和ldelem.ref指令一样吗?

我想,除非您了解到JIT对待这两个指令的具体方式,否则您是无法得出其中性能高低的。因为IL还是过于高级,您看到了一条IL指令,您可以知道它的作用,但是您还是不知道它最终造成了何种结果。您还是无法证明“Object泛型集合的性能不会低于直接存放Object的非泛型集合”。也正因为如此,我在那篇文章里探究问题的手段,是比较了MyArrayList.get_Item方法和MyList<Object>.get_Item方法的汇编代码,最后得出结果是“毫无二致”。由于汇编代码和机器代码一一对应,因此观察汇编代码就可以完全了解CPU是如何执行这两个方法的。汇编代码一模一样,就意味着CPU对待这两个方法的方式一模一样,它们的性能怎么会有不同呢?

于是,您过去,现在或将来,可能就会在某本书、某篇博客或文章看到这样一种说法:.NET的Object泛型容器的性能不会低于直接使用Object的容器,因为CLR在处理Object泛型的时候,会生成与直接使用Object类型时一模一样的类型,因此性能是不会降低的。但是您是通过学习IL可以了解这些吗?我认为,如果您只是学习了IL,最终还是要“听别人说”才能知道这些,而即使您不学IL,在“听别人说”了之后您也了解了这些——同时也不会因为不了解IL而变得“易忘”等等。

同样道理,IL的call指令和callvirt指令的区别是什么呢?“别人会告诉你”call指令直接就去调用了那个方法,而callvirt还需要去虚方法表里去“寻找”那个真正的方法;“别人可能还会告诉你”,查找虚方法是靠方法表地址加偏移量;《Essential .NET》还会将方法表的实现结构告诉给你,而这些都是IL不会告诉您的。您就算了解再多IL,也不如“别人告诉你”的这些来得重要。您要了解“别人告诉你”的东西,也不需要了解多少IL。

示例二:只有经过调用的方法才能获得其汇编代码吗?

许多资料都告诉我们,在一个方法被第一次调用之前,它是不会被JIT的。也就是说,直到第一次调用时它才会被转化为机器码。不过,这个真是这样吗?我们还是准备一段简单的C#代码:

namespace TestConsole
{
    class Program
    {
        [MethodImpl(MethodImplOptions.NoInlining)]
        private static void SomeMethod()
        {
            Console.WriteLine("Hello World!");
        }

        static void Main(string[] args)
        {
            Console.WriteLine("Before JITed.");
            Console.ReadLine();

            SomeMethod();

            Console.WriteLine("After JITed");
            Console.ReadLine();
        }
    }
}

那么Main方法的IL代码是怎么样的呢?

.method private hidebysig static void Main(string[] args) cil managed
{
    .entrypoint
    .maxstack 8
    // 分配字符串"Before JITed"
    L_0000: ldstr "Before JITed."
    // 调用Console.WriteLine方法
    L_0005: call void [mscorlib]System.Console::WriteLine(string)
    // 调用Console.ReadLine方法
    L_000a: call string [mscorlib]System.Console::ReadLine()
    L_000f: pop 
    // 调用Program.SomeMethod方法
    L_0010: call void TestConsole.Program::SomeMethod()
    // 分配字符串"After JITed"
    L_0015: ldstr "After JITed"
    // 调用Console.WriteLine方法
    L_001a: call void [mscorlib]System.Console::WriteLine(string)
    // 调用Console.ReadLine方法
    L_001f: call string [mscorlib]System.Console::ReadLine()
    L_0024: pop 
    L_0025: ret 
}

IL代码多容易懂呀,这段IL代码基本上就和我们的C#一样。没错,这就是IL的作用。IL和C#一样,都是用于表现程序逻辑。C#使用if...else、while、for等等丰富语法,而在IL中就会变成判断+跳转语句。但是,您从一段几十行的IL语句中,看出一句十几行的while逻辑——收获在哪里?除此之外,C#分配一个变量,IL也分配一个。C#调用一个方法,IL就call或callvirt一下。C#里new一个,IL中就newobj一下(自然也会有一些特殊,例如可以使用jmp或tail call一个方法——是为尾递归,但也只是及其特殊的情况)。可以发现IL的功能大部分就是C#可以表现的功能。而C#隐藏掉的一些细节,在IL这里同样没有显示出来!

那么我们又该如何发现一些细节呢?例如“书本”告诉我们的JIT的工作方式:方法第一次调用之后才会生成机器码。

这段程序会打印三行文字,在打印出Before JITed和After JITed字样之后都会有一次停止,需要用户按回车之后才能继续。在进行试验的时候,您可以在程序暂停的时候使用WinDbg的File - Attach to Process命令附加到TestConsole.exe进程中,或者在两次暂停时各生成一个dump文件,这样便可不断地重现一些过程。否则的话,应用程序两次启动所生成的地址很可能会完全不同——因为JIT的工作是动态的,有时候很难提前把握。

好,我们已经进入了第一个Console.ReadLine暂停,在点击回车继续下去之前。我们先使用WinDbg进行调试。以下是Main方法的汇编代码:

0:000> !name2ee *!TestConsole.Program
Module: 70f61000 (mscorlib.dll)
--------------------------------------
Module: 00172c5c (TestConsole.exe)
Token: 0x02000002
MethodTable: 00173010
EEClass: 001712d0
Name: TestConsole.Program
0:000> !dumpmt -md 00173010
EEClass: 001712d0
Module: 00172c5c
Name: TestConsole.Program
mdToken: 02000002  (...\bin\Release\TestConsole.exe)
BaseSize: 0xc
ComponentSize: 0x0
Number of IFaces in IFaceMap: 0
Slots in VTable: 7
--------------------------------------
MethodDesc Table
   Entry MethodDesc      JIT Name
71126ab0   70fa4944   PreJIT System.Object.ToString()
71126ad0   70fa494c   PreJIT System.Object.Equals(System.Object)
71126b40   70fa497c   PreJIT System.Object.GetHashCode()
71197540   70fa49a0   PreJIT System.Object.Finalize()
0017c019   00173008     NONE TestConsole.Program..ctor()
0017c011   00172ff0     NONE TestConsole.Program.SomeMethod()
003e0070   00172ffc      JIT TestConsole.Program.Main(System.String[])
0:000> !u 003e0070
Normal JIT generated code
TestConsole.Program.Main(System.String[])
Begin 003e0070, size 4d
>>> 003e0070 55              push    ebp
003e0071 8bec            mov     ebp,esp
*** WARNING: Unable to verify checksum for mscorlib.ni.dll
003e0073 e8a8d3da70      call    mscorlib_ni+0x22d420 (7118d420) (System.Console.get_Out(), ...)
003e0078 8bc8            mov     ecx,eax
003e007a 8b153020d102    mov     edx,dword ptr ds:[2D12030h] ("Before JITed.")
003e0080 8b01            mov     eax,dword ptr [ecx]
003e0082 ff90d8000000    call    dword ptr [eax+0D8h]
003e0088 e8971b2571      call    mscorlib_ni+0x6d1c24 (71631c24) (System.Console.get_In(), ...)
003e008d 8bc8            mov     ecx,eax
003e008f 8b01            mov     eax,dword ptr [ecx]
003e0091 ff5064          call    dword ptr [eax+64h]
003e0094 ff15f82f1700    call    dword ptr ds:[172FF8h] (TestConsole.Program.SomeMethod(), ...)
003e009a e881d3da70      call    mscorlib_ni+0x22d420 (7118d420) (System.Console.get_Out(), ...)
003e009f 8bc8            mov     ecx,eax
003e00a1 8b153420d102    mov     edx,dword ptr ds:[2D12034h] ("After JITed")
003e00a7 8b01            mov     eax,dword ptr [ecx]
003e00a9 ff90d8000000    call    dword ptr [eax+0D8h]
003e00af e8701b2571      call    mscorlib_ni+0x6d1c24 (71631c24) (System.Console.get_In(), ...)
003e00b4 8bc8            mov     ecx,eax
003e00b6 8b01            mov     eax,dword ptr [ecx]
003e00b8 ff5064          call    dword ptr [eax+64h]
003e00bb 5d              pop     ebp
003e00bc c3              ret

请关注上面那个被标红的call语句,它的含义是:

  1. 先从读取172FF8地址中的值,这才是方法调用的目标地址(即SomeMethod方法)。
  2. 使用call指令调用刚才读取到的目标地址

那么在第一次调用SomeMethod方法之前,目标地址的指令是什么呢?

0:000> dd 172FF8
00172ff8  0017c011 71030002 00200006 003e0070
00173008  00060003 00000004 00000000 0000000c
00173018  00050011 00000004 711d0770 00172c5c
00173028  0017304c 001712d0 00000000 00000000
00173038  71126ab0 71126ad0 71126b40 71197540
00173048  0017c019 00000080 00000000 00000000
00173058  00000000 00000000 00000000 00000000
00173068  00000000 00000000 00000000 00000000
0:000> !u 0017c011
Unmanaged code
0017c011 b000            mov     al,0
0017c013 eb08            jmp     0017c01d
0017c015 b003            mov     al,3
0017c017 eb04            jmp     0017c01d
0017c019 b006            mov     al,6
0017c01b eb00            jmp     0017c01d
0017c01d 0fb6c0          movzx   eax,al
0017c020 c1e002          shl     eax,2
0017c023 05f02f1700      add     eax,172FF0h
0017c028 e9d7478c00      jmp     00a40804

这是什么,不像是SomeMethod的内容阿,SomeMethod是会调用Console.WriteLine方法的,怎么变成了一些跳转了呢?于是我们想起书本(例如《CLR via C#》)中的话来,在方法第一次调用时,将会跳转到JIT的指令处,对方法的IL代码进行编译。再想想书中的示意图,于是恍然大悟,原来这段代码的作用是“让JIT编译IL”啊。那么在JIT后,同样的调用会产生什么结果呢?

我们在WinDbg中Debug - Detach Debuggee,让程序继续运行。单击回车,您会发现屏幕上出现了Hello Word和After JIT的字样。于是我们继续Attach to Process,重复上面的命令。由于Main方法已经被编译好了,它的汇编代码不会改变,因此在调用SomeMethod方法时的步骤还是不变:先去内存172FF8中读取目标地址,再call至目标地址。

0:000> dd 172FF8
00172ff8  003e00d0 71030002 00200006 003e0070
00173008  00060003 00000004 00000000 0000000c
00173018  00050011 00000004 711d0770 00172c5c
00173028  0017304c 001712d0 00000000 00000000
00173038  71126ab0 71126ad0 71126b40 71197540
00173048  0017c019 00000080 00000000 00000000
00173058  00000000 00000000 00000000 00000000
00173068  00000000 00000000 00000000 00000000
0:000> !u 003e00d0
Normal JIT generated code
TestConsole.Program.SomeMethod()
Begin 003e00d0, size 1a
>>> 003e00d0 55              push    ebp
003e00d1 8bec            mov     ebp,esp
*** WARNING: Unable to verify checksum for mscorlib.ni.dll
003e00d3 e848d3da70      call    mscorlib_ni+0x22d420 (7118d420) (System.Console.get_Out(), mdToken: 06000772)
003e00d8 8bc8            mov     ecx,eax
003e00da 8b153820d102    mov     edx,dword ptr ds:[2D12038h] ("Hello World!")
003e00e0 8b01            mov     eax,dword ptr [ecx]
003e00e2 ff90d8000000    call    dword ptr [eax+0D8h]
003e00e8 5d              pop     ebp
003e00e9 c3              ret

于是我们发现,虽然步骤没有变,但是由于地址172FF8中的值改变了,因此call的目标也变了。新的目标中包含了SomeMethod方法的IL代码编译后的机器码,而我们现在看到便是这个机器码的汇编表现形式。

在《使用WinDbg获得托管方法的汇编代码》一文中我也曾经做过类似的试验,只是这次更简化了一些。在上一次的回复中,有朋友提问说“在ngen之后,是否便可以直接看到这些汇编代码,即使方法还没有被调用过”。我的说法是“可以,但是要观察到这一点并不如现在那样简单”。您能否亲自验证这一点呢?

示例三:泛型方法是为每个类型各生成一份代码吗?

IL和我们平时用的C#程序代码不一样,其中使用了各种指令,而不是像C#那样有类似于英语的关键字,甚至是语法。但是有一点是类似的,它的主要目的是表现程序逻辑,而他们表现得逻辑也大都是相同的,接近的。你创建对象那么我也创建,你调用方法那么我也调用。因此才可以有.NET Reflector帮我们把IL反编译为比IL更高级的C#代码。如果IL把太多细节都展开了,把太多信息都丢弃了,那么怎么可以如此容易就恢复呢?例如,您可以把一篇Word文章转化为图片,那么又如何才能把图片再转回为Word格式呢?C => 汇编、汇编 => C,此类例子数不胜数。

再举一个例子,例如您有以下的范型方法:

private static void GenericMethod<T>()
{
    Console.WriteLine(typeof(T));
}

static void Main(string[] args)
{
    GenericMethod<string>();
    GenericMethod<int>();
    GenericMethod<object>();
    GenericMethod<DateTime>();
    GenericMethod<Program>();
    GenericMethod<double>();

    Console.ReadLine();
}

有朋友认为,范型会造成多份代码拷贝。那么您是否知道,使用不同的范型类型去调用GenericMethod方法,会各生成一份机器码吗?我们先看一下IL吧:

.method private hidebysig static void GenericMethod<T>() cil managed
{
    .maxstack 8
    L_0000: ldtoken !!T
    L_0005: call class [mscorlib]System.Type 
        [mscorlib]System.Type::GetTypeFromHandle(valuetype [mscorlib]System.RuntimeTypeHandle)
    L_000a: call void [mscorlib]System.Console::WriteLine(object)
    L_000f: ret 
}

.method private hidebysig static void Main(string[] args) cil managed
{
    .entrypoint
    .maxstack 8
    L_0000: call void TestConsole.Program::GenericMethod<string>()
    L_0005: call void TestConsole.Program::GenericMethod<int32>()
    L_000a: call void TestConsole.Program::GenericMethod<object>()
    L_000f: call void TestConsole.Program::GenericMethod<valuetype [mscorlib]System.DateTime>()
    L_0014: call void TestConsole.Program::GenericMethod<class TestConsole.Program>()
    L_0019: call void TestConsole.Program::GenericMethod<float64>()
    L_001e: ret 
}

这……怎么和我们的C#代码如此接近。嗯,谁让IL清清楚楚明明白白地知道什么叫做“泛型”,于是直接使用这个特性就可以了。所以我们还是用别的办法吧。

其实要了解CLR是否为每个不同类型生成了一份新的机器码,只要看看汇编中是否每次都call到同一个地址中去便可以了。用相同的方法可以看到Main方法的汇编代码如下:

0:003> !u 00a70070
Normal JIT generated code
....Main(System.String[])
Begin 00a70070, size 44
>>> 00a70070 55              push    ebp
00a70071  mov     ebp,esp
// 准备GenericMethod<string>
00a70073  mov     ecx,3A30C4h (MD: ....GenericMethod[[System.String, mscorlib]]())
// 引用类型实际都共享一个GenericMethod<System.__Canon>方法的代码
00a70078  call    dword ptr ds:[3A3098h] (....GenericMethod[[System.__Canon, mscorlib]](), ...)
// 调用GenericMethod<int>
00a7007e  call    dword ptr ds:[3A3108h] (....GenericMethod[[System.Int32, mscorlib]](), ...)
// 准备GenericMethod<object>
00a70084  mov     ecx,3A3134h (MD: ....GenericMethod[[System.Object, mscorlib]]())
// 引用类型实际都共享一个GenericMethod<System.__Canon>方法的代码
00a70089  call    dword ptr ds:[3A3098h] (....GenericMethod[[System.__Canon, mscorlib]](), ...)
// 调用GenericMethod<DateTime>
00a7008f  call    dword ptr ds:[3A3178h] (....GenericMethod[[System.DateTime, mscorlib]](), ...)
// 准备GenericMethod<object>
00a70095  mov     ecx,3A31A4h (MD: ....GenericMethod[[TestConsole.Program, TestConsole]]())
// 引用类型实际都共享一个GenericMethod<System.__Canon>方法的代码
00a7009a  call    dword ptr ds:[3A3098h] (....GenericMethod[[System.__Canon, mscorlib]](), ...)
// 调用GenericMethod<double>
00a700a0  call    dword ptr ds:[3A31E8h] (....GenericMethod[[System.Double, mscorlib]](), ...)
*** WARNING: Unable to verify checksum for C:\...\mscorlib.ni.dll
// 调用Console.ReadLine()
00a700a6  call    mscorlib_ni+0x6d1c24 (71631c24) (System.Console.get_In(), mdToken: 06000771)
00a700ab  mov     ecx,eax
00a700ad  mov     eax,dword ptr [ecx]
00a700af  call    dword ptr [eax+64h]
00a700b2  pop     ebp
00a700b3  ret

从这里我们可以看到,CLR为引用类型(string/object/Program)生成共享的机器码,它们都实际上在调用一个GenericMethod<System.__Canon>所生成的代码。而对于每个不同的值类型(int/DateTime/double),CLR则会为每种类型各生成一份。自然,您有充分的理由说:“调用的目标地址不一样,但是可能机器码是相同的”。此外,CLR的“泛型共享机器码”特性也并非如此简单,如果有多个泛型参数(且引用和值类型“混搭”)呢?如果虽然有泛型参数,但是确没有使用呢?关于这些,您可以自行进行验证。本文的目的在于说明一些问题,并非是要把这一细节给深究到底。

总结

以上三个示例都是用IL无法说明的,而这样的问题其实还有很多,例如:

  • 引用类型和值类型是怎么分配的
  • GC是怎么分代,怎么工作的
  • Finalizer做什么的,对GC有什么影响
  • 拆箱装箱到底做了些什么
  • CLR是怎么验证强签名程序集的
  • 跨AppDomain通信是怎么Marshal by ref或by value的
  • 托管代码是怎么做P/Invoke的
  • ……

您会发现,这些东西虽然无法用IL说明,却其中大部分可以说是最最基本的一些.NET/CLR工作方式的常识,更别说一些细节(数组存放方式,方法表结构)了。它们依旧需要别人来告诉您,您就算学会了IL指令,学会了IL表现逻辑的方式,您还是无法自己知道这些。

IL还是太高级了,太高级了,太高级了……CLR作为承载IL的平台,负担的还是太多。与CPU相比,CLR就像一个溺爱孩子的父母,操办了孩子生活所需要的一切。这个孩子一嚷嚷“我要吃苹果”,则父母就会拿过来一个苹果。您咋看这个孩子,都还是无法了解父母是如何获得苹果的(new一个Apple对象),怎么为孩子收拾残局的(GC)。虽然这些经常是所谓的“成年人(.NET 程序员)必知必会”。而您如果盯着孩子看了半天,耐心分析他吃苹果的过程(使用IL编写的逻辑),最后终于看懂了,可惜发现——tmd老子自己也会吃苹果啊(从C#等高级语言中也能看出端倪来)!不过这一点,还是由下一篇文章来分析和论证吧。

这也是为什么各种.NET相关的书,即使是《CLR via C#》或《Essential .NET》此类偏重“内幕”的书,也只是告诉您什么是IL,它能做什么。然后大量的篇幅都是在使用各种示意图配合高级语言进行讲解,然后通过试验来进行验证,不会盯着IL捉摸不停。同理,我们可以看到《CLR via C#》,《CLR via VB.NET》和《CLR via CLI/C++》,但从来没有过《CLR via IL》。IL还是对应于高级语言,直接对应着.NET特性,而不是CLR的内部实现——既然IL无法说明比高级语言更多的东西,那么为什么要“via IL”?同样的例子还有,MSDN Magazine的CLR Inside Out专栏也没有使用IL来讲解内容,Mono甚至使用了与MS CLR不同实现方式来“编译”相同的IL(Mono是不能参考任何CLR和.NET的代码的,一行都看不得)。你要了解CLR?那么多看看Rotor,多看看Mono——看IL作用不大,它既不是您熟悉CLR的必要条件也不是充分条件,因为您关注的不是对IL的读取,甚至不是IL到机器码的转换方式,而是CLR各处所使用的方案。

最后,我还是想再补充的一句:本文全篇在使用WinDbg进行探索,这并非要以了解IL作为基础,您完全可以不去关心IL那些缤纷复杂的指令的作用是什么。甚至于您完全忽略IL的存在,极端地“认为”是C#直接编译出的机器码,也不妨碍您来使用本文的做法来一探究竟——细节上会有不同,但是看到的东西是一样的。

不过这并不意味着,您不需要了解一些额外的东西。就我看来,您需要具备哪些条件呢?

  • 学习计算机组成原理,计算机体系结构等基础课程的内容,至少是这些课程中的基础。
  • 以事实为基准,而不是“认为是,应该是”的办事方式。
  • 严谨的态度,缜密的逻辑,大胆的推测。
  • ……

“大胆的推测”和“认为是,应该是”并非一个意思。大胆的推测是根据已知现象,运用逻辑进行判断,从而前进,而最终这些推测要通过事实进行确定。正所谓“大胆推测,小心求证”。

以上这些是您“自行进行探索”所需要的条件,而如果您只是要“看懂”某个探索过程的话,就要看“描述”者的表达情况了。一般来说,看懂一个探索过程的要求会低很多,相信只要您有耐心,并且有一些基本概念(与这些条件有关,与IL无关),想要看懂我的探索过程,以及吸收最后的结论应该不是一件困难的事情。

相关文章