在空指针上进行算术运算是否属于未定义行为?

22

我认为以下程序计算了一个无效的指针,因为NULL只适用于赋值和比较相等性:

#include <stdlib.h>
#include <stdio.h>

int main() {

  char *c = NULL;
  c--;

  printf("c: %p\n", c);

  return 0;
}

然而,在GCC或Clang中针对未定义行为的任何警告或工具似乎都没有指出这实际上是未定义行为。这个算术运算是否真的有效,而我又过于追求完美了?还是他们的检查机制存在缺陷,我应该报告?

已测试:

$ clang-3.3 -Weverything -g -O0 -fsanitize=undefined -fsanitize=null -fsanitize=address offsetnull.c -o offsetnull
$ ./offsetnull
c: 0xffffffffffffffff

$ gcc-4.8 -g -O0 -fsanitize=address offsetnull.c -o offsetnull
$ ./offsetnull 
c: 0xffffffffffffffff

似乎有相当充分的证据表明,由Clang和GCC使用的AddressSanitizer更注重于捕获错误指针的引用,因此这是公平的。但其他检查也没有发现问题 :-/

编辑: 我提出这个问题的部分原因是-fsanitize标志可以启用对生成代码中定义良好性的动态检查。这应该是他们应该发现的吗?


6
对于不是数组的指针,对其进行算术运算是未定义行为(UB),但在非数组指针的一端之后加上+1除外。 - chris
4
你完全错了,应该仔细阅读其他人发布的内容 - 他们确认,无论任何编译器实际上做了什么,形成该指针都是未定义行为。 - Phil Miller
3
这个例子会减少一个 char * 的值。不管怎样,NULL 并不总是定义为 ((void*)0)(至少我从 C++ 的一些小问题中记得)。 - chris
2
@c.fogelklou 你对“始终能够对某个东西+1”的定义在这里并不是很有用:虽然它是有效的语法,但没有理由不编译,但在C++中它是未定义的行为,就此而言。 - juanchopanza
3
@juanchopanza,C也是一样的。我发现在我手头的C11草案中,非数组指针算术和单个变量的“超出末尾”这两个相关部分是相同的。 - chris
显示剩余15条评论
3个回答

22
指针算术运算指向非数组的指针是未定义行为。
此外,对空指针进行解引用是未定义行为。
char *c = NULL;
c--;

因为c不指向数组,所以Undefined是定义行为。

C++11标准5.7.5:

当一个具有整数类型的表达式被加到或从指针中减去时,结果具有指针操作数的类型。如果指针操作数指向数组对象的元素,并且数组足够大,则结果指向距离原始元素偏移量为整数表达式的元素,换句话说,如果表达式P指向数组对象的第i个元素,则表达式(P)+N(等价地,N+(P))和(P)-N(其中N的值为n)分别指向数组对象的第i + n个和i−n个元素,前提是它们存在。此外,如果表达式P指向数组对象的最后一个元素,则表达式(P)+1指向数组对象的最后一个元素之后的一个位置,如果表达式Q指向数组对象的最后一个元素之后的一个位置,则表达式(Q)-1指向数组对象的最后一个元素。如果指针操作数和结果都指向同一数组对象的元素或该数组对象的最后一个元素之一,则评估不应产生溢出;否则,行为未定义。


2
显然,解引用NULL指针是未定义行为,就像C++11中描述的“通过”NULL指针间接引用一样。 - Phil Miller
1
“指针算术运算不在数组范围内”无效可能是关键。 - Phil Miller
一个内存块也是你所命名的数组吗?还是在分配的内存中进行指针算术操作也会导致未定义行为? - dhein
有些平台中,地址零是一个有效的地址,有充分的理由去读/写该地址。此外,虽然NULL可以被定义为非零值,但是NULL仍然可能被定义为零--在这种情况下,操作空指针(而不是C++11中的nullptr)并不总是UB。 - Brian Vandenberg
如果这种情况发生,那么操作一个值等于NULL的指针... - Brian Vandenberg
如果我将空指针转换为 uintptr_t,执行算术运算,然后再将其转换回来,这仍然属于未定义行为吗? - Zz Tux

18

