前十篇里,编译器是全能证明者:内存安全、数据竞争、生命周期,全部机器可证。但世界上存在一类它证明不了的真相——硬件寄存器的地址约定、C 库文档里的承诺、一个自定义数据结构内部“这两个区域绝不重叠”的手工推理。对这类真相,Rust 的选择不是装作它们不存在,而是开一扇明码标价的门:unsafe。这扇门被误解得极深——它不“关闭安全检查”,它说的是:这五件事的证明,从现在起由你手写。 本篇拆开这扇门: unsafe 到底是什么、恰好五项特权、边界怎么画,以及为什么高手写得越来越少。
最反直觉的事实:unsafe 不关闭借用检查器
先立本篇的承重墙。下面这段代码躺在 unsafe 块里——而编译器照常见义勇为:
unsafe {
let s = String::from("hi");
let r = &s;
drop(s); // error[E0505]: cannot move out of `s` because it is borrowed
println!("{r}");
}
所有权、移动、借用规则、生命周期、Drop——前十篇学过的每一条检查,在 unsafe 块里原样执行,一条不少。unsafe 改变的只是“额外允许五件事”。把它想成“关闭安全模式”是最流行的错误心理模型;正确的模型是:编译器的证明覆盖不了的地方,允许你用人工证明补上——但机器证明的部分,一寸都不退让。
恰好五项特权
- 解引用裸指针。
*const T/*mut T是摘掉合同的引用:可以为空、可以悬垂、可以自由别名、没有生命周期追踪。创建裸指针是安全的——危险只在解引用那一刻发生,所以也只有那一刻需要unsafe:
let mut n = 5u32;
let r = &mut n as *mut u32; // 安全:只是造了一个地址
unsafe {
*r += 1; // unsafe:此刻起,有效性由你担保
}
println!("{n}"); // 6
- 调用 unsafe 函数——包括 FFI。 调用 C 函数是最常见的场景。
extern块声明签名(2024 edition 要求unsafe extern,因为签名本身就是你替编译器做的承诺),调用点再用 unsafe 块包住:
unsafe extern "C" { fn abs(input: i32) -> i32; }
let v = unsafe { abs(-42) }; // 编译器查不了 C 的功课,只能信你
- 实现 unsafe trait。 第 9 篇的
Send/Sync就是 unsafe trait——手动实现等于签字画押“这个类型跨线程是安全的,理由在我脑子里”。这是五项里最罕见的一项,多数类型靠 auto derive 永远不需要。 - 访问 union 的字段。 union 主要为 C 互操作存在;编译器不知道哪个字段是“当前有效”的那个,所以读取归你负责。
- 读写可变静态量。 全局可变状态是多线程的永恒雷区;2024 edition 进一步收紧,连取
static mut的引用都发出警告。现代写法几乎总是Mutex/OnceLock等同步原语,这条特权越来越多是历史遗迹。
安全抽象:把 unsafe 关在门内
孤立地看,五项特权每一项都能制造未定义行为(UB——那个让“程序可以做任何事,包括看起来正常工作”的深渊)。Rust 的答案不是禁止,而是封装:把小块 unsafe 关进安全签名的函数里,让调用者永远碰不到它。这就是“安全抽象”(safe abstraction),也是整个标准库的建造方式——Vec、String、第 9 篇的 split_at_mut,内部都有 unsafe 核心,外部都是滴水不漏的安全门。
亲手画一次这条边界。实现“把切片拆成首元素和剩余部分”——两个 &mut 指向同一切片的不同区域,借用检查器证不出“不重叠”,但你能:
fn first_and_rest_mut<T>(slice: &mut [T]) -> Option<(&mut T, &mut [T])> {
if slice.is_empty() { return None; }
let ptr = slice.as_mut_ptr();
let len = slice.len();
unsafe {
// SAFETY: ptr 指向切片首元素;[0..1] 与 [1..len] 绝不重叠,
// 且两个借用都源自同一个 &mut [T],生命周期一致
Some((&mut *ptr, std::slice::from_raw_parts_mut(ptr.add(1), len - 1)))
}
}
let mut v = vec![1, 2, 3];
let (first, rest) = first_and_rest_mut(&mut v).unwrap();
*first += 10;
rest[0] += 100;
println!("{v:?}"); // [11, 102, 3]
三个要点构成这门手艺:
- 门内最小化。 unsafe 块只包住必需的三两行,前后都是安全代码——审计面就是那几行。
// SAFETY:注释是强制惯例。 每条 unsafe 上方写清“为什么这段人工证明成立”。它不是形式主义:写不出这段注释,通常意味着证明其实不成立。- 签名必须滴水不漏。 安全抽象的全部含义是:无论调用者传什么合法输入,UB 都不可能发生。 空切片返回
None就是这个义务的一部分——它是签名对世界的承诺,不是实现细节。
什么时候该写,什么时候该跑
该写的信号很清晰:FFI 边界、硬件与内核接口、性能最后一公里且 profiler 已经作证安全写法是瓶颈。该跑的信号更简单:问题已被生态解决——FFI 有 libc 和 bindgen,字节操作有 bytes,并发有 tokio,解析有 nom。成熟生态的含义就是:有人已经替你画好了那些边界,并用百万用户做了审计。
写了 unsafe 之后,验证工具链的最后一环是 Miri——Rust 的 UB 解释器,能在测试时抓出悬垂指针、越界访问、数据竞争等未定义行为。unsafe 代码不配 Miri 跑一遍测试,就像跳伞不检查伞包。
练习,以及第 12 篇
- 亲手验证承重墙:把本篇 E0505 的例子敲出来,在 unsafe 块内外各编译一次。然后给团队新人(或三个月后的你)写一句注释解释它——这是 unsafe 最该被记住的事实。
- 给
first_and_rest_mut补三个测试:空切片、单元素、多元素。然后安装 Miri(rustup +nightly component add miri)跑一遍——体验一次“UB 被机器抓住”的感觉。 - 用
unsafe extern再调一个 C 库函数(比如strlen),并写一个安全包装:fn c_strlen(s: &CStr) -> usize。注意包装如何把一个“信任文档”的调用变成“信任类型”的调用——这就是安全抽象在 FFI 边界的样子。
第 12 篇换个完全不同的武器:宏。macro_rules! 声明宏的卫生性(hygiene)为什么重要、vec! 和 println! 的真身、过程宏三兄弟(derive、attribute、function-like)各自解决什么,以及“什么时候该写宏”的严格判据——它是本系列里唯一一个“先学什么时候不用它”的特性。