常量求值 (不) 安全规则

2022 年 9 月 15 日 · Felix Klock 代表 编译器团队

在最近的一个 Rust issue (#99923) 中,一位开发者注意到,即将发布的 Rust 1.64-beta 版本开始在其 crate icu4x 上报告错误。icu4x crate 在常量求值期间使用了 unsafe 代码。常量求值,简称 "const-eval",在编译时运行,但产生的值最终可能会嵌入到运行时执行的最终目标代码中。

Rust 的常量求值系统支持 safe 和 unsafe Rust,但是对于 unsafe 代码在常量求值期间允许做什么的规则,比 unsafe 代码在运行时允许的规则更加严格。本文将详细介绍其中一条规则。

(注意:如果你的 const 代码不使用任何 unsafe 块或调用带有 unsafe 块的 const fn,那么你无需为此担心!)

一个新的需要关注的诊断信息

这个问题,经过 #99923 的评论串的简化,是某些静态初始化表达式(见下文)被定义为在编译时具有未定义行为 (UB) (playground)

pub static FOO: () = unsafe {
    let illegal_ptr2int: usize = std::mem::transmute(&());
    let _copy = illegal_ptr2int;
};

(非常感谢 @eddyb 提供了最小复现示例!)

上面的代码被 Rust 1.63 及更早版本接受,但在 Rust 1.64-beta 中,它现在会导致编译时错误,并显示以下消息:

error[E0080]: could not evaluate static initializer
 --> demo.rs:3:17
  |
3 |     let _copy = illegal_ptr2int;
  |                 ^^^^^^^^^^^^^^^ unable to turn pointer into raw bytes
  |
  = help: this code performed an operation that depends on the underlying bytes representing a pointer
  = help: the absolute address of a pointer is not known at compile-time, so such operations are not supported

如消息所示,此操作不受支持:上面的 transmute 试图将内存地址 &() 重新解释为 usize 类型的整数。编译器无法预测 () 在执行时会关联哪个内存地址,因此它拒绝允许这种重新解释。

当你编写 safe Rust 时,编译器负责防止未定义行为。当你编写任何 unsafe 代码时(无论是 const 还是非 const),你负责防止 UB,而且在常量求值期间,关于什么 unsafe 代码具有定义行为的规则比约束 Rust 运行时语义的类似规则更加严格。(换句话说,被归类为“UB”的代码比你可能意识到的要。)

如果你在常量求值期间遇到未定义行为,Rust 编译器将保护自身免受诸如未定义行为泄漏到类型系统等不利影响,但除此之外几乎没有其他保证。例如,编译时 UB 可能导致运行时 UB。此外,如果你在常量求值时遇到 UB,不能保证你的代码在不同编译器版本之间都能被接受。

这里有什么新变化

你可能会想:“它过去是被接受的;因此,以前版本的编译器在这里使用的内存地址一定有某个值。”

但这种推理是基于一个不精确的视角,关于 Rust 编译器在这里做了什么。

Rust 编译器的常量求值机制(也称为“CTFE 引擎”)是基于一个 MIR 解释器构建的,该解释器使用一个假设机器的抽象模型作为评估这些表达式的基础。这个抽象模型不必将内存地址表示为简单的整数;事实上,为了支持对 UB 的细粒度检查,它对抽象内存存储中持有的值使用了更丰富的数据类型。

(上述 MIR 解释器也是 Miri 的基础,Miri 是一个解释非 const Rust 代码的研究工具,专注于显式检测未定义行为。Miri 开发者是 Rust 编译器中 CTFE 引擎的主要贡献者。)

CTFE 引擎值表示的细节对于我们在这里的讨论并不太重要。我们仅注意到,早期版本的编译器默默接受了那些看似将内存地址转换为整数、四处复制,然后又转换回地址的表达式;但底层实际情况并非如此。相反,发生的情况是这些值被盲目地传递(毕竟,transmute 的核心是它不对输入值进行任何转换,因此从操作语义上来说它是一个无操作)。

它将内存地址传递到一个你期望始终存在整数值的上下文中的事实,如果能被捕获,也只会发生在稍后某个时刻。

例如,常量求值机制会拒绝尝试将转换后的指针嵌入到运行时代码可能使用的值中的代码,就像这样 (playground)

pub static FOO: usize = unsafe {
    let illegal_ptr2int: usize = std::mem::transmute(&());
    illegal_ptr2int
};

同样,它会拒绝尝试对该非整数值执行算术运算的代码,就像这样 (playground)

pub static FOO: () = unsafe {
    let illegal_ptr2int: usize = std::mem::transmute(&());
    let _incremented = illegal_ptr2int + 1;
};

后面的这两个变体在稳定版 Rust 中都被拒绝,并且自 Rust 开始在静态初始化器中接受指针到整数的转换以来一直如此(例如参见 Rust 1.52)。

大同小异

事实上,根据 Rust 常量求值系统的语义,上面提供的所有示例都表现出未定义行为

第一个带有 _copy 的例子在 Rust 1.46 到 1.63 版本中被接受,这是由于 CTFE 实现中的一些历史遗留问题(artifacts)。CTFE 引擎在检测 UB 方面投入了大量精力,但并不能捕获所有实例。此外,默认情况下,这种检测可能会被推迟到实际发现问题表达式之后很远的地方。

但使用 nightly Rust,我们可以通过传递不稳定的标志 -Z extra-const-ub-checks 来选择启用引擎提供的额外 UB 检查。如果这样做,那么对于上面所有示例,我们都会得到相同的结果:

error[E0080]: could not evaluate static initializer
 --> demo.rs:2:34
  |
2 |     let illegal_ptr2int: usize = std::mem::transmute(&());
  |                                  ^^^^^^^^^^^^^^^^^^^^^^^^ unable to turn pointer into raw bytes
  |
  = help: this code performed an operation that depends on the underlying bytes representing a pointer
  = help: the absolute address of a pointer is not known at compile-time, so such operations are not supported

早期的示例产生的诊断输出将问题归咎于一个误导性的位置。启用了更精确的 -Z extra-const-ub-checks 检查后,编译器会突出显示我们可以首次看到 UB 的表达式:即原始的 transmute 本身!(这在本文开头已经说明;这里我们只是指出这些工具可以更精确地定位注入点。)

为什么不默认启用这些额外的常量 UB 检查呢?嗯,这些检查会给 Rust 编译时间带来性能开销,我们不确定这种开销是否可以接受。(然而,Miri 开发者之间最近的讨论表明,这里的固有成本可能不像他们最初想象的那么糟糕。未来版本的编译器或许会默认启用这些额外检查。)

改变是困难的

此时你很可能会想:“等等,常量求值期间什么时候可以将指针转换为 usize?” 答案很简单:“永远不行。”

在常量求值期间将指针转换为 usize 一直都是未定义行为,自常量求值添加了对 transmuteunion 的支持以来就是如此。你可以在 const_fn_transmute / const_fn_union 稳定化报告中阅读更多关于此的内容,特别是题为“Pointer-integer-transmutes”的小节。(transmute文档中也提到了这一点).)

