一、IL与汇编语言
IL是微软.NET平台上衍生出的一门中间语言,.NET平台上的各种高级语言(如C#,VB,F#)的编译器会将各自的代码转化为IL。,其中包含了.NET平台上的各种元素,如“范型”,“类”、、“接口”、“模块”、“属性”等等。值得注意的是,各种高级语言本身可能根本没有这些“概念”在里头,如IronScheme是一个在.NET平台上的Scheme语言实现,其中根本没有前面提到的这些IL——亦或说是.NET平台上的名词。IL本身并不知道自己是由哪种高级语言转化而来的,哪种语言中有哪些特性,IL也根本不会关心。
各种语言的编译器将: 高级语言 => IL。
汇编是让CPU直接使用的“语言”,请注意“直接”二字:一条汇编指令便是让CPU作一件事情(如寄存器的复制,从内存中读取数据等等),毫无二义。不同族CPU拥有不同的指令集,但是它们都有一样的特征:指令的数量相对较少,每个指令功能都简单之至。
由于CPU只认识汇编代码(机器码和汇编其实也是一一对应的,您可以这样理解:汇编是机器码的文字表现形式,提供了一些方便人们记忆的“助记符”),因此就算是IL也需要再次进行转化,才能被CPU执行。这次转化便由 “JIT Compiler”( 即时编译器)完成。CLR加载了IL之后,当每个方法——请注意这是IL中的概念——第一次被执行时,就会使用JIT将IL代码进行编译为机器码。与IL 不同的是,CLR,JIT都是真正了解CPU的,对于同样的IL,JIT会把它为不同的CPU架构(如x86/IA64等等)生成不同的机器码。这也是 Java/.NET中“Compile Once,Run Everywhere”这一口号的技术基础:它们为不同的CPU架构提供了不同的“IL转化器”,仅此而已。与高级语言到IL的转化类似,CPU也完全不知道自己在执行的指令是从哪里来的,可能是JIT从IL转化而来,可能是JVM从Java Bytecode转化而来,也有可能是C语言编译得来,也有可能是由MIT/GNU Scheme解释而来。
这就是.NET平台上的高级语言在机器上运行的第二次转化:IL => 汇编(机器码)。
因此,IL和汇编的区别是显著的。IL拥有各种高级特性,它知道什么是范型,什么是类和方法(以及它们的“名称”),什么是继承,什么是字符串,布尔值,什么是User对象。而CPU只知道寄存器,地址,内存,01010101。与汇编相比,IL简直太高级了,几乎完全是一个高级语言,比C语言还要高级。因此,您会看到.NET Reflector几乎可以把IL代码“一五一十”地反编译为可读性良好的C#代码,包括类,属性,方法等等;而从汇编只能勉勉强强地反编译为C语言—— 而且其中的“方法名”等信息已经完全不可恢复了,更别说“模块”等高级抽象的内容。您想要把汇编反编译成C#代码?相信在将来这是可行的,不过现在这还是天方夜谭。
二、IL并不是万能的,CLR还有很多内容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
{
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
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
// 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
.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
结论:.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语句,它的含义是:
先从读取172FF8地址中的值,这才是方法调用的目标地址(即SomeMethod方法)。
使用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代码编译后的机器码,而我们现在看到便是这个机器码的汇编表现形式。
示例三:泛型方法是为每个类型各生成一份代码吗?
IL和我们平时用的C#程序代码不一样,其中使用了各种指令,而不是像C#那样有类似于英语的关键字,甚至是语法。但是有一点是类似的,它的主要目的是表现程序逻辑,而他们表现得逻辑也大都是相同的,接近的。你创建对象那么我也创建,你调用方法那么我也调用。因此才可以有.NET Reflector帮我们把IL反编译为比IL更高级的C#代码。如果IL把太多细节都展开了,把太多信息都丢弃了,那么怎么可以如此容易就恢复呢?例如,您可以把一篇Word文章转化为图片,那么又如何才能把图片再转回为Word格式呢?C => 汇编、汇编 => C,此类例子数不胜数。
再举一个例子,例如您有以下的范型方法:
private static void GenericMethod
{
Console.WriteLine(typeof(T));
}
static void Main(string[] args)
{
GenericMethod
GenericMethod
GenericMethod
GenericMethod
GenericMethod
GenericMethod
Console.ReadLine();
}
有朋友认为,范型会造成多份代码拷贝。那么您是否知道,使用不同的范型类型去调用GenericMethod方法,会各生成一份机器码吗?我们先看一下IL吧:
.method private hidebysig static void GenericMethod
{
.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
L_0005: call void TestConsole.Program::GenericMethod
L_000a: call void TestConsole.Program::GenericMethod
L_000f: call void TestConsole.Program::GenericMethod
L_0014: call void TestConsole.Program::GenericMethod
L_0019: call void TestConsole.Program::GenericMethod
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
00a70073 mov ecx,3A30C4h (MD: ....GenericMethod[[System.String, mscorlib]]())
// 引用类型实际都共享一个GenericMethod
00a70078 call dword ptr ds:[3A3098h] (....GenericMethod[[System.__Canon, mscorlib]](), ...)
// 调用GenericMethod
00a7007e call dword ptr ds:[3A3108h] (....GenericMethod[[System.Int32, mscorlib]](), ...)
// 准备GenericMethod
00a70084 mov ecx,3A3134h (MD: ....GenericMethod[[System.Object, mscorlib]]())
// 引用类型实际都共享一个GenericMethod
00a70089 call dword ptr ds:[3A3098h] (....GenericMethod[[System.__Canon, mscorlib]](), ...)
// 调用GenericMethod
00a7008f call dword ptr ds:[3A3178h] (....GenericMethod[[System.DateTime, mscorlib]](), ...)
// 准备GenericMethod
00a70095 mov ecx,3A31A4h (MD: ....GenericMethod[[TestConsole.Program, TestConsole]]())
// 引用类型实际都共享一个GenericMethod
00a7009a call dword ptr ds:[3A3098h] (....GenericMethod[[System.__Canon, mscorlib]](), ...)
// 调用GenericMethod
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
总结
以上三个示例都是用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无关),想要看懂如上的探索过程,以及吸收最后的结论应该不是一件困难的事情。
三、IL可以看到的东西,其实大都也可以用C#来发现
我们使用工具.NET Reflector来完成这部分知识的学习,从.NET 1.x开始,.NET Reflector就是一个探究.NET框架(主要是BCL)内部实现的有力工具,它可以把一个程序集高度还原成C#等高级语言的代码。在它的帮助下,几乎所有程序集实现都变得一目了然,这大大方便了我们的工作。在某段不算短的时间内,使用.NET Reflector阅读过的代码数量远远超过了自己编写的代码。与此相反的是,几乎没有使用IL探索过.NET框架下的任何问题。这可能还涉及到方式方法和个人做事方式,但是如果这真有效果的话,为什么要舍近求远呢?希望您看过了这篇文章,也可以像我一样摆脱IL,投入.NET Reflector的怀抱。
示例一:探究语言细节
C#语言从1.0到3.0版本的进化过程中,大部分新特性都是依靠编译器的魔法。就拿C#3.0的各种新特性来说,Lambda表达式,LINQ,自动属性等等,完全都是基于CLR 2.0中已有的功能,再配合新的C#编译器而产生的各种神奇效果。有些朋友认为,掌握IL之后便把握了.NET的根本,以不变应万变,只要读懂IL,那么这些新特性都不会对您形成困扰。这话说的并没有错,只是,“掌握IL”在这里只是一个“充分条件”而不是一个“必要条件”,我们完全可以使用.NET Reflector将程序集反编译成C#代码来观察这些。
这里我们使用.NET Reflector来观察最最常见,最最普通的foreach关键字的功能。我们都知道foreach是遍历一个IEnumerble对象内元素的方式,我们也都知道foreach其实是GoF Iterator模式的实现,通过MoveNext方法和Current属性进行配合共同完成。不过大部分朋友似乎都是从IL进行观察,或是“听别人说” 而了解这些的。事实上,.NET Reflector也可以很容易地证实这一点,只是这中间还有些“特别”的地方。那么首先,我们还是来准备一个最简单的foreach语句:
static void DoEnumerable(IEnumerable
{
foreach (int i in source)
{
Console.WriteLine(i);
}
}
如果观察它的IL代码,即使不了解IL的朋友也一定可以看出,其中涉及到了GetEnumerator,MoveNext和Current等成员的访问:
.method private hidebysig static void DoEnumerable(
class [mscorlib]System.Collections.Generic.IEnumerable`1 source) cil managed
{
.maxstack 1
.locals init (
[0] int32 i,
[1] class [mscorlib]System.Collections.Generic.IEnumerator`1 CS$5$0000)
L_0000: ldarg.0
L_0001: callvirt instance class [mscorlib]System.Collections.Generic.IEnumerator`1
[mscorlib]System.Collections.Generic.IEnumerable`1::GetEnumerator()
L_0006: stloc.1
L_0007: br.s L_0016
L_0009: ldloc.1
L_000a: callvirt instance !0 [mscorlib]System.Collections.Generic.IEnumerator`1::get_Current()
L_000f: stloc.0
L_0010: ldloc.0
L_0011: call void [mscorlib]System.Console::WriteLine(int32)
L_0016: ldloc.1
L_0017: callvirt instance bool [mscorlib]System.Collections.IEnumerator::MoveNext()
L_001c: brtrue.s L_0009
L_001e: leave.s L_002a
L_0020: ldloc.1
L_0021: brfalse.s L_0029
L_0023: ldloc.1
L_0024: callvirt instance void [mscorlib]System.IDisposable::Dispose()
L_0029: endfinally
L_002a: ret
.try L_0007 to L_0020 finally handler L_0020 to L_002a
}
但是,如果使用.NET Reflector观察它的C#代码又会如何呢?
private static void DoEnumerable(IEnumerable source)
{
foreach (int i in source)
{
Console.WriteLine(i);
}
}
请注意,以上这段是由.NET Reflector从IL反编译后得到的C#代码,这简直……不是简直,是完完全全真真正正地和我们刚才写的代码一模一样!这就是.NET Reflector的强大之处,由于它意识到IL调用了IEnumerable
刚才提到,.NET Reflector在判断IL代码时发现一些标准的模式时会进行代码“优化”。那么我们能否让.NET Reflector不要做这种“优化”呢?答案是肯定的,只是需要您在.NET Reflector中进行一些简单的设置:
打开View菜单中的Options对话框,在左侧Disassembler选项卡中修改Optimization级别,默认很可能是.NET 3.5,而现在我们要将其修改为None。这么做会让.NET Reflector最大程度地“直接”翻译IL代码,而不做一些额外优化。将Optimization级别设为None以后,DoEnumerable方法的代码就变为了:
static void DoEnumerable(IEnumerable
{
int num;
IEnumerator
enumerator = source.GetEnumerator();
Label_0007:
try
{
goto Label_0016;
Label_0009:
num = enumerator.Current;
Console.WriteLine(num);
Label_0016:
if (enumerator.MoveNext() != null)
{
goto Label_0009;
}
goto Label_002A;
}
finally
{
Label_0020:
if (enumerator == null)
{
goto Label_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之前,建议您可以尝试一下使用None Optimization来观察C#代码。
实事求是地说,上面的C#代码的“转向逻辑”并不那么清晰,因此您在理解的时候可以把它复制到编辑器中,进行一些简单调整。但是从我的经验上来看,需要使用None Optimization进行探索的地方非常少见。foreach是一个,还有便是C#中的其他一些“别名”,如使用using关键字管理 IDisposable对象,以及lock关键字。而且,其实这段逻辑也只是没有优化IL中的跳转语句而已,已经比IL本身要直观许多了。此外,关于对象创建,变量声明,方法调用,属性访问,事件加载……一切的一切都还是最常用的C#代码。因为还是那个原因:从大部分情况上来看,IL也只是表现了程序逻辑,并没有比C#等语言体现出更多的细节。
我在这里举了一个较为极端的例子,因为我发现不少朋友并没有尝试过使用None Optimization来观察过代码。这里也可以看出,.NET Reflector的“优化级别”还不够“细致”。不过这应该是一个“产品设计”的正常结果,因为foreach/using/lock的关键字都是从.NET 1.0诞生伊始就存在的,也就是说,即使.NET Reflector选择将IL编译为C# 1.0,它的表现形式依旧是“标准模式”,这方面可能就不能过于强求了吧。至于其他一些探索,例如C#中的自动属性,Lambda表达式构建表达式树或匿名委托,乃至C# 4.0中的dynamic关键字,都是使用.NET 3.5 Optimization进行探索便可得知的结果。您可以回忆一下自己看过的文章,其中有多少是使用IL解释问题的呢?
示例二:学习.NET平台上的其他语言
在.NET平台上,任何语言都会先编译为IL,然后再运行时由JIT转化为机器码。因此有种说法是,只要把握了IL,.NET平台上各种语言之间的迁移都会变得容易。对此不同看法。在以前讨论语言是否重要的时候,提到,语言它并不仅仅是一种文字表现形式,而是一种“思维方式”的改变,这可能会影响到您程序的编码风格,API设计乃至架构(这个链接可能打不开,因为……)。实际上,如果您只是在C#与VB.NET之间进行迁移,原本就是一件相当容易的事情,因为它们之间“语言”的各种概念和特性都非常接近。而一种改变您思维的语言,才是真正有价值,而且值得进行比较和探索的。如果一味地追求“把握本源”,那么甚至还有比IL更低抽象的事务,但这些就已经违背了“创造一门语言”,以及您学习它的目的了,不是吗?
当然,探索也是需要的,尤其是.NET平台上的各种语言,他们被统一在同样的平台上,这本身就是一种很好的资源。这种资源就是所谓的“比较学习”。您可以把新的语言和您熟悉的语言进行对比,吸收其中的长处(如优秀的思维方式),这样便可以更好地使用旧有语言。例如,您把F#类库转化为C#代码进行观察之后,发现其中大量函数式编程风格的API是使用“委托”来实现的,您可能就会想到是否可以设计出函数式编程风格的C# API,是否可以把F#中List或Seq模块中的各种高阶函数移植到您自己的项目中来。这就有了更好的价值,这价值也不仅仅只是您“学会了新的语言”。
例如,我们现在使用尾递归来计算斐波那契数列。在之前的文章中,我们的作法是:
private static int FibTail(int n, int acc1, int acc2)
{
if (n == 0) return acc1;
return FibTail(n - 1, acc2, acc1 + acc2);
}
public static int Fib(int n)
{
return FibTail(n, 0, 1);
}
为了“尾递归”,我们必须定义一个私有的FibTail方法,接收三个参数。而对外的接口还是一个公有的Fib方法,它返回斐波那契数列第n项的结果。这个示例很简单,作法也没有任何问题。但是我有时候会觉得,我们为什么非要定义一个额外的“辅助方法”,然后在现有的方法里只是进行一个简单的转发?如果这个辅助方法会在其他地方得到调用也就罢了(我们遵守了DRY原则),但是现在却有点“平白无故”地在代码里增加了一个方法,这样在VS的 Class View或编辑器上方的下拉列表中也会多出一项。此外,为了表示两个方法的关系,您可能还会使用region把它们包裹起来……
不过在F#中,上面的尾递归就可以这样写:
let fib n =
let rec fibTail x acc1 acc2 =
match x with
| 0 -> acc1;
| _ -> fibTail (x - 1) acc2 (acc1 + acc2)
fibTail n 0 1
在fib方法内部,我们可以重新定义一个fibTail方法,其中实现了尾递归。对于外部来说,只有fib方法是公开的,外界丝毫不知道 fibTail方法的存在,这种定义内部函数的作法在F#中非常常见。而编译后,我们在.NET Reflector中便可看到与之对应的C#实现:
public static int fib(int n)
{
switch (n)
{
case 0:
return 0;
}
return fibTail@7@7(n - 1, 1, 1);
}
internal static int fibTail@7@7(int x, int acc1, int acc2)
{
...
}
在F#中没有internal的访问级别,您可以认为这里internal便是private。于是我们得知(可能您本身也猜得到):由于.NET本身并没有“嵌套方法”特性,因此在这里编译器会重新生成一个特殊的私有方法,并且在fib方法里进行调用。于是我们想到,这个“自动生成方法”的特性,在C#中也有体现啊。例如,IEnmuerable
public static int Fib(int n)
{
Func
fibTail = (x, acc1, acc2) =>
{
if (x == 0) return acc1;
return fibTail(x - 1, acc2, acc1 + acc2);
};
return fibTail(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或造成了False Sharing,或造成了资源竞争等等,这可能就会造成数倍甚至更多的性能损耗。换句话说,影响现代应用程序的性能的因素大都是“宏观”的,用通俗的话来说,一般都是“写法”上造成的问题。
这也是为什么说“Make clean code fast”远比“Make fast code clean”来的容易,现代程序更注重的是“清晰”而并非是“性能”。因为程序清晰,更容易让人发现性能瓶颈究竟在何处,可以进行有针对性地优化(即使是那种在极端性能要求下故意进行的“丑陋”写法,也是为了高性能而“丑陋”,而不是因为“丑陋”而高性能,分清这一点很重要)。换句话说,如果我们有一种更清晰地方式来查看同样的程序实现,不也降低了探索程序性能瓶颈的难度吗?那么,同样一段程序,您会通过C#进行观察,还是使用IL呢?
有朋友可能会说:即使无法把握JIT对于IL的优化,但是从IL中可以看出高级语言,如C#的编译器的优化效果啊。这话本没有错,但问题还是在于,C#的编译器优化效果,是否在“反编译”回来之后就无法观察到了呢?“优化过程”往往都是不可逆的,它会造成信息丢失,导致我们很难从“优化结果”中看出“原始模样”,这一点在上一篇文章中也有过论述。换句话说,我们通过C# => IL => C#这一系列“转化”之后,几乎都可以清楚地发现C#编译器做过哪些优化。这里还是使用经典的foreach作为示例,您知道以下两个方法的性能高低如何?
static void DoArray(int[] source)
{
foreach (int i in source)
{
Console.WriteLine(i);
}
}
static void DoEnumerable(IEnumerable
{
foreach (int i in source)
{
Console.WriteLine(i);
}
}
经过了C#编译器的优化,再使用.NET Reflector查看IL反编译成C#(None Optimization)的结果,就会发现它们变成了此般模样:
private static void DoArray(int[] source)
{
int num;
int[] numArray;
int num2;
numArray = source;
num2 = 0;
goto Label_0014;
Label_0006:
num = numArray[num2];
Console.WriteLine(num);
num2 += 1;
Label_0014:
if (num2 < ((int)numArray.Length))
{
goto Label_0006;
}
return;
}
private static void DoEnumerable(IEnumerable
{
int num;
IEnumerator
enumerator = source.GetEnumerator();
Label_0007:
try
{
goto Label_0016;
Label_0009:
num = enumerator.Current;
Console.WriteLine(num);
Label_0016:
if (enumerator.MoveNext() != null)
{
goto Label_0009;
}
goto Label_002A;
}
finally
{
Label_0020:
if (enumerator == null)
{
goto Label_0029;
}
enumerator.Dispose();
Label_0029: ;
}
Label_002A:
return;
}
C#编译器的优化效果表露无遗:对于int数组的foreach其实是被转化为类似于for的下标访问遍历,而对于 IEnumerable
不过,判断两者性能高低,最简单,也最直接的方式还是进行性能测试。例如您可以使用CodeTimer来比较DoArray和DoEnumerable方法的性能,一目了然。
值得一提的是,如果要进行性能优化,需要做的事情有很多,而“阅读代码”在其中的重要性其实并不高,而且它也最容易误入歧途的一种。“阅读代码”充其量是一种人工的“静态分析”,而程序的运行效果是“动态”的。这篇文章解释了为什么使用foreach对ArrayList进行遍历的性能会比List
其实对于性能方面说的这些,可以大致归纳为以下三点:
·关注IL,对于从微观角度观察程序性能很难有太大帮助,因为您很难具体指出JIT对IL的编译方式。
·关注IL,对于从宏观角度观察程序性能同样很难有太大帮助,因为它的表述能力不会比C#来的直观清晰。
·性能优化,最关键的一点是使用Profiler来找出性能瓶颈,有的放矢。
所以,如果您问:“学习IL,对写出高性能的.NET程序有帮助吗?”回答:“有,肯定有啊”。
但是,如果您问:“我想写出高性能的.NET程序,应该学习IL吗?”回答:“别,别学IL”。
总结
feilng在前文留下的一些评论,我认为说得非常有道理:
IL只是在CLR的抽象级别上说明干什么,而不是怎么干……重要的是要清楚在现实条件下,需要进入那个层次才能获取足够的信息,掌握接口的完整语义和潜在副作用。
IL的确比C#等高级语言来的所谓“底层”,但是很明显,IL本身也是一种高级抽象。而即使是机器码,它也可以说是基于CPU的抽象,CPU上如流水线,并行,内存模型,Cache Lock等东西对于汇编/机器码来说也可以说是一种“封装”。从不同层次可以获得不同信息,我们追求“底层”的目的肯定也不是“底层”这两个字,而是一种收获。了解自身需要什么,然后能够选择一个合理的层次进入,并得到更好的收益,这本身也是一种能力。追求IL的做法,本身并没有错,只是追求IL一定是当前情况下的最优选择吗?这是一个值得不断讨论的问题,我的这篇文章也只是表达了我个人对某些问题的看法。
1、如何看到元件的中间语言吗?
Microsoft 提供了一个称为 Ildasm 的工具,它可以用来查看元件的 metadata 和 IL。
2、能否通过反向工程从 IL 中获得源代码?
是的。相对而言,从 IL 来重新生成高级语言源代码 (例如 C#) 通常是很简单的。
3、如何防止别人通过反向工程获得我的代码?
目前唯一的办法是运行带有 /owner 选项的 ilasm。这样生成的元件的 IL 不能通过 ildasm 来查看。然而,意志坚定的代码破译者能够破解 ildasm 或者编写自己的 ildasm 版本,所以这种方法只能吓唬那些业余的破译者。
不幸的事,目前的 .NET 编译器没有 /owner 选项,所以要想保护你的 C# 或 VB.NET 元件,你需要像下面那样做:
csc helloworld.cs
ildasm /out=temp.il helloworld.exe
ilasm /owner temp.il
(这个建议是 Hany Ramadan 贴到 DOTNET 上的。)
看起来过一段时间能有 IL 加密工具 (无论来自 Microsoft 或第三方)。这些工具会以这样的方式来“优化” IL:使反向工程变得更困难。
当然,如果你是在编写 Web 服务,反向工程看起来就不再是一个问题,因为客户不能访问你的 IL。
4、我能直接用 IL 编程吗?
是的。Peter Drayton 在 DOTNET 邮件列表里贴出了这个简单的例子:
程序代码
.assembly MyAssembly {}
.class MyApp {
.method static void Main() {
.entrypoint
ldstr "Hello, IL!"
call void System.Console::WriteLine(class System.Object)
ret
}
}
将其放入名为 hello.il 的文件中,然后运行 ilasm hello.il,将产生一个 exe 元件。
5、IL 能做到 C# 中做不到的事吗?
是的。一些简单的例子是:你能抛出不是从 SystemException 导出的异常,另外你能使用非以零起始的数组。