[No0000152]C#基础之IL,轻松读懂IL

时间:2023-12-12 10:48:26

先说说学IL有什么用,有人可能觉得这玩意平常写代码又用不上,学了有个卵用。到底有没有卵用呢,暂且也不说什么学了可以看看一些语法糖的实现,或对.net理解更深一点这些虚头巴脑的东西。其实IL本身逻辑很清楚,主要是把指令的意思搞明白就好办了。记指令只要记住几个规律就好,我把它们分为三类。

第一类 :直观型

这一类的特点是一看名字就知道是干嘛的,不需要多讲,如下:

名称

说明

Add

将两个值相加并将结果推送到计算堆栈上。

Sub

从其他值中减去一个值并将结果推送到计算堆栈上。

Div

将两个值相除并将结果作为浮点(F 类型)或商(int32 类型)推送到计算堆栈上。

Mul

将两个值相乘并将结果推送到计算堆栈上。

Rem

将两个值相除并将余数推送到计算堆栈上。

Xor

计算位于计算堆栈顶部的两个值的按位异或,并且将结果推送到计算堆栈上。

And

计算两个值的按位"与"并将结果推送到计算堆栈上。

Or

计算位于堆栈顶部的两个整数值的按位求补并将结果推送到计算堆栈上。

Not

计算堆栈顶部整数值的按位求补并将结果作为相同的类型推送到计算堆栈上。

Dup

复制计算堆栈上当前最顶端的值,然后将副本推送到计算堆栈上。

Neg

对一个值执行求反并将结果推送到计算堆栈上。

Ret

从当前方法返回,并将返回值(如果存在)从调用方的计算堆栈推送到被调用方的计算堆栈上。

Jmp

退出当前方法并跳至指定方法。

Newobj

New Object创建一个值类型的新对象或新实例,并将对象引用推送到计算堆栈上。

Newarr

New Array将对新的从零开始的一维数组(其元素属于特定类型)的对象引用推送到计算堆栈上。

Nop

如果修补操作码,则填充空间。尽管可能消耗处理周期,但未执行任何有意义的操作。Debug下的

Pop

移除当前位于计算堆栈顶部的值。

Initobj

Init Object将位于指定地址的值类型的每个字段初始化为空引用或适当的基元类型的 0。

Isinst

Is Instance测试对象引用是否为特定类的实例。

Sizeof

将提供的值类型的大小(以字节为单位)推送到计算堆栈上。

Box

将值类转换为对象引用。

Unbox

将值类型的已装箱的表示形式转换为其未装箱的形式。

Castclass

尝试将引用传递的对象转换为指定的类。

Switch

实现跳转表。

Throw

引发当前位于计算堆栈上的异常对象。

Call

调用由传递的方法说明符指示的方法。

Calli

通过调用约定描述的参数调用在计算堆栈上指示的方法(作为指向入口点的指针)。

Callvirt

对对象调用后期绑定方法,并且将返回值推送到计算堆栈上。

强调一下,有三种call,用的场景不太一样:

Call:常用于调用编译时就确定的方法,可以直接去元数据里找方法,如静态函数,实例方法,也可以call虚方法,不过只是call这个类型本身的虚方法,和实例的方法性质一样。另外,call不做null检测。

Calli: MSDN上讲是间接调用指针指向的函数,具体场景没见过,有知道的朋友望不吝赐教。

Callvirt: 可以调用实例方法和虚方法,调用虚方法时以多态方式调用,不能调用静态方法。Callvirt调用时会做null检测,如果实例是null,会抛出NullReferenceException,所以速度上比call慢点。

第二类:加载(ld)和存储(st)

我们知道,C#程序运行时会有线程栈把参数,局部变量放上来,另外还有个计算栈用来做函数里的计算。所以把值加载到计算栈上,算完后再把计算栈上的值存到线程栈上去,这类指令专门干这些活。

比方说 ldloc.0:

这个可以拆开来看,Ld打头可以理解为Load,也就是加载;loc可以理解为local variable,也就是局部变量,后面的 .0表示索引。连起来的意思就是把索引为0的局部变量加载到计算栈上。对应的 ldloc.1就是把索引为1的局部变量加载到计算栈上,以此类推。

