CLR via C# 摘要二:IL速记

最简单的IL程序

.assembly test {}
.method void Func()
{
	.entrypoint
	ldstr "hello world"
	call void [mscorlib]System.Console::WriteLine(string)
	ret
}

对上面的程序说明如下:

  • .assemble伪指令用来声明程序集,该关键字是必须的
  • .method伪指令用来申明方法
  • .entrypoint伪指令用来表示程序的入口函数(方法),一个程序只能有一个入口,且不能放在实例方法中
  • ldstr指令是 load a string on stock 的缩写,表示把一个字符串入栈
  • call指令用来调用方法,参数取值栈顶。[mscorlib]表示调用方法所在程序集,类名和方法名之间用::隔开。
  • ret指令表示方法返回,应该是 return 的缩写

打开VS20XX开发人员命令提示工具,输入如下命令可编译IL程序为可执行文件:

ilasm test.il

函数和方法

上面的例子中,入口“方法”Func不属于任何一个类,为了区别于实例方法和静态方法,下文中称之为“函数”,IL中既可以写方法也可以写函数,而且函数可以作为程序的入口点,有点类似C++。声明一个方法的语法如下:

.method [public|private|family|assembly|famorassem|famandassem|privatescope] [hidebysig] [static|instance] 返回值类型 方法名(参数类型, 参数类型2...)[il managed]
{
    //方法体
    ret
}

访问限制修饰符

  • public 等同于C#的public
  • private 等同于C#的private
  • family 等同于C#的protected
  • assembly 等同于C#的internal
  • famorassem fam是family的缩写,assem是assembly的缩写,等同于C#的protected internal
  • famandassem C#中没有对应的修饰符
  • privatescope 当前的module(一个程序集中可以包含多个module)中随处可访问

其他常见修饰符

  • hidebysig 相当于C#方法前面的new修饰符
  • static和instance 前者表示静态方法,后者表示实例方法

方法的调用

请看实例:

namespace ILTest
{
    public sealed class Program
    {
        public static void Main(string[] args)
        {
            int result = Program.Add(1, 2);
        }

        public static int Add(int a, int b)
        {
            return a + b;
        }
    }
}

IL:

.method public hidebysig static void Main(string[] args) cil managed 
{
	.maxstack 2
	.entrypoint
	.locals init (
		[0] int32 result
	)

	IL_0001: ldc.i4.1 //将第1参数入栈
	IL_0002: ldc.i4.2 //将第2参数入栈
	IL_0003: call int32 ILTest.Program::Add(int32, int32) //调用方法Add,参数pop自栈顶
	IL_0008: stloc.0 //Add方法内已经将计算结果放到了栈顶,此处将栈顶数据赋值变量result
	IL_0009: ret //方法结束
} 

.method public hidebysig static int32 Add(int32 a, int32 b) cil managed 
{
	.maxstack 2
	.locals init (
		[0] int32 CS$1$0000
	)

	IL_0001: ldarg.0 //将第一个参数入栈
	IL_0002: ldarg.1 //将第二个参数入栈
	IL_0003: add //从栈中pop两个数,执行加法计算后将结果入栈
	IL_0004: stloc.0 //将栈顶数据pop并赋值给变量CS$1$0000
	IL_0005: br.s IL_0007 //跳转到行IL_0007

	IL_0007: ldloc.0 //将变量CS$1$0000入栈
	IL_0008: ret //方法结束,将返回值推送到调用者栈
}

上例可见,返回值在子方法中被入栈,父方法中再从栈顶出栈,方法的返回值通过栈来实现传递。

命名空间和类

下面是一个最简单的具有命名空间和类的例子:

.assembly test {}
.namespace test.com.joey
{
	.class Program
	{
		.method static void Main()
		{
			.entrypoint
			ldstr "hello world"
			call void [mscorlib]System.Console::WriteLine(string)
			ret
		}
	}
}

类的常见修饰符有如下这些:

  • public|private 相当于C#的public和Internal
  • abstract 表示抽象类
  • sealed 表示封闭类
  • ansi 类中字符串编码格式,可选项还有unicodeautochar(根据不同的平台,会自动转换成相对应的字符集)
  • auto 告诉运行时(CLR)自动为非托管内存中的对象的成员选择适当的布局,可选项还有sequentialexplicit

字段和构造函数

编写如下C#代码:

namespace ILTest
{
    public class Program
    {
        private int _var = 100;
        private static int _staticVar = 100;
        public static void Main(string[] args)
        {
        }
    }
}

编译为IL:
捕获.PNG-38.5kB
请看第5和第6行代码,.field伪指令用来声明字段,虽然C#代码中为这个两个字段赋了默认值,但是IL代码中并没有,真正的赋值操作是在默认构造函数和静态构造函数完成的。

请看从行号23开始的默认构造函数(.ctor),ldc.i4.s 100表示将100入栈(凡是ld开头的指令多是入栈指令),stfld int32 ILTest.Program::_var表示将栈顶数据赋值给字段_var(凡是st打头的指令多是赋值指令)。再看从39行开始的静态构造函数(.cctor),静态字段声明是赋值其实也是在静态构造函数里完成的。