是的,这是未定义行为,并且是-fsanitize=undefined 应该捕获到的内容; 我的待办列表上已经有了添加检查此内容的任务。

FWIW,C和C++在这里的规则略有不同:在C中,将0 添加到空指针和从另一个空指针中减去一个空指针具有未定义行为,而在C ++中没有。 在两种语言中,对空指针进行的所有其他算术运算都具有未定义的行为。


3
[expr.add]p7的意图似乎是在C++中将0添加到空指针或减去两个空指针是被定义的,但是p5和p6明确表示行为是未定义的。通常情况下,如果标准的一部分似乎定义了程序的行为,而另一部分说行为是未定义的,则说行为是未定义的那部分胜出。 - user743382
4
我会尽力改进第5段和第6段的措辞(它们还有其他一些问题),但到目前为止没有成功。还要注意,第6段的注脚描述了另一个不同且与之微妙不兼容的指针算术模型。 - Richard Smith
@RichardSmith 在一个读写地址0是有效的平台上处理代码是否很直接,还是我们需要使用一个清单来进行净化? - Brian Vandenberg
@BrianVandenberg:如果一个应用程序需要修改从地址零开始的中断表之类的东西,我建议定义函数来读取和写入特定指定的物理地址。除非代码特别频繁地更新这些表,否则将这样的代码放在单独链接的函数中,该函数对编译器或卫士不可见(并且在许多情况下可以很容易地编写汇编代码),不应该真正影响性能。 - supercat
另一种可能性取决于清洁剂的工作方式,可以使用类似 int volatile *p; p = (int volatile*)4; 的东西,然后使用 p[-1] 访问该位置。通常,给定 p[-1],当 p 为 null 时编译器陷阱会有价值,但当 p-1 为 null 时编译器陷阱没有价值,因为后者发生的唯一方式是如果已经发生了某些不好的事情。 - supercat
@hvd:在没有明确说明特定条件下给定的行为是未定义行为,或者说明该行为是有定义的除非出现其他条件的情况下,支持未定义行为胜过有定义的行为的解释会让文档自相矛盾。很遗憾,人们并没有谴责编译器作者认为自相矛盾的解释应该优于非矛盾解释的荒谬想法。 - supercat

6
不仅对空指针进行算术运算是被禁止的,而且实现失败将试图解引用的陷阱也陷入空指针算术运算会大大降低空指针陷阱的效益。
标准从未定义任何情况,使得将任何内容添加到空指针可以产生合法的指针值;此外,实现可以为这些操作定义任何有用行为的情况很少,并且通常可以通过编译器内置函数更好地处理。 但是,在许多实现中,如果未捕获空指针算术运算,则将偏移量添加到空指针可能会生成指针,该指针虽然无效,但不再可识别为空指针。 尝试解引用这样的指针不会被陷阱,但可能会触发任意效果。
拦截(null + offset)和(null - offset)形式的指针计算将消除此危险。 请注意,保护不一定需要陷阱(pointer-null),(null-pointer)或(null-null),虽然前两个表达式返回的值可能不具有任何有用性[如果实现规定 null-null 将产生零,则针对该特定实现的代码有时可能比必须特殊处理 null 指针的代码更有效率]它们不会生成无效指针。 此外,使(null + 0)和(null - 0)返回空指针而不是陷阱可能不会危及安全性,并且可以避免用户代码特殊处理空指针的需要,但优点不太令人信服,因为编译器必须添加额外的代码才能实现这一点。
(*)例如,在8086编译器上的这种内在属性可能接受无符号16位整数“seg”和“ofs”,并读取地址seg:ofs处的字,即使当地址恰好为零时也不会发生空陷阱。 在8086上,地址(0x0000:0x0000)是一种中断向量,某些程序可能需要访问该地址,而在只有20个地址线的旧处理器上,地址(0xFFFF:0x0010)访问与(0x0000:0x0000)相同的物理位置,但在具有24个或更多地址线的处理器上,它访问物理位置0x100000)。 在某些情况下,另一种选择是为预计指向未被C标准识别的内容的指针(如中断向量之类的内容)指定特殊设计,并避免对其进行空陷阱,或者指定 volatile 指针将以这种方式处理。 我至少在一个编译器中看到了第一种行为,但我认为我没有看到第二种行为。

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