我们用C#、VB.NET语言编写的代码最终都会被编译成程序集或IL。因此用VB.NET编写的代码 可以在C#中修改,随后在COBOL中使用。因此,理解IL是非常有必要的。
一旦熟悉了IL,理解.NET 技术就不会有障碍了,因为所有的.NET语言都会编译为IL。IL是一门中性语言。IL是先发明的,随后才有 了C#、VB.NET等语言。
我们将在一个短而精辟的程序中展示IL。我们还假设读者至少熟悉一 门.NET语言。
a.il
.method void vijay()
{
}
随后,我们用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
.method void vijay()
{
.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
.method void vijay()
{
.entrypoint
ret
}
“函数结束”指令被称为ret。前面所有的函数都必须以这个指令作为结束。
Output
Exception occurred: System.BadImageFormatException: Exception from HRESULT: 0x8007000B. Failed to load C:\IL\A.EXE.
在执行这个程序时,我们再次得到了相同的错误。这次我们的问题又在哪里呢?
a.il
.assembly mukhi {}
.method void vijay()
{
.entrypoint
ret
}
错误在于我们忘记在名称后面使用必不可少的伪指令assembly。我们将其合成在上面的 代码中,并在一对空的花括号之后使用了名称mukhi。这个程序集伪指令用于给出程序的名称。它又被称 为一个部署单元。
上面的代码是可以汇编而没有任何错误的最小的程序,虽然它在执行时并没有 做什么有用的事情。它没有任何名为Main的函数。它只有一个带有entrypoint伪指令的函数vijay。现在 汇编这个程序并运行而根本不会有任何错误。
在.NET中,程序集的概念是极其重要的,应该对其 有彻底的认识。我们将在本章后半部分使用这个伪指令。
a.il
.assembly mukhi {}
.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
.assembly mukhi {}
.method void vijay()
{
ret
.entrypoint
}
.entrypoint伪指令需要被定位为函数中的第一个指令或最后一个指令。它仅出现在函数 体中,从而将它的状态宣布为第一个被执行的函数。伪指令不是程序集指令,甚至可以被放置在任何ret 指令之后。提醒你一下,ret表示函数代码的结束。
a.il
.assembly mukhi {}
.method void vijay()
{
.entrypoint
call void System.Console::WriteLine()
ret
}
我们可能有一个用C#、VB.NET编写的函数,但是在IL中执行这个函数的机制是相同的。 如下所示:
我们必须使用汇编指令调用。调用指令之后,按照给定的顺序,为以下详细内容:
函数的返回类型(void)
命名空间(System)
类 (Console)
函数名称 (WriteLine())
函数被调用但不会生成任何输出。因为,我们传递一个参数到WriteLine函数中。
a.il
.assembly mukhi {}
.method void vijay()
{
.entrypoint
call void System.Console::WriteLine(class System.String)
ret
}
上面的代码有一处“闪光点”。当一个函数在IL中被调用时,除了它的返回 类型之外,被传递的参数的数据类型,也必须被指定。我们将Writeline设置为——希望得到 一个System.String类型作为参数,但是由于没有字符串被传递到这个函数中,所以它会生成一个运行时 错误。
因此,在调用一个函数时,在IL和其他程序语言之间有一个明显的区别。在IL中,当我们 调用一个函数,我们必须指定关于该函数我们所知道的任何内容,包括它的返回类型和它的参数的数据类 型。通过在运行期间进行恰当的检查,保证了汇编器能够在语法上验证代码的有效性。
现在我们 将看到如何将参数传递到一个函数中。
a.il
.assembly mukhi {}
.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
.assembly mukhi {}
.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 zzz
{
}
让我们引进称为class的IL指令:
a.il
.assembly mukhi {}
.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
.assembly mukhi {}
.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
.assembly mukhi {}
.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
.module aa.exe
.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
class zzz
{
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
// Microsoft (R) .NET Framework IL Disassembler. Version 1.0.2204.21
// 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
Public Module modmain
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
// Microsoft (R) .NET Framework IL Disassembler. Version 1.0.2204.21
// 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之后是这些变量的数据类型和它的名称。