Rust Miri 完全指南:如何使用和用好 Miri

前言

在 Rust 的世界里,虽然编译器已经帮我们解决了大部分内存安全问题,但当你开始编写 unsafe 代码时,就进入了一个编译器无法完全保护你的领域。这时候,Miri 就成为了你最重要的安全网。

正如一位开发者在 Rust 重写项目中所说:"如果你在编写 unsafe 代码时没有使用 Miri 进行检查,我认为这是愚蠢的。它迟早会给你带来麻烦。"

什么是 Miri?

Miri 是 Rust MIR(中级中间表示,Mid-level Intermediate Representation)的解释器。在 Rust 的编译过程中,代码首先会被编译为 MIR,然后再提交给 LLVM 进行处理。Miri 可以在这个层面上运行和检测代码。

Miri 能检测哪些问题?

Miri 可以帮助我们检查常见的未定义行为(UB),包括但不限于:

  1. 内存越界访问和释放后使用(use-after-free)

  2. 使用未初始化的数据

  3. 数据竞争

  4. 内存对齐问题

  5. 违反 Stacked Borrows 规则(多个可变引用问题)

  6. 双重释放(double free)

  7. 无效的内存访问

快速开始:安装和使用 Miri

安装 Miri

Miri 目前只能在 nightly Rust 上使用。安装步骤如下:

# 安装 nightly 工具链
rustup install nightly

# 添加 miri 组件
rustup +nightly component add miri

提示:如果遇到问题,可以尝试指定特定的 nightly 版本:

rustup +nightly-2024-09-01 component add miri

基本使用

# 运行程序
cargo +nightly miri run

# 运行测试
cargo +nightly miri test

# 运行特定测试
cargo +nightly miri test test_name

第一个例子:检测多个可变引用

让我们看一个简单但危险的例子:

fn main() {
    let mut x = 1;
    unsafe {
        let a: *mut usize = &mut x;
        let b: *mut usize = &mut x;
        *a = 2;
        *b = 3;
    }
}

运行 cargo run 时,这段代码不会报错。但使用 Miri:

cargo +nightly miri run

Miri 会检测到错误:

error: Undefined Behavior: attempting a write access using <2883> 
at alloc1335[0x0], but that tag does not exist in the borrow stack 
for this location
 --> src/main.rs:7:9
  |
7 |         *a = 2;
  |         ^^^^^^

这就是 Miri 的价值:将本应该在运行时爆发的 UB 问题,在测试阶段就发现。

在 Fuchsia 项目中使用 Miri

如果你在使用 Fuchsia 或其他大型项目,需要一些额外的设置步骤:

1. 生成 Cargo.toml

# 配置构建
fx set PRODUCT.BOARD --with //path/to/your:tests --cargo-toml-gen

# 运行构建
fx build

# 生成 Cargo 配置
fx gen-cargo '//path/to/your:target(//build/toolchain:host_x64)'

2. 设置 Miri

由于 Miri 需要编译新的 Rust sysroot,需要访问外部 crates。编辑 src/.cargo/config 并注释掉 vendored crates 配置:

# [source.crates-io]
# replace-with = "vendored-sources"

# [source.vendored-sources]
# directory = "../third_party/rust_crates/vendor"

然后运行:

cargo +nightly miri setup

3. 运行 Miri

cd src/path/to/your/crate
cargo +nightly miri test

Miri 的最佳实践

1. 理解 Stacked Borrows

Miri 使用一个叫做 "Stacked Borrows" 的模型来检测违反 Rust 借用规则的行为。Rust 的核心规则是:

多个只读引用 XOR 一个可变引用

unsafe 代码中,很容易不小心违反这个规则:

fn problematic_code() {
    let mut data = vec![1, 2, 3];
    let ptr = data.as_mut_ptr();
    
    unsafe {
        // 危险:创建了多个指向同一内存的可变指针
        let ptr1 = ptr;
        let ptr2 = ptr;
        *ptr1 = 10;  // Miri 会在这里报错
        *ptr2 = 20;
    }
}

2. 在 CI/CD 中集成 Miri

为了确保代码质量,应该将 Miri 集成到持续集成流程中:

# .github/workflows/miri.yml
name: Miri
on: [push, pull_request]

jobs:
  miri:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions-rs/toolchain@v1
        with:
          toolchain: nightly
          components: miri
          override: true
      - name: Run Miri
        run: cargo miri test

3. 使用特性标志隔离问题代码

当使用包含 C/汇编代码的库(如加密库)时,Miri 无法运行这些代码。解决方案是使用特性标志:

# Cargo.toml
[features]
default = ["native-crypto"]
native-crypto = []
miri-safe = []

[dependencies]
aws-lc-rs = { version = "1.0", optional = true }
#[cfg(all(test, not(miri)))]
mod tests_with_crypto {
    // 使用原生加密库的测试
}

#[cfg(all(test, miri))]
mod miri_safe_tests {
    // 只使用纯 Rust 代码的测试
}

运行测试时:

# 正常测试
cargo test

# Miri 测试(禁用原生加密)
cargo +nightly miri test --no-default-features --features miri-safe

4. 编写 Miri 友好的测试

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_basic_operations() {
        let mut list = MyList::new();
        list.push(1);
        list.push(2);
        assert_eq!(list.pop(), Some(2));
        assert_eq!(list.pop(), Some(1));
        assert_eq!(list.pop(), None);
    }

    #[test]
    #[cfg_attr(miri, ignore)]  // 对于非常慢的测试,在 Miri 下忽略
    fn test_large_dataset() {
        // 处理大量数据的测试
    }
}

5. 解读 Miri 错误信息

