Newshot
Account

Rust 与 C++ 中的指针来源(provenance)

Translation(model translation, not the source's own text)

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 的转换

![](https://sofiabelen.github.io/projects/dimitry-b-GNu4g4Z5jN4-unsplash.jpg)

要看看 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 相关的职位,所以写这些文章让我保持动力,也帮我找到了社群归属感。感谢阅读,希望你喜欢!

The translation is machine-generated and individual terms may be off. Nothing is rewritten or cut; the original is authoritative.

Published by Lobsters
This site only summarises and aggregates; copyright stays with the original author. Headlines and summaries follow the source — where they differ, the original wins.
Read the original ↗