阶段三 · 元编程与底层

unsafe 与内存底层

一句话总结

unsafe 不是"关闭安全检查",而是"向编译器担保这段代码满足它无法自动验证的前提".
它只解锁五种额外能力,借用检查,类型检查在 unsafe 块里照常生效.正确用法是:把不安全操作封装进一个对外安全的接口.

前置回顾

前面八篇从所有权,类型系统,迭代器,闭包一路讲到宏,构建了 Rust 的安全抽象体系.本篇来到这个体系的"地基层":当安全抽象无法满足需求时,unsafe 是怎么工作的,以及如何正确使用它.

先破除一个误解

很多人以为 unsafe 像一个"作弊开关",一打开整段代码就没有任何检查了.这是彻底的误解.

unsafe 不会关闭借用检查

unsafe 块里,所有权,借用规则,生命周期,类型检查全部照常运作.unsafe 唯一的作用,是额外解锁五种平时禁止的操作.

它表达的是一句承诺:"编译器,这五种操作的安全性无法自动验证,但已经人工确认过它满足要求." 责任从编译器转移到了编写者身上,仅此而已.

unsafe 像实验室里"高压电区"的门禁卡.刷卡进去,实验室的其他安全规程(防火,通风)一条不少.
它只是额外允许接触平时被锁住的高压设备.出了事,因为是主动刷卡进去操作的,责任在操作者而非门禁系统.

unsafe 解锁的五种能力

unsafe 能且仅能做这五件事.记住这张清单,就划定了它的全部边界.

能力 说明
解引用裸指针 读写 *const T / *mut T 指向的内存
调用 unsafe 函数 包括 FFI 中的外部 C 函数
访问/修改可变静态变量 static mut,存在数据竞争风险
实现 unsafe 特征 Send,Sync
访问 union 字段 主要用于与 C 互操作

日常最常见的是第一种

五种能力里,应用层代码绝大多数时候只会碰到第一种--解引用裸指针.其余几种集中出现在 FFI(与 C 交互),底层并发原语,嵌入式等专门领域.所以本篇重点讲裸指针.

裸指针:脱去保护的引用

Rust 有两种裸指针:*const T(只读)和 *mut T(可变).它们和普通引用 &T 的区别,正是 Rust 安全保证的来源.

特性 引用 &T / &mut T 裸指针 *const T / *mut T
保证非空 否,可能为空
保证指向有效内存 否,可能悬垂
受借用规则约束 是(同一时刻读写互斥) 否,可同时存在多个可变
解引用需要 unsafe
let mut num = 5;

// 创建裸指针本身是安全的,不需要 unsafe
let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;

// 但解引用裸指针必须在 unsafe 块里
unsafe {
println!("r1 指向: {}", *r1);
*r2 = 10; // 通过裸指针修改
}
println!("num = {num}"); // 10

创建裸指针安全,解引用才危险

注意上面的细节:把引用转成裸指针,传递裸指针,都不需要 unsafe;只有解引用(读写它指向的内存)才需要.

原因很直观--光是持有一个地址不会出问题,真正动手去访问那块内存时,才可能踩到空指针或已释放的内存.

内存布局:Rust 如何摆放数据

unsafe 代码免不了和内存布局打交道.三个核心概念:

  • 大小(size):类型占多少字节,std::mem::size_of::<T>()
  • 对齐(alignment):类型的起始地址必须是某个数的倍数,std::mem::align_of::<T>()
  • 填充(padding):为满足对齐,字段间编译器插入的空白字节
struct Foo {
a: u8, // 1 字节
b: u32, // 4 字节,要求 4 字节对齐
c: u8, // 1 字节
}
// size_of::<Foo>() 通常是 12,而非 1+4+1=6
// 因为编译器会插入 padding 让 b 落在 4 的倍数地址上

默认布局是不确定的

Rust 默认不保证结构体字段在内存里的顺序--编译器有权重排字段以减少 padding.所以不能假设字段的内存排列.

需要确定布局时(如与 C 交互,做底层序列化),必须显式标注 #[repr(C)](按 C 的规则排布)或 #[repr(packed)](取消填充).这是 FFI 场景的必备知识.

核心原则:把 unsafe 关进笼子

Rust 的整个安全大厦,其实建立在一个关键模式上:unsafe 实现底层操作,再用一个对外安全的接口把它包起来,并在内部确保所有不安全前提都被满足.

标准库本身就是最好的范例.Vec,String,Rc 内部全是裸指针和 unsafe,但它们暴露出的 API 完全安全.看一个简化的"安全封装"例子--标准库 split_at_mut 的思路:

use std::slice;

fn split_at_mut(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = slice.len();
let ptr = slice.as_mut_ptr();
assert!(mid <= len); // 在进入 unsafe 前,先校验前提

unsafe {
// 借用检查器无法理解"这两段不重叠",但 assert 已保证了
(
slice::from_raw_parts_mut(ptr, mid),
slice::from_raw_parts_mut(ptr.add(mid), len - mid),
)
}
}
// 调用方完全不需要写 unsafe,这个函数对外是安全的

安全抽象的两个要点

上例体现了安全封装的精髓:
其一,进入 unsafe 块前用 assert 等手段把前提条件检查到位(这里是 mid <= len).
其二,函数签名对外不带 unsafe,调用方无需也无从破坏其安全性.

把不安全性收敛在尽可能小的范围内,外界只能通过受控的安全接口接触它--这就是"把 unsafe 关进笼子".

未定义行为:踩中就全盘失效

滥用 unsafe 最严重的后果是触发未定义行为(Undefined Behavior, UB).一旦发生 UB,编译器对程序的所有推理前提都崩塌了,程序可能崩溃,可能给出错误结果,甚至看似"正常运行"却埋着隐患.

常见的 UB 来源:

  • 解引用空指针或已释放的内存(悬垂指针)
  • 制造两个同时有效的 &mut 指向同一数据(违反别名规则)
  • 读取未初始化的内存
  • 构造非法的值(如让 bool 取 0,1 以外的字节)

unsafe 的责任全在编写者

编译器对 unsafe 块内的这些前提不再检查,也无法检查.一旦违反,后果不是"这一行出错",而是整个程序的行为变得不可预测--这是 UB 最可怕的地方.

因此:能不用 unsafe 就不用;必须用时,把它压缩到最小范围,写清注释说明担保了哪些前提,并尽量用现成的安全抽象(标准库,成熟 crate)而非自己造轮子.可以用 Miri 等工具检测潜在的 UB.

快速回顾

  • unsafe 不关闭检查,借用与类型检查照常,它只解锁五种额外能力
  • 五种能力核心是解引用裸指针,其余多见于 FFI 与底层并发
  • 裸指针无非空,有效,借用规则保证;创建安全,解引用才需 unsafe
  • 内存布局默认不确定,需确定布局用 #[repr(C)]
  • 核心模式:用安全接口封装 unsafe,进入前校验前提,收敛不安全范围
  • 未定义行为一旦触发,整个程序行为不可预测,责任全在编写者

动手练习

  1. 裸指针实验:创建一个 i32,取它的 *const i32*mut i32,在 unsafe 块里读取并修改它,确认块外不加 unsafe 时解引用会编译失败.
  2. 观察 padding:定义 struct Foo { a: u8, b: u32, c: u8 },用 size_of 打印其大小,再加 #[repr(packed)] 对比,理解 padding 的作用.
  3. 阅读标准库源码:阅读标准库 Vec::pushslice::split_at_mut 的源码,找出其中的 unsafe 块,分析它如何在进入前保证安全前提.