电话和Callvirt

问题描述 投票:56回答:6

CIL指令“Call”和“Callvirt”之间有什么区别?

.net reflection cil reflection.emit
6个回答
48
投票

call用于调用非虚拟,静态或超类方法,即调用的目标不受覆盖。 callvirt用于调用虚方法(因此,如果this是覆盖该方法的子类,则调用子类版本)。


52
投票

当运行时执行call指令时,它正在调用一段精确的代码(方法)。毫无疑问它存在于何处。 一旦IL被JIT,在调用站点生成的机器代码是无条件的jmp指令。

相比之下,callvirt指令用于以多态方式调用虚方法。必须在运行时为每次调用确定方法代码的确切位置。生成的JITted代码涉及通过vtable结构的一些间接。因此,调用执行起来较慢,但它更灵活,因为它允许多态调用。

请注意,编译器可以为虚拟方法发出call指令。例如:

sealed class SealedObject : object
{
   public override bool Equals(object o)
   {
      // ...
   }
}

考虑调用代码:

SealedObject a = // ...
object b = // ...

bool equal = a.Equals(b);

虽然System.Object.Equals(object)是一种虚方法,但在这种用法中,Equals方法无法存在重载。 SealedObject是一个密封的类,不能有子类。

出于这个原因,.NET的sealed类可以比非密封类对应的方法具有更好的方法调度性能。

编辑:原来我错了。 C#编译器无法无条件跳转到方法的位置,因为对象的引用(方法中this的值)可能为null。相反,它会发出callvirt,它会执行null检查并在需要时抛出。

这实际上解释了我在.NET框架中使用Reflector找到的一些奇怪的代码:

if (this==null) // ...

编译器可以发出可验证的代码,该代码具有this指针(local0)的空值,只有csc不会这样做。

所以我猜call仅用于类静态方法和结构。

鉴于此信息,我现在认为sealed仅对API安全性有用。我发现another question似乎表明密封你的课程没有性能上的好处。

编辑2:除此之外还有更多内容。例如,以下代码发出call指令:

new SealedObject().Equals("Rubber ducky");

显然,在这种情况下,对象实例不可能为null。

有趣的是,在DEBUG构建中,以下代码发出callvirt

var o = new SealedObject();
o.Equals("Rubber ducky");

这是因为您可以在第二行设置断点并修改o的值。在发布版本中,我想这个调用将是call而不是callvirt

不幸的是我的PC目前还没有动作,但是一旦它重新上升,我会试验一下。


11
投票

出于这个原因,.NET的密封类可以比非密封类具有更好的方法调度性能。

不幸的是,这种情况并非如此。 Callvirt做了另一件让它变得有用的事情。当一个对象有一个调用它的方法时,callvirt将检查该对象是否存在,如果没有抛出NullReferenceException。即使对象引用不存在,调用也只会跳转到内存位置,并尝试执行该位置的字节。

这意味着callvirt总是由C#编译器(不确定VB)用于类,并且call总是用于结构(因为它们永远不能为null或子类)。

编辑响应Drew Noakes评论:是的,似乎您可以让编译器为任何类发出调用,但仅限于以下非常具体的情况:

public class SampleClass
{
    public override bool Equals(object obj)
    {
        if (obj.ToString().Equals("Rubber Ducky", StringComparison.InvariantCultureIgnoreCase))
            return true;

        return base.Equals(obj);
    }

    public void SomeOtherMethod()
    {
    }

    static void Main(string[] args)
    {
        // This will emit a callvirt to System.Object.Equals
        bool test1 = new SampleClass().Equals("Rubber Ducky");

        // This will emit a call to SampleClass.SomeOtherMethod
        new SampleClass().SomeOtherMethod();

        // This will emit a callvirt to System.Object.Equals
        SampleClass temp = new SampleClass();
        bool test2 = temp.Equals("Rubber Ducky");

        // This will emit a callvirt to SampleClass.SomeOtherMethod
        temp.SomeOtherMethod();
    }
}

注意为了使其工作,不必密封该类。

所以如果所有这些都是真的,看起来编译器会发出一个调用:

  • 方法调用在对象创建之后立即进行
  • 该方法未在基​​类中实现

6
投票

