Rust的const-eval系统运行的规则指南(附代码)

372 阅读10分钟

在最近的一个Rust问题(#99923)中,一位开发者注意到,即将到来的1.64-beta版本的Rust已经开始在他们的crate上发出错误信号。icu4x.icu4x crate在const评估过程中使用了不安全的代码。const评估,或只是 "const-eval",在编译时运行,但产生的值可能最终嵌入到运行时执行的最终对象代码中。

Rust的const-eval系统同时支持安全和不安全的Rust,但不安全代码在const-eval期间被允许做的事情的规则甚至比不安全代码在运行时被允许做的事情更加严格。这篇文章将详细介绍这些规则中的一条。

(注意:如果你的const 代码没有使用任何unsafe 块或用unsafe 块调用任何const fn,那么你就不需要担心这个问题!)

一个需要注意的新诊断方法

#99923的评论线程中减少的问题是,某些静态初始化表达式(见下文)在编译时(playground)被定义为具有未定义行为(UB):

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

(非常感谢@eddyb ,提供了最小的转载量!)

上面的代码被1.63版本和更早的Rust所接受,但在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时,那么编译器就负责防止未定义的行为。当你写任何不安全的代码(不管是const还是non-const)时,你要负责防止UB,在const-eval期间,关于哪些不安全代码有定义行为的规则甚至比管理Rust的运行时语义的类似规则更严格。(换句话说,更多的代码被归类为 "UB",而不是你所意识到的那样。

如果你在const-eval过程中遇到了未定义行为,Rust编译器会保护自己不受不利影响,比如未定义行为泄露到类型系统中,但除此之外几乎没有其他保证。此外,如果你在const-eval时出现了未定义行为,就不能保证你的代码在不同的编译器版本中都能被接受。

这里有什么新东西

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

但是这样的推理是基于对Rust编译器在这里所做的不精确的看法。

Rust编译器的const-eval机制是建立在MIR-解释器Miri之上的,它使用一个假设机器的抽象模型作为评估此类表达的基础。这个抽象模型不必将内存地址仅仅表示为整数;事实上,为了支持Miri的细粒度检查,它为抽象内存存储中的值使用了更丰富的数据类型。

Miri的值表示的细节对我们在这里的讨论没有太大关系。我们只是注意到,早期版本的编译器默默地接受那些似乎将内存地址转化为整数的表达式,将它们复制到周围,然后再将它们转化为地址;但这并不是实际发生的事情。相反,正在发生的是Miri值被盲目地传递(毕竟,transmute的全部意义在于它没有对其输入值进行转换,所以就其操作语义而言,它是一个无用的)。

事实上,它正在将一个内存地址传入一个你期望总是有一个整数值的上下文中,如果有的话,也只能在后来的某个时候被发现。

例如,const-eval机制会拒绝那些试图将转换后的指针嵌入到一个可以被运行时代码使用的值中的代码,就像这样(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的const-eval系统的语义,上面提供的所有例子都表现出未定义行为

第一个例子与_copy ,在Rust 1.46到1.63版本中被接受,因为Miri实现了神器。Miri在检测UB方面付出了相当大的努力,但并没有抓住所有的实例。此外,在默认情况下,Miri的检测可能会延迟到发现实际问题表达式之后很久。

但在Rust的夜间版本中,我们可以通过传递unstable标志-Z extra-const-ub-checks ,选择加入Miri提供的额外的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的表达方式:原始的转化本身!(这在这篇文章的开头已经说过了;这里我们只是指出,这些工具可以更精确地指出注入点)。

为什么不把这些额外的const-ub检查放在默认情况下?嗯,这些检查会在Rust编译时引入性能开销,我们不知道这种开销是否可以接受。(然而,最近Miri开发者之间的争论表明,这里的内在成本可能并不像他们最初想象的那样糟糕。也许未来版本的编译器会默认开启这些额外的检查)。

改变是困难的

在这一点上,你很可能会想:"等等,在常量计算过程中,什么时候可以将指针转变成usize ?" 答案很简单:"永远不可以。"

自从 const-eval 增加了对transmuteunion 的支持后,在 const-eval 过程中把指针转到 usize 一直是未定义的行为。你可以在const_fn_transmute /const_fn_union 稳定化报告中读到更多关于这个的内容,特别是题为 "指针-整数-转 "的小节。(在transmute文档中也提到了这一点)。

因此,我们可以看到,在const评估期间,将上述例子归类为UB,根本不是什么新鲜事。这里唯一的变化是Miri有一些内部变化,使得它开始检测UB而不是默默地忽略它。

这意味着Rust编译器对它将明确捕获的UB有一个转变的概念。我们预见到了这一点。RFC 3016,"const UB",明确指出

[...]不能保证在CTFE过程中可靠地检测到UB。这可能会随着编译器版本的不同而改变。导致UB的CTFE代码在一个编译器中可以很好地构建,而在另一个编译器中却无法构建。(这符合不健全的代码不受稳定性保证的一般政策)。

说了这么多。Rust的成功在很大程度上是围绕着我们与社区的信任而建立的。是的,项目一直保留着在解决健全性错误时进行破坏性修改的权利;但我们也一直努力在可行的情况下通过未来兼容的lints来减少这种破坏性。

今天,由于我们目前在Miri之上的const-eval架构,要确保像注入问题#99923这样的修改通过未来兼容的警告周期是不可行的。 编译器团队计划继续关注这个领域的问题。如果我们看到有证据表明这类变化确实会导致非微不足道数量的编译器损坏,那么我们将进一步研究如何使编译器版本之间的过渡路径更加平滑。然而,我们需要平衡任何这样的目标,因为Miri的开发者非常有限:确定如何定义Rust等不安全语言语义的研究人员。我们不希望拖累他们的工作。

为了安全起见,你可以做什么

如果你在Rust 1.64上的crate上观察到could not evaluate static initializer ,而它是用以前的Rust版本编译的,我们希望你能让我们知道:提交一个问题!

如果你能在9月22日稳定版发布之前在1.64-beta版上测试编译你的crate,那就更好了。尝试测试版的一个简单方法是使用rustup的override shortand

$ rustup update beta
$ cargo +beta build

随着Rust的const-eval的发展,我们可能会看到另一种类似的情况再次出现。如果你想防御未来的const-eval UB实例,我们建议你设置一个持续集成服务,在你的代码上调用带有unstable-Z extra-const-ub-checks 标志的夜间rustc

想帮忙吗?

正如你所想象的,我们很多人对诸如 "什么应该是未定义行为 "这样的问题相当感兴趣。

例如,请看Ralf Jung关于指针为什么复杂的优秀博客系列(第一部分),其中包含了上面省略的关于Miri表示法的一些细节,并阐明了为什么即使在const-eval之外,你也可能要关注指针到使用的转化的原因。

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

如果你有兴趣了解更多关于Miri的信息,或者为它做贡献,你可以在Miri zulip中说你好。

总结

总结一下。当你写安全的Rust时,那么编译器就负责防止未定义行为。当你写任何不安全的代码时,要负责防止未定义的行为。Rust的const-eval系统有一套更严格的规则来管理哪些不安全的代码有定义的行为:具体来说,在const-eval期间将一个指针值重新解释(又称 "转化")为usize ,是未定义行为。如果你在const-eval时有未定义的行为,就不能保证你的代码从一个编译器版本到另一个版本都能被接受。

编译器团队希望第99923号问题是一个特殊的侥幸,1.64稳定版不会遇到与上述const-eval机制的变化有关的任何其他意外情况。

但无论是否侥幸,这个问题都提供了很好的动力,让我们花一些时间去探索Rust的const-eval架构,以及支撑它的Miri解释器的各个方面。我们希望你能像我们写这篇文章一样喜欢阅读。