不安全 Rust

到目前为止,我们讨论的所有代码都在编译时强制执行了 Rust 的内存安全保证。 然而,Rust 内部隐藏着第二种语言,它不强制执行这些内存安全保证:它被称为不安全 Rust,其工作方式与常规 Rust 完全相同,但为我们提供了额外的超能力。

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

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

不安全超能力

要切换到不安全 Rust,请使用 unsafe 关键字,然后启动一个新的代码块来存放不安全代码。 您可以在不安全 Rust 中执行五项在安全 Rust 中无法执行的操作,我们称之为不安全超能力。 这些超能力包括以下能力:

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

重要的是要理解 unsafe 不会关闭借用检查器或禁用 Rust 的任何其他安全检查:如果您在不安全代码中使用引用,它仍然会被检查。 unsafe 关键字仅允许您访问这五个功能,编译器不会检查这些功能的内存安全性。 在不安全块内部,您仍然会获得一定程度的安全性。

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

人是会犯错的,错误会发生,但是通过要求这五个不安全操作必须位于用 unsafe 注释的代码块内,您将知道与内存安全相关的任何错误都必须在 unsafe 代码块内。 保持 unsafe 代码块尽可能小; 当您调查内存错误时,您会为此感到庆幸。

为了尽可能隔离不安全代码,最好将不安全代码封装在安全抽象中并提供安全的 API,我们将在本章稍后讨论不安全函数和方法时进行讨论。 标准库的某些部分被实现为经过审核的不安全代码的安全抽象。 将不安全代码包装在安全抽象中可以防止 unsafe 的使用泄漏到您或您的用户可能想要使用使用 unsafe 代码实现的功能的所有位置,因为使用安全抽象是安全的。

让我们依次查看这五个不安全超能力。 我们还将查看一些为不安全代码提供安全接口的抽象。

解引用原始指针

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

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

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

通过选择不让 Rust 强制执行这些保证,您可以放弃有保证的安全性,以换取更高的性能或与 Rust 的保证不适用的另一种语言或硬件进行交互的能力。

列表 19-1 展示了如何从引用创建不可变和可变原始指针。

fn main() {
    let mut num = 5;

    let r1 = &num as *const i32;
    let r2 = &mut num as *mut i32;
}

列表 19-1:从引用创建原始指针

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

我们通过使用 as 将不可变引用和可变引用转换为它们对应的原始指针类型来创建原始指针。 因为我们直接从保证有效的引用创建了它们,所以我们知道这些特定的原始指针是有效的,但我们不能对任何原始指针做出这种假设。

为了证明这一点,接下来我们将创建一个我们无法确定其有效性的原始指针。 列表 19-2 展示了如何创建指向内存中任意位置的原始指针。 尝试使用任意内存是未定义的:该地址可能存在数据,也可能不存在数据,编译器可能会优化代码,使其不进行内存访问,或者程序可能会因段错误而报错。 通常,没有充分的理由编写这样的代码,但这并非不可能。

fn main() {
    let address = 0x012345usize;
    let r = address as *const i32;
}

列表 19-2:创建指向任意内存地址的原始指针

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

fn main() {
    let mut num = 5;

    let r1 = &num as *const i32;
    let r2 = &mut num as *mut i32;

    unsafe {
        println!("r1 is: {}", *r1);
        println!("r2 is: {}", *r2);
    }
}

列表 19-3:在 unsafe 代码块内解引用原始指针

创建指针本身无害; 只有当我们尝试访问它指向的值时,我们才可能最终处理无效值。

另请注意,在列表 19-1 和 19-3 中,我们创建了都指向同一内存位置(存储 num 的位置)的 *const i32*mut i32 原始指针。 相反,如果我们尝试创建指向 num 的不可变引用和可变引用,则代码将无法编译,因为 Rust 的所有权规则不允许在存在任何不可变引用的同时存在可变引用。 使用原始指针,我们可以创建指向同一位置的可变指针和不可变指针,并通过可变指针更改数据,从而可能产生数据竞争。 请务必小心!

考虑到所有这些危险,您为什么还要使用原始指针呢? 一个主要的用例是与 C 代码进行交互,您将在下一节 “调用不安全函数或方法” 中看到。另一种情况是构建借用检查器无法理解的安全抽象。 我们将介绍不安全函数,然后查看使用不安全代码的安全抽象的示例。

调用不安全函数或方法

您可以在不安全代码块中执行的第二种类型的操作是调用不安全函数。 不安全函数和方法看起来与常规函数和方法完全一样,但它们在定义其余部分之前有一个额外的 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 代码块。

在不安全代码之上创建安全抽象

