例如,这个
unsigned f(float x) {
unsigned u = *(unsigned *)&x;
return u;
}
在平台上导致不可预测的结果,
unsigned
和float
都是 32 位- 指针对于所有类型的指针都具有固定大小
unsigned
和float
可以存储到内存的同一部分并从内存的同一部分加载。
我知道严格的混叠规则,但大多数显示违反严格混叠的问题案例的示例如下所示。
static int g(int *i, float *f) {
*i = 1;
*f = 0;
return *i;
}
int h() {
int n;
return g(&n, (float *)&n);
}
在我的理解中,编译器可以自由地假设i
和f
是隐式restrict
的。如果编译器认为*f = 0;
是冗余的(因为i
和f
不能别名),则可以1
h
的返回值,或者如果它考虑到i
和f
的值相同,则可以0
返回值。这是未定义的行为,因此从技术上讲,任何其他事情都可能发生。
但是,第一个示例有点不同。
unsigned f(float x) {
unsigned u = *(unsigned *)&x;
return u;
}
很抱歉我的措辞不清楚,但一切都是"就地"完成的。我想不出编译器可以解释行unsigned u = *(unsigned *)&x;
的任何其他方式,除了"将x
位复制到u
"。
在实践中,我在 https://godbolt.org/测试的各种架构的所有编译器都经过了完全优化,对第一个示例产生了相同的结果,而对于第二个示例,产生了不同的结果(0
或1
)。
我知道从技术上讲,unsigned
和float
可能有不同的大小和对齐要求,或者应该存储在不同的内存段中。在这种情况下,即使是第一个代码也没有意义。但是在大多数现代平台上,以下情况成立,第一个示例是否仍然未定义的行为(它会产生不可预测的结果)?
unsigned
和float
都是 32 位- 指针对于所有类型的指针都具有固定大小
unsigned
和float
可以存储到内存的同一部分并从内存的同一部分加载。
在真正的代码中,我确实写
unsigned f(float x) {
unsigned u;
memcpy(&u, &x, sizeof(x));
return u;
}
优化后,编译结果与使用指针强制转换相同。这个问题是关于代码严格混叠规则的标准的解释,例如第一个示例。
通过不兼容的指针复制变量的位总是未定义的行为吗?
是的。
规则是 https://port70.net/~nsz/c/c11/n1570.html#6.5p7:
对象的存储值只能由具有以下 以下类型:
- 与对象的有效类型兼容的类型,
- 与对象的有效类型兼容的限定版本,
一种类型,- 该类型是有符号或无符号类型,对应于 对象
- 一种类型,该类型是与 对象的有效类型,
- 包含上述类型之一的聚合或联合类型 成员(递归地包括子聚合或包含的联合的成员),或
- 字符类型。
对象x
的有效类型是float
- 它是使用该类型定义的。
unsigned
与float
不兼容,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:
在以下情况下未定义该行为:
- 对象的存储值不是通过允许类型的 (6.5) 的左值访问的。
正如卡米尔所解释的那样,它是UB。 即使是int
和long
(或long
和long long
)也不兼容别名,即使它们的大小相同。 (但有趣的是,unsigned int
与int
兼容)
这与大小相同或使用注释中建议的相同寄存器集无关,它主要是一种让编译器在优化时假设不同的指针不指向重叠内存的方法。 他们仍然必须支持C99union
类型双关语,而不仅仅是memcpy
。 因此,例如,如果 dst 和 src 具有不同的类型,则dst[i] = src[i]
循环在展开或矢量化时不需要检查可能的重叠。1
如果您正在访问相同的整数数据,则标准要求您使用完全相同的类型,仅对诸如signed
vs.unsigned
和const
. 或者你使用(无符号的)char*
,这就像GNU C__attribute__((may_alias))
。
你问题的另一部分似乎是为什么它在实践中似乎有效,尽管有 UB。
您的 godbolt 链接忘记链接您尝试过的实际编译器。
https://godbolt.org/z/rvj3d4e4o 显示了GCC4.1,从GCC特意支持像这样的"明显的"本地编译时可见的情况之前,有时不会使用这样的不可移植习语破坏人们的错误代码。 它会从堆栈内存加载垃圾,除非您先使用-fno-strict-aliasing
将其movd
到该位置。 (存储/重新加载而不是movd %xmm0, %eax
是一个遗漏的优化错误,在大多数情况下已在后来的 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注意到了这一点,并且不认为它定义得很好。后来选择支持此代码的 GCC 仍然会发出警告。
有时在简单的情况下工作,但在其他情况下中断,还是总是失败更好,这是值得商榷的。 但鉴于 GCC-Wall
仍然对此发出警告,对于处理遗留代码或从 MSVC 移植的人来说,这可能是一个很好的权衡。 另一种选择是始终破坏它,除非人们使用-fno-strict-aliasing
,如果处理依赖于此行为的代码库,他们应该这样做。
成为 UB 并不意味着需要失败
恰恰相反;例如,在C抽象机器中的每个有符号溢出上实际捕获需要大量的额外工作,尤其是在优化2 + c - 3
c - 1
之类的东西时。 这就是gcc -fsanitize=undefined
试图做的,在添加后添加 x86jo
指令(除了它仍然进行常量传播,所以它只是添加-1
,而不是在INT_MAX上检测到临时溢出。 https://godbolt.org/z/WM9jGT3ac)。而且似乎严格混叠不是它试图在运行时检测到的UB类型之一。
另请参阅clang博客文章:每个C程序员都应该知道的关于未定义行为的知识
一个实现可以自由定义ISO C标准未定义的行为
例如,MSVC 总是定义这种混叠行为,就像 GCC/clang/ICC 对-fno-strict-aliasing
所做的那样。 当然,这并不能改变纯ISO C未定义的事实。
这只是意味着在这些特定的 C实现上,代码可以保证按照您想要的方式工作,而不是碰巧这样做,或者碰巧这样做,如果它足够简单,现代 GCC 可以识别并做更"友好"的事情。
就像有符号整数溢出的gcc -fwrapv
一样。
脚注 1:严格别名帮助代码生成的示例
#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
默认值,我们只得到一个具有movdqu
/cvtdq2ps
/movups
和循环开销的 SIMD 环路。 使用-O3 -fno-strict-aliasing
,我们得到了两个版本的循环,以及重叠检查以查看我们是否可以运行标量或 SIMD 版本。
是否存在严格的别名有助于更好地生成代码的实际情况,在这种情况下,
restrict
无法实现相同的目标
您可能有一个指针,该指针可能指向两个int
数组中的任何一个,但绝对不是指向任何float
变量,因此您不能在其上使用restrict
。 严格别名将允许编译器仍然避免通过指针在存储周围溢出/重新加载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 似乎所做的是特殊情况下[]
运算符与指针取消引用具有不同的含义,但标准中没有任何内容做出这样的区分。 一致地解释标准的唯一方法是认识到带有[]
的形式属于标准不要求实现有意义地处理但无论如何都依赖于它们来处理的操作类别。
像你这样的使用直接投射指针访问与原始指针类型的对象关联的存储的示例的构造属于类似的结构类别,至少标准的一些作者可能期望(并且会要求,如果他们没有期望的话)编译器将可靠地处理,无论是否有授权, 因为没有可以想象的理由为什么高质量的编译器会这样做。 然而,从那以后,clang和gcc已经发展到无视这种期望。 即使 clang 和 gcc 通常会为函数生成有用的机器代码,它们也会寻求执行积极的过程间优化,从而无法预测哪些构造是 100% 可靠的。 与一些编译器不同,除非他们能证明它们是合理的,否则不会应用潜在的优化转换,而 clang 和 gcc 试图执行无法证明会影响程序行为的转换。