常量求值(非)安全规则

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

在最近的 Rust 问题 (#99923) 中,一位开发者指出即将发布的 Rust 1.64-beta 版本开始在其 crate icu4x 上发出错误信号。 icu4x crate 在常量求值期间使用了不安全代码。常量求值,或简称“const-eval”,在编译时运行,但会生成可能最终嵌入在运行时执行的最终目标代码中的值。

Rust 的常量求值系统支持安全和不安全的 Rust,但是对于在常量求值期间允许不安全代码执行的操作的规则,甚至比运行时不安全代码所允许的操作还要严格。这篇文章将详细介绍其中一条规则。

(注意:如果你的 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 类型的整数。编译器无法预测 () 在执行时将关联到哪个内存地址,因此它拒绝允许这种重新解释。

当你编写安全的 Rust 时,编译器负责防止未定义行为。当你编写任何不安全代码(无论是常量还是非常量)时,你负责防止 UB,并且在常量求值期间,关于不安全代码具有定义行为的规则比控制 Rust 运行时语义的类似规则更加严格。(换句话说,与你可能意识到的相比,更多代码被归类为“UB”。)

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

这里的新内容是什么

你可能会想:“它曾经被接受;因此,之前版本的编译器在这里使用的内存地址肯定有一些值。”

但是,这样的推理是基于对 Rust 编译器在这里所做的事情的不精确理解。

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

(上述 MIR 解释器也是 Miri 的基础,Miri 是一种解释非常量 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 常量求值系统的语义,上面提供的所有示例都表现出未定义行为

由于 CTFE 实现的缺陷,第一个使用 _copy 的示例在 Rust 1.46 到 1.63 版本中被接受。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 是可以的?”答案很简单:“永远不要。”

自从常量求值添加了对 transmuteunion 的支持以来,在常量求值期间将指针转换为 usize 一直是未定义的行为。你可以在 const_fn_transmute / const_fn_union 稳定报告中阅读更多相关信息,特别是题为“指针-整数-转换”的小节。(在 transmute文档中也有提到).)

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

这意味着 Rust 编译器对它将明确捕获的 UB 的概念在不断变化。我们预料到了这一点:RFC 3016 “const UB”明确指出

[...] 不能保证在 CTFE 期间可靠地检测到 UB。这可能会因编译器版本而异:导致 UB 的 CTFE 代码可能在一个编译器上可以正常构建,而在另一个编译器上则无法构建。(这符合不健全的代码不受稳定性保证的一般政策。)

话虽如此:Rust 的成功很大程度上建立在我们与社区建立的信任之上。是的,该项目始终保留在解决健全性错误时进行重大更改的权利;但是,我们还努力通过类似 未来不兼容 lint 等方式,在可行的情况下尽量减少此类破坏。

今天,使用我们当前的常量求值架构,无法确保诸如 注入 issue #99923 的更改会经历未来不兼容警告周期。编译器团队计划密切关注这方面的问题。如果我们看到证据表明这些类型的更改确实给大量 crate 造成了破坏,那么我们将进一步研究如何才能平滑编译器版本之间的过渡路径。但是,我们需要权衡任何此类目标与 Miri 的开发人员数量非常有限这一事实:即研究人员正在确定如何定义 Rust 等不安全语言的语义。我们不想减慢他们的工作速度!

为了安全你可以做些什么

如果你的 crate 在 Rust 1.64 上出现 无法评估静态初始化器 消息,并且它在之前的 Rust 版本中可以编译,我们希望你让我们知道:提交一个 issue

我们为 1.64-beta 进行crater 运行,并且没有发现此特定问题的任何其他实例。如果你能在 9 月 22 日稳定版本发布之前测试你的 crate 在 1.64-beta 上的编译,那就更好了!尝试 beta 版的一个简单方法是使用 rustup 的覆盖简写

$ rustup update beta
$ cargo +beta build

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

想帮忙吗?

正如你可能想象的那样,我们很多人对诸如“什么应该被定义为未定义行为?”之类的问题很感兴趣

例如,请参阅 Ralf Jung 关于为什么指针很复杂的精彩博客系列(第 IIIIII 部分),其中包含一些关于指针值表示的上述省略的细节,并阐明了即使在常量求值之外,你可能需要担心指针到 usize 转换的原因。

如果你有兴趣帮助我们找出这些问题的答案,请加入我们的 不安全代码指南 zulip

如果您有兴趣了解更多关于 Miri 的信息,或者为它做出贡献,您可以在 miri zulip 中打个招呼。

结论

总结一下:当您编写安全的 Rust 代码时,编译器负责防止未定义行为。当您编写任何不安全代码时,负责防止未定义行为。Rust 的常量求值系统对不安全代码的已定义行为有更严格的规则:具体来说,在常量求值期间,将指针值重新解释(也称为“转换”)为 usize 是未定义行为。如果在常量求值时出现未定义行为,则无法保证您的代码会被不同版本的编译器接受。

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

但无论是否是偶然事件,这个问题都提供了极好的动力,让我们花一些时间探索 Rust 常量求值架构及其底层的解释器。我们希望您阅读这篇文章和我们写作这篇文章一样愉快。