引发/生成空引用异常后的CLR实现是什么?

时间:2012-06-28 06:51:00

标签: c# .net memory-management clr nullreferenceexception

我们确实遇到过这个特殊问题,也是我们编码/开发生活日或其他日子中最常见的例外情况之一。我的问题是关于为什么(我知道当我们尝试访问实际指向null的引用变量的属性时它会引发)但它是关于 HOW NULL REFERENCE EXCEPTION由CLR生成。

有时我被迫认为用于标识对null的引用的机制(可能null是内存中的保留空间)然后通过CLR引发异常。 CLR如何识别并引发此特定异常。操作系统在其中起任何作用吗?

我想分享其中一个最有趣的主张:

  

null 实际上是CLR已知的全时保留内存空间,禁止所有类型的访问。因此,在参考时   找到空间,它默认生成访问被拒绝的类型   通过OS的异常,被解释为NULL引用异常   CLR。

我没有找到支持上述陈述的任何文章或帖子,因此很难相信。可能由于我缺少挖掘细节或其他原因,我希望Stackoverflow是最合适的平台之一,我会得到最好的响应。

3 个答案:

答案 0 :(得分:9)

它不一定(可能有显式检查),但它可以捕获访问冲突异常。

.NET对象将变为本机对象:其字段成为以特定方式布局的内存块,其方法被嵌入本机机器代码方法,以及v表或其他虚方法重载机制已创建。

  1. 然后访问一个字段,意味着找到对象的地址,添加成员的偏移量,以及读取或写入所提到的内存。

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

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

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

    1. 它可以访问任何不是我们类型的对象的任意内存,导致各种令人兴奋的并且很难追踪错误(.NET代码通常不会导致任何导致此问题的内容场景)。

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

    3. 您可能知道来自C,C ++或ASM编码的第二种情况。如果没有,你可能仍然会看到一个程序崩溃,并且它的奄奄一息的气息谈论某个地址的访问冲突。如果是这样,你可能已经注意到虽然给出的地址几乎可以是任何东西,但它通常是0x00000000或非常低的东西,如0x00000020。这些是由试图取消引用空指针的代码引起的,无论是访问字段还是调用虚方法(实际上是访问字段然后根据你得到的内容调用)。

      现在,由于第一个64k或内存始终受到保护,因此取消引用空指针将始终导致第二种情况(访问冲突)而不是第一种情况(任意内存被误用并导致奇怪的“fandango”核心“错误”。

      这与.NET完全相同(或者更确切地说,由它产生的jitted代码),但是如果(A)访问冲突发生在低于0x00010000的地址,并且(B)发现这样的违规已经被jitted的代码发生了,然后它变成NullReferenceException,否则它会变成AccessViolationException

      我们可以使用不取消引用但访问受保护内存的代码来模拟这一点(我们只会阅读,所以如果我们偶然发现不受保护的内存,结果也不会太多奇怪的):

      以下代码将引发AccessViolationException:

      unsafe
      {
        int read = *((int*)long.MaxValue - 8);
      }
      

      以下代码将引发NullReferenceException:

      unsafe
      {
        int read = *((int*)8);
      }
      

      这两个代码都没有实际取消引用任何内容。两者都会导致访问冲突,但CLR假设后者可能是由空引用引起的(公平地,到目前为止最可能的情况)并引发它。

      因此,我们可以看到字段访问和callvirt如何导致此问题。

      现在值得注意的是,由于决定不允许C#在空引用上调用方法,即使在安全的情况下这样做,callvirt也被用作C#中大多数情况的IL,并且是唯一的异常是静态方法的情况,或者在编译时可以显示为不在空引用上的情况。 (编辑:还有一些其他情况,编译器可以看到callvirt可以被call替换,即使该方法实际上是虚拟的[如果编译器可以告诉哪个重载会被命中并且后来的编译器会稍微更频繁地执行此操作,但它仍然会比您想象的更频繁地使用callvirt

      一个有趣的案例是优化意味着可以内联使用callvirt调用的方法,但是在编译时不知道保证非空的方法。在这种情况下,可以在“呼叫”(实际上不是呼叫)发生的地方之前添加字段访问,准确地在开始时而不是在中间触发NullReferenceException。方法。这意味着优化不会改变观察到的行为。

答案 1 :(得分:4)

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

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

编辑:Eric Lippert在此相关问题/答案中对此进行了讨论:https://stackoverflow.com/a/8681563

答案 2 :(得分:1)

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

  

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

ldnull 指令的描述:

  

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