在做一个有趣的实验,将上面的C#代码中添加构造函数如下:

public Program()
{
    _var = 200;
}

再看生产的IL代码中的构造函数:
无标题.png-16.7kB

_var变量居然被赋值了2次,2次!可见,C#编译器并未就这种情况作出优化,不过貌似没人会写这么傻瓜的代码吧,声明时赋值后又在无参构造函数中赋值。大家有兴趣可以试试静态变量,情况也是一样一样的。

还有一点需要注意,字段声明时赋值总是放在构造函数的最前面,然后是调用父类构造函数(第20行),最后才是自己写的代码,而静态构造函数的又在非静态构造函数之前执行,所有,实例化一个类时,正确的执行顺序就是这样的:

  1. 静态字段赋值(如果声明的同时就赋值的话)
  2. 静态构造函数
  3. 非静态字段赋值(如果声明的同时就赋值的话)
  4. 非静态父类构造函数
  5. 非静态子类构造函数

学了IL后妈妈再也不用担心我记不住类的实例化执行顺序了。

另外需要注意的地方是,如果C#类中没有无参构造函数,那么C#编译器会自动生成,但是静态构造函数就不会制动时生成了。

局部变量

编写C#代码如下:

namespace ILTest
{
    public class Program
    {
        public static void Main(string[] args)
        {
            int intVar = 100;
            float floatVar = 101f;
            String stringVar = "hello";

            Console.WriteLine(intVar);
            Console.WriteLine(floatVar);
            Console.WriteLine(stringVar);
        }
    }
}

编译为IL:
无标题.png-24.9kB

由上图可见,局部变量相关的步骤可分为3步:
第一步:编号
.locals init伪指令负责把局部变量从0开始编号(第14行),后面的代码中只使用这个编号,变量名称不再使用
第二步:赋值
ldc.i4.s 100(第21行)将100入栈,接下来stloc.0表示把栈顶值赋值给第0个变量
第三步:使用
ldloc.0指令表示把第0个变量入栈(第27行),接下来System.Console::WriteLine方法从栈顶得到该变量的值

条件判断与循环

IL没有提供ifwhile等循环,只有br跳转指令或brXXX等有条件跳转指令,C#编译器或将C#的条件判断和循环编译为br跳转指令,请看如下的例子:

public static void Main(string[] args)
{
    bool symbol = false;
    if (symbol)
    {
        Console.WriteLine("hello");
    }
}

IL:

.method public hidebysig static void Main (string[] args) cil managed 
{
	.maxstack 2
	.entrypoint
	.locals init (
		[0] bool symbol, //C#代码声明的变量
		[1] bool CS$4$0000 //编译器自动生成一个变量
	)

	IL_0001: ldc.i4.0 //把0入栈,0用来表示false
	IL_0002: stloc.0 //把0出栈并赋值给变量symbol
	IL_0003: ldloc.0 //把变量symbol的值0入栈
	IL_0004: ldc.i4.0 //把0入栈,0用来表示false
	IL_0005: ceq //对栈顶和栈中第二个数出栈并比较其是否相等,如果相等将1入栈,否则将0入栈
	IL_0007: stloc.1 //将栈顶元素出栈并赋值给变量CS$4$0000,该变量存储指令ceq执行的结果
	IL_0008: ldloc.1 //将变量CS$4$0000的值入栈,该变量存储指令ceq执行的结果
	IL_0009: brtrue.s IL_0018 //如果栈顶元素是1,那么跳转到IL_0018行

	IL_000c: ldstr "hello"
	IL_0011: call void [mscorlib]System.Console::WriteLine(string)

	IL_0018: ret
} 

循环语句亦是如此,不在赘述。

常用指令备忘

上面所说的是IL中最为基础的部分,如果要想再深入学习,只需编写C#代码并编译为IL查看即可,有了上面的基础应该可以看懂了。下面列举一些常用的IL指令以供不时之查:

指令 说明
Add
Sub
Mul
Div
Rem 取余
Xor 按位异或
And 按位与
Or 按位或
Not 按位补
Dup 复制计栈顶端值,然后将副本入栈
Neg 按位反
Ret 从当前方法返回,并将返回值(如果存在)从子方法栈推送到调用方法的栈上
Jmp 退出当前方法并跳至指定方法
Newobj 创建对象新实例,并将对象引用推送到栈上
Newarr 创建数组,并将数组引用推送到栈上
Nop Debug模式下生成,断点设置辅助
Initobj 将位于指定地址的值类型的每个字段初始化为空引用或适当的基元类型的 0
Isinst 测试对象引用是否为特定类的实例
Sizeof
Box 装箱
Unbox 拆箱
Castclass 类型转换
Switch 实现跳转表
Throw 引发异常
Call 调用静态方法
Callvirt 调用实例方法或虚方法

参考资料

(翻译) 《C# to IL》
Introduction to IL Assembly Language
IL汇编语言介绍(译)
30分钟?不需要,轻松读懂IL

原文地址:https://www.cnblogs.com/zzy0471/p/5943332.html