第 22 章 不安全代码(1)

第 22 章 不安全代码

希望没有人认为我卑微、软弱或顺从,

希望他们明白我与众不同:

对于敌人,我意味着危险;对于朋友,我意味着忠诚。

这便是我荣耀的人生。

——《美狄亚》,Euripides

系统编程的隐秘乐趣在于,在每一种安全语言和精心设计的抽象之下,都是极度不安全的机器语言和按位操作的汹涌暗流。你也可以用 Rust 写出这种代码。

迄今为止,通过类型检查、生命周期检查、限界检查等方法,本书中介绍的这门语言可以确保你的程序完全自动地摆脱内存错误和数据竞争的困扰。但是这种自动化推理有其局限性,因为 Rust 中仍然有许多无法识别为安全的高价值技术。

不安全 1 代码 能让你告诉 Rust:“我选择使用你无法保证安全的特性。”通过将块或函数标记为不安全的,你可以获得调用标准库中的 unsafe 函数、解引用不安全指针以及调用以其他语言(如 C 和 C++)编写的函数等能力。Rust 的其他安全检查仍然适用:类型检查、生命周期检查和索引的边界检查都会正常进行。不安全代码只会启用一小部分附加特性。

这种跨越 Rust 安全边界的能力使得 Rust 可以实现自身许多最基本的特性,就像 C 和 C++ 被用于实现自己的标准库一样。使用不安全代码, Vec 类型可以更加高效地管理其缓冲区, std::io 模块可以和操作系统对话, std::thread 模块和 std::sync 模块可以提供并发原语。

本章涵盖了使用不安全特性的所有要点。

  • Rust 的 unsafe 块在普通的、安全的 Rust 代码和使用了不安全特性的代码之间建立了边界。
  • 可以将函数标记为 unsafe,提醒调用者这里存在必须遵守的额外契约,以避免未定义行为。
  • 裸指针及其方法允许不受限制地访问内存,进而构建 Rust 的类型系统原本会禁止的数据结构。Rust 的引用是安全但受限的,而任何 C 或 C++ 程序员都知道,裸指针是一个强大而锋利的工具。
  • 理解未定义行为的定义将帮助你理解为什么它会产生比得到不正确的结果还要严重的后果。
  • 不安全特型( unsafe trait)与 unsafe 函数类似,对每个实现而不是每个调用者都强加了必须遵守的契约。

22.1 不安全因素来自哪里

在本书的开头,我们展示过一个因为没有遵守 C 标准规定中的规则而以令人惊讶的方式崩溃的 C 程序。在 Rust 中可以做到同样的事情:

$ cat crash.rs
fn main() {
 let mut a: usize = 0;
 let ptr = &mut a as *mut usize;
 unsafe {
 *ptr.offset(3) = 0x7ffff72f484c;
 }
}
$ cargo build
 Compiling unsafe-samples v0.1.0
 Finished debug [unoptimized + debuginfo] target(s) in 0.44s
$ ../../target/debug/crash
crash: Error: .netrc file is readable by others.
crash: Remove password or make file unreadable by others.
Segmentation fault (core dumped)
$

这个程序借用了对局部变量 a 的可变引用,将其转换为 *mut usize 类型的裸指针,然后使用 offset 方法在内存中又生成了 3 个字的指针。这恰好是存储 main 的返回地址的地方。这个程序用一个常量覆盖了返回地址,这样从 main 返回的行为就会令人非常惊讶。导致这次崩溃的原因是程序错误地使用了不安全特性——在这个例子中就是解引用裸指针的能力。

不安全特性是强加了某种 契约 的特性:Rust 不能自动执行这些规则,但你必须遵守这些规则以避免 未定义行为

这种契约超出了常规类型检查和生命周期检查的能力范围,针对该不安全特性强加了更多规则。通常,Rust 本身根本不了解契约,契约只是在该特性的文档中进行了解释。例如,裸指针类型有一个契约,它禁止解引用已超出其原始引用目标末尾的指针。上述例子中的表达式 *ptr.offset(3) = ... 破坏了这个契约。但是,正如前面的记录所示,Rust 毫无怨言地编译了这段程序,因为它的安全检查并未检测到这种违规行为。当使用了不安全特性时,作为程序员,你有责任检查自己的代码是否遵守了它们的契约。

许多特性需要遵守某些规则才能正确使用,但这些规则并不是这里所说的契约,除非违反它们的后果包括未定义行为。未定义行为是“Rust 坚定地认为你的代码永远不会出现的行为”。例如,Rust 认为你不会用其他内容覆盖函数调用的返回地址。能够通过 Rust 通常的安全检查并遵守其用到的不安全特性的契约的代码不可能做这样的事情。由于前面的程序违反了裸指针契约,因此其行为是未定义的,它已经偏离了轨道。

