不安全的 Rust

我们迄今为止讨论的所有代码都在编译时强制执行了 Rust 的内存安全保证。然而,Rust 有一种隐藏在其中的第二种语言,它不强制执行这些内存安全保证:它被称为 不安全的 Rust,它就像普通的 Rust 一样工作,但赋予我们额外的超能力。

Unsafe Rust 存在的原因在于,静态分析本质上是保守的。当编译器尝试确定代码是否遵守保证时,拒绝一些有效的程序总比接受一些无效的程序要好。虽然代码可能是正确的,但如果 Rust 编译器没有足够的信息来确信,它将拒绝该代码。在这种情况下,您可以使用不安全的代码来告诉编译器,“相信我,我知道自己在做什么。” 但是请注意,您使用不安全的 Rust 时风险自负:如果您不正确地使用不安全的代码,可能会由于内存不安全问题(如空指针解引用)而出现问题。

另一个原因是底层计算机硬件本质上是不安全的。如果 Rust 不允许你执行不安全的操作,你就无法完成某些任务。Rust 需要允许你进行低级系统编程,例如直接与操作系统交互,甚至编写你自己的操作系统。进行低级系统编程是该语言的目标之一。让我们探索使用不安全的 Rust 可以做什么以及如何做。

不安全的超级能力

要切换到不安全的 Rust,使用 unsafe 关键字,然后开始一个新的块 来包含不安全的代码。在不安全的 Rust 中,你可以执行五种在安全的 Rust 中无法执行的操作,我们称之为 不安全的超能力。这些超能力包括:

  • 解引用原始指针
  • 调用不安全的函数或方法
  • 访问或修改可变静态变量
  • 实现一个不安全的特质
  • 访问 union 的字段

重要的是要理解 unsafe 并不会关闭借用检查器或禁用 Rust 的任何其他安全检查:如果你在不安全代码中使用引用,它仍然会被检查。unsafe 关键字只给你访问这五个特性,这些特性然后不会被编译器检查以确保内存安全。你仍然会在不安全块内获得一定程度的安全性。

此外,unsafe 并不意味着块内的代码一定是危险的或肯定会有内存安全问题:其意图是,作为程序员,您将确保 unsafe 块内的代码将以有效的方式访问内存。

人们会犯错,但通过要求这五种不安全操作必须在带有 unsafe 注解的块中进行,您将知道任何与内存安全相关的错误都必须在 unsafe 块内。保持 unsafe 块小;当您以后调查内存错误时,您会感谢自己。

为了尽可能地隔离不安全代码,最好将不安全代码封装在安全的抽象中,并提供一个安全的 API,我们将在本章后面讨论不安全的函数和方法时详细探讨。标准库的某些部分是通过安全抽象实现的,这些抽象覆盖了已经经过审核的不安全代码。将不安全代码封装在安全抽象中可以防止 unsafe 的使用泄露到你或你的用户可能想要使用通过 unsafe 代码实现的功能的所有地方,因为使用安全抽象是安全的。

让我们逐一看看这五种不安全的超能力。我们还将看看一些提供安全接口的抽象来访问不安全代码。

解引用原始指针

在第 4 章的 “悬垂引用” 部分,我们提到编译器确保引用始终有效。不安全的 Rust 有两种新的类型,称为 原始指针,它们类似于引用。与引用一样,原始指针可以是不可变的或可变的,分别写为 *const T*mut T。星号不是解引用运算符;它是类型名称的一部分。在原始指针的上下文中,不可变 意味着指针在解引用后不能直接赋值。

不同于引用和智能指针,原始指针:

  • 允许通过同时拥有不可变和可变指针或多个可变指针来忽略借用规则。
  • 不一定指向有效的内存
  • 允许为空
  • 不要实现任何自动清理

通过选择不使用 Rust 强制执行这些保证,您可以放弃保证的安全性,以换取更高的性能或与另一种语言或硬件接口的能力,这些语言或硬件不受 Rust 的保证约束。

