☰
Rust 健全性(Soundness)深度解析:comprehensive-rust 课程中的内存安全基石
2026/10/7 0:40:57 网站建设 项目流程

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)如下:

  1. Safe Rust 代码没有任何安全前置条件(空集);
  2. 因此,任何调用纯 Safe Rust 函数的人,都平凡地满足了这个空的前置条件集合;
  3. Safe Rust 代码不可能触发 UB。

由 1、2、3 即得证。直觉解释:所有纯 Safe Rust 代码都是"好代码"——程序员无需为它考虑任何安全前置条件,它永远守规矩,永远不会触发 UB。

这一推论是 Rust 安全故事的核心支柱:编译器把 "safe 代码不可能 UB" 作为一个可证明的属性,从而让不安全行为被精确地隔离在unsafe块与unsafe fn之中。

Sound 代码的三种形态

那么,什么样的代码是 sound 的呢?3 Shapes of Sound Rust 给出了答案:sound 代码只可能有以下三种形态:

  1. 纯 Safe Rust 函数:内部不含任何unsafe块;
  2. 完全封装了 unsafe 块的 Safe 函数:unsafe块被安全地包裹在内部,调用者完全不需要知道它们的存在,即"不可能被误用";
  3. 未封装 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?课程给出两条路:

  1. 改变source的类型,换成已知长度的类型(比如像前面那样用切片);
  2. 把函数标记为 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; } }

与前几版相比,变化有三点:

  1. copy被标记为unsafe fn;
  2. 通过文档注释中的# Safety小节明确文档化安全前置条件;
  3. 每个内部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 代码审查清单(这也正是课程希望读者建立的"心智框架"):

  1. 判定形态:函数属于三种 sound 形态中的哪一种?纯 Safe?封装 unsafe?还是暴露 unsafe?
  2. 确认责任方:若含 unsafe 块,作者是否对任意输入都能保证无 UB?若为 unsafe 函数,前置条件是否被完整文档化?
  3. 核对前置条件:每个文档化条件是否都被调用方真正满足?条件是否"可满足"(如 dangling 指针、无终止符这类无法防御性检查的条件,必须转为文档义务)?
  4. 警惕 Crying Wolf:unnecessaryunsafe同样是一种代码坏味道;
  5. 回归本质:记住形式定义——在满足安全前置条件的前提下,函数能否触发 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询