对不起文字的墙,但我想提供一个很好的背景情况。我知道你可以在IL中调用null引用的方法,但是在你理解CLR的工作方式时,仍然不会理解一些非常奇怪的事情。我在这里发现的其他几个问题没有涉及我在这里看到的行为。
这是一些IL:
.assembly MrSandbox {}
.class private MrSandbox.AClass {
.field private int32 myField
.method public int32 GetAnInt() cil managed {
.maxstack 1
.locals init ([0] int32 retval)
ldc.i4.3
stloc retval
ldloc retval
ret
}
.method public int32 GetAnotherInt() cil managed {
.maxstack 1
.locals init ([0] int32 retval)
ldarg.0
ldfld int32 MrSandbox.AClass::myField
stloc retval
ldloc retval
ret
}
}
.class private MrSandbox.Program {
.method private static void Main(string[] args) cil managed {
.entrypoint
.maxstack 1
.locals init ([0] class MrSandbox.AClass p,
[1] int32 myInt)
ldnull
stloc p
ldloc p
call instance int32 MrSandbox.AClass::GetAnotherInt()
stloc myInt
ldloc myInt
call void [mscorlib]System.Console::WriteLine(int32)
ret
}
}
现在,当这段代码运行时,我们得到了我期望发生的事情,有点。 callvirt
将检查空值,call
不会,但是,此处会调用NullReferenceException
。这对我来说并不清楚,因为我希望System.AccessViolationException
代替Main(string[] args)
。我将在这个问题的最后解释我的推理。
如果我们将.locals
内的代码替换为 ldnull
stloc p
ldloc p
call instance int32 MrSandbox.AClass::GetAnInt()
stloc myInt
ldloc myInt
call void [mscorlib]System.Console::WriteLine(int32)
ret
行之后的代码:
3
令我惊讶的是,这一次运行,并将Main(string[] args)
打印到控制台,成功退出。我在null引用上调用一个函数,它正在执行。我的猜测是它与没有调用实例字段这一事实有关,因此CLR可以成功执行代码。
最后,这就是我真正困惑的地方,用.locals
替换 ldnull
stloc p
ldloc p
call instance int32 MrSandbox.AClass::GetAnInt()
stloc myInt
ldloc myInt
call void [mscorlib]System.Console::WriteLine(int32)
call valuetype [mscorlib]System.ConsoleKeyInfo [mscorlib]System.Console::ReadKey()
pop
call instance int32 MrSandbox.AClass::GetAnotherInt()
stloc myInt
ldloc myInt
call void [mscorlib]System.Console::WriteLine(int32)
ret
中的代码(在3
行之后):
NullReferenceException
现在,您希望此代码能做什么?我希望代码将System.AccessViolationException
写入控制台,从控制台读取密钥,然后在callvirt
上失败。嗯,没有发生这种情况。相反,除了NullReferenceException
之外,屏幕上不会打印任何值。为什么它不一致?
有了背景,以下是我的问题:
1)如果obj为空,MSDN列出call
将抛出call
,但callvirt
只是说它不能为空。为什么然后,它是默认抛出NRE而不是访问冲突?在我看来,call
通过契约将尝试访问内存并失败,而不是通过首先检查null来执行AccessViolationException
所做的事。
2)第二个例子是否起作用的原因是它没有访问类级字段并且{{1}}没有进行空检查?如果是这样,如何在空引用上调用非静态方法并返回成功?我的理解是,当一个引用类型放在堆栈上时,只有它放在堆上的Type对象。那么从类型对象调用的方法是什么?
3)为什么在第一个和最后一个例子之间存在异常差异?在我看来,第三个例子抛出了正确的异常,{{1}},因为这正是它正在尝试做的事情;访问未分配的内存。
在“行为未定义”答案开始之前,我知道这不是 AT ALL 一种正确的写作方式,我只是希望有人可以帮助提供一些见解以上问题。
感谢。
答案 0 :(得分:5)
1)处理器 引发访问冲突。 CLR根据异常的访问地址捕获异常并转换它。地址空间的前64KB内的任何访问都将作为托管NullReferenceException重新引发。请查看this answer以供参考。
2)是的,CLR不会强制执行非此值。例如,C ++ / CLI编译器生成的代码不执行此检查,就像本机C ++一样。只要该方法不使用 this 引用,就不会导致异常。在方法调用callvirt之前,C#编译器显式生成代码以验证 this 的值。请参阅this blog post以供参考。
3)你错了IL,GetAnotherInt()是一个实例方法,但你忘了编写ldloc指令。你得到一个AV,因为引用指针是随机的。
答案 1 :(得分:1)
我无法回答2)肯定,但这里是1)和3)。
NullReferenceException
与AccessViolationException
相同;在CLR的早期,根本没有AccessViolationException
,并且取消引用无效但非空指针仍然提供NullReferenceException
。
这是因为在今天的计算机上,让硬件进行健全性检查的成本更低。您抛出哪个异常的概念是基于CLR进行显式空检查(if (foo == null) throw new NullReferenceException()
)的想法,但微软的Windows PC实现并非如此。
当您取消引用无效地址时,您的程序会因为执行无效操作而中断; CLR挂接到该中断,并将根据触发故障的地址抛出NullReferenceException
或AccessViolationException
。这样,它不需要插入任何内存检查,它仍然会以可预测的方式运行。
如果我没记错的话,访问0xFFFF
下的任何地址都会产生NullReferenceException
,上面的任何内容都会是AccessViolationException
。您可以使用不安全的代码和指针进行验证。我自己从来没有在C#中使用过不安全的代码,所以下面的代码片段可能不起作用,但我希望测试所需的修复程序是微不足道的。 (一位朋友在最新版本的.NET Framework 3或3.5中对此进行了测试,因此这些数据可能不是最新的。)
byte* foo = null;
*foo; // NullReferenceException
byte* bar = 0x10000;
*foo; // AccessViolationException
我对问题2的不太关注的是,调用方法的地址是在编译时确定的,因为它不能改变。空引用的callvirt
错误的原因是它需要访问对象的vtable,并且这样做需要读取对象的头。使用常规call
,因为不需要在运行时确定要调用的方法,所以无需查找,CLR可以直接进行。 (至少,这大致是它对C ++的作用,所以我认为它与CLR的工作原理并不太远。)
答案 2 :(得分:1)
这有点奇怪,因为OP声明PEverify没有失败。
对GetAnotherInt
的最后一次调用看起来无效。
那一刻堆栈中什么都没有。
至少解释了AccessViolationException
; P
不确定为什么PEVerify允许它。
<强>更新强>
PEVerify确实失败了。
[IL]: Error: MrSandbox.Program::Main][offset 0x00000021] Stack underflow.