“原文为2022年5月发布的论文《Verifying Dynamic Trait Objects in Rust》[1] 。 注意,本文不是对该论文的全文翻译,而是论文关键摘要总结,仅供学习使用。
该论文是康奈尔大学和亚马逊工程师合作编写的,本文主要介绍开源的 Kani Rust verifier[2] 验证工具如何使用 MIR 表示的语义trait信息进行验证。该团队在调研 500 个下载次数最多的 Rust 库中发现,有 37% 使用表示动态调用的 dyn
关键字,而动态调度隐式调用达到70%(rustc编译时至少有70%包含一个vtable)。Kani 是第一个用于 Rust 的符号建模检查工具,提供了用于动态 trait 对象的开源验证方法。
Kani 简介
虽然 Rust 语言类型系统可以检查大多数内存安全问题,但仍然有很多执行错误的方法。因此,亚马逊(AWS 推动)和康奈尔大学的合作构建了开源工具 Kani ,用于对 Rust 程序进行健全、位精确的符号分析,主要目标是能无缝集成到 Rust 大型现有项目中。目前 kani 已经被集成到了开源的 Firecraker 虚拟机监视器组件上,Firecraker 为 AWS 的 Lambda 和 Fargate 提供虚拟化。
该团队在实现 Kani 的过程中,发现一个意想不到的挑战,就是对动态 trait 对象的方法表进行建模。默认情况下, trait 方法调用是通过泛型限定的方式静态分发,即单态化。但是,开发者也可以使用 dyn
关键字来获得动态表达能力,即使用 trait对象。Rust 的闭包和匿名函数也可以通过 trait 对象动态调度(因为它们都实现了 FnOnce/FnMut/Fn
)。
因为 Rust 生态库中大量使用 trait 对象,所以 kani 的目标就包括了对 Rust 标准库和 crate 提供全面支持。Kani 是目前唯一一个针对 Rust MIR 并且可以推理动态 trait 对象和动态闭包符号的模型检查工具。Kani 作为 Rust 编译器后端而实现,该编译器后端使用成熟的工业强度模型检查工具 「C 有界模型检查器(CBMC)[3]」作为验证引擎。Kani 将 Rust MIR 翻译为 GOTO-C(CBMC 类 C 的中间语言)。可以使用 Cargo 对单个rust 文件或 crate 来调用 Kani 。Kani 可以检查用户添加的断言、算术溢出、越界内存访问和无效指针,对于 Unsafe Rust 尤其有用。但默认情况下, Kani 使用断言方式运行。
Rust trait 概述
trait 静态分发(单态化)
泛型限定
trait 通常通过泛型限定进行静态分发,比如:
代码语言:javascript复制trait Countable {
fn count(&self) -> usize;
}
impl Countable for Vec {
fn count(&self) -> usize {
self.len()
}
}
impl Countable for Bucket {
fn count(&self) -> usize {
self.item_count
}
}
fn print_count(obj: T) {
print!("Count = {}", obj.count());
}
上面定义的泛型函数 print_count
在 MIR 层面就会被单态化为具体类型的函数:
fn print_count_vec_i8(obj: Vec) {
print!("Count = {}", obj.count::>());
}
fn print_count_bucket(obj: Bucket) {
print!("Count = {}", obj.count::());
}
闭包单态化
此外,闭包也可以作为 trait 限定:
代码语言:javascript复制fn price<T: Fn(f32)->f32>(cost: f32, with_tax: T) 2 -> f32 {
with_tax(cost)
}
fn main(){
let tax_rate = 1.1;
price(5., |a| a * tax_rate); // Price is: 5.5
price(5., |a| a 2.);
}
上面两个调用闭包的代码 Rust 也会对其单态化:
代码语言:javascript复制fn see_price_closure@main:1(cost: f32) -> f32 {
closure@main:1([closure@main:1], cost) // 这里要存储 cost
}
fn see_price_closure@main:2(cost: f32) -> f32 ... // 这里不需要存储 cost
单态化成本
单态化可以提升性能,但是会增加编译文件大小和编译时间。
trait 动态分发(trait 对象)
为了权衡运行时效率和改进代码大小和编译时间,开发人员可以使用 trait 对象来进行动态分发(避免单态化)。
代码语言:javascript复制trait Countable {
fn count(&self) -> usize;
}
// `&dyn Countable` trait对象,动态分发
fn print_count(obj: &dyn Countable) {
print!("Count = {}", obj.count());
}
当调用 print_count
时,编译器不会为每个具体类型创建一个新函数,而是使用一个 print_count
实例和可以表示所有实现 Countable
的对象实例。
trait 对象由一个胖指针表示,这个胖指针包含了一个指向对象本身(数据)的指针和一个指向其实现方法的虚表(Vtable
)的指针。
因为 Vtable 需要跳转到动态计算的地址,所以它们可能会在安全攻击中被利用,因此它们的精确实现具有隐患。虽然 Rust 的非正式规范中没有指出 Vtable 的布局,但 MIR 提供了用于构建特定形式 Vtable 的实用函数。Kani 参考了 LLVM 后端中 Vtable 的特定布局。
代码语言:javascript复制“在 LLVM 后端中,Vtable 中包含着对象元数据(数据的大小和对齐方式),以及每个方法实现的函数指针。每个 vtable 中都包含一个指向具体类型的 drop(析构函数)方法实现的函数指针。
// 上面 print_count 函数等价于
fn print_count(obj: &dyn Countable) {
print!("Count = {}",
*(obj.vtable.count)(obj.vtable.data)
);
}
// 当使用 as 强转具体类型为 trait 对象时
// 该 trait 对象的胖指针就会包含一个指向 Bucket 的指针和指向 Vtable 的指针
print_count(&Bucket::new(1) as &dyn Countable);
Kani 对 trait 对象验证的方式
因为 GOTO-C 没有对 trait 对象 的原生支持,所以 Kani 在实现的时候,只能遵循 LLVM 后端的 Vtable 实现来保持 trait 对象的语义。但 Kani 生成的 Vtable 对象是 GOTO-C 结构。
Kani 在实现 trait 对象验证的过程中遇到了下面的一些问题:
- 不同trait 但可能存在同名的方法,会造成歧义。 Kani 使用 MIR Api 返回的
vtable_entries
来解决此问题,MIR 保留了大部分 Rust 类型的语义信息,这些丰富的类型信息提供了帮助。 - Rust 目前不支持 trait upcasting (需要更改底层 vtable 实现,目前这个工作正在进行中),即将 trait 对象向上转换为它的 suptertrait 的 trait对象。但是对于 auto trait 来说可以进行强转,Kani 最初忽略了这一点。
- Rust 的借用检查器和动态分发之间有一些微妙的联系。闭包解糖之后实际对应三种类型的方法签名(
FnOnce(self)/FnMut(&mut self)/Fn(&self)
),但是 Kani 当初只围绕self
进行验证。
与其他语言无关的验证工具相比,Kani 的优势是可以利用 Rust 的语义提高验证的完整性和性能。
AWS EC2 应用案例: Firecracker
在对 Firecracker 进行验证过程中一个巨大挑战是代码中使用了很多 std::io::Error
trait 对象(错误处理),这让 CBMC 符号执行引擎无法在四小时内完成任务。Kane 实现了一种「基于 trait 的函数指针限制」模式,将该过程加速了 15 倍。
相关测试代码见:icse22ae-kani[4]
其他类型工具比较
- CRUST,一个类似于 Rust 的有界模型检查器,也使用 CBMC 工具作为验证后端。但是 CRUST 明确不支持 trait对象,并且不再积极开发。
- Prusti,一个建立在 Viper 验证基础设施上的 Rust 编译器插件,同 Kani 一样,Prusti 也通过 MIR 类型信息改进验证结果。Prusti 的类型注释语言比 Kani 更具表现力,包括支持循环不变量,允许验证 Kani 目前无法验证的程序。但是 Prusti 对 Unsafe 的代码支持有限,并且不支持 trait 对象。
- Crux-MIR,使用 Galois 的 Crucible 验证器,同样基于 MIR 。它可以通过
&dyn
指针引用验证动态分发的简单情况,但不支持Box<dyn T>
和动态闭包对象(如&dyn Fn()
)。 - MIRAI (facebook 开源)是一个 MIR 抽象解释器,不提供健全性验证。
- 其他一些基于 LLVM IR 的验证工具,伴随着无法理解 Rust 类型级别语义的缺陷。
- SMACK 工具链
- RVT(来自 Google Research)
小结
Kani 是致力于提供在大型 Rust 项目中部署验证,本论文介绍了 Kani 如何支持 trait 对象的验证,并且展示了如何基于 MIR 中的类型信息将验证速度提升了 15 倍。
目前 Kani 正在积极开发中,Kani 的主要更改记录在 RFC Book[5]中。目前支持相当数量的 Rust 语言特性,但不是全部(比如还不支持并发)。请参阅Rust feature support[6]以获取支持特性的详细列表。Kani 每两周发布一次。作为每个版本的一部分,Kani 将与最近的 Rust Nightly 版本同步。
如需进一步了解 Kani 的应用,可以参考延伸阅读。
延伸阅读
- https://whileydave.com/2021/10/26/test-driving-the-rust-model-checker-rmc/
- https://model-checking.github.io/kani/
参考资料
[1]
《Verifying Dynamic Trait Objects in Rust》: https://www.cs.cornell.edu/~avh/dyn-trait-icse-seip-2022-preprint.pdf
[2]
Kani Rust verifier: https://github.com/model-checking/kani
[3]
C 有界模型检查器(CBMC): https://www.cprover.org/cbmc/
[4]
icse22ae-kani: https://github.com/avanhatt/icse22ae-kani
[5]
RFC Book: https://model-checking.github.io/kani/rfc
[6]
Rust feature support: https://model-checking.github.io/kani/rust-feature-support.html