知道了Ld的意思,下面这些指令 也就很容易理解了。

ldstr = load string,

ldnull = load null,

ldobj = load object,

ldfld = load field,

ldflda = load field address,

ldsfld = load static field,

ldsflda = load static field address,

ldelem = load element in array,

ldarg = load argument,

ldc 则表示加载数值,如ldc.i4.0,

关于后缀

.i[n]:[n]表示字节数,1个字节是8位,所以是8*n的int,比如i1, i2, i4, i8,i1就是int8(byte), i2是int16(short),i4是int32(int),i8是int64(long)。

相似的还有.u1 .u2 .u4 .u8  分别表示unsigned int8(byte), unsigned int16(short), unsigned int32(int), unsigned int64(long);

.R4,.R8 表示的是float和double。

.ovf (overflow)则表示会进行溢出检查,溢出时会抛出异常;

.un (unsigned)表示无符号数;

.ref (reference)表示引用;

.s (short)表示短格式,比如说正常的是用int32,加了.s的话就是用int8;

.[n] 比如 .1,.2 等,如果跟在i[n]后面则表示数值,其他都表示索引。如 ldc.i4.1就是加载数值1到计算栈上,再如ldarg.0就是加载第一个参数到计算栈上。

ldarg要特别注意一个问题:如果是实例方法的话ldarg.0加载的是本身,也就是this,ldarg.1加载的才是函数的第一个参数;如果是静态函数,ldarg.0就是第一个参数。

与ld对应的就是st,可以理解为store,意思是把值从计算栈上存到变量中去,ld相关的指令很多都有st对应的,比如stloc, starg, stelem等,就不多说了。

第三类:比较指令,比较大小或判断bool值

有一部分是比较之后跳转的,代码里的 if 就会产生这些指令,符合条件则跳转执行另一些代码:

以b开头:beq, bge, bgt, ble, blt, bne

先把b去掉看看:
eq: equivalent with, == 
ge: greater than or equivalent with , >=

gt: greater than , > 
le: less than or equivalent with, <= 
lt: less than, < 
ne: not equivalent with, !=

这样是不是很好理解了,beq IL_0005就是计算栈上两个值相等的话就跳转到IL_0005, ble IL_0023是第一个值小于或等于第二个值就跳转到IL_0023。

以br(break)开头:br, brfalse, brtrue,

br是无条件跳转;

brfalse表示计算栈上的值为 false/null/0 时发生跳转;

brtrue表示计算栈上的值为 true/非空/非0 时发生跳转

还有一部分是c开头,算bool值的,和前面b开头的有点像:

ceq 比较两个值,相等则将 1 (true) 推到栈上,否则就把 0 (false)推到栈上

cgt 比较两个值,第一个大于第二个则将 1 (true) 推到栈上,否则就把 0 (false)推到栈上

clt  比较两个值,第一个小于第二个则将 1 (true) 推到栈上,否则就把 0 (false)推到栈上

以上就是三类常用的,把这些搞明白了,IL指令也就理解得七七八八了。就像看文章一样,认识大部分字后基本就不影响阅读了,不认识的猜下再查下,下次再看到也就认得了。

例子

下面看个例子,随手写段简单的代码,是否合乎逻辑暂不考虑,主要是看IL:

源代码:

 1 using System;
2
3 namespace ILLearn
4 {
5 class Program
6 {
7 const int WEIGHT = 60;
8
9 static void Main(string[] args)
10 {
11 var height = 170;
12
13 People people = new Developer("*");
14
15 var vocation = people.GetVocation();
16
17 var healthStatus = People.IsHealthyWeight(height, WEIGHT) ? "healthy" : "not healthy";
18
19 Console.WriteLine($"{vocation} is {healthStatus}");
20
21 Console.ReadLine();
22 }
23 }
24
25 abstract class People
26 {
27 public string Name { get; set; }
28
29 public abstract string GetVocation();
30
31 public static bool IsHealthyWeight(int height, int weight)
32 {
33 var healthyWeight = (height - 80) * 0.7;
34 return weight <= healthyWeight * 1.1 && weight >= healthyWeight * 0.9; //标准体重是 (身高-80) * 0.7,区间在10%内都是正常范围
35 }
36 }
37
38 class Developer : People
39 {
40 public Developer(string name)
41 {
42 Name = name;
43 }
44
45 public override string GetVocation()
46 {
47 return "Developer";
48 }
49 }
50 }

