62

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

4

6 回答 6

60

当运行时执行一条call指令时,它正在调用一段确切的代码(方法)。毫无疑问它存在于何处。 一旦 IL 被 JITted,在调用站点生成的机器代码就是一个无条件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方法内的值)可能为空。相反,它会发出callvirt执行 null 检查并在需要时抛出。

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

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

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

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

鉴于这些信息,现在在我看来,这sealed仅对 API 安全有用。我发现另一个问题似乎表明密封你的课程没有性能优势。

编辑2:这比看起来的要多。例如下面的代码发出一条call指令:

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

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

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

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

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

不幸的是,我的电脑目前无法使用,但一旦它再次启动,我将对此进行试验。

于 2008-10-11T10:51:59.837 回答
51

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

于 2008-10-11T10:45:14.327 回答
11

出于这个原因,.NET 的密封类比它们的非密封类具有更好的方法分派性能。

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

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

编辑回应 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();
    }
}

注意这个类不需要被密封才能工作。

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

  • 方法调用是在对象创建之后立即调用的
  • 该方法未在基​​类中实现
于 2008-10-11T11:02:48.770 回答
7

根据 MSDN:

致电

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

呼叫虚拟机

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

所以基本上,不同的路由被用来调用一个对象的实例方法,覆盖与否:

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

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

于 2010-10-03T13:34:05.793 回答
4

可能值得在前面的答案中添加的一件事是,“IL call”的实际执行方式似乎只有一个方面,而“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”和带有“call”的 FExt()。

其次,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,然后到实际插槽)

希望这会有所帮助,鲍里斯

于 2015-06-28T15:15:02.313 回答
1

只是添加到上面的答案,我认为改变已经很久了,这样Callvirt IL指令将为所有实例方法生成,Call IL指令将为静态方法生成。

参考 :

Pluralsight 课程“C# 语言内部知识 - 第 1 部分,由 Bart De Smet 撰写(视频——简而言之 CLR IL 中的调用指令和调用堆栈)

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

于 2017-03-13T16:01:10.647 回答