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 p
与 place0 := 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
不难发现,两段序列的 不同之处在第三行:对于 place0
,reborrow_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.