Rust 与 C++ 中的指针来源
我一直在探索 Rust,其中一个让我着迷的主题是指针来源,尤其是因为它不容易可视化。
为了对 vtables 有直观感受,我想做的是查看实际内存,看看它真实的样子,并证明我确实可以通过各自的函数指针调用这些函数。对于来源,我也想用同样的方式把它拆开,但事实证明这并不那么直接。这反而让我更感兴趣了,所以让我们看看能发现什么!
我第一次听说来源时,我想,为什么我以前没听说过?它似乎如此关键,却又有点晦涩。
什么是来源:指针不只是被美化的整数

图片由 CHUTTERSNAP 拍摄,来自 Unsplash
让我们先好好看看这段代码。它很短,但如果是第一次遇到这个概念,我们的直觉可能会骗我们。这里的 void opaque(int*) 是前置声明,所以对我们来说它是一个黑盒。无论是我们还是编译器,都无法排除这个函数会解引用 p 并写入内存。
void opaque(int*);
int f(int* p) {
int x = 1;
opaque(p);
return x;
}
如果我们用 -O2 编译,Clang 会把它优化成返回 1。让我们在 Godbolt 上试试:
问题是,它怎么知道 int* p 没有指向 x 所在的那个内存地址(也就是说 opaque(p) 最终可能会修改 x)?
编译器假定 p 不可能等于 &x,因为 p 不是从 x 派生出来的。
我第一次听说这个时,觉得这个概念有点奇怪。某物从某物派生出来是什么意思?
我的意思是,如果指针只是数字(内存地址),理论上 p 就可能恰好持有与 x 在栈上的分配相同的内存地址。幸好它们不只是数字!
让我们跟着白兔走。

图片由 Sincerely Media 拍摄,来自 Unsplash
这就把我们带到来源概念的核心。那个“派生自”关系就是来源:对每个指针,编译器都会跟踪它属于哪个分配,因此可以触碰哪个分配。
我试着找到一种打印这个神秘来源的方法,但事实证明它纯粹是编译器抽象。它并不作为与内存地址一起存储的实际数据字段存在。(不过也有例外:不妨查一查 CHERI 架构,掉进另一个兔子洞。)
检查来源:LLVM IR
那么我们能做什么来检查它?如果编译器知道它,那它一定存在于某处。既然如此,就一定有办法看到它。
我就是这样了解到 LLVM IR(中间表示)的;它本质上是一种低级编程语言,充当高级语言(如 Rust、C++ 等)与机器码之间的桥梁。
那我们改试这段代码,其中我们还将 &x 传给 opaque,看看当编译器没有把那部分优化掉时会发生什么:
void opaque(int*);
int f(int* p) {
int x = 1;
opaque(p);
opaque(&x);
return x;
}
我们可以在 Godbolt 上使用 -emit-llvm 标志来查看 LLVM IR:
define dso_local noundef i32 @f(int*)(ptr noundef %0) local_unnamed_addr #0 !dbg !14 {
%2 = alloca i32, align 4 // --> root of %2 allocation (x)
#dbg_assign(i1 poison, !22, !DIExpression(), !23, ptr %2, !DIExpression(), !24)
#dbg_value(ptr %0, !21, !DIExpression(), !24)
call void @llvm.lifetime.start.p0(ptr nonnull %2) #3, !dbg !25
store i32 1, ptr %2, align 4, !dbg !26
#dbg_assign(i32 1, !22, !DIExpression(), !28, ptr %2, !DIExpression(), !24)
tail call void @opaque(int*)(ptr noundef %0), !dbg !29
call void @opaque(int*)(ptr noundef nonnull %2), !dbg !30
%3 = load i32, ptr %2, align 4, !dbg !31
call void @llvm.lifetime.end.p0(ptr nonnull %2) #3, !dbg !32
ret i32 %3, !dbg !33
}
declare void @llvm.lifetime.start.p0(ptr captures(none)) #1
declare !dbg !34 void @opaque(int*)(ptr noundef) local_unnamed_addr #2
declare void @llvm.lifetime.end.p0(ptr captures(none)) #1
attributes #0 = { mustprogress uwtable "min-legal-vector-width"="0" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cmov,+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" }
attributes #1 = { mustprogress nocallback nofree nosync nounwind willreturn memory(argmem: readwrite) }
attributes #2 = {
- addr() 读取地址,但不暴露 provenance。
- with_addr() 用新地址构建一个新指针,但其 provenance 与原指针相同。
## 遗留风格的 Rust,与 C++ 类似
在遗留代码中,我们不得不写成这样:
let addr = p as usize; let tagged_addr = addr | 1; let revived_ptr = (tagged_addr & !1) as const u32; println!("revived_ptr: {}", unsafe { *revived_ptr});
这段代码能运行,但如果用 Miri 运行,它会发出警告:
warning: integer-to-pointer cast --> src/main.rs:6:22
6 | let revived_ptr = (tagged_addr & !1) as *const u32; | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ integer-to-pointer cast
= help: this program is using integer-to-pointer casts or (equivalently) ptr::with_exposed_provenance, which means that Miri might miss pointer bugs in this program = help: see https://doc.rust-lang.org/nightly/std/ptr/fn.with_exposed_provenance.html for more details on that operation = help: to ensure that Miri does not miss bugs in your program, use Strict Provenance APIs (https://doc.rust-lang.org/nightly/std/ptr/index.html#strict-provenance, https://crates.io/crates/sptr) instead = help: you can then set MIRIFLAGS=-Zmiri-strict-provenance to ensure you are not relying on with_exposed_provenance semantics = help: alternatively, MIRIFLAGS=-Zmiri-permissive-provenance disables this warning
*revived_ptr: 1 warning: 1 warning emitted
其实,它能运行的原因也很有意思。发生的事情与 C++ 示例类似。p as usize 暴露了 p 的 provenance,而由于重建出的指针指向同一个分配,provenance 就被恢复了。
如果我们做一些指针运算,落到另一个分配里,provenance 就会丢失。
## 不暴露 Provenance 的转换

