如何解释 Miri 的堆栈跟踪?
解读
在国内 Rust 岗位面试中,Miri 已逐渐成为考察候选人“是否真的写过 unsafe”的试金石。
面试官抛出“解释 Miri 的堆栈跟踪”并不是想听你背文档,而是想看三点:
- 你是否亲手用 cargo miri 定位过未定义行为(UB);
- 能否把 Miri 报出的冗长回溯翻译成“哪一行 unsafe 违反了哪条 Stacked Borrows 规则”;
- 是否知道在 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,后续解引用已失效地址。 - 调试口诀:
- 先找 “first occurred” 行,定位到测试用例;
- 再向上找 “inside call” 中第一个用户 crate 帧,跳过 std;
- 把 tag 编号与代码里
addr_of_mut!或slice::from_raw_parts_mut出现顺序对照,逆推哪次 borrow 产生了该 tag; - 用
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)
逐层翻译:
- 冲突点:
*node.data试图用 tag <1628> 做一次读,但同一块内存已被 tag <1625> 冻结为只读共享,任何写或“新读”都违法。 - tag 来源:help 行指出 <1625> 诞生于 39 行
&*box_ptr的共享只读借用;而 <1628> 是 47 行解引用裸指针时新生成的“活跃”标签。 - 违反规则:在 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;
}
总结给面试官的三句话:
- “我首先看 help 行,它直接告诉我是哪次 borrow 产生了冲突 tag。”
- *“然后我对照代码,确认是否出现了&mut → & → mut 的非法降级链。”
- “最后我统一生命周期,用独占访问或 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 分钟内完成,既守安全红线,又不阻塞流水线。