很抱歉有这么多的文字,但我想给出一个很好的背景情况。我知道你可以在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
打印到控制台,成功退出。我在一个空引用上调用一个函数,它正在正确地执行。我的猜测是,这与没有调用实例字段的事实有关,因此CLR可以成功执行代码。
最后,这是我真正困惑的地方,用以下代码替换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)
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
外,没有任何值被打印到屏幕上。为什么不一致?
背景完了,下面是我的问题:
1) MSDN列出,callvirt
将抛出一个NullReferenceException
,如果obj是空的,但call
只是说,它必须不是空的。那么,为什么它在默认情况下抛出一个NRE而不是一个访问冲突呢?在我看来,call
通过契约将尝试访问内存并失败,而不是像callvirt
那样先检查null。
2)第二个例子的原因是由于它访问没有类级别字段和call
不做空检查?如果是这样,如何在空引用上调用非静态方法并成功返回?我的理解是,当一个引用类型被放在堆栈上时,只有它放在堆上的type对象。方法是从类型对象调用的吗?
3)为什么第一个和最后一个例子抛出的异常不同?在我看来,第三个例子抛出了正确的异常,一个AccessViolationException
,因为这正是它想要做的;访问未分配内存
在"行为未定义"的答案出现之前,我知道这根本不是一种正确的写作方式,我只是希望有人能帮助我对上述问题提供一些见解。
谢谢。
1)处理器引发访问冲突。CLR捕获异常,并且根据异常的访问地址转换异常。地址空间的前64KB内的任何访问都将作为托管NullReferenceException重新引发。参考这个答案。
2)是的,CLR不强制非空this值。例如,c++/CLI编译器生成的代码不执行此检查,这与本地c++非常相似。只要方法不使用this引用,就不会导致异常。c#编译器在调用callvirt方法之前显式地生成代码来验证this的值。参考这篇博文。3)你得到了错误的IL, GetAnotherInt()是一个实例方法,但你忘了写ldloc指令。你得到一个AV,因为引用指针是随机的。
我不能肯定地回答2),但这里有1)和3)。
NullReferenceException
和AccessViolationException
是一样的;在CLR的早期,根本没有AccessViolationException
,对一个无效但非空的指针解引用仍然会得到一个NullReferenceException
。
这是因为在今天的计算机上,让硬件进行完整性检查的成本更低。你对抛出哪个异常的概念是基于CLR做显式null检查(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
在null引用上出错的原因是它需要访问对象的虚函数表,这样做需要读取对象的头。对于常规的call
,由于不需要在运行时确定要调用的方法,因此不需要查找任何内容,CLR可以直接进行。(至少,这大致是c++的工作方式,所以我想它与CLR的工作方式相差不大)
这有点奇怪,因为OP声明PEverify不会失败。
对GetAnotherInt
的最后一次调用看起来无效。
此时堆栈上没有任何内容。
这至少解释了AccessViolationException
; p
不知道为什么PEVerify允许。
更新:
PEVerify确实失败了。
[IL]: Error: MrSandbox.Program::Main][offset 0x00000021] Stack underflow.