列表 20-1 显示了如何创建不可变和可变的原始指针。

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;
}
Listing 20-1: Creating raw pointers with the raw borrow operators

请注意,我们在代码中没有包含 unsafe 关键字。我们可以在安全代码中创建原始指针;我们只是不能在 unsafe 块之外解引用原始指针,你将在稍后看到这一点。

我们通过使用原始借用操作符创建了原始指针:&raw const num 创建一个 *const i32 不可变原始指针,而 &raw mut num 创建一个 &mut i32 可变原始指针。因为它们是直接从一个局部变量创建的,我们知道这些特定的原始指针是有效的,但我们不能对任何原始指针都做出这样的假设。

为了演示这一点,接下来我们将创建一个原始指针,其有效性我们不能那么确定,使用 as 来转换一个值,而不是使用原始引用操作符。列表 20-2 显示了如何创建一个指向任意内存位置的原始指针。尝试使用任意内存是未定义的:该地址可能有数据,也可能没有,编译器可能会优化代码以不访问内存,或者程序可能会因段错误而报错。通常,编写这样的代码没有好的理由,特别是在可以使用原始借用操作符的情况下,但这是可能的。

fn main() {
    let address = 0x012345usize;
    let r = address as *const i32;
}
Listing 20-2: Creating a raw pointer to an arbitrary memory address

回想一下,我们可以在安全代码中创建原始指针,但我们不能解引用 原始指针并读取被指向的数据。在示例 20-3 中,我们对需要一个 unsafe 块的原始指针使用了解引用运算符 *

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;

    unsafe {
        println!("r1 is: {}", *r1);
        println!("r2 is: {}", *r2);
    }
}
Listing 20-3: Dereferencing raw pointers within an unsafe block

创建指针本身并无害;只有当我们尝试访问它所指向的值时,我们才可能会遇到无效值。

请注意,在清单 20-1 和 20-3 中,我们创建了 *const i32*mut i32 原始指针,它们都指向存储 num 的同一内存位置。如果我们尝试创建一个不可变引用和一个可变引用到 num,代码将无法编译,因为 Rust 的所有权规则不允许同时存在可变引用和任何不可变引用。使用原始指针,我们可以创建一个可变指针和一个不可变指针到同一位置,并通过可变指针更改数据,这可能会导致数据竞争。小心!

有了所有这些危险,为什么还要使用原始指针?一个主要的用例是在与C代码接口时,正如你将在下一节中看到的, “调用不安全的函数或方法。” 另一个用例是在构建借用检查器不理解的安全抽象时。 我们将介绍不安全的函数,然后看一个使用不安全代码的安全抽象示例。

调用不安全的函数或方法

unsafe 块中可以执行的第二种操作是调用不安全函数。不安全函数和方法看起来与普通函数和方法完全相同,但它们在定义的其余部分之前有一个额外的 unsafe。在这种上下文中,unsafe 关键字表示该函数有我们需要在调用此函数时遵守的要求,因为 Rust 无法保证我们已经满足这些要求。通过在 unsafe 块中调用不安全函数,我们表示我们已经阅读了此函数的文档,并承担起遵守函数契约的责任。

这里是一个名为 dangerous 的不安全函数,其函数体中没有任何操作:

fn main() {
    unsafe fn dangerous() {}

    unsafe {
        dangerous();
    }
}

我们必须在单独的 unsafe 块中调用 dangerous 函数。如果我们尝试在没有 unsafe 块的情况下调用 dangerous,我们将得到一个错误:

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0133]: call to unsafe function `dangerous` is unsafe and requires unsafe function or block
 --> src/main.rs:4:5
  |
4 |     dangerous();
  |     ^^^^^^^^^^^ call to unsafe function
  |
  = note: consult the function's documentation for information on how to avoid undefined behavior