在命令行里输入:csc /debug- /optimize+ /out:program.exe Program.cs

打开IL查看工具:C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.6 Tools\ildasm.exe,不同版本可能目录不太一样。打开刚编译的program.exe文件,如下:

[No0000152]C#基础之IL,轻松读懂IL

双击节点就可以查看IL,如:

Developer的构造函数:

 1 .method public hidebysig specialname rtspecialname
2 instance void .ctor(string name) cil managed
3 {
4 // 代码大小 14 (0xe)
5 .maxstack 8
6 IL_0000: ldarg.0 //加载第1个参数,因为是实例,而实例的第1个参数始终是this
7 IL_0001: call instance void ILLearn.People::.ctor() //调用基类People的构造函数,而People也会调用Object的构造函数
8 IL_0006: ldarg.0 //加载this
9 IL_0007: ldarg.1 //加载第二个参数也就是name
10 IL_0008: call instance void ILLearn.People::set_Name(string) //调用this的 set_Name, set_Name这个函数是编译时为属性生成的
11 IL_000d: ret //return
12 } // end of method Developer::.ctor

Developer的GetVocation:

1 .method public hidebysig virtual instance string //虚函数
2 GetVocation() cil managed
3 {
4 // 代码大小 6 (0x6)
5 .maxstack 8 //最大计算栈,默认是8
6 IL_0000: ldstr "Developer" //加载string "Developer"
7 IL_0005: ret //return
8 } // end of method Developer::GetVocation

People的IsHealthyWeight:

 1 .method public hidebysig static bool  IsHealthyWeight(int32 height,  //静态函数
2 int32 weight) cil managed
3 {
4 // 代码大小 52 (0x34)
5 .maxstack 3 //最大计算栈大小
6 .locals init ([0] float64 healthyWeight) //局部变量
7 IL_0000: ldarg.0 //加载第1个参数,因为是静态函数,所以第1个参数就是height
8 IL_0001: ldc.i4.s 80 //ldc 加载数值, 加载80
9 IL_0003: sub //做减法,也就是 height-80,把结果放到计算栈上,前面两个已经移除了
10 IL_0004: conv.r8 //转换成double,因为下面计算用到了double,所以要先转换
11 IL_0005: ldc.r8 0.69999999999999996 //加载double数值 0.7, 为什么是0.69999999999999996呢, 二进制存不了0.7,只能找个最相近的数
12 IL_000e: mul //计算栈上的两个相乘,也就是(height - 80) * 0.7
13 IL_000f: stloc.0 //存到索引为0的局部变量(healthyWeight)
14 IL_0010: ldarg.1 //加载第1个参数 weight
15 IL_0011: conv.r8 //转换成double
16 IL_0012: ldloc.0 //加载索引为0的局部变量(healthyWeight)
17 IL_0013: ldc.r8 1.1000000000000001 //加载double数值 1.1, 看IL_0010到IL_0013,加载了3次,这个函数最多也是加载3次,所以maxstack为3
18 IL_001c: mul //计算栈上的两个相乘,也就是 healthyWeight * 1.1, 这时计算栈上还有两个,第一个是weight,第二个就是这个计算结果
19 IL_001d: bgt.un.s IL_0032 //比较这两个值,第一个大于第二个就跳转到 IL_0032,因为第一个大于第二个表示第一个条件weight <= healthyWeight * 1.1就是false,也操作符是&&,后面没必要再算,直接return 0
20 IL_001f: ldarg.1 //加载第1个参数 weight
21 IL_0020: conv.r8 //转换成double
22 IL_0021: ldloc.0 //加载索引为0的局部变量(healthyWeight)
23 IL_0022: ldc.r8 0.90000000000000002 //加载double数值 0.9
24 IL_002b: mul //计算栈上的两个相乘,也就是 healthyWeight * 0.9, 这时计算栈上还有两个,第一个是weight,第二个就是这个计算结果
25 IL_002c: clt.un //比较大小,第一个小于第二个则把1放上去,否则放0上去
26 IL_002e: ldc.i4.0 //加载数值0
27 IL_002f: ceq //比较大小,相等则把1放上去,否则放0上去
28 IL_0031: ret //return 栈顶的数,为什么没用blt.un.s,因为IL_0033返回的是false
29 IL_0032: ldc.i4.0 //加载数值0
30 IL_0033: ret //return 栈顶的数
31 } // end of method People::IsHealthyWeight

