通过不兼容的指针复制变量的位是否总是未定义的行为?

Is it always undefined behaviour to copy the bits of a variable through an incompatible pointer?

例如,这个可以吗

unsigned f(float x) {
    unsigned u = *(unsigned *)&x;
    return u;
}

在平台上造成不可预测的结果,

我知道严格的别名规则,但大多数显示违反严格别名的问题案例的示例如下所示。

static int g(int *i, float *f) {
    *i = 1;
    *f = 0;
    return *i;
}

int h() {
    int n;
    return g(&n, (float *)&n);
}

根据我的理解,编译器可以自由地假设 if 隐含地 restrict。如果编译器认为 *f = 0; 是多余的(因为 if 不能别名),h 的 return 值可能是 1,或者如果考虑到 if 的值相同,则它可能是 0。这是未定义的行为,所以从技术上讲,任何其他事情都可能发生。

但是,第一个示例有点不同。

unsigned f(float x) {
    unsigned u = *(unsigned *)&x;
    return u;
}

抱歉我的措辞不明确,但一切都是“就地”完成的。除了“将 x 的位复制到 u”之外,我想不出编译器可能解释行 unsigned u = *(unsigned *)&x; 的任何其他方式。

实际上,我在 https://godbolt.org/ 中测试的各种体系结构的所有编译器都经过完全优化,为第一个示例产生相同的结果,但结果不同(01)对于第二个例子。

我知道 unsignedfloat 具有不同的大小和对齐要求在技术上是可能的,或者应该存储在不同的内存段中。在那种情况下,即使是第一个代码也没有意义。但是在以下情况成立的大多数现代平台上,第一个示例是否仍然是未定义的行为(它会产生不可预测的结果)吗?


在实际代码中,我写

unsigned f(float x) {
    unsigned u;
    memcpy(&u, &x, sizeof(x));
    return u;
}

优化后的编译结果与使用指针转换相同。这个问题是关于代码的严格别名规则的标准的解释,例如第一个例子。

Is it always undefined behaviour to copy the bits of a variable through an incompatible pointer?

是的。

规则是https://port70.net/~nsz/c/c11/n1570.html#6.5p7:

An object shall have its stored value accessed only by an lvalue expression that has one of the following types:

  • a type compatible with the effective type of the object,
  • a qualified version of a type compatible with the effective type of the object,
  • a type that is the signed or unsigned type corresponding to the effective type of the object,
  • a type that is the signed or unsigned type corresponding to a qualified version of the effective type of the object,
  • an aggregate or union type that includes one of the aforementioned types among its members (including, recursively, a member of a subaggregate or contained union), or
  • a character type.

对象 x 的有效类型是 float - 它是用该类型定义的。

  • unsignedfloat
  • 不兼容
  • unsigned 不是 float,
  • 的合格版本
  • unsigned 不是 float,
  • 的有符号或无符号类型
  • unsigned 不是与 float
  • 的合格版本相对应的有符号或无符号类型
  • unsigned 不是聚合或联合类型
  • unsigned不是字符类型。