如果代码表现出未定义行为,那你就已经违背了与 Rust 达成的交易,所以 Rust 无法对其后果负责。从系统库深处挖掘出不相关的错误消息并导致崩溃是一种可能的后果,将计算机的控制权交给攻击者是另一种后果。在没有警告的情况下,从 Rust 的一个版本换到下一个版本可能会产生不同的效果。然而,有时未定义行为并没有明显的后果。如果 main 函数永远不会返回(比如调用了 std::process::exit 来提前终止程序),那么损坏的返回地址可能无关紧要。

只能在 unsafe 块或 unsafe 函数中使用不安全特性,我们将在接下来的内容中对两者进行解释。这可以避免在不知不觉中使用不安全特性:通过强制编写 unsafe 块或函数,Rust 会确保你已经知道在自己的代码中可能要遵守的额外规则。

22.2 不安全块

unsafe 块看起来就像前面加了 unsafe 关键字的普通 Rust 块,不同之处在于可以在块中使用不安全特性:

unsafe {
 String::from_utf8_unchecked(ascii)
}

如果块前面没有 unsafe 关键字,那么 Rust 就会反对使用 from_utf8_unchecked,因为这是一个 unsafe 函数。有了它周围的 unsafe 块,就可以在任何地方使用此代码了。

与普通的 Rust 块一样, unsafe 块的值就是其最终表达式的值,如果没有则为 ()。前面展示的对 String::from_utf8_unchecked 的调用提供了该块的值。

unsafe 块解锁了 5 个额外的选项。

  • 可以调用 unsafe 函数。每个 unsafe 函数都必须根据自己的目的指定自己的契约。
  • 可以解引用裸指针。安全代码可以传递裸指针,比较它们,并从引用(甚至整数)转换成它们,但只有不安全代码才能真正使用它们来访问内存。22.8 节将详细介绍裸指针并解释如何安全地使用它们。
  • 可以访问 union 的各个字段,编译器无法确定这些字段是否包含其各自类型的有效位模式。
  • 可以访问可变的 static 变量。如 19.3.11 节所述,Rust 无法确定线程何时使用可变 static 变量,因此它们的契约要求你确保所有访问都能正确同步。
  • 可以访问通过 Rust 的外部函数接口声明的函数和变量。即使声明为不可变的,这些函数和变量也仍然会被看作 unsafe 的,因为它们对于用其他可能不遵守 Rust 安全规则的语言编写的代码仍然是可见的。

将不安全特性限制在 unsafe 块中并不能真正阻止你做任何想做的事。你完全可以只将一个 unsafe 块粘贴到代码中,然后继续我行我素。该规则的主要目的在于将人们的视线吸引到 Rust 无法保证其安全性的代码上。

  • 你不会无意中使用不安全特性,然后发现要对连自己都不知道在哪里的契约负责。
  • unsafe 块会引起评审者的更多关注。有些项目甚至会通过自动化设施来确保这一点,它们会标记出影响 unsafe 块的代码更改以引起特别关注。
  • 当你考虑编写 unsafe 块时,可以花点儿时间问问自己是否真的需要这样的措施。如果是为了性能,那是否有测量结果表明这确实是一个瓶颈呢?也许在安全的 Rust 中有更好的办法来完成同样的事情。

22.3 示例:高效的 ASCII 字符串类型

下面是 Ascii 的定义,它是一种能确保其内容始终为有效 ASCII 的字符串类型。这种类型使用了不安全特性来提供到 String 的零成本转换:

mod my_ascii {
 /// 一个ASCII编码的字符串
 #[derive(Debug, Eq, PartialEq)]
 pub struct Ascii(
 // 必须只持有格式良好的ASCII文本:字节范围从`0`到`0x7f`
 Vec<u8>
 );

 impl Ascii {
 /// 从`bytes`的ASCII文本中创建`Ascii`。如果`bytes`包含
 /// 任何非ASCII字符,则返回`NotAsciiError`错误
 pub fn from_bytes(bytes: Vec<u8>) -> Result<Ascii, NotAsciiError> {
 if bytes.iter().any(|&byte| !byte.is_ascii()) {
 return Err(NotAsciiError(bytes));
 }
 Ok(Ascii(bytes))
 }
 }

 // 当转换失败时,给出无法转换的向量。这会实现
 // `std::error::Error`,为保持简洁已省略
 #[derive(Debug, Eq, PartialEq)]
 pub struct NotAsciiError(pub Vec<u8>);

 // 使用不安全代码实现的安全、高效的转换
 impl From<Ascii> for String {
 fn from(ascii: Ascii) -> String {
 // 如果此模块没有bug,这就是安全的,因为格式
 // 良好的ASCII文本必然是格式良好的UTF-8
 unsafe { String::from_utf8_unchecked(ascii.0) }
 }
 }
 ...
}