主函数Main:

 1 .method private hidebysig static void  Main(string[] args) cil managed
2 {
3 .entrypoint //这是入口
4 // 代码大小 67 (0x43)
5 .maxstack 3 //大小为3的计算栈
6 .locals init (string V_0,
7 string V_1) //两个string类型的局部变量,本来还有个people的局部变量,被release方式优化掉了,因为只是调用了people的GetVocation,后面没用,所以可以不存
8 IL_0000: ldc.i4 0xaa //加载int型170
9 IL_0005: ldstr "*" //加载string "*"
10 IL_000a: newobj instance void ILLearn.Developer::.ctor(string) //new一个Developer并把栈上的*给构造函数
11 IL_000f: callvirt instance string ILLearn.People::GetVocation() //调用GetVocation
12 IL_0014: stloc.0 //把上面计算的结果存到第1个局部变量中,也就是V_0
13 IL_0015: ldc.i4.s 60 //加载int型60
14 IL_0017: call bool ILLearn.People::IsHealthyWeight(int32, //调用IsHealthyWeight,因为是静态函数,所以用call
15 int32)
16 IL_001c: brtrue.s IL_0025 //如果上面返回true的话就跳转到IL_0025
17 IL_001e: ldstr "not healthy" //加载string "not healthy"
18 IL_0023: br.s IL_002a //跳转到IL_002a
19 IL_0025: ldstr "healthy" //加载string "healthy"
20 IL_002a: stloc.1 //把结果存到第2个局部变量中,也就是V_1, IL_0017到IL_002a这几个指令加在一起用来计算三元表达式
21 IL_002b: ldstr "{0} is {1}" //加载string "{0} is {1}"
22 IL_0030: ldloc.0 //加载第1个局部变量
23 IL_0031: ldloc.1 //加载第2个局部变量
24 IL_0032: call string [mscorlib]System.String::Format(string, //调用string.Format,这里也可以看到C# 6.0的语法糖 $"{vocation} is {healthStatus}",编译后的结果和以前的用法一样
25 object,
26 object)
27 IL_0037: call void [mscorlib]System.Console::WriteLine(string) //调用WriteLine
28 IL_003c: call string [mscorlib]System.Console::ReadLine() //调用ReadLine
29 IL_0041: pop
30 IL_0042: ret
31 } // end of method Program::Main

很简单吧,当然,这个例子也很简单,没有事件,没有委托,也没有async/await之类,这些有兴趣的可以写代码跟一下,这几种都会在编译时插入也许你不知道的代码。

就这么简单学一下,应该差不多有底气和面试官吹吹牛逼了。

1.实例解析IL

  作为C#程序员,IL的作用不言而喻,首先来看一个非常简单的程序和它的IL解释图,通过这个程序的IL指令来简单的了解常见的IL指令是什么意思。

   class Program
{
static void Main(string[] args)
{
int i = 2;
string str= "C#";
Console.WriteLine("hello "+str);
}
}
[No0000152]C#基础之IL,轻松读懂IL
    class Program
{
static void Main(string[] args)
{
int i = 2;
string str= "C#";
Console.WriteLine("hello "+str);
}
}
[No0000152]C#基础之IL,轻松读懂IL

[No0000152]C#基础之IL,轻松读懂IL

