我有一些不安全的C#代码,它在类型为byte*
的大型内存块上执行指针运算,在64位计算机上运行。它在大多数情况下都能正常工作,但是当事情变得很大时,我经常会遇到指针不正确的腐败。
奇怪的是,如果我打开“检查算术溢出/下溢”,一切正常。我没有任何溢出异常。但由于性能大,我需要在没有此选项的情况下运行代码。
可能导致这种行为差异的原因是什么?
答案 0 :(得分:6)
这里检查和未检查之间的区别实际上是IL中的一个错误,或者只是一些不好的源代码(我不是语言专家所以我不会评论C#编译器是否生成了正确的IL对于ambigious源代码)。我使用4.0 #30319.1版本的C#编译器编译了这个测试代码(虽然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处,add得到2个操作数,一个是字节*,另一个是uint32。根据CLI规范,这些实际上分别归一化为native int和int32。根据CLI规范(准确的分区III),结果将是native int。因此,必须将secodn操作数提升为native int类型。根据规范,这是通过符号扩展来完成的。所以uint.MaxValue(带符号表示法为0xFFFFFFFF或-1)符号扩展为0xFFFFFFFFFFFFFFFF。然后添加2个操作数(0x0000000008000000L +( - 1L)= 0x0000000007FFFFFFL)。转换操作码仅用于验证目的,将原生int转换为int64,在生成的代码中为int。
现在对于已检查的块,我们有这个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操作码之外,它实际上是相同的。对于添加操作码,我们添加了2个'后缀'。第一个是“.ovf”后缀,它具有明显的含义:检查溢出,但也需要'启用第二个后缀:“。un”。 (即没有“add.un”,只有“add.ovf.un”)。 “.un”有2个效果。最明显的一点就是完成了额外的溢出检查,就好像操作数是无符号整数一样。从我们的CS类回来的时候,希望大家都记得,由于二进制补码二进制编码,有符号加法和无符号加法是相同的,所以“.un”真的只影响溢出检查,对吗?
错误。
请记住,在IL堆栈上,我们没有2个64位数字,我们有一个int32和一个native int(在规范化之后)。那么“.un”意味着从int32到native的转换被视为“conv.u”,而不是如上所述的默认“conv.i”。因此uint.MaxValue为零扩展为0x00000000FFFFFFFFL。然后正确添加产生0x0000000107FFFFFFL。 conv操作码确保无符号操作数可以表示为有符号的int64(它可以)。
您的修复程序只能找到64位。在IL级别,更正确的修复方法是将uint32操作数显式转换为native int或unsigned native int,然后检查和取消选中对于32位和64位都是相同的。
答案 1 :(得分:3)
请仔细检查您的不安全代码。在分配的内存块之外读取或写入内存会导致“损坏”。
答案 2 :(得分:3)
这是一个C#编译器错误(filed on Connect)。 @Grant has shown 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);
给定指针类型
P
的表达式T*
和类型为N
的表达式int
,uint
,long
或{ {1}},表达式ulong
和P + N
计算N + P
类型的指针值,该值是将T*
添加到N * sizeof(T)
给出的地址。同样,表达式P
计算P - N
类型的指针值,该值是从T*
给出的地址中减去N * sizeof(T)
的结果。给定指针类型
P
的两个表达式P
和Q
,表达式T*
计算P – Q
给出的地址之间的差异,P
然后将该差异除以Q
。结果的类型始终为sizeof(T)
。实际上,long
计算为P - Q
。如果指针算术运算溢出指针类型的域,则结果将以实现定义的方式截断,但不会产生异常。
您可以向指针添加((long)(P) - (long)(Q)) / sizeof(T)
,不会发生隐式转换。并且操作不会溢出指针类型的域。因此不允许截断。
答案 3 :(得分:1)
我正在回答我自己的问题,因为我已经解决了问题,但仍然有兴趣阅读关于为什么行为随checked
vs 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
(顺便说一句,在我的项目中,我首先将所有代码编写为托管代码而没有所有这些不安全的指针算术肮脏,但发现它使用了太多内存。这只是一个爱好项目;唯一一个受到伤害的项目如果它爆炸是我。)