Unsafe:把不安全性封装在最小边界
课程概览 · 第 19 章
unsafe 不会关闭类型、借用或生命周期检查。它允许执行需要额外安全证明的操作,保证的责任因此落到实现者或调用者身上。本章把证明写进最小代码边界,同时区分真实文件写出、FFI 协议演示与不能普通执行的 UB 示意。
前置知识与学习目标
需要第 06 章的借用、第 10 章的 trait 与第 05 章的可失败 I/O。学完本章应能:
- 区分 unsafe 操作、unsafe fn 调用合同与 unsafe trait 实现合同。
- 为小型安全包装器写出具体 SAFETY 依据。
- 分析裸指针、FFI 的有效性、别名、ABI 与释放责任。
- 用显式 Result 返回写出错误,并解释测试和 Miri 的证据边界。
unsafe 能做什么,不能放宽什么
unsafe 允许解引用裸指针、调用 unsafe 函数、访问可变静态变量及 union 字段等操作;实现 unsafe trait 也需要显式 unsafe impl。它不会让悬垂引用、数据竞争、未初始化读取或无效位模式成为合法行为。&T/&mut T 仍须满足有效性、对齐、寿命和别名要求;&T 的内部可变性例外通过 UnsafeCell 及其安全封装建立,不能直接把共享引用强转成可写指针就修改。
未定义行为(UB)不是一种可恢复的错误返回,也不保证以 panic 或崩溃表现。某些构建可能插入额外诊断,但不能依赖它捕获违规;“编译通过、打印了正确值”都不是安全证明。默认使用安全 API,不能为压掉借用错误或未经测量的性能直觉引入 unsafe。
first_fast 与安全 API 对照
空检查证明索引有效,再进入一个表达式大小的 unsafe 块。返回引用的寿命仍由输入切片约束,调用者无需额外条件。
独立示例(仅标准库;src/main.rs,cargo +stable run):
1 | |
这只是封装结构的教学示例,没有证据证明比 items.first() 更快。实际业务默认直接使用 first;若 unsafe 的复杂性不能由必要的底层合同或测量结果支持,就删掉它。
unsafe fn 与 # Safety
unsafe fn 将前置条件交给调用者;unsafe 块标出实现中需要证明的具体操作。合同必须覆盖调用时的条件,不只是指针创建时曾经有效。
独立示例(仅标准库;src/main.rs,cargo +stable run):
1 | |
Edition 2024 将 unsafe_op_in_unsafe_fn 默认设为 warning,不是默认硬错误。本课程规范始终写显式 unsafe 块,把函数级调用合同与语句级操作分开;项目可用 deny 将 lint 升为错误。
独立示例(仅标准库;独立 src/main.rs,Edition 2024 编译产生 unsafe_op_in_unsafe_fn 警告;实际调用条件合法):
1 | |
故意失败:编译错误(仅标准库;独立 src/main.rs。deny 将缺少显式 unsafe 块的 lint 升为错误):
1 | |
裸指针、别名与 Miri
裸指针不由借用检查器完整追踪,不等于任意访问都合法。下面先从共享引用获得指针,再通过新独占借用访问同一位置,最后使用旧指针;这是工具别名模型下的违规示意,不应放进正常运行路径。
不应执行:UB 示意(仅标准库;独立 src/main.rs。只可单独交给 Miri 观察别名诊断,不普通运行):
1 | |
Miri 的 Stacked Borrows/Tree Borrows 是用于检查的模型,不是已经完全定型的语言别名规范。具体诊断、标签和规则受工具版本与模型配置影响,不应把某条诊断逐字视为稳定接口。参见 Rust Reference 的未定义行为说明。Miri 能发现执行路径中的许多有效性错误,但不能证明任意输入或全部外部代码安全,也不应把所有内存泄漏一概称为 UB。
独立示例(仅标准库;src/main.rs,cargo +stable run。独占访问结束后重新取得指针):
1 | |
代码片段(工具命令;分别在单独的合法示例或 UB 示意临时项目根执行。需已安装带 Miri 的 nightly,不替代稳定版测试):
1 | |
FFI:ABI、有效性与释放责任
#[repr(C)] 控制结构体字段布局,但不会把其中的 String、Vec 或任意 Rust 字段变成 FFI-safe。默认 Rust 布局不应作为跨语言协议依赖;整数宽度、对齐、指针长度、字符串终止和分配器约定都要与对端一致。C 有 bool/_Bool,仍须按实际 ABI 约定选择对应表示;下例明确约定 done 使用 c_int 的 0/1。
独立示例(仅标准库;src/main.rs,cargo +stable run。这是 Rust 侧同签名演示,不验证真实 C 库或跨语言链接):
1 | |
代码片段(真实 C 库的声明语法;依赖上块的 TaskSummary、c_int 定义,替换上块 summarize 实现而非并存。调用前必须链接提供该符号的库):
1 | |
Edition 2024 要求 extern 块标 unsafe,块内也可显式声明 safe 项,前提是作者能保证所有合法 Rust 实参都安全;不能把整个块里的项一概解释成必然 unsafe 调用。分配与释放须遵循对端合同:Rust 分配的 Box 不能默认交给 C free,C malloc 得到的内存也不能默认包装成 Box::from_raw;即使分配器相同,还须满足 Rust 类型、布局与所有权条件。
ABI 还决定 unwind 合同。普通 extern "C" 不允许 Rust panic unwind 穿过,C-unwind 有不同约定;catch_unwind 只处理可展开的 Rust panic,不捕获 panic=abort,也不保证处理任意外部异常。边界上返回错误码或 Result 转换后的协议值,不能靠“捕获一切异常”代替设计。
显式可失败清理:真实写出
Drop 的签名不能返回 Result,隐式销毁中发生的写出错误无法直接传给调用者。复用第 05 章的标准库模式即可:创建文件,缓冲写入,然后显式 flush 并返回错误,不需要虚构 Journal 包装器。
独立示例(仅标准库;src/main.rs,cargo +stable run。只在本次创建的临时目录中写入与清理):
1 | |
不存在的父目录触发的是 File::create 失败,并非 flush 失败。成功例证明写出并重读的字节一致,不证明已经 fsync 或掉电持久化。File/BufWriter 自动销毁负责释放资源,显式 flush 负责报告缓冲写出的错误;Drop 也可能尽力执行可失败清理,但不应以它作为唯一错误报告路径。析构期间再次 panic 可能导致 abort,因此自定义 Drop 应避免 panic。
unsafe trait:实现者承担合同
普通 trait 的 Eq/Hash 一致性、Ord 全序等语义合同很重要,但 unsafe 代码不能因为某个 safe trait 的实现违反业务语义就产生 UB。需要让底层实现承诺内存安全前提时,使用 unsafe trait 并写清 # Safety;unsafe impl 的审查重点是为何每个实现都满足这些前提。
独立示例(仅标准库;src/main.rs,cargo +stable run。零初始化仅用于显式证明有效的类型):
1 | |
不能给引用或 NonZeroU32 这样零位模式无效的类型写同样实现。unsafe impl Send/Sync 同理:必须证明字段、生命周期、同步和析构路径,不是只写一句“可跨线程”。Unpin 是 safe trait,Deref 也是 safe trait;它们有语义边界,但不能混称为 unsafe trait。
深入阅读:安全边界的维护
安全包装器要在构造、修改、销毁和 FFI 回调各路径维持不变量,而不只是检查一次。SAFETY 注释应说明具体来源、范围与寿命;未来修改前置分支或表示方式时,必须同时复核引用该证明的 unsafe 操作。
Edition 2024 的 static_mut_refs 默认 deny 禁止创建对可变静态的引用(包括隐式引用),并不是只禁止 &'static mut 这一种写法。共享全局状态通常用原子类型、锁、OnceLock/LazyLock;裸指针本身不免除同步和别名责任。常用 trait 的选型可补充阅读标准库模块地图与常用 trait,本章的安全合同不依赖该文章。
自测
- first_fast 的 SAFETY 注释必须引用哪条可检查证据?为什么默认仍应使用 first?
- unsafe fn 和 unsafe 块分别承担什么职责?2024 默认警告与 deny 有何区别?
- 编译通过和 Miri 通过为什么都不能证明任意输入无 UB?
- repr©、extern 块和 catch_unwind 各自不能保证什么?
- save_report 的失败例发生在哪一步?flush 成功能否保证掉电持久化?
参考答案
- 空分支已被排除,索引 0 在范围内,返回借用不超出输入寿命;安全 first 已满足需求,没有测量收益就不引入 unsafe。
- 函数文档约束调用者,块标出实现中的具体操作;默认 lint 是 warning,deny 才使缺块被拒绝编译。
- rustc 不完整检查裸指针合同;Miri 依照工具模型检查已执行样本,既非完整语言规范也非全输入证明。
- repr© 不使内部 Rust 字段自动 FFI-safe;extern unsafe 不保证声明正确;catch_unwind 不捕获 abort 或任意外部异常,ABI 必须匹配。
- 缺父目录在 File::create 阶段返回错误;flush 报告缓冲写出,不等于 fsync 或持久化保证。