接下来要明确一个概念,.NET运行时任何有意义的操作都是在堆栈上完成的,而不是直接操作寄存器。这就为.NET跨平台打下了基础,通过设计不同的编译器编译相同的IL代码来实现跨平台。对于堆栈我们的操作无非就是压栈和出栈,在IL中压栈通常以ld开头,出栈则以st开头。知道这个后再看上面的指令感觉一下子就豁然开朗了,接下来继续学习的步伐,下面的表格是对于一些常见ld指令。st指令则是将ld指令换成st,功能有压栈变为出栈,有时候会看到在st或ld后加.s这表示只取一个字节。再来看看流程控制,知道压出栈和流程控制后,基本上看出IL的大概意思那就冒闷踢啦。流程控制主要就是循环和分支,下面我写了个有循环和分支的小程序。其中我们用到了加法和比较运算,为此得在这里介绍最基本的三种运算:算术运算(add、sub、mul乘法、div、rem求余);比较运算(cgt大于、clt小于、ceq等于);位运算(not、and、or、xor异或、左移shl、右移shr)。要注意在比较运算中,当执行完指令后会直接将结果1或0压栈,这个过程是自动完成的。对于流程控制,主要是br、brture和brfalse这3条指令,其中br是直接进行跳转,brture和brture则是进行判断再进行跳转。

ldarg 加载成员的参数,如上面的ldarg.0
ldarga 装载参数的地址,注意一般加个a表示取地址
ldc 将数字常量压栈,如上面的ldc.i4.2
ldstr 将字符串的引用压栈
ldloc/ldloca ldloc将一个局部变量压栈,加a表示将这个局部变量的地址压栈
Ldelem 表示将数组元素压栈
ldlen 将数组长度压栈
ldind 将地址压栈,以地址来访问或操作数据内
 class Program
{
static void Main(string[] args)
{
int count = 2;
string strName= "C#";
if (strName == "C#")
{
for(int i=0;i<count;i++)
Console.WriteLine("hello C#");
}
else
Console.WriteLine("ha ha");
}
}
[No0000152]C#基础之IL,轻松读懂IL
 class Program
{
static void Main(string[] args)
{
int count = 2;
string strName= "C#";
if (strName == "C#")
{
for(int i=0;i<count;i++)
Console.WriteLine("hello C#");
}
else
Console.WriteLine("ha ha");
}
}
[No0000152]C#基础之IL,轻松读懂IL

[No0000152]C#基础之IL,轻松读懂IL

2.面向对象的IL

  有了前面的基础后,基本上看一般的IL代码不会那么方了。如果我们在程序中声明一个类并创建对象,则在IL中可以看到newobj、class、instance、static等关键字。看IL指令会发现外部是类,类里面有方法,虽然方法里面是指令不过这和C#代码的结构是很相似的。从上面的这些现象可以很明显的感受到IL并不是简单的指令,它是面向对象的。当我们在C#中使用new创建一个对象时则在IL中对应的是newobj,另外还有值类型也是可以通过new来创建的,不过在IL中它对应的则是initobj。newobj用来创建一个对象,首先会分配这个对象所需的内存,接着初始化对象附加成员同步索引块和类型对象指针然后再执行构造函数进行初始化并返回对象引用。initobj则是完成栈上已经分配好的内存的初始化工作,将值类型置0引用类型置null即可。另外string是引用类型,从上面的例子可以看到一般是使用ldstr来将元数据中的字符串引用加载到栈中而不是newobj。但是如果在代码中创建string变量不是直接赋值而是使用new关键字来得到string对象,那么在IL中将会看到newobj指令。当创建一维零基数组时还会看到newarr指令,它会创建数组并将首地址压栈。不过如果数组不是一维零基数组的话仍将还是会看到我们熟悉的newobj。

  既然是面向对象的,那么继承中的虚方法或抽象方法在IL中肯定会有相应的指令去完成方法的调用。调用方法主要是call、callvirt、calli,call主要用来调用静态方法,callvirt则用来调用普通方法和需要运行时绑定的方法(也就是用instance标记的实例方法),calli是通过函数指针来进行调用的。不过也存在特殊情况,那就是call去调用虚方法,比如在密封类中的虚方法因为一定不可能会被重写因此使用call可提高性能。为什么会提高性能呢?不知道你是否还记得创建一个对象去调用这个对象的方法时,我们经常会判断这个对象是否为null,如果这个对象为null时去调用方法则会报错。之所以出现这种情况是因为callvirt在调用方法时会进行类型检测,此外判断是否有子类方法覆盖的情况从而动态绑定方法,而采用call则直接去调用了。另外当调用基类的虚方法时,比如调用object.ToString方法就是采用call方法,如果采用callvirt的话因为有可能要查看子类(一直查看到最后一个继承父类的子类)是否有重写方法,从而降低了性能。不过说到底call用来调用静态方法,而callvirt调用与对象关联的动态方法的核心思想是可以肯定的,那些采用call的特殊情况都是因为在这种情况下根本不需要动态绑定方法而是可以直接使用的。calli的意思就是拿到一个指向函数的引用,通过这个引用去调用函数,不过在我的学习中没有使用到这个,这个具体是如何拿到引用的我也不清楚,感兴趣者请自行百度。

