
Rust 与 C++ 的指针来源对比:指针不只是整数
来源标题:Pointer Provenance in Rust vs. C++: Pointers Are Not Just Glorified Ints
一篇技术文章对比了 Rust 和 C++ 中的指针来源(pointer provenance)概念,指出指针并非简单的整数。作者以一段 C++ 代码为例,说明 Clang 在 -O2 优化下会将函数优化为直接返回 1,因为编译器无法排除 opaque 函数通过指针 p 写内存的可能,但这正体现了指针来源对编译器优化的影响。
以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。
2026年11月2日 9分钟阅读 我一直在探索 Rust,其中一个让我着迷的话题是指针来源(pointer provenance),尤其是因为它不容易可视化。 为了对 vtable 有个直观感受,我想做的是查看实际的内存,看看它真实的样子,并证明我确实可以通过各自的函数指针调用这些函数。对于 provenance,我也想用同样的方式把它拆解开来,但事实证明这并不那么简单。这反而让我更加好奇,所以让我们看看能发现什么!
我第一次听说 provenance 时,心想:为什么我以前没听说过?它看起来如此关键,却又有点晦涩。 让我们先仔细看看这段代码。它很短,但如果是第一次遇到这个概念,我们的直觉可能会欺骗我们。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 在栈上分配相同的内存地址。幸好它们不只是数字!
这就引出了 provenance 的核心。那个“派生自”的关系就是 provenance:对于每个指针,编译器会跟踪它属于哪个分配,因此可能触及哪个分配。 我试图找到一种方法来打印这个神秘的 provenance,但事实证明它纯粹是编译器的一种抽象。它并不作为实际的数据字段与内存地址一起存储。(不过也有例外:不妨去查查 CHERI 架构,掉进另一个兔子洞)。
那么我们能做些什么来检查它呢?如果编译器知道它,那它一定存在于某处。如果是这样,就一定有办法看到它。 这就是我了解到 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 = { "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 #3 = { nounwind }
编译器所做的,是在概念上构建一个 DAG(有向无环图),来回答指针来自哪里以及两个指针是否能触及同一块内存的问题。 如果根的来源不匹配,LLVM 就能保证通过 %0 写入永远不会改变存储在 %2 处的值。
这里还有太多东西可以探索,很难不跑题,因为它实在太迷人了。不过,我最初的目标是比较 C++ 和 Rust 如何处理这件事,所以我们继续吧。
如果我们把 &x 转换为整数,那么 LLVM 就必须假设任何人都可以从那个整数重建出指向 x 的指针。
#include <cstdint> void g(std::uintptr_t); void opaque(int*);
int f(int* p) { int x = 1; g(reinterpret_caststd::uintptr_t(&x)); // x 的地址现在被“暴露”了 opaque(p); return x; }
结果是编译器放弃了优化: 如果我们只是想读取地址而从未打算把它转换回指针,那就太可惜了。如果我们能把这个意图传达给编译器,岂不是很好?
这正是 Rust 通过其强大的 provenance API 所提供的。
let p: *const u32 = &x; let a: usize = p.addr();
p.addr() 提取原始的内存地址整数,同时告诉 Rust 的抽象机: 我正在读取这个数字地址以便检查,但我保证我不会把它转换回一个有效指针。
为了对照我们之前的实验,我们可以试着看看优化是否被避免了: 我们看到的是,mov eax, dword ptr [rsp + 12] 在之后重新加载了 x
(原文较长,此处译出前 6000 字,完整内容请点「打开原文」。)
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。