所有权系统如何防止双重释放 ——从 MIR 语义到 FFI 边界的 5 000 字深潜
·

0 双重释放:看似小 bug,实则核弹
// C 代码:一次手误,两次 free
char *p = malloc(64);
free(p);
free(p); // 未定义行为:可能 crash,也可能破坏 malloc 元数据
传统系统语言靠「程序员自律 + 运行时检测」;
Rust 把这一问题 前移 到 编译期,用 所有权系统 让 双重释放成为编译错误。
1 Rust 的三级防火墙
| 层级 | 机制 | 双重释放保护 |
|---|---|---|
| 语言规则 | 单一所有权 + move 语义 | 一个值只能被 drop 一次 |
| 类型系统 | Drop trait & 线性类型 | 值离开作用域时 恰好 调用一次析构 |
| MIR 检查 | borrow checker + drop elaboration | 保证 move 后再访问 = 编译错误 |
2 Move 语义:把「释放」从运行时搬到编译期
2.1 最小示例:move 后不可再使用
let s = String::from("hello");
let t = s; // s 的所有权 **move** 到 t
println!("{}", s); // ❌ 编译错误:value borrowed here after move
编译器输出:
error[E0382]: borrow of moved value: `s`
背后 MIR(简化):
bb0:
_1 = String::from("hello");
_2 = move _1; // move
_3 = _1; // ❌ use after move
move在 MIR 中表现为 变量标记为uninit- 任何后续读取都会触发 线性类型检查失败
3 Drop 的线性化保证
3.1 单一路径,单一析构
struct Bomb(*mut u8);
impl Drop for Bomb {
fn drop(&mut self) {
unsafe { std::alloc::dealloc(self.0, std::alloc::Layout::new::<u8>()); }
}
}
fn demo() {
let bomb = Bomb(Box::into_raw(Box::new(0u8)));
// bomb 离开作用域时,drop 被调用 **一次**
}
- 编译器插入的 drop elaboration 确保 恰好一条路径 走到
drop - 任何提前 move、return、panic,都会被 MIR 生成器 正确插入
drop或drop_in_place
4 深度实验:手写智能指针,验证「双重释放 = 编译期错误」
目标:写一个 最小 Box,故意触发双重释放,观察编译器如何拦截。
4.1 故意错误版本(无法编译)
struct MyBox<T>(*mut T);
impl<T> MyBox<T> {
fn new(value: T) -> Self {
Self(Box::into_raw(Box::new(value)))
}
}
impl<T> Drop for MyBox<T> {
fn drop(&mut self) {
unsafe { Box::from_raw(self.0); } // 正确释放
}
}
fn double_drop() {
let b = MyBox::new(42);
let _c = b; // move
drop(b); // ❌ 编译错误:use of moved value
}
编译器:
error[E0382]: use of moved value: `b`
4.2 允许二次释放的唯一途径:unsafe
fn evil() {
let raw = Box::into_raw(Box::new(42u8));
unsafe {
Box::from_raw(raw);
Box::from_raw(raw); // UB:双重释放
}
}
unsafe 是逃生舱,但所有权系统依旧生效:
在 safe API 边界外,责任回到程序员;
工具链提供 Miri / AddressSanitizer 做 动态补票。
5 FFI 边界:C 裸指针如何继承 Rust 所有权?
5.1 借用守卫(Borrow Guard)模式
#[repr(C)]
pub struct FfiSlice {
data: *const u8,
len: usize,
}
impl FfiSlice {
pub fn new(s: &str) -> Self {
Self { data: s.as_ptr(), len: s.len() }
}
}
// 禁止返回 &'static str,防止悬垂
pub struct Guard<'a> {
_marker: PhantomData<&'a str>,
}
pub fn with_slice<R>(s: &str, f: extern "C" fn(FfiSlice) -> R) -> R {
let slice = FfiSlice::new(s);
let _guard = Guard { _marker: PhantomData };
f(slice)
}
Guard保证 Rust 字符串生命周期 ≥ C 调用区间- C 侧 无法 保留指针越过回调边界,否则 Miri 报错
6 工具链:双重释放的「雷达网」
| 工具 | 用法 | 捕获示例 |
|---|---|---|
| Miri | cargo +nightly miri run | 未初始化读、double free |
| AddressSanitizer | RUSTFLAGS="-Zsanitizer=address" | 运行期检测 |
-Zdrop-tracking | nightly | MIR 级 drop 路径可视化 |
7 极端场景:手动管理内存时的「线性类型」技巧
7.1 标记类型(marker type)
struct Allocation(*mut u8, Layout);
impl Allocation {
fn new(layout: Layout) -> Self {
let ptr = unsafe { std::alloc::alloc(layout) };
if ptr.is_null() { std::alloc::handle_alloc_error(layout); }
Self(ptr, layout)
}
}
impl Drop for Allocation {
fn drop(&mut self) {
unsafe { std::alloc::dealloc(self.0, self.1); }
}
}
7.2 禁止复制 / 克隆
impl !Clone for Allocation {}
impl !Copy for Allocation {}
- 编译期 保证
Allocation只能 move,无法 clone → 杜绝双重释放 - 与 C++ 的
std::unique_ptr等价,但 零运行时开销
8 终极挑战:故意写出一个「可编译的双重释放」
结论:在 safe Rust 中,不可能;
在 unsafe Rust 中,需要显式 复制裸指针。
fn really_evil() {
let b = Box::new(42u8);
let raw1 = Box::into_raw(b);
let raw2 = raw1; // 复制裸指针,合法
unsafe {
Box::from_raw(raw1);
Box::from_raw(raw2); // UB
}
}
- 即使如此,工具链依旧能捕获:
Miri 输出:
error: Undefined Behavior: double free detected
9 工程 Checklist:如何在团队中彻底杜绝双重释放
- 禁止裸指针逃逸 safe API 边界
- 所有 FFI 返回指针必须配合借用守卫 RAII
- CI 强制跑 Miri + AddressSanitizer
- 对 unsafe 代码块 100 % code review + miri test
10 结语:双重释放从 bug 变成「编译期不可能事件」
Rust 的所有权系统把「何时释放」从 运行时策略 升级为 编译期定理。
一旦你把值交给 safe Rust,编译器就替你证明:
• 它一定会被释放
• 且只会被释放一次
剩下的 unsafe 角落,则交给 Miri、ASan、review 三道闸门。
从此,双重释放不再是程序员的噩梦,而是编译器的日常作业。

更多推荐


所有评论(0)