如何解释 Miri 的堆栈跟踪?

解读

在国内 Rust 岗位面试中,Miri 已逐渐成为考察候选人“是否真的写过 unsafe”的试金石。
面试官抛出“解释 Miri 的堆栈跟踪”并不是想听你背文档,而是想看三点:

  1. 你是否亲手用 cargo miri 定位过未定义行为(UB)
  2. 能否把 Miri 报出的冗长回溯翻译成“哪一行 unsafe 违反了哪条 Stacked Borrows 规则”
  3. 是否知道在 CI 里加 miri test 做门禁,而不是事后救火。
    因此,回答必须结合真实回溯片段,逐层拆解“函数帧 → 标签 → 权限冲突”的物理含义,并给出修复代码。

知识点

  • Miri 本质:Rust 官方 UB 动态检测器,基于中间码解释执行,不依赖硬件地址,用“标签”追踪每块内存的借用状态。
  • 堆栈跟踪三栏
    ① frame #N:函数调用栈;
    ② 带 tags 的内存描述,如 alloc892@0x4..0x8
    权限冲突详情,如 read access with tag <1628> at alloc892 conflicts with frozen tag <1625>
  • 核心规则:Stacked Borrows / Tree Borrows;tag 编号越小,表示借用栈越靠底“frozen”代表共享不可变引用已冻结原 tag
  • 常见触发场景
    – 把裸指针转成引用后,原引用仍被使用
    – 手动实现链表时,&mut self 与 next 域别名
    – 用地址作为 key 做 HashMap,后续解引用已失效地址
  • 调试口诀
    1. 先找 “first occurred” 行,定位到测试用例;
    2. 再向上找 “inside call” 中第一个用户 crate 帧,跳过 std;
    3. 把 tag 编号与代码里 addr_of_mut!slice::from_raw_parts_mut 出现顺序对照,逆推哪次 borrow 产生了该 tag
    4. cargo miri test -- --nocapture 打开回溯,配合 eprintln! 打印 tag,验证假设。
  • CI 集成.github/workflows/miri.yml 里加 cargo miri test --lib -- -Zmiri-disable-isolation必须加 timeout-minutes: 30,否则 OOM 被 kill 看不出原因。

答案

以下是一段真实 Miri 回溯(已脱敏):

error: Undefined Behavior: read access by tag <1628> at alloc892[0x4] 
       conflicts with frozen tag <1625>
  --> src/arena.rs:47:9
   |
47 |         *node.data
   |         ^^^^^^^^^^ read access by tag <1628>
   |
   = help: the tag <1625> was created by a SharedReadOnly borrow at src/arena.rs:39
   = note: backtrace:
   |  frame #0  src/arena.rs:47:9 (myapp::arena::get)
   |  frame #1  src/arena.rs:88:24 (myapp::arena::Arena::alloc)
   |  frame #2  tests/arena.rs:11:5 (integration_test::insert_twice)

逐层翻译

  1. 冲突点*node.data 试图用 tag <1628> 做一次,但同一块内存已被 tag <1625> 冻结为只读共享,任何写或“新读”都违法
  2. tag 来源:help 行指出 <1625> 诞生于 39 行 &*box_ptr共享只读借用;而 <1628> 是 47 行解引用裸指针时新生成的“活跃”标签。
  3. 违反规则:在 Stacked Borrows 模型里,一旦共享只读引用出现,原 &mut 路径即被冻结,后续即使通过裸指针再读,也必须保证不创建新的可变别名。代码里 39 行把 &mut 降级成 &,紧接着 47 行却用裸指针“复活”了可变语义,制造了别名,于是 Miri 报错。

修复方案

// 原问题代码
let shared: &Node = &*box_ptr;          // 39 行:冻结 box_ptr
let raw = shared as *const Node;
let val = unsafe { (*raw).data };       // 47 行:UB!

// 修复:要么全程用共享,要么全程用唯一可变
unsafe {
    let unique = &mut *box_ptr;         // 保证唯一 &mut
    let val = unique.data;
}

总结给面试官的三句话

  1. “我首先看 help 行,它直接告诉我是哪次 borrow 产生了冲突 tag。”
  2. *“然后我对照代码,确认是否出现了&mut → & → mut 的非法降级链。”
  3. “最后我统一生命周期,用独占访问或 UnsafeCell 显式共享,确保 Miri 通过。”

拓展思考

  • Tree Borrows 与 Stacked Borrows 差异:Tree Borrows 允许结构化父子关系,对 self-referential struct 更友好;面试时可主动问“贵项目用哪条模型”,展示你对 nightly -Zmiri-tree-borrows 的熟悉度
  • Miri 盲区数据竞争、对齐违规、跨线程原子顺序 目前不在检测范围;若面试官追问“为什么 Miri 没报 data race”,可答“需配合 loom 或 sanitizers,Miri 单线程解释执行,无法模拟并发交错”。
  • 性能与落地:Miri 比 valgrind 慢 10~100 倍,不适合全量压测;国内大厂(如某头部云)做法是把核心 unsafe 模块抽成独立 crate,在 PR 阶段跑 cargo miri test -p unsafe_utils限制在 5 分钟内完成,既守安全红线,又不阻塞流水线。