为什么.NET将低地址空间(非空的)内存访问报告为NullReferenceException?

31

这会导致抛出一个 AccessViolationException 异常:

using System;

namespace TestApplication
{
    internal static class Program
    {
        private static unsafe void Main()
        {
            ulong* addr = (ulong*)Int64.MaxValue;
            ulong val = *addr;
        }
    }
}

这会导致抛出NullReferenceException异常:

using System;

namespace TestApplication
{
    internal static class Program
    {
        private static unsafe void Main()
        {
            ulong* addr = (ulong*)0x000000000000FF;
            ulong val = *addr;
        }
    }
}

它们都是无效指针并违反内存访问规则。为什么会发生空引用异常?


2
这个编程问题要解决什么还不清楚。这听起来只是闲聊而已。 - Raymond Chen
你好奇地问一下,你是在运行64位还是32位的系统?也许这会有所不同? - Corey Ogburn
@CoreyOgburn我在另一个答案的评论中提到它是64位的,而改为使用uint而不是ulong并没有解决问题。 - Michael J. Gray
4个回答

45
这是由Windows多年前做出的设计决策导致的。地址空间的底部64千字节被保留。访问该范围内的任何地址将报告空引用异常,而不是底层访问违规。这是一个明智的选择,空指针可能会在实际上不为零的地址处产生读取或写入。例如,读取C++类对象的字段时,它与对象的起始位置有一个偏移量。如果对象指针为空,则代码将因在大于0的地址处读取而崩溃。
C#没有完全相同的问题,该语言保证在调用类的实例方法之前捕获空引用。然而,这是特定于语言的,不是CLR功能。您可以使用C++/CLI编写托管代码并生成非零空指针解引用。在nullptr对象上调用方法可以正常工作。该方法将愉快地执行。并调用其他实例方法。直到尝试访问实例变量或调用虚拟方法,需要对 this 进行解引用,然后就会爆炸。
C#的保证非常好,它使得诊断空引用问题更加容易,因为它们在调用站点生成并且不会在嵌套方法内部崩溃。并且它在根本上更安全,在极大对象的偏移量大于64K时,实例变量可能不会触发异常。在托管代码中很难做到,与C ++不同。但是并非免费,这在blog post中有解释。

2
如果我有一个具有超过64千字节字段的对象,然后尝试从该对象的实例访问特定字段时,如果它被分配为null,它不会抛出NullReferenceException,因为字段偏移量将超过65535,从而计算到超出该范围的地址? - Michael J. Gray
4
@JNZ - 对的。更糟糕的是,由于内存刚好映射到该地址,很有可能根本不会得到任何异常。当我尝试时就发生了这种情况。 - Hans Passant
2
@Hans:那么,可以得出结论,即使在C#中,对于一个大型对象,someReferenceTypeExpression.SomeField = someValue;也是一种基本不安全的表达式吗? - Ani
5
是的,编写具有超过4096个公共字段的类的程序员编写的代码基本上是错误的。但是这个事实已经广为人知了 :) - Hans Passant
1
@Ani:从我的测试结果来看,超过64K的偏移量访问字段仍然会被捕获。因为JIT知道字段偏移量,所以它很容易在访问超过64K的偏移量的字段时生成明确的空检查,并在访问较小偏移量的字段时省略该检查。 - supercat
显示剩余3条评论

17

空引用异常和访问冲突异常都是由CPU作为访问冲突而引发的。然后CLR必须猜测访问违规是否应该被特化为空引用异常或保留更一般的访问违规。

从你的结果中可以看出,CLR推断地址非常靠近0的访问违规是由空引用引起的。因为它们几乎肯定是由空引用加字段偏移量生成的。使用不安全代码会欺骗这个启发式算法。


1
考虑到这一点,这意味着任何足够大的类在尝试访问其为 null 的情况下会错误地引发 AccessViolationException,而它不应该这样做。 - Michael J. Gray
5
在这种情况下,CLR 在访问该字段之前可能会对偏移量为0的内容进行显式解引用,以避免错误的判断。 - Raymond Chen
3
这个启发式方法的精确性源于Windows操作系统不会自然地提供低于64KB的内存(虽然你可以通过NtAllocateVirtualMemory强制分配,但是在Windows 8中除非在NTVDM模式下,否则无法这样做,但我跑题了)。因此,任何引起AV的托管对象都是一个小于64KB的对象的空指针引用。为了避免将大于64KB的对象错误地报告为AccessViolation而不是空指针引用,CLR将在引用从开头超过64KB的字段之前自动“触碰”任何大于64KB的对象的第一个字节。 - SecurityMatt
@RaymondChen 当JIT到达ldfldstfld指令时,CLR总是知道字段的真实偏移量。如果字段偏移量大于CPU访问冲突导致NullReferenceException的区域大小,则会在对象本身上发出其中一个cmp指令,以确保您确实看到正确的异常。对于较低偏移量的字段,可以(并且通常)省略显式的cmp - Sam Harwell

3
这可能是一个语义问题。
你的第一个示例尝试取消引用一个指针,其内容是地址Int64.MaxValue,而不是指向具有值Int64.MaxValue的变量的指针。
看起来你正在尝试读取存储在地址Int64.MaxValue处的值,显然不在你的进程拥有的范围内。
你是不是想做类似这样的事情?
        static unsafe void Main(string[] args)
        {
            ulong val = 1;// some variable space to store an integer
            ulong* addr = &val;
            ulong read = *addr;

            Console.WriteLine("Val at {0} = {1}", (ulong)addr, read);

#if DEBUG 
            Console.WriteLine("Press enter to continue");
            Console.ReadLine();
#endif
        }

不,只是在玩耍时注意到抛出异常之间的不一致性。有点好奇为什么会有差异。我明白这些地址对于进程来说完全无效。 - Michael J. Gray
@JNZ 我们两个都遇到了 NullReferenceException 异常。嗯。 - 3Dave

2

来自http://msdn.microsoft.com/en-us/library/system.accessviolationexception.aspx

版本信息

此异常在 .NET Framework 2.0 中新增。在早期的 .NET Framework 版本中,非托管代码或不安全的托管代码中发生的访问冲突将被托管代码表示为 NullReferenceException。在可验证的托管代码中引用 null 引用时也会抛出 NullReferenceException,但这种情况并不涉及数据损坏,而且在 1.0 或 1.1 版本中无法区分这两种情况。

管理员可以允许选择性应用程序还原到 .NET Framework 1.1 的行为。将以下行放置在应用程序的配置文件中的 <runtime> 元素部分:

其他 <legacyNullReferenceExceptionPolicy enabled = "1"/>


那么,从什么时候起0x000000000000FF就等同于null了呢? - Michael J. Gray
它没有添加任何新信息,我很抱歉但我没有看到你的观点。 - Michael J. Gray

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接