仅仅因为一个函数包含不安全代码并不意味着我们需要将整个函数标记为不安全。 实际上,将不安全代码包装在安全函数中是一种常见的抽象。 例如,让我们研究标准库中的 split_at_mut 函数,该函数需要一些不安全代码。 我们将探讨如何实现它。 此安全方法在可变 slice 上定义:它接受一个 slice,并通过在作为参数给出的索引处拆分 slice 来将其变为两个。 列表 19-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]);
}

列表 19-4:使用安全的 split_at_mut 函数

我们无法仅使用安全的 Rust 来实现此函数。 一种尝试可能类似于列表 19-5,但它不会编译。 为了简单起见,我们将 split_at_mut 实现为一个函数而不是一个方法,并且仅针对 i32 值的 slice,而不是针对泛型类型 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);
}

列表 19-5:尝试仅使用安全 Rust 实现 split_at_mut

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

然后,我们在元组中返回两个可变 slice:一个从原始 slice 的开头到 mid 索引,另一个从 mid 到 slice 的结尾。

当我们尝试编译列表 19-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 的借用检查器无法理解我们正在借用 slice 的不同部分; 它只知道我们正在从同一个 slice 借用两次。 借用 slice 的不同部分从根本上来说是可以的,因为两个 slice 没有重叠,但 Rust 不够聪明,无法知道这一点。 当我们知道代码没问题,但 Rust 不知道时,就该使用不安全代码了。

列表 19-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);
}

列表 19-6:在 split_at_mut 函数的实现中使用不安全代码

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

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

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

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

相比之下,列表 19-7 中 slice::from_raw_parts_mut 的使用可能会在 slice 被使用时崩溃。 此代码采用任意内存位置并创建一个长度为 10,000 个条目的 slice。

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) };
}

列表 19-7:从任意内存位置创建 slice

我们不拥有此任意位置的内存,并且无法保证此代码创建的 slice 包含有效的 i32 值。 尝试像使用有效 slice 一样使用 values 会导致未定义的行为。

使用 extern 函数调用外部代码

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

列表 19-8 演示了如何设置与 C 标准库中的 abs 函数的集成。 在 extern 代码块中声明的函数始终可以从 Rust 代码中不安全地调用。 原因在于其他语言不强制执行 Rust 的规则和保证,并且 Rust 无法检查它们,因此确保安全的责任落在程序员身上。

文件名:src/main.rs

extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    unsafe {
        println!("Absolute value of -3 according to C: {}", abs(-3));
    }
}

列表 19-8:声明和调用另一种语言中定义的 extern 函数

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

从其他语言调用 Rust 函数

我们还可以使用 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 中,全局变量称为静态变量。 列表 19-9 展示了静态变量的示例声明和使用,其中字符串 slice 作为值。

文件名:src/main.rs

static HELLO_WORLD: &str = "Hello, world!";

fn main() {
    println!("name is: {HELLO_WORLD}");
}

列表 19-9:定义和使用不可变静态变量

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

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

文件名:src/main.rs

static mut COUNTER: u32 = 0;

fn add_to_count(inc: u32) {
    unsafe {
        COUNTER += inc;
    }
}

fn main() {
    add_to_count(3);

    unsafe {
        println!("COUNTER: {COUNTER}");
    }
}

列表 19-10:读取或写入可变静态变量是不安全的

与常规变量一样,我们使用 mut 关键字指定可变性。 任何从 COUNTER 读取或写入的代码都必须在 unsafe 代码块内。 此代码会编译并打印 COUNTER: 3,正如我们所期望的那样,因为它是单线程的。 让多个线程访问 COUNTER 可能会导致数据竞争。

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

实现不安全 Trait

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

unsafe trait Foo {
    // methods go here
}

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

fn main() {}

列表 19-11:定义和实现不安全 Trait

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

例如,回想一下我们在第 16 章的 “通过 SyncSend Trait 扩展并发性”部分中讨论的 SyncSend 标记 Trait:如果我们的类型完全由 SendSync 类型组成,则编译器会自动实现这些 Trait。 如果我们实现一个包含非 SendSync 类型(例如原始指针)的类型,并且我们想将该类型标记为 SendSync,则必须使用 unsafe。 Rust 无法验证我们的类型是否遵守可以在线程之间安全发送或从多个线程访问的保证; 因此,我们需要手动执行这些检查,并使用 unsafe 表明这一点。

访问 Union 的字段

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

何时使用 Unsafe 代码

使用 unsafe 来执行刚刚讨论的五种操作(超能力)并不是错误的,甚至不被反感。但是,编写正确的 unsafe 代码更棘手,因为编译器无法帮助维护内存安全。当你有理由使用 unsafe 代码时,你可以这样做,并且显式的 unsafe 注解使得在问题发生时更容易追踪问题的来源。