CppCon 2014 学习:The Perils of Strict Aliasing
你引用的是 C++ 标准中关于**对象存储访问规则(aliasing rules)**的条款,具体是 [basic.lval]/10(也叫做“strict aliasing rule”的核心内容)。我们来逐句解读并深入理解这个规定。
条款原文简化回顾(§3.10.10)
如果程序试图通过一个 glvalue(广义左值) 来访问某个对象的存储值,但这个 glvalue 的类型不是以下列表中的任何一种,那么这种行为是未定义的(undefined behavior, UB):
哪些类型是允许的(可以“安全 alias”)
假设对象的实际类型(dynamic type)是 int
,下面这些是允许访问的 glvalue 类型:
类型 | 示例 | 原因 |
---|---|---|
① 对象自身类型 | int | 同类型访问 |
② cv 限定版本 | const int , volatile int | 加了限定词也行 |
③ 类似类型(similar type) | 指大小和布局相同的类型(定义在§4.4) | 比如 typedef int myint; |
④ 对应的 signed/unsigned 类型 | unsigned int 对 int | C++ 特例 |
⑤ 带 cv 的 signed/unsigned 类型 | const unsigned int | 同上 |
⑥ 包含该类型的聚合或联合体 | 如 union 中有 int 成员 | 通过 union 成员访问 |
⑦ 基类类型 | Base 是 Derived 的基类 | 多态访问 |
⑧ char 或 unsigned char | 万能 alias 类型:可用于原始字节访问 |
举个未定义行为的例子(违反 aliasing)
float f = 3.14f;
int* ip = (int*)&f; // undefined behavior: accessing float as int
std::cout << *ip;
虽然很多编译器允许这么做,但从标准角度看,这是 UB,因为 int
不是 float
的允许 alias 类型。
合法的方式:使用 memcpy
或 std::bit_cast
(C++20)
float f = 3.14f;
int i;
std::memcpy(&i, &f, sizeof(i)); // 合法,字节复制,不触发 UB
或(C++20)
int i = std::bit_cast<int>(f); // 安全合法的类型转换
为什么要有 strict aliasing rule?
主要目的是:
- 允许编译器优化:当编译器知道两个不同类型的指针不可能指向同一内存,就能大胆优化代码。
- 提高执行效率:尤其在现代 CPU 的流水线和缓存机制下。
附注:char 类型的特权
char
和unsigned char
被允许访问任何类型对象的字节内容(即“byte-wise aliasing”)。
所以这合法:
int x = 42;
unsigned char* p = reinterpret_cast<unsigned char*>(&x);
std::cout << (int)p[0]; // 合法,按字节读写
总结
内容 | 说明 |
---|---|
glvalue 访问对象 | 只有特定类型访问是合法的 |
其他类型访问 | 是 undefined behavior(UB) |
例外 | char , unsigned char , union 成员等 |
目的 | 提供优化空间、防止隐式错误 |
推荐方式 | 使用 std::bit_cast 、memcpy 、类型安全访问等方式避免 aliasing 问题 |
详细讲解你提到的代码是否是合法的 C++ 行为。
代码分析
uint32_t swaphalves(uint32_t a)
{ auto ptr = reinterpret_cast<uint16_t*>(&a); std::swap(ptr[0], ptr[1]); return a;
}
它的作用:
这段代码的目的是将一个 uint32_t
类型整数的高 16 位和低 16 位互换。
举个例子:
a = 0x12345678; // 十六进制表示
ptr[0] = 0x5678; // 低 16 位
ptr[1] = 0x1234; // 高 16 位
交换后:
a = 0x56781234;
是否是合法的行为?
不完全合法。原因是:
违反了 C++ 的 strict aliasing(严格别名规则)
标准规定:你只能通过某些特定类型的指针或引用访问对象的内存内容,否则会是 未定义行为(undefined behavior)。
对于一个 uint32_t
类型的对象,你只能通过以下类型访问它:
uint32_t
或const uint32_t
int32_t
(有符号/无符号的对应类型)char
或unsigned char
(用于按字节访问)- 包含它的 union、相同布局的结构体等
合法的访问方式:
uint32_t* p = &a;
非法的访问方式:
uint16_t* p = reinterpret_cast<uint16_t*>(&a); // 违反 aliasing 规则
虽然大多数编译器(如 GCC、Clang)在某些平台(如 x86)上运行时没问题,但这是标准层面上的未定义行为,可能会在优化或不同编译器设置下出问题。
正确写法:使用位操作(推荐)
标准且高效的做法是使用位运算实现“高低 16 位交换”:
uint32_t swaphalves(uint32_t a) {return (a << 16) | (a >> 16);
}
为什么这更好:
- 没有类型别名问题
- 没有指针转换
- 编译器会自动优化成一条交换指令(如
ror
) - 完全标准、安全、跨平台
总结
方法 | 合法性 | 推荐程度 | 说明 | |
---|---|---|---|---|
reinterpret_cast<uint16_t*>(&a) | 不合法(UB) | 不推荐 | 违反别名规则 | |
std::memcpy 或 std::bit_cast (C++20) | 合法 | 推荐 | 安全地重解释类型 | |
位运算:`(a << 16) | (a >> 16)` | 合法 | 强烈推荐 | 快速、清晰、安全 |
如果你是做底层驱动、嵌入式或特定平台代码,有时可能“知道它能用”,但建议仍然尽量写成标准允许的形式,避免难以排查的 bug。 | ||||
需要我用 std::memcpy 或 bit_cast 版本实现也可以告诉我。 |
你这个例子用的是 union(联合体) 来做高低 16 位交换,属于 C++ 中常见的类型重解释技巧。我们来看它是否合法,有没有风险。
代码回顾
uint32_t swaphalves_union_ptr(uint32_t a)
{ union u { uint32_t dw; int16_t w[2]; }; auto u_ptr = reinterpret_cast<u*>(&a); std::swap(u_ptr->w[0], u_ptr->w[1]); return u_ptr->dw;
}
关键点分析
1. 访问对象内存时的 aliasing 规则
标准中提到:
通过包含该类型成员的 union访问该对象,是允许的。
u
联合体包含一个 uint32_t
成员 dw
和一个 int16_t[2]
数组成员 w
。
你传入的是一个 uint32_t a
,取它地址强制转换成 u*
,然后通过 w
访问。
2. 这是不是符合 strict aliasing 规则?
- 联合体类型的成员访问被认为是允许的“别名”访问(aliasing)。
- 但这里的关键点是:
- 你的
a
是 一个独立的uint32_t
变量,而不是原本就是u
类型的对象。 - 你只是强制把
uint32_t*
指针 reinterpret_cast 成了u*
。
- 你的
3. 是否安全?
根据 C++ 标准(尤其是严格的解释):
- 你不能通过
u*
去访问一个原本是uint32_t
对象的内存,除非该对象本身就是u
类型。 - 这仍然是未定义行为(UB)。
简而言之:
只有当对象本身是 union 类型,才能通过其成员访问内存。
仅靠
reinterpret_cast
转换指针来访问不同的 union 成员并不保证合法。
4. 运行时效果
在大多数编译器(GCC、Clang、MSVC)和平台上,这个代码通常能正常工作,因为底层内存布局是兼容的。
但是从标准合规角度讲,存在未定义行为风险。
5. 正确且标准的做法
- 避免用
reinterpret_cast
来强制转指针。 - 如果要用 union 来做类型重解释,应该直接定义变量为 union 类型:
union u
{ uint32_t dw; int16_t w[2];
};
uint32_t swaphalves_union(uint32_t a) {u value;value.dw = a;std::swap(value.w[0], value.w[1]);return value.dw;
}
这才是标准认可且安全的访问方式。
6. 更推荐的移植性写法
直接用位操作:
uint32_t swaphalves(uint32_t a) {return (a << 16) | (a >> 16);
}
总结表
方案 | 是否标准合法 | 说明 |
---|---|---|
reinterpret_cast<uint16_t*> | 否(UB) | 违反 strict aliasing |
union 指针 reinterpret_cast | 否(UB) | 指针转换访问非 union 对象成员 |
union 变量直接访问 | 是 | 对 union 变量访问其成员合法 |
位运算 | 是 | 最简单且可移植的实现 |
这段代码理解和合法性分析
uint32_t swaphalves_union(uint32_t a)
{ union { uint32_t dw; int16_t w[2]; }; dw = a; std::swap(w[0], w[1]); return dw;
}
1. 匿名 union 作为局部变量
这里你定义了一个匿名 union作为局部变量(未命名变量名),它有两个成员:
dw
—— 32 位无符号整数w[2]
—— 两个 16 位整数组成的数组
匿名 union 的所有成员都可以直接访问,就像它们是函数体中的普通变量。
2. 使用步骤:
- 把输入参数
a
赋值给dw
- 通过
w[0]
和w[1]
访问它的低16位和高16位 - 使用
std::swap
交换这两个16位 - 返回修改后的
dw
3. 这种写法的合法性:
- 这里的 union 是你新建的局部变量,不是对传入变量强制类型转换。
- 你通过 union 的成员访问它内部存储的数据,符合 C++ 标准对 union 访问的要求。
- 这种用法是 标准安全的,符合 strict aliasing 规则。
4. 关于 int16_t
是否合适
- 你用的是带符号的
int16_t
,交换后高位的符号位也会被交换,这通常没问题。 - 但如果你想处理的是纯粹的二进制位,通常更推荐用 无符号类型
uint16_t
,避免符号扩展或其他意外行为。
5. 总结
代码 | 是否标准合法 | 说明 |
---|---|---|
你这段匿名 union 版本 | 局部变量,直接访问union成员,合法且安全 | |
之前 reinterpret_cast 指针版本 | 违反 strict aliasing,UB |
改进版建议(用无符号16位)
uint32_t swaphalves_union(uint32_t a)
{ union { uint32_t dw; uint16_t w[2]; };dw = a;std::swap(w[0], w[1]);return dw;
}
你写的这段代码用到了 GCC/Clang 特有的 __attribute__((__may_alias__))
,这是一个用来**告诉编译器“这个类型的指针可能别名其他类型”**的扩展属性。
代码分析
uint32_t swaphalves_mayalias(uint32_t a)
{ using uint16_alias = uint16_t __attribute__((__may_alias__)); auto ptr = reinterpret_cast<uint16_alias*>(&a); std::swap(ptr[0], ptr[1]); return a;
}
这个属性的作用
__may_alias__
让编译器知道用这个类型的指针访问内存时,不要因为 strict aliasing 规则而假设它不会别名其他类型。- 相当于告诉编译器:“别因为别名规则就乱优化这块内存访问”。
这样写的效果
- 避免了因为严格别名规则导致的 未定义行为(UB)。
- 使得用
uint16_alias*
去访问uint32_t
对象的内存更安全一些(至少在 GCC/Clang 下)。 - 这个是 GCC 和 Clang 的扩展,非标准 C++,不能跨编译器使用。
是否推荐?
优点 | 缺点 |
---|---|
能避免 strict aliasing 带来的 UB | 非标准扩展,不可移植 |
允许直接用 16 位指针访问 32 位对象 | 只在 GCC/Clang 有效,MSVC 不支持 |
代码简洁,性能好 | 依赖编译器扩展 |
标准做法的对比
方法 | 标准合法性 | 备注 |
---|---|---|
__may_alias__ (GCC/Clang) | 部分非标准 | 非标准扩展,性能好 |
union 访问 | 标准合法 | 安全,代码稍冗长 |
位操作交换 | 标准合法 | 简单、跨平台 |
reinterpret_cast 指针 | 非法 | UB,可能触发奇怪错误 |
总结
- 如果你的项目只用 GCC/Clang,且在乎性能,可以用这个技巧。
- 如果追求代码跨编译器兼容,最好用位操作或者 union 访问方式。
- 不推荐在 MSVC 或其它编译器中使用,因为不支持这个属性。
你这里提到的命令:
g++ -std=c++11 -O3 -fno-strict-aliasing alias.cpp -o alias
和你给的代码:
uint32_t swaphalves(uint32_t a)
{ auto ptr = reinterpret_cast<uint16_t*>(&a); std::swap(ptr[0], ptr[1]); return a;
}
你的问题的背景和分析
1. -fno-strict-aliasing
的作用
- 这个编译选项告诉 GCC 关闭严格别名规则的优化。
- 这样编译器就不会假设不同类型的指针不会别名,也就避免了一些因别名规则导致的未定义行为问题。
- 你使用它的原因,通常是为了“允许”通过不同类型指针访问同一内存。
2. 代码本身的问题
你的代码是:
- 将
uint32_t
地址转换成uint16_t*
- 然后交换这两个 16 位部分
根据 C++ 标准,这违反了 strict aliasing 规则,本应是未定义行为(UB)。
但你使用了-fno-strict-aliasing
选项后: - 编译器不再因为别名规则做优化假设
- 程序在大多数情况下会正常运行
3. 结论和理解
- 使用
-fno-strict-aliasing
是 GCC 提供的一种“妥协”方式,避免某些别名相关的编译优化导致程序出错。 - 它会带来一定性能损失,因为编译器的别名优化被关闭。
- 你给的代码在开启这个选项时是可行的,运行时结果符合预期。
- 但是,这仍然不是标准推荐做法。
4. 推荐做法总结
方案 | 标准兼容 | 优缺点 | |
---|---|---|---|
使用 -fno-strict-aliasing | 否 | 兼容别名,性能下降,不推荐 | |
union 访问成员 | 是 | 标准合法,稍复杂但安全 | |
位操作实现(`(a<<16) | (a>>16)`) | 是 | 简洁、高效、跨平台,强烈推荐 |
5. 你这条命令的用法建议
如果你要快速跑通代码,不用修改逻辑,使用:
g++ -std=c++11 -O3 -fno-strict-aliasing alias.cpp -o alias
是可以的。
你的这段代码用 std::memcpy
实现了交换 uint32_t
的高低 16 位,整体思路是把内存内容拷贝到新变量中,调整顺序后返回,避免了严格别名规则的问题。
代码分析
uint32_t swaphalves_memcpy(uint32_t a)
{ uint32_t swapped; auto swapped_char = reinterpret_cast<char*>(&swapped); auto a_char = reinterpret_cast<char const*>(&a); std::memcpy(swapped_char, a_char + sizeof(uint16_t), sizeof(uint16_t)); std::memcpy(swapped_char + sizeof(uint16_t), a_char, sizeof(uint16_t)); return swapped;
}
作用
a_char
是指向a
内存的char*
指针,按字节访问- 先把
a
的高 16 位(偏移 2 字节)拷贝到swapped
的低 16 位位置 - 再把
a
的低 16 位拷贝到swapped
的高 16 位位置 - 返回交换后的结果
优点
- 完全标准合规,不会触发严格别名规则的未定义行为
- 用
memcpy
访问内存是安全的,因为标准允许任何对象用char*
类型访问内存 - 代码可移植,跨平台
缺点
- 比位运算稍慢(实际差异在绝大多数场景几乎可以忽略)
- 代码相比位运算复杂一点,但安全性更高
总结
实现方式 | 标准合法 | 性能 | 说明 | |
---|---|---|---|---|
位操作 `(a<<16) | (a>>16)` | 是 | 最高 | 简洁高效,推荐首选 |
union 访问成员 | 是 | 很快 | 方便且标准,需注意符号问题 | |
memcpy 拷贝 | 是 | 较快 | 最安全的字节级内存访问方式 | |
直接指针转换访问 | 否 | 可能最好 | 违反严格别名规则,不推荐 |
总结一下几点:
1. 低层代码和网络(反)序列化中的别名问题
- 在操作底层内存、网络数据包,序列化/反序列化时,常常需要通过不同类型指针访问同一内存区域。
- 这时候严格别名规则带来的限制和潜在的未定义行为非常容易出现。
- 这些代码里经常会见到绕过别名规则的技巧和编译器特定的开关。
2. -fno-strict-aliasing
的使用
- Linux 内核、libevent 等一些大型项目确实使用了
-fno-strict-aliasing
选项,来避免别名规则带来的问题。 - 它是一个权宜之计,能够减少潜在的编译器优化带来的错误,但会牺牲部分性能。
- 这说明实际项目中,标准严格别名规则的“硬”限制确实存在痛点。
3. 关于 placement new
- GCC 关于使用 placement new(定位 new)相关的“合法别名”问题支持不够明确或者说存在争议。
- placement new 重新构造对象,在别名和生命周期方面的细节,编译器和标准的支持及解释可能会影响别名规则的判断。
- 因此,有时候代码会陷入模糊地带,难以用纯标准方式解决别名问题。
4. alias_cast 方案
alias_cast
是一种用来安全转换指针类型的工具(类似于bit_cast
、reinterpret_cast
),设计时兼顾了别名规则。- 典型实现会利用
memcpy
或特殊的 union,保证转换操作既标准合法,又高效。 - 它是应对别名规则严格限制的优雅方案,尤其适合需要在不同类型间“重解释”数据的场景。
结论总结
主题 | 说明 |
---|---|
低层代码和网络数据操作 | 经常碰到别名规则带来的挑战,需要特殊处理 |
-fno-strict-aliasing | 业界常用折中方案,但有性能和标准合规问题 |
placement new | 别名和对象生命周期问题复杂,编译器支持不够统一 |
alias_cast | 未来或已有的优雅解决方案,兼顾安全、标准和性能 |