Strict Aliasing
文章:What is Strict Aliasing and Why do we Care? (github.com)
所谓 Strict Aliasing 就是指为 aliasing 设定条件,使得编译器大多数场景下认为代码没有 aliasing,从而可以激进优化代码。
#include <iostream>
int foo( float *f, int *i ) {
*i = 1;
*f = 0.f;
return *i;
}
int main() {
int x = 0;
std::cout << x << "\n"; // Expect 0
x = foo(reinterpret_cast<float*>(&x), &x);
std::cout << x << "\n"; // Expect 0?
}
上述代码在 gcc 13.1 -O2
下编译,第二行打印结果为 1。编译器认为 f 和 i 指针必定不重合,所以直接返回了 1。
什么时候 Aliasing 是允许的?
比较复杂,而且 C 和 C++ 的要求不同。在 alias 可用时,编译器不会像上面那样激进优化。下面只提到了一部分规则。
允许同类指针,而且两者都允许给指针加 cv 限定,但是 C++17 还允许 unsigned 和 signed 对应类型之间的别名,C11 不允许(尽管 gcc 和 clang 都是按照允许的方式实现的)。
最基本的保证是:无论指针如何变化,只要目的类型是对象的真实类型,就能正常使用。但是如何开启一个新对象的生命周期则有区别。C 语言中只要写入就已经可以按照写入类型启用一个新的生命周期。而在 C++ 必须保存用于写入的指针并在之后始终使用该指针!(C++17 新增了 std::launder
,可以不必再保存指针但是要洗一下)
问题:保存 memcpy 返回的指针可以吗?
C 语言的 restrict 关键字
https://stackoverflow.com/a/30827311/
restrict 关键字是指针的属性,写在指针的右侧、变量名之前,它保证了一个指针和其他指针变量没有重叠(没有 aliasing),以便编译器在 aliasing 可用时仍激进优化(可以理解为优化同类指针,但实际上是兼容类型的都可以)。
Type Punning
把一种类型当成另一种来使用,即存 A 取 B。
文章提出:安全无误的 type punning 可以用 memcpy
。
void func1( double d ) {
std::int64_t n;
std::memcpy(&n, &d, sizeof d);
//...
}
在这种比较简单的赋值场景下,只要没有用 -fno-builtin
禁止内置函数优化,memcpy
调用就能直接被优化掉,对于主流编译器来说即便是 -O0
也是如此!
memcpy 复制了值,不再使用指针,从而不受限于 aliasing 规则。
在访问目标的 alignment 和 size 都相同时,我认为 type punning 的不安全之处还是在于通过转换后指针对目标的修改,对于使用转换前指针的读可以是不可见的。复制值后当然就只能读不能写了。最开始的代码要是返回的是从 f 强转成 int *
之后再读取的,也不会出现这种情况。
C++
std::launder
本来应该是应对上面加粗字体说明的情况的,但是我在对局部变量使用时没有成功。2023 年 6 月 30 日:对于 A 类型的指针,强制转换为 B 类型后 launder,才能和其他 B 类型指针发生 aliasing 关系。(相容类型也可以 alias)
下面是对非聚合类使用 memcpy:
#include <cstdio>
#include <cstring>
#include <cassert>
#include <vector>
int main() {
static_assert(sizeof(std::vector<int>) == 24);
std::vector<int> v{1, 2, 3};
long long buf[sizeof(v) / sizeof(long long)];
memcpy(buf, &v, sizeof(v));
printf("%lld %lld %lld\n", buf[0], buf[1], buf[2]);
}
https://godbolt.org/z/zM4cY1b5n
尽管在 -O0
时显式调用了 memcpy
,但是 -O1
时就能内联成简单复制。
如果不想复制怎么办?这种做法对于大对象合适吗?
C++ 20 有 std::bit_cast
,是对这种特殊情况下 std::memcpy 的替代方案。也是值语义复制,而且要求目的类型和参数类型所占空间一致,而且两种类型都是 trivially copyable
的。
Common initial Sequence
It says that we are allowed to read the non-static data member of the non-active member if it is part of the common initial sequence of the the structs [class.mem.general]p26.
看上去这条规则没有什么用,但是:
It Is likely the common initial sequence rule was put in place to allow discriminated union without having the discriminator outside the the union and therefore likely have padding between the discriminator and the union itself e.g.
union { struct { char kind; ... } a; struct { char kind; ... } b; ... };
根据文章,这可能使得我们可以在 union 中利用 padding 存储 index,以提供 discriminated union 的功能。
C++ std::launder
std::launder
有什么用呢?表面含义是洗,也就是让指针的来源难以追溯,目的是抑制非同类指针的激进优化。但实际上 std::launder
的作用还是非常局限的,因为它的使用有一些必须满足的条件(其中就包含指针的类型必须和其真实类型对应,以及不能传入 void 指针和函数指针)。
std::launder - cppreference.com
典型的用法是用 placement new 在一块字符数组上面创建对象,然后用 std::launder
洗字符数组的首元素指针。
如果不这样做,按照 strict aliasing 的规则,也可以保存 placement new 的返回值到一个变量,然后每次都利用这个变量做操作。
https://stackoverflow.com/a/63003406
- 初始化,u.x 被激活。
- 赋值,u.f 被激活,u.x 失活。
- placement new 不会激活子对象!
- 通过保存的指针返回不会有问题。
- 虽然是访问失活的元素,但是洗了指针。
- 直接访问失活元素,UB。
回到最开始的例子
这一部分实际上都没洗成功。
将第一个部分的 C++ 代码改造如下:
https://godbolt.org/z/ME4PTa74b
#include <iostream>
#include <new>
int foo( float *f, int *i ) {
*i = 1;
*f = 0.f;
return *std::launder(i);
}
int main() {
int x = 0;
std::cout << x << "\n"; // Expect 0
x = foo(reinterpret_cast<float *>(&x), &x);
std::cout << x << "\n"; // Expect 0?
}
没有洗成功。编译器预先假设了 f 和 i 无关,这样洗还是它还是觉得返回值只和 i 有关。想要 f 的结果被编译器看到,则必须让返回值和 f 建立起来联系。实际上,在 cppreference 对 std::launder
的用法说明中上面的例子就不满足条件,因为对象的真实类型是 float(*f = 0.f)
,而要使用的类型却是 int。
还可以在中间穿插对其他 volatile 变量的访问(会禁止指令重排)。也可以直接用 volatile 访问目标数据。
#include <string.h>
#include <stdio.h>
int foo( float *f, int *i ) {
*i = 1;
*f = 0.f;
return *(int volatile *)i;
}
int main() {
int x = 0;
printf("%d\n", x); // Expect 0
x = foo((float *)(&x), &x);
printf("%d\n", x); // Yes, it's 0
}
每次用 volatile 访存显然是 overkill,观察汇编得知比开启 -fno-strict-aliasing
生成的代码稍差。
总结
避免 strict aliasing violation 的方法:
- 用
memcpy
或std::bit_cast
复制数据。 - 禁用编译器的 strict aliasing 功能。
- 保存函数或操作符的返回值 并在其上操作,或者在 placement new 之后洗指针。
“保存函数或操作符的返回值”:这样后续操作就对返回值产生了依赖性,不需要洗指针。
The Linux kernel is compiled with
-fno-strict-aliasing
. Linus justifies this in https://www.mail-archive.com/linux-btrfs@vger.kernel.org/msg01647.html .