要看看 provenance 未被暴露时会发生什么,我们可以使用 ptr::without_provenance,它会创建一个背后没有任何分配的裸地址。(它适用于悬垂指针或哨兵指针,但不能被解引用)。
let bare_ptr = std::ptr::without_provenance::<u32>(p.addr()); println!("tagged_ptr: {}", unsafe { bare_ptr});
不出所料,Miri 抱怨出现了悬垂指针,并明确告诉我们它没有 provenance。
error: Undefined Behavior: memory access failed: attempting to access 4 bytes, but got 0x20b28[noalloc] which is a dangling pointer (it has no provenance) --> src/main.rs:11:42
11 | println!("tagged_ptr: {}", unsafe { bare_ptr}); | ^^^^^^^^^ Undefined Behavior occurred here
= help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior = help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information
note: some details are omitted, run with MIRIFLAGS=-Zmiri-backtrace=full for a verbose backtrace
error: aborting due to 1 previous error
小结
简而言之,provenance 是一份隐藏的信息,用来跟踪指针源自“哪一个分配”,LLVM 会用它来做优化。
在 C++ 中,uintptr_t 转换会迫使优化器为最坏情况做准备,从而禁用部分优化。
在 Rust 中,addr()、with_addr() 和 without_provenance() 让你可以明确表达意图:你只要那个数字,或者你是在从已有指针派生一个新指针。原则上,这给了编译器更激进优化的空间。然而在我们的实验中,Rust 版本同样没有被优化:x 仍然是从栈上重新加载的。不过使用表达力更强的 API 仍然有价值,因为 Miri 可以强制执行规则,并捕获因非预期用法而导致的 bug。
我原本计划比较 Miri 和 ASan 各能捕获什么,但我打算留到另一篇文章再写。就目前而言,新信息已经够多了,而且要把这些完全想明白还需要一些时间。
Rust 与 C++ 中的指针来源
我目前处于待业状态,正在寻找 Rust 相关的职位,所以写这些文章让我保持动力,也帮我找到了社群归属感。感谢阅读,希望你喜欢!