这个模块的关键是 Ascii 类型的定义。该类型本身是被标记为 pub 的,以令其在 my_ascii 模块之外可见。但是该类型的 Vec<u8> 元素 不是 公共的,因此只有 my_ascii 模块可以构造 Ascii 值或引用其元素。这使得模块的代码可以完全控制允许出现或不允许出现的内容。只要公共构造函数和方法能确保新创建的 Ascii 值是格式良好的并在其整个生命周期中都是如此,程序的其余部分就不会违反该规则。事实上,公共构造函数 Ascii::from_bytes 在同意从给定的向量中构造 Ascii 之前会仔细检查它。为简洁起见,我们没有展示任何方法,但你可以想象有一组文本处理方法,并确保 Ascii 值始终包含正确的 ASCII 文本,就像 String 的方法会确保其内容始终是格式良好的 UTF-8 一样。

这种安排让我们可以非常高效地为 String 实现 From<Ascii>。不安全函数 String::from_utf8_unchecked 会获取字节向量并从中构建一个 String,而不会检查其内容是否为格式良好的 UTF-8 文本,该函数的契约要求其调用者对此负责。幸运的是, Ascii 类型强制执行的规则正是应该满足 from_utf8_unchecked 契约的规则。正如 17.2 节所解释的那样,任何 ASCII 文本块也是格式良好的 UTF-8,因此 Ascii 的底层 Vec<u8> 可以立即用作 String 的缓冲区。

有了这些定义,便可以这样写:

use my_ascii::Ascii;

let bytes: Vec<u8> = b"ASCII and ye shall receive".to_vec();

// 这个调用不需要分配内存或复制文本,只需做扫描
let ascii: Ascii = Ascii::from_bytes(bytes)
 .unwrap(); // 我们知道所选的这些字节肯定是正确的

// 这个调用是零开销的:无须分配内存、复制文本或扫描
let string = String::from(ascii);

assert_eq!(string, "ASCII and ye shall receive");

使用 Ascii 时不需要 unsafe 块。我们已经使用不安全操作实现了一个安全接口,并准备好仅依赖模块自己的代码而不必靠其用户的行为来满足它们的契约。

Ascii 只不过是 Vec<u8> 的包装器,但隐藏在对其内容实施额外规则的模块中。这种类型称为 newtype,这是 Rust 中的一种常见模式。Rust 自己的 String 类型以完全相同的方式定义,不过它的内容被限制为 UTF-8,而不是 ASCII。事实上,标准库中 String 的定义是这样的:

pub struct String {
 vec: Vec<u8>,
}

在机器层面,由于不认识 Rust 的类型,newtype 及其元素在内存中具有相同的表示,因此构造 newtype 根本不需要任何机器指令。在 Ascii::from_bytes 中,表达式 Ascii(bytes) 被简单地看作 Vec<u8> 的一种表观,只是它现在持有一个 Ascii 值。同理, String::from_utf8_unchecked 在内联时可能也不需要机器指令,因为 Vec<u8> 现在直接作为 String 使用。

22.4 不安全函数

unsafe 函数看起来就像前面加了 unsafe 关键字的普通函数。 unsafe 函数的主体自动被视为 unsafe 块。

只能在 unsafe 块中调用 unsafe 函数。这意味着将函数标记为 unsafe 会警告其调用者,为避免未定义行为,该函数具有他们必须满足的契约。

例如,下面是前面介绍的 Ascii 类型的新构造函数,它会从字节向量构建 Ascii,而不检查其内容是否为有效的 ASCII:

// 以下代码必须放在`my_ascii`模块内部
impl Ascii {
 /// 从`bytes`构造`Ascii`值,不检查`bytes`中是否真正包含格式良好的ASCII
 ///
 /// 这个构造函数是不会出错的,它会直接返回`Ascii`,而不会像
 /// `from_bytes`那样返回`Result<Ascii, NotAsciiError>`
 ///
 /// # 安全性
 ///
 /// 调用者必须确保`bytes`只包含ASCII字符:各字节
 /// 都不大于0x7f。否则,其行为就是未定义的
 pub unsafe fn from_bytes_unchecked(bytes: Vec<u8>) -> Ascii {
 Ascii(bytes)
 }
}

调用 Ascii::from_bytes_unchecked 的代码大概已经以某种方式知道了自己手中的向量只会包含 ASCII 字符,因此 Ascii::from_bytes 坚持要执行的检查只是浪费时间,调用者也将不得不编写代码来处理他知道永远不会发生的 Err 结果。 Ascii::from_bytes_unchecked 能让这样的调用者回避检查和错误处理。

但早些时候,为了确保 Ascii 值是格式良好的,我们强调了 Ascii 的公共构造函数和方法的重要性。 from_bytes_unchecked 难道不能履行这一责任吗?

