为什么在struct字段上调用实例方法的C#struct实例方法首先检查ecx?

时间:2016-05-09 01:26:17

标签: c# x86 jit

为什么以下C#方法CallViaStruct的X86包含cmp指令?

struct Struct {
    public void NoOp() { }
}
struct StructDisptach {

    Struct m_struct;

    [MethodImpl(MethodImplOptions.NoInlining)]
    public void CallViaStruct() {
        m_struct.NoOp();
        //push        ebp  
        //mov         ebp,esp  
        //cmp         byte ptr [ecx],al  
        //pop         ebp  
        //ret
    }
}

这是一个更完整的程序,可以使用各种(发布)解压缩作为注释进行编译。我预计CallViaStructClassDispatch类型中StructDispatch的X86相同,但StructDispatch(上面提取的)中的版本包含cmp指令另一个没有。

cmp指令似乎是用来确保变量不为空的习惯用法;取消引用值为0的寄存器会触发av转换为NullReferenceException。但是在StructDisptach.CallViaStruct中,我无法想象ecx因为它指向一个结构而为空的方法。

更新:我希望接受的答案将包含导致NRE被StructDisptach.CallViaStruct抛出的代码,方法是让cmp指令取消引用归零{{1}注册。请注意,使用ecx方法可以很容易地设置CallViaClass而无法使用m_class = null,因为没有ClassDisptach.CallViaStruct指令。

cmp

更新:事实证明,可以在非虚拟函数上使用using System.Runtime.CompilerServices; namespace NativeImageTest { struct Struct { public void NoOp() { } } class Class { public void NoOp() { } } class ClassDisptach { Class m_class; Struct m_struct; internal ClassDisptach(Class cls) { m_class = cls; m_struct = new Struct(); } [MethodImpl(MethodImplOptions.NoInlining)] public void CallViaClass() { m_class.NoOp(); //push ebp //mov ebp,esp //mov eax,dword ptr [ecx+4] //cmp byte ptr [eax],al //pop ebp //ret } [MethodImpl(MethodImplOptions.NoInlining)] public void CallViaStruct() { m_struct.NoOp(); //push ebp //mov ebp,esp //pop ebp //ret } } struct StructDisptach { Class m_class; Struct m_struct; internal StructDisptach(Class cls) { m_class = cls; m_struct = new Struct(); } [MethodImpl(MethodImplOptions.NoInlining)] public void CallViaClass() { m_class.NoOp(); //push ebp //mov ebp,esp //mov eax,dword ptr [ecx] //cmp byte ptr [eax],al //pop ebp //ret } [MethodImpl(MethodImplOptions.NoInlining)] public void CallViaStruct() { m_struct.NoOp(); //push ebp //mov ebp,esp //cmp byte ptr [ecx],al //pop ebp //ret } } class Program { static void Main(string[] args) { var classDispatch = new ClassDisptach(new Class()); classDispatch.CallViaClass(); classDispatch.CallViaStruct(); var structDispatch = new StructDisptach(new Class()); structDispatch.CallViaClass(); structDispatch.CallViaStruct(); } } } ,该函数具有null检查this指针的副作用。虽然这是callvirt调用点的情况(这就是我们在那里看到空检查的原因)CallViaClass使用StructDispatch.CallViaStruct指令。

call

更新:有人建议.method public hidebysig instance void CallViaClass() cil managed noinlining { // Code size 12 (0xc) .maxstack 8 IL_0000: ldarg.0 IL_0001: ldfld class NativeImageTest.Class NativeImageTest.StructDisptach::m_class IL_0006: callvirt instance void NativeImageTest.Class::NoOp() IL_000b: ret } // end of method StructDisptach::CallViaClass .method public hidebysig instance void CallViaStruct() cil managed noinlining { // Code size 12 (0xc) .maxstack 8 IL_0000: ldarg.0 IL_0001: ldflda valuetype NativeImageTest.Struct NativeImageTest.StructDisptach::m_struct IL_0006: call instance void NativeImageTest.Struct::NoOp() IL_000b: ret } // end of method StructDisptach::CallViaStruct 可以捕获cmp此指针未在呼叫站点被捕获的情况。如果是这种情况,那么我希望null在方法的顶部出现一次。但是,每次调用cmp时都会显示一次:

NoOp

1 个答案:

答案 0 :(得分:3)

简短回答:JITter无法证明结构未被指针引用,并且必须至少在每次调用NoOp()时至少取消引用一次以获得正确的行为。

答案很长:结构很奇怪。

JITter很保守。只要有可能,它只能以非常某些产生正确行为的方式优化代码。 “大部分都是正确的”还不够好。

所以现在这里是一个示例场景,如果JITter优化了取消引用,它将会中断。请考虑以下事实:

首先:请记住,结构可以(并且可以!)存在于C#之外 - 例如,指向StructDispatch的指针可能来自非托管代码。正如卢卡斯所指出的,你可以用指针作弊;但是JITter无法确定您是否在代码中的其他位置使用了指向StructDispatch的指针。

第二:请记住,在非托管代码中,这是结构首先存在的最大原因,所有的赌注都是关闭的。仅仅因为你只是从内存中读取一个值并不意味着它将是相同的值,甚至一个值,下次你读取相同的确切地址。线程化和多处理,可以在下一个时钟滴答声中改变那些值,更不用说像DMA这样的非CPU演员了。一个并行线程可以VirtualFree()包含该结构的页面,而JITter必须防范它。你要求从内存读取,所以你从内存中读取。我的猜测是,如果你踢了优化器,它会删除其中一条cmp指令,但我非常怀疑它会删除它们。

第三:例外也是真实的代码。 NullReferenceException不一定会停止程序;它可以被抓住和处理。这意味着从JITter的角度来看,NRE更像是一个if语句而不是goto:它是一种必须在每个内存解除引用时处理和考虑的条件分支。

所以现在把这些碎片放在一起。

JITter不知道 - 也无法知道 - 您没有使用不安全的C#或其他地方的外部源来与StructDispatch的内存进行交互。它不会产生CallViaStruct()的单独实现,一个用于“可能安全的C#代码”,另一个用于“可能有风险的外部代码”;它始终为可能存在风险的场景生成保守版本。这意味着它不能完全切断对NoOp()的调用,因为不能保证StructDispatch不会映射到甚至没有被分页到内存中的地址。

它知道NoOp()是空的并且可以省略(调用可以消失),但它至少必须通过戳结构的内存地址来模拟 ldfla,因为那里可能是代码取决于提出的NRE。内存解除引用就像if语句:它们可能导致分支,并且无法导致分支可能导致程序损坏。微软无法做出假设,只是说,“你的代码不应该依赖于它。”想象一下,如果NRE没有被写入业务的错误日志,那么只是因为JITter认为它不是一个“重要的”NRE而不是首先触发的,因此给微软发起了愤怒的电话。 JITter别无选择,只能解析该地址至少一次以确保正确的语义。

课程没有任何这些问题;一个班级没有强制记忆怪异。但结构却更加古怪。