For more information about this error, try `rustc --explain E0133`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error

通过unsafe代码块,我们向Rust断言我们已经阅读了函数的文档,我们理解如何正确使用它,并且我们已经验证了我们正在履行函数的契约。

不安全函数的主体实际上是unsafe块,因此在不安全函数中执行其他不安全操作时,我们不需要添加另一个unsafe块。

Creating a Safe Abstraction over Unsafe Code

仅仅因为一个函数包含不安全的代码,并不意味着我们需要将整个函数标记为不安全。事实上,将不安全的代码包装在安全的函数中是一种常见的抽象。例如,让我们研究一下标准库中的 split_at_mut 函数,它需要一些不安全的代码。我们将探讨如何实现它。这个安全方法定义在可变切片上:它接受一个切片并通过在给定的索引处分割切片来将其变成两个。列表 20-4 显示了如何使用 split_at_mut

fn main() {
    let mut v = vec![1, 2, 3, 4, 5, 6];

    let r = &mut v[..];

    let (a, b) = r.split_at_mut(3);

    assert_eq!(a, &mut [1, 2, 3]);
    assert_eq!(b, &mut [4, 5, 6]);
}
Listing 20-4: Using the safe split_at_mut function

我们不能仅使用安全的 Rust 来实现这个函数。尝试实现可能看起来像列表 20-5,但这不会编译。为了简单起见,我们将实现 split_at_mut 作为函数而不是方法,并且只针对 i32 类型的切片,而不是泛型 T

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();

    assert!(mid <= len);

    (&mut values[..mid], &mut values[mid..])
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}
Listing 20-5: An attempted implementation of split_at_mut using only safe Rust

这个函数首先获取切片的总长度。然后它通过检查索引是否小于或等于长度来断言作为参数给出的索引在切片范围内。这个断言意味着,如果我们传递一个大于长度的索引来分割切片,函数将在尝试使用该索引之前崩溃。

然后我们返回一个元组,其中包含两个可变切片:一个从原始切片的开始到mid索引,另一个从mid到切片的末尾。

当我们尝试编译列表 20-5 中的代码时,我们会遇到一个错误。

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0499]: cannot borrow `*values` as mutable more than once at a time
 --> src/main.rs:6:31
  |
1 | fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
  |                         - let's call the lifetime of this reference `'1`
...
6 |     (&mut values[..mid], &mut values[mid..])
  |     --------------------------^^^^^^--------
  |     |     |                   |
  |     |     |                   second mutable borrow occurs here
  |     |     first mutable borrow occurs here
  |     returning this value requires that `*values` is borrowed for `'1`
  |
  = help: use `.split_at_mut(position)` to obtain two mutable non-overlapping sub-slices

For more information about this error, try `rustc --explain E0499`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error

Rust 的借用检查器无法理解我们正在借用切片的不同部分;它只知道我们正在从同一个切片中借用两次。借用切片的不同部分本质上是没问题的,因为这两个切片并不重叠,但 Rust 并不够智能来理解这一点。当我们知道代码是安全的,但 Rust 却不知道时,就该使用不安全的代码了。

列表 20-6 显示了如何使用 unsafe 块、原始指针和一些不安全函数的调用来使 split_at_mut 的实现工作。

