C#基础之IL,轻松读懂中间代码IL转载
先说说学IL有什么⽤,有⼈可能觉得这玩意平常写代码⼜⽤不上,学了有个卵⽤。到底有没有卵⽤呢,暂且也不说什么学了可以看看⼀些语法糖的实现,或对理解更深⼀点这些虚头巴脑的东西。其实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)和存储
我们知道,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("brook");
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+ / Program.cs
打开IL查看⼯具:C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.6 ,不同版本可能⽬录不太⼀样。打开刚编译的⽂件,如下:
双击节点就可以查看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(int3
2 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:
weight代表什么意思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 "brook" //加载string "brook"
10 IL_000a: newobj instance void ILLearn.Developer::.ctor(string) //new⼀个Developer并把栈上的brook给构造函数
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);
}
}
class Program
{
static void Main(string[] args)
{
int i = 2;
string str= "C#";
Console.WriteLine("hello "+str);
}
}
接下来要明确⼀个概念,.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");
}
}
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");
}
}
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个元数据表,主要包括定义表、引⽤表、指针表和堆。定义表包括类定义表、⽅法表等,引⽤表描述引⽤到类型或⽅法之间的映射记录,指针表⾥存放着⽅法指针、参数指针等。可以看到元数据表就相当于⼀个数据库,多张表之间有类似于主外键之间的关系。
由前⾯的知识可以总结出IL是独⽴于CPU且⾯向对象的指令集。.NET平台将其之上的语⾔全都编译成符合CLS(公共语⾔规范)的IL指令集,接着再由不同的编译器翻译成本地代码,⽐如我们常见的JIT编译器,如果在Mac上运⾏C#可通过Mac上的特定编译器来将IL翻译成Mac系统能够执⾏的机器码。也就是说IL正如它的名字⼀样是作为⼀种中间语⾔来
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论