为什么越界指针算术是未定义的行为?

36

以下示例取自维基百科

int arr[4] = {0, 1, 2, 3};
int* p = arr + 5;  // undefined behavior
如果我从不解引用p,那么为什么arr + 5就算未定义的行为?我期望指针表现得像整数-唯一的例外是当解引用指针时,指针的值被视为内存地址。

我相当确定,“undefined”部分只是标准说法,它无法告诉您指针现在指向哪里。像大多数指针“未定义”一样,我相信创建它是可以的,但是解除引用是非法的。 - user406009
3
只有在他们说结果“值”未定义时,这种说法才是正确的。如果“行为”未定义,则即使您从未引用它,执行它也不安全。 - user541686
2
指针不是整数。在底层,它们的表示可能会重叠,但就“C++抽象机器”而言,它们是完全不同的东西,只是共享一些语法,例如struct { int a; int x; }struct { char x; } - user395760
可能是C++访问越界数组不会报错,为什么?的重复问题。这个问题不是那么重要,但是这个问题的顶部答案很有价值。 - Tony
3
因为不是所有的机器都像您的个人电脑一样表现,您期望特定的行为基于它在您的机器上的运行方式。标准委员会具有更多的经验,并且了解其他体系结构实现指针的方式不同,因此无法保证在所有平台上都具有上述行为(因此未定义)。 - Martin York
3
我发现一个情况,其中这种未定义行为实际上会使计算错误(在普通的x86上):https://dev59.com/02Ag5IYBdhLWcg3wWp7- - Bernd Elkemann
7个回答

30

那是因为指针不像整数那样行为表现。这是未定义的行为,因为标准如此规定。

然而,在大多数平台上(如果不是全部),如果您不对数组进行解引用操作,则不会崩溃或遇到可疑的行为。但是,如果您不进行解引用操作,那么进行这种加法的意义何在呢?

也就是说,需要注意的是,一个超过数组末尾一位的表达式在 C++11 规范 §5.7 ¶5 中技术上是 100%“正确”的,并且保证不会崩溃。然而,该表达式的结果是未指定的(仅保证不会出现溢出);而任何超过数组边界一个以上的其他表达式明确会造成未定义的行为。

请注意:这并不意味着从超出一个位置的偏移量读取和写入数据是安全的。您很可能会编辑不属于该数组的数据,并且会导致状态/内存损坏。只是你不会引起溢出异常。

我的猜测是,这是因为不仅解引用是错误的。还有指针算术、比较指针等等。因此,直接说不要这样做比列举可能危险的情况要容易些。


超出边界的情况如何处理? - Mahmoud Al-Qudsi
4
标准说可以,那就可以这样。 - user395760
@LuchianGrigore 如果您不介意的话,我已经编辑了您的帖子。如果您介意的话,我可以撤回并单独回答。 - Mahmoud Al-Qudsi
@MahmoudAl-Qudsi 没问题。我没有在标准中寻找确切的引用,因为我从之前的 SO 问题中知道了这一点 :) - Luchian Grigore
3
将指向数组最后一个元素的指针递增的结果并非未指定;它被指定为指向数组最后一个元素刚过的指针;从这样的指针中减去1到数组大小之间的值将产生指向数组元素的有效指针。 - supercat
显示剩余3条评论

23

原始的x86存在这样的问题。在16位代码中,指针是16+16位的。如果你添加一个偏移值到低16位,你可能需要处理溢出并改变高16位。这是一个缓慢的操作,最好避免。

在这些系统上,如果offset在范围内(<=数组大小),则可以保证array_base+offset不会溢出。但是如果array只包含3个元素,则array+5将会溢出。

这种溢出的结果是,你得到了一个指向数组之前而不是之后的指针。那甚至可能不是RAM,而是映射到内存的硬件。C++标准不试图限制构造指向随机硬件组件的指针时会发生什么,也就是说,在真实的系统上它是未定义行为。


5
如果 arr 恰好位于机器内存空间的末尾,那么 arr+5 可能超出该内存空间,指针类型可能无法表示该值,即可能会发生溢出,并且溢出是未定义的。

