Rust 中的隐匿概念 —— Place(位置)

在本文开始前,我们先看一个奇怪的问题。以下两个函数,为什么 reborrow_good 能通过编译,而 deref_bad 不能?

fn reborrow_good(p: *const i32) {
let _p = &raw const *p;
}

fn deref_bad(p: *const i32) {
let _v = *p;
}

&raw const B 是 Rust 1.82 中引入的新语法,作用是创建一个指向 B 的 raw 指针。
没有此语法前,为了获取指向 x.y 的 raw 指针,只能使用特殊宏 addr_of!(x.y)。而有了 &raw 语法后便可写为 &raw const x.y

使用 Edition 2024 的 Rust 编译器,deref_bad 会报如下错(Playground),提示我们解引用 raw 指针需要在 unsafe block 中进行:

error[E0133]: dereference of raw pointer is unsafe and requires unsafe block
--> src/lib.rs:6:14
|
6 | let _v = *p;
| ^^ dereference of raw pointer
|
= note: raw pointers may be null, dangling or unaligned; they can violate aliasing rules and cause data races: all of these are undefined behavior

在 Rust 中,raw 指针相较引用,不受所有权规则的约束。它们可能传自其他语言,可能是空指针,可能没有对齐,可能有 alias 问题。因此,解引用 raw 指针需要在 unsafe 块中进行,而且要万分小心。

但这就奇怪了:明明 reborrow_good&raw const *p 中也有 *p 这一表达式,为什么它就能通过编译,而不需要 unsafe 呢?

拆解表达式

为了解答以上问题,我们不妨从编译器视角出发,对两个例子作拆解分析,设想编译器是怎么生成对应的伪指令序列的。

分析 reborrow_good

首先来看 _p = &raw const *p 这个表达式。

p 作为一个变量,它“不是一个值”。Rust 中的变量是对外部位置的一种抽象。一个变量可能对应 CPU 寄存器,也可能对应内存中的某个位置。为了知道变量里面的内容,我们先要 “读取”它,得到一个整数值。这一步可以记为 $val0 := load p。注意,我用带 $ 前缀的 $val0 表示值,从而与 p 等位置作区分。

有了 $val0 这个值,下一步是对其应用 * 解引用运算。解引用相当于拿着一个代表地址的整数,去寻找对应的内存位置。因此这一步的结果是一个位置,而不是一个值,可以记为 place0 := deref $val0

下一步是 &raw const 操作,即取地址操作。取地址相当于以一个位置作为输入,输出一个代表其地址的整数值。这步可记为 $val1 := addrof place0

最后,我们将 $val1 赋给 _p。这步可记为 store _p $val1。整个表达式的伪指令序列如下:

$val0  :=   load  p
place0 := deref $val0
$val1 := addrof place0
store _p $val1

分析 deref_bad

我们再来看 deref_bad 中的 _v = *p 表达式。这其中也有 *p 一项,因此前两条伪指令与上面一样,分别是 $val0 := load pplace0 := deref $val0

接下来,我们需要对 _v 变量赋值,而这个值是从 place0 中读取的,因此在 store 指令前,还需要一条 load place0 指令。最终,整个表达式的伪指令序列如下:

$val0  :=   load  p
place0 := deref $val0
$val1 := load place0
store _v $val1

对比分析

$val0  :=   load  p
place0 := deref $val0
$val1 := addrof place0
store _p $val1
$val0  :=   load  p
place0 := deref $val0
$val1 := load place0
store _v $val1

不难发现,两段序列的 不同之处在第三行:对于 place0reborrow_good 使用了 addrof,而 deref_bad 使用了 load。这意味着后者会产生对 place0 位置的访问,而前者没有。由于 place0 源自 *p,Rust 推断出 load place0 是不安全的,因此拒绝编译 deref_bad 的代码。

Place(位置)

经过以上分析,我们不难发现,Rust 中存在一种抽象概念,即Place(位置)。一个 Place 即代表了一块外部世界的存储空间。

Place 这一概念在语言标准中没有提及,各大教程中也没有介绍,但它确确实实存在于 Rust 的世界观中。事实上,它存在于 MIR(Mid-level IR)。MIR 是 Rust 编译器的一种中间表示。从 Rust 到 MIR 再到汇编,抽象程度逐渐降低,与物理世界的契合逐步增加。于是,MIR 需要一个概念,指涉物理世界广泛存在的“储存空间”——这便是 Place。

Rust 中隐匿的 Place 概念

Place 这一抽象始于低层的 MIR,却或多或少外溢到了高层的 Rust 中。然而 Rust 有意隐匿了这一抽象,进而造就了诸如 reborrow_good/deref_bad 合法性的困惑。

“解引用”一词常被理解为“寻址+读取”。但从上文的分析我们不难看出,表达式 *p 实则只通过“寻址”产生了一个 Place,而是否“读取”要看后续的操作,如在 deref_bad 中需要对 Place 取值再重赋值,这便涉及到了对 Place 的读取。Place 本身不会造成安全问题,即便其可能非法。真正带来安全隐患的是对 Place 的事实访问。

*p 产生一个 Place,因而也被称为 Place 表达式。除了 *p,还有其他一些常见的 Place 表达式,如单变量表达式 x,字段表达式 x.y 以及下标表达式 x[y] 等。对于 Place 表达式,我们可以进行“取址”、“读取”和“赋值”三种操作。“取址”和“赋值”分别对应 Rust 中的 &= 运算,但“读取”并没有类似的对应——Rust 会隐式地在合适的地方插入对 Place 的“读取”操作,淡化了这一步骤的重要性。

事实上,我们可以假想 Rust 中有个额外的 load 运算符。load 只能作用于 Place 之上,但编译器允许我们随意省略。在作类似分析时,为了看到问题的本质,我们可以显示加上省略的 load。还是以 _p = &raw const *p 为例,加上 load 后变为:

_p = &raw const *(load p)

_v = *p 变为:

_v = (load *(load p))

如此一来,何处产生内存访问便一目了然。


理解 Place,对于理解 Rust 中 Reborrow 等奇特概念,以及 unsafe 代码的编写,都有着重要的帮助。这些问题我会在后续博客中分析。

参考


作者:hsfzxjy
链接:
许可:CC BY-NC-ND 4.0.
著作权归作者所有。本文不允许被用作商业用途,非商业转载请注明出处。

OOPS!

A comment box should be right here...But it was gone due to network issues :-(If you want to leave comments, make sure you have access to disqus.com.