** IL 代码底层运行机制之 **
** 函数相关 **
刘强
2003 年 10 月 31 日
本文涉及到的内容可能算是 C# 和 MSIL 中的高级特性(对 IL 来说没有什么可以称为高级特性的东西,但在这里我干脆也称之为高级特性)。函数部分包括了函数调用、函数内部变量处理、委托、事件、非托管代码调用等话题,涉及到 C# 语言中接口、继承、密闭类、委托、事件等概念。读者需要比较熟悉 C# 语言及初步了解 IL 语言。
1. 函数声明定义
在 IL 中,函数的实现形式跟 C# 非常相似,即 .method 标识后面是函数声明 [ 返回值类型 ] 函数名称 ( 参数列表 ) 。大家可以尝试编译下面这段代码:
//test.il
//Command Line: ilasm test.il
.assembly Test
{
.hash algorithm 0x00008004 file://@1
.ver 0:0:0:0 file://@2
}
// 这是配件说明语句。 @1 , @2 行可以注释掉,但这一说明不能没有。 .Net PE 文件装载
// 器要根据由它生成的配件清单装载配件。
.method static void Hello(string[] args)
{
.entrypoint
.maxstack 30
.locals init([0] int32 V_1,
[1] int32 V_2,
[2] string [] V_2)
ldstr "Hello,world"
call void [mscorlib] System.Console::WriteLine(string)
ldarg.0 file:// 加载 args 参数
ldlen file:// 计算其长度
conv.i4 file:// 转换为 32 位整型
stloc.1
br.s CPR
L1:
ldarg.0
ldloc.0
ldelem.ref file:// 根据数组引用和索引取得元素引用
call void [mscorlib] System.Console::WriteLine(string)
ldloc.0
ldc.i4.1
add
stloc.0
CPR:
ldloc.0 file:// 索引值计数
ldloc.1 file:// 数组长度
blt.s L1 file:// 根据长度、索引,确定是否满足循环条件
ret
}
这是我们的第一个 IL 版 Hello,World 程序。该程序将命令行参数依次输出,如输入 test good lucky ,则程序输出为:
Hello,World!
good
lucky
可以看到,尽管我们习惯把 IL 看成是一种汇编语言,但它还是相当高级的。函数声明是我们熟悉的 C 语言风格,函数体也用一对花括号包括起来。 .entrypoint 标识说明该函数是程序主函数,也即程序入口点; .maxstack 30 指定函数栈大小;它不一定要跟函数在运行时所用到的最大栈数目相同,但一定不能小于,否则会引发无效程序异常。当然太大了也将引起空间浪费,特别是在嵌套或递归调用的时候。我这里定义得大了一点,但不要紧,程序很小不会浪费多少空间。 .locals init([0] int32 V_1, [1] int32 V_2, [2] string [] V_2) 定义局部变量,还记得我在《 IL 代码底层运行机制》一文中关于它的描述吗?这条语句只是指示编译器,在最终编译成 VM 码时代之以相应内存分配操作。下面我还会详细说明。
2 .函数调用
我们在 IL 代码经常可以看到这样的函数调用语句:
callvirt instance bool Functional.A::PleaseSayIt(string)
或者:
call bool Functional.C::PleaseSayIt(string)
不仅函数声明形式比较接近高级语言,调用形式也相当高级。两种指令, callvirt 和 call 有什么区别?从指令助记符看来, callvirt 仿佛是用来调用虚函数的。我们还注意到, callvirt 指令操作码(函数全名)前有 instance 标识,而 call 指令则没有。我们是否可以这样推测, callvirt 用来调用类实例方法,而 call 调用的是类静态方法?事实证明我们的推测是正确的。那为什么要定义两种函数调用指令?通过下面的解释,我们会得到答案。 call 指令通过类名直接访问在该类中定义的静态方法,对类静态方法来说,其调用可以在编译期指定,这是确定无疑的,它不会在运行期有什么改变。那么 callvirt 指令又怎样?让我们看看下面的这个例子。
class A {
public void PleaseSayIt(string s) {
Console.WriteLine(s+” IN CLASS A”);
}
}
class B : A {
public void PleaseSayIt(string s) {
Console.WriteLine(s+” IN CLASS B”);
}
}
让我们看看执行这样的代码会得到什么样的结果:
A a=new B();
a.PleaseSayIt(“Hello,”);
得到的输出为 Hello,IN CLASS A 。这似乎不是我们期望的结果。这点跟 java 很不一样,在 java 中,如果 B 重载了 A 中的方法,则像上面的语句, a.PleaseSayIt 调用的将是 B 中的函数。而要在 C# 语言中达到这样的目的比在 java 实现麻烦一点,首先需要在 A 的 PleaseSayIt 定义前添加 virtual 关键字,这样在 A 的所有子类的重载 PleaseSayIt 方法都具有了虚函数属性。其次在子类 B 的 PleaseSayIt 定义前添加 override 关键字,说明其基类的方法已被重写。这也就说明了上面的疑问:为什么要有 callvirt 指令,答案是有些函数调用不能在编译期,而是在运行期确定。
好了,我们现在要弄清楚 callvirt 的具体执行过程:首先,根据当前引用,查看被调用的函数是否是虚函数,不是则直接调用该函数;如果是,则在该对象空间内向下查找是否有重写实现,如没有,则也直接调用该函数,如有,则调用重写实现;继续进行上述过程,直到找到最新重写实现。如下所示:
A 、 B 、 C 、 D 继承关系:
A::(virtual)DoSth : B::(override)DoSth : C::(override)DoSth : D::DoSth
代码:
A a=new D();
a.DoSth();
IL 代码:
.locals init([0] class A a)
newobj instance void D::.ctor()
stloc.0
ldloc.0
callvirt instance void A::DoSth()
1 this void DoSth() is virtual ? no : invoke it | yes : goto 2
2 search for next overloaded method void DoSth()
3 is there ? no : invoke it | yes goto 4
4 this method is override ? no : invoke prev mehod | yes : goto 1
类 D 逻辑继承图
则 a.DoSth() 调用的是 C::DoSth() 。经过我这样解释,你现在应该清楚 callvirt 和 call 指令的区别了;更应该清楚 virtual 和 override 的用法了。
其实,除了了 callvirt 和 call 指令外,还有一个特殊的函数调用命令,那既是构造函数调用命令 newobj 。让我们看看这样的语句是怎样实现的:
FunT.A a=new FunT.A();
它的一个实现可以是:
.locals init ([0] class FunT.A V_0)
newobj instance void FunT.C::.ctor()
stloc.0
newobj 指令执行的操作大致上说就是分配一块内存空间,同时获得该内存空间的引用,然后根据该引用调用类构造方法对该空间进行初始化,最后将其引用加载至堆栈之上。
我们再附带讨论一下构造函数。如 A 的一个缺省实现:
public A() {
}
则其 IL 实现为:
.method public hidebysig specialname rtspecialname instance void .ctor() cil managed
{
.maxstack 1
ldarg.0
call instance void [mscorlib]System.Object::.ctor()
ret
}
有两点值得我们注意。一是 ldarg.0 指令,这是装载参数的指令。可是 A 的缺省构造函数并没有参数。注意,虚拟机在遇到 newobj 指令时,需要在对象堆新增加一个结点用于存储该对象引用,同时将堆查找关键值传递给实例,也即是将其引用传给实例(也就是说,对象引用实际上就是堆查找关键值,它是一个 32 位无符号整数)。在实例方法中, 0 参数就是该实例的引用;它不是由实例方法显式指定的。如,我们要调用对象 a 的 PleaseSayIt 方法,其过程是这样的:
.locals init([0] class FunT.A a)
ldloc.0
ldstr “Hello,World!”
callvirt instance void FunT.A::PleaseSayIt(string)
在这里,要将 a 引用隐式传递给 FunT.A::PleaseSayIt 方法;否则的话,类代码和对象数据是分开存储的, A 的对象可能有多个, FunT.A::PleaseSayIt 怎么知道该对哪个对象进行操作呢?我以前也提到过,实例方法参数下标从 1 开始,这是因为对象引用 0 参数被隐藏。而静态方法并不需要也没有对象实例参与,故其下标还是从 0 开始的。正如你所见,方法和对象实例是分立的;关于类和对象的存储方式,我以后还会详细介绍。
还要注意的第二点就是 call instance void [mscorlib]System.Object::.ctor() 语句。显然,它是调用基类构造函数。每当创建新对象时,都要首先调用基类的构造函数。如果我们没有显式指定调用基类的哪个构造函数,则编译器将为我们指定一个默认的构造函数。
关于函数调用,还有几条命令,如 calli 等,在这里就不加讨论了。
3. 局部变量与递归调用
在函数里面,只要有局部变量,就要有如 .locals init( param list…) 的语句。我前面也说过,这条语句只是指示编译器对局部变量进行处理的。那么究竟是起到什么作用呢?看下面的例子。
我们可能在初学编程都用递归调用实现过由 1 到指定数值的逐项求和: 1+2+3+4+……
static long LinearSum(int num) {
long result=1;
if(num==1) return result;
else result=num+LinearSum(num-1);
return result;
}
函数局部变量为:
.locals init (int64 result, int64 retval)
考虑这样的情况,如果函数中定义的局部变量是存储在固定内存空间的话,则在每次进入 LinearSum 函数体时, result 都是上次执行之后的数值,就会造成极大的混乱。在求的不同的数组项和时,它会将所有的值累加直至溢出,除了了第一次是正确的之外,后面的求值会得到莫名奇妙的结果。倘若每次都进行 result=1 的操作,则又会清除以前得到的结果。(在 C/C++ 语言中,可以模拟这种情况,即在 result 声明中添加 static 关键字。在 C# 中,函数方法中不能有静态局部变量。)所以,实际上每次在进入相同的函数内部时,都要重新分配变量空间,存储在运行期得到的数值。在 IL 语言当中,也有内存分配的指令如 initblk 等。因此,每当遇到 .locals init 语句时,这里都会被编译器代之以相关的内存分配指令,在函数末尾添加收回内存的指令。这样,每进入一个函数,首先是分配内存给局部变量(如果有局部变量的话),最后在末尾回收分配给变量的内存,通过这样来实现递归。实际上,在最后的机器码中(经过 JIT 编译之后),局部变量是存储在系统堆栈之中的。对变量的操作,是通过对栈的操作完成的。例如, edx 存储结果的高四字节, eax 存储结果的底四字节,而该 result 变量在堆栈中位于栈顶之前 28h 字节处,则存储的实现形式是这样的:
mov ebp , esp
……
mov dword ptr [ebp-28h] , eax
mov dword ptr [ebp-24h] , edx
在函数的尾部,恢复 esp 的值即可实现内存变量的回收。对这些内容的理解,有助于我们深入了解技术的底层细节。
4. 委托与事件
4 . 1 委托
C# 语言为我们提供了一很方便的特性,这就是委托( delegate )。这使得我们在处理各种事件,特别是 UI 事件时很方便,不象在 C++ 中那样,使用回调( CALL-BACK )函数,不仅麻烦而且容易出错。比如说,我们要在主窗体 MainForm 关闭时处理一些问题,而这些问题并不是由 MainForm 处理,而是由 MyTask 对象处理。那么我们如何得到 MainForm 的关闭消息呢?这里,委托就显示了其灵活性。 MainForm 在关闭时的响应由 Closed 实现,而 Closed 在 C# 中被声明为委托。这样, MyTask 要接收并处理窗体关闭事件,只需要实现一个形式与该委托 System.EventHandler 相同的、含有处理代码的函数,并向 MainForm 的 Closed 注册该函数即可。如,在 MainForm 和 MyTask 被创建之后,执行如下操作: MainForm.Closed += new System.EventHandler(MyTask.ProcessWhileClosed) ,就可以达到由 MyTask 响应 MainForm 的关闭事件的目的了。
下面让我们看看在 IL 层次, .net 是怎样处理委托的。先声明一个委托,如 public delegate void Ehandler(object src) ,然后再反汇编以查看它被处理成什么:
.class public auto ansi sealed EHandler extends System.MulticastDelegate {
.method public hidebysig specialname rtspecialname
instance void .ctor(object 'object', native int 'method') runtime managed {
}
.method public hidebysig virtual instance void Invoke(object src) runtime managed {
}
.method public hidebysig newslot virtual instance class System.IAsyncResult BeginInvoke (object src, class System.AsyncCallback callback, object 'object') runtime managed {
}
.method public hidebysig newslot virtual instance void EndInvoke(class System.IAsyncResult result) runtime managed {
}
}
从这里我们可以看出,我们定义的委托其实是一个从 System.MulticastDelegate 继承而来的密闭( sealed )类,它含有三个方法: BeginInvoke 、 Invoke 、 EndInvoke 。其构造函数有两个参数,第一个 object 型参数为对象引用,接收方法对应的对象,第二个参数为方法引用(为 32 位整数,如同对象引用,有点像函数指针,但又有很大区别,对应 System.IntPtr ( native int )类型)。如,在上面 MainForm.Closed += new System.EventHandler(MyTask.ProcessWhileClosed) 的例子中,传入 EventHandler 构造函数的第一个参数为 MyTask ,第二个参数为 ProcessWhileClosed 方法引用。如果被委托的函数是静态方法,则第一个参数为 null 。这实际上也明确地告诉我们,不要试图去继承 System.MulticastDelegate 来构建自己的委托类,因为我们无法获得方法引用(在 C# 语言当中不能,但在 IL 语言当中有一个 ldftn 指令可以得到方法引用),只有编译器才能确定。事实上, C# 语言也规定了我们不能继承像 MulticastDelegate 这样的特殊类,因为它们是专门为 C# 语言而设计的。从这一点,我们也可以看出, C# 语言是和 .net 类库结合是相当紧密的,它的语法实现由 .net 类库来支持。这也就不难理解为什么说 C# 是专门针对 .net 环境而设计的语言了。
下面我们来看看委托过程调用的实现是怎样的。如, MainForm 对象在其内部的一个合适的方法内调用 Closed 委托 ( 比如 Form.WndProc , C# 中的窗体过程 ) :
……
case WM_CLOSED :
Closed(sender, earg);
break;
……
则调用 Closed 委托的 IL 实现是这样的:
ldarg.0 file:// 加载对象引用
ldfld class MyForm::Closed file:// 获得字段
ldarg.0
callvirt instance void MyForm.Closed::Invoke(object) file:// 间接调用 Closed 的 Invoke 方法
大概的过程是这样的,首先将窗体对象引用( MainForm )加载至堆栈上,再根据该引用将 Closed 委托字段引用加载至堆栈。然后再次将 MainForm 引用加载至堆栈,调用 Closed 委托的 Invoke 方法来调用注册在 Closed 中的方法。这可比 C/C++ 中的回调函数好用多了,一个委托可以注册多个静态或实例方法,处理这些方法都由委托对象帮我们做了,不再需要我们费尽心机编写回调函数来实现了。如果大家有 C/C++ WINDOWS 程序设计经验,就会深刻体会这句话的含义了。
4.2 事件
我在前面介绍了一下委托,这里介绍一下与它结合得比较紧密的特性:事件( event )。委托和事件天生就是兄弟,它们相互合作来实现 C# 中简易方便的特性。还是以上面的例子来说明。看看这样的声明: public System.EventHandler Closed 。 Closed 委托被声明为 public ,这样我们可以在外部像这样 Closed += new EventHandler(your.Process) 向其注册方法。但我们也可以像 MainForm.Closed(sender,arg) 来在外部直接触发该方法,这就违背了面向对象思想中的封装精神。如果将它声明为 private ,则又不能在类之外接受注册了;使用属性来解决,又实在是太麻烦了。 C# 中的解决的方法就是在委托声明前添加 event 关键字,像这样: public event System.EventHandler Closed 。这样, Closed 被声明为事件,它可以在外部接受注册,但不能在外部被调用。这就是 event 关键字的用法了。
添加的 event 关键字还会导致什么样的结果呢?它的作用是不是仅仅让委托可以在类外部接受注册,而不能在外部被调用呢?不仅如此,它还会指示编译器为你生成一个事件属性、两个添加、删除委托的方法:
.event System.EventHandler Closed {
.addon instance void MyForm::add_Closed(class System.EventHandler)
.removeon instance void MyForm::remove_Closed(class System.EventHandler)
}
其中, addon 、 removeon 属性分别对应 += 、 -= 操作。因为我们希望在恰当的时候响应某事件,而在不需要时可以像 MainForm.Closed -= new EventHandler(MyTask.ProcessSth) 这样移除已经注册了的处理方法。下面我们详细讨论一下 addon 属性对应的 add_Closed (EventHandler ) 方法。
.method public hidebysig specialname instance void add_Closed(class System.EventHandler value') cil managed synchronized {
.maxstack 3
ldarg.0
ldarg.0
ldfld class System.EventHandler MyForm::Closed
ldarg.1
call class System.Delegate System.Delegate::Combine(class System.Delegate, class System.Delegate)
castclass System.EventHandler
stfld class System.EventHandler MyForm::Closed
ret
}
这段代码的大概过程是,先得到字段 Closed 委托的引用;再加载参数 1 ,即要添加的委托;然后调用 Delegate.Combine 方法将它们绑定到一个委托。
首先我们感兴趣的是 System.Delegate.Combine ( Delegate,Delegate )函数。从函数名称我们也可以大概看出该函数将一个委托( MulticastDelegate 继承自 Delegate )绑定到另一个委托中去。 Combine 是 Delegate 中定义的静态方法,其作用是将第二个委托中的函数引用添加到第一个委托中的函数引用列表当中去。所以,我们可以看到这样的语句:
EventHandler eh=null;
eh += new EventHandler(instance.SomeMethod);
可能有人会疑惑为什么这样的代码不会引发空引用异常。其实正是因为 Combine 方法,将委托“合并”,才使得这样的代码不会产生异常。也就是说,当我们调用 Delegate.Combine(eh,another) 时,如果 eh 不空,则将 another 委托中的方法引用添加到 eh 的方法引用列表当中;如果 eh 空,则创建 eh 并复制 another 到 eh 。这并不是像其他情况,如我们重载并使用的 += 操作符(就好像定义了一个特殊的方法,然后根据对象引用调用该方法)。从这里也可以看出, C# 对于委托是提供了语言层次上的支持的,编译器在处理委托时,遇到 += 或 -= 操作符,则代之以相应的 add 或 remove 方法。
其次我们感兴趣的方法修饰符中的 synchronized 关键字。这说明,事件添加必须同步,不能被打断。否则可能引发管理上的混乱。如下面的例子,
thread1 : eh += eventhandler1
thread2 : eh += eventhandler2
线程 1 正在将 eventhandler1 中的函数引用添加到 eh 时被中断,然后线程 2 执行将 eventhandler2 中的函数引用添加到 eh 中。这可能造成先注册的函数反而后执行,很有可能造成许多的问题。
[ 后记 ]
关于 IL 的文章,我想可能暂时到这里告一段落了。我觉得呢,动手写的过程,也是一个自我提高的过程;虽然我以前对 JVM 机制有一点的了解,但是通过这一段时间对 IL 的研究,本文的完成,使得我对虚拟机及 .net 底层有了更深的了解。
由于作者水平有限,文章中可能会存在这样那样的错误缺点,希望大家能够不吝赐教;可能还有比较多关于 <