Rust 健全性(Soundness)深度解析:comprehensive-rust 课程中的内存安全基石
【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust
在 Rust 中,"健全性(soundness)"是决定代码是否安全的核心概念:一份 sound 的代码在满足其安全前置条件时,不可能触发未定义行为(Undefined Behavior,UB)或内存安全问题。本文基于 Google Android 团队使用的 Rust 课程仓库 comprehensive-rust 中 rules-of-the-game(游戏规则) 章节,系统讲解 soundness 的形式化定义、三种 sound 代码形态、责任归属(burden of proof),并通过一个从 Safe Rust 到暴露 unsafe 的copy函数五步演进,让你掌握"如何写出、审查出 sound 的 unsafe 代码"的完整方法。
Soundness:Rust 的立身之本
课程 Rust is sound 一节开宗明义地指出:
- Soundness 是 Rust 的根本(Soundness is fundamental to Rust);
- Soundness ≈ 不可能触发内存安全问题;
- Sound 的函数具有共同的"形态(shapes)"。
在此之前,课程中已经展示了大量存在问题的代码示例,但一直缺乏统一的术语来描述它们。Rules of the game 章节导语 说明了引入这套词汇的动机:由于许多安全前置条件本质上是**语义性(semantic)**的,而非语法性(syntactic)的,使用共享的词汇才能让开发者就语义达成一致。整节围绕三个核心术语展开:undefined behavior(未定义行为)、sound(健全)、unsound(不健全),最终目标是建立关于 soundness 的心智框架,确保包含 unsafe 的 Rust 代码依然保持健全。
在给出正式定义之前,课程先用直觉语言刻画 sound 的含义:sound 代码就是无法触发内存安全问题的代码,它由 sound 的函数与 sound 的操作组成;而 sound 函数,是指其所有可能的输入都不会引发健全性问题的函数。
形式化定义:Sound、Unsound 与 UB
Rust is sound 一文承诺"稍后将给出 soundness 的正式定义",这一承诺由 Soundness(健全性) 一节兑现:
Sound 函数:一个函数,只要其安全前置条件(safety preconditions)得到满足,就绝不会触发未定义行为(UB)。
与之相对,Unsoundness(不健全性) 给出了反面定义:
Unsound 函数:即使你满足了文档中声明的全部安全前置条件,它依然可能触发 UB。
课程特别强调:unsound 代码是"坏的"(Unsound code is bad)。即使调用者完全遵守了文档规则,unsound 代码仍可能引发 UB,因此在代码仓库中不应存在任何 unsound 代码;找出 unsound 代码,是 code review 的首要目标(finding unsound code is the primary goal of the code review)。
把形式定义翻译成直觉语言:sound 函数是"守规矩的好函数"——它清楚地文档化自己的安全前置条件,调用者满足这些条件后,函数就表现良好(不产生 UB)。而 unsound 函数则是"不守规矩的坏函数",无论调用者多么小心,都可能踩雷。
责任归属:谁为前置条件负责
一个容易被忽略的要点是:满足安全前置条件的责任在调用者,而不在编译器。编译器不会帮你验证这些条件——这正是"语义性前置条件"的代价。这一点在 soundness 定义一节被反复强调。
关键推论:纯 Safe Rust 必然 Sound(附证明)
基于上述定义,课程给出了一个优雅的推论(Soundness Proof Part 2):
推论:所有仅用 Safe Rust 实现的函数都是 sound 的。
证明过程(QED)如下:
- Safe Rust 代码没有任何安全前置条件(空集);
- 因此,任何调用纯 Safe Rust 函数的人,都平凡地满足了这个空的前置条件集合;
- Safe Rust 代码不可能触发 UB。
由 1、2、3 即得证。直觉解释:所有纯 Safe Rust 代码都是"好代码"——程序员无需为它考虑任何安全前置条件,它永远守规矩,永远不会触发 UB。
这一推论是 Rust 安全故事的核心支柱:编译器把 "safe 代码不可能 UB" 作为一个可证明的属性,从而让不安全行为被精确地隔离在unsafe块与unsafe fn之中。
Sound 代码的三种形态
那么,什么样的代码是 sound 的呢?3 Shapes of Sound Rust 给出了答案:sound 代码只可能有以下三种形态:
- 纯 Safe Rust 函数:内部不含任何
unsafe块; - 完全封装了 unsafe 块的 Safe 函数:
unsafe块被安全地包裹在内部,调用者完全不需要知道它们的存在,即"不可能被误用"; - 未封装 unsafe 块、并将证明负担(proof burden)转嫁给调用者的 unsafe 函数:这类函数必须把安全前置条件文档化。
举证责任(Burden of Proof)的转移
三种形态对应的责任主体各不相同,这是理解 unsafe 代码审查的关键:
| 代码形态 | 由谁保证 soundness |
|---|---|
| 纯 Safe Rust 函数 | 编译器(compile-time 保证) |
| 含有 unsafe 块的 Safe 函数 | 函数作者(必须保证对任意输入都不会引发内存问题) |
| unsafe 函数 | 调用者(必须满足文档化的安全前置条件) |
理解这张表之后,审查 unsafe 代码时就有了明确的问题清单:作者是否封装了 unsafe?前置条件是否文档化?调用者是否真的满足了这些条件?
实战案例:copy函数的安全形态演进
为了把上述抽象概念落到实处,课程用同一个功能——"从source读取字节并写入dest"——演示了五种实现形态(起点原型见 Copying memory):
/// Reads bytes from `source` and writes them to `dest` pub fn copy(dest: &mut [u8], source: &[u8]) { ... }dest是可变的字节切片,source是不可变的字节切片。接下来逐一看五种演进。
形态一:纯 Safe Rust 实现
Safe Rust 给出第一种实现:
pub fn copy(dest: &mut [u8], source: &[u8]) { for (dest, src) in dest.iter_mut().zip(source) { *dest = *src; } } fn main() { let a = &[114, 117, 115, 116]; let b = &mut [82, 85, 83, 84]; println!("{}", String::from_utf8_lossy(b)); copy(b, a); println!("{}", String::from_utf8_lossy(b)); }这个实现只使用 Safe Rust,因此对于所有可能的输入,都不可能触发内存安全问题。课程借此引导读者思考:为什么迭代器方案是安全的?因为通过 Rust 的迭代器,我们永远不会直接操作指针,从而自动规避了空指针、越界检查等指针相关错误。还能想到其他保证吗?课程给出的答案包括:
- 不会发生别名(aliasing)问题;
- 悬垂指针(dangling pointer)不可能出现;
- 对齐(alignment)一定是正确的;
- 不会意外读取未初始化内存。
因此可以说copy是 sound 的——Rust 保证了它的所有安全前置条件都被满足。从程序员视角看,这个函数没有任何安全前置条件。
不过要注意:sound 不等于"总是符合调用者意图"。如果dest空间不足,数据就不会被完整拷贝过去(zip会在较短的切片处停止),但这只是逻辑层面的限制,与内存安全无关。
形态二:封装 unsafe 的 Safe 函数
Encapsulated Unsafe Rust 展示了第二种形态:函数签名保持 safe,但内部使用 unsafe 手动访问内存:
pub fn copy(dest: &mut [u8], source: &[u8]) { let len = dest.len().min(source.len()); let mut i = 0; while i < len { // SAFETY: `i` must be in-bounds as it was produced by source.len() let new = unsafe { source.get_unchecked(i) }; // SAFETY: `i` must be in-bounds as it was produced by dest.len() let old = unsafe { dest.get_unchecked_mut(i) }; *old = *new; i += 1; } }这次实现绕开了迭代器,改为手动内存访问。关键问题随之而来:这段代码正确吗?有隐患吗?由谁负责保证正确性?
答案:由函数作者负责。Safe 函数内部包含 unsafe 块时,只要"不存在能让某个输入引发内存安全问题的可能",它就是 sound 的。这里作者通过dest.len().min(source.len())精确限定了循环上界,并在每个 unsafe 块旁用SAFETY:注释说明索引不会越界,属于"完全封装、调用者无感知"的第二类形态。
形态三:暴露 unsafe 的函数(问题代码)
Exposed Unsafe Rust 改变了游戏规则:签名变为接收裸指针,要求调用者配合:
pub fn copy(dest: &mut [u8], source: *const u8) { let source = { let mut len = 0; let mut end = source; while unsafe { *end != 0 } { len += 1; end = unsafe { end.add(1) }; } unsafe { std::slice::from_raw_parts(source, len + 1) } }; for (dest, src) in dest.iter_mut().zip(source) { *dest = *src; } }功能与之前相同:把字节从一处拷到另一处。但为了从裸指针构造切片,必须先找到数据的结尾——既然处理的是文本,就采用 C 语言惯例:以 null 字节结尾的字符串(NUL-terminated string)。
这段代码可以编译,输出也与前两版一致——但课程点破了一个重要事实:一个 unsound 的函数,在部分输入上依然可能正常工作。测试通过了,不代表你的函数就是 sound 的。
课程随即引导读者找问题:
- 可读性差:代码难以快速扫读;
source指针可能为null;source指针可能悬垂(指向已释放或未初始化的内存);source可能没有以 null 字节结尾。
假设不能修改函数签名,可做的改进包括:
- 空指针:加空指针检查并提前返回(
if source.is_null() { return; }); - 可读性:不要自己实现"寻找第一个 null 字节",改用经过充分测试的库。
但有些安全需求是无法通过防御性检查来满足的,例如:悬垂指针、缺少 null 终止字节——这些只有调用者才知道。
那么,如何让这个函数变得 sound?课程给出两条路:
- 改变
source的类型,换成已知长度的类型(比如像前面那样用切片); - 把函数标记为 unsafe,并文档化安全前置条件。
形态四:文档化安全前置条件的 unsafe 函数
Documented safety preconditions 走第二条路,最终形成完整的 sound 形态:
/// ... /// /// # Safety /// /// This function can easily trigger undefined behavior. Ensure that: /// /// - `source` pointer is non-null and non-dangling /// - `source` data ends with a null byte within its memory allocation /// - `source` data is not freed (its lifetime invariants are preserved) /// - `source` data contains fewer than `isize::MAX` bytes pub unsafe fn copy(dest: &mut [u8], source: *const u8) { let source = { let mut len = 0; let mut end = source; // SAFETY: Caller has provided a non-null pointer while unsafe { *end != 0 } { len += 1; // SAFETY: Caller has provided a data with length < isize:MAX end = unsafe { end.add(1) }; } // SAFETY: Caller maintains lifetime and aliasing requirements unsafe { std::slice::from_raw_parts(source, len + 1) } }; for (dest, src) in dest.iter_mut().zip(source) { *dest = *src; } }与前几版相比,变化有三点:
copy被标记为unsafe fn;- 通过文档注释中的
# Safety小节明确文档化安全前置条件; - 每个内部
unsafe块旁都有SAFETY:行内注释。
课程给出的结论是:当一个 unsafe 函数的"安全前置条件"和"内部 unsafe 块"都被文档化时,它就是 sound 的。
相应地,main中的调用也必须修改:
fn main() { let a = [114, 117, 115, 116].as_ptr(); let b = &mut [82, 85, 83, 84, 0]; println!("{}", String::from_utf8_lossy(b)); unsafe { copy(b, a); } println!("{}", String::from_utf8_lossy(b)); }注意两点修正:a指向的[114, 117, 115, 116]并不满足"以 null 字节结尾" 这一前置条件(这正是课程设置的陷阱);同时,调用 unsafe 函数需要unsafe块,并在块中(或紧邻处)用SAFETY:注释说明调用者已核实各项前置条件。
形态五(反面教材):Crying Wolf 函数
Crying Wolf 展示了另一种常见误区——"狼来了"式函数:
pub unsafe fn copy(dest: &mut [u8], source: &[u8]) { for (dest, src) in dest.iter_mut().zip(source) { *dest = *src; } }这类函数被标记为unsafe,但实际上没有任何需要调用者检查的安全前置条件(参数全是对齐良好、生命周期安全的切片)。它带来的是纯粹的负担:调用者被迫写unsafe块、被迫思考并不存在的风险,而编译器本可以替你完成全部保证。课程给出的判断标准很清晰:不要为了"看起来需要小心"而标记 unsafe,unsafe 应该与真实存在的、文档化的安全前置条件一一对应。
从课程到实践:一套可执行的审查清单
把这五种形态串起来,就得到了一套实用的 unsafe 代码审查清单(这也正是课程希望读者建立的"心智框架"):
- 判定形态:函数属于三种 sound 形态中的哪一种?纯 Safe?封装 unsafe?还是暴露 unsafe?
- 确认责任方:若含 unsafe 块,作者是否对任意输入都能保证无 UB?若为 unsafe 函数,前置条件是否被完整文档化?
- 核对前置条件:每个文档化条件是否都被调用方真正满足?条件是否"可满足"(如 dangling 指针、无终止符这类无法防御性检查的条件,必须转为文档义务)?
- 警惕 Crying Wolf:unnecessary
unsafe同样是一种代码坏味道; - 回归本质:记住形式定义——在满足安全前置条件的前提下,函数能否触发 UB?如果能,它就是 unsound,必须修复。
另外值得强调课程反复出现的一句话:"测试通过 ≠ sound"。unsound 函数可以在某些输入上行为正常,因此单元测试不能替代对 safety preconditions 的严谨推理。
小结
围绕 Rust is sound 这一核心,comprehensive-rust 课程给出了从直觉到证明、再到实战的完整链条:
- 直觉:sound ≈ 不会触发内存安全问题;
- 定义:满足安全前置条件即不触发 UB 的函数是 sound 的;反之则是 unsound;
- 推论(带证明):纯 Safe Rust 函数必然 sound;
- 形态:sound 代码只有三种形态,责任分别落在编译器、函数作者与调用者身上;
- 实战:同一个
copy函数历经 Safe 实现、封装 unsafe、暴露 unsafe、文档化前置条件、Crying Wolf 五种形态,完整演示了如何把不安全代码"驯化"为 sound 代码。
这套"规则"正是 Rust 生态中大量底层库(标准库、std::slice::from_raw_parts等不安全接口)能够安全共存的基础。掌握它,你不仅能写出可靠的 unsafe 代码,也能在 code review 中精准识别出 unsound 的实现。相关章节还可以进一步研读 3 Shapes of Sound Rust 与 Copying memory 系列,以及本章后续的 Copying memory 深度讲解 与 Safety preconditions 章节。
【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考