想把一个 std::uint32_t 的位模式按 float 读出来,最顺手的写法是 *reinterpret_cast<float*>(&bits);想在一段内存上先放一个 X、析构后再放一个 Y,最自然的做法是直接复用那个指针。这两个「顺手」的操作,恰恰是 C++ 里最容易踩的**别名(aliasing)**坑:前者违反严格别名规则(strict aliasing),后者让旧指针变成指向「已死对象」的悬垂指针。这篇把三条合法路线 memcpy、std::bit_cast、std::launder 一次讲清,并配真实汇编与 UBSan 报告证明它们的差别。
1. 引子:为什么不能「换个马甲」读同一块内存
先看这段代码。它没有编译错误,-O2 下运行结果通常也是「对的」,但它是未定义行为(undefined behavior):
// 反例,不要这么写:reinterpret_cast 做类型混淆,违反严格别名规则
#include <cstdint>
#include <cstdio>
float read_float(const std::uint32_t bits) {
return *reinterpret_cast<const float*>(&bits); // 把 int 的位模式强读成 float
}
int main() {
std::printf("%f\n", read_float(0x3f800000u)); // 期望 1.0f
}
问题不在「运行结果」,而在编译器对别名的假设。C++ 的严格别名规则规定:通过「不相关类型」的指针去读写同一块内存是 UB。编译器据此大胆假设「float* 和 std::uint32_t* 永远不会指向同一块内存」,于是它可以随意重排、缓存这些访问。一旦你的程序真这么干了,优化器可能把写操作「优化没了」,或读到一个过期的缓存值。这类 bug 极难排查,因为它只在特定优化级别、特定编译版本下才露出马脚。
2. 严格别名规则:一张表说清能做什么
严格别名的本质是:一块内存只能以它「真实」的类型(加上少数允许的类型)被访问。允许的类型(可以安全地通过指针去读)包括:
| 内存里实际住着的类型 T | 可以用哪些类型去访问它 |
|---|---|
int | int、const int、volatile int、unsigned int 对应的「有符号/无符号变体」 |
float | float(不能用 int 去读它的位) |
char[] / std::byte[] | 任意类型(char*/std::byte* 是唯一「万能别名」) |
某结构体 S | S 本身、S 的第一个成员(标准布局下) |
union 的活跃成员 | 只能读活跃的那个成员(type punning 读非活跃成员是 UB) |
一句话:char*/std::byte* 可以指向任何东西,其余情况基本只能「同类型或近亲类型」互访。想跨类型读位模式,就得走下面三条合法路线。
3. 路线一:memcpy——最古老、任何标准都能用的合法重解释
std::memcpy 是把「一块内存的字节」复制到「另一块内存」的字节级操作,它天然合法:你从 std::uint32_t 读出 4 个字节、按字节写进一个 float,全程没有「用不相关类型指针直接读写」,编译器不会产生任何错误的别名假设。C++17 就能用:
// memcpy_pun.cpp — 编译: g++ -std=c++17 -Wall -O2 memcpy_pun.cpp -o mp
#include <cstdint>
#include <cstdio>
#include <cstring>
int main() {
const std::uint32_t bits = 0x3f800000u; // IEEE-754 里 1.0f 的位模式
float f{};
std::memcpy(&f, &bits, sizeof(f)); // 合法:按字节搬运,不违反严格别名
std::printf("位模式 0x%08x 按 float 读 = %f\n", bits, f);
// 反方向:float -> uint32_t 也合法
const float g = -2.5f;
std::uint32_t g_bits{};
std::memcpy(&g_bits, &g, sizeof(g_bits));
std::printf("%f 的位模式 = 0x%08x\n", g, g_bits);
}
位模式 0x3f800000 按 float 读 = 1.000000
-2.500000 的位模式 = 0xc0200000
反向也成立:-2.5f 按位读回整数是 0xc0200000(IEEE-754 的符号位 + 指数 + 尾数拼出来的结果)。memcpy 双向都合法,因为它始终是「字节搬运」,不产生任何类型化的别名访问。
官方文档:std::memcpy
4. 路线二:std::bit_cast——C++20 把 memcpy 规范化成一行
std::memcpy 合法但啰嗦,而且容易写错 sizeof。C++20 引入 std::bit_cast<To>(from),语义等价于一次 memcpy,但:
- 一行搞定,
To和From的字节数必须相等(否则编译期就报错); - 是
constexpr,能在编译期做按位重解释(memcpy不行); - 编译器把它优化成和
memcpy完全一样的机器码(见下文汇编)。
// verify: std=c++20
// bitcast.cpp — 编译: g++ -std=c++20 -Wall -O2 bitcast.cpp -o bc
#include <bit>
#include <cstdint>
#include <cstdio>
int main() {
const std::uint32_t bits = 0x3f800000u; // 1.0f
const float f = std::bit_cast<float>(bits);
std::printf("bit_cast<float>(0x3f800000) = %f\n", f);
// 编译期也能用:constexpr 按位重解释
constexpr std::uint32_t back = std::bit_cast<std::uint32_t>(1.0f);
static_assert(back == 0x3f800000u, "1.0f 的位模式必须是 0x3f800000");
std::printf("bit_cast<std::uint32_t>(1.0f) = 0x%08x\n", back);
}
bit_cast<float>(0x3f800000) = 1.000000
bit_cast<std::uint32_t>(1.0f) = 0x3f800000
官方文档:std::bit_cast(需要 C++20)
真实汇编:bit_cast 与 memcpy 零开销
godbolt 上(gcc 13.2,-O2 -std=c++20)把 reinterpret_cast、bit_cast、memcpy 三种写法编译出来对比:
via_reinterpret(unsigned int):
movd xmm0, edi
ret
via_bitcast(unsigned int):
movd xmm0, edi
ret
via_memcpy(unsigned int):
movd xmm0, edi
ret
三个函数一模一样,都是 movd xmm0, edi(把整数寄存器里的位直接搬进浮点寄存器)+ ret。这说明两件事:第一,bit_cast 和 memcpy 没有任何「拷贝」开销,编译器直接把它们优化成一条寄存器搬运;第二,reinterpret_cast 在这里也生成同样的码,但它依然是 UB。差异不在「现在跑出什么」,而在「编译器有没有权利在别处做你意料之外的优化」。所以别用 reinterpret_cast 做类型混淆,不是因为它慢,是因为它给了优化器一把「误解你的代码」的刀。
5. reinterpret_cast 的 UB 会被 UBSan 抓到吗
严格别名违规本身,UBSan(-fsanitize=undefined)不一定能报,它是一种「编译器假设」,不是运行时的非法操作。但 reinterpret_cast 有一类立刻就能被 UBSan 抓到的 UB:未对齐访问(misaligned access)。把 char 缓冲区错开 1 字节,强行 reinterpret_cast 成 int* 再解引用:
// 片段:reinterpret_cast 到未对齐地址 —— 反例,不要这么写
#include <cstdio>
int main() {
alignas(int) char buf[2 * sizeof(int)];
char* p = buf + 1; // 故意错开 1 字节,未对齐
int* ip = reinterpret_cast<int*>(p); // 反例:未对齐的 int 指针
*ip = 42; // 未对齐写
std::printf("%d\n", *ip); // 未对齐读
}
clang 19 配 -fsanitize=undefined 真跑,stderr 里是:
/app/example.cpp:6:5: runtime error: store to misaligned address 0x7ffc~~ for type 'int', which requires 4 byte alignment
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior /app/example.cpp:6:5
/app/example.cpp:7:25: runtime error: load of misaligned address 0x7ffc~~ for type 'int', which requires 4 byte alignment
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior /app/example.cpp:7:25
(地址每次运行都不同,用 ~~ 占位。)这就是 reinterpret_cast 的第二类坑:它只做「位级强制转换」,不检查对齐、不检查类型安全。而 memcpy/bit_cast 因为走的是字节搬运,天然不会产生未对齐的「类型化」访问,也就不会触发这类 UB。
6. 路线三:placement new 重用内存 + std::launder
另一个别名场景和「按位重解释」无关,而是生命周期:你想在同一块存储上先放一个对象,析构后再放一个新的。placement new 让你在不分配新内存的情况下原地构造对象,但它会带来一个悬垂指针问题:指向旧对象的指针不会自动「知道」新对象的到来。
同一块存储(storage)上,对象的生命周期更替
时间 ──────────────────────────────────────────────────────────►
[1] new(storage) X{10} 存储上住着一个 X,p 指向它(有效)
│
[2] p->~X() 显式析构,X 的生命周期结束,p 变悬垂
│
[3] new(storage) X{20} 原地重新构造一个新的 X(同类型、同地址)
│ ┌─ q 是新构造的,直接用没问题
│ └─ p 仍「指向」这块存储,但它指向的旧 X 已死:
│ 直接 p->n 是 UB(const 成员让编译器可能缓存旧值 10)
│ 必须 std::launder(p) 才能拿到「新 X」的合法指针
▼
std::launder(p)->n == 20
std::launder(C++17)的职责就是**「洗」掉编译器的过时假设**:它接收一个指向某块存储的指针,返回一个指向「这块存储上当前活着的对象」的指针,明确告诉编译器「别用之前缓存的旧值,重新读」。它在 const 成员、const 引用成员、vptr 这类「编译器会大胆缓存」的场景里不可省略。真跑:
// launder.cpp — 编译: g++ -std=c++17 -Wall -O2 launder.cpp -o launder
#include <cstdio>
#include <new>
struct X {
const int n; // const 成员:编译器有理由缓存 n 的值
explicit X(int v) : n(v) {}
};
int main() {
alignas(X) unsigned char storage[sizeof(X)];
X* p = new (storage) X{10}; // placement new:原地构造,不分配新内存
std::printf("构造 X,n = %d\n", p->n);
p->~X(); // 显式结束 X 的生命周期
X* q = new (storage) X{20}; // 在同一块存储上重新构造
std::printf("重新构造 X,q->n = %d\n", q->n); // q 是新对象,直接读没问题
std::printf("launder(p)->n = %d\n", std::launder(p)->n); // 旧指针 p 必须洗一下才能用
}
构造 X,n = 10
重新构造 X,q->n = 20
launder(p)->n = 20
注意 q 和 std::launder(p) 返回的其实是「指向同一个新 X」的指针,但含义不同:q 是 placement new 直接给你的新鲜指针,p 是你手里残留的、指向已死对象的旧指针。在有 const 成员的场景,p->n 不带 launder 直接读,编译器完全可能返回缓存的旧值 10(它以为 const 不会变);std::launder(p)->n 才保证读到 20。
官方文档:std::launder(C++17) · placement new
7. 完整示例:一个把三条路线串起来的工具集
把 memcpy、bit_cast、launder 放进一个能整体编译运行的程序,分别演示「按位重解释」和「原地重建对象」两类需求:
// alias_final.cpp — 编译: g++ -std=c++17 -Wall -O2 alias_final.cpp -o afinal
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <new>
// 路线一:memcpy 按位重解释(C++17 通用)
float bits_to_float(std::uint32_t bits) {
float f{};
std::memcpy(&f, &bits, sizeof(f));
return f;
}
// 演示 const 成员的原地重建
struct Cell {
const int id;
explicit Cell(int v) : id(v) {}
};
int main() {
// 1) memcpy:合法按位重解释
const std::uint32_t one_bits = 0x3f800000u;
std::printf("memcpy: 0x%08x -> %f\n", one_bits, bits_to_float(one_bits));
// 2) placement new + launder:同一块存储重建 const 成员对象
alignas(Cell) unsigned char storage[sizeof(Cell)];
Cell* c = new (storage) Cell{1};
c->~Cell();
new (storage) Cell{2}; // 重建,新 id = 2
std::printf("launder 后读到新对象的 id = %d\n", std::launder(c)->id);
// 3) 反向:把 float 的位模式读回整数(同样用 memcpy)
const float g = -2.5f;
std::uint32_t g_bits{};
std::memcpy(&g_bits, &g, sizeof(g_bits));
std::printf("%f 的位模式 = 0x%08x\n", g, g_bits);
}
memcpy: 0x3f800000 -> 1.000000
launder 后读到新对象的 id = 2
-2.500000 的位模式 = 0xc0200000
8. 延伸阅读
- strict aliasing / object — cppreference —— 严格别名与对象生命周期的正式规则,这篇第 2 节表格的依据
- std::bit_cast — cppreference —— C++20 按位重解释,等价于 memcpy 且 constexpr
- std::memcpy — cppreference —— 最古老的合法重解释手段
- std::launder — cppreference —— 原地重建对象后「洗」旧指针,const/引用成员的坑
- placement new — cppreference —— 原地构造的语法与生命周期约定
本知识库内的相关篇目:
- 《异常的实现与代价:零成本异常、栈展开与 -fno-exceptions 什么时候用》 —— C++ 异常常被说「零成本」,但它的代价藏在抛出路径上。
- 《C++ 对象内存模型:vtable 布局、多继承 vptr、RTTI 与 dynamic_cast 走查》 —— 用 sizeof/offsetof/typeid/dynamic_cast 实测对象内存布局
- 《C++ ABI 与跨库兼容:为什么换编译器要重编、符号可见性》 —— C++ ABI 与跨库兼容,同体系里最该一起看的另一篇。
9. 一句话总结
跨类型读位模式,reinterpret_cast 是严格别名违规的 UB(哪怕跑起来「看着对」),合法手段是 memcpy(C++17 通用)或 std::bit_cast(C++20,等价且 constexpr),两者都会被优化成零开销的寄存器搬运;原地重建对象后,旧指针指向的是「已死对象」,必须用 std::launder 洗掉编译器的过时缓存假设——这三条路线合起来,就是「合法绕过别名」的完整答案。
6



被折叠的 条评论
为什么被折叠?