3.IL的角色

  大家都知道C#代码编译后就会生成元数据和IL,可是我们常见的exe这样的程序集是如何生成的呢,它与IL是什么关系呢?首先有一点是可以肯定的,那就是程序集中肯定会包含元数据和IL,因为这2样东西是程序集中的核心。下面是一个描述程序集和内部组成图,从图中可以看出一个程序集是有多个托管模块组成的,一个模块可以理解为一个类或者多个类一起编译后生成的程序集。程序集清单指的是描述程序集的相关信息,PE文件头描述PE文件的文件类型、创建时间等。CLR头描述CLR版本、CPU信息等,它告诉系统这是一个.NET程序集。然后最主要的就是每个托管模块中的元数据和IL了。元数据用来描述类、方法、参数、属性等数据,.NET中每个模块包含44个元数据表,主要包括定义表、引用表、指针表和堆。定义表包括类定义表、方法表等,引用表描述引用到类型或方法之间的映射记录,指针表里存放着方法指针、参数指针等。可以看到元数据表就相当于一个数据库,多张表之间有类似于主外键之间的关系。

[No0000152]C#基础之IL,轻松读懂IL

由前面的知识可以总结出IL是独立于CPU且面向对象的指令集。.NET平台将其之上的语言全都编译成符合CLS(公共语言规范)的IL指令集,接着再由不同的编译器翻译成本地代码,比如我们常见的JIT编译器,如果在Mac上运行C#可通过Mac上的特定编译器来将IL翻译成Mac系统能够执行的机器码。也就是说IL正如它的名字一样是作为一种中间语言来执行动态程序,比如我们调用一个方法表中的方法,这个方法会指向一个触发JIT编译器地址和方法对应的IL地址,于是JIT编译器便将这个方法指向的IL编译成本地代码。生成本地代码后这个方法将会有一条引用指向本地代码首地址,这样下次调用这个方法的时候将直接执行指向的本地代码。

结束

IL其实不难,有没有用则仁者见仁,智者见智,有兴趣就学一下,也花不了多少时间,确实也没必要学多深,是吧。

当然,也是要有耐心的,复杂的IL看起来还真是挺头痛。好在有工具ILSpy,可以在option里选择部分不反编译来看会比较简单些。

最后介绍两个工具: 
.Net Reflector可以把用户自己编写的IL指令转化为正常代码,大家可以自己下载安装; 
IL查看工具可以把正常代码转化为IL指令,vs2010中路径为C:\Program Files (x86)\Microsoft SDKs\Windows\v7.0A\Bin\NETFX 4.0 Tools\ildasm.exe,不同版本目录可能不太一样。 
有了这两个工具当我们想用IL指令实现某一功能但不会写时,可以先用正常代码把功能写出来,在IL查看工具中查看IL代码是什么样的,然后自己再根据转化的IL代码逻辑使用IL指令实现想要的功能。