它不一定是(可能有明确的检查),但它可以通过捕获访问冲突异常来工作。
一个 .NET 对象将变成一个原生对象:它的字段变成一块以特定方式布局的内存块,它的方法被编译成原生机器代码方法,并创建了一个 v-table 或其他虚拟方法重载机制。
访问一个字段意味着找到对象的地址,添加成员的偏移量,并读取或写入所引用的内存块。
调用虚方法,意味着找到对象的地址,找到它的方法表(在对象内设置偏移量),找到方法的地址(在表内设置偏移量)并在该地址调用方法,并使用正在传递的对象的地址(this
指针)。
调用非虚拟方法,意味着调用传递的对象地址(this
指针)的方法。
显然,如果在问题的地址处没有实际对象,则案例 1 和 2 会以某种方式出错,而案例 3 将起作用(但可能反过来导致案例 1 或 2)。出错的主要方式有两种:
它可以访问并非真正属于我们类型的对象的任意内存位,从而导致各种令人兴奋且非常难以跟踪的错误(.NET 代码通常不会导致导致这种情况的任何事情)。
它可以访问受保护的任意内存位,从而导致访问冲突。
您可能从 C、C++ 或 ASM 编码中了解第二种情况。如果没有,您可能仍然会看到程序崩溃,并在其垂死的呼吸中谈论某个地址的访问冲突。如果是这样,您可能已经注意到,虽然给出的地址几乎可以是任何东西,但它通常是 0x00000000 或非常低的值,例如 0x00000020。这些是由试图取消引用空指针的代码引起的,无论是访问字段还是调用虚拟方法(本质上是访问字段,然后根据您获得的内容进行调用)。
现在,由于第一个 64k 或内存始终受到保护,取消引用空指针将始终导致第二种情况(访问冲突)而不是第一种情况(任意内存被误用并导致奇怪的“核心上的 fandango”错误)。
这与 .NET 完全相同(或者更确切地说,是由它生成的 jitted 代码),但是如果 (A) 访问冲突发生在低于 0x00010000 的地址并且 (B) 发现这样的冲突发生在被 jitted 的代码,则转为 a NullReferenceException
,否则转为AccessViolationException
.
我们可以使用不取消引用但确实访问受保护内存的代码来模拟这一点(我们只会读取,所以如果我们不小心碰到了不受保护的内存,结果不会太奇怪!) :
以下代码将引发 AccessViolationException:
unsafe
{
int read = *((int*)long.MaxValue - 8);
}
以下代码将引发 NullReferenceException:
unsafe
{
int read = *((int*)8);
}
这两个代码实际上都没有取消引用任何内容。两者都导致访问冲突,但 CLR 假定后者可能是由空引用引起的(公平地说,到目前为止最有可能的情况)并引发它。
因此,我们可以看到字段访问是如何callvirt
导致这种情况的。
现在值得注意的是,由于决定不允许 C# 调用空引用上的方法,即使这样做callvirt
是安全的,在 C# 中的大多数情况下都将其用作 IL,唯一的例外是静态方法或它可以在编译时显示为不在空引用上。(编辑:在其他一些情况下,编译器可以看到 acallvirt
可以替换为 a call
,即使该方法实际上是虚拟的[如果编译器可以判断哪个重载会被命中],并且后来的编译器会稍微多做一点经常,尽管它仍然callvirt
会比你想象的更频繁地使用)。
一个有趣的情况是,优化意味着callvirt
可以内联调用 with 的方法,但在编译时不知道它是否保证非空。在这种情况下,可以在“调用”(不是真正的调用)发生的位置之前添加字段访问,正是为了NullReferenceException
在方法的开头而不是中间触发。这意味着优化不会改变观察到的行为。