在.net平台下基于C#语言生成的IL代码,我们主要学习的就是C#语法下生成什么样的IL代码和IL指令含义和执行逻辑;看过很多博客写的有关IL文章,对指令解释不够详细,现在我结合我的学习心得和大家一起分析一下IL几个常用指令。如果你是刚了解clr和IL的爱好着,请仔细阅读以下文字。
-
首先先介绍一下记忆体
下面这段源自网上文章资料,我觉得这些介绍够我们今天讨论,有关于托管堆计算栈和调用栈的详细内容有机会我想详细讨论,因为这哥仨始终贯穿的IL执行过程,尤其是加入线程之后讨论点更多了。
1、Managed Heap:這是动态配置(Dynamic Allocation)的记忆体,由 Garbage Collector(GC)在执行時自動管理,整個Process 共用一個 Managed Heap。
2、Call Stack:這是由 .NET CLR 在执行時自動管理的记忆体,每個 Thread 都有自己专属的 Call Stack。每呼叫一次 method,就会使得Call Stack 上多了一個 Record Frame;呼叫完毕之后,此 Record Frame 会被丢弃。一般來說,Record Frame 內记录着 method 参数(Parameter)、返回位址(Return Address)、以及区域变数(Local Variable)。Java VM 和 .NET CLR 都是使用 0, 1, 2… 编号的方式來識別区别变数。
3、Evaluation Stack:這是由 .NET CLR 在执行時自動管理的记忆体,每個 Thread 都有自己专属的 Evaluation Stack。前面所謂的堆叠式虚拟机器,指的就是這個堆叠。
-
结合实例讨论IL
上面介绍的三个记忆体,我们这里只讨论call stack和Evaluation stack,首先执行CLR托管代码时要找到入口Main方法,然后JIT将会编译这些指令,JIT编译的对象永远是方法内部的指令,至于程序集,命名空间,类这些结构和逻辑咱们暂且先交给.net,至于是怎么编译成机器码的也先不管。
这里我们先讨论指令是怎么用记忆体的,上面介绍到方法在调用时会用到call stack,在上面创建一个栈帧(Record Frame ),里面存了啥上面上也介绍了,每个方法被调用时都会创建一个栈帧,关键是为什么用栈帧呢,因为方法调用时有参数,方法里面有局部变量,这些东西总需要一个空间来存的,而且方法在调用完之后这个栈帧会被丢弃,讲到这里是不是有种似曾相识的感觉,你们在学语法的时候老师是不是也是这么讲的,现在我们从CLR的角度再来理解会更清晰。在说一下,Call stack中的区域变数,这个东西就是存方法局部变量的地方(重要且常用,想想在C#里局部变量是不是很常用),其访问方式将在下面代码中介绍。
总结一下:call stack 是个方法调用时用的栈,且线程专属,栈的特点后进先出,调用一个方法创建一个栈帧,方法里面还调用了方法会再创建一个栈帧,也就是在栈的顶部压入一个栈帧,栈帧丢弃要在该方法结束后执行,也就是当前方法里面调用的其他方法还没结束你需要等待,因为对应的你的栈帧上面还有栈帧,而出栈永远是最上面一个(说到这里你是否能够想到为何调用栈是线程专属的,为何不能整个进程共享)。
再说一下Evaluation Stack,这个是计算栈,也是线程专属,听名字就知道是指令运算时用到的,而且它会需要和call stack配合,也就是在指令执行的时候会需要把调用栈上的数据传到计算栈上来执行该指令,具体是咋用的我会在IL中详细说明。
好了,学习IL用到的知识都大致说了。下面我们将用的这些知识来学习IL,并分析IL代码和C#代码在C#编译器编译之后的变化,如此我们能更好的理解C#,以及C#中一些很骚的东西(说优雅更好点)本质是啥。
下面开始上硬菜:
//刘棚超
public int add(int a,int b)
{
int c = a + b;
long d = a ;
a = 2147483647;
b = 2147483647;
int e = a+b;//这里我为何不写成e=2147483647+2147483647
//e = unchecked(2147483647 + 2147483647);
d.ToString();
2.ToString();//这里可以看到2在IL中被直接当成Int32类型处理了
return c;
}
看到上面代码是不是觉得很简单,确实很简单以至于你看都不想看,接下来看看这个方法生成的IL代码,用ILDASM工具,装了VS的都有,如果不清楚就百度查一下。再看IL代码:
.method public hidebysig instance int32 'add'(int32 a,
int32 b) cil managed
{
/*看到这么多代码是不是有点眼花,不过没关系我
们可以把每行代码对应的IL指令分开来,便于理解。
方法上描述性的关键字先不讨论,有些你可能看懂有
有些就完全不知道啥意识。咱们记住JIT编译的是下
面这些内容就行。*/
// 代码大小 52 (0x34)
.maxstack 2 这个表示该方法在计算栈(还记得Evaluation stack)上的最大深度,也就是这个
方法在计算栈中最大能压入几个数据,这取决于指令在运算时最多需要几个参数。
(这里我抛出一个问题,为何IL代码在执行前就知道了最大栈深,是谁给他计算
出来的?还有为什么要计算栈深度?)
.locals init ([0] int32 c, //申明变量,开始给局部变量分配空间了,在哪分配的呢,就是
[1] int64 d, 在call stack 中区域变数中,但是此时它们都没有值(或者说
[2] int32 e, 都是0),看了代码我们知道c,d,e不就是我们申明的局部变量嘛,
[3] int32 V_3, 可能你又疑惑了,方法的参数怎么没有,其实方法被调用的时候在
[4] int32 V_4) call stack的栈帧中就已经被申明且初始化好了,接着往下看,
V_3,V_4是啥啊,代码中好像没定义这俩货吧,我们在下面的指令中
告诉你这两个变量是啥。补充一点申明变量的格式:[Index][Type]
[name]。它告诉我们一个变量在区域变数中位置,和他们的类型名
字。(这里有个有意识的事V_3,V_4为何从3和4开始而不是0开始
看完后文章后有兴趣的可以去了解一下)
//下面的所有指令可以网上IL指令表中找到,需要用时某个指令时可以表中查看用法。
IL_0000: nop //这个这令不执行任何有意义的操作。啥玩意?一上来就让人懵逼,既然没意义
就先不管。(指令表给出:如果修补操作码,则填充空间)
IL_0001: ldarg.1 //终于来了个有用的,加载参数,ld表示加载,arg表示参数,1,表示第1号
索引位置的参数。(有个思考,索引不是从0开始的吗,怎么这里第一个参
数从1开始,那0号位置是个啥)这里1号位置索引指的就是参数a,加载到了
哪呢,还记得计算栈嘛(Evaluation stack)。没有错,就是把这个参数压
入这个栈了。
IL_0002: ldarg.2 //同上面一条指令一样,把b参数也压入计算栈了
IL_0003: add //作加法,意识是从刚刚压入计算栈的两个参数取出来做相加。
然后得到的结果再压入计算栈。(我前面提到过.maxstack这里就能体
现,因为加法需要两个数,所有计算栈里必须能方两个数,同理如果其
他指令需要三个数,则.maxstack 3 如果这个方法还有更大的,那就取那
个,而且.maxstack在编译前就算好了的)至此,代码已经执行完了a+b
(这里注意一下,指令取栈中的数据是出栈操作,也就是说add会把a和b
从栈中取出来)
IL_0004: stloc.0 //将刚刚加法计算在计算栈的结果存到栈帧中第0号位置(也是出栈操
作),也就是变量c中
回头往上看看0号索引位是不是c,至此代码执行完了c=a+b;
IL_0005: ldarg.1 //再次从栈帧中把a参数压入到计算栈。可能有人疑惑了,上面也有个
ldarg.1指令嘛,那不是已经把a取出来了嘛,怎么这里还能取。
说明一下此栈非彼栈,我们取的是call stack中栈帧(Frame)里面的数
据,它只是复制,不叫出栈,就像cpu取内存的数据,内存数据不会没有
了的。
IL_0006: conv.i8 //类型转换,解释一下,conv取得是转换的单词前半部,i表示int,8表示
八个字节,所以完整意识就是将计算栈中顶部那个数据转换为Int64类
型。
IL_0007: stloc.1 //将刚刚转换的结构存入变数列表1号位置中,也就是d,往上看看1号索引
是不是d,至此完成了d=a;
IL_0008: ldc.i4 0x7fffffff //将0x7fffffff这个常数加载到计算栈中i4含义和i8一样
IL_000d: starg.s a //重新给参数a赋值把0x7fffffff赋值给了a
IL_000f: ldc.i4 0x7fffffff //同上面一样
IL_0014: starg.s b //同上面a一样
IL_0016: ldarg.1 //a有了新值重新加载到计算栈
IL_0017: ldarg.2 //b有了新值重新加载到计算栈
IL_0018: add //两数做加法,同上面的加法一样,但是我为何写两个同样的操作,
主要是这里相加会溢出。(引发思考)
IL_0019: stloc.2 //把结果存到e中
IL_001a: ldloca.s d //加载变量d的地址,这一步是为了给下一步调用方法做准备。
//结合上一步这一步是调实例进行tostring。(有个思考为何要把d变量的地址传过去)
IL_001c: call instance string [mscorlib]System.Int64::ToString()
IL_0021: pop //d在tostring后会返回一个结果,但是代码中不需要这个结果,所以出栈
IL_0022: ldc.i4.2 //将一个整数2加载到计算栈,这是干嘛有点懵啊,别急往下看。
IL_0023: stloc.3 //把刚刚那个常数存到3号索引位置上,往上看看3号是啥,原来是V_3,
看到这里是不是突然明白了,原来V_3变量是代码中2.ToString()中的2
啊。(这里想一下为什么这个2会被申明成Int32类型)
IL_0024: ldloca.s V_3 //同ldloca.s d 一样,不多说
//这个也同IL_001c一样。
IL_0026: call instance string [mscorlib]System.Int32::ToString()
IL_002b: pop //同IL_0021一样
IL_002c: ldloc.0 //把0号索引数据加载进栈,0号位是啥,看了上面原来是c变量,这是干啥,
不明白啊,那咱就先往下看
IL_002d: stloc.s V_4 //把c存到V_4中,还是不明白要干啥
IL_002f: br.s IL_0031 //无条件跳转到IL_0031对应的指令,可还是不明白要干啥,
让咱跳咱就跳,咱也不懂咱也不敢问。
IL_0031: ldloc.s V_4 //找了半天咱跳过来了,又把V_4加载到计算栈了,妈呀你这是要干
啥,痛快点行不。算了,那咱就看完最后一个指令。
IL_0033: ret //ret——>retun 原来是退出指令,指令含义是:从当前方法返回,并将返回
值(如果存在)从调用方法的计算堆栈推送到被调用方的计算堆栈上。
md前面这么多铺垫,原来是返回值并退出方法。看看上一条指令原来返回
的是V_4,也就是说V_4这个变量就是返回值变量。
(再思考:为啥要V_4这个返回值变量。咱代码不是直接return c吗)
} // end of method Program::'add'
-
结束语
到这里我们初步简单分析了IL代码在一个方法里的执行过程,IL注释里也抛出了一些问题和思考,有助于去思考C#和CLR一些更深的东西,同时我们也感受到了,在C#中我们哪些灵活多变的编程方式原来在IL中都变得这么老老实实中规中矩。其实C#还有很多很多优雅且灵动的操作,那他们都是怎么实现的呢,我们可以写代码来测试,和IL做对比来看看C#编译器到底帮我们实现了什么。最后说明,以上类容完全来自个人学习心得。若有错误和不足之处,欢迎指出。
关灯 拉闸 睡觉 拜拜