5
“未定义行为”并不意味着它必须在那一行代码上崩溃,但这确实意味着您无法对结果做出任何保证。例如:
int arr[4] = {0, 1, 2, 3};
int* p = arr + 5; // I guess this is allowed to crash, but that would be a rather 
                  // unusual implementation choice on most machines.

*p; //may cause a crash, or it may read data out of some other data structure
assert(arr < p); // this statement may not be true
                 // (arr may be so close to the end of the address space that 
                 //  adding 5 overflowed the address space and wrapped around)
assert(p - arr == 5); //this statement may not be true
                      //the compiler may have assigned p some other value

我相信你肯定还有其他的例子可以举出来。

1
arr+5 不是指向结尾的下一个位置,而是指向结尾的下两个位置,因此根据 §5.7 ¶5,它是未定义行为,并且在具有指针陷阱表示的机器上可能会崩溃。 - Jonathan Wakely
1
那是回复已被删除的评论, 请忽略“not one-past-the-end”部分。其余部分仍然适用,它可能会崩溃,但我同意这是不寻常的。 - Jonathan Wakely

2
一些系统,非常罕见的系统(我无法列举出一个),当你超过边界增加时会导致陷阱。此外,它允许提供边界保护的实现存在...不过我想不到有哪个系统这样做。
基本上,你不应该这样做,因此没有必要指定发生了什么。指定发生的事情会给实现提供者带来不必要的负担。

4
能够做到这一点的系统实际上非常普遍,其中Intel x86(以及兼容产品)是一个主要例子。虽然x86通常没有被用于这种方式,但它的基于段的内存保护可以像描述的那样工作——即使尝试形成无效地址,也可以引发异常。然而,大多数典型的操作系统都将所有段设置为基址0和限制4Gig,使得所有可能的偏移量都是有效的。值得一提的是,这一功能实际上在OS/2 1.x中被使用过。 - Jerry Coffin
@JerryCoffin:我希望英特尔在80386上使用32位段寄存器,其中上部分选择段描述符,下部分作为一个缩放乘数,其行为将由该段描述符控制。这样的架构将使得可以使用32位对象引用而不受4GB寻址限制(不同对象的数量将被限制在远远低于40亿个,但它们的总大小可以更大)。 - supercat

0
除了硬件问题之外,另一个因素是出现了试图在各种编程错误上进行陷阱的实现。尽管许多这样的实现可以在配置为在程序已知不使用但由C标准定义的结构上进行陷阱时最有用,但标准的作者不想定义将会成为错误症状的结构的行为,这在许多编程领域中都是如此。
在许多情况下,捕获使用指针算术计算意外对象地址的操作要比以某种方式记录指针不能用于访问它们标识的存储但可以修改以便它们可以访问其他存储更容易。除了较大(二维)数组中的数组之外,在每个对象“刚好过去”的位置上保留空间是允许的。例如,对于类似doSomethingWithItem(someArray+i);的代码,实现可以捕获任何试图传递任何不指向数组元素或最后一个元素刚刚过去的空间的地址。如果someArray的分配为额外未使用的元素保留了空间,并且doSomethingWithItem()仅访问其接收到的指针所指向的项,则实现可以相对廉价地确保上述代码的任何非捕获执行最坏情况下都可以访问未使用的存储。

能够计算“刚过去”的地址会使边界检查比它本来应该的更困难(最常见的错误情况是传递一个指向数组结束位置刚好过去的指针给doSomethingWithItem(),但只要doSomethingWithItem不尝试对指针进行解引用操作——调用者可能无法证明这一点——则行为是已定义的)。然而,由于标准允许编译器在大多数情况下保留数组之后的空间,因此这种允许可以使实现限制未捕获错误造成的损害——如果允许更通用的指针算术运算,则这很可能是不可行的。


-1
你看到的这个结果是由于x86的基于段的内存保护机制。我认为这种保护是有道理的,因为当你增加指针地址并存储时,这意味着在将来的代码中,你将会解引用指针并使用该值。因此编译器希望避免这种情况,即你最终会更改其他人的内存位置或删除代码中某个其他人拥有的内存。为了避免这种情况,编译器设置了限制。

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