6

在我们的编码/开发生活日或其他日子里,我们确实遇到了这个特殊的和最常见的例外之一。我的问题不是关于为什么(我知道当我们尝试访问实际上指向 null 的引用变量的属性时它会引发),而是关于CLR如何生成 NULL REFERENCE EXCEPTION。

有时我不得不考虑识别对 null 的引用(也许 null 是内存中的保留空间)然后由 CLR 引发异常的机制。CLR 如何识别和引发此特定异常。操作系统在其中有什么作用吗?

我想分享一个关于它的最有趣的主张:

null实际上是 CLR 已知的所有时间保留的内存空间,并且禁止所有类型的访问。因此,当找到对该空间的引用时,默认情况下,它会通过 OS 生成访问被拒绝类型的异常,CLR 将其解释为 NULL 引用异常。

我没有找到任何支持上述说法的文章或帖子,因此很难相信。可能由于我缺少详细信息或其他原因,我希望 Stackoverflow 是我将获得最佳响应的最合适的平台之一。

4

3 回答 3

11

它不一定是(可能有明确的检查),但它可以通过捕获访问冲突异常来工作。

一个 .NET 对象将变成一个原生对象:它的字段变成一块以特定方式布局的内存块,它的方法被编译成原生机器代码方法,并创建了一个 v-table 或其他虚拟方法重载机制。

  1. 访问一个字段意味着找到对象的地址,添加成员的偏移量,并读取或写入所引用的内存块。

  2. 调用虚方法,意味着找到对象的地址,找到它的方法表(在对象内设置偏移量),找到方法的地址(在表内设置偏移量)并在该地址调用方法,并使用正在传递的对象的地址(this指针)。

  3. 调用非虚拟方法,意味着调用传递的对象地址(this指针)的方法。

显然,如果在问题的地址处没有实际对象,则案例 1 和 2 会以某种方式出错,而案例 3 将起作用(但可能反过来导致案例 1 或 2)。出错的主要方式有两种:

  1. 它可以访问并非真正属于我们类型的对象的任意内存位,从而导致各种令人兴奋且非常难以跟踪的错误(.NET 代码通常不会导致导致这种情况的任何事情)。

  2. 它可以访问受保护的任意内存位,从而导致访问冲突。

您可能从 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在方法的开头而不是中间触发。这意味着优化不会改变观察到的行为。

于 2012-06-28T09:32:30.723 回答
4

MS 实现,IIRC,通过访问冲突来做到这一点。Null 本质上是一个零引用,基本上:他们故意保留该地址空间并使该页面未映射。内存访问冲突在 CPU/OS 级别自动引发(即不需要额外的代码来进行空值检查),然后 CLI 将其报告为空引用异常。

有趣的是,由于内存是在页面中处理的,因此出于同样的原因,您实际上可以模拟(如果您足够努力的话)一个非零但低值的空引用异常。

编辑:Eric Lippert 讨论了这个相关的问题/答案:https ://stackoverflow.com/a/8681563

于 2012-06-28T08:52:06.777 回答
1

你读过 CLI 规范 - ECMA-335吗?你会在那里找到一些答案。

11 类的语义...当一个以类为类型的变量或字段被创建(例如,通过调用具有类类型的局部变量的方法)时,该值最初应为空,一个特殊值that := 具有所有类类型,即使它不是任何特定类的实例。

以及ldnull指令的描述:

ldnull 将空引用(O 类型)压入堆栈。这用于在它们变为活动之前或它们变为死亡时初始化位置。[理由:可能认为 ldnull 是多余的:为什么不使用 ldc.i4.0 或 ldc.i8.0 来代替?答案是 ldnull 提供了一个与大小无关的 null - 类似于不存在的 ldc.i 指令。但是,即使 CIL 包含 ldc.i 指令,保留 ldnull 指令仍然有利于验证算法,因为它使类型跟踪更容易。基本原理] 可验证性: ldnull 指令始终是可验证的,并产生一个可分配给 (§I.8.7.3) 任何其他引用类型的 null 类型 (§1.8.1.2) 的值。

于 2012-06-28T08:22:39.243 回答