配色: 字号:
IL详解
2013-09-17 | 阅:  转:  |  分享 
  
谈IL(1):IL是什么,它又不是什么?那么汇编呢?

我们.NET开发人员必定离不开IL,就算您没有学习,也一定可以在各处看到它的身影。最近在博客园上活跃的IL文章译者包建强同学的一些看法让我大为震惊,决定独立开篇,希望可以让大家看到不同的声音。真理越辩越明,也欢迎大家来一起讨论,发表自己意见。我也会尽量把朋友们留在我博客上的看法汇总起来,并加以回应。

《我谈IL》也是系列文章,目前的计划有4篇,依次是:

IL是什么,它又不是什么?那么汇编呢?。

CLR内部有太多太多IL看不到的东西,包括您平时必须了解的那些(示例)。

IL可以看到的东西,基本上都可以使用C#等高级语言来发现(示例)。

什么时候应该学IL,该怎么学IL。

您现在看到的便是本系列的第1篇:IL是什么,它又不是什么?那么汇编呢?。

我曾经在博客《浅谈尾递归的优化方式》和《使用WinDbg获得托管方法的汇编代码》涉及到了一些x86汇编,包同学在文章后留言,认为通过UltraEdit32此类编辑器观察到x86汇编:

我第三次仔细看了一遍最后这段代码,这段代码应该是从UltraEdit32中看到的,所以里面很多word不是IL中的关键字……

……就像我昨天提到用UltraEdit32这个工具来解读汇编代码,道理是一样的……

同样的事情发生在我最近写的一篇文章《从汇编入手,探究泛型的性能问题》以及包同学自己的文章后面的回复中:

如果不熟悉IL,又怎么能自己动手分析性能呢?你这篇文章不就证明了学习一点IL的重要性了么?

其实IL就是一门汇编语言,很奇怪有人一边在用ILAssembler分析性能,一边又在讲不要学习IL的话……

包同学作为微软MVP,是许多IL文章的译者,还有一本译作《Expert.NET2.0ILAssembler》即将出版,本应是这方面的专家。但是我现在非常担心这位专家的话会给许多学习.NET的朋友们一些较为严重的误导。从包同学之前的话来看,他对于IL和汇编的概念,从各自的作用到获取方式几乎完全混淆起来。因此冲动的我每次看到这样的言论都忍不住跳出来批驳一番,而这次更决定独立成文进行详细说明。

