前置知识: Rust

Unsafe Rust

11 min高级

unsafe 边界:裸指针、unsafe trait 与安全抽象的封装纪律。

前十九篇的所有保障,都来自”Safe Rust”:编译器证明每个引用合法、每次释放恰有一次。但总有一些场景编译器无法证明却真实安全——双端切片、手写容器、调用 C 库。unsafe 不是”关闭安全检查的开关”,而是一条信任声明:“这里的正确性编译器验证不了,由我人工担保。” 它把 Rust 与 C/C++ 区分开的地方在于:unsafe 被限制在显式的、可搜索的代码块里,配合封装纪律,风险面积可以被压缩到接近于零。本篇讲清 unsafe 的能力边界、裸指针与别名规则、安全契约与封装惯例,并预览 FFI。

前置知识

学习目标

  1. 明确 unsafe 解锁的五种能力清单及其风险边界。
  2. 掌握裸指针 *const/*mut 的用法,理解严格别名规则与未定义行为。
  3. 理解 unsafe fn 的”安全契约”含义与调用方责任。
  4. 学会封装安全抽象:最小化 unsafe 范围、论证不变量、公共 API 保持安全。
  5. 初识 FFI:extern "C" 与 #[no_mangle] 的衔接方式。

1. unsafe 的定位:五种能力与不变的守护

先明确 unsafe 不会做什么:它不关闭借用检查、不关闭类型检查、不关闭所有权规则。它只额外解锁五种编译器无法自动验证的操作:

  1. 解引用裸指针(*const T / *mut T)。
  2. 调用 unsafe 函数或方法。
  3. 访问或修改可变静态变量(static mut)。
  4. 实现 unsafe trait。
  5. 访问 union 的字段。

关于第 3 条,2024 edition 有一个重要的收紧:对 static mut 取引用被默认拒绝(static_mut_refs lint 在该 edition 下为 deny)——引用的存活期不可控,正是静态可变数据最危险的用法。替代方案是用 &raw const/&raw mut(或 addr_of_mut!)直接取裸指针,或干脆改用 AtomicU32、Mutex<T> 等安全抽象。新版编译器把这条危险路径从”写法自由”变成了”写法违规”,工具链在替你把关。

使用方式是 unsafe { ... } 块或 unsafe fn。心智模型是”双重世界”:Safe Rust 里编译器担保安全;unsafe 块里程序员向编译器担保——担保的内容是若干不变量(invariant),比如”这个指针有效且未被别名修改”。写 unsafe 的核心技能不是记 API,而是识别并论证不变量。纪律要求:每个 unsafe 块都要写 // SAFETY: 注释说明为何安全,说不出理由就不该写这个块。

什么情况下值得动用 unsafe?现实中的正当理由集中在四类:调用操作系统接口或 C 库(编译器看不见对面的世界);实现性能关键的容器与分配器(Vec 的扩容、String 的缓冲区管理都在内部使用 unsafe);使用 CPU 特殊指令(SIMD 内建函数、原子操作);以及封装标准库尚未覆盖的底层能力。反过来说,下列情况都不是正当理由:“借用检查器报错不想改”(回去重设计数据归属)、“听说 unsafe 更快”(先做基准测试,Safe Rust 与 unsafe 的速度差通常远小于想象)、“别人这么写我也写”。一个工程数据供参考:成熟的 Rust 项目中 unsafe 代码占比通常在个位数百分比,且集中在少数经过专项测试的模块里——这个比例本身就是设计质量的信号。

2. 裸指针与别名规则:危险的根源

裸指针是去掉全部保障的指针:可以为空、可以悬垂、不参与生命周期检查、*const 与 *mut 可以同时存在。创建裸指针本身是安全操作(只是记录地址),解引用才需要 unsafe:

fn main() {
    let mut songs = vec![
        String::from("开场曲"),
        String::from("主打歌"),
    ];

    // 创建裸指针是安全操作,解引用才进入 unsafe 边界
    let first: *const String = &songs[0];

    unsafe {
        // SAFETY:songs 未被修改,first 指向的元素仍有效
        println!("第一首:{}", *first);
    }

    // 通过裸指针原地修改:借用检查器看不到这条路径
    let p = &mut songs[0] as *mut String;
    unsafe {
        // SAFETY:p 指向 songs[0],且此刻没有其他指针访问同一元素
        (*p).push_str("(序曲版)");
    }
    println!("修改后:{}", songs[0]);
}

解读:真正的危险来自别名规则(aliasing rule)的破坏——Safe Rust 的铁律是”同一时刻至多一个 &mut 或若干个并存的 &”,而编译器优化正建立在这条假设上:它会假设没有别的指针在改数据,从而重排读写、缓存寄存器。裸指针绕过检查,一旦你在 unsafe 里制造了”两个可变指针指向同一内存且至少一个在写”,就构成未定义行为(UB):程序不会立刻报错,而是优化器自由发挥后的任何结果——错误数据、崩溃、甚至看似正常。上面代码为什么安全?因为 first 的解引用发生在修改之前、修改后不再使用 first,时间轴上无别名冲突。这就是 unsafe 编程的全部功课:在时间轴与空间上守住”无冲突别名”。

裸指针家族还有几个常用操作值得认识:ptr::read 与 ptr::write 按位读写字节(不执行析构,是手写容器搬移数据的工具);ptr.offset/add 做指针运算,但要求目标落在同一块分配内且对齐正确;NonNull<T> 是”永不为空”的裸指针包装,配合 PhantomData 可以手写自定义智能指针。标准库手写容器的典型套路——分配 Vec 容量、拿裸指针、unsafe 写入后更新 len——正是围绕这些原语展开的。对初学者更重要的是规则而非 API:指针运算只在同一分配内合法、所有访问必须对齐、读到的内存必须已被初始化,违反任何一条都是 UB。

3. unsafe fn 与安全契约:把前置条件写进签名

unsafe fn 声明”调用本函数需要满足若干前置条件”,调用方必须写 unsafe 块,等于在调用点上签下责任书。函数文档必须写明契约——违反即 UB 的边界条件:

// unsafe fn:把"前置条件"写进签名,调用方必须用 unsafe 块包裹
/// # Safety
///
/// `idx` 必须小于 `list` 的长度,且 `list[idx]` 指向的字符串
/// 在返回的引用存活期间保持有效(数据未被释放或移动)。
unsafe fn take_unchecked(list: &[*const str], idx: usize) -> &str {
    &*list[idx] // 解引用裸指针:安全性由调用方契约保证
}

fn main() {
    let a = String::from("星海之约");
    let list: [*const str; 1] = [a.as_str() as *const str];

    unsafe {
        // SAFETY:idx = 0 < list.len();list[0] 指向仍存活的 a
        println!("{}", take_unchecked(&list, 0));
    }
}

解读:take_unchecked 没有做任何边界检查——它把”idx 在界内""数据仍存活”两项检查转移给了调用方,换来零开销。这与 Vec::get_unchecked 的设计一致:标准库同时提供安全版 get(返回 Option,付出边界检查代价)与 unsafe 版 get_unchecked(快,但契约自负)。设计准则:unsafe fn 的契约要少而清晰,能用 assert 在函数内部廉价验证的条件就别留给调用方——每减少一条契约,误用的可能性就降一档。

另一个与 unsafe fn 直接相关的 edition 变化:2024 edition 起启用 unsafe_op_in_unsafe_fn lint(默认 warn)——unsafe fn 体内执行不安全操作也必须写显式的 unsafe { ... } 块,不能再”整个函数天然是 unsafe 上下文”。这让函数体内哪些语句真正依赖担保一目了然,与”unsafe 块最小化”的纪律一脉相承;新代码建议直接遵守,老代码迁移时会收到编译器逐条提示。

4. 封装安全抽象:把 unsafe 关进最小笼子

unsafe 的工程价值在于封装:内部使用 unsafe 实现性能或能力,对外暴露经过论证的安全 API。经典案例是亲手实现”双可变借用”——借用检查器不允许从同一个切片取两个 &mut,但两个不重叠的区间在运行期其实完全安全,标准库 split_at_mut 正是靠 unsafe 实现的:

// 手动实现"相邻两个元素的可变借用":
// 借用检查器不允许同源两个 &mut,但不重叠区间运行期是安全的
fn split_two(v: &mut [i32]) -> (&mut i32, &mut i32) {
    assert!(v.len() >= 2, "至少需要两个元素"); // 不变量前置检查
    let ptr = v.as_mut_ptr(); // 首元素的可变裸指针
    unsafe {
        // SAFETY:两个指针指向不同下标,区间不重叠;
        // v 独占该切片的可变借用,不存在第三方别名。
        (&mut *ptr, &mut *ptr.add(1))
    }
}

fn main() {
    let mut votes = [80, 95];
    let (a, b) = split_two(&mut votes);
    *a += 1;
    *b += 5;
    println!("得票:{a} {b}"); // 得票:81 100
}

解读:这就是”安全抽象”的完整配方——(1) 公共签名安全(调用方不需要 unsafe);(2) 不变量用 assert 前置检查(长度不足直接 panic 而非 UB);(3) unsafe 块紧贴最小操作,附 // SAFETY: 论证;(4) 文档写明 panic 条件。注意对比:如果 ptr.add(1) 越界(长度只有 1),就是未定义行为——assert 把这个 case 提前拦下,unsafe 块只承担”已被 assert 保证”的部分。另一个纪律是优先复用:标准库与成熟 crate(bytes、smallvec)已经封装了大量经过审计的 unsafe,先搜生态再动手,自己写的 unsafe 永远是审计负担。

把”安全性论证”制度化,还有一套社区沉淀的检查清单。提交 unsafe 代码前自问四个问题:其一,所有不变量是否都被 assert 或类型系统在 unsafe 之前强制(而不是只写在注释里)?其二,unsafe 块是否最小——把它单独抽出来后,剩下的代码是否仍然能通过 Safe Rust 的编译?其三,这个抽象在极端输入(空集合、超长输入、并发调用)下是否仍满足契约?其四,是否配置了对应的检测工具链?工具层面,Miri 是 Rust 官方的解释执行器,能捕获越界、未初始化读取、别名违规等一大类 UB;cargo fuzz 基于模糊测试探索输入空间;AddressSanitizer(-Z sanitizer=address)补充检测内存错误。三者覆盖面不同,重要模块建议至少接入 Miri 与 fuzzing 各一条 CI 流水线。

5. 与 FFI 的衔接(预览)

FFI(外部函数接口)是 unsafe 最刚性的应用场景:跨语言边界时,编译器对另一侧代码一无所知,所有调用天然处于 unsafe。两个方向——引入外部 C 函数用 extern "C" 块;把 Rust 函数导出给 C 用 #[no_mangle](关闭名称修饰,让符号名可被 C 链接器找到):

// 导出 Rust 函数给 C 调用:no_mangle 保留原始符号名,extern "C" 用 C 调用约定
#[no_mangle]
pub extern "C" fn ticket_price(base: u32, vip: i32) -> u32 {
    // 注意:C 语言没有 bool,FFI 边界通常用 i32 承载布尔值
    // 函数体是纯安全代码:C 侧调用它无需任何 unsafe 前提
    if vip != 0 { base * 2 } else { base }
}

fn main() {
    // 同一 crate 内按普通函数调用(真实 FFI 场景中由 C 侧调用)
    println!("VIP 票价:{}", ticket_price(180, 1));
}

解读:FFI 的完整版还包括 unsafe extern 块声明外部函数、#[repr(C)] 让结构体布局与 C 对齐、CString 处理以空字符结尾的字符串、Box::into_raw/from_raw 手工管理跨边界内存。本篇只立框架,深入留待 FFI 专题。这里最重要的是边界纪律:导出的 Rust API 应该是安全函数(如上例),把不安全契约消化在内部;引入的外部 C 函数则一律视为 unsafe 调用,必须审查其文档契约。

易错点与最佳实践

  1. 以为 unsafe 关闭了借用检查。错误:在 unsafe 块里期待 &mut 与 & 并存而编译通过就”安全了”——借用检查仍在,但绕过它制造的别名会引发 UB。修正:unsafe 只用于五种已知操作,别名规则在心智层面依旧遵守。
  2. unsafe 块一大片。错误:整个函数体套一个 unsafe {},事后无法判断哪一行真正依赖担保。修正:unsafe 最小化,只包住具体那一句;每块配 // SAFETY: 注释论证不变量。
  3. 解引用悬垂或越界指针。错误:持有 Vec 扩容前的裸指针继续使用。修正:指针只在”数据稳定”的窗口内使用;能用引用/迭代器/split_at_mut 等安全抽象就绝不手写指针运算。
  4. 把 unsafe 直接透传给公共 API。错误:pub fn f() 内部只做一行 unsafe 却不做任何检查,契约全甩给调用方。修正:公共 API 保持安全——assert 前置检查 + 文档化 panic 条件,契约在内部消化。
  5. 不验证就上线 unsafe 代码。最佳实践:用 Miri(rustup +nightly component add miri 后 cargo +nightly miri run/test)动态检测 UB;unsafe 代码的测试密度应高于普通代码,必要时引入 fuzzing。

本篇小结

  1. unsafe 只解锁五种能力(解引用裸指针、调用 unsafe fn、可变静态变量、unsafe trait、union 字段),借用检查与所有权规则依旧生效。
  2. 裸指针的风险本质是别名规则:制造可变别名冲突即未定义行为,后果是优化器自由发挥后的任意结果。
  3. unsafe fn 是”前置条件进签名”:契约要少而清晰,能在内部廉价验证的就别甩给调用方。
  4. 安全抽象四件套:安全签名、assert 前置检查、最小 unsafe 块加 SAFETY 注释、文档写明契约;优先复用生态里已审计的封装。
  5. FFI 中跨语言边界一律视为 unsafe;导出侧尽量提供纯安全函数,引入侧严格审查外部契约。

一句话记忆:unsafe 是”担保书”不是”后门”——解锁五种操作、责任回到程序员;纪律只有三条:unsafe 最小化、SAFETY 注释论证不变量、公共 API 保持安全,上线前让 Miri 替你查一遍。

动手实践

  1. 用裸指针实现一个固定容量的环形队列(push/pop),内部用 unsafe 操作连续内存。思路:Vec 分配容量 + as_mut_ptr 取首指针,head/tail 下标控制读写位置;给每个 unsafe 块写 SAFETY 注释论证”下标不越界、无并发访问”。
  2. 给第 3 节的 take_unchecked 补全契约:先实现安全版 try_take(返回 Option<&str>),再对比 unsafe 版省去了哪些检查;然后故意违反契约(越界 idx)用 Miri 观察检测报告。思路:安全版内部做边界检查,unsafe 版把检查成本转嫁调用方。
  3. 把第 4 节的 split_two 扩展为 split_three(返回三个互不重叠区间),写出完整的不变量论证;再思考若区间可能重叠会发生什么,并设计一个运行期断言来防护。思路:区间不重叠等价于 start < end 且区间按序排列,可用 assert 校验后再进 unsafe 块。