当 Miri 报错时,关键是理解错误信息:

error: Undefined Behavior: trying to reborrow for Unique at alloc84055,
but parent tag <209678> does not have an appropriate item in the borrow stack

这个错误的含义:

  • trying to reborrow for Unique:尝试创建一个独占的可变引用

  • parent tag:原始的借用标记

  • borrow stack:Miri 维护的借用堆栈,用于跟踪引用的生命周期

通常这意味着你在 unsafe 代码中创建了冲突的引用。

Miri 的局限性

1. 动态分析的局限

Miri 是一个动态分析工具,这意味着:

  • 优点:可以检测实际运行时的 UB

  • 缺点:只能检测执行路径上的问题,无法覆盖所有可能的代码路径

这就是为什么需要高测试覆盖率的原因。

2. 无法运行 FFI 代码

Miri 无法运行 C/C++/汇编代码:

// 这段代码在 Miri 下无法运行
use openssl::*;

fn encrypt_data(data: &[u8]) -> Vec<u8> {
    // OpenSSL 使用 C 代码
    // Miri 会报错: can't call foreign function
}

解决方案

  • 使用特性标志隔离 FFI 代码

  • 使用纯 Rust 实现作为测试替代(如果可能)

  • 对于 FFI 部分,使用 Valgrind 等工具补充检测

3. 性能开销

Miri 运行速度较慢,可能比正常测试慢 10-1000 倍。因此:

  • 不要在 Miri 下运行性能测试

  • 考虑只在 CI 的特定阶段运行 Miri

  • 使用 #[cfg_attr(miri, ignore)] 标记特别慢的测试

Miri vs 静态分析工具

研究人员开发了静态分析工具如 SafeDrop,它与 Miri 有不同的特点:

特性

Miri

SafeDrop(静态分析)

分析时机

运行时

编译时

路径覆盖

只检测执行的路径

检测所有可能的路径

检测范围

广泛(各种 UB)

专注于内存释放问题

性能

较慢

较快

FFI 支持

不支持

支持

误报率

可能较高

建议:两种工具结合使用,Miri 用于动态检测,静态分析工具用于覆盖更多路径。

实战案例:修复 use-after-free

让我们看一个实际的例子:

问题代码

pub struct MyQueue<T> {
    head: Option<Box<Node<T>>>,
}

struct Node<T> {
    elem: T,
    next: Option<Box<Node<T>>>,
}

impl<T> MyQueue<T> {
    pub fn pop(&mut self) -> Option<T> {
        self.head.take().map(|head| {
            let head = *head;  // 将 Box<Node> 解包
            self.head = head.next;  // 移动 next
            
            // 问题:head 被解包后,其内存在这里被释放
            // 但我们还持有指向它的引用
            head.elem  // 返回 elem
        })
    }
}

Miri 检测

$ cargo +nightly miri test
error: Undefined Behavior: trying to reborrow for Unique at alloc84055

修复方案

impl<T> MyQueue<T> {
    pub fn pop(&mut self) -> Option<T> {
        self.head.take().map(|mut head| {
            // 先获取 next
            self.head = head.next.take();
            // 然后返回 elem,Box 会自动清理
            head.elem
        })
    }
}

高级技巧

1. 使用环境变量调整 Miri 行为

# 显示更详细的追踪信息
$ MIRIFLAGS="-Zmiri-track-raw-pointers" cargo +nightly miri test

# 禁用隔离(允许访问文件系统等)
$ MIRIFLAGS="-Zmiri-disable-isolation" cargo +nightly miri test

# 启用数据竞争检测
$ MIRIFLAGS="-Zmiri-disable-weak-memory-emulation=false" cargo +nightly miri test

2. 针对特定平台测试

# 测试不同的目标平台
$ cargo +nightly miri test --target x86_64-unknown-linux-gnu
$ cargo +nightly miri test --target aarch64-unknown-linux-gnu

3. 创建 Miri 配置文件

在项目根目录创建 .miri-config.toml

[miri]
# 额外的 Miri 标志
flags = ["-Zmiri-track-raw-pointers"]

# 忽略特定的测试
ignored-tests = ["test_with_ffi", "slow_integration_test"]

常见陷阱和解决方案

陷阱 1:忘记初始化内存

// 错误
let mut buffer: [u8; 1024];
unsafe {
    // Miri 会报错:使用未初始化的内存
    ptr::copy_nonoverlapping(src.as_ptr(), buffer.as_mut_ptr(), len);
}

// 正确
let mut buffer = [0u8; 1024];
// 或者使用 MaybeUninit
let mut buffer: [MaybeUninit<u8>; 1024] = unsafe {
    MaybeUninit::uninit().assume_init()
};

陷阱 2:指针别名问题

// 错误:创建了别名的可变指针
unsafe fn bad_swap(x: &mut i32, y: &mut i32) {
    let temp = *x;
    *x = *y;
    *y = temp;
}

let mut value = 42;
unsafe {
    bad_swap(&mut value, &mut value);  // Miri 错误!
}

// 正确:使用 ptr::read 和 ptr::write
unsafe fn good_swap(x: &mut i32, y: &mut i32) {
    if ptr::eq(x, y) { return; }
    ptr::swap(x, y);
}

陷阱 3:生命周期问题

// 错误
fn return_reference(data: &[i32]) -> &i32 {
    let boxed = Box::new(data[0]);
    &*boxed  // Miri 错误:返回悬垂指针
}

// 正确
fn return_value(data: &[i32]) -> i32 {
    data[0]
}

参考资源


本文综合了多个 Rust 社区资源和实战经验,希望能帮助你更好地使用 Miri 编写更安全的 Rust 代码。