use std::slice;

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();
    let ptr = values.as_mut_ptr();

    assert!(mid <= len);

    unsafe {
        (
            slice::from_raw_parts_mut(ptr, mid),
            slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}
Listing 20-6: Using unsafe code in the implementation of the split_at_mut function

回想第 4 章“切片类型”部分,切片是指向某些数据的指针和切片的长度。 我们使用 len 方法获取切片的长度,使用 as_mut_ptr 方法访问切片的原始指针。在这种情况下,因为我们有一个可变的 i32 值切片,as_mut_ptr 返回一个类型为 *mut i32 的原始指针,我们将其存储在变量 ptr 中。

我们保持 mid 索引在切片内的断言。然后我们进入不安全的代码:slice::from_raw_parts_mut 函数接受一个原始指针和一个长度,并创建一个切片。我们使用此函数创建一个从 ptr 开始且长度为 mid 的切片。然后我们调用 ptr 上的 add 方法,参数为 mid,以获取一个从 mid 开始的原始指针,并使用该指针和 mid 之后剩余的项目数作为长度创建一个切片。

函数 slice::from_raw_parts_mut 是不安全的,因为它接受一个原始指针,并且必须信任这个指针是有效的。原始指针上的 add 方法也是不安全的,因为它必须信任偏移位置也是一个有效的指针。因此,我们必须在调用 slice::from_raw_parts_mutadd 时将它们放在 unsafe 块中。通过查看代码并添加 mid 必须小于或等于 len 的断言,我们可以确定 unsafe 块内使用的所有原始指针都是指向切片内数据的有效指针。这是一个可接受且适当的 unsafe 用法。

请注意,我们不需要将生成的 split_at_mut 函数标记为 unsafe,并且我们可以从安全的 Rust 代码中调用此函数。我们已经通过使用 unsafe 代码以安全方式实现的函数创建了一个安全的抽象,因为它仅从该函数可以访问的数据创建有效的指针。

相比之下,清单 20-7 中使用 slice::from_raw_parts_mut 很可能在使用切片时崩溃。此代码获取一个任意的内存位置并创建一个长度为 10,000 的切片。

fn main() {
    use std::slice;

    let address = 0x01234usize;
    let r = address as *mut i32;

    let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
}
Listing 20-7: Creating a slice from an arbitrary memory location

我们不拥有这个任意位置的内存,也没有保证这段代码创建的切片包含有效的i32值。尝试将values作为有效切片使用会导致未定义行为。

Using extern Functions to Call External Code

有时,您的 Rust 代码可能需要与用其他语言编写的代码进行交互。为此,Rust 有关键字 extern,它有助于创建和使用 Foreign Function Interface (FFI)。FFI 是一种编程语言定义函数并使另一种(外部)编程语言能够调用这些函数的方式。

列表 20-8 展示了如何设置与 C 标准库中的 abs 函数的集成。在 extern 块中声明的函数总是从 Rust 代码调用时是不安全的。原因是其他语言不会强制执行 Rust 的规则和保证,而 Rust 也无法检查它们,因此确保安全性的责任落在程序员身上。

Filename: src/main.rs
extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    unsafe {
        println!("Absolute value of -3 according to C: {}", abs(-3));
    }
}
Listing 20-8: Declaring and calling an extern function defined in another language

extern "C" 块中,我们列出要调用的来自另一种语言的外部函数的名称和签名。 "C" 部分定义了外部函数使用的 应用程序二进制接口 (ABI):ABI 定义了如何在汇编级别调用函数。 "C" ABI 是最常用的,并遵循 C 编程语言的 ABI。

Calling Rust Functions from Other Languages

我们还可以使用 extern 创建一个接口,允许其他语言调用 Rust 函数。我们不需要创建一个完整的 extern 块,而是在相关函数的 fn 关键字之前添加 extern 关键字并指定要使用的 ABI。我们还需要添加一个 #[no_mangle] 注解,告诉 Rust 编译器不要混淆此函数的名称。混淆 是指编译器将我们给定的函数名称更改为包含更多信息的其他名称,这些信息供编译过程的其他部分使用,但可读性较差。每种编程语言的编译器对名称的混淆方式略有不同,因此为了让其他语言能够调用 Rust 函数,我们必须禁用 Rust 编译器的名称混淆。

在以下示例中,我们将 call_from_c 函数编译为共享库并从 C 代码链接后,使其可以从 C 代码访问:

#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn call_from_c() {
    println!("Just called a Rust function from C!");
}
}

这种使用 extern 的方式不需要 unsafe

访问或修改可变静态变量

