CIL 指令“Call”和“Callvirt”有什么区别?
6 回答
当运行时执行一条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
.
不幸的是,我的电脑目前无法使用,但一旦它再次启动,我将对此进行试验。
call
用于调用非虚拟、静态或超类方法,即调用的目标不受覆盖。callvirt
用于调用虚方法(因此如果this
是覆盖该方法的子类,则改为调用子类版本)。
出于这个原因,.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();
}
}
注意这个类不需要被密封才能工作。
所以看起来如果所有这些事情都是真的,编译器会发出一个调用:
- 方法调用是在对象创建之后立即调用的
- 该方法未在基类中实现
根据 MSDN:
致电:
调用指令调用与指令一起传递的方法描述符所指示的方法。方法描述符是一个元数据标记,指示要调用的方法……元数据标记携带足够的信息来确定调用是对静态方法、实例方法、虚拟方法还是全局函数。在所有这些情况下,目标地址完全由方法描述符确定(这与调用虚拟方法的 Callvirt 指令形成对比,其中目标地址还取决于在 Callvirt 之前推送的实例引用的运行时类型)。
callvirt 指令调用对象的后期绑定方法。也就是说,方法是根据 obj 的运行时类型而不是方法指针中可见的编译时类来选择的。Callvirt 可用于调用虚拟和实例方法。
所以基本上,不同的路由被用来调用一个对象的实例方法,覆盖与否:
调用:变量 ->变量的类型对象 -> 方法
CallVirt:变量 -> 对象实例 ->对象的类型对象 -> 方法
可能值得在前面的答案中添加的一件事是,“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,然后到实际插槽)
希望这会有所帮助,鲍里斯
只是添加到上面的答案,我认为改变已经很久了,这样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/