引言

Rust 以其强大的编译期安全保证而闻名,它通过所有权、借用检查和类型系统在编译时消除了整类的内存错误。然而,Rust 也是一门系统级编程语言,它必须能够执行一些编译器无法静态验证的操作,例如与 C 语言交互(FFI)、操作裸指针、实现底层数据结构。

unsafe 关键字就是 Rust 提供的通往这些底层能力的“逃生舱口”。它并非“不安全”的代名词,而是“未经编译器检查”(unchecked)的标记。unsafe 是 Rust 设计哲学中的一个深思熟虑的妥协:它允许开发者在必要时承担起内存安全的责任,以换取极致的性能和控制力。

unsafe 是一份契约:你(开发者)告诉编译器:“请暂停你的安全检查,我将亲自维护你所依赖的不变量(invariants),并对这块代码的内存安全负全责。”因此,如何安全、规范地使用 unsafe,是衡量一位 Rust 专家的重要标准。

unsafe 的本质:五种“超能力”

unsafe 块并不会关闭 Rust 的所有检查(例如借用检查器依然在工作),它只是解锁了五种编译器无法静态验证安全性的“超能力”:

  1. 解引用裸指针*const T*mut T

  2. 调用 unsafe 函数(包括外部的 C 函数 FFI)

  3. 访问或修改可变的静态变量static mut

  4. 实现 unsafe trait

  5. 访问 union 的字段(除了 MaybeUninit

这些操作的共同点是,它们都可能违反 Rust 的核心安全保证(如内存安全、数据竞争安全)。我们的目标就是在使用这些能力时,确保代码的逻辑正确性,手动维护这些保证。

核心准则一:最小化 unsafe 作用域

unsafe 代码是项目中的“债务”,它需要最高级别的审查和关注。因此,最基本、最重要的准则就是:unsafe 块限制在绝对必要的最小范围。

专业思考:
unsafe 块应该像一个微小的“黑盒”,其内部的危险操作被严格控制。不要因为函数中有一行需要 unsafe,就将整个函数标记为 unsafe(除非是 unsafe fn 的公共 API)或将整个函数体包裹在 unsafe 块中。

unsafe 块越小,审查就越容易。审查者的目标是验证“这个 unsafe 块是否安全”,如果这个块只有 3 行代码,验证成本就极低;如果它有 300 行,验证成本将高到几乎不可能。

核心准则二:将 unsafe 封装于安全抽象

unsafe 的存在不是为了让应用程序开发者随意破坏安全,而是为了让库开发者能够构建出新的、安全的抽象。这是 Rust 生态系统得以安全扩展的基石。

专业思考:
标准库中的 Vec<T>StringMutex<T> 内部都大量使用了 unsafe 代码。Vec 内部使用裸指针 *mut T 进行内存分配和管理,但它对外暴露的 API(如 push, pop, get)是 100% 安全的。

这种模式的核心在于“不变量”Vec 结构体维护着几个核心不变量:

  1. ptr 指针必须有效、非空,且指向 capacityT 大小的已分配内存。

  2. len 必须始终小于等于 capacity (len <= capacity)。

  3. ptr 所指向的前 len 个元素必须是已初始化的。

Vec 的所有安全方法(safe methods)的设计目标就是维护这些不变量。而其**unsafe 代码**(如 set_len)则依赖这些不变量为前提。只要这个封装是严密的——即外部的安全代码无论如何调用都无法破坏这些不变量——那么这个抽象就是安全的。

实践:
当你必须使用 unsafe 时,你的第一反应应该是:“我能否创建一个新的结构体或模块,将 unsafe 逻辑作为内部实现细节隐藏起来,并对外提供一个 100% 安全的 API?”

核心准则三:详尽的 // SAFETY: 注释

如果说 unsafe 是一份契约,那么 // SAFETY: 注释就是这份契约的条款。在 Rust 社区(尤其是标准库和 tokioserde 等顶级库)中,这已成为一项严格的实践标准。

专业思考:
任何一个 unsafe 块都必须伴随一个 // SAFETY: 注释,这个注释必须清晰地解释:

  1. 为什么这里必须使用 unsafe(例如:需要调用 FFI libc::malloc

  2. unsafe 做了什么?(例如:解引用裸指针 ptr

  3. 为什么这个操作是安全的?(这是最重要的部分)

“为什么是安全的”需要你明确列出该操作所依赖的不变量,并论证为什么在当前上下文中这些不变量必然成立

实践:
一个好的 // SAFETY:FETY: 注释示例:

// SAFETY:
// 1. `ptr` 是通过 `Vec::with_capacity` 创建的,保证了非空且对齐。
// 2. `index` 在此之前已被 `if index < self.len` 检查过,
//    保证了 `self.ptr.add(index)` 位于已分配且已初始化的
//    内存边界(`[0, len)`)内。
// 3. 因此,解引用 `self.ptr.add(index)` 是安全的。
value = *self.ptr.add(index);

没有 // SAFETY: 注释的 unsafe 代码应在 Code Review 中被视为严重缺陷并立即拒绝。

核心准则四:使用 miri 检测未定义行为 (UB)

unsafe 代码最危险的敌人是**未定义行为(defined Behavior, UB)**。UB 是指代码执行了 C 或 LLVM 规范中未定义的操作,例如越界内存访问、使用未初始化内存、数据竞争等。UB 的可怕之处在于它可能“看起来能工作”,直到某次编译器升级、换个平台或遇到特定负载时才以灾难性的方式崩溃。

专业思考:
仅仅通过 cargo test 无法捕获所有 UB。unsafe 代码必须使用更专业的工具进行验证。

实践:
miri 是 Rust 的官方 UB 检测器。它是一个实验性的 Rust 解释器,能够在执行测试时检测出大多数类型的 UB。

# 1. 安装 miri
rustup component add miri

# 2. 运行测试
cargo miri test

在任何包含 unsafe 代码的库中,使用 cargo miri test 应该成为 CI/CD 流程的强制步骤。它能捕获到许多人眼审查难以发现的错误,例如指针未对齐、读写了未初始化内存、违反了 std::ptr 的 API 约定等。

核心准则五:优先使用安全的替代方案

最好的 unsafe 代码就是没有 unsafe 代码。在伸手去拿 unsafe 这个“手术刀”之前,请务必确认是否真的需要它。

专业思考:
Rust 生态系统在不断进化,许多过去需要 unsafe 的模式现在已经有了安全的替代方案。

实践:

  • **需要未初始内存?**

    • 旧方法 (unsafe): mem::uninitialized() (现已废弃,极度危险)

    • 新方法 (safe): MaybeUninit<T>MaybeUninit 是一个类型安全的包装器,它允许你持有未初始化的数据,并通过安全的 API(如 as_mut_ptrassume_init)在准备好后才将其转为 T

  • 需要内部可变性?

    • unsafe 方式: static mut 或裸指针转换。

    • 安全方式: Cell<T>, RefCell<T> (单线程), Mutex<T>, `RwLock<T>多线程)。

  • 需要指针计算?

    • 优先使用切片(slices)和迭代器。`slice::getmut远比ptr.add(i)` 安全。

当你认为你需要 unsafe 时,请先查阅 std::ptrstd::memstd::cell 的文档,以及《The Rustonomicon》(Rust 异闻录),确认没有更高级、更安全的方式可以实现你的目标。

总结

unsafe 是 Rust 赋予系统程序员的强大工具,它代表了对开发者能力的信任。但这种信任是建立在严格的纪律之上的。安全使用 unsafe 是一门艺术,更是一门严谨的工程科学。

通过最小化作用域封装于安全抽象编写详尽的 // SAFETY: 注释、**使用 `miri格验证**,并始终寻求安全替代方案,我们才能在享受 unsafe 带来的性能与控制力的同时,不违背 Rust 的核心承诺——安全与可靠。

记住,unsafe 代码的审查标准必须比安全代码高一个数量级。你不仅要证明它能工作,更要证明它在任何情况下都不可能出错。⚖️


Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