违反了“应”,这是未定义的行为(参见 https://port70.net/~nsz/c/c11/n1570.html#4p2 )。没有其他解释。

我们还有https://port70.net/~nsz/c/c11/n1570.html#J.2 :

The behavior is undefined in the following circumstances:

  • An object has its stored value accessed other than by an lvalue of an allowable type (6.5).

正如 Kamil 所解释的那样,这是 UB。即使 intlong(或 longlong long)即使大小相同也不是 alias-compatible。 (但有趣的是,unsigned intint 兼容)

这与大小相同或使用评论中建议的相同 register-set 无关,它主要是让编译器在优化时假设不同的指针不指向重叠内存的一种方式.他们仍然必须支持 C99 union type-punning,而不仅仅是 memcpy。因此,例如,如果 dst 和 src 具有不同的类型,dst[i] = src[i] 循环在展开或矢量化时不需要检查可能的重叠。1

如果您要访问相同的整数数据,则标准要求您使用完全相同的类型,仅取模 signedunsignedconst 之类的东西。或者你使用 (unsigned) char*,就像 GNU C __attribute__((may_alias)).


你问题的另一部分似乎是为什么它在实践中似乎有效,尽管有 UB。
你的神 link 忘了 link 你试过的实际编译器。

https://godbolt.org/z/rvj3d4e4o 显示 GCC4.1,从 GCC 竭尽全力支持像这样的“明显”本地 compile-time-visible 案例 到有时不要使用像这样的 non-portable 习语来破坏人们的错误代码。 它从堆栈内存中加载垃圾,除非您首先使用 -fno-strict-aliasing 使其 movd 到该位置。 (Store/reload 而不是 movd %xmm0, %eax 是一个 missed-optimization 错误,在大多数情况下已在以后的 GCC 版本中修复。)

f:     # GCC4.1 -O3
        movl    -4(%rsp), %eax
        ret
f:    # GCC4.1 -O3 -fno-strict-aliasing
        movss   %xmm0, -4(%rsp)
        movl    -4(%rsp), %eax
        ret

即使是旧的 GCC 版本也会发出警告 warning: dereferencing type-punned pointer will break strict-aliasing rules,这应该表明 GCC 注意到了这一点并且 考虑它 well-defined。后来选择支持此代码的 GCC 仍然会发出警告。

有时在简单的情况下工作,但在其他时候中断,与总是失败是否更好,这是值得商榷的。但是考虑到 GCC -Wall 确实仍然对此发出警告,这可能是处理遗留代码的人的便利性或从 MSVC 移植之间的一个很好的权衡。另一种选择是始终打破它,除非人们使用 -fno-strict-aliasing,如果处理依赖于此行为的代码库,他们应该这样做。


成为UB并不意味着required-to-fail

恰恰相反;例如,在 C 抽象机中实际捕获每个带符号的溢出需要大量额外的工作,尤其是在将 2 + c - 3 之类的东西优化为 c - 1 时。这就是 gcc -fsanitize=undefined 试图做的,在添加后添加 x86 jo 指令(除了它仍然 constant-propagation 所以它只是添加 -1,没有检测 [=135= 上的临时溢出].https://godbolt.org/z/WM9jGT3ac). strict-aliasing 似乎不是它在 运行 时尝试检测的 UB 类型之一。

另请参阅 clang 博客文章:What Every C Programmer Should Know About Undefined Behavior


实现可以自由定义 ISO C 标准未定义的行为

例如,MSVC 总是定义这种别名行为,就像 GCC/clang/ICC 和 -fno-strict-aliasing 一样。当然,这并没有改变纯 ISO C 未定义它的事实。

这只是意味着在 那些 特定的 C 实现上,代码保证按您想要的方式工作,而不是偶然或 de-facto 编译器行为,如果它足够简单,现代 GCC 可以识别并做更“友好”的事情。

就像 gcc -fwrapv for signed-integer 溢出一样。


脚注 1:strict-aliasing 帮助 code-gen
的示例
#define QUALIFIER // restrict

void convert(float *QUALIFIER pf, const int *pi) {
    for(int i=0 ; i<10240 ; i++){
        pf[i] = pi[i];
    }
}

Godbolt 表明,对于 x86-64,GCC11.2 的 -O3 默认值,我们得到 just 一个带有 [=41= 的 SIMD 循环] / cvtdq2ps / movups 和循环开销。使用 -O3 -fno-strict-aliasing,我们得到两个版本的循环,并进行重叠检查以查看我们是否可以 运行 标量或 SIMD 版本。

Is there actual cases where strict aliasing helps better code generation, in which the same cannot be achieved with restrict

您可能有一个指针可能指向两个 int 数组中的任何一个,但绝对不指向任何 float 变量,因此您不能在其上使用 restrict . Strict-aliasing 将让编译器仍然通过指针避免存储周围的 spill/reload 个 float 对象,即使 float 对象是全局变量或者不是可证明的本地对象功能。 (逃逸分析。)

或者 struct node * 肯定与树中的有效载荷类型不同。

此外,大多数代码 不会 到处使用 restrict。它可能会变得非常麻烦。不仅在循环中,而且在处理指向结构的指针的每个函数中。如果你弄错了并承诺了一些不真实的东西,你的代码就坏了。

该标准从未打算完全、准确和明确地划分具有定义行为和未定义行为的程序 (*),而是依赖于编译器编写者运用一定的常识。

(*) 如果它是为此目的而设计的,它会惨遭失败,由此产生的大量混乱就是明证。

考虑以下两个代码片段:

/* Assume suitable declarations of u are available everywhere */
union test { uint32_t ww[4]; float ff[4]; } u;

/* Snippet #1 */
uint32_t proc1(int i, int j)
{
  u.ww[i] = 1;
  u.ff[j] = 2.0f;
  return u.ww[i];
}

/* Snippet #2, part 1, in one compilation unit */
uint32_t proc2a(uint32_t *p1, float *p2)
{
  *p1 = 1;
  *p2 = 2.0f;
  return *p1;
}

/* Snippet #2, part 2, in another compilation unit */
uint32_t proc2(int i, int j)
{
  return proc2a(u.ww+i, u.ff+j);
}

很明显,该标准的作者打算在有意义的平台上对代码的第一个版本进行有意义的处理,但也很明显,至少 C99 及更高版本的一些作者确实这样做了不打算要求对第二个版本进行同样的处理(C89 的一些作者可能打算“严格别名规则”仅适用于 直接命名的 对象将被访问的情况通过另一种类型的指针,如已发布的基本原理中给出的示例所示;基本原理中没有任何内容表明希望更广泛地应用它。

另一方面,标准 定义 [] 运算符的方式使得 proc1 在语义上等同于:

uint32_t proc3(int i, int j)
{
  *(u.ww+i) = 1;
  *(u.ff+j) = 2.0f;
  return *(u.ww+i);
}

并且标准中没有任何内容暗示 proc() 不应具有相同的语义。 gcc 和 clang 似乎做的是 special-case [] 运算符与指针解除引用具有不同的含义,但标准中没有任何内容做出这样的区分。一致地解释标准的唯一方法是认识到带有 [] 的表单属于标准不要求实现过程有意义但无论如何都依赖于它们来处理的操作类别。

诸如您使用 directly-cast 指针访问与原始指针类型的对象关联的存储的示例的构造属于类似的构造类别,至少标准的某些作者可能期望(并且会要求,如果他们不期望的话)编译器将可靠地处理,有或没有授权,因为没有可以想象的理由为什么一个高质量的编译器会不这样做。然而,从那时起,clang 和 gcc 已经发展到违背这种期望。即使 clang 和 gcc 通常会为函数生成有用的机器代码,它们也会寻求执行积极的 inter-procedural 优化,这使得无法预测哪些构造将 100% 可靠。与一些编译器不同,除非它们可以证明它们是合理的,否则不会应用潜在的优化转换,clang 和 gcc 寻求执行无法证明会影响程序行为的转换。