并非如此,其实 from_bytes_unchecked 通过它的契约将这些义务推脱给了调用者。这个契约的存在使得将这个函数标记为 unsafe 是正确的:虽然函数本身没有执行任何不安全操作,但它的调用者必须遵守某些不能靠 Rust 自动执行的规则来避免未定义行为。

真的可以通过破坏 Ascii::from_bytes_unchecked 的契约来导致未定义行为吗?是的。可以构造一个包含格式错误的 UTF-8 的 String,如下所示:

// 将这个向量想象成用来生成ASCII的一些复杂过程的结果。但这里有问题!
let bytes = vec![0xf7, 0xbf, 0xbf, 0xbf];

let ascii = unsafe {
 // 如果`bytes`中存有非ASCII字节,就违反了这个不安全函数的契约
 Ascii::from_bytes_unchecked(bytes)
};

let bogus: String = ascii.into();

// `bogus`现在持有格式错误的UTF-8。解析其第一个字符会生成一个不是有效Unicode
// 码点的`char`。这是未定义行为,所以语言无法说明这个断言应该是什么样的行为
assert_eq!(bogus.chars().next().unwrap() as u32, 0x1fffff);

在某些平台上某些版本的 Rust 中,会观察到此断言失败并显示以下有趣的错误消息:

thread 'main' panicked at 'assertion failed: `(left == right)`
 left: `2097151`,
 right: `2097151`', src/main.rs:42:5

这两个数值在我们看来明明是相等的——这不是 Rust 的错,而是前一个 unsafe 块所导致的。当我们说未定义行为会导致无法预测的结果时,就是这个意思。

这个例子说明了关于 bug 和不安全代码的两个关键事实。

  • unsafe 块之前发生的 bug 可能会破坏契约unsafe 块是否会导致未定义行为不仅取决于块本身的代码,还取决于为其提供操作目标的代码。 unsafe 代码为满足契约所依赖的一切都与安全有关。仅当模块的其余部分都能正确维护 Ascii 的不变条件时,基于 String::from_utf8_uncheckedAsciiString 的转换才是有明确定义的。
  • 离开 unsafe 区块后,仍可能出现此处违约的后果。由于没有遵守不安全特性的契约而招致的未定义行为通常并不会发生在 unsafe 块内部。如前所述,伪造 String 的行为可能直到程序执行了很久之后才引发问题。

本质上,Rust 的类型检查器、借用检查器和其他静态检查都是在检查你的程序并试图构建出证据,证明它不会表现出未定义行为。如果 Rust 能成功编译程序,那么就意味着它成功地证明了你的代码是正确的。而 unsafe 块是这个证明中的一个缺口,也就是说, unsafe 块就相当于你对 Rust 说:“这段代码很好,请相信我。”你的声明正确与否可能取决于程序中会影响到此 unsafe 块的任意部分,并且其错误的后果也可能会出现在受此 unsafe 块影响的任意地点。写出 unsafe 关键字,就相当于你在提醒自己没能充分利用该语言的安全检查。

如果可以选择,你自然更喜欢创建不需要契约的安全接口。这些接口更容易使用,因为用户可以依靠 Rust 的安全检查来确保他们的代码没有未定义行为。即使你的实现使用了不安全特性,最好还是使用 Rust 的类型、生命周期和模块系统来满足它们的契约,同时最好只使用你能自行担保的特性,而不是把责任转嫁给你的调用者。

不过遗憾的是,在实际开发中遇到不安全函数的情况并不少见,而这些函数的文档并没有认真地解释过它们的契约。因此,你要根据自己的经验和对代码行为方式的了解自行推断出规则。如果你曾焦虑不安地想知道用 C API 或 C++ API 所做的事情是否正常,那么对这种感觉肯定也感同身受。

22.5 不安全块还是不安全函数

你可能想知道应该使用 unsafe 块还是将整个函数都标记为 unsafe。我们推荐的方法是先对函数做一些判定。

  • 如果能正常编译,但仍可能以导致未定义行为的方式滥用函数,则必须将其标记为不安全。正确使用函数的规则是它的契约,契约的存在意味着函数是不安全的。
  • 否则,函数就是安全的。也就是说,对函数的任何类型良好的调用都不会导致未定义行为。这样的函数不应该标记为 unsafe

函数是否在函数体中使用了不安全特性无关紧要,重要的是契约存在与否。之前,我们曾展示过一个没有使用不安全特性的不安全函数,以及一个使用了不安全特性的安全函数。

不要仅仅因为函数体中使用了不安全特性就把安全的函数标记为 unsafe。这会让函数更难使用,并使那些期望在某处找到契约说明的读者感到困惑(只要是 unsafe 就理当有契约说明)。相反,应该使用 unsafe 块,即便整个函数体只有这一个块。