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

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

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

我想与大家分享一个最有趣的主张:

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

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

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

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

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

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

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

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

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

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

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

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

这与.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 。 这意味着优化不会改变观察到的行为。

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

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

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

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

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

ldnull指令的描述:

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