IL是微软.NET平台上衍生出来的一门中间语言,.NET平台上的各种高级语言(如C#,VB,F#)的编译器会将各自的文字表述方式转化为IL。各种不同的文字形式最终被统一到了IL的表述方式,其中包含了.NET平台上的各种元素,如“范型”,“类”、、“接口”、“模块”、“属性”等等。值得注意的是,各种高级语言本身可能根本没有这些“概念”在里头,如IronScheme是一个在.NET平台上的Scheme语言实现,其中根本没有前面提到的这些IL——亦或说是.NET平台上的名词。IL本身并不知道自己是由哪种高级语言转化而来的,哪种语言中有哪些特性,IL也根本不会关心。

谁来关心这些呢?自然是各语言的编译器了。这就是.NET平台上的高级语言的第一次转化:高级语言=>IL。

而我们平时说的“汇编”则要简单得多,这个简单并不代表“容易掌握,方便使用”,这个“简单”是指它的“定义”。汇编是让CPU直接使用的“语言”,请注意“直接”二字:一条汇编指令便是让CPU作一件事情(如寄存器的复制,从内存中读取数据等等),毫无二义。不同族CPU拥有不同的指令集,但是它们都有一样的特征:指令的数量相对较少,每个指令功能都简单之至。这也是为什么现在几乎没有人直接使用汇编写程序的原因,试想一下给您红、绿、蓝三原色,让您绘制一幅色彩绚丽的图画有多么困难。

由于CPU只认识汇编代码,因此就算是IL也需要再次进行转化,才能被CPU执行。这次转化便由“JITCompiler”完成。CLR加载了IL之后,当每个方法——请注意这是IL中的概念——第一次被执行时,就会使用JIT将IL代码进行编译为机器码——对了,刚才我忘了提,机器码和汇编其实也是一一对应的,您可以这样理解:汇编是机器码的文字表现形式,提供了一些方便人们记忆的“助记符”。与IL不同的是,CLR,JIT都是真正了解CPU的,对于同样的IL,JIT会把它为不同的CPU架构(如x86/IA64等等)生成不同的机器码。这也是Java/.NET中“CompileOnce,RunEverywhere”这一口号的技术基础:它们为不同的CPU架构提供了不同的“IL转化器”,仅此而已。与高级语言到IL的转化类似,CPU也完全不知道自己在执行的指令是从哪里来的,可能是JIT从IL转化而来,可能是JVM从JavaBytecode转化而来,也有可能是C语言编译得来,也有可能是由MIT/GNUScheme解释而来。

这就是.NET平台上的高级语言在机器上运行的第二次转化:IL=>汇编(机器码)。

因此,IL和汇编的区别是显著的。IL拥有各种高级特性,它知道什么是范型,什么是类和方法(以及它们的“名称”),什么是继承,什么是字符串,布尔值,什么是User对象。而CPU只知道寄存器,地址,内存,01010101。与汇编相比,IL简直太高级了,几乎完全是一个高级语言,比C语言还要高级。因此,您会看到.NETReflector几乎可以把IL代码“一五一十”地反编译为可读性良好的C#代码,包括类,属性,方法等等;而从汇编只能勉勉强强地反编译为C语言——而且其中的“方法名”等信息已经完全不可恢复了,更别说“模块”等高级抽象的内容。您想要把汇编反编译成C#代码?相信在将来这是可行的,不过现在这还是天方夜谭。

那么我们再来看看包同学的观点,例如首先他认为“用UltraEdit32这个工具来解读汇编代码”。如果您理解了我之前的解释,应该可以意识到这完全是一种谬论。IL是一种高度抽象,在运行之前,还需要由JIT转化为机器码才行。同样的IL代码,可以由不同CPU架构下的JIT编译成不同的机器码(同样的IL代码在同样的机器上是否也生成同样的机器码呢?答案是否定的,例如“泛型”……下一篇文章中我们会对此进行观察)。甚至于,CLR在运行了一段时间之后,可以让JIT重新生成一段更适合当前环境,性能更高的机器码供CPU执行。从这个角度上说,IL是静态的,而汇编是动态的。设法使用一个静态查看工具UltraEdit32来阅读一个动态的,不确定的内容,这又该如何实现呢?

不过真要说起来,使用UltraEdit32从理论上的确可以阅读一个编译后的IL代码,因为此时IL已经以二进制的形式存储在程序集文件中。例如ILDisassembler(ildasm.exe)和.NETReflector便是通过读取程序集文件的数据(而不是在将程序集加载到CLR中)来获得IL代码,微软也发布了CommonCompilerInfrastructure:Metadata和CCI:CodeandAST两个和.NET基础结构有关的开源项目。而近在“博客园”中,也有AndersLiu大牛写过一个CliPeViewer,从程序集的物理结构中读取其元数据,再进一步便可获取到IL代码了。

虽然已经有了多次请求,但是包同学还是没有公布他使用UltraEdit32获取汇编代码的做法。窃以为,是因为“这个真无法实现”的原因吧。

至于包同学的另一个看法是,我使用ILAssembler(他应该是指ILDisassembler)查看汇编代码所犯的错误同样是混淆了IL和汇编两种截然不同的东西。那些都是我在程序运行之后,使用WinDbg观察汇编的结果,也就是说,是JIT将IL进行再次编译(第一次是指高级语言编译器生成IL)的结果。由于JIT每次进行处理的最小单元是“方法”,因此如果一个.NET方法还没有执行过,则是无法获取它的汇编代码的。我在《使用WinDbg获得托管方法的汇编代码》一文中清楚地演示了目标方法在BeforeJIT和AfterJIT两个不同情况下,由WinDbg观察到的结果。

由于IL还是过于高级,因此很多真真切切的东西并无法看到,因此我也不得不用汇编从根本上证实了泛型不会降低性能——严格来说,由于IL在不同平台上生成的汇编不同,我其实也只是证实了在x86平台上的这个结论。请您回想一下,您看过的.NET书籍中有多少是使用IL来说明问题的呢?为了心中踏实,我刚才又去翻了翻《Essential.NET》、《CLRviaC#》这《Customizingthe.NETCommonLanguageRuntime》这几本“偏底层”的书,证实了这一观点。其实理由很简单,一是大师能够用朴实的语言,或更易理解的高级代码把问题明明白白彻彻底底地讲清楚,二便是因为IL可以说明的东西实在有限。例如,泛型在运行期间是否生成了新类型?CLR对待引用类型和值类型的泛型时是否一样?

在包同学之前的文章中,横道天笑也回复了类似的看法:

……我觉得IL的作用就仅此而已,对于其他,甚至是虚方法调用等等从IL上都看不出来什么。因为你真的不知道callvir指令内部到底干了啥,更别说性能了,因为IL之后还有个JIT,JIT之后才得到的是我们传统上的汇编,汇编语言与机器指令是一一对应的,所以从汇编语言上才能发现一切的一切,比如多态靠的是对象地址加偏移,比如我的那篇汇编讨论性能的文章。

这篇文章的目的是“讲道理”,在接下来了两篇文章中,我还会通过示例,也就是“摆事实”来解释为什么说“CLR内部有太多太多IL看不到的东西,包括平时您必须了解的那些”,以及“IL可以看到的东西,基本上都可以使用C#等高级语言来发现”。

最后,为了避免给大家带来误导,我还是希望多补充几句。我的这几篇文章似乎是在“呼吁”大家不要学习IL,其实不然。我是希望在澄清事实的的基础上,探究出一些更有实践价值结论。例如学习IL和汇编有什么好处,什么是对IL和汇编的滥用,以及究竟该如何可以更快速方便地解决某些问题等等——例如我使用汇编来说明泛型的性能问题就是一种滥用,最好的方法应该是“编写试验代码”。

因此,本系列文章的最后一篇想讨论的话题便是“什么时候应该学IL,该怎么学IL”。在这里,我也请大家留下您的观点,真理便是在各种观点中总结出来的,不是吗?

?

看了大家的评论,我觉得可能还需要把汇编和机器码的关系进行一些补充。

有朋友提出,汇编不是和机器码一一对应的,因为还有宏汇编,高级汇编等等。其实高级汇编其实已经是一种“语言”了,它最终还是被转换成机器码执行。我说的汇编是指汇编“指令”,由机器码简单一一替换成助记符给人看。

还有某位朋友指出的一点,如果JIT生成的是汇编,那么肯定无法给机器直接执行。文章里可能没有说清楚,我的意思是,JIT生成机器码,而汇编是机器码进行简单替换后,方便给人看的结果。阅读汇编代码,就完全了解了CPU是如何一步一步进行执行任务的,没有丝毫偏差。这也是我认为观察汇编就是阅读机器码的原因。

Font:ComicSansMS



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

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

IL能够说明的内容太少,包括大部分.NET“必知必会”。

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

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

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

namespaceTestConsole

{

publicclassMyArrayList

{

publicMyArrayList(intlength)

{

this.m_items=newobject[length];

}



privateobject[]m_items;



publicobjectthis[intindex]

{

[MethodImpl(MethodImplOptions.NoInlining)]

get

{

returnthis.m_items[index];

}

[MethodImpl(MethodImplOptions.NoInlining)]

set

{

this.m_items[index]=value;

}

}

}



publicclassMyList

{

publicMyList(intlength)

{

this.m_items=newT[length];

}



privateT[]m_items;



publicTthis[intindex]

{

[MethodImpl(MethodImplOptions.NoInlining)]

get

{

returnthis.m_items[index];

}

[MethodImpl(MethodImplOptions.NoInlining)]

set

{

this.m_items[index]=value;

}

}

}



classProgram

{

staticvoidMain(string[]args)

{

MyArrayListarrayList=newMyArrayList(1);

arrayList[0]=arrayList[0]??newobject();



MyListlist=newMyList(1);

list[0]=list[0]??newobject();



Console.WriteLine("Herecomesthetestingcode.");



vara=arrayList[0];

varb=list[0];



Console.ReadLine();

}

}

}



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

//MyArrayList的get_Item方法

.methodpublichidebysigspecialnameinstanceobjectget_Item(int32index)cilmanagednoinlining

{

.maxstack8

L_0000:ldarg.0

L_0001:ldfldobject[]TestConsole.MyArrayList::m_items

L_0006:ldarg.1

L_0007:ldelem.ref

L_0008:ret

}



//MyList的get_Item方法

.methodpublichidebysigspecialnameinstance!Tget_Item(int32index)cilmanagednoinlining

{

.maxstack8

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.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#代码:

namespaceTestConsole

{

classProgram

{

[MethodImpl(MethodImplOptions.NoInlining)]

privatestaticvoidSomeMethod()

{

Console.WriteLine("HelloWorld!");

}



staticvoidMain(string[]args)

{

Console.WriteLine("BeforeJITed.");

Console.ReadLine();



SomeMethod();



Console.WriteLine("AfterJITed");

Console.ReadLine();

}

}

}



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

.methodprivatehidebysigstaticvoidMain(string[]args)cilmanaged

{

.entrypoint

.maxstack8

//分配字符串"BeforeJITed"

L_0000:ldstr"BeforeJITed."

//调用Console.WriteLine方法

L_0005:callvoid[mscorlib]System.Console::WriteLine(string)

//调用Console.ReadLine方法

L_000a:callstring[mscorlib]System.Console::ReadLine()

L_000f:pop

//调用Program.SomeMethod方法

L_0010:callvoidTestConsole.Program::SomeMethod()

//分配字符串"AfterJITed"

L_0015:ldstr"AfterJITed"

//调用Console.WriteLine方法

L_001a:callvoid[mscorlib]System.Console::WriteLine(string)

//调用Console.ReadLine方法

L_001f:callstring[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或tailcall一个方法——是为尾递归,但也只是及其特殊的情况)。可以发现IL的功能大部分就是C#可以表现的功能。而C#隐藏掉的一些细节,在IL这里同样没有显示出来!

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

这段程序会打印三行文字,在打印出BeforeJITed和AfterJITed字样之后都会有一次停止,需要用户按回车之后才能继续。在进行试验的时候,您可以在程序暂停的时候使用WinDbg的File-AttachtoProcess命令附加到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-md00173010

EEClass:001712d0

Module:00172c5c

Name:TestConsole.Program

mdToken:02000002(...\bin\Release\TestConsole.exe)

BaseSize:0xc

ComponentSize:0x0

NumberofIFacesinIFaceMap:0

SlotsinVTable:7

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

MethodDescTable

EntryMethodDescJITName

71126ab070fa4944PreJITSystem.Object.ToString()

71126ad070fa494cPreJITSystem.Object.Equals(System.Object)

71126b4070fa497cPreJITSystem.Object.GetHashCode()

7119754070fa49a0PreJITSystem.Object.Finalize()

0017c01900173008NONETestConsole.Program..ctor()

0017c01100172ff0NONETestConsole.Program.SomeMethod()

003e007000172ffcJITTestConsole.Program.Main(System.String[])

0:000>!u003e0070

NormalJITgeneratedcode

TestConsole.Program.Main(System.String[])

Begin003e0070,size4d

>>>003e007055pushebp

003e00718becmovebp,esp

WARNING:Unabletoverifychecksumformscorlib.ni.dll

003e0073e8a8d3da70callmscorlib_ni+0x22d420(7118d420)(System.Console.get_Out(),...)

003e00788bc8movecx,eax

003e007a8b153020d102movedx,dwordptrds:[2D12030h]("BeforeJITed.")

003e00808b01moveax,dwordptr[ecx]

003e0082ff90d8000000calldwordptr[eax+0D8h]

003e0088e8971b2571callmscorlib_ni+0x6d1c24(71631c24)(System.Console.get_In(),...)

003e008d8bc8movecx,eax

003e008f8b01moveax,dwordptr[ecx]

003e0091ff5064calldwordptr[eax+64h]

003e0094ff15f82f1700calldwordptrds:[172FF8h](TestConsole.Program.SomeMethod(),...)

003e009ae881d3da70callmscorlib_ni+0x22d420(7118d420)(System.Console.get_Out(),...)

003e009f8bc8movecx,eax

003e00a18b153420d102movedx,dwordptrds:[2D12034h]("AfterJITed")

003e00a78b01moveax,dwordptr[ecx]

003e00a9ff90d8000000calldwordptr[eax+0D8h]

003e00afe8701b2571callmscorlib_ni+0x6d1c24(71631c24)(System.Console.get_In(),...)

003e00b48bc8movecx,eax

003e00b68b01moveax,dwordptr[ecx]

003e00b8ff5064calldwordptr[eax+64h]

003e00bb5dpopebp

003e00bcc3ret

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

先从读取172FF8地址中的值,这才是方法调用的目标地址(即SomeMethod方法)。

使用call指令调用刚才读取到的目标地址

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

0:000>dd172FF8

00172ff80017c0117103000200200006003e0070

001730080006000300000004000000000000000c

001730180005001100000004711d077000172c5c

001730280017304c001712d00000000000000000

0017303871126ab071126ad071126b4071197540

001730480017c019000000800000000000000000

0017305800000000000000000000000000000000

0017306800000000000000000000000000000000

0:000>!u0017c011

Unmanagedcode

0017c011b000moval,0

0017c013eb08jmp0017c01d

0017c015b003moval,3

0017c017eb04jmp0017c01d

0017c019b006moval,6

0017c01beb00jmp0017c01d

0017c01d0fb6c0movzxeax,al

0017c020c1e002shleax,2

0017c02305f02f1700addeax,172FF0h

0017c028e9d7478c00jmp00a40804

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

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

0:000>dd172FF8

00172ff8003e00d07103000200200006003e0070

001730080006000300000004000000000000000c

001730180005001100000004711d077000172c5c

001730280017304c001712d00000000000000000

0017303871126ab071126ad071126b4071197540

001730480017c019000000800000000000000000

0017305800000000000000000000000000000000

0017306800000000000000000000000000000000

0:000>!u003e00d0

NormalJITgeneratedcode

TestConsole.Program.SomeMethod()

Begin003e00d0,size1a

>>>003e00d055pushebp

003e00d18becmovebp,esp

WARNING:Unabletoverifychecksumformscorlib.ni.dll

003e00d3e848d3da70callmscorlib_ni+0x22d420(7118d420)(System.Console.get_Out(),mdToken:06000772)

003e00d88bc8movecx,eax

003e00da8b153820d102movedx,dwordptrds:[2D12038h]("HelloWorld!")

003e00e08b01moveax,dwordptr[ecx]

003e00e2ff90d8000000calldwordptr[eax+0D8h]

003e00e85dpopebp

003e00e9c3ret

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

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

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

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

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

privatestaticvoidGenericMethod()

{

Console.WriteLine(typeof(T));

}



staticvoidMain(string[]args)

{

GenericMethod();

GenericMethod();

GenericMethod();

GenericMethod();

GenericMethod();

GenericMethod();



Console.ReadLine();

}



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

.methodprivatehidebysigstaticvoidGenericMethod()cilmanaged

{

.maxstack8

L_0000:ldtoken!!T

L_0005:callclass[mscorlib]System.Type

[mscorlib]System.Type::GetTypeFromHandle(valuetype[mscorlib]System.RuntimeTypeHandle)

L_000a:callvoid[mscorlib]System.Console::WriteLine(object)

L_000f:ret

}



.methodprivatehidebysigstaticvoidMain(string[]args)cilmanaged

{

.entrypoint

.maxstack8

L_0000:callvoidTestConsole.Program::GenericMethod()

L_0005:callvoidTestConsole.Program::GenericMethod()

L_000a:callvoidTestConsole.Program::GenericMethod()

L_000f:callvoidTestConsole.Program::GenericMethod()

L_0014:callvoidTestConsole.Program::GenericMethod()

L_0019:callvoidTestConsole.Program::GenericMethod()

L_001e:ret

}

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

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

0:003>!u00a70070

NormalJITgeneratedcode

....Main(System.String[])

Begin00a70070,size44

>>>00a7007055pushebp

00a70071movebp,esp

//准备GenericMethod

00a70073movecx,3A30C4h(MD:....GenericMethod[[System.String,mscorlib]]())

//引用类型实际都共享一个GenericMethod方法的代码

00a70078calldwordptrds:[3A3098h](....GenericMethod[[System.__Canon,mscorlib]](),...)

//调用GenericMethod

00a7007ecalldwordptrds:[3A3108h](....GenericMethod[[System.Int32,mscorlib]](),...)

//准备GenericMethod

00a70084movecx,3A3134h(MD:....GenericMethod[[System.Object,mscorlib]]())

//引用类型实际都共享一个GenericMethod方法的代码

00a70089calldwordptrds:[3A3098h](....GenericMethod[[System.__Canon,mscorlib]](),...)

//调用GenericMethod

00a7008fcalldwordptrds:[3A3178h](....GenericMethod[[System.DateTime,mscorlib]](),...)

//准备GenericMethod

00a70095movecx,3A31A4h(MD:....GenericMethod[[TestConsole.Program,TestConsole]]())

//引用类型实际都共享一个GenericMethod方法的代码

00a7009acalldwordptrds:[3A3098h](....GenericMethod[[System.__Canon,mscorlib]](),...)

//调用GenericMethod

00a700a0calldwordptrds:[3A31E8h](....GenericMethod[[System.Double,mscorlib]](),...)

WARNING:UnabletoverifychecksumforC:\...\mscorlib.ni.dll

//调用Console.ReadLine()

00a700a6callmscorlib_ni+0x6d1c24(71631c24)(System.Console.get_In(),mdToken:06000771)

00a700abmovecx,eax

00a700admoveax,dwordptr[ecx]

00a700afcalldwordptr[eax+64h]

00a700b2popebp

00a700b3ret

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

总结

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

引用类型和值类型是怎么分配的

GC是怎么分代,怎么工作的

Finalizer做什么的,对GC有什么影响

拆箱装箱到底做了些什么

CLR是怎么验证强签名程序集的

跨AppDomain通信是怎么Marshalbyref或byvalue的

托管代码是怎么做P/Invoke的

……

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

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

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

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

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

学习计算机组成原理,计算机体系结构等基础课程的内容,至少是这些课程中的基础。

以事实为基准,而不是“认为是,应该是”的办事方式。

严谨的态度,缜密的逻辑,大胆的推测。

……

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

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



谈IL(3):IL可以看到的东西,其实大都也可以用C#来发现

在上一篇文章中,我们通过一些示例谈论了IL与CLR中的一些特性。IL与C#等高级语言的作用类似,主要用于表示程序的逻辑。由于它同样了解太多CLR中的高级特性,因此它在大部分情况下依旧无法展现出比那些高级语言更多的CLR细节。因此,如果您想要通过学习IL来了解CLR,那么这个过程很可能会“事倍功半”。因此,从这个角度来说,我并不倾向于学习IL。不过严格说来,即使IL无法看出CLR的细节,也不足以说明“IL无用”——这里说“无用”自然有些夸张。但是,如果我们还发现,那些原本被认为需要通过IL挖掘到的东西,现在都可以使用更好的方法来获得,并且可以起到“事半功倍”的效果,那么似乎我们真的没有太多理由去追逐IL了。

在这篇文章中,我们使用最多的工具便是.NETReflector,从.NET1.x开始,.NETReflector就是一个探究.NET框架(主要是BCL)内部实现的有力工具,它可以把一个程序集高度还原成C#等高级语言的代码。在它的帮助下,几乎所有程序集实现都变得一目了然,这大大方便了我们的工作。我对此深有感触,因为在某段不算短的时间内,我使用.NETReflector阅读过的代码数量远远超过了自己编写的代码。与此相反的是,我几乎没有使用IL探索过.NET框架下的任何问题。这可能还涉及到方式方法和个人做事方式,但是如果这真有效果的话,为什么要舍近求远呢?希望您看过了这篇文章,也可以像我一样摆脱IL,投入.NETReflector的怀抱。

示例一:探究语言细节

C#语言从1.0到3.0版本的进化过程中,大部分新特性都是依靠编译器的魔法。就拿C#3.0的各种新特性来说,Lambda表达式,LINQ,自动属性等等,完全都是基于CLR2.0中已有的功能,再配合新的C#编译器而产生的各种神奇效果。有些朋友认为,掌握IL之后便把握了.NET的根本,以不变应万变,只要读懂IL,那么这些新特性都不会对您形成困扰。这话说的并没有错,只是我认为,“掌握IL”在这里只是一个“充分条件”而不是一个“必要条件”,我们完全可以使用.NETReflector将程序集反编译成C#代码来观察这些。

这里我们使用.NETReflector来观察最最常见,最最普通的foreach关键字的功能。我们都知道foreach是遍历一个IEnumerble对象内元素的方式,我们也都知道foreach其实是GoFIterator模式的实现,通过MoveNext方法和Current属性进行配合共同完成。不过大部分朋友似乎都是从IL进行观察,或是“听别人说”而了解这些的。事实上,.NETReflector也可以很容易地证实这一点,只是这中间还有些“特别”的地方。那么首先,我们还是来准备一个最简单的foreach语句:

staticvoidDoEnumerable(IEnumerablesource)

{

foreach(intiinsource)

{

Console.WriteLine(i);

}

}



如果观察它的IL代码,即使不了解IL的朋友也一定可以看出,其中涉及到了GetEnumerator,MoveNext和Current等成员的访问:

.methodprivatehidebysigstaticvoidDoEnumerable(

class[mscorlib]System.Collections.Generic.IEnumerable`1source)cilmanaged

{

.maxstack1

.localsinit(

[0]int32i,

[1]class[mscorlib]System.Collections.Generic.IEnumerator`1CS$5$0000)

L_0000:ldarg.0

L_0001:callvirtinstanceclass[mscorlib]System.Collections.Generic.IEnumerator`1

[mscorlib]System.Collections.Generic.IEnumerable`1::GetEnumerator()

L_0006:stloc.1

L_0007:br.sL_0016

L_0009:ldloc.1

L_000a:callvirtinstance!0[mscorlib]System.Collections.Generic.IEnumerator`1::get_Current()

L_000f:stloc.0

L_0010:ldloc.0

L_0011:callvoid[mscorlib]System.Console::WriteLine(int32)

L_0016:ldloc.1

L_0017:callvirtinstancebool[mscorlib]System.Collections.IEnumerator::MoveNext()

L_001c:brtrue.sL_0009

L_001e:leave.sL_002a

L_0020:ldloc.1

L_0021:brfalse.sL_0029

L_0023:ldloc.1

L_0024:callvirtinstancevoid[mscorlib]System.IDisposable::Dispose()

L_0029:endfinally

L_002a:ret

.tryL_0007toL_0020finallyhandlerL_0020toL_002a

}

但是,如果使用.NETReflector观察它的C#代码又会如何呢?

privatestaticvoidDoEnumerable(IEnumerablesource)

{

foreach(intiinsource)

{

Console.WriteLine(i);

}

}

请注意,以上这段是由.NETReflector从IL反编译后得到的C#代码,这简直……不是简直,是完完全全真真正正地和我们刚才写的代码一模一样!这就是.NETReflector的强大之处,由于它意识到IL调用了IEnumerable.GetEnumerator方法,因此它就“留心”判断这个调用的“模式”是否符合一个标准的foreach,如果是,那么就会显示为一个foreach语句而不是while...MoveNext。不过,这难道不就掩盖了“事物本质”了吗?要知道我们的目标可是探究foreach的形式,既然.NETReflector帮不了的话,我们不还是需要去观察IL吗?

刚才我提到,.NETReflector在判断IL代码时发现一些标准的模式时会进行代码“优化”。那么我们能否让.NETReflector不要做这种“优化”呢?答案是肯定的,只是需要您在.NETReflector中进行一些简单的设置:



打开View菜单中的Options对话框,在左侧Disassembler选项卡中修改Optimization级别,默认很可能是.NET3.5,而现在我们要将其修改为None。这么做会让.NETReflector最大程度地“直接”翻译IL代码,而不做一些额外优化。将Optimization级别设为None以后,DoEnumerable方法的代码就变为了:

staticvoidDoEnumerable(IEnumerablesource)

{

intnum;

IEnumeratorenumerator;

enumerator=source.GetEnumerator();

Label_0007:

try

{

gotoLabel_0016;

Label_0009:

num=enumerator.Current;

Console.WriteLine(num);

Label_0016:

if(enumerator.MoveNext()!=null)

{

gotoLabel_0009;

}

gotoLabel_002A;

}

finally

{

Label_0020:

if(enumerator==null)

{

gotoLabel_0029;

}

enumerator.Dispose();

Label_0029:;

}

Label_002A:

return;

}



这是C#代码吗?为什么会有那么多的goto?为什么MoveNext方法返回的布尔值可以和null进行比较?其实您把这段代码复制粘贴后会发现,它能够正常编译通过,效果也和刚才的foreach语句完全一样。这就是去除“优化”的效果。我在上一篇文章中谈到说:IL和C#一样,都是用于表现程序逻辑。C#使用if...else、while、for等等丰富语法,而在IL中就会变成判断+跳转语句。上面的C#代码便直接保留了IL的这个“特性”。不过还好,我们还是可以看出try...finally,可以看出MoveNext方法和Current属性的访问,可以看到程序使用Console.WriteLine输出数据。至此,我们便发现了foreach语句的真面目。从现在开始,在您准备深入IL之前,我也建议您可以尝试一下使用NoneOptimization来观察C#代码。

实事求是地说,上面的C#代码的“转向逻辑”并不那么清晰,因此您在理解的时候可以把它复制到编辑器中,进行一些简单调整。但是从我的经验上来看,需要使用NoneOptimization进行探索的地方非常少见。foreach是一个,还有便是C#中的其他一些“别名”,如使用using关键字管理IDisposable对象,以及lock关键字。而且,其实这段逻辑也只是没有优化IL中的跳转语句而已,已经比IL本身要直观许多了。此外,关于对象创建,变量声明,方法调用,属性访问,事件加载……一切的一切都还是最常用的C#代码。因为还是那个原因:从大部分情况上来看,IL也只是表现了程序逻辑,并没有比C#等语言体现出更多的细节。

我在这里举了一个较为极端的例子,因为我发现不少朋友并没有尝试过使用NoneOptimization来观察过代码。这里也可以看出,.NETReflector的“优化级别”还不够“细致”。不过这应该是一个“产品设计”的正常结果,因为foreach/using/lock的关键字都是从.NET1.0诞生伊始就存在的,也就是说,即使.NETReflector选择将IL编译为C#1.0,它的表现形式依旧是“标准模式”,这方面可能就不能过于强求了吧。至于其他一些探索,例如C#中的自动属性,Lambda表达式构建表达式树或匿名委托,乃至C#4.0中的dynamic关键字,都是使用.NET3.5Optimization进行探索便可得知的结果。您可以回忆一下自己看过的文章,其中有多少是使用IL解释问题的呢?

示例二:学习.NET平台上的其他语言

在.NET平台上,任何语言都会先编译为IL,然后再运行时由JIT转化为机器码。因此有种说法是,只要把握了IL,.NET平台上各种语言之间的迁移都会变得容易。对此我有不同看法。在以前讨论语言是否重要的时候,我提到,语言它并不仅仅是一种文字表现形式,而是一种“思维方式”的改变,这可能会影响到您程序的编码风格,API设计乃至架构(这个链接可能打不开,因为……)。实际上,如果您只是在C#与VB.NET之间进行迁移,原本就是一件相当容易的事情,因为它们之间“语言”的各种概念和特性都非常接近。而一种改变您思维的语言,才是真正有价值,而且值得进行比较和探索的。如果一味地追求“把握本源”,那么甚至还有比IL更低抽象的事务,但这些就已经违背了“创造一门语言”,以及您学习它的目的了,不是吗?

当然,探索也是需要的,尤其是.NET平台上的各种语言,他们被统一在同样的平台上,这本身就是一种很好的资源。这种资源就是所谓的“比较学习”。您可以把新的语言和您熟悉的语言进行对比,吸收其中的长处(如优秀的思维方式),这样便可以更好地使用旧有语言。例如,您把F#类库转化为C#代码进行观察之后,发现其中大量函数式编程风格的API是使用“委托”来实现的,您可能就会想到是否可以设计出函数式编程风格的C#API,是否可以把F#中List或Seq模块中的各种高阶函数移植到您自己的项目中来。这就有了更好的价值,这价值也不仅仅只是您“学会了新的语言”。

例如,我们现在使用尾递归来计算斐波那契数列。在之前的文章中,我们的作法是:

privatestaticintFibTail(intn,intacc1,intacc2)

{

if(n==0)returnacc1;

returnFibTail(n-1,acc2,acc1+acc2);

}



publicstaticintFib(intn)

{

returnFibTail(n,0,1);

}



为了“尾递归”,我们必须定义一个私有的FibTail方法,接收三个参数。而对外的接口还是一个公有的Fib方法,它返回斐波那契数列第n项的结果。这个示例很简单,作法也没有任何问题。但是我有时候会觉得,我们为什么非要定义一个额外的“辅助方法”,然后在现有的方法里只是进行一个简单的转发?如果这个辅助方法会在其他地方得到调用也就罢了(我们遵守了DRY原则),但是现在却有点“平白无故”地在代码里增加了一个方法,这样在VS的ClassView或编辑器上方的下拉列表中也会多出一项。此外,为了表示两个方法的关系,您可能还会使用region把它们包裹起来……

不过在F#中,上面的尾递归就可以这样写:

letfibn=

letrecfibTailxacc1acc2=

matchxwith

|0->acc1;

|_->fibTail(x-1)acc2(acc1+acc2)

fibTailn01



在fib方法内部,我们可以重新定义一个fibTail方法,其中实现了尾递归。对于外部来说,只有fib方法是公开的,外界丝毫不知道fibTail方法的存在,这种定义内部函数的作法在F#中非常常见。而编译后,我们在.NETReflector中便可看到与之对应的C#实现:

publicstaticintfib(intn)

{

switch(n)

{

case0:

return0;

}

returnfibTail@7@7(n-1,1,1);

}



internalstaticintfibTail@7@7(intx,intacc1,intacc2)

{

...

}



在F#中没有internal的访问级别,您可以认为这里internal便是private。于是我们得知(可能您本身也猜得到):由于.NET本身并没有“嵌套方法”特性,因此在这里编译器会重新生成一个特殊的私有方法,并且在fib方法里进行调用。于是我们想到,这个“自动生成方法”的特性,在C#中也有体现啊。例如,IEnmuerable有一个扩展方法是Where,我们可以用Lambda表达式构造一个匿名委托作为参数……唔唔,这不就相当于把一个方法定义在另一个方法内部了吗?于是,我们修改一下之前C#的尾递归的实现:

publicstaticintFib(intn)

{

FuncfibTail=null;

fibTail=(x,acc1,acc2)=>

{

if(x==0)returnacc1;

returnfibTail(x-1,acc2,acc1+acc2);

};



returnfibTail(n,0,1);

}



如果没有F#的“提示”,可能我们只能想到list.Where(i=>i%2==0)这种形式的用法,我们平时不会在方法内部额外地“创建一个委托”,然后加以调用,而且还用到了“递归”——甚至还是“尾递归”(虽然C#编译器在这里没有进行优化,而且这里其实也只是个“伪递归”,因为fibTail其实是个可改变的“函数指针”)。不过,由于我们刚才通过C#来观察F#的编译结果,联想到它和我们以前观察到的C#中“某个特性”非常相似,再加上合理的尝试,最终同样得出了一个还算“令人满意”的使用方式。

这只是一个示例,我并不是说这种作法是所谓的“最佳实践”。任何办法一旦遭到滥用也肯定不会有好处,您要根据当前情况判断是否应该采取某种作法。刚才的演示只是为了说明,我们应该如何从其他语言中吸取优势思想,改进我们的编程工作。当然,您使用IL来探索新的语言也没有太大问题,C#能看到的东西用IL也可以看到。但是请您回想一下,即使您平时学习IL,您想过直接使用IL来写程序吗?您学习和探索新语言的目的,只是为了搞清楚它的IL表现形式吗?为什么您不使用简单易懂的C#,却要纠缠于IL中那些纷繁复杂的指令呢?

示例三:性能相关

学习IL对写出高性能的.NET程序有帮助吗?

记得以前在学习“计算机系统概论”课程时,有一个实验就是为几段C程序进行优化。当时的手段可谓无所不用其极,例如内联一个子过程以避免call指令的消耗,或把一段C代码使用汇编进行替换等等。从结果上看,它们都能对性能有“明显”的提高。不过,那些都是为了加深概念而进行的练习,并不是说在现代程序中应该使用这种方式进行优化。现在早已不是在“指令级别”进行性能优化的时期了,连操作系统内核也只是在一些对性能要求非常高的地方,如内存管理,线程调度中的细微方面使用汇编来编写,其余部分也都是用C语言来完成。这并不是仅仅是因为“可维护性”等考虑,也有部分原因是因为在目前编译技术的发展下,一些极端的做法已经很难产生有效的优化效果了(例如一般来说来,程序员写出的C代码的性能会优于他写的汇编代码)。

此外,在您不知道JIT究竟作了什么事情的情况下,观察IL这样一种高度抽象的语言,您还是无法真正判断出一个程序从微观上的性能如何。不过这并不是说,现代程序不应该“主动”追究性能,而是说,现代程序在性能优化问题上并非如此简单,它涉及到的东西会更多,需要更加合适的手段。例如,即使您内联了一个子过程,也只是减少了call指令的所带来的消耗,但是这与这个子过程本身“一长串”指令相比,所带来的提高是微乎其微的。而如果您一旦破坏了Locality或造成了FalseSharing,或造成了资源竞争等等,这可能就会造成数倍甚至更多的性能损耗。换句话说,影响现代应用程序的性能的因素大都是“宏观”的,用通俗的话来说,一般都是“写法”上造成的问题。

这也是为什么说“Makecleancodefast”远比“Makefastcodeclean”来的容易,现代程序更注重的是“清晰”而并非是“性能”。因为程序清晰,更容易让人发现性能瓶颈究竟在何处,可以进行有针对性地优化(即使是那种在极端性能要求下故意进行的“丑陋”写法,也是为了高性能而“丑陋”,而不是因为“丑陋”而高性能,分清这一点很重要)。换句话说,如果我们有一种更清晰地方式来查看同样的程序实现,不也降低了探索程序性能瓶颈的难度吗?那么,同样一段程序,您会通过C#进行观察,还是使用IL呢?

有朋友可能会说:即使无法把握JIT对于IL的优化,但是从IL中可以看出高级语言,如C#的编译器的优化效果啊。这话本没有错,但问题还是在于,C#的编译器优化效果,是否在“反编译”回来之后就无法观察到了呢?“优化过程”往往都是不可逆的,它会造成信息丢失,导致我们很难从“优化结果”中看出“原始模样”,这一点在上一篇文章中也有过论述。换句话说,我们通过C#=>IL=>C#这一系列“转化”之后,几乎都可以清楚地发现C#编译器做过哪些优化。这里还是使用经典的foreach作为示例,您知道以下两个方法的性能高低如何?

staticvoidDoArray(int[]source)

{

foreach(intiinsource)

{

Console.WriteLine(i);

}

}



staticvoidDoEnumerable(IEnumerablesource)

{

foreach(intiinsource)

{

Console.WriteLine(i);

}

}



经过了C#编译器的优化,再使用.NETReflector查看IL反编译成C#(NoneOptimization)的结果,就会发现它们变成了此般模样:

privatestaticvoidDoArray(int[]source)

{

intnum;

int[]numArray;

intnum2;

numArray=source;

num2=0;

gotoLabel_0014;

Label_0006:

num=numArray[num2];

Console.WriteLine(num);

num2+=1;

Label_0014:

if(num2<((int)numArray.Length))

{

gotoLabel_0006;

}

return;

}



privatestaticvoidDoEnumerable(IEnumerablesource)

{

intnum;

IEnumeratorenumerator;

enumerator=source.GetEnumerator();

Label_0007:

try

{

gotoLabel_0016;

Label_0009:

num=enumerator.Current;

Console.WriteLine(num);

Label_0016:

if(enumerator.MoveNext()!=null)

{

gotoLabel_0009;

}

gotoLabel_002A;

}

finally

{

Label_0020:

if(enumerator==null)

{

gotoLabel_0029;

}

enumerator.Dispose();

Label_0029:;

}

Label_002A:

return;

}

C#编译器的优化效果表露无遗:对于int数组的foreach其实是被转化为类似于for的下标访问遍历,而对于IEnumerable还是保持了foreach关键字中标准的“while...MoveNext”模式(如果您把Console.WriteLine语句去掉的话,就可以使用.NET3.5Optimization直接看出两者的不同,您不妨一试)。由此看来,DoArray的性能会比后两者要高。事实上,由于性能主要是由“实现方式”决定的,因此我们可以通过反编译成C#代码的方式来阅读.NET框架中的大部分代码,IL在这里起到的效果很小。例如在文章《泛型真的会降低性能吗?》里,Teddy大牛就通过阅读.NET代码来发现数组的IEnumerable实现,为什么性能远低于IEnumerable

不过,判断两者性能高低,最简单,也最直接的方式还是进行性能测试。例如您可以使用CodeTimer来比较DoArray和DoEnumerable方法的性能,一目了然。

值得一提的是,如果要进行性能优化,需要做的事情有很多,而“阅读代码”在其中的重要性其实并不高,而且它也最容易误入歧途的一种。“阅读代码”充其量是一种人工的“静态分析”,而程序的运行效果是“动态”的。这篇文章解释了为什么使用foreach对ArrayList进行遍历的性能会比List低,其中使用了Profiler来说明问题。Profiler能告诉我们很多难以观察到的事情,例如在遍历中究竟是ArrayList哪个方法消耗时间最长。此外它还发现了ArrayList在遍历时创建了大量的对象,这种对于内存资源的消耗,几乎不可能从一小段代码中观察得出。此外,不同环境下,同样的代码可能执行效果会有不同。如果没有Profiler,我们可能会选择把一段执行了100遍的代码性能提升1秒钟,却不会把一段执行100000遍的代码性能提升100毫秒。性能优化的关键是“有的放矢”,如果没有Profiler帮我们指明道路,做到这一点相当困难。

其实我对于性能方面说的这些,可以大致归纳为以下三点:

关注IL,对于从微观角度观察程序性能很难有太大帮助,因为您很难具体指出JIT对IL的编译方式。

关注IL,对于从宏观角度观察程序性能同样很难有太大帮助,因为它的表述能力不会比C#来的直观清晰。

性能优化,最关键的一点是使用Profiler来找出性能瓶颈,有的放矢。

所以,如果您问我:“学习IL,对写出高性能的.NET程序有帮助吗?”我会回答:“有,肯定有啊”。

但是,如果您问我:“我想写出高性能的.NET程序,应该学习IL吗?”我会回答:“别,别学IL”。

总结

feilng在前文留下的一些评论,我认为说得非常有道理:

IL只是在CLR的抽象级别上说明干什么,而不是怎么干……重要的是要清楚在现实条件下,需要进入那个层次才能获取足够的信息,掌握接口的完整语义和潜在副作用。

IL的确比C#等高级语言来的所谓“底层”,但是很明显,IL本身也是一种高级抽象。而即使是机器码,它也可以说是基于CPU的抽象,CPU上如流水线,并行,内存模型,CacheLock等东西对于汇编/机器码来说也可以说是一种“封装”。从不同层次可以获得不同信息,我们追求“底层”的目的肯定也不是“底层”这两个字,而是一种收获。了解自身需要什么,然后能够选择一个合理的层次进入,并得到更好的收益,这本身也是一种能力。追求IL的做法,本身并没有错,只是追求IL一定是当前情况下的最优选择吗?这是一个值得不断讨论的问题,我的这篇文章也只是表达了我个人对某些问题的看法。

谈IL(4):什么时候应该学IL,该怎么学IL

又是一个拖了半年的系列,可能是前几篇主要以事实为准,举例子的文章总是比较容易写的,因此十分顺畅。而最后一篇打算做一个总结,以讲道理为主——却发现该将的似乎都已经讲完了。不过做事要有始有终,该完成的也必须要完成。那么现在就来谈谈我的一些个人看法:什么时候应该学IL,以及应该怎么学IL。

对了,先表个态,我个人并不支持普通程序员学习IL——至于什么是“普通”,什么叫做“学习”我们再慢慢谈。

经常听到有朋友有人说:如果要成为一个优秀的.NET程序员,那么IL很重要,一定要学习。这时候我经常会问一句“为什么”,得到的答复往往是“IL比较接近底层”。不过在我看来,“底层”只是IL本身的特性,并不足以证明“IL是高级程序员的必知必会”。IL的确比C#等高级语言来的所谓“底层”,但是IL本身其实也是一种高级抽象,它所能表现出来的几乎所有东西,都可以从C#等高级语言中获取到,而我们平时所了解到的如“装箱”,“引用类型和值类型的分配方式”,“垃圾收集”都属于CLR的范畴,您可以从书中看到或听人讲起,但是无法从IL上看出来。

当然,无论怎么说IL总是相对较为“底层”的东西,但是“底层”就应该学习吗?从不同层次可以获得不同信息,我们追求“底层”的目的肯定也不是“底层”这两个字,而是一种收获。所有人的时间或精力都是宝贵的,而对于一个优秀的程序员来说,知道了解自身需要什么,然后能够选择一个合理的层次进入,并得到更好的收益,这本身也是一种能力——而且可以说是必须的能力。这种能力不光是体现在有选择性的“学习”上,而可以体现在更多方面,因为几乎做任何一件事情都有多种方式,我们要选择最合适的。

方法,很重要。

学习IL的另一个重要“原因”,可能是有些朋友认为学习IL对于优化.NET程序性能有帮助,对于这个问题在之前的文章中也有过讨论,这次再拿出来一谈。性能优化是最需要“方法”的工作之一,如果方法不对,不仅仅是工作效率的问题,甚至很难得到正确的结果。IL和普通高级语言的代码一样,是一种静态的事物,您就算对它了解地再透彻,您也只能“阅读”它。而对于性能优化来说,要做的事情有很多,“阅读代码”在其中的重要性其实并不高——而且它也最容易误入歧途的一种。如果要进行性能优化,首先要进行的其实是“发现需要优化的地方”。徐宥写过这样一个八卦:

话说当年在贝尔实验室,一群工程师围着一个巨慢无比的小型机发呆。为啥呢,因为他们觉得这个机器太慢了。什么超频,液氮等技术都用了,这个小型机还是比不上实验室新买的一台桌上计算机。这些家伙很不爽,于是准备去优化这个机器上的操作系统。他们也不管三七二十一,就去看究竟那个进程占用CPU时间最长,然后就集中优化这个进程。他们希望这样把每个程序都优化到特别高效,机器就相对快了。于是,他们终于捕捉到一个平时居然占50%CPU的进程,而且这个进程只有大约20K的代码。他们高兴死了,立即挽起袖子敲键盘,愣是把一个20K的C语言变成了快5倍的汇编。这时候他们把此进程放到机器上这么一实验,发现居然整体效率没变化。百思不得其解的情况下他们去请教其他牛人。那个牛人就说了一句话:你们优化的进程,叫做SystemIdle。

无论这个八卦是否存在“艺术加工”,但都至少说明一个问题,就是如果没有经过合适的Profiling,没有找到性能的问题所在,优化是几乎不会有效果的。因此,我们一直说要把代码写的易于理解,说makecleancodefast远比makefastcodeclean要容易,都是同样的道理,因为代码清晰,我们可以找出其性能瓶颈,然后有针对性地加以优化——那么,了解IL对此会有帮助吗?IL虽然并不难懂(稍后会谈到),但也比如C#代码要“间接”很多——试想,如果您使用阅读IL的方式来了解字符串连接的实现细节会是什么情况呢?

因为程序是动态的,动态的事情要用“动态的方式”地决定,也就是Profiling。而即便是最最普通的Profiling方式,如使用CodeTimer来统计时间,也比“阅读代码”要靠谱许多,因为它可以直接反应出不同实现方式之间运行时间的区别——而不是靠猜测。事实上您也可以发现,我在研究性能问题的时候,使用的最多的还是CodeTimer(如泛型问题、并发缓存容器、字符串连接)。的确,CodeTimer得到的只是“表象”,因此发现性能瓶颈可能还需要依赖Profiler——这也是我为什么在这个问题上尝试半天的原因。有了Profiler之后,发现性能的HotPath便有更多依据了。

方法,很重要。

再次表达观点:我认为,对于一个“普通”的.NET程序员来说,是不需要去“学习”IL的。这里的“普通”二字是针对“工作内容”,而不是“人员水平”。也就是说,和“普通程序员”相对的是“工作内容特别的程序员”而不是“高级程序员”。“特殊”是指您的工作需要“直接”用到IL,例如您如果想要写一个.NET上的新语言,就必须了解IL,了解怎样使用IL写程序。而“直接”二字自然是和“间接”二字对应,或者说您的工作中会“顺便”用到IL——例如,您像我一样想要写一个延迟加载类库,或者为NHibernate实现个通用的UserType支持——在这种情况您可能也并不需要花功夫去学它。事实上,按照我的分类方式,我也是个普通程序员,所以我没有学IL,我很难看懂复杂的IL代码,更不会用IL写程序,但这并不影响我完成一些简单的IL相关工作——这并不矛盾。

为什么这么说呢?因为IL和C#这样的高级语言实在太接近了。IL几乎只是C#的另一种表现形式,它和C#一样,都几乎直接表现出.NET中定义的各种概念:泛型、数组、类、接口、继承、异常……甚至连部分关键字都一样。还有相当重要的一点,它们都是命令式的语言,因此.NETReflector可以将其“还原”成C#代码,因为在这个过程中实在没有丢失太多信息。但是,如果想要把IL还原成F#,这就非常困难了,因为F#和IL无法做到十分对应。因此,您在通过C#学习.NET时,已经在不断降低IL的门槛了。最后您会发现,IL真不是什么特别的东西。

于是,在用到Emit的时候,您就可以先写一些C#目标代码并编译成程序集,然后用.NETReflector将其反编译成IL,一条一条指令地“抄”至程序中——甚至现在已经有了插件来生成这些Emit代码。同样,只要有些耐心和细心,修改个程序集可能也只是“写C#”,“反编译”,“复制/粘贴”的过程而已。

很多时候,IL也只是被“神化”了。因此会有朋友觉得,了解IL的就是高手,要成为高手必须学习IL——其实IL和水平高低的关系并不大。多说一句,即便是所谓“底层工作者”,这也是在不同抽象上办事,并不代表他们一定就是牛人。我之前谈的很多东西,其实也是想打破这个“IL神话”罢了。有时候我也纳闷,为什么Java平台上学习ByteCode或JVM的“氛围”就远不如.NET平台上学习IL和CLR呢?

当然,如果您真心希望,您感兴趣,自然可以学习IL——多了解一些总是好的。我建议,如果您真要学习IL,可以先去学习一些汇编——这么说似乎也不够确切,因为我始终认为它们的可比性并不大——只是从“表现形式”上来说,IL和汇编略有相似(例如逻辑跳转方面)。您可以先看看《深入理解计算机系统》这本书,它讲的不是IL,它讲的是“计算机系统”。在学习过程中会涉及到一些汇编——甚至您可以直接翻到对应章节学习这部分内容。您无需“学完”,只需要大概了解一下,再配合C#编程经验,就会发现IL其实“真的很直观”,此时您可能已经不太会去找一本讲IL的书去特意学习IL编程了。

嗯,“找一本讲IL的书去特意学习IL编程”,这就是我在文章开始提到的学习方式——我不推荐这种做法



献花(0)
+1
(本文系zww_blog首藏)