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

Chr*_*ing 20 c# x86 jit

为什么以下C#方法的X86 CallViaStruct包含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
    }
}
Run Code Online (Sandbox Code Playgroud)

这是一个更完整的程序,可以使用各种(发布)解压缩作为注释进行编译.我期望CallViaStruct两者ClassDispatchStructDispatch类型中的X86 相同但是StructDispatch(上面提取的)中的版本包括cmp指令而另一个没有.

看来该cmp指令是一个成语用于确保变量不为空; 取消引用值为0的寄存器会触发一个av转为a的寄存器NullReferenceException.然而,在StructDisptach.CallViaStructecx指向一个结构时,我无法设想为null 的方法.

更新:我希望接受的答案将包括StructDisptach.CallViaStruct通过使其cmp指令取消引用归零ecx寄存器而导致NRE被抛出的代码.请注意,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();
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

更新:原来可以callvirt在非虚函数上使用,该函数具有null检查this指针的副作用.虽然这是CallViaClasscallsite 的情况(这就是我们在那里看到null检查的原因)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
Run Code Online (Sandbox Code Playgroud)

更新:有人建议cmp可以捕获null这个指针没有被困在调用站点的情况.如果是这种情况,那么我希望cmp在方法的顶部出现一次.但是,每次调用都会出现一次NoOp:

struct StructDisptach {

    Struct m_struct;

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

Sea*_*ema 3

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

\n\n
\n\n

长答案:结构很奇怪。

\n\n

JITter 是保守的。只要有可能,它只能以绝对确定产生正确行为的方式优化代码。“大部分正确”还不够好。

\n\n

现在这里有一个示例场景,如果 JITter 优化取消引用,该场景就会中断。考虑以下事实:

\n\n

第一:请记住,结构可以(并且确实!)存在于 C# \xe2\x80\x94 之外,例如,指向 StructDispatch 的指针可能来自非托管代码。正如卢卡斯指出的,你可以使用指针来作弊;但 JITter 无法确定您没有在代码中的其他位置使用指向 StructDispatch 的指针。

\n\n

第二:请记住,在非托管代码中(这是结构存在的最大原因),所有的赌注都将被取消。仅仅因为您刚刚从内存中读取了一个值,并不意味着它会是相同的值,甚至您下次读取相同的精确地址时也是一个值。线程和多处理实际上可以在下一个时钟周期改变该值,更不用说像 DMA 这样的非 CPU 参与者了。并行线程可以 VirtualFree() 包含该结构的页面,并且 JITter 必须防范这种情况。您要求从内存中读取数据,因此您会从内存中读取数据。我的猜测是,如果您启动优化器,它会删除这些 cmp 指令之一,但我非常怀疑它是否会删除这两条指令。

\n\n

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

\n\n

现在把这些部分放在一起。

\n\n

JITter 不知道 \xe2\x80\x94 并且无法知道 \xe2\x80\x94 您没有使用不安全的 C# 或其他地方的外部源来与 StructDispatch 的内存进行交互。它不会生成 CallViaStruct() 的单独实现,一种用于“可能安全的 C# 代码”,另一种用于“可能有风险的外部代码”;它总是为可能存在风险的情况生成保守版本。这意味着它不能完全删除对 NoOp() 的调用,因为不能保证 StructDispatch 不会映射到甚至没有分页到内存中的地址。

\n\n

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

\n\n
\n\n

类没有任何这些问题;类中没有强制的记忆怪异。但结构却更加古怪。

\n