C#中的64位指针算术,检查算术溢出更改行为

本文关键字:溢出 中的 64位 指针 检查 | 更新日期: 2023-09-27 17:58:53

我有一些不安全的C#代码,它在类型为byte*的大型内存块上执行指针运算,运行在64位机器上。它在大多数情况下都能正常工作,但当事情变得很大时,我经常会遇到指针不正确的情况。

奇怪的是,如果我打开"检查算术上溢/下溢",一切都能正常工作。我没有得到任何溢出异常。但由于性能受到很大影响,我需要在没有此选项的情况下运行代码。

是什么导致了这种行为上的差异?

C#中的64位指针算术,检查算术溢出更改行为

这里检查和未检查的区别实际上是IL中的一个错误,或者只是一些糟糕的源代码(我不是语言专家,所以我不会评论C#编译器是否为恶劣的源代码生成了正确的IL)。我使用C#编译器的4.0.30319.1版本编译了这段测试代码(尽管2.0版本似乎也做了同样的事情)。我使用的命令行选项是:/o+/unsafe/debug:pdbonly。

对于未检查的块,我们有以下IL代码:

//000008:     unchecked
//000009:     {
//000010:         Console.WriteLine("{0:x}", (long)(testPtr + offset));
  IL_000a:  ldstr      "{0:x}"
  IL_000f:  ldloc.0
  IL_0010:  ldloc.1
  IL_0011:  add
  IL_0012:  conv.u8
  IL_0013:  box        [mscorlib]System.Int64
  IL_0018:  call       void [mscorlib]System.Console::WriteLine(string,
                                                                object)

在IL偏移量11处,加法得到2个操作数,一个为byte*类型,另一个为uint32类型。根据CLI规范,它们实际上分别规范化为本机int和int32。根据CLI规范(准确地说是分区III),结果将是本机int。因此,secodn操作数必须升级为本机int类型。根据规范,这是通过符号扩展实现的。所以uint。MaxValue(在有符号表示法中是0xFFFFFFFF或-1)被符号扩展为0xFFFFFFFFFFFFF。然后将2个操作数相加(0x0000000008000000L+(-1L)=0x0000000007FFFFFFL)。conv操作码仅用于验证目的,以将本机int转换为int64,在生成的代码中,int64是一个nop。

现在,对于已检查的块,我们有这个IL:

//000012:     checked
//000013:     {
//000014:         Console.WriteLine("{0:x}", (long)(testPtr + offset));
  IL_001d:  ldstr      "{0:x}"
  IL_0022:  ldloc.0
  IL_0023:  ldloc.1
  IL_0024:  add.ovf.un
  IL_0025:  conv.ovf.i8.un
  IL_0026:  box        [mscorlib]System.Int64
  IL_002b:  call       void [mscorlib]System.Console::WriteLine(string,
                                                                object)

除了add和conv操作码之外,它实际上是完全相同的。对于add操作码,我们添加了2个"后缀"。第一个是".off"后缀,它有一个明显的含义:检查溢出,但也需要"启用第二个后缀:".un"。(即没有"add.un",只有"add.off.un")。".un"有两个效果。最明显的一点是,加法和溢出检查就像操作数是无符号整数一样进行。从我们的CS类中,希望我们都记得,由于二的补码二进制编码,有符号加法和无符号加法是相同的,所以".un"实际上只影响溢出检查,对吧?

错了。

请记住,在IL堆栈上,我们没有2个64位数字,我们有一个int32和一个本机int(标准化后)。".un"意味着从int32到native的转换被视为"conv.u",而不是如上所述的默认"conv.i"。因此uint。MaxValue为零,扩展为0x00000000FFFFFFFFL。然后加法正确地产生0x0000000107FFFFFFL。conv操作码确保无符号操作数可以表示为有符号int64(可以)。

您的修复只适用于64位。在IL级别,一个更正确的解决方案是将uint32操作数显式转换为本机int或无符号本机int,然后对于32位和64位,检查和未检查操作都将相同。

这是一个C#编译器错误(在Connect上提交)@Grant已经表明,C#编译器生成的MSIL将uint操作数解释为有符号的。根据C#规范,这是错误的,以下是相关部分(18.5.6):

18.5.6指针算术

在不安全的上下文中,+-运算符(§7.8.4和§7.8.5)可以应用于除void*之外的所有指针类型的值。因此,对于每个指针类型T*,都隐式定义了以下运算符:

T* operator +(T* x, int y);
T* operator +(T* x, uint y);
T* operator +(T* x, long y);
T* operator +(T* x, ulong y);
T* operator +(int x, T* y);
T* operator +(uint x, T* y);
T* operator +(long x, T* y);
T* operator +(ulong x, T* y);
T* operator –(T* x, int y);
T* operator –(T* x, uint y);
T* operator –(T* x, long y);
T* operator –(T* x, ulong y);
long operator –(T* x, T* y);

给定指针类型T*的表达式P和类型intuintlongulong的表达式N,表达式P + NN + P计算类型T*的指针值,该指针值是将N * sizeof(T)添加到由P给出的地址而得到的。同样地,表达式P - N计算从P给出的地址减去N * sizeof(T)得到的类型为T*的指针值。

给定指针类型为T*的两个表达式PQ,表达式P – Q计算由PQ给出的地址之间的差,然后将该差除以sizeof(T)。结果的类型始终为long。实际上,P - Q被计算为((long)(P) - (long)(Q)) / sizeof(T)

如果指针算术运算溢出指针类型的域,则会以实现定义的方式截断结果,但不会产生异常。


您可以向指针添加一个uint,不会发生隐式转换。并且该操作不会使指针类型的域溢出。因此,不允许截断。

请仔细检查您的不安全代码。在分配的内存块之外读取或写入内存会导致"损坏"。

我已经解决了问题,正在回答我自己的问题,但我仍然有兴趣阅读关于为什么行为随checkedunchecked而变化的评论。

此代码演示了问题和解决方案(添加之前总是将偏移量强制转换为long):

public static unsafe void Main(string[] args)
{
    // Dummy pointer, never dereferenced
    byte* testPtr = (byte*)0x00000008000000L;
    uint offset = uint.MaxValue;
    unchecked
    {
        Console.WriteLine("{0:x}", (long)(testPtr + offset));
    }
    checked
    {
        Console.WriteLine("{0:x}", (long)(testPtr + offset));
    }
    unchecked
    {
        Console.WriteLine("{0:x}", (long)(testPtr + (long)offset));
    }
    checked
    {
        Console.WriteLine("{0:x}", (long)(testPtr + (long)offset));
    }
}

这将返回(当在64位机器上运行时):

7ffffff
107ffffff
107ffffff
107ffffff

(顺便说一句,在我的项目中,我第一次把所有的代码都写成托管代码,没有这些不安全的指针算术问题,但发现它使用了太多内存。这只是一个业余项目;如果它爆炸了,唯一会受伤的是我。)