呼叫和Callvirt

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

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

当运行时执行一个call指令时,它会调用一段精确的代码(方法)。 毫无疑问,它存在于哪里。 一旦IL被打印出来,在呼叫地点产生的机器码是无条件的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类可以比非sealed类具有更好的方法调度性能。

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

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

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

编译器可能会发出可validation的代码,该代码的空指针(local0)为空值,只有csc不会这样做。

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

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

编辑2:还有更多这似乎。 例如下面的代码发出一个call指令:

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

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

有趣的是,在DEBUG构build中,下面的代码发出callvirt

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

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

不幸的是我的个人电脑目前无法使用,但是一旦它重新开始,我就会尝试一下。

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

不幸的是,这种情况并非如此。 Callvirt还有其他一些有用的东西。 当一个对象有一个方法调用它时,callvirt会检查对象是否存在,如果不是则抛出一个NullReferenceException。 即使对象引用不存在,调用也将跳转到内存位置,并尝试执行该位置的字节。

这意味着callvirt总是被类C#编译器(不确定VB)所使用,而且调用总是用于结构(因为它们不能为空或者子类)。

编辑作为对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(); } } 

注意这个类不需要被密封起来。

因此,如果所有这些事情都是真的,编译器就会发出一个调用:

  • 方法调用是在创build对象之后立即进行的
  • 该方法没有在基类中实现

根据MSDN:

致电 :

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

CallVirt :

callvirt指令在一个对象上调用一个后期绑定的方法。 也就是说, 该方法是根据obj的运行时types而不是在方法指针中可见的编译时类来select的 。 Callvirt可以用来调用虚拟和实例方法。

所以基本上,采取不同的路由来调用一个对象的实例方法,重写或不重写:

调用:variables – > variables的types对象 – >方法

CallVirt:variables – >对象实例 – > 对象的types对象 – >方法

有一件事值得补充的是,“IL调用”实际执行的方式似乎只有一个,而“IL调用”执行的方式则有两个方面。

以此示例设置。

  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%相同的,opcode-to-opcode(除了一个被声明为“instance”,另外一个是“static”) – 但是,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中的一个字节(以便可能抛出exception“在那里然后“)。

因此,如果你烦恼的次数,你必须写的“检查部分”

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

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

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

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

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

参考:

Pluralsight课程“C#语言内部 – 第1部分由Bart De Smet(video – CLR IL中的调用指令和调用栈简介)

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