CSDN博客

img cambest

IL代码底层运行机制之函数相关

发表于2003/11/27 0:29:00  1473人阅读

IL代码底层运行机制之

  IL代码底层运行机制之

                                                               函数相关

                                                                                                                     刘强

                                                                                                          Cambest@sohu.com

                                                                                                            20031031

 

    本文涉及到的内容可能算是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

}

这是我们的第一个ILHello,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)

不仅函数声明形式比较接近高级语言,调用形式也相当高级。两种指令,callvirtcall有什么区别?从指令助记符看来,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实现麻烦一点,首先需要在APleaseSayIt定义前添加virtual关键字,这样在A的所有子类的重载PleaseSayIt方法都具有了虚函数属性。其次在子类BPleaseSayIt定义前添加override关键字,说明其基类的方法已被重写。这也就说明了上面的疑问:为什么要有callvirt指令,答案是有些函数调用不能在编译期,而是在运行期确定。

好了,我们现在要弄清楚callvirt的具体执行过程:首先,根据当前引用,查看被调用的函数是否是虚函数,不是则直接调用该函数;如果是,则在该对象空间内向下查找是否有重写实现,如没有,则也直接调用该函数,如有,则调用重写实现;继续进行上述过程,直到找到最新重写实现。如下所示:

ABCD继承关系:

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()。经过我这样解释,你现在应该清楚callvirtcall指令的区别了;更应该清楚virtualoverride的用法了。

其实,除了了callvirtcall指令外,还有一个特殊的函数调用命令,那既是构造函数调用命令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参数就是该实例的引用;它不是由实例方法显式指定的。如,我们要调用对象aPleaseSayIt方法,其过程是这样的:

.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        ebpesp

    ……

       mov    dword ptr [ebp-28h]eax

mov    dword ptr [ebp-24h]edx

在函数的尾部,恢复esp的值即可实现内存变量的回收。对这些内容的理解,有助于我们深入了解技术的底层细节。

 

4.  委托与事件

4委托

    C#语言为我们提供了一很方便的特性,这就是委托(delegate)。这使得我们在处理各种事件,特别是UI事件时很方便,不象在C++中那样,使用回调(CALL-BACK)函数,不仅麻烦而且容易出错。比如说,我们要在主窗体MainForm关闭时处理一些问题,而这些问题并不是由MainForm处理,而是由MyTask对象处理。那么我们如何得到MainForm的关闭消息呢?这里,委托就显示了其灵活性。MainForm在关闭时的响应由Closed实现,而ClosedC#中被声明为委托。这样,MyTask要接收并处理窗体关闭事件,只需要实现一个形式与该委托System.EventHandler相同的、含有处理代码的函数,并向MainFormClosed注册该函数即可。如,在MainFormMyTask被创建之后,执行如下操作: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)类,它含有三个方法:BeginInvokeInvokeEndInvoke。其构造函数有两个参数,第一个object型参数为对象引用,接收方法对应的对象,第二个参数为方法引用(为32位整数,如同对象引用,有点像函数指针,但又有很大区别,对应System.IntPtrnative 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.WndProcC#中的窗体过程)

       ……

       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://间接调用ClosedInvoke方法

大概的过程是这样的,首先将窗体对象引用(MainForm)加载至堆栈上,再根据该引用将Closed委托字段引用加载至堆栈。然后再次将MainForm引用加载至堆栈,调用Closed委托的Invoke方法来调用注册在Closed中的方法。这可比C/C++中的回调函数好用多了,一个委托可以注册多个静态或实例方法,处理这些方法都由委托对象帮我们做了,不再需要我们费尽心机编写回调函数来实现了。如果大家有C/C++ WINDOWS程序设计经验,就会深刻体会这句话的含义了。

4.2          事件

我在前面介绍了一下委托,这里介绍一下与它结合得比较紧密的特性:事件(event)。委托和事件天生就是兄弟,它们相互合作来实现C#中简易方便的特性。还是以上面的例子来说明。看看这样的声明:public System.EventHandler ClosedClosed委托被声明为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)

}

其中,addonremoveon属性分别对应 +=-= 操作。因为我们希望在恰当的时候响应某事件,而在不需要时可以像 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.CombineDelegate,Delegate)函数。从函数名称我们也可以大概看出该函数将一个委托(MulticastDelegate继承自Delegate)绑定到另一个委托中去。CombineDelegate中定义的静态方法,其作用是将第二个委托中的函数引用添加到第一个委托中的函数引用列表当中去。所以,我们可以看到这样的语句:

    EventHandler eh=null;

    eh += new EventHandler(instance.SomeMethod);

可能有人会疑惑为什么这样的代码不会引发空引用异常。其实正是因为Combine方法,将委托“合并”,才使得这样的代码不会产生异常。也就是说,当我们调用Delegate.Combine(eh,another)时,如果eh不空,则将another委托中的方法引用添加到eh的方法引用列表当中;如果eh空,则创建eh并复制anothereh。这并不是像其他情况,如我们重载并使用的 += 操作符(就好像定义了一个特殊的方法,然后根据对象引用调用该方法)。从这里也可以看出,C#对于委托是提供了语言层次上的支持的,编译器在处理委托时,遇到 +=-=操作符,则代之以相应的addremove方法。

其次我们感兴趣的方法修饰符中的synchronized关键字。这说明,事件添加必须同步,不能被打断。否则可能引发管理上的混乱。如下面的例子,

        thread1 : eh += eventhandler1

        thread2 : eh += eventhandler2

线程1正在将eventhandler1中的函数引用添加到eh时被中断,然后线程2执行将eventhandler2中的函数引用添加到eh中。这可能造成先注册的函数反而后执行,很有可能造成许多的问题。

 

[后记]

关于IL的文章,我想可能暂时到这里告一段落了。我觉得呢,动手写的过程,也是一个自我提高的过程;虽然我以前对JVM机制有一点的了解,但是通过这一段时间对IL的研究,本文的完成,使得我对虚拟机及.net底层有了更深的了解。

由于作者水平有限,文章中可能会存在这样那样的错误缺点,希望大家能够不吝赐教;可能还有比较多关于IL技术方面的东西我没有涉及到,也希望大家来信讨论。这里,还有一个比较深的话题就是C#中的特性(attribute)。可能有人还会有人对特性感到不太明白,如什么是特性,特性有什么作用,特性在底层,在IL层次是怎么实现的,什么时候应该使用特性,等等,就像我刚接触到C#一样,也很迷惑。由于最近比较忙,所以不能马上撰文加以说明。在这期间,也希望有人来信提出你们的见解,我将非常高兴与你们进行此类交流。再说一下我 emaicambest@sohu.com

 

欢迎各网络媒体在保留作者署名、不对文章做未经授权的修改的情况下转载作者系列文章。

 

[参考书籍]

   Java虚拟机规范,Tim LindholmFrank Yellin著,玄建伟等译

   C#高级编程,Simon RobinsonK. Scott Allen著,杨浩,杨铁男等译

阅读全文
0 0

相关文章推荐

img
取 消
img