(翻译) 《C# to IL》第一章 IL入门
-1-
我们用C#、VB.NET语言编写的代码最终都会被编译成程序集或IL。因此用VB.NET编写的代码可以在C#中修改,随后在COBOL中使用。因此,理解IL是非常有必要的。
一旦熟悉了IL,理解.NET技术就不会有障碍了,因为所有的.NET语言都会编译为IL。IL是一门中性语言。IL是先发明的,随后才有了C#、VB.NET等语言。
我们将在一个短而精辟的程序中展示IL。我们还假设读者至少熟悉一门.NET语言。
a.il
{
}
随后,我们用IL编写了一个非常短小的IL程序——它显然是不能工作的,并将它命名为a.il。那么我们怎么才能把它编译为一个可执行程序呢?不需要为此而焦急,Microsoft提供了一个ilasm程序,它的唯一任务就是从IL文件中创建可执行文件。
在允许这个命令之前,要确保你的变量路径被设置为framework中的bin子目录。如果不是,请输入命令如下:
set path=c:\progra~1\microsoft.net\frameworksdk\bin;%PATH%
现在,我们使用如下命令:
c:\il>ilasm /nologo /quiet a.il
这样做会生成下面的错误:
Source file is ANSI
Error: No entry point declared for executable
***** FAILURE *****
将来,我们将不会显示由ilasm生成的输出的第一行和最后一行。我们还将移除非空白行之间的空白行。
在IL中,允许我们使用句点.作为一行的开始,这是一条指令,要求编译器执行某个功能,如创建一个函数或类,等等。任何开始于句点的语句都是一条实际俄编译器指令。
.method表示创建一个名为vijay的函数(或方法),并且这个函数返回void,即它不返回任何值。因为缺少较好的命名法则,函数名称vijay显得很随意。
汇编器显然理解不了这个程序,从而会显示“no entry point”的消息。这个错误信息的生成是因为IL文件能够包括无数的函数,而汇编器无法区分哪个会被首先被执行。
在IL中,首先被执行的函数被称为进入点(entrypoint)函数。在C#中,这个函数是Main。函数的语法是,名称之后是一对圆括号()。函数代码的开始和结束用花括号{}来表示。
a.il
{
.entrypoint
}
c:\il>ilasm /nologo /quiet a.il
Source file is ANSI
Creating PE file
Emitting members:
Global Methods: 1;
Writing PE file
Operation completed successfully
现在不会生成任何错误了。伪指令(directive)entrypoint表示程序执行必须开始于这个函数。在这个例子中,我们不得不使用这个伪指令,虽然事实上这个程序只有一个函数。当在DOS提示符中给出dir命令后,我们看到有3个文件会被创建。a.exe是一个可执行文件,现在可以执行它来看到程序的输出。
C:\il>a
Exception occurred: System.BadImageFormatException: Exception from HRESULT: 0x8007000B. Failed to load C:\IL\A.EXE.
当我们试图执行上面的程序时,我们的运气似乎不太好,因为会生成上面的运行时错误。一个可能的原因是,这个函数是不完整的,每个函数都应当具有一个“函数结束”指令在函数体中。我们匆忙之中显然没有注意到这个事实。
a.il
{
.entrypoint
ret
}
“函数结束”指令被称为ret。前面所有的函数都必须以这个指令作为结束。
Output
Exception occurred: System.BadImageFormatException: Exception from HRESULT: 0x8007000B. Failed to load C:\IL\A.EXE.
在执行这个程序时,我们再次得到了相同的错误。这次我们的问题又在哪里呢?
a.il
.method void vijay()
{
.entrypoint
ret
}
错误在于我们忘记在名称后面使用必不可少的伪指令assembly。我们将其合成在上面的代码中,并在一对空的花括号之后使用了名称mukhi。这个程序集伪指令用于给出程序的名称。它又被称为一个部署单元。
上面的代码是可以汇编而没有任何错误的最小的程序,虽然它在执行时并没有做什么有用的事情。它没有任何名为Main的函数。它只有一个带有entrypoint伪指令的函数vijay。现在汇编这个程序并运行而根本不会有任何错误。
在.NET中,程序集的概念是极其重要的,应该对其有彻底的认识。我们将在本章后半部分使用这个伪指令。
a.il
.method void vijay()
{
.entrypoint
ret
}
.method void vijay1()
{
.entrypoint
ret
}
Error
***** FAILURE *****
上面错误信息的原因是,上面的程序有2个函数,vijay和vijay1,每个函数都包括了.entrypoint伪指令。正如前面提到的那样,这个指令指定了关于那个函数会被首先执行。
因此,在功能上,它类似于C#中的Main函数。当C#代码被转换为IL代码时,在Main函数中包含的代码会被转换为IL中的函数中并包括.entrypoint伪指令。例如,如果在COBOL程序中执行的第一个函数被称为abc,那么在IL中生成的代码就会在这个函数中插入.entrypoint伪指令。
在常规的程序语言中,首先被执行的函数必须有一个特定的名称,例如Main,但是在IL中,只需要一个.entrypoint伪指令。因此,因为一个程序只能由一个开始点,所以在IL代码中只允许一个函数包括.entrypoint伪指令。
迫切地看到,没有生成任何错误消息编号或说明,使得调试这个错误非常困难。
a.il
.method void vijay()
{
ret
.entrypoint
}
.entrypoint伪指令需要被定位为函数中的第一个指令或最后一个指令。它仅出现在函数体中,从而将它的状态宣布为第一个被执行的函数。伪指令不是程序集指令,甚至可以被放置在任何ret指令之后。提醒你一下,ret表示函数代码的结束。
a.il
.method void vijay()
{
.entrypoint
call void System.Console::WriteLine()
ret
}
我们可能有一个用C#、VB.NET编写的函数,但是在IL中执行这个函数的机制是相同的。如下所示:
我们必须使用汇编指令调用。调用指令之后,按照给定的顺序,为以下详细内容:
- 函数的返回类型(void)
- 命名空间(System)
- 类 (Console)
- 函数名称 (WriteLine())
函数被调用但不会生成任何输出。因为,我们传递一个参数到WriteLine函数中。
a.il
.method void vijay()
{
.entrypoint
call void System.Console::WriteLine(class System.String)
ret
}
上面的代码有一处“闪光点”。当一个函数在IL中被调用时,除了它的返回类型之外,被传递的参数的数据类型,也必须被指定。我们将Writeline设置为——希望得到一个System.String类型作为参数,但是由于没有字符串被传递到这个函数中,所以它会生成一个运行时错误。
因此,在调用一个函数时,在IL和其他程序语言之间有一个明显的区别。在IL中,当我们调用一个函数,我们必须指定关于该函数我们所知道的任何内容,包括它的返回类型和它的参数的数据类型。通过在运行期间进行恰当的检查,保证了汇编器能够在语法上验证代码的有效性。
现在我们将看到如何将参数传递到一个函数中。
a.il
.method void vijay()
{
.entrypoint
ldstr "hell"
call void System.Console::WriteLine(class System.String)
ret
}
Output
hell
汇编器指令ldstr把字符串放到栈上。Ldstr的名称是文本"load a string on the stack"的缩写版本。栈是一块内存区域,它用来传递参数到函数中。所有的函数从栈上接收它们的参数。因此,像ldstr这样的指令是必不可少的。
a.il
.method public hidebysig static void vijay()il managed
{
.entrypoint
ldstr "hell"
call void System.Console::WriteLine(class System.String)
ret
}
Output
hell
我们在方法vijay上添加了一些特性。接下来我们将逐个讲解它们。
public:被称为可访问特性,它决定了都有谁可以访问一个方法。public意味着这个方法可以被程序的其他任何部分所访问。
hidebysig:类可以从其它多个类中派生。hidebysig特性保证了父类中的函数在具有相同名称或签名的派生类中会被隐藏。在这个例子中,它保证了如果函数vijay出现在基类中,那么它在派生类中就是不可见的。
static:方法可以是静态的或非静态的。静态方法属于一个类而不属于一个实例。因此, 就像我们只有一个单独的类,我们不能拥有一个静态函数的多份复制。静态函数可以在哪里创建是没有约束的。带有entrypoint指令的函数必须是静态的。静态函数必须具有相关联的实体或者源代码,并且使用类型名称而不是实例名称来引用它们。
il managed:由于它的复杂性质,我们将关于这个特性的解释延后。当时机成熟时,它的功能将会被解释清楚。
上面涉及的特性并没有修改函数的输出。 稍后,你将明白为什么我们要提供这些特性的解释。
无论何时我们用C#语言编写一个程序,我们首先在类的名称前指定关键字class,随后,我们将源代码封闭在一对花括号内。示范如下:
a.cs
{
}
让我们引进称为class的IL指令:
a.il
.class zzz
{
.method public hidebysig static void vijay()il managed
{
.entrypoint
ldstr "hell"
call void System.Console::WriteLine(class System.String)
ret
}
}
注意到,汇编器输出中的改变: Class 1 Methods: 1;
Output
hell
伪指令.class之后是类的名称。它在IL中是可选的,让我们通过添加一些类的特性来增强这个类的功能。
a.il
.class private auto ansi zzz
{
.method public hidebysig static void vijay()il managed
{
.entrypoint
ldstr "hell"
call void System.Console::WriteLine(class System.String)
ret
}
}
Output
hell
我们添加了 3个特性到类的伪指令中。
- private:这表示了对类的成员的访问被约束为只能在当前类中。
- auto:这表示类在内存中的布局将只由运行时来决定,而不是由我们的程序决定。
- ansi:源代码通常被划分为两个主要的类别:托管代码和非托管代码。
以诸如C语言编写的代码被称为非托管代码或不可信任的代码。我们需要一个特性来处理非托管代码和托管代码之间的互操作。例如,当我们想要在托管和非托管代码之间转移字符串时,这个特性会被使用到。
如果我们跨越托管代码的边界并钻进非托管代码的领域,那么一个字符串——由2字节Unicode字符组成的数组,将会被转换为一个ANSI字符串——由1字节ANSI字符组成的数组;反之亦然。修饰符ansi用于消除托管和非托管代码之间的转换。
a.il
.class private auto ansi zzz extends System.Object
{
.method public hidebysig static void vijay()il managed
{
.entrypoint
ldstr "hell"
call void System.Console::WriteLine(class System.String)
ret
}
}
Output
hell
类zzz从System.Object中派生。在.NET中,为了定义类型的一致性,所有的类型最终都派生于System.Object。因此,所有的对象都有一个共同的基类Object。在IL中,类从其它类中派生,与C++、C#和Java的表现方式相同,
a.il
.subsystem 3
.corflags 1
.assembly extern mscorlib
{
.originator = (03 68 91 16 D3 A4 AE 33 )
.hash = (52 44 F8 C9 55 1F 54 3F 97 D7 AB AD E2 DF 1D E0
F2 9D 4F BC )
.ver 1:0:2204:21
}
.assembly a as "a"
{
.hash algorithm 0x00008004
.ver 0:0:0:0
}
.class private auto ansi zzz extends System.Object
{
.method public hidebysig static void vijay() il managed
{
.entrypoint
ldstr "hell"
call void System.Console::WriteLine(class System.String)
ret
}
.method public hidebysig specialname rtspecialname instance void .ctor() il managed
{
.maxstack 8
ldstr "hell1"
call void System.Console::WriteLine(class System.String)
ldarg.0
call instance void [mscorlib]System.Object::.ctor()
ret
}
}
Output
hell
你一定想知道为什么我们会编写出这么难看的程序。在迷雾驱散之前你需要保持耐心,所有的一切就要开始有意义了。我们将逐个解释新引进的函数和特性。
.ctor: 我们引进了一个新的函数.ctor,它调用了WriteLine函数来显示hell1,但是它没有被调用。.ctor涉及到了构造函数。
rtspecialname: 这个特性会告诉运行时——函数的名称是特殊的,它会以一种特殊的方式被对待。
specialname: 这个特性会提示编译器和工具——函数是特殊的。运行时可能选择忽略这个特性。
instance: 一个常规的函数会被一个实例函数调用。这样一个函数与一个对象关联,不同于静态方法,后者关联到一个类。
在合适的时候,为函数选择特定名称的原因会变得明朗。
ldarg.0: 这是一个汇编器指令,它加载this指针或第0个参数的地址到执行栈上。我们随后将详细解释ldarg.0。
mscorlib: 在上面的程序中,函数.ctor会被基类System.Object调用。通常,函数的名称以包括代码的库的名称作为前缀。这个库的名称被放置在方括号中。在这个例子中,它是可选的——因为mscorlib.dll是默认的库,并且它包括了.NET所需要的大部分类。
.maxstack: 这个伪指令指定了在一个方法被调用时,能够出现在计算栈上的元素的最大数量。
.module: 所有的IL文件必须是一个逻辑实体的一部分,或它们的组合体,我们将这些实体称为模块(module)。文件被添加到使用了.module伪指令的模块中。模块的名称可能被规定为aa.exe,但是可执行文件的名称和前面保持一样,即a.exe。
.subsystem: 这个指令用于指定可执行体运行在什么操作系统上。这是另一种指定可执行体所代表的种类的方式。一些数字值和它们对应的操作系统如下所示:
2 - A Windows Character 子系统。
3 - A Windows GUI 子系统。
5 – 像OS/2这样的老系统。
.corsflags: 这个伪指令用于指定对于64位计算机唯一的标志。值1表示它是从il中创建的可执行文件,而值64表示一个库。
.assembly: 在前面,我们曾经简单涉及过一个名为.assembly的指令。现在让我们进行深入的研究。
无论我们创建了什么,都是一个称为清单(manifest)的实体的一部分。.assembly伪指令标注了一个清单的开始位置。在层次上,模块是清单最小的实体。.assembly伪指令指定了这个模块属于哪个程序集。模块只能包括一个单独的.assembly伪指令。
对于exe文件,这个伪指令的存在是必须的,但是,对于.dll中的模块,则是可选的。这是因为,我们需要使用这个伪指令来创建一个程序集。这是.NET的基本需要。程序集伪指令包括了其它伪指令。
.hash: 散列计算是一门在计算机世界中通用的技术,这里有大量使用到的散列方法或算法。这个伪指令用于散列计算。
.ver: .ver:伪指令包括了4个由冒号分割的数字。按照下面给定的顺序,它们代表了下面的信息:
- 主版本编号
- 次版本编号
- 内部版本号
- 修订版本号
extern: 如果有涉及到其它程序集的需求,就要使用到extern伪指令。.NET核心类的代码位于mscorlib.dll中。除了这个dll之外,当我们的程序需要涉及到大量其它的dll时,extern伪指令就要排上用场了。
originator: 在转移到解释上面程序的本质和意义之前,这是我们要研究的最后一个伪指令。这个伪指令揭示了创建该dll的标识。它包括了dll的所有者公钥的8个字节。它显然是一个散列值。
让我们以一种不同的方式一步一步地温习到目前为止我们所做的事情。
(a)我们开始于一个我们能够编写的最简单的程序。这个程序被称为a.cs,并包括了下面的代码:
a.cs
{
public static void Main()
{
System.Console.WriteLine("hi");
}
}
(b)然后我们使用下面的命令运行C#编译器。
>csc a.cs
因此,会创建名为a.exe的exe文件。
(c)在可执行体中,我们运行一个名为ildasm的程序,它是由Microsoft提供的:
>ildasm /out=a.txt a.exe
这就创建了一个txt文件,具有下面的内容:
a.txt
// Copyright (C) Microsoft Corp. 1998-2000
// VTableFixup Directory:
// No data.
.subsystem 0x00000003
.corflags 0x00000001
.assembly extern mscorlib
{
.originator = (03 68 91 16 D3 A4 AE 33 ) // .h..3
.hash = (52 44 F8 C9 55 1F 54 3F 97 D7 AB AD E2 DF 1D E0
F2 9D 4F BC ) // RD..U.T?O.
.ver 1:0:2204:21
}
.assembly a as "a"
{
.hash algorithm 0x00008004
.ver 0:0:0:0
}
.module aa.exe
// MVID: {89CFAD60-F5BD-11D4-A55A-96B5C7D61E7B}
.class private auto ansi zzz
extends System.Object
{
.method public hidebysig static void vijay() il managed
{
.entrypoint
// Code size 11 (0xb)
.maxstack 8
IL_0000: ldstr "hell"
IL_0005: call void System.Console::WriteLine(class System.String)
IL_000a: ret
} // end of method zzz::vijay
.method public hidebysig specialname rtspecialname
instance void .ctor() il managed
{
// Code size 17 (0x11)
.maxstack 8
IL_0000: ldstr "hell"
IL_0005: call void System.Console::WriteLine(class System.String)
IL_000a: ldarg.0
IL_000b: call instance void [mscorlib]System.Object::.ctor()
IL_0010: ret
} // end of method zzz::.ctor
} // end of class zzz
//*********** DISASSEMBLY COMPLETE ***********************
当我们阅读上面的文件时,你将明白它的所有内容都已经在前面解释过了。我们开始于一个简单的C#程序,然后将它编译到一个可执行文件中。在正常的环境下,它将被转换为机器语言或这个程序运行在所在的计算机/微处理器的汇编程序。一旦创建了可执行体,我们就使用ildasm来反汇编它。反汇编输出被保存到一个新的文件a.txt中。这个文件可能被命名为a.il,然后我们可以通过对其运行ilasm反过来再次创建这个可执行体。
让我们看一下最小的VB.NET程序。我们将它命名为one.vb,而它的源代码如下所示:
one.vb
Sub Main()
System.Console.WriteLine("hell")
End Sub
End Module
在编写完上述的代码后,我们运行Visual.Net编译器vbc如下:
>vbc one.vb
这就产生了文件one.exe。
下面,我们执行ildasm如下所示:
>ildasm /out=a.txt one.exe
这就生成了下面的文件a.txt:
a.txt
// Copyright (C) Microsoft Corp. 1998-2000
// VTableFixup Directory:
// No data.
.subsystem 0x00000003
.corflags 0x00000001
.assembly extern mscorlib
{
.originator = (03 68 91 16 D3 A4 AE 33 ) // .h..3
.hash = (52 44 F8 C9 55 1F 54 3F 97 D7 AB AD E2 DF 1D E0
F2 9D 4F BC ) // RD..U.T?.O.
.ver 1:0:2204:21
}
.assembly extern Microsoft.VisualBasic
{
.originator = (03 68 91 16 D3 A4 AE 33 ) // .h..3
.hash = (5B 42 1F D2 5E 1A 42 83 F5 90 B2 29 9F 35 A1 BE
E5 5E 0D E4 ) // [B..^.B.).5.
.ver 1:0:0:0
}
.assembly one as "one"
{
.hash algorithm 0x00008004
.ver 1:0:0:0
}
.module one.exe
// MVID: {1ED19820-F5C2-11D4-A55A-96B5C7D61E7B}
.class public auto ansi modmain
extends [mscorlib]System.Object
{
.custom instance void [Microsoft.VisualBasic]Microsoft.VisualBasic.Globals/Globals$StandardModuleAttribute::.ctor() = ( 01 00 00 00 )
.method public static void Main() il managed
{
// Code size 11 (0xb)
.maxstack 1
.locals init (class System.Object[] V_0)
IL_0000: ldstr "hell"
IL_0005: call void [mscorlib]System.Console::WriteLine(class System.String)
IL_000a: ret
} // end of method modmain::Main
} // end of class modmain
.class private auto ansi _vbProject
extends [mscorlib]System.Object
{
.custom instance void [Microsoft.VisualBasic]Microsoft.VisualBasic.Globals/Globals$StandardModuleAttribute::.ctor() = ( 01 00 00 00 )
.method public static void _main(class System.String[] _s) il managed
{
.entrypoint
// Code size 6 (0x6)
.maxstack 8
IL_0000: call void modmain::Main()
IL_0005: ret
} // end of method _vbProject::_main
} // end of class _vbProject
//*********** DISASSEMBLY COMPLETE ***********************
你将惊讶地看到由两个不同的编译器所生成的输出几乎是相同的。我向你展示了这个示例用以证实——语言的无关性,最终,源代码将会被转换为IL代码。无论我们使用VB.NET或C#,都会调用相同的WriteLine函数。
因此,程序语言间的不同现在是表面上的问题。无休止的争论那个语言是最优的是没有意义的。从而,IL使得程序员可以自由使用他们所选择的语言。
让我们揭开上面给出的代码的神秘面纱。
每个VB.NET程序都需要被包括在一个模块中。我们称之为modmain。Visual Basic中的所有模块都是以关键字End结束的,从而我们会看到End Module。这是VB在语法上不区别于C#的地方——C#不理解模块是什么。
在VB.NET中,函数被称为子程序。我们需要子程序来标注程序执行的开始位置。这个子程序被称为Main。
VB.NET代码不仅关联到mscorlib.dll,还使用了文件Microsoft.VisualBasic。
在IL中会创建一个名为_vbProject的类,因为在VB中类的名称不是必须的。
称为_main的函数是子函数的开始,因为它具有entrypoint伪指令。它的名称前面有一个下划线。这些名称是由VB编译器选择用来生成IL代码的。
这个函数会传递一个字符串数组作为参数。它具有一个自定义伪指令来处理元数据的概念。
接下来,我们具有这个函数的完整原型,以一系列可选的字节作为终结。这些字节是元数据规范中的一部分。
模块modmain被转换为一个具有相同名称的类。和之前一样,这个类还具有相同的伪指令.custom和一个Main函数。该函数使用了名为.locals的伪指令在栈上创建一个只能在这个方法中使用变量。这个变量只存在于方法执行期间,当方法停止运行时,它就会“消亡”。
字段还存储在内存中,但是需要更长的时间来为它们分配内存。关键字init表示在创建期间,这些变量应该被初始化为它们的默认值。默认值依赖于变量的类型。数值总是被初始化为值ZERO。关键字init之后是这些变量的数据类型和它的名称。