轻松读懂IL
先说说学IL有什么用,有人可能觉得这玩意平常写代码又用不上,学了有个卵用。到底有没有卵用呢,暂且也不说什么学了可以看看一些语法糖的实现,或对.net理解更深一点这些虚头巴脑的东西。最重要的理由就是一个:当面试官看你简历上写着精通C#时,问你一句:
"懂不懂IL?"
怎么回答?
"不好意思,那东西没什么卵用,所以我没学。"
还是
"还行,可以探讨一下。"
你觉得哪个回答好呢,答得好才更有底气要到更多的薪资,多个几千块也说不定,而这只不过花上不到半小时学习就可以跟面试官吹上一阵了,很实用,有没有。
为什么取这个标题呢,记得很久之前看过一篇文章,叫"正则表达式30分钟入门教程",学正则最重要的就是记住各个符号的含义。个人觉得相比难以直接看出实际意义的正则符号如"\w","\d","*","?","{}[]"等,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)和存储(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:
源代码:
using System;
namespace ILLearn
{
class Program
{
const int WEIGHT = 60;
static void Main(string[] args)
{
var height = 170;
People people = new Developer("brook");
var vocation = people.GetVocation();
var healthStatus = People.IsHealthyWeight(height, WEIGHT) ? "healthy" : "not healthy";
Console.WriteLine($"{vocation} is {healthStatus}");
Console.ReadLine();
}
}
abstract class People
{
public string Name { get; set; }
public abstract string GetVocation();
public static bool IsHealthyWeight(int height, int weight)
{
var healthyWeight = (height - 80) * 0.7;
return weight <= healthyWeight * 1.1 && weight >= healthyWeight * 0.9; //标准体重是 (身高-80) * 0.7,区间在10%内都是正常范围
}
}
class Developer : People
{
public Developer(string name)
{
Name = name;
}
public override string GetVocation()
{
return "Developer";
}
}
}
在命令行里输入: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文件,如下:
双击节点就可以查看IL,如:
Developer的构造函数:
.method public hidebysig specialname rtspecialname
instance void .ctor(string name) cil managed
{
// 代码大小 14 (0xe)
.maxstack 8
IL_0000: ldarg.0 //加载第1个参数,因为是实例,而实例的第1个参数始终是this
IL_0001: call instance void ILLearn.People::.ctor() //调用基类People的构造函数,而People也会调用Object的构造函数
IL_0006: ldarg.0 //加载this
IL_0007: ldarg.1 //加载第二个参数也就是name
IL_0008: call instance void ILLearn.People::set_Name(string) //调用this的 set_Name, set_Name这个函数是编译时为属性生成的
IL_000d: ret //return
} // end of method Developer::.ctor
Developer的GetVocation:
.method public hidebysig virtual instance string //虚函数
GetVocation() cil managed
{
// 代码大小 6 (0x6)
.maxstack 8 //最大计算栈,默认是8
IL_0000: ldstr "Developer" //加载string "Developer"
IL_0005: ret //return
} // end of method Developer::GetVocation
People的IsHealthyWeight:
.method public hidebysig static bool IsHealthyWeight(int32 height, //静态函数
int32 weight) cil managed
{
// 代码大小 52 (0x34)
.maxstack 3 //最大计算栈大小
.locals init ([0] float64 healthyWeight) //局部变量
IL_0000: ldarg.0 //加载第1个参数,因为是静态函数,所以第1个参数就是height
IL_0001: ldc.i4.s 80 //ldc 加载数值, 加载80
IL_0003: sub //做减法,也就是 height-80,把结果放到计算栈上,前面两个已经移除了
IL_0004: conv.r8 //转换成double,因为下面计算用到了double,所以要先转换
IL_0005: ldc.r8 0.69999999999999996 //加载double数值 0.7, 为什么是0.69999999999999996呢, 二进制存不了0.7,只能找个最相近的数
IL_000e: mul //计算栈上的两个相乘,也就是(height - 80) * 0.7
IL_000f: stloc.0 //存到索引为0的局部变量(healthyWeight)
IL_0010: ldarg.1 //加载第1个参数 weight
IL_0011: conv.r8 //转换成double
IL_0012: ldloc.0 //加载索引为0的局部变量(healthyWeight)
IL_0013: ldc.r8 1.1000000000000001 //加载double数值 1.1, 看IL_0010到IL_0013,加载了3次,这个函数最多也是加载3次,所以maxstack为3
IL_001c: mul //计算栈上的两个相乘,也就是 healthyWeight * 1.1, 这时计算栈上还有两个,第一个是weight,第二个就是这个计算结果
IL_001d: bgt.un.s IL_0032 //比较这两个值,第一个大于第二个就跳转到 IL_0032,因为第一个大于第二个表示第一个条件weight <= healthyWeight * 1.1就是false,也操作符是&&,后面没必要再算,直接return 0
IL_001f: ldarg.1 //加载第1个参数 weight
IL_0020: conv.r8 //转换成double
IL_0021: ldloc.0 //加载索引为0的局部变量(healthyWeight)
IL_0022: ldc.r8 0.90000000000000002 //加载double数值 0.9
IL_002b: mul //计算栈上的两个相乘,也就是 healthyWeight * 0.9, 这时计算栈上还有两个,第一个是weight,第二个就是这个计算结果
IL_002c: clt.un //比较大小,第一个小于第二个则把1放上去,否则放0上去
IL_002e: ldc.i4.0 //加载数值0
IL_002f: ceq //比较大小,相等则把1放上去,否则放0上去
IL_0031: ret //return 栈顶的数,为什么没用blt.un.s,因为IL_0033返回的是false
IL_0032: ldc.i4.0 //加载数值0
IL_0033: ret //return 栈顶的数
} // end of method People::IsHealthyWeight
主函数Main:
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint //这是入口
// 代码大小 67 (0x43)
.maxstack 3 //大小为3的计算栈
.locals init (string V_0,
string V_1) //两个string类型的局部变量,本来还有个people的局部变量,被release方式优化掉了,因为只是调用了people的GetVocation,后面没用,所以可以不存
IL_0000: ldc.i4 0xaa //加载int型170
IL_0005: ldstr "brook" //加载string "brook"
IL_000a: newobj instance void ILLearn.Developer::.ctor(string) //new一个Developer并把栈上的brook给构造函数
IL_000f: callvirt instance string ILLearn.People::GetVocation() //调用GetVocation
IL_0014: stloc.0 //把上面计算的结果存到第1个局部变量中,也就是V_0
IL_0015: ldc.i4.s 60 //加载int型60
IL_0017: call bool ILLearn.People::IsHealthyWeight(int32, //调用IsHealthyWeight,因为是静态函数,所以用call
int32)
IL_001c: brtrue.s IL_0025 //如果上面返回true的话就跳转到IL_0025
IL_001e: ldstr "not healthy" //加载string "not healthy"
IL_0023: br.s IL_002a //跳转到IL_002a
IL_0025: ldstr "healthy" //加载string "healthy"
IL_002a: stloc.1 //把结果存到第2个局部变量中,也就是V_1, IL_0017到IL_002a这几个指令加在一起用来计算三元表达式
IL_002b: ldstr "{0} is {1}" //加载string "{0} is {1}"
IL_0030: ldloc.0 //加载第1个局部变量
IL_0031: ldloc.1 //加载第2个局部变量
IL_0032: call string [mscorlib]System.String::Format(string, //调用string.Format,这里也可以看到C# 6.0的语法糖 $"{vocation} is {healthStatus}",编译后的结果和以前的用法一样
object,
object)
IL_0037: call void [mscorlib]System.Console::WriteLine(string) //调用WriteLine
IL_003c: call string [mscorlib]System.Console::ReadLine() //调用ReadLine
IL_0041: pop
IL_0042: ret
} // end of method Program::Main
很简单吧,当然,这个例子也很简单,没有事件,没有委托,也没有async/await之类,这些有兴趣的可以写代码跟一下,这几种都会在编译时插入也许你不知道的代码。
就这么简单学一下,应该差不多有底气和面试官吹吹牛逼了。
结束
IL其实不难,有没有用则仁者见仁,智者见智,有兴趣就学一下,也花不了多少时间,确实也没必要学多深,是吧。
当然,也是要有耐心的,复杂的IL看起来还真是挺头痛。好在有工具ILSpy,可以在option里选择部分不反编译来看会比较简单些。