IL 汇编语言入门






4.95/5 (131投票s)
开始使用 IL 汇编语言进行低级别代码调试,并了解 .NET 如何处理你的高级代码
引言
本文介绍 IL 汇编语言的基础知识,可用于低级别调试你的 .NET 代码(用任何 .NET 高级语言编写)。低级别,我指的是你的高级语言编译器完成工作后的那个阶段。此外,利用这些基础知识,你可以计划编写自己的新 .NET 语言编译器。
目录
- IL 汇编语言入门
- 求值栈
- IL 数据类型
- 变量声明
- 判断/条件
- 循环
- 定义方法
- 按引用传递变量
- 创建命名空间和类
- 对象作用域(成员访问修饰符)
- 创建和使用类对象
- 创建构造函数和属性
- 创建 Windows 窗体(框架)
- 错误和调试
- 摘要
- 结论
当你使用 .NET 编译代码时,无论你选择哪种语言,它都会被转换为中间语言(IL),也称为 Microsoft 中间语言或通用中间语言。你可以将 IL 想象成 Java 语言生成的字节码。如果你想了解 .NET 如何处理数据类型,以及你编写的代码如何被转换为 IL 代码等,那么了解 IL 将为你带来巨大的优势。这些优势可能包括了解 .NET 编译器发出的代码。因此,如果你了解 IL,你就可以检查编译器发出的代码并进行必要的修改(尽管在大多数情况下并不需要)。你可以更改 IL 代码以进行必要的修改(这可能是你的高级语言不允许的),以提高代码的性能。这也有助于你进行低级别代码调试。此外,如果你打算为 .NET 编写编译器,那么理解 IL 是必要的。
IL 本身是二进制格式,意味着人类无法阅读。但正如其他二进制(可执行)代码有汇编语言一样,IL 也有一个称为 IL 汇编(ILAsm)的汇编语言。IL 汇编拥有与原生汇编语言相似的指令。例如,要将两个数字相加,你会使用 add 指令,要减去两个数字,你会使用 sub 指令,等等。显而易见,.NET 运行时(JIT)无法直接执行 ILAsm。如果你用 ILAsm 编写了某些代码,那么你必须首先将其编译成 IL 代码,然后 JIT 才会负责运行这段 IL 代码。
注意:请注意,IL 和 IL 汇编是两回事。当我们谈论 IL 时,指的是 .NET 编译器发出的二进制代码,而 ILAsm 指的是 IL 汇编语言,它不是二进制形式。
注意:请注意,我假定你非常熟悉 .NET(包括任何高级语言)。在本文中,我不会深入探讨所有细节,只介绍我认为需要解释的部分。如果有什么让你困惑,可以联系我进一步讨论。
IL 汇编语言入门
现在让我们开始本文的主要目的,介绍 IL 汇编。ILAsm 拥有与原生汇编语言相同的指令集。你可以使用任何文本编辑器(如记事本)用 ILAsm 编写代码,然后使用 .NET 框架提供的命令行编译器(ILAsm.exe)进行编译。对于那些只从事高级语言的程序员来说,ILAsm 是一个艰巨的任务,但 C 或 C++ 的程序员可能会很容易适应。这是一个艰巨的任务,所以我们不应浪费时间。在 IL 汇编中,我们必须手动完成所有事情,例如将值推入栈,管理内存等。你可以将 ILAsm 想象成汇编语言,但那个汇编语言处理的是原生 Windows 可执行文件,而这个汇编(ILAsm)处理的是 .NET 可执行文件,而且这个汇编也稍微容易一些,并且是面向对象的。
那么,让我们用我们的第一个示例程序开始这个语言的学习,这个程序将在屏幕(控制台)上打印一个短语。每种语言的开始都包含一个“hello world”程序,这是一种传统,所以我们也这样做,但短语有所改变。
//Test.IL
//A simple programme which prints a string on the console
.assembly extern mscorlib {}
.assembly Test
{
.ver 1:0:1:0
}
.module test.exe
.method static void main() cil managed
{
.maxstack 1
.entrypoint
ldstr "I am from the IL Assembly Language..."
call void [mscorlib]System.Console::WriteLine (string)
ret
}
将以上代码(图 1.1)保存在记事本等简单文本编辑器中,并另存为 Test.il。现在,让我们先编译并运行这段代码,然后再详细介绍。要编译代码,请在命令提示符下键入以下内容:
ILAsm Test.il (See the screen shot below)
ILAsm.exe 是 .NET Framework 附带的命令行工具,位于 <windowsfolder>\Microsoft.NET\Framework\<version> 文件夹中。你可以将此路径添加到你的路径环境变量中。当你完成 .IL 文件的编译后,它将输出一个与 .IL 文件同名的 EXE 文件。你可以使用 /OutPut=<filename>
开关指定输出文件名,例如 ILAsm Test.il /output=MyFile.exe。要运行输出的 EXE 文件,只需键入 EXE 文件名并按回车键。输出将在屏幕上显示。现在,让我们花点时间理解我们写了什么。请记住,在描述代码时,我指的是图 1.1。
- 前两行(以 // 开头)是注释。在 ILAsm 中,你可以像 C# 或 C++ 那样注释。要注释多行或行的一部分,你还可以使用 /* ... */ 块。
- 接下来,我们指示 ILAsm 导入名为
mscorlib
的库(.assembly extern mscorlib {}
)。在 ILasm 中,每个以句点(.)开头的语句都表示该语句是特殊指令(或指令)。因此,这里的.assembly
指令表示我们将使用一个外部库(即不是我们在代码中编写的,而是预先编译好的)。 - 下一个
.assembly
指令定义了我们文件的程序集信息(.assembly Test ....
)。在上面的示例中,我们将“Test
”作为程序集名称,并在括号内提供了一些关于输出程序集的信息。即版本信息。我们可以在这个块中提供更多关于程序集的信息,如公钥等。 - 下一个指令告诉我们程序集的模块名称(
.module Test.exe
)。我们知道,每个程序集必须至少有一个模块。 - 接下来(
.method static void main () cil managed
),.method
指令表示我们将定义一个方法,它是一个static
方法(与 C# 中的static
关键字相同),并且不返回任何值(void
)。此外,方法名为main
,并且不接受任何参数(因为括号内为空)。最后的cil
managed 指示编译器将其编译为托管代码。 - 进入方法内部,第一条指令是
maxstack
(.maxstack 1
)。这是一个重要的事情,它声明了我们将在内存(实际上是求值栈)中加载的最大项目数。稍后我们将详细讨论这一点。如果你对此不清楚,暂时跳过。 .entrypoint
指令告诉编译器将此方法标记为应用程序的入口点,即程序的第一个函数,执行将从这里开始。- 下一条语句(ldstr "I am from the IL Assembly Language..."),ldstr 指令用于将字符串加载到内存或求值栈中。在利用值之前,必须将其加载到求值栈中。我们很快就会详细讨论求值栈。
- 下一条语句(
call void [mscorlib]System.Console::WriteLine (string)
)调用(调用)一个驻留在 mscorlib 库中的方法。请注意,我们提供了该方法的完整签名,包括返回类型、参数类型以及它驻留在哪个库中。我们传递了一个string
作为参数,它不是一个变量,而是一种数据类型。上一条语句(ldstr “I am from the IL…..”
)将string
加载到栈中,而这个方法则使用同一个string
来打印。 - 最后一条语句
ret
,虽然无需解释,但指示从方法返回。
通过阅读以上几行,你可能已经对如何在 ILAsm 语言中编写代码有了一定的了解。而且你可能已经知道 ILAsm 与高级 .NET 语言(VB、C#)不同。无论你写什么代码,你都必须遵循这种结构(或者在处理类时稍微改变)。现在,有几件事情需要进一步讨论。主要是求值栈,让我们先来处理这个。
求值栈
求值栈可以看作是普通的机器栈,是语句执行前用于存储信息的栈。我们知道,当我们需要对信息进行操作时,信息会存储在内存中。就像在汇编语言中,我们在调用某个指令/中断之前将值移入寄存器一样。同样,在处理(在上面的例子中是输出到屏幕)信息之前,我们需要将信息(在上面的例子中是 string
)移入栈。在我们的 main
方法(图 1.1)开始时,我们通知 .NET 运行时,在方法执行过程中我们需要存储一些信息。我们说我们将使用 maxstack
指令一次只将一个值移入栈。因此,如果我们写指令为 .maxstack 3,
,那么运行时将为栈创建一个可以随时使用的三个值的空间。请注意,这并不意味着在方法的整个生命周期中我们只能将三个值加载到栈中,而是意味着我们一次最多可以将三个值移入栈。因为当处理完成时,值会从栈中移除。还应该注意的是,每当调用(调用)函数时,在函数中使用到的值都会从栈中移除,栈空间会被清空。垃圾回收器在 .NET 中就是这样工作的。此外,栈没有限制我们可以移动特定类型的数据。我们可以随时将任何类型的数据(如 string
、integer
、objects
等)移入栈。
让我们再举一个例子,它将更清楚地阐明求值栈的概念。
//Add.il
//Add Two Numbers
.assembly extern mscorlib {}
.assembly Add
{
.ver 1:0:1:0
}
.module add.exe
.method static void main() cil managed
{
.maxstack 2
.entrypoint
ldstr "The sum of 50 and 30 is = "
call void [mscorlib]System.Console::Write (string)
ldc.i4.s 50
ldc.i4 30
add
call void [mscorlib]System.Console::Write (int32)
ret
}
main
方法以上的部分与我们的第一个例子相同。只有模块名称被更改了。需要讨论的是 main
方法中的 .maxstack 2
,它指示运行时在内存中分配足够的空间,以便我们可以保存两个值。然后,我们加载了一个 string
并将其打印出来。接下来,我们同时加载了两个整数(使用 ldc.i4.s
和 ldc.i4
指令),并发出 add
语句,最后打印了一个整数类型的值。Add
语句会在栈中查找两个值,如果找到,它会相加并将结果存储在栈顶。在 add
语句之后,有一个名为 Write
的方法,它会在控制台写入一些内容。此方法要求栈顶必须存储有值。在这种情况下,它会查找整数类型。如果找到整数值,它会打印出来,否则会引发错误。
不要混淆 ldc.i4.s
和 ldc.i4
,两者都表示整数数据类型,但前者是单字节,后者是四字节。
我希望你已经理解了求值栈的使用方式以及它是如何工作的。现在,让我们继续讨论 ILAsm 语言的更多内容。
IL 数据类型
学习任何语言,我们首先要讨论该语言使用的数据类型。这里也是一样。让我们看下面的表格(图 1.4)了解 IL 汇编的数据类型。但在进入表格之前,我想指出一点,即 .NET 中不同语言的数据类型定义并不一致。例如,VB .NET 中的整数(32 位)用 Integer 定义,而在 C# 和 VC++ 中是 int
;尽管在两种情况下都代表 System.Int32
。此外,我们需要记住它是否符合通用语言规范(CLS)。例如,VB .NET 不识别 UInt32
,而且它也不符合 CLS。
好了,让我们开始记住这个为 ILAsm 语言提供数据类型新名称的表格。
IL 名称 | .NET 基类型 | 含义 | CLS 合规 |
Void | | 无数据,仅用作返回类型 | 否 |
Bool | System.Boolean | 布尔值 | 否 |
Char | System.Char | 字符值(16 位 Unicode) | 否 |
int8 | System.SByte | 单字节整数(有符号) | 否 |
int16 | System.Int16 | 双字节整数(有符号) | 否 |
int32 | System.Int32 | 四字节整数(有符号) | 是 |
int64 | System.64 | 8 字节整数(有符号) | 是 |
native int | System.IntPtr | 有符号整数 | 是 |
unsigned int8 | System.Byte | 单字节整数(无符号) | 否 |
unsigned int16 | System.UInt16 | 双字节整数(无符号) | 否 |
unsigned int32 | System.UInt32 | 四字节整数(无符号) | 否 |
unsigned int64 | System.UInt64 | 八字节整数(无符号) | 是 |
native unsigned int | System.UIntPtr | 无符号整数 | 是 |
Float32 | System.Single | 四字节浮点数 | 否 |
Float64 | System.Double | 八字节浮点数 | 否 |
object | System.Object | 对象类型值 | 是 |
& | | 托管指针 | 是 |
* | System.IntPtr | 非托管指针 | 是 |
typedef | System.Typed Reference | 特殊类型,用于存储数据并明确指示数据的类型。 | 是 |
数组 | System.Array | 数组 | 是 |
字符串 | System.String | 字符串类型 | 是 |
我们还有一些 ILAsm 中的数据类型助记符,例如 .i4
、.i4.s
、.u4
等,如我们在上面的例子中使用的一样。上面列出的类型是 ILAsm 识别的类型,并且表格还指出了哪些类型符合 CLS 标准,哪些不符合。因此,考虑到以上类型,我们可以这样调用任何函数:
call int32 SomeFunction (string, int32, float64<code lang=msil>)
这意味着,函数 SomeFunction
返回 int32
(System.Int32
)类型的值,并接受三个类型分别为 string
(System.String
)、int32
(System.Int32
)和 float64
(System.Double
)的值。请注意,这些是 CLR 和 ILAsm 的基本数据类型。如果我们对处理非基本数据类型(用户定义)感兴趣,我们可以这样做:
//In C#
ColorTranslator.FromHtml(htmlColor)
//In ILAsm
call instance string [System.Drawing]
System.Drawing.ColorTranslator::ToHtml(valuetype
[System.Drawing]System.Drawing.Color)
请注意,我们显式定义了参数类型。我们还定义了类型所在的命名空间以及一个 value-type
关键字,该关键字表示我们即将引用任何非基本数据类型。
在下一节中,当我们编写一个处理类型的示例程序时,事情会变得更清晰。但首先,让我们看一下语言的基础知识,如变量声明、循环、条件等。
变量声明
变量是任何编程语言的重要组成部分,因此,ILAsm 也为我们提供了声明和使用变量的方法。虽然不像高级语言(VB .NET、C#)那样简单,但可以使用 .locals
指令来声明变量。此指令通常应出现在任何方法的开头,但你也可以在任何地方放置声明,但显然要在使用它们之前。以下是一个示例,可以声明变量,设置值,然后使用它们进行打印。
.locals init (int32, string)
ldc.i4 34
stloc.0
ldstr "Some Text for Local Variable"
stloc.1
ldloc.0
call void [mscorlib]System.Console::WriteLine(int32)
ldloc.1
call void [mscorlib]System.Console::WriteLine(string)
我们使用 .locals
指令声明了两个变量,一个类型为 int32
,另一个类型为 string
。然后,我们将值 34(类型为 int32
)加载到内存中,并将其赋给局部变量零,即第一个变量。请注意,在 ILAsm 中,变量可以通过其索引(定义顺序)进行访问,这些编号从零开始。然后,我们将一个 string
加载到内存中,并将其赋给第二个变量。最后,我们打印了两个变量。ldloc.?
可用于将任何类型的变量值加载到内存中(整数、double float
或对象)。
我在这里没有使用变量名。因为它们是局部的,并且我们不打算将它们暴露在方法之外。但这并不意味着你不能按名称声明变量。当然可以。要声明局部变量,你可以像 C# 那样为变量命名,并加上其数据类型,例如 .locals init (int32 x, int32 y)
。
之后,你可以使用相同的语句加载或设置这些变量的值,但使用变量名,如 stloc x
和 ldloc y
。尽管你已经使用名称声明了变量,但仍然可以通过它们的编号访问它们,例如 ldloc.0
、stloc.0
等。注意:在本文的所有代码中,我使用的是没有名称的变量。
现在你已经了解了如何处理变量和栈。如果你遇到任何问题,请回顾上面的代码,因为从现在开始,我们将进行一些棘手的任务,与栈打交道。我们将频繁地将数据移入内存并取回。因此,对初始化变量、设置变量值以及从变量加载值到栈有良好的理解是必要的。
判断/条件
判断或条件是任何编程语言的其他必要组成部分。在低级语言(如原生汇编语言)中,判断是通过跳转(或分支)完成的。ILAsm 也是如此。看看下面的代码片段:
br JumpOver //Or use the br.s instead of br
//Other code here which will be skipped after getting br statement.
//
JumpOver:
//The statements here will be executed
将此语句与任何高级语言中的 goto
语句进行比较,它会将控制权转移到 goto
语句之后的标签。但在这里,我们使用 br
而不是 goto
。如果你确定目标在 br
语句的 -128 到 +127 字节范围内,也可以使用 br.s
,因为它将使用 int8
而不是 int32
作为分支偏移量。上面的方法是无条件分支,因为在 br
语句之前没有评估条件,代码总是会跳转/分支到 JumpOver
标签。让我们看一个代码片段,它可以描述条件分支的使用,即通过某个逻辑测试进行跳转(或分支)。
//Branching.il
.method static void main() cil managed
{
.maxstack 2
.entrypoint
//Takes First values from the User
ldstr "Enter First Number"
call void [mscorlib]System.Console::WriteLine (string)
call string [mscorlib]System.Console::ReadLine ()
call int32 [mscorlib]System.Int32::Parse(string)
//Takes Second values from the User
ldstr "Enter Second Number"
call void [mscorlib]System.Console::WriteLine (string)
call string [mscorlib]System.Console::ReadLine ()
call int32 [mscorlib]System.Int32::Parse(string
)
ble Smaller
ldstr "Second Number is smaller than first."
call void [mscorlib]System.Console::WriteLine (string)
br Exit
Smaller:
ldstr "First number is smaller than second."
call void [mscorlib]System.Console::WriteLine (string)
Exit:
ret
}
上面的程序从用户那里获取两个值,然后检查较小的数字。需要注意的语句是“ble Smaller”,它指示编译器检查栈中的第一个值是否小于或等于第二个值,如果是,则应该分支到 Smaller 标签。如果不是,则不会发生分支,将执行下一条语句,即加载一个 string
然后将其打印出来。之后,发生了一个无条件分支,这是必要的,因为如果它不在这里,根据程序流程,将执行 Smaller 标签之后的语句。因此,我们发出了“br Exit
”,这导致程序分支到 Exit 标签并执行了 ret 语句。
其他条件包括 beq
(==)、bne
(!= )、bge
(>= )、bgt
(>)、ble
(<= )、blt
(<) 以及 brfalse
(如果栈顶元素为零)和 brtrue
(如果栈顶元素不为零)。你可以使用任何一个来执行代码的某一部分并跳过其他部分。正如我之前提到的,ILAsm 没有像高级语言那样的便利。所以,如果你仍然打算用 ILAsm 编写代码,一切都应该自己完成。
循环
语言基础的另一个部分是循环。循环只不过是重复执行同一代码块。它实际上涉及到依赖于称为循环索引的变量的值进行分支。同样,你需要查看代码并花费一点时间来理解循环是如何工作的。
.method static void main() cil managed
{
//Define two local
variables .locals init (int32, int32)
.maxstack 2
.entrypoint
ldc.i4 4
stloc.0 //Upper limit of the Loop, total 5
ldc.i4 0
stloc.1 //Initialize the Starting of loop
Start:
//Check if the Counter exceeds
ldloc.1
ldloc.0
bgt Exit //If Second variable exceeds the first variable, then exit
ldloc.1
call void [mscorlib]System.Console::WriteLine(int32)
//Increase the Counter
ldc.i4 1
ldloc.1
add
stloc.1
br Start
Exit:
ret
}
而在 C# 等高级语言中,相同的代码可能看起来像这样:
for (temp=0; temp <5; temp++)
System.Console.WriteLine (temp)
让我们检查一下代码。首先,我们声明了两个局部变量,并将第一个变量初始化为 4,第二个变量初始化为零。真正的循环从 Start
标签开始,在那里我们首先检查循环计数器(变量 2,ldloc 1
)是否超过循环的上限(变量 1,ldloc 0
),如果是这种情况,程序将跳转到 Exit
标签,这将导致程序终止。如果不是这种情况,则将在屏幕上打印值,并将变量递增 1,代码将跳转到 Start
标签再次执行检查计数器是否超过上限。这就是 ILAsm 中循环的工作方式。
定义方法
我们已经了解了判断(条件或分支)、循环以及变量声明。现在是时候看看如何在 ILAsm 中创建方法了。ILAsm 中声明方法的方式几乎与 C# 或 C++ 相同,我希望你现在已经猜到了,但有一些小改动。所以,让我们先看代码片段,然后讨论我们做了什么。
//Methods.il
//Creating Methods
.assembly extern mscorlib {}
.assembly Methods
{
.ver 1:0:1:0
}
.module Methods.exe
.method static void main() cil managed
{
.maxstack 2
.entrypoint
ldc.i4 10
ldc.i4 20
call int32 DoSum(int32, int32)
call void PrintSum(int32)
ret
}
.method public static int32 DoSum (int32 , int32 ) cil managed
{
.maxstack 2
ldarg.0
ldarg.1
add
ret
}
.method public static void PrintSum(int32) cil managed
{
.maxstack 2
ldstr "The Result is : "
call void [mscorlib]System.Console::Write(string)
ldarg.0
call void [mscorlib]System.Console::Write(int32)
ret
}
一个简单的程序,它将两个数字相加(预定义,为了代码简单)并打印结果。我们在这里定义了两个方法。请注意,这两个方法都是 static
的,因此我们可以直接使用它们,而无需创建任何实例。首先,我们将两个数字加载到栈中,并调用第一个方法 DoSum
,该方法期望在栈中有两个 int32
值。进入函数体,正如声明与 main 相同,并且我们已经多次看到,我们再次定义了 maxstack
,但请注意,我们没有包含 .entrypoint
指令,因为一个程序只能有一个入口点,并且在上面的例子中,我们将 main 方法声明为入口点。ldarg.0
和 ldarg.1
指令使运行时将参数加载到求值栈中,即传递给方法的参数。然后,我们使用 add
语句简单地将它们相加,然后方法返回。请注意,该方法返回一个 int32
类型的值。那么它应该返回哪个值?当然是 add
语句完成工作时栈中可用的值。从这里,控制权将返回到 main 方法,然后从那里调用另一个方法 PrintSum
。PrintSum
也需要一个 int32
类型的值。现在应该注意的是,DoSum
方法返回了一个 int32
类型的值,并且该值在求值栈中;与此同时,我们调用了 PrintSum
方法,该方法也正在查找一个 int32
类型的值。因此,从 DoSum
方法返回的值将被传递给 PrintSum
,而在 PrintSum
中,它首先在屏幕上打印一个简单的字符串,然后使用 ldarg.0
加载参数,然后也打印出来。
以上方式表明,在 ILAsm 中创建方法并非难事。是的,实际上是这样。但方法确实可以通过引用传递值。所以,让我们也来看看。
按引用传递变量
IL 也支持按引用传递值,当然应该如此,因为 .NET 中的高级语言支持按引用传递参数,而高级语言的代码会被转换为 IL 代码,我们讨论的是产生相同 IL 代码的 IL 汇编语言。当我们按引用传递任何变量时,传递的是存储该变量值的内存位置的地址,而不是按值传递的方法,后者将值的副本传递给函数。让我们看一个按引用方法在 IL 汇编中如何工作的例子。
.method static void main() cil managed
{
.maxstack 2
.entrypoint
.locals init (int32, int32)
ldc.i4 10
stloc.0
ldc.i4 20
stloc.1
ldloca 0 //Loads the address of first local variable
ldloc.1 //Loads the value of Second Local Variable
call void DoSum(int32 &, int32 )
ldloc.0
//Load First variable again, but value this time, not address
call void [mscorlib]System.Console::WriteLine(int32)
ret
}
.method public static void DoSum (int32 &, int32 ) cil managed
{
.maxstack 2
.locals init (int32)
//Resolve the address, and copy value to local variable
ldarg.0
ldind.i4
stloc.0
ldloc.0
//Perform the Sum
ldarg.1
add
stloc.0
ldarg.0
ldloc.0
stind.i4
ret
}
上面的例子中有趣的是使用了一些新指令,例如 ldloca
,它将变量的地址加载到栈中,而不是值。在 main
方法中,我们声明了两个变量(local
)并将它们分别赋值为 10 和 20。然后,我们将第一个变量的地址加载到内存中,并将第二个变量的值也加载进来,然后调用了 DoSum
方法。如果你查看 DoSome
方法签名(调用),你会注意到一件事,即我们在第一个 int32
(参数)前面使用了 &,这表示栈中包含的是内存引用而不是值,并且我们有兴趣按引用传递变量。同样,DoSome
方法声明的第一个参数也包含相同的 &,这当然也说明变量将按引用传递。因此,一个变量按引用传递,第二个按值传递(正常)。现在的问题是如何解析地址到值,以便我们可以对值执行某些操作,然后在必要时将其设置回变量。出于这个原因,我们将第一个参数加载到栈中(它实际上包含传递给该方法的原始变量的地址),然后调用 ldind.i4
语句,该语句通过从栈中获取地址来加载一个整数(32 位)的值(转到该地址,读取值并放入栈中)。我们将该值存储在一个局部变量中,以便可以轻松重用它(否则我们必须一次又一次地执行这些步骤)。然后,我们简单地将该局部变量和第二个参数(按值传递的)加载到栈中,并将它们相加,然后存储在同一个局部变量中。现在这里还有一个有趣的事情,那就是我们更改了代表第一个参数值(按引用传递的参数)的内存位置的值。我们通过首先将参数 0(按引用传递的参数)加载到栈中(这将实际加载传递给该方法的原始变量的地址),然后加载我们想要设置的值,最后使用 stind.i4
语句来完成,这个语句与我们上面使用的 ldind.i4
语句正好相反。它将栈中提供的内存位置的值设置好。为了测试更改后的值,我们在 main
方法中将其打印出来。请注意,DoSum
方法不返回任何值,在 main
方法中,我们简单地再次加载第一个局部变量(现在是值,不是内存引用),并使用 WriteLine
方法来打印它。
这就是 ILAsm 处理按引用传递变量的方式。到目前为止,我们已经了解了如何处理变量声明、条件、循环和方法(按值参数和按引用参数)。现在是时候使用 ILAsm 语言声明我们的命名空间和类了。
创建命名空间和类
是的,当然,在 ILAsm 中创建你的类和命名空间是可能的。实际上,在 ILAsm 中创建类或命名空间与在任何高级语言中一样简单。不相信吗?那么,让我们看看。
//Classes.il
//Creating
Classes
.assembly extern
mscorlib {} .assembly Classes
{ .ver 1:0:1:0 }
.module Classes.exe
.namespace HangamaHouse
{
.class public ansi auto Myclass extends [mscorlib]System.Object
{
.method public static void main() cil managed
{
.maxstack 1
.entrypoint
ldstr "Hello World From HangamaHouse.MyClass::main()"
call void [mscorlib]System.Console::WriteLine(string)
ret
}
}
}
我想,现在不需要再多解释代码了。事情很简单,发出 .namespace
指令,后跟名称 HangamaHouse
,是为了告诉编译器我们将创建一个名为 HangamaHouse
的命名空间。在命名空间块内,我们使用 .class
指令引入了一个新类,并提到该类是 public
的,并且它扩展(继承)自 System.Object
类。该类只包含一个 public
的 static
方法。其余部分的方法代码你都很熟悉了。
这里,我想提一下,如果你不指定任何继承,那么你创建的所有类都将继承自 Object
类。就像本例一样,我们显式地提到我们的类继承自 System
命名空间下的 Object
类。如果我们在这里不指定,它仍然会继承 Object
类。是的,如果我们的类继承自任何其他类,那么它将不会继承 Object
类(但你从中继承类的类可能继承自 Object
类)。
在上面的类创建中还使用了另外两个关键字:ansi 和 auto。Ansi 表示类中的所有 string
都应转换为 ANSI 字符串。其他选项是 unicode 和 autochar(根据平台自动确定转换)。第二个关键字 auto 指定运行时将自动选择对象成员在非托管内存中的布局。其他选项是 sequential(成员按顺序布局)和 explicit(布局显式定义)。有关更多信息,请参阅 MSDN 中的 StructLayout
或 LayoutKind
枚举。auto 和 ansi 是类的默认关键字,如果你不定义任何内容,这些将被自动假定。
对象作用域(成员访问修饰符)
下表总结了 ILAsm 中类的作用域。
ILAsm 名称 | 描述 | C# 名称 |
Public | 对类、命名空间和对象可见(全部) | public |
Private | 仅在类内部可见 | 私有的 |
Family | 仅对类和派生类可见 | 受保护的 |
assembly | 仅在同一程序集内可见 | internal |
familyandassem | 在同一程序集中的派生类可见 | N/A |
familyorassem | 对派生类和同一程序集中的类可见 | protected internal |
privatescope | 与 private 相同,但无法引用 | N/A |
还有一些可以与方法和字段(类中的变量)一起使用的作用域。你可以在 MSDN 中找到完整的列表。
创建和使用类对象
在本节中,我将向你展示如何在 ILAsm 中创建类实例并使用它。在此之前,你已经看到了如何在 ILAsm 中创建自己的命名空间和类。但创建某样东西是没有用的,除非我们能使用它。所以,让我们开始创建一个简单的类并使用它。
让我们用 ILAsm 创建自己的库。这个简单的库只包含一个 public
方法。也就是说,它将接收一个值并返回该值的平方。简单就是最好的理解。看看代码。
.assembly extern mscorlib {}
.assembly MathLib
{
.ver 1:0:1:0
}
.module MathLib.dll
.namespace HangamaHouse
{
.class public ansi auto MathClass extends [mscorlib]System.Object
{
.method public int32 GetSquare(int32) cil managed
{
.maxstack 3
ldarg.0 //Load the Object's 'this' pointer on the stack
ldarg.1
ldarg.1
mul
ret
}
}
}
注意:将以上代码编译为 DLL 文件。使用 ILAsm MathLib.il /dll
代码看起来很简单。它定义了一个名为 HangamaHouse
的命名空间,以及在该命名空间内一个名为 MathClass
的类,该类继承自 System
命名空间下的 Object
类,就像我们上面的类(图 1.10)一样。在该类中,我们定义了一个名为 GetSquare
的方法,该方法接受一个 int32
类型的参数。我们将 maxstack
设置为 3
,然后加载了参数零。然后,我们又加载了两次参数一。等等,我们在这里只接收一个参数,但我们加载了参数零和参数一(总共两个参数)。这怎么可能?是的,这是可能的。实际上,零参数(ldarg.0
)是“this
”指针的对象引用。因为每个实例对象总是传递对象的内存地址。所以我们实际的参数从索引 1 开始。好了,我们加载了两次参数 1,以便我们可以将它们相乘,我们使用 mul 指令完成了乘法。乘法结果存储在栈中,通过 ret 指令立即返回给调用方法。
构建库不是问题。简单但,现在让我们看看使用这个库的例子。
.assembly extern mscorlib {}
.assembly extern MathLib {.ver 1:0:1:0}
//
//rest code here
//
.method static void Main() cil managed
{
.maxstack 2
.entrypoint
.locals init (valuetype [MathLib]HangamaHouse.MathClass mclass)
ldloca mclass
ldc.i4 5
call instance int32 [MathLib]HangamaHouse.MathClass::GetSquare(int32)
ldstr "The Square of 5 Returned : "
call void [mscorlib]System.Console::Write(string)
call void [mscorlib]System.Console::WriteLine(int32)
ret
}
这个方法的前两行很简单。来看第三行,我们定义了一个 MathClass
类型的局部变量(来自 HangamaHouse 命名空间。请注意,我们已经像本文开头那样,使用 mscorlib 库导入了这个库(MathLib)。我们也提供了版本号,虽然这并非必需,因为我们从一开始就在使用外部库 mscorlib 而没有提供版本号。再次请注意,在创建我们将要创建的对象类型之前,我们写了 valuetype
,并提供了类(包括库名称)的完整签名。接下来,我们加载了局部变量 mclass 的地址。然后加载了值为 5 的整数,并调用了 MathClass
对象 mclass 的 GetSquare
方法。这是因为我们刚刚在前面几个语句中加载了 mlcass
对象。mclass
对象的内存引用在栈中可用。当我们调用 MathClass
的 GetSquare
方法时,它会在栈中查找对象的引用以及参数的值,如果找到,则使用该引用调用该方法。还有一件事你应该注意到,我们在调用方法时使用了 instance
关键字,而我们以前从未这样做过。Instance
关键字告诉编译器我们将调用任何对象的实例的方法,而不是 static
方法。执行完成后,GetSquare
方法返回一个 int32
类型的值,该值存储在栈中,我们将其与一个 string
一起用于在控制台打印。
因此,这里的主要事情是使用 .locals
指令和 valuetype
以及类的完整签名(包括库)来声明类的对象。其次,调用类中的方法,这是通过首先将类对象加载到内存中,然后加载将传递给方法的任何值,最后在调用方法时使用 instance
关键字来完成的。
同样,我们也可以在类中使用属性和构造函数,并在代码中调用它们。我的文章的下一节将描述如何在 ILAsm 语言中创建你的 private
字段、构造函数和属性,以及如何使用相同的 ILAsm 代码调用它们。
创建构造函数和属性
构造函数实际上是一个方法,在高级语言中,当创建某个类的对象时就会调用它。但在 ILAsm 等低级语言中,你必须手动调用它,尽管毫无疑问它是一个永远返回空的方法。下面的示例代码演示了如何创建构造函数。我只包含必要的部分,本文的源代码包含了此部分的完整代码。请在阅读本节时保持专注,因为本节将教你很多东西。
.class public MathClass extends [mscorlib]System.ValueType
{
.field private int32 mValue
//Other code goes here
.method public specialname rtspecialname
instance void .ctor() cil managed
{
ldarg.0
ldc.i4.s 15
stfld int32 HangamaHouse.MathClass::mValue
ret
}
//Other code goes here.
MathLib
类的构造函数你可能首先注意到的是,我继承了我的类于 System.ValyeType
而不是 Object。嗯,因为在 .NET 中,类实际上是一种类型,所以它总是继承自 ValueType
。尽管你可能已经继承了我的类于其他类,但如果你查看继承树,你最终会得到 ValueType
。是的,我在之前继承过我的类于 Object
类,但那不是描述这个的正确时机,而且那些不是完整的类,因为我们将在这里创建一个完整的类(带有构造函数、属性等)。如果你不想利用这些类的完整功能,那么你可以从任何地方继承你的类。
在类声明之后,我声明了一个名为 mValue
的 private
字段(高级语言中的 private
变量)。然后,我使用 .method
指令声明了一个构造函数。记住,构造函数是一个方法。现在你可能会惊讶地发现,使用的不是类名(如 C++ 等高级语言中的),而是 .ctor
。是的,.ctor
在 ILAsm 中代表构造函数方法。这是默认构造函数,因为它不接受任何参数。我们在这里所做的是,使用 ldarg.0
语句加载了对象本身的引用(this
),然后加载了一个值为 15 的整数,并将其赋值给我们的 private
字段 mValue
。stfld
语句可用于设置任何字段的值。我们提供了该字段的完整签名。我想你现在不应该惊讶为什么我们这样做。最后,我们从方法(构造函数)返回。
你还应该注意到,我们在声明这个构造函数时还使用了另外几个关键字。它们是 specialname
和 rtspecialname
。实际上,这些告诉语言运行时将此方法名视为特殊名称。你可以将其与构造函数和属性等一起使用。但它们不是必需的。
与高级语言不同,在 ILAsm 中构造函数不是自动调用的。你必须显式调用它。以下是一个调用构造函数来初始化类值的代码片段。
.method public static void Main() cil managed
{
.maxstack 2
.entrypoint
.locals init (valuetype [MathLib]HangamaHouse.MathClass mclass)
ldloca mclass
call instance void [MathLib]HangamaHouse.MathClass::.ctor()
上面的代码创建了一个名为 mclass 的局部变量,类型为 MathClass
,它驻留在 HangamaHouse
命名空间中。然后,它加载该变量的地址到栈中,然后调用构造函数(.ctor
方法)。如果你仔细观察,就会发现它与我们在 ILAsm 中调用普通方法的方式相同。没有任何区别。同样,你可以在创建新的 .ctor
方法时定义重载构造函数,该方法接受参数,并以相同的方式调用,就像我们调用这个一样。
至于属性,属性实际上也是方法。请看图,然后我们就能完全理解它了。
.method specialname public instance int32 get_Value() cil managed
{
ldarg.0
ldfld int32 HangamaHouse.MathClass::mValue
ret
}
.method specialname public instance void set_Value(int32 ) cil managed
{
ldarg.0
ldarg.1
stfld int32 HangamaHouse.MathClass::mValue
ret
}
//Define the property, Value
.property int32 Value()
{
.get instance int32 get_Value()
.set instance void set_Value(int32 )
}
你可以检查上面的代码,并且肯定地说它与方法代码相同。但你还可以看到另一件事:.property
指令。它在其主体中定义了两件事:属性 get
和属性 set
。这实际上将方法标记为属性的一部分,并将它们放在一个树下。因为我们可以看到上面定义了两个方法:get_Value
和 set_Value
。它们很简单,就像我们在本文中所看到的一样。调用任何属性也相当简单,因为我们已经看到它们就像方法一样工作,而它们实际上就是方法。
.maxstack 2 .locals
init (valuetype [MathLib]HangamaHouse.MathClass tclass)
ldloca tclass
ldc.i4 25
call instance void [MathLib]HangamaHouse.MathClass::set_Value(int32)
ldloca tclass
call instance int32 [MathLib]HangamaHouse.MathClass::get_Value()
ldstr "Propert Value Set to : "
call void [mscorlib]System.Console::Write(string)
call void [mscorlib]System.Console::WriteLine(int32)
GetSquare
不足为奇。我们创建了类的实例,然后调用了 set_Value
方法(它实际上是一个属性,我们将设置属性的值)。然后,为了确认,我们重新读取了该属性的值,并将其与一个 string
一起打印出来。
到目前为止,我们已经涵盖了很多可能有助于你开始使用 ILAsm 语言的内容。但还有一件必要的事情没有讲,那就是错误和调试。
创建 Windows 窗体(框架)
本节演示了如何将上面提供的信息结合起来创建一个简单的 GUI,即 Windows 窗体。在此应用程序中,我从 System.Windows.Forms.Form
类创建了一个简单的窗体。它不包含任何控件,但我更改了窗体的属性,如 BackColor
、Text
(标题)和 WindowState
。代码非常简单,一步接一步。所以,在结束我的文章之前,让我们来看看这个最终代码。
.namespace MyForm
{
.class public TestForm extends
[System.Windows.Forms]System.Windows.Forms.Form
{
.field private class [System]System.ComponentModel.IContainer components
.method public static void Main() cil managed
{
.entrypoint
.maxstack 1
//Create New Object of TestForm Class and Call the Constructor
newobj instance void MyForm.TestForm::.ctor()
call void [System.Windows.Forms]
System.Windows.Forms.Application::Run(
class [System.Windows.Forms]System.Windows.Forms.Form)
ret
}
这是我们应用程序的入口点。首先(在 MyForm
命名空间中创建 TestForm
类之后),我们定义了一个 IContainer
类型的局部变量(字段)。请注意,我们在定义字段类型之前提到了类。然后在 Main
方法中,我们使用 newobj
指令创建了 TestForm
的对象。然后我们调用了 Application.Run
方法来启动应用程序。如果你将其与高级语言代码进行比较,你会注意到它与那里使用的技术相同。现在,让我们看看我们类的 .ctor
方法(TestForm
)。
.method public specialname rtspecialname instance
void .ctor() cil managed
{
.maxstack 4
ldarg.0
//Call Base Class Constructor
call instance void [System.Windows.Forms]
System.Windows.Forms.Form::.ctor()
//Initialize the Components
ldarg.0
newobj instance void [System]System.ComponentModel.Container::.ctor()
stfld class [System]System.ComponentModel.IContainer
MyForm.TestForm::components
//Set the Title of the Window (Form)
ldarg.0
ldstr "This is the Title of the Form...."
call instance void [System.Windows.Forms]
System.Windows.Forms.Control::set_Text(string)
//Set the Back Color of the Form
ldarg.0
ldc.i4 0xff
ldc.i4 0
ldc.i4 0
call valuetype [System.Drawing]System.Drawing.Color
[System.Drawing]System.Drawing.Color::FromArgb(
int32, int32, int32)
call instance void [System.Windows.Forms]
System.Windows.Forms.Control::set_BackColor(
valuetype [System.Drawing]System.Drawing.Color)
//Maximize the Form using WindowState Property
ldarg.0
ldc.i4 2 //2 for Maximize
call instance void [System.Windows.Forms]
System.Windows.Forms.Form::set_WindowState(
valuetype [System.Windows.Forms]
System.Windows.Forms.FormWindowState)
ret
}
TestForm
的 .ctor 方法(构造函数)非常简单,我们只是调用了基类的 .ctor
方法(构造函数)。然后,我们创建了一个 Container
的对象,并将其赋值给了我们的组件对象(我们类的字段)。窗体初始化到此完成。现在,我们将设置我们新窗体的一些属性。首先,我们将设置我们窗体的标题(Text
属性)。我们加载了一个 string
到栈中,并调用了 Control
对象的 set_Text
方法(因为 Text
属性是从 Control 继承的)。设置完 Text
属性后,我们开始设置 BackColor
属性。我们调用了 FromArgb
方法,从 Reg
、Green
和 Blue
值中获取颜色。我们首先将这三个值加载到栈中,然后调用 Color.FromArgb
方法来获取 Color
类的对象,以便我们可以将其赋值给窗体的 BackColor
属性。我们以与之前设置属性相同的方式将其赋值给窗体的 BackColor
属性。最后,我们将窗体的 WindowState
属性设置为 Maximized。同样的方式,同样的方法。请注意,我们加载了一个整数值到栈中,并将其赋值给 FormWindowState
枚举,因为 Enum
实际上是具有预定义值的变量。
尽管创建 Windows 窗体的代码在这里结束了,但我们也可以定义 Dispose
事件(窗体的析构函数),以便我们可以通过释放不必要的对象来清理内存。如果你有兴趣了解 Dispose
事件的代码,请看这行下方。
.method family virtual instance void Dispose(bool disposing) cil managed
{
.maxstack 2
ldarg.0
ldfld class [System]System.ComponentModel.IContainer
MyForm.TestForm::components
callvirt instance void [mscorlib]System.IDisposable::Dispose()
//Call Base Class's Dispose Event
ldarg.0
ldarg.1
call instance void [System.Windows.Forms]
System.Windows.Forms.Form::Dispose(bool)
ret
}
Dispose
方法是重载的,因此它被声明为 virtual
。我们只是加载了对象的引用(this
),加载了组件字段,并调用了 IDisposable
的 Dispose
方法。然后我们调用了我们窗体的 Dispose
方法。就这样。
因此,创建用户界面并不是一件非常困难的任务(尽管有点)。从这里,你可以编写代码来向你的窗体添加文本框、标签等控件,还可以编写处理它们的事件的代码。你能做到吗?
错误和调试
错误是任何编程语言的一部分。所以 ILAsm 也不例外。与其他编程语言的错误一样,在 ILAsm 中你也可能会遇到编译器错误(通常称为语法错误)、运行时错误和逻辑错误。我不会详细介绍这些错误是什么,因为你们都很清楚。本节的目的是介绍一些可能有助于你调试程序的工具和技术。首先,你可以在编译代码时生成调试信息文件。只需在编译代码时使用 ILAsm.exe 的 /debug
开关,例如:
ILAsm.exe Test.il /debug
这将生成一个名为 Text.exe 的 EXE 文件,还有一个调试信息文件 Test.pdb。现在可以使用此文件以后调试你的应用程序。
你可以使用一个工具来验证你的应用程序(实际上是一个程序集)。那就是 PE Verify(peverify.exe),它是 .NET Framework SDK 附带的,默认情况下你可以找到它在 C:\Program Files\Microsoft .NET\Framework SDK\Bin 文件夹中。Pre Verify 工具不使用源代码来验证程序集,但它实际上会检查 EXE,以确定编译器是否发出了无效代码。在某些情况下,你可能需要验证你的程序集,例如你可能正在使用第三方编译器或编写自己的编译器。用法非常简单。以以下示例为例:
peverify.exe Test.exe
有关更多信息和选项,你可以使用 peverify.exe /?
查看 peverify.exe 的命令行开关。
你还可以使用 ILDasm.exe 从你任何一个已预编译的 EXE 或 DLL(在 .NET 中)中获取 IL 代码。ILDasm.exe 是 .NET Framework SDK 附带的另一个有价值的工具,可以帮助你进行低级别代码调试。如果你是用任何高级 .NET 语言编写的代码,并且想查看编译器生成的 IL 代码,这个工具会很有用。它位于与 peverify.exe 相同的文件夹下(Framework SDK\Bin 文件夹)。你可以用这种方式获取你任何一个 .NET EXE 文件的 IL 代码。
ILDasm.exe SomeProject.exe /out:SomeProject.il
还有一些其他工具可用于调试你的 .NET 应用程序,例如 DbgClr.exe, CorDbg.exe。你可以从 MSDN、.NET Framework SDK 查找它们的参考,或搜索网络查找第三方工具。
摘要
在本文中,我们学习了 ILAsm 的基础知识,并使用 ILAsm 语言编写了一些程序。我们从 ILAsm 的基础知识以及为什么需要它开始。然后,我们编写了一个打印一行文本到控制台的示例程序。我们学习了一点关于求值栈的知识,并通过一个简单的代码(相加数字)了解了它的工作原理。然后,我们收集了关于 IL 数据类型的知识,并使用它们来声明变量、应用条件、创建循环等。我们还定义了方法,然后切换到命名空间和类创建。在那里,我们创建了自己的类的对象并使用了它们。此外,我们为类创建了构造函数和属性。
结论
用 ILAsm 编写代码并非易事。因为本文中还有许多内容没有讨论,如数组、错误处理等。但在熟悉 ILAsm 后,你可以继续自己处理它们。如果你想进行低级别代码调试,或者你打算为 .NET 平台编写一些编译器,ILAsm 会很有帮助。如果你刚开始接触 .NET,我不建议你走这条路,因为它需要对 .NET 有很好的理解,但它会让你更清楚地了解 .NET 公共语言运行时在幕后是如何工作的。
关于作者
Sameers (theAngrycodeR) 于 2002 年 2 月获得了计算机科学硕士学位。他是 City Soft(巴基斯坦)的项目经理,并已开发和管理了多个项目。他在 Visual Basic 6 方面展现了专业知识,现在正在从事 VB .NET 工作。他是 CodeProject.com 和 Microsoft .NET 社区网站 GotDotNet 的多篇文章的作者。他还曾在 Planet-Source-Code.com 上提交过许多源代码。