【问题标题】:Does (p+x)-x always result in p for pointer p and integer x in gcc linux x86-64 C++(p+x)-x 在 gcc linux x86-64 C++ 中是否总是导致指针 p 和整数 x 的 p
【发布时间】:2019-07-24 20:45:01
【问题描述】:

假设我们有:

char* p;
int   x;

正如最近讨论的in another question,算术包括对无效指针的比较操作可能会在 gcc linux x86-64 C++ 中产生意外行为。这个新问题专门针对表达式(p+x)-x:它能否在x86-64 linux 上运行的任何现有GCC 版本中产生意外行为(即结果不是p)?

请注意,这个问题只是关于指针算术;绝对没有意图访问*(p+x)指定的位置,这显然通常是不可预测的。

这里的实际兴趣是non-zero-based arrays。请注意,(p+x)x 的减法发生在这些应用程序代码的不同位置。

如果可以证明 x86-64 上的最新 GCC 版本永远不会为 (p+x)-x 生成意外行为,那么这些版本可以通过非零基数组认证,并且可以修改或配置生成意外行为的未来版本以支持这个认证。

更新

对于上述实际情况,我们还可以假设p 本身是一个有效指针,而p != NULL

【问题讨论】:

  • "可以在 gcc linux x86-64 C++ 中生成 UB" - 它不会生成 UB。它 C++语言中的UB - 编译器和平台与它是否是UB无关。
  • “具体来说,如果 x86-64 上的最新 GCC 版本可以显示永远不会为 (p+x)-x 生成 UB,那么这些版本可以被认证为非从零开始的数组”。我想我是唯一一个愚蠢到无法理解这一点的人?
  • 你还是完全误解了UB的意思。 (是的,我在您的其他问题中阅读了您所有的 cmets。)UB 在源代码中。它不是由编译器或系统生成的。
  • Gcc 非常复杂,很难确定它在任何涉及未定义行为的情况下都不会出现意外行为。对于您所描述的情况,我当然不会排除它。
  • 无论你设计什么方案,CPU 都必须添加一些东西。调整索引(我的方案)与调整指针(你的方案?)有何不同?

标签: c++ linux gcc x86-64 pointer-arithmetic


【解决方案1】:

是的,特别是对于 gcc5.x 及更高版本,该特定表达式很早就优化为 p,即使禁用了优化,也不管任何可能的运行时 UB。

即使使用静态数组和编译时常量大小也会发生这种情况。 gcc -fsanitize=undefined 也没有插入任何工具来查找它。 -Wall -Wextra -Wpedantic也没有警告

int *add(int *p, long long x) {
    return (p+x) - x;
}

int *visible_UB(void) {
    static int arr[100];
    return (arr+200) - 200;
}

在任何优化通过之前使用gcc -dump-tree-original 转储其程序逻辑的内部表示表明这种优化甚至发生在在gcc5.x 和更新版本之前。 (甚至发生在-O0)。

;; Function int* add(int*, long long int) (null)
;; enabled by -tree-original

return <retval> = p;


;; Function int* visible_UB() (null)
;; enabled by -tree-original
{
  static int arr[100];

    static int arr[100];
  return <retval> = (int *) &arr;
}

那是from the Godbolt compiler explorer with gcc8.3-O0

x86-64 asm 输出只是:

; g++8.3 -O0 
add(int*, long long):
    mov     QWORD PTR [rsp-8], rdi
    mov     QWORD PTR [rsp-16], rsi    # spill args
    mov     rax, QWORD PTR [rsp-8]     # reload only the pointer
    ret
visible_UB():
    mov     eax, OFFSET FLAT:_ZZ10visible_UBvE3arr
    ret

-O3 输出当然只是mov rax, rdi


gcc4.9 及更早版本仅在稍后的通道中进行此优化,而不是在 -O0:树转储仍然包括减法,而 x86-64 asm 是

# g++4.9.4 -O0
add(int*, long long):
    mov     QWORD PTR [rsp-8], rdi
    mov     QWORD PTR [rsp-16], rsi
    mov     rax, QWORD PTR [rsp-16]
    lea     rdx, [0+rax*4]            # RDX = x*4 = x*sizeof(int)
    mov     rax, QWORD PTR [rsp-16]
    sal     rax, 2
    neg     rax                       # RAX = -(x*4)
    add     rdx, rax                  # RDX = x*4 + (-(x*4)) = 0
    mov     rax, QWORD PTR [rsp-8]
    add     rax, rdx                  # p += x + (-x)
    ret

visible_UB():       # but constants still optimize away at -O0
    mov     eax, OFFSET FLAT:_ZZ10visible_UBvE3arr
    ret

这确实与-fdump-tree-original 输出一致:

return <retval> = p + ((sizetype) ((long unsigned int) x * 4) + -(sizetype) ((long unsigned int) x * 4));

如果x*4 溢出,您仍然会得到正确的答案。在实践中,我想不出一种方法来编写一个会导致 UB 导致行为发生明显变化的函数。


作为更大函数的一部分,允许编译器推断一些范围信息,例如 p[x]p[0] 属于同一对象,因此在 / 之间读取内存允许并且不会出现段错误。例如允许搜索循环的自动矢量化。

但我怀疑 gcc 甚至会寻找它,更不用说利用它了。

(请注意,您的问题标题是特定于 gcc 在 Linux 上针对 x86-64,不是关于 gcc 中类似的事情是否安全,例如,如果在单独的语句中完成。我的意思是可能是安全的练习,但不会在解析后立即被优化掉。而且绝对不是一般的 C++。)