在本书中,我们还没有讨论全局变量,虽然 Rust 确实支持全局变量,但它们可能会与 Rust 的所有权规则产生冲突。如果两个线程访问同一个可变全局变量,可能会导致数据竞争。

在 Rust 中,全局变量被称为 静态 变量。列表 20-9 显示了一个使用字符串切片作为值的静态变量的声明和使用示例。

Filename: src/main.rs
static HELLO_WORLD: &str = "Hello, world!";

fn main() {
    println!("name is: {HELLO_WORLD}");
}
Listing 20-9: Defining and using an immutable static variable

静态变量类似于我们在第 3 章“变量和常量之间的区别”部分讨论的常量。按照惯例,静态变量的名称使用SCREAMING_SNAKE_CASE。静态变量只能存储具有'static生命周期的引用,这意味着 Rust 编译器可以推断出生命周期,我们不需要显式地标注它。访问不可变的静态变量是安全的。

常量和不可变静态变量之间的一个细微差别是,静态变量中的值在内存中有一个固定的地址。使用该值将始终访问相同的数据。另一方面,常量在使用时允许复制其数据。另一个不同之处在于静态变量可以是可变的。访问和修改可变静态变量是不安全的。列表 20-10 显示了如何声明、访问和修改一个名为 COUNTER 的可变静态变量。

Filename: src/main.rs
static mut COUNTER: u32 = 0;

/// SAFETY: Calling this from more than a single thread at a time is undefined
/// behavior, so you *must* guarantee you only call it from a single thread at
/// a time.
unsafe fn add_to_count(inc: u32) {
    COUNTER += inc;
}

fn main() {
    unsafe {
        // SAFETY: This is only called from a single thread in `main`.
        add_to_count(3);
        println!("COUNTER: {}", COUNTER);
    }
}
Listing 20-10: Reading from or writing to a mutable static variable is unsafe

与常规变量一样,我们使用 mut 关键字指定可变性。任何读取或写入 COUNTER 的代码都必须在 unsafe 块中。这段代码编译并打印 COUNTER: 3,正如我们所期望的那样,因为它是在单线程中运行的。如果多个线程访问 COUNTER,可能会导致数据竞争,因此这是未定义的行为。因此,我们需要将整个函数标记为 unsafe,并记录安全限制,以便调用该函数的任何人都知道他们可以和不可以安全地做什么。

每当我们编写一个不安全的函数时,习惯上会写一个以 SAFETY 开头的注释,解释调用者需要做什么才能安全地调用该函数。同样,每当执行不安全操作时,习惯上也会写一个以 SAFETY 开头的注释,解释如何遵守安全规则。

对于全局可访问的可变数据,很难确保没有数据竞争,这就是为什么 Rust 认为可变静态变量是不安全的。在可能的情况下,最好使用我们在第 16 章中讨论的并发技术和线程安全的智能指针,以便编译器检查从不同线程访问的数据是否安全。

实现一个不安全的特质

我们可以使用 unsafe 来实现一个不安全的 trait。当一个 trait 的至少一个方法有一些编译器无法验证的不变量时,该 trait 就是不安全的。我们通过在 trait 前添加 unsafe 关键字来声明一个 trait 是 unsafe 的,并且也将 trait 的实现标记为 unsafe,如清单 20-11 所示。

unsafe trait Foo {
    // methods go here
}

unsafe impl Foo for i32 {
    // method implementations go here
}

fn main() {}
Listing 20-11: Defining and implementing an unsafe trait

通过使用 unsafe impl,我们承诺将维护编译器无法验证的不变性。

作为示例,回想我们在第 16 章“使用 SyncSend 特性实现可扩展的并发” 部分讨论的 SyncSend 标记特性:如果我们的类型完全由 SendSync 类型组成,编译器会自动实现这些特性。如果我们实现的类型包含不是 SendSync 的类型,例如原始指针,并且我们希望将该类型标记为 SendSync,我们必须使用 unsafe。Rust 无法验证我们的类型是否保持可以安全地跨线程发送或从多个线程访问的保证;因此,我们需要手动进行这些检查并用 unsafe 指示。

