Const Eval (不)安全规则

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

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

Rust 的 const-eval 系统支持安全和不安全的 Rust,但 const-eval 期间允许使用的不安全代码规则比运行时允许的不安全代码规则更加严格。这篇文章将详细介绍其中的一条规则。

(注意:如果你的 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 代码时,编译器负责防止未定义行为。当你编写任何不安全的代码(无论是 const 还是非 const),你都负责防止 UB,而在 const-eval 期间,关于哪些不安全代码具有定义行为的规则比管理 Rust 运行时语义的类似规则更加严格。(换句话说,更多代码被归类为“UB”,而不是你可能意识到的。)

如果你在 const-eval 期间遇到未定义行为,Rust 编译器会保护自己免受 不利影响,例如未定义行为泄漏到类型系统中,但除了这一点之外,几乎没有其他保证。例如,编译时 UB 可能导致运行时 UB。此外,如果你在 const-eval 时出现 UB,则无法保证你的代码从一个编译器版本到另一个编译器版本会被接受。

这里有什么新东西

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

但这种推理是基于对 Rust 编译器在这里所做工作的模糊理解。

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

(前面提到的 MIR 解释器也是 Miri 的基础,Miri 是一种研究工具,它解释非 const Rust 代码,重点是显式检测未定义行为。Miri 开发人员是 Rust 编译器中 CTFE 引擎的主要贡献者。)

CTFE 引擎的值表示的细节对于我们在这里的讨论并不重要。我们只是注意到,编译器的早期版本默默地接受了似乎将内存地址转换为整数的表达式,将它们复制,然后将它们转换回地址;但实际上,幕后发生的事情并非如此。相反,发生的事情是,这些值被盲目地传递(毕竟,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 版本中被接受,因为 CTFE 实现存在一些缺陷。CTFE 引擎在检测 UB 方面付出了相当大的努力,但并非所有 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 本身!(这在本文开头就已说明;这里我们只是指出这些工具可以更精确地查明注入点。)

为什么不默认启用这些额外的 const-ub 检查?嗯,这些检查会增加 Rust 编译时间的性能开销,我们不知道这种开销是否可以接受。(然而,Miri 开发人员之间的最近辩论表明,这里固有的成本可能不像他们最初认为的那样糟糕。也许编译器的未来版本将默认启用这些额外的检查。)

改变是困难的

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

在 const-eval 期间将指针转换为 usize 一直都是未定义行为,自从 const-eval 添加对 transmuteunion 的支持以来一直如此。你可以在 const_fn_transmute / const_fn_union 稳定性报告 中了解更多信息,特别是名为“指针-整数-转换”的小节。(它也提到了 transmute 的文档中).)

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

这意味着 Rust 编译器对它将显式捕获的 UB 有一个不断变化的概念。我们预料到了这一点:RFC 3016,“const UB”,明确 表示

[...] 无法保证在 CTFE 期间可靠地检测到 UB。这可能会因编译器版本而异:导致 UB 的 CTFE 代码可能在一个编译器中构建良好,而在另一个编译器中构建失败。(这符合一般政策,即不稳定代码不受稳定性保证的约束。)

话虽如此:Rust 的成功在很大程度上建立在我们与社区建立的信任之上。是的,该项目始终保留在解决健全性错误时进行重大更改的权利;但我们也一直努力通过诸如 未来不兼容的 lint 之类的东西来尽可能地减轻这种破坏。

在当前的 const-eval 架构下,无法保证像 注入 问题 #99923 这样的更改会经过未来不兼容警告周期。编译器团队计划密切关注此方面的問題。如果我们发现这类更改确实会导致大量箱子出现问题,我们将进一步研究如何平滑编译器版本之间的过渡路径。但是,我们需要权衡任何这样的目标,因为 Miri 的开发人员数量非常有限:研究人员决定如何定义像 Rust 这样的不安全语言的语义。我们不想减慢他们的工作速度!

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

如果您在 Rust 1.64 上的箱子上观察到 could not evaluate static initializer 消息,并且它在之前的 Rust 版本中可以编译,我们希望您告知我们:提交问题

我们已经 执行 了针对 1.64-beta 的 crater 运行,并且没有发现任何其他此类问题的实例。如果您可以在 9 月 22 日稳定版发布之前尝试在 1.64-beta 上编译您的箱子,那就更好了!尝试 beta 版的一种简单方法是使用 rustup 的覆盖简写

$ rustup update beta
$ cargo +beta build

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

想帮忙吗?

正如您可能想象的那样,我们很多人对诸如“什么是未定义行为?”之类的问题非常感兴趣。

例如,请参阅 Ralf Jung 关于为什么指针很复杂(部分 IIIIII)的优秀博客系列,其中包含上面关于指针值表示的一些省略的细节,并阐述了为什么您可能想要关注指针到 usize 的转换,即使是在const-eval之外。

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

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

结论

总而言之:当您编写安全的 Rust 代码时,编译器负责防止未定义行为。当您编写任何不安全的代码时,负责防止未定义行为。Rust 的 const-eval 系统有一套更严格的规则来管理哪些不安全代码具有定义的行为:具体来说,在 const-eval 期间将指针值重新解释(也称为“转换”)为 usize 是未定义的行为。如果您在 const-eval 时出现未定义行为,则无法保证您的代码从一个编译器版本到另一个编译器版本会被接受。

编译器团队希望问题 #99923 是一个特例,并且 1.64 稳定版不会遇到与上述 const-eval 机制更改相关的任何其他意外情况。

但是,无论是否是特例,这个问题都为我们提供了一个绝佳的动机,让我们花些时间探索 Rust 的 const-eval 架构和底层解释器的各个方面。我们希望您像我们编写它一样享受阅读它。