根据MSDN:

Call

调用指令调用由指令传递的方法描述符指示的方法。方法描述符是指示要调用的方法的元数据标记...元数据标记携带足够的信息以确定调用是静态方法,实例方法,虚拟方法还是全局函数。在所有这些情况下,目标地址完全由方法描述符确定(与调用虚拟方法的Callvirt指令形成对比,其中目标地址还取决于在Callvirt之前推送的实例引用的运行时类型)。

CallVirt

callvirt指令调用对象的后期绑定方法。也就是说,该方法是基于obj的运行时类型而不是方法指针中可见的编译时类来选择的。 Callvirt可用于调用虚拟和实例方法。

所以基本上,采用不同的路由来调用对象的实例方法,覆盖或不覆盖:

调用:变量 - >变量的类型对象 - >方法

CallVirt:变量 - >对象实例 - >对象的类型对象 - >方法


2
投票

也许值得添加到之前的答案的一件事是,似乎只有一个面对“IL调用”实际执行的方式,以及两个面对“IL callvirt”如何执行。

采取此样本设置。

    public class Test {
        public int Val;
        public Test(int val)
            { Val = val; }
        public string FInst () // note: this==null throws before this point
            { return this == null ? "NO VALUE" : "ACTUAL VALUE " + Val; }
        public virtual string FVirt ()
            { return "ALWAYS AN ACTUAL VALUE " + Val; }
    }
    public static class TestExt {
        public static string FExt (this Test pObj) // note: pObj==null passes
            { return pObj == null ? "NO VALUE" : "VALUE " + pObj.Val; }
    }

首先,FInst()和FExt()的CIL主体是100%相同的,操作码到操作码(除了一个被声明为“实例”而另一个被称为“静态”) - 然而,FInst()将被调用“callvirt”和FExt()带有“call”。

其次,FInst()和FVirt()都将使用“callvirt”调用 - 即使一个是虚拟但另一个不是 - 但它不是真正能够执行的“相同的callvirt”。

以下是JITting后大致发生的事情:

    pObj.FExt(); // IL:call
    mov         rcx, <pObj>
    call        (direct-ptr-to) <TestExt.FExt>

    pObj.FInst(); // IL:callvirt[instance]
    mov         rax, <pObj>
    cmp         byte ptr [rax],0
    mov         rcx, <pObj>
    call        (direct-ptr-to) <Test.FInst>

    pObj.FVirt(); // IL:callvirt[virtual]
    mov         rax, <pObj>
    mov         rax, qword ptr [rax]  
    mov         rax, qword ptr [rax + NNN]  
    mov         rcx, <pObj>
    call        qword ptr [rax + MMM]  

“call”和“callvirt [instance]”之间的唯一区别是“callvirt [instance]”故意在调用实例函数的直接指针之前尝试从* pObj访问一个字节(为了可能引发异常“就在那里,然后“)。

因此,如果您对您必须编写“检查部分”的次数感到恼火

var d = GetDForABC (a, b, c);
var e = d != null ? d.GetE() : ClassD.SOME_DEFAULT_E;

你不能推“if(this == null)return SOME_DEFAULT_E;”进入ClassD.GetE()本身(因为“IL callvirt [instance]”语义禁止你这样做)但如果你将.GetE()移动到某个扩展函数,你可以自由地将它推入.GetE() (因为“IL调用”语义允许它 - 但唉,失去对私人成员的访问等)

也就是说,“callvirt [instance]”的执行与“call”的共同点比“callvirt [virtual]”更多,因为后者可能必须执行三重间接才能找到函数的地址。 (间接到typedef base,然后到base-vtab-or-some-interface,然后到实际的slot)

希望这有帮助,鲍里斯


1
投票

只是添加到上面的答案,我认为已经做了很长的改变,以便为所有实例方法生成Callvirt IL指令,并且将为静态方法生成调用IL指令。

参考:

Pluralsight课程“C#语言内部 - 第1部分:Bart De Smet(视频 - CLR IL中的调用指令和调用栈)

还有https://blogs.msdn.microsoft.com/ericgu/2008/07/02/why-does-c-always-use-callvirt/

© www.soinside.com 2019 - 2024. All rights reserved.