我强烈建议不要这样做。使用uintptr_t 来保存不是实际有效指针的类似指针的值。就像您在更新C++ gcc extension for non-zero-based array pointer allocation? 上的答案时所做的那样。

【讨论】:

  • 谢谢你教我-fdump-tree-original;这太酷了。无论如何,是的,我打算 (p+x) 和减法可能发生在不同的地方,就像在非零基数组指针问题中一样;我已经更新了这个问题。是的,我一直在寻找编译器可以做什么样的优化来破坏(p+x)-x==p。您的预取示例是可以想象的(正如您所说,不太可能)。
  • @personal_cloud:我的意思不是预取,我的意思是使用 16 字节加载和 pcmpeqb / pmovmskb / 自动矢量化像 while(*p++ != 0){}(即 strlen)这样的搜索循环test+jz。因此,您不可避免地会触摸终止 0 字节之后的数据,除非它恰好位于向量的末尾。为了安全起见,您必须对齐指针,以免进入不包含您可以读取的任何字节的页面,因此可能未映射。 Is it safe to read past the end of a buffer within the same page on x86 and x64?.
  • 如果编译器“知道”它是安全的,它可能会决定从任何起点使用未对齐的加载,因此如果终止符是页面的最后一个字节,则可能会出现段错误。 (但是,是的,不太可能,因为它必须考虑到没有找到终止符并且它继续读取超出已知安全距离的情况。)除了 gcc/clang 在循环行程计数时从不自动矢量化的事实之外在第一次迭代运行之前不知道。不过,ICC 会的。
  • 我明白了。可以想象,如果我有一个伪造的指针q=(p+100),然后我从q[-100] 开始执行搜索循环,编译器会查看它并认为它可以一直加载到q,它跨越了页面边界(因为p 的大小只有 32,比如说)和 1% 的运行出现段错误......似乎是合理的。
  • 我认为 -fsanitize=pointer-overflow 应该对此进行检测,它看起来像一个不会禁用优化的错误。
【解决方案2】:

这是 gcc 扩展的列表。 https://gcc.gnu.org/onlinedocs/gcc/C-Extensions.html

指针算术有一个扩展。 Gcc 允许对 void 指针执行指针运算。 (不是您要询问的扩展程序。)

因此,gcc 在语言标准中描述的相同条件下将您询问的指针算术行为视为未定义。

您可以查看那里,看看我是否遗漏了与您的问题相关的任何内容。

【讨论】:

  • 感谢您分享列表。你能解释一下为什么“(p+x)-x 计算为 p”的特征必须是一个扩展吗?是不是核心引擎已经有这个功能了?毕竟,C++ 不会禁止它。 (事实上​​,C++ 规范会使避免在 x86-64 中实现该功能变得困难。编译器很难确定p 既不是内存位置也没有一个数组元素也没有在分配的页面)
  • 如果它没有被记录为扩展,那么它在与标准相同的条件下是 UB。如果是 UB,编译器在某些情况下很可能会生成具有“意外行为”的代码。
  • “GCC 有未定义的行为”。你能解释一下这是什么意思吗? GCC 项目是否在某处定义了该术语?请注意,我们在这里是语言-律师-巨魔-区域,所以我不确定如果没有定义这是否可以接受!
  • @Johannes,我的意思是,如果标准说行为是未定义的并且 gcc 本身没有定义它,那么 gcc 会将行为视为未定义。
  • 我仍然想知道您为什么怀疑当前的实现是否具有我期望的属性,但我必须根据对文档的合理一级搜索接受您的回答是有效且相关的.我很欣赏你对这个话题的见解。感谢您分享它们。
【解决方案3】:

你不明白什么是“未定义的行为”,我不能责怪你,因为它通常解释得不好。这就是标准定义未定义行为的方式,intro.defs 中的第 3.27 节:

本文档没有要求的行为

就是这样。不多不少,不多不少。该标准可以被认为是编译器供应商在生成有效程序时要遵循的一系列约束。当有未定义的行为时,所有的赌注都被取消了。

有些人说未定义的行为会导致您的程序产生龙或重新格式化您的硬盘,但我觉得这有点像稻草人。更实际地讲,像超出数组边界这样的事情可能导致段错误(由于触发页面错误)。有时未定义的行为允许编译器进行优化,从而以意想不到的方式改变程序的行为,因为没有什么说编译器不能

关键是编译器不会“生成未定义的行为”。 你的程序中存在未定义的行为。

我的意思是,如果 GCC 有一个很棒的功能(特别是无效指针的数学运算)但目前没有命名,我们可以给它一个名字,然后在未来的版本中也需要它。

那么这将是一个非标准的扩展,人们会期望它被记录在案。我也非常怀疑这样的功能是否会有很高的需求,因为它不仅允许人们编写不安全的代码,而且很难为其生成可移植的程序。

【讨论】:

  • 是的,我知道 UB 的一般含义。对于这个问题,我澄清说我的意思是写 unexpected behavior ,我将其定义为 (p+x)-x 不会导致 p (这应该从标题中非常清楚的问题)和一个非常具体的 C++ 实现,即 gcc linux x86-64。
  • undefined behavior can lead to your program spawning dragons 公平地说,你需要在系统中安装一个龙生成适配器。
猜你喜欢
  • 1970-01-01
  • 2018-03-16
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2020-10-08
  • 1970-01-01
  • 1970-01-01
  • 2016-07-11
相关资源
最近更新 更多