C#中的64位指针算术,检查算术溢出更改行为
本文关键字:溢出 中的 64位 指针 检查 | 更新日期: 2023-09-27 17:58:53
我有一些不安全的C#代码,它在类型为byte*
的大型内存块上执行指针运算,运行在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
和类型int
、uint
、long
或ulong
的表达式N
,表达式P + N
和N + P
计算类型T*
的指针值,该指针值是将N * sizeof(T)
添加到由P
给出的地址而得到的。同样地,表达式P - N
计算从P
给出的地址减去N * sizeof(T)
得到的类型为T*
的指针值。给定指针类型为
T*
的两个表达式P
和Q
,表达式P – Q
计算由P
和Q
给出的地址之间的差,然后将该差除以sizeof(T)
。结果的类型始终为long
。实际上,P - Q
被计算为((long)(P) - (long)(Q)) / sizeof(T)
。如果指针算术运算溢出指针类型的域,则会以实现定义的方式截断结果,但不会产生异常。
您可以向指针添加一个uint
,不会发生隐式转换。并且该操作不会使指针类型的域溢出。因此,不允许截断。
请仔细检查您的不安全代码。在分配的内存块之外读取或写入内存会导致"损坏"。
我已经解决了问题,正在回答我自己的问题,但我仍然有兴趣阅读关于为什么行为随checked
和unchecked
而变化的评论。
此代码演示了问题和解决方案(添加之前总是将偏移量强制转换为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
(顺便说一句,在我的项目中,我第一次把所有的代码都写成托管代码,没有这些不安全的指针算术问题,但发现它使用了太多内存。这只是一个业余项目;如果它爆炸了,唯一会受伤的是我。)