因此,我们可以看到上面这些例子在常量求值期间被归类为 UB 并不是什么新鲜事。这里唯一的改变是 CTFE 引擎进行了一些内部修改,使其开始检测 UB,而不是默默地忽略它。

这意味着 Rust 编译器对于它会显式捕获哪些 UB 有一个动态变化的概念。我们预料到了这一点:RFC 3016,“常量 UB”,明确地说道

[...] 不能保证在 CTFE 期间能可靠地检测到 UB。这可能会随着编译器版本的变化而变化:导致 UB 的 CTFE 代码可能在一个编译器版本上正常构建,而在另一个版本上构建失败。(这与不健全代码不受稳定性保证的总体政策一致。)

话虽如此:Rust 的许多成功都建立在我们与社区建立的信任之上。是的,该项目始终保留在解决健全性错误时进行破坏性更改的权利;但我们也一直努力在可行的情况下通过诸如未来不兼容 lint 等方式来减轻这种破坏。

今天,在我们当前的常量求值架构下,无法确保像引入了 issue #99923 的变化能够经历未来不兼容警告周期。编译器团队计划密切关注这个领域的 issues。如果我们看到证据表明这类变化确实对数量不少的 crates 造成了破坏,那么我们将进一步研究如何平滑编译器版本之间的过渡。然而,我们需要平衡任何这样的目标与 Miri 开发者数量非常有限这一事实:他们是那些正在确定如何定义像 Rust 这样的 unsafe 语言语义的研究人员。我们不想拖慢他们的工作!

为了安全,你能做什么

如果你在 Rust 1.64 上你的 crate 中看到了 could not evaluate static initializer 消息,并且它在之前的 Rust 版本中可以编译通过,我们希望你告知我们:提交一个 issue

我们为 1.64-beta 执行了一次 crater run,并且没有发现这个特定问题的其他实例。如果你能在 9 月 22 日稳定版发布前测试你的 crate 在 1.64-beta 上的编译情况,那就再好不过了!尝试 beta 版本的一个简单方法是使用 rustup 的 override 快捷方式

$ rustup update beta
$ cargo +beta build

随着 Rust 常量求值的演进,我们可能会再次看到类似的情况出现。如果你想防御未来可能出现的常量求值 UB,我们建议你设置一个持续集成服务,在你的代码上使用不稳定的 -Z extra-const-ub-checks 标志调用 nightly rustc

想帮忙吗?

正如你可能想象的,我们许多人都对诸如“什么应该被视为未定义行为?”这样的问题非常感兴趣。

例如,可以参阅 Ralf Jung 关于指针为何复杂的精彩博客系列(第 部分),其中包含了一些上面省略的关于指针值表示的细节,并详细阐述了为什么即使在常量求值之外,你也可能需要关注指针到 usize 的 transmute。

如果你对帮助我们找出这些问题的答案感兴趣,请加入我们的unsafe 代码规范 zulip

如果你对了解更多 Miri 或为其做贡献感兴趣,你可以在miri zulip 中打个招呼。

结论

总而言之:当你编写 safe Rust 时,编译器负责防止未定义行为。当你编写任何 unsafe 代码时,负责防止未定义行为。Rust 的常量求值系统有一套更严格的规则来约束什么 unsafe 代码具有定义行为:具体来说,在常量求值期间将指针值重新解释(即“transmute”)为 usize 是未定义行为。如果你在常量求值时遇到未定义行为,不能保证你的代码在不同编译器版本之间都能被接受。

编译器团队希望 issue #99923 是一个特殊的偶然事件,并且 1.64 稳定版发布不会遇到与上述常量求值机制变化相关的任何其他意外情况。

但无论是不是偶然事件,这个 issue 都提供了一个很好的动力,花一些时间探索 Rust 常量求值架构及其底层解释器的各个方面。我们希望你阅读本文的乐趣与我们撰写本文的乐趣一样多。