访问联合体的字段

最后一种仅与 unsafe 一起使用的行为是访问 联合体 的字段。一个 union 类似于一个 struct,但在特定实例中一次只能使用一个声明的字段。联合体主要用于与 C 代码中的联合体进行接口。访问联合体字段是不安全的,因为 Rust 无法保证当前存储在联合体实例中的数据类型。您可以在 Rust 参考手册 中了解有关联合体的更多信息。

使用Miri检查不安全的代码

当编写不安全的代码时,你可能希望检查你所编写的代码实际上是安全和正确的。一种最好的方法是使用Miri,一个官方的Rust工具,用于检测未定义行为。虽然借用检查器是一个在编译时工作的静态工具,Miri是一个在运行时工作的动态工具。它通过运行你的程序或其测试套件,并检测你何时违反了其理解的Rust应如何工作的规则来检查你的代码。

使用Miri需要Rust的夜间构建版本(我们将在附录G:Rust的制作和“夜间Rust”中详细讨论)。您可以通过键入rustup +nightly component add miri来安装Rust的夜间版本和Miri工具。这不会更改您的项目使用的Rust版本;它只是将工具添加到您的系统中,以便您在需要时可以使用它。 您可以通过键入cargo +nightly miri runcargo +nightly miri test在项目上运行Miri。

为了举例说明这有多有帮助,考虑当我们针对列表 20-10 运行它时会发生什么:

$ cargo +nightly miri run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
     Running `/Users/chris/.rustup/toolchains/nightly-aarch64-apple-darwin/bin/cargo-miri runner target/miri/aarch64-apple-darwin/debug/unsafe-example`
warning: creating a shared reference to mutable static is discouraged
  --> src/main.rs:14:33
   |
14 |         println!("COUNTER: {}", COUNTER);
   |                                 ^^^^^^^ shared reference to mutable static
   |
   = note: for more information, see <https://doc.rust-lang.org/nightly/edition-guide/rust-2024/static-mut-references.html>
   = note: shared references to mutable statics are dangerous; it's undefined behavior if the static is mutated or if a mutable reference is created for it while the shared reference lives
   = note: `#[warn(static_mut_refs)]` on by default

COUNTER: 3

它帮助我们正确地注意到我们对可变数据有共享引用,并对此发出警告。在这种情况下,它没有告诉我们如何修复问题,但意味着我们知道可能存在一个问题,并可以考虑如何确保它是安全的。在其他情况下,它可以实际告诉我们某些代码肯定是错误的,并提出如何修复的建议。

Miri 不会捕捉到你在编写不安全代码时可能犯的所有错误。 一方面,由于它是动态检查,因此只能捕捉到实际运行的代码中的问题。这意味着你需要将其与良好的测试技术结合使用,以提高你对自己编写的不安全代码的信心。另一方面,它并不能涵盖你的代码可能存在的每一种不安全方式。如果 Miri 确实捕捉到一个问题,你就知道有 bug,但仅仅因为 Miri 没有捕捉到一个 bug 并不意味着没有问题。尽管如此,Miri 可以捕捉到很多问题。尝试在本章中的其他不安全代码示例上运行它,看看它怎么说!

何时使用不安全代码

使用 unsafe 来执行上述五种操作(超级能力)之一并不是错误,甚至也不被反对。但是,编写 unsafe 代码更难正确,因为编译器无法帮助维护内存安全。当你有理由使用 unsafe 代码时,你可以这样做,而显式的 unsafe 注解使得在出现问题时更容易追踪问题的来源。每当你编写不安全的代码时,你可以使用 Miri 来帮助你更有信心地确保你编